2 Commits

Author SHA1 Message Date
96bf917191 Quote cancel 2026-06-23 10:56:11 -03:00
298e9c39e3 Api key configuracion and quotes endpooint 2026-06-01 13:03:06 -03:00
8 changed files with 316 additions and 9 deletions

View File

@ -36,7 +36,11 @@ type Service struct {
AuthorizedServices map[string]AuthorizedService `toml:"AuthorizedServices"`
APIBasePort string
EnableJWTAuth bool // Enable JWT authentication for service-to-service communication
FIX FIXConfig
// ServiceAPIKey is the shared secret that authenticates qbymarouter (and any
// other internal service) when posting to /qfixdpl/v1/quotes and
// /qfixdpl/v1/messages. Must match the DPLAPIKey configured on the caller.
ServiceAPIKey string
FIX FIXConfig
}
type FIXConfig struct {

View File

@ -348,7 +348,7 @@ func (cont *Controller) AllMessages(ctx *gin.Context) {
return
}
if req.APIKey != "1234" {
if !cont.checkServiceAPIKey(req.APIKey) {
ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"})
return
}
@ -356,6 +356,13 @@ func (cont *Controller) AllMessages(ctx *gin.Context) {
ctx.JSON(http.StatusOK, cont.tradeProvider.GetAllMessages(req.In, req.Out))
}
// checkServiceAPIKey returns true when the provided key matches the configured
// service-to-service shared secret. Empty configured key is always rejected
// to avoid open authentication when misconfigured.
func (cont *Controller) checkServiceAPIKey(key string) bool {
return cont.config.ServiceAPIKey != "" && key == cont.config.ServiceAPIKey
}
// GetPendingQuoteRequests godoc
// @Summary List pending QuoteRequests
// @Description Returns all QuoteRequests received from TW that have not been quoted yet by the dealer
@ -382,12 +389,19 @@ func (cont *Controller) GetPendingQuoteRequests(ctx *gin.Context) {
// @Failure 500 {object} HTTPError
// @Router /qfixdpl/v1/quotes [post]
func (cont *Controller) SendQuote(ctx *gin.Context) {
setHeaders(ctx, cont.config)
var req SendQuoteRequest
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
}
price, err := decimal.NewFromString(req.Price)
if err != nil {
ctx.JSON(http.StatusBadRequest, HTTPError{Error: "invalid price: " + err.Error()})
@ -409,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"})
}

View File

@ -18,10 +18,17 @@ type Session struct {
}
type SendQuoteRequest struct {
APIKey string `json:"APIKey" binding:"required"`
QuoteReqID string `json:"QuoteReqID" binding:"required"`
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

View File

@ -24,10 +24,12 @@ func SetRoutes(api *API) {
qfixdpl.GET("/trades", cont.GetTrades)
qfixdpl.GET("/trades/:quoteReqID/logs", cont.GetLogs)
qfixdpl.GET("/quote-requests", cont.GetPendingQuoteRequests)
qfixdpl.POST("/quotes", cont.SendQuote)
msgs := v1.Group("/")
msgs.POST("/messages", cont.AllMessages)
// services group: API-key auth via body, no session cookie required.
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)

View File

@ -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
}
@ -39,6 +40,10 @@ type Config struct {
AuthorizedServices map[string]app.AuthorizedService `toml:"AuthorizedServices"`
Port string
EnableJWTAuth bool
// ServiceAPIKey authenticates internal services (qbymarouter, etc.) calling
// /qfixdpl/v1/quotes and /qfixdpl/v1/messages. Compared against the APIKey
// field in the request body.
ServiceAPIKey string
}
func New(userData app.UserDataProvider, storeInstance *store.Store, tradeProvider TradeProvider, config Config, notify domain.Notifier) *API {

View File

@ -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 {

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

View File

@ -51,6 +51,7 @@ func Runner(cfg app.Config) error {
External: cfg.External,
AuthorizedServices: cfg.AuthorizedServices,
EnableJWTAuth: cfg.EnableJWTAuth,
ServiceAPIKey: cfg.ServiceAPIKey,
}
api := rest.New(userData, appStore, fixManager, apiConfig, notify)