Quote cancel
This commit is contained in:
@ -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"})
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
82
src/client/fix/manager_cancel_test.go
Normal file
82
src/client/fix/manager_cancel_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user