604 lines
18 KiB
Go
604 lines
18 KiB
Go
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
|
|
}
|