Quote cancel

This commit is contained in:
2026-06-23 10:56:11 -03:00
parent 298e9c39e3
commit 96bf917191
6 changed files with 286 additions and 4 deletions

View File

@ -423,3 +423,47 @@ func (cont *Controller) SendQuote(ctx *gin.Context) {
ctx.JSON(http.StatusOK, Msg{Text: "Quote sent"}) 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"})
}

View File

@ -23,6 +23,12 @@ type SendQuoteRequest struct {
Price string `json:"Price" binding:"required" example:"99.6"` 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 { type AllMessagesRequest struct {
APIKey string APIKey string
In int In int

View File

@ -29,6 +29,7 @@ func SetRoutes(api *API) {
services := v1.Group("/") services := v1.Group("/")
services.POST("/messages", cont.AllMessages) services.POST("/messages", cont.AllMessages)
services.POST("/quotes", cont.SendQuote) services.POST("/quotes", cont.SendQuote)
services.POST("/quotes/cancel", cont.CancelQuote)
backoffice := qfixdpl.Group("/backoffice") backoffice := qfixdpl.Group("/backoffice")
backoffice.Use(cont.BackOfficeUser) backoffice.Use(cont.BackOfficeUser)

View File

@ -22,6 +22,7 @@ type TradeProvider interface {
GetTrades() []domain.ListTrade GetTrades() []domain.ListTrade
GetPendingQuoteRequests() []domain.ListTrade GetPendingQuoteRequests() []domain.ListTrade
SendQuote(quoteReqID string, price decimal.Decimal) error SendQuote(quoteReqID string, price decimal.Decimal) error
CancelQuote(quoteReqID, text string) error
GetAllMessages(inSeq, outSeq int) []domain.Message GetAllMessages(inSeq, outSeq int) []domain.Message
} }

View File

@ -1,6 +1,7 @@
package fix package fix
import ( import (
"fmt"
"log/slog" "log/slog"
"os" "os"
"sort" "sort"
@ -20,6 +21,7 @@ import (
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/executionreport" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/executionreport"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quote" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quote"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoteack" "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/quoterequest"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoteresponse" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoteresponse"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quotestatusreport" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quotestatusreport"
@ -38,6 +40,8 @@ type listTrade struct {
Price decimal.Decimal Price decimal.Decimal
} }
const defaultQuoteCancelText = "Quote withdrawn by dealer"
// Manager wraps the QuickFIX initiator and implements domain.FIXSender. // Manager wraps the QuickFIX initiator and implements domain.FIXSender.
type Manager struct { type Manager struct {
initiator *quickfix.Initiator initiator *quickfix.Initiator
@ -493,16 +497,113 @@ func (m *Manager) SendQuote(quoteReqID string, price decimal.Decimal) error {
return nil 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. // firstGroup returns the first repetition of a NUMINGROUP field, or nil.
func firstGroup(body map[string]any, name string) map[string]any { func firstGroup(body map[string]any, name string) map[string]any {
if body == nil { if body == nil {
return nil return nil
} }
groups, ok := body[name].([]map[string]any)
if !ok || len(groups) == 0 { switch groups := body[name].(type) {
return nil 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. // 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 "" 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 // 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. // as strings (e.g. "10000"); INT-typed counts may be int. Both are accepted.
func getDecimal(body map[string]any, name string) decimal.Decimal { func getDecimal(body map[string]any, name string) decimal.Decimal {

View File

@ -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)
}
}