From 96bf91719128211e32384c9e65721c045b009544 Mon Sep 17 00:00:00 2001 From: Facundo Marion Date: Tue, 23 Jun 2026 10:56:11 -0300 Subject: [PATCH] Quote cancel --- src/client/api/rest/controller.go | 44 ++++++++ src/client/api/rest/model.go | 6 + src/client/api/rest/routes.go | 1 + src/client/api/rest/server.go | 1 + src/client/fix/manager.go | 156 +++++++++++++++++++++++++- src/client/fix/manager_cancel_test.go | 82 ++++++++++++++ 6 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 src/client/fix/manager_cancel_test.go diff --git a/src/client/api/rest/controller.go b/src/client/api/rest/controller.go index 1a3642f..6dfc1a6 100644 --- a/src/client/api/rest/controller.go +++ b/src/client/api/rest/controller.go @@ -423,3 +423,47 @@ func (cont *Controller) SendQuote(ctx *gin.Context) { ctx.JSON(http.StatusOK, Msg{Text: "Quote sent"}) } + +// CancelQuote godoc +// @Summary Cancel a Quote for a QuoteRequest +// @Description Builds and sends a QuoteCancel (35=Z) to TW for an existing QuoteRequest +// @Tags fix +// @Accept json +// @Produce json +// @Param body body CancelQuoteRequest true "Quote cancel request" +// @Success 200 {object} Msg +// @Failure 400 {object} HTTPError +// @Failure 401 {object} HTTPError +// @Failure 404 {object} HTTPError +// @Failure 409 {object} HTTPError +// @Failure 500 {object} HTTPError +// @Router /qfixdpl/v1/quotes/cancel [post] +func (cont *Controller) CancelQuote(ctx *gin.Context) { + setHeaders(ctx, cont.config) + + var req CancelQuoteRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()}) + return + } + + if !cont.checkServiceAPIKey(req.APIKey) { + ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"}) + return + } + + if err := cont.tradeProvider.CancelQuote(req.QuoteReqID, req.Text); err != nil { + msg := err.Error() + switch { + case strings.Contains(msg, "not found"): + ctx.JSON(http.StatusNotFound, HTTPError{Error: "quoteReqID not found"}) + case strings.Contains(msg, "cannot respond"): + ctx.JSON(http.StatusConflict, HTTPError{Error: "quote request cannot be cancelled"}) + default: + ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "failed to cancel quote"}) + } + return + } + + ctx.JSON(http.StatusOK, Msg{Text: "Quote cancel sent"}) +} diff --git a/src/client/api/rest/model.go b/src/client/api/rest/model.go index 2e30da4..663457b 100644 --- a/src/client/api/rest/model.go +++ b/src/client/api/rest/model.go @@ -23,6 +23,12 @@ type SendQuoteRequest struct { Price string `json:"Price" binding:"required" example:"99.6"` } +type CancelQuoteRequest struct { + APIKey string `json:"APIKey" binding:"required"` + QuoteReqID string `json:"QuoteReqID" binding:"required"` + Text string `json:"Text,omitempty"` +} + type AllMessagesRequest struct { APIKey string In int diff --git a/src/client/api/rest/routes.go b/src/client/api/rest/routes.go index cf517ae..bc7a836 100644 --- a/src/client/api/rest/routes.go +++ b/src/client/api/rest/routes.go @@ -29,6 +29,7 @@ func SetRoutes(api *API) { services := v1.Group("/") services.POST("/messages", cont.AllMessages) services.POST("/quotes", cont.SendQuote) + services.POST("/quotes/cancel", cont.CancelQuote) backoffice := qfixdpl.Group("/backoffice") backoffice.Use(cont.BackOfficeUser) diff --git a/src/client/api/rest/server.go b/src/client/api/rest/server.go index 3abb7da..380aab9 100644 --- a/src/client/api/rest/server.go +++ b/src/client/api/rest/server.go @@ -22,6 +22,7 @@ type TradeProvider interface { GetTrades() []domain.ListTrade GetPendingQuoteRequests() []domain.ListTrade SendQuote(quoteReqID string, price decimal.Decimal) error + CancelQuote(quoteReqID, text string) error GetAllMessages(inSeq, outSeq int) []domain.Message } diff --git a/src/client/fix/manager.go b/src/client/fix/manager.go index 0c311b3..85d3051 100644 --- a/src/client/fix/manager.go +++ b/src/client/fix/manager.go @@ -1,6 +1,7 @@ package fix import ( + "fmt" "log/slog" "os" "sort" @@ -20,6 +21,7 @@ import ( "quantex.com/qfixdpl/quickfix/gen/fix50sp2/executionreport" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quote" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoteack" + "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quotecancel" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoterequest" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoteresponse" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quotestatusreport" @@ -38,6 +40,8 @@ type listTrade struct { Price decimal.Decimal } +const defaultQuoteCancelText = "Quote withdrawn by dealer" + // Manager wraps the QuickFIX initiator and implements domain.FIXSender. type Manager struct { initiator *quickfix.Initiator @@ -493,16 +497,113 @@ func (m *Manager) SendQuote(quoteReqID string, price decimal.Decimal) error { return nil } +// CancelQuote builds and sends a QuoteCancel (35=Z) for an existing QuoteRequest. +func (m *Manager) CancelQuote(quoteReqID, text string) error { + m.tradesMu.RLock() + t, ok := m.trades[quoteReqID] + if !ok { + m.tradesMu.RUnlock() + err := tracerr.Errorf("CancelQuote: quoteReqID %s not found", quoteReqID) + slog.Error(err.Error()) + return err + } + + sessionID := t.SessionID + if sessionID == (quickfix.SessionID{}) { + sessionID = m.anyActiveSessionID() + if sessionID == (quickfix.SessionID{}) { + m.tradesMu.RUnlock() + err := tracerr.Errorf("CancelQuote: no active FIX session for quoteReqID %s", quoteReqID) + slog.Error(err.Error()) + return err + } + } + + quoteRequestBody := t.QuoteRequest.Body + m.tradesMu.RUnlock() + + ownerTraderID := quoteRequestString(quoteRequestBody, "OwnerTraderID") + canRespond, hasCanRespond, rawCanRespond := quoteRequestCanRespond(quoteRequestBody) + canRespondLogValue := rawCanRespond + if canRespondLogValue == "" { + canRespondLogValue = "missing" + } + + if !hasCanRespond { + slog.Warn("CancelQuote: CanRespond missing or unparseable, sending QuoteCancel anyway", + "quoteReqID", quoteReqID, + "canRespond", canRespondLogValue, + ) + } else if !canRespond { + err := tracerr.Errorf("CancelQuote: quoteReqID %s cannot respond", quoteReqID) + slog.Error(err.Error(), + "quoteReqID", quoteReqID, + "canRespond", canRespondLogValue, + ) + return err + } else { + slog.Info("CancelQuote: CanRespond allows QuoteCancel", + "quoteReqID", quoteReqID, + "canRespond", canRespondLogValue, + ) + } + + quoteID := uuid.NewV4().String() + text = strings.TrimSpace(text) + if text == "" { + text = defaultQuoteCancelText + } + + qc := quotecancel.New( + field.NewQuoteReqID(quoteReqID), + field.NewQuoteCancelType(enum.QuoteCancelType_CANCEL_SPECIFIED_SINGLE_QUOTE), + ) + qc.SetQuoteID(quoteID) + qc.SetTransactTime(time.Now().UTC()) + qc.SetText(text) + + if ownerTraderID != "" { + qc.SetOwnerTraderID(ownerTraderID) + } + + if sendErr := quickfix.SendToTarget(qc, sessionID); sendErr != nil { + sendErr = tracerr.Errorf("CancelQuote: failed to send quote cancel (quoteReqID=%s, quoteID=%s): %w", quoteReqID, quoteID, sendErr) + slog.Error(sendErr.Error()) + return sendErr + } + + slog.Info("QuoteCancel sent", + "quoteReqID", quoteReqID, + "quoteID", quoteID, + "canRespond", canRespondLogValue, + "ownerTraderID", ownerTraderID, + "text", text, + ) + + return nil +} + // firstGroup returns the first repetition of a NUMINGROUP field, or nil. func firstGroup(body map[string]any, name string) map[string]any { if body == nil { return nil } - groups, ok := body[name].([]map[string]any) - if !ok || len(groups) == 0 { - return nil + + switch groups := body[name].(type) { + case []map[string]any: + if len(groups) == 0 { + return nil + } + return groups[0] + case []any: + if len(groups) == 0 { + return nil + } + group, _ := groups[0].(map[string]any) + return group } - return groups[0] + + return nil } // getString reads a string value from a body map, tolerating nil maps and missing keys. @@ -516,6 +617,53 @@ func getString(body map[string]any, name string) string { return "" } +func quoteRequestString(body map[string]any, name string) string { + if v := getString(body, name); v != "" { + return v + } + + return getString(firstGroup(body, "NoRelatedSym"), name) +} + +func quoteRequestCanRespond(body map[string]any) (bool, bool, string) { + if value, ok := body["CanRespond"]; ok { + return parseCanRespondValue(value) + } + if value, ok := firstGroup(body, "NoRelatedSym")["CanRespond"]; ok { + return parseCanRespondValue(value) + } + + return false, false, "" +} + +func parseCanRespondValue(value any) (bool, bool, string) { + switch v := value.(type) { + case bool: + if v { + return true, true, "true" + } + return false, true, "false" + case string: + normalized := strings.ToUpper(strings.TrimSpace(v)) + switch normalized { + case "Y", "YES", "TRUE", "1": + return true, true, v + case "N", "NO", "FALSE", "0": + return false, true, v + default: + return false, false, v + } + case int: + return v != 0, true, fmt.Sprint(v) + case int64: + return v != 0, true, fmt.Sprint(v) + case float64: + return v != 0, true, fmt.Sprint(v) + default: + return false, false, "" + } +} + // getDecimal reads a decimal value from a body map. Numeric FIX types come through // as strings (e.g. "10000"); INT-typed counts may be int. Both are accepted. func getDecimal(body map[string]any, name string) decimal.Decimal { diff --git a/src/client/fix/manager_cancel_test.go b/src/client/fix/manager_cancel_test.go new file mode 100644 index 0000000..3030f13 --- /dev/null +++ b/src/client/fix/manager_cancel_test.go @@ -0,0 +1,82 @@ +package fix + +import "testing" + +func TestQuoteRequestCanRespond(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body map[string]any + want bool + wantOK bool + wantRaw string + }{ + { + name: "top-level bool true", + body: map[string]any{"CanRespond": true}, + want: true, + wantOK: true, + wantRaw: "true", + }, + { + name: "group string yes", + body: map[string]any{ + "NoRelatedSym": []map[string]any{{"CanRespond": "Y"}}, + }, + want: true, + wantOK: true, + wantRaw: "Y", + }, + { + name: "json decoded group string yes", + body: map[string]any{ + "NoRelatedSym": []any{map[string]any{"CanRespond": "Y"}}, + }, + want: true, + wantOK: true, + wantRaw: "Y", + }, + { + name: "group false", + body: map[string]any{ + "NoRelatedSym": []map[string]any{{"CanRespond": false}}, + }, + want: false, + wantOK: true, + wantRaw: "false", + }, + { + name: "missing", + body: map[string]any{}, + want: false, + wantOK: false, + wantRaw: "", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, gotOK, gotRaw := quoteRequestCanRespond(tc.body) + if got != tc.want || gotOK != tc.wantOK || gotRaw != tc.wantRaw { + t.Fatalf("quoteRequestCanRespond() = (%v, %v, %q), want (%v, %v, %q)", + got, gotOK, gotRaw, tc.want, tc.wantOK, tc.wantRaw) + } + }) + } +} + +func TestQuoteRequestStringFallsBackToNoRelatedSym(t *testing.T) { + t.Parallel() + + body := map[string]any{ + "NoRelatedSym": []map[string]any{{"OwnerTraderID": "dealer-1"}}, + } + + if got := quoteRequestString(body, "OwnerTraderID"); got != "dealer-1" { + t.Fatalf("quoteRequestString() = %q, want dealer-1", got) + } +}