package fix import ( "log/slog" "os" "strings" "sync" "time" "github.com/rs/zerolog/log" "github.com/shopspring/decimal" "quantex.com/qfixdpl/quickfix" "quantex.com/qfixdpl/quickfix/gen/enum" "quantex.com/qfixdpl/quickfix/gen/field" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/executionack" "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/quoterequest" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoteresponse" "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quotestatusreport" "quantex.com/qfixdpl/quickfix/store/file" "quantex.com/qfixdpl/src/app" "quantex.com/qfixdpl/src/common/tracerr" "quantex.com/qfixdpl/src/domain" ) type listTrade struct { QuoteReqID string ListID string Symbol string SecurityIDSrc enum.SecurityIDSource Currency string Side enum.Side OrderQty decimal.Decimal SettlDate string Price decimal.Decimal OwnerTraderID string SessionID quickfix.SessionID } // Manager wraps the QuickFIX initiator and implements domain.FIXSender. type Manager struct { initiator *quickfix.Initiator app *application sessionsMu sync.RWMutex sessions map[string]quickfix.SessionID tradesMu sync.RWMutex trades map[string]*listTrade store domain.PersistenceStore notify domain.Notifier cfg app.FIXConfig } func NewManager(cfg app.FIXConfig, store domain.PersistenceStore, notify domain.Notifier) *Manager { return &Manager{ sessions: make(map[string]quickfix.SessionID), trades: make(map[string]*listTrade), store: store, notify: notify, cfg: cfg, } } func (m *Manager) Start() error { fixApp := newApplication(m.notify) fixApp.onLogon = m.onLogon fixApp.onLogout = m.onLogout fixApp.onQuoteRequest = m.handleQuoteRequest fixApp.onQuoteAck = m.handleQuoteAck fixApp.onQuoteResponse = m.handleQuoteResponse fixApp.onExecutionReport = m.handleExecutionReport fixApp.onExecutionAck = m.handleExecutionAck fixApp.onRawMessage = m.handleRawMessage m.app = fixApp if err := m.loadActiveTrades(); err != nil { slog.Error("failed to load active trades from DB, starting with empty state", "error", err) } f, err := os.Open(m.cfg.SettingsFile) if err != nil { err = tracerr.Errorf("error opening FIX settings file %q: %s", m.cfg.SettingsFile, err) log.Error().Msg(err.Error()) return err } defer f.Close() settings, err := quickfix.ParseSettings(f) if err != nil { err = tracerr.Errorf("error parsing FIX settings: %s", err) log.Error().Msg(err.Error()) return err } storeFactory := file.NewStoreFactory(settings) logFactory, err := quickfix.NewFileLogFactory(settings) if err != nil { err = tracerr.Errorf("error creating file log factory: %s", err) log.Error().Msg(err.Error()) return err } initiator, err := quickfix.NewInitiator(fixApp, storeFactory, settings, logFactory) if err != nil { err = tracerr.Errorf("error creating FIX initiator: %s", err) log.Error().Msg(err.Error()) return err } m.initiator = initiator if err = m.initiator.Start(); err != nil { err = tracerr.Errorf("error starting FIX initiator: %s", err) log.Error().Msg(err.Error()) return err } slog.Info("FIX initiator started", "settings", m.cfg.SettingsFile) return nil } func (m *Manager) Stop() { if m.initiator != nil { m.initiator.Stop() slog.Info("FIX initiator stopped") } } func (m *Manager) onLogon(sessionID quickfix.SessionID) { m.sessionsMu.Lock() m.sessions[sessionID.String()] = sessionID m.sessionsMu.Unlock() } func (m *Manager) onLogout(sessionID quickfix.SessionID) { m.sessionsMu.Lock() delete(m.sessions, sessionID.String()) m.sessionsMu.Unlock() } // sendQuoteStatusReport sends a QuoteStatusReport (35=AI) to acknowledge the incoming QuoteRequest. func (m *Manager) sendQuoteStatusReport(quoteReqID, ownerTraderID string, sessionID quickfix.SessionID) error { qsr := quotestatusreport.New( field.NewTransactTime(time.Now()), field.NewQuoteStatus(enum.QuoteStatus_ACCEPTED), ) qsr.SetQuoteReqID(quoteReqID) qsr.SetQuoteID(quoteReqID) qsr.SetSymbol("[N/A]") if ownerTraderID != "" { qsr.SetOwnerTraderID(ownerTraderID) } return quickfix.SendToTarget(qsr, sessionID) } // sendTradeRequestAck sends a QuoteStatusReport (35=AI) to acknowledge a trade request (TRDREQACK). func (m *Manager) sendTradeRequestAck(quoteReqID, quoteRespID string, sessionID quickfix.SessionID) error { qsr := quotestatusreport.New( field.NewTransactTime(time.Now()), field.NewQuoteStatus(enum.QuoteStatus_ACCEPTED), ) qsr.SetQuoteReqID(quoteReqID) qsr.SetQuoteRespID(quoteRespID) qsr.SetSymbol("[N/A]") return quickfix.SendToTarget(qsr, sessionID) } // sendExecutionAck sends an ExecutionAck (35=BN) to acknowledge an incoming ExecutionReport. func (m *Manager) sendExecutionAck(orderID, clOrdID, execID string, sessionID quickfix.SessionID) error { bn := executionack.New( field.NewOrderID(orderID), field.NewExecID(execID), field.NewExecAckStatus(enum.ExecAckStatus_ACCEPTED), ) bn.SetClOrdID(clOrdID) bn.SetSymbol("[N/A]") bn.SetTransactTime(time.Now()) return quickfix.SendToTarget(bn, sessionID) } // handleQuoteRequest auto-responds to an incoming QuoteRequest with a QuoteStatusReport (acknowledge) followed by a Quote at price 99.6. func (m *Manager) handleQuoteRequest(msg quoterequest.QuoteRequest, sessionID quickfix.SessionID) { quoteReqID, err := msg.GetQuoteReqID() if err != nil { slog.Error("handleQuoteRequest: missing QuoteReqID", "error", err.Error()) return } // Validate LST_ prefix for List Trading flow. if !strings.HasPrefix(quoteReqID, "LST_") { slog.Warn("handleQuoteRequest: QuoteReqID missing LST_ prefix, ignoring", "quoteReqID", quoteReqID) return } var ( symbol, currency, ownerTraderID, settlDate, listID, negotiationType string side enum.Side secIDSource enum.SecurityIDSource orderQty decimal.Decimal ) relatedSyms, relErr := msg.GetNoRelatedSym() if relErr == nil && relatedSyms.Len() > 0 { sym := relatedSyms.Get(0) symbol, _ = sym.GetSecurityID() secIDSource, _ = sym.GetSecurityIDSource() currency, _ = sym.GetCurrency() side, _ = sym.GetSide() ownerTraderID, _ = sym.GetOwnerTraderID() orderQty, _ = sym.GetOrderQty() settlDate, _ = sym.GetSettlDate() listID, _ = sym.GetListID() negotiationType, _ = sym.GetNegotiationType() } if listID == "" { slog.Warn("handleQuoteRequest: missing ListID", "quoteReqID", quoteReqID) return } if negotiationType != "RFQ" { slog.Warn("handleQuoteRequest: unexpected NegotiationType", "quoteReqID", quoteReqID, "negotiationType", negotiationType) return } // Step 1: Send QuoteStatusReport (35=AI) to acknowledge the inquiry. if ackErr := m.sendQuoteStatusReport(quoteReqID, ownerTraderID, sessionID); ackErr != nil { slog.Error("handleQuoteRequest: failed to send QuoteStatusReport", "quoteReqID", quoteReqID, "error", ackErr.Error()) return } slog.Info("QuoteStatusReport sent", "quoteReqID", quoteReqID) // Step 2: Build and send Quote (35=S) with price. price := decimal.NewFromFloat(99.6) sIDSource := enum.SecurityIDSource_ISIN_NUMBER if secIDSource == enum.SecurityIDSource_CUSIP { sIDSource = enum.SecurityIDSource_CUSIP } quoteID := quoteReqID q := quote.New( field.NewQuoteID(quoteID), field.NewQuoteType(enum.QuoteType_SEND_QUOTE), field.NewTransactTime(time.Now()), ) q.SetSymbol("[N/A]") q.SetSecurityID(symbol) q.SetSecurityIDSource(sIDSource) q.SetQuoteReqID(quoteReqID) if currency != "" { q.SetCurrency(currency) } if !orderQty.IsZero() { q.SetOrderQty(orderQty, 0) } if settlDate != "" { q.SetSettlDate(settlDate) } q.SetPrice(price, 8) if side == enum.Side_BUY { q.SetOfferPx(price, 8) q.SetSide(enum.Side_BUY) } else { q.SetBidPx(price, 8) q.SetSide(enum.Side_SELL) } q.SetPriceType(enum.PriceType_PERCENTAGE) if ownerTraderID != "" { q.SetOwnerTraderID(ownerTraderID) } if sendErr := quickfix.SendToTarget(q, sessionID); sendErr != nil { slog.Error("handleQuoteRequest: failed to send quote", "quoteReqID", quoteReqID, "error", sendErr.Error()) return } slog.Info("Quote sent", "quoteReqID", quoteReqID, "quoteID", quoteID, "symbol", symbol) // Store trade state for subsequent steps. m.tradesMu.Lock() m.trades[quoteReqID] = &listTrade{ QuoteReqID: quoteReqID, ListID: listID, Symbol: symbol, SecurityIDSrc: sIDSource, Currency: currency, Side: side, OrderQty: orderQty, SettlDate: settlDate, Price: price, OwnerTraderID: ownerTraderID, SessionID: sessionID, } m.tradesMu.Unlock() // Persist structured message (outside mutex). m.persistMessage(quoteReqID, parseQuoteRequest(msg)) // Persist outgoing QuoteStatusReport. m.persistMessage(quoteReqID, buildOutgoingMessageJSON("AI", quoteReqID, map[string]interface{}{ "QuoteReqID": quoteReqID, "QuoteStatus": string(enum.QuoteStatus_ACCEPTED), "OwnerTraderID": ownerTraderID, })) // Persist outgoing Quote. m.persistMessage(quoteReqID, buildOutgoingMessageJSON("S", quoteReqID, map[string]interface{}{ "QuoteReqID": quoteReqID, "QuoteID": quoteID, "Symbol": symbol, "Side": string(side), "Price": price.String(), "OrderQty": orderQty.String(), "Currency": currency, "SettlDate": settlDate, })) } // handleQuoteAck handles an incoming QuoteAck (35=CW). func (m *Manager) handleQuoteAck(msg quoteack.QuoteAck, sessionID quickfix.SessionID) { quoteReqID, _ := msg.GetQuoteReqID() status, _ := msg.GetQuoteAckStatus() text, _ := msg.GetText() m.persistMessage(quoteReqID, parseQuoteAck(msg)) if status != enum.QuoteAckStatus_ACCEPTED { slog.Error("handleQuoteAck: quote rejected by TW", "quoteReqID", quoteReqID, "quoteAckStatus", string(status), "text", text) m.tradesMu.Lock() delete(m.trades, quoteReqID) m.tradesMu.Unlock() return } slog.Info("handleQuoteAck: accepted", "quoteReqID", quoteReqID) } // handleQuoteResponse handles an incoming QuoteResponse (35=AJ). // Supports _TRDREQ (trade request), _TRDEND (trade ended), and _TRDSUMM (trade summary) suffixes. func (m *Manager) handleQuoteResponse(msg quoteresponse.QuoteResponse, sessionID quickfix.SessionID) { quoteReqID, _ := msg.GetQuoteReqID() quoteRespID, _ := msg.GetQuoteRespID() slog.Info("handleQuoteResponse", "quoteReqID", quoteReqID, "quoteRespID", quoteRespID) isTrdReq := strings.HasSuffix(quoteRespID, "_TRDREQ") isTrdEnd := strings.HasSuffix(quoteRespID, "_TRDEND") isTrdSumm := strings.HasSuffix(quoteRespID, "_TRDSUMM") isListEnd := strings.HasSuffix(quoteRespID, "_LISTEND") if !isTrdReq && !isTrdEnd && !isTrdSumm && !isListEnd { slog.Info("handleQuoteResponse: QuoteRespID has unrecognized suffix, ignoring", "quoteRespID", quoteRespID) return } // TODO: handle 694=2 (Counter) for flow 8.5 (Client Countering) in the future. // Always send ACK regardless of whether the trade is in our map. // TW will keep retrying until it receives an ACK. if ackErr := m.sendTradeRequestAck(quoteReqID, quoteRespID, sessionID); ackErr != nil { slog.Error("handleQuoteResponse: failed to send ACK", "quoteReqID", quoteReqID, "quoteRespID", quoteRespID, "error", ackErr.Error()) return } slog.Info("QuoteResponse ACK sent", "quoteReqID", quoteReqID, "quoteRespID", quoteRespID) // Persist incoming QuoteResponse. m.persistMessage(quoteReqID, parseQuoteResponse(msg)) // Persist outgoing ACK. m.persistMessage(quoteReqID, buildOutgoingMessageJSON("AI", quoteReqID, map[string]interface{}{ "QuoteReqID": quoteReqID, "QuoteRespID": quoteRespID, "QuoteStatus": string(enum.QuoteStatus_ACCEPTED), })) // _TRDSUMM is the final message — clean up the trade. if isTrdSumm { slog.Info("Trade summary received, cleaning up", "quoteReqID", quoteReqID) m.tradesMu.Lock() delete(m.trades, quoteReqID) m.tradesMu.Unlock() } } // handleExecutionReport handles an incoming ExecutionReport (35=8). // In flow 8.4 (List Trading), the dealer NEVER sends an ExecutionReport — only TW does. func (m *Manager) handleExecutionReport(msg executionreport.ExecutionReport, sessionID quickfix.SessionID) { execID, _ := msg.GetExecID() orderID, _ := msg.GetOrderID() clOrdID, _ := msg.GetClOrdID() execType, _ := msg.GetExecType() ordStatus, _ := msg.GetOrdStatus() listID, _ := msg.GetListID() slog.Info("handleExecutionReport received", "execID", execID, "orderID", orderID, "clOrdID", clOrdID, "execType", string(execType), "ordStatus", string(ordStatus), "listID", listID, ) // Send ExecutionAck (35=BN) for every incoming ExecutionReport from TW. if ackErr := m.sendExecutionAck(orderID, clOrdID, execID, sessionID); ackErr != nil { slog.Error("handleExecutionReport: failed to send ExecutionAck", "execID", execID, "error", ackErr.Error()) } else { slog.Info("ExecutionAck sent", "execID", execID) } switch { case strings.Contains(execID, "_LISTEND"): slog.Info("List ended (due-in closed), awaiting trade result from TW", "execID", execID, "clOrdID", clOrdID) case strings.Contains(execID, "_TRDEND"): slog.Info("Trade ended", "execID", execID, "clOrdID", clOrdID) case strings.Contains(execID, "_TRDSUMM"): slog.Info("Trade summary received from TW, cleaning up", "execID", execID, "clOrdID", clOrdID, "ordStatus", string(ordStatus), "listID", listID) m.tradesMu.Lock() delete(m.trades, clOrdID) m.tradesMu.Unlock() case execType == enum.ExecType_TRADE: slog.Info("Trade result received from TW", "execID", execID, "clOrdID", clOrdID, "ordStatus", string(ordStatus), "listID", listID) } // Persist incoming ExecutionReport. m.persistMessage(clOrdID, parseExecutionReport(msg)) // Persist outgoing ExecutionAck. m.persistMessage(clOrdID, buildOutgoingMessageJSON("BN", clOrdID, map[string]interface{}{ "OrderID": orderID, "ExecID": execID, "ClOrdID": clOrdID, "ExecAckStatus": string(enum.ExecAckStatus_ACCEPTED), })) } // handleExecutionAck handles an incoming ExecutionAck (35=BN) from TW. func (m *Manager) handleExecutionAck(msg executionack.ExecutionAck, sessionID quickfix.SessionID) { // Logged in application.go, no further action needed. } // GetTrades returns a snapshot of all active trades. func (m *Manager) GetTrades() []domain.ListTrade { m.tradesMu.RLock() defer m.tradesMu.RUnlock() trades := make([]domain.ListTrade, 0, len(m.trades)) for _, t := range m.trades { trades = append(trades, domain.ListTrade{ QuoteReqID: t.QuoteReqID, ListID: t.ListID, Symbol: t.Symbol, SecurityIDSrc: string(t.SecurityIDSrc), Currency: t.Currency, Side: string(t.Side), OrderQty: t.OrderQty.String(), SettlDate: t.SettlDate, Price: t.Price.String(), OwnerTraderID: t.OwnerTraderID, }) } return trades } // handleRawMessage persists raw FIX message strings to the logs table. func (m *Manager) handleRawMessage(direction string, msg *quickfix.Message) { quoteReqID := extractIdentifier(msg) if err := m.store.SaveLog(domain.LogEntry{ QuoteReqID: quoteReqID, RawMsg: "[" + direction + "] " + msg.String(), }); err != nil { slog.Error("failed to persist raw log", "error", err) } } // persistMessage saves a structured FIX message to the messages table. func (m *Manager) persistMessage(quoteReqID string, fixJSON domain.FixMessageJSON) { if err := m.store.SaveMessage(domain.TradeMessage{ QuoteReqID: quoteReqID, JMessage: fixJSON, }); err != nil { slog.Error("failed to persist message", "msgType", fixJSON.MsgType, "quoteReqID", quoteReqID, "error", err) } } // loadActiveTrades reconstructs active trades from today's messages in the database. func (m *Manager) loadActiveTrades() error { messages, err := m.store.GetTodayMessages() if err != nil { return err } activeTrades := make(map[string]*listTrade) for _, msg := range messages { switch msg.JMessage.MsgType { case "R": // QuoteRequest -> trade is born if !strings.HasPrefix(msg.QuoteReqID, "LST_") { continue } body := msg.JMessage.Body nt, _ := body["NegotiationType"].(string) if nt != "RFQ" { continue } listID, _ := body["ListID"].(string) if listID == "" { continue } trade := &listTrade{ QuoteReqID: msg.QuoteReqID, ListID: listID, } if v, ok := body["SecurityID"].(string); ok { trade.Symbol = v } if v, ok := body["Currency"].(string); ok { trade.Currency = v } if v, ok := body["Side"].(string); ok { trade.Side = enum.Side(v) } if v, ok := body["OrderQty"].(string); ok { trade.OrderQty, _ = decimal.NewFromString(v) } if v, ok := body["SettlDate"].(string); ok { trade.SettlDate = v } if v, ok := body["OwnerTraderID"].(string); ok { trade.OwnerTraderID = v } activeTrades[msg.QuoteReqID] = trade case "CW": // QuoteAck — if rejected, trade is dead body := msg.JMessage.Body quoteAckStatus, _ := body["QuoteAckStatus"].(string) if quoteAckStatus != string(enum.QuoteAckStatus_ACCEPTED) { delete(activeTrades, msg.QuoteReqID) } case "AJ": // QuoteResponse — _TRDSUMM means trade is done (flow 8.6) body := msg.JMessage.Body quoteRespID, _ := body["QuoteRespID"].(string) if strings.HasSuffix(quoteRespID, "_TRDSUMM") { delete(activeTrades, msg.QuoteReqID) } case "8": // ExecutionReport — _TRDSUMM means trade is done (flow 8.4) body := msg.JMessage.Body execID, _ := body["ExecID"].(string) clOrdID, _ := body["ClOrdID"].(string) if strings.Contains(execID, "_TRDSUMM") { delete(activeTrades, clOrdID) } } } m.trades = activeTrades slog.Info("recovery completed", "activeTrades", len(activeTrades)) return nil }