Post trade working

This commit is contained in:
2026-03-19 12:55:27 -03:00
parent 072d44f3c3
commit 01e04df0f6
8 changed files with 654 additions and 2 deletions

View File

@ -0,0 +1,136 @@
package fix
import (
"log/slog"
"quantex.com/qfixpt/quickfix"
"quantex.com/qfixpt/quickfix/gen/fix50sp2/allocationreport"
"quantex.com/qfixpt/quickfix/gen/fix50sp2/confirmation"
"quantex.com/qfixpt/quickfix/gen/fix50sp2/tradecapturereport"
"quantex.com/qfixpt/quickfix/gen/tag"
"quantex.com/qfixpt/src/domain"
)
type application struct {
router *quickfix.MessageRouter
notifier domain.Notifier
onLogon func(quickfix.SessionID)
onLogout func(quickfix.SessionID)
onTradeCaptureReport func(tradecapturereport.TradeCaptureReport, quickfix.SessionID)
onAllocationReport func(allocationreport.AllocationReport, quickfix.SessionID)
onConfirmation func(confirmation.Confirmation, quickfix.SessionID)
}
func newApplication(n domain.Notifier) *application {
app := &application{
router: quickfix.NewMessageRouter(),
notifier: n,
}
app.router.AddRoute(tradecapturereport.Route(app.handleTradeCaptureReport))
app.router.AddRoute(allocationreport.Route(app.handleAllocationReport))
app.router.AddRoute(confirmation.Route(app.handleConfirmation))
return app
}
func (a *application) OnCreate(sessionID quickfix.SessionID) {
slog.Info("FIX session created", "session", sessionID.String())
}
func (a *application) OnLogon(sessionID quickfix.SessionID) {
slog.Info("FIX session logged on", "session", sessionID.String())
if a.onLogon != nil {
a.onLogon(sessionID)
}
}
func (a *application) OnLogout(sessionID quickfix.SessionID) {
slog.Info("FIX session logged out", "session", sessionID.String())
go a.notifier.SendMsg(domain.MessageChannelError, "Logout", domain.MessageStatusWarning, nil)
if a.onLogout != nil {
a.onLogout(sessionID)
}
}
func (a *application) ToAdmin(_ *quickfix.Message, _ quickfix.SessionID) {}
func (a *application) ToApp(_ *quickfix.Message, _ quickfix.SessionID) error { return nil }
func (a *application) FromAdmin(_ *quickfix.Message, _ quickfix.SessionID) quickfix.MessageRejectError {
return nil
}
func (a *application) FromApp(msg *quickfix.Message, sessionID quickfix.SessionID) quickfix.MessageRejectError {
beginString, _ := msg.Header.GetBytes(tag.BeginString)
msgType, _ := msg.Header.GetBytes(tag.MsgType)
var applVerID quickfix.FIXString
msg.Header.GetField(tag.ApplVerID, &applVerID)
slog.Info("FIX FromApp received",
"beginString", string(beginString),
"msgType", string(msgType),
"applVerID", string(applVerID),
"session", sessionID.String(),
"rawMsg", msg.String(),
)
rejErr := a.router.Route(msg, sessionID)
if rejErr != nil {
slog.Error("FIX FromApp routing failed",
"msgType", string(msgType),
"error", rejErr.Error(),
"isBusinessReject", rejErr.IsBusinessReject(),
)
}
return rejErr
}
func (a *application) handleTradeCaptureReport(msg tradecapturereport.TradeCaptureReport, sessionID quickfix.SessionID) quickfix.MessageRejectError {
tradeReportID, _ := msg.GetTradeReportID()
slog.Info("TradeCaptureReport received",
"tradeReportID", tradeReportID,
"session", sessionID.String(),
)
if a.onTradeCaptureReport != nil {
a.onTradeCaptureReport(msg, sessionID)
}
return nil
}
func (a *application) handleAllocationReport(msg allocationreport.AllocationReport, sessionID quickfix.SessionID) quickfix.MessageRejectError {
allocReportID, _ := msg.GetAllocReportID()
slog.Info("AllocationReport received",
"allocReportID", allocReportID,
"session", sessionID.String(),
)
if a.onAllocationReport != nil {
a.onAllocationReport(msg, sessionID)
}
return nil
}
func (a *application) handleConfirmation(msg confirmation.Confirmation, sessionID quickfix.SessionID) quickfix.MessageRejectError {
confirmID, _ := msg.GetConfirmID()
slog.Info("Confirmation received",
"confirmID", confirmID,
"session", sessionID.String(),
)
if a.onConfirmation != nil {
a.onConfirmation(msg, sessionID)
}
return nil
}

360
src/client/fix/manager.go Normal file
View File

@ -0,0 +1,360 @@
package fix
import (
"log/slog"
"os"
"time"
"github.com/rs/zerolog/log"
"github.com/shopspring/decimal"
"quantex.com/qfixpt/quickfix"
"quantex.com/qfixpt/quickfix/gen/enum"
"quantex.com/qfixpt/quickfix/gen/field"
"quantex.com/qfixpt/quickfix/gen/fix50sp2/allocationreport"
"quantex.com/qfixpt/quickfix/gen/fix50sp2/allocationreportack"
"quantex.com/qfixpt/quickfix/gen/fix50sp2/confirmation"
"quantex.com/qfixpt/quickfix/gen/fix50sp2/confirmationack"
"quantex.com/qfixpt/quickfix/gen/fix50sp2/tradecapturereport"
"quantex.com/qfixpt/quickfix/gen/fix50sp2/tradecapturereportack"
filelog "quantex.com/qfixpt/quickfix/log/file"
filestore "quantex.com/qfixpt/quickfix/store/file"
"quantex.com/qfixpt/src/app"
"quantex.com/qfixpt/src/common/tracerr"
"quantex.com/qfixpt/src/domain"
)
// Custom Tradeweb tags not in generated code.
const (
tagTestMessage = 23029
tagTWTradeID = 23068
tagTWOrigTradeID = 23096
tagTWClrID = 23025
tagDlrClrID = 23027
tagClearingStatus = 5440
)
// Manager wraps the QuickFIX initiator for post-trade message handling.
type Manager struct {
initiator *quickfix.Initiator
app *application
notify domain.Notifier
cfg app.FIXConfig
}
func NewManager(cfg app.FIXConfig, notify domain.Notifier) *Manager {
return &Manager{
notify: notify,
cfg: cfg,
}
}
func (m *Manager) Start() error {
fixApp := newApplication(m.notify)
fixApp.onLogon = m.onLogon
fixApp.onLogout = m.onLogout
fixApp.onTradeCaptureReport = m.handleTradeCaptureReport
fixApp.onAllocationReport = m.handleAllocationReport
fixApp.onConfirmation = m.handleConfirmation
m.app = fixApp
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 := filestore.NewStoreFactory(settings)
logFactory, err := filelog.NewLogFactory(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) {
slog.Info("FIX PT session logged on", "session", sessionID.String())
}
func (m *Manager) onLogout(sessionID quickfix.SessionID) {
slog.Info("FIX PT session logged out", "session", sessionID.String())
}
// handleTradeCaptureReport processes an incoming TradeCaptureReport (35=AE).
func (m *Manager) handleTradeCaptureReport(msg tradecapturereport.TradeCaptureReport, sessionID quickfix.SessionID) {
tradeReportID, _ := msg.GetTradeReportID()
// ACK immediately before any validation.
ack := tradecapturereportack.New()
ack.SetTradeReportID(tradeReportID)
if err := quickfix.SendToTarget(ack, sessionID); err != nil {
slog.Error("failed to send TradeCaptureReportAck", "tradeReportID", tradeReportID, "error", err.Error())
} else {
slog.Info("TradeCaptureReportAck sent", "tradeReportID", tradeReportID)
}
// Check if test message.
testMessage := getCustomBool(&msg.Message.Body.FieldMap, tagTestMessage)
if testMessage {
slog.Info("test TradeCaptureReport, skipping", "tradeReportID", tradeReportID)
return
}
// Parse trade data.
tradeReportType, _ := msg.GetTradeReportType()
tradeID, _ := msg.GetTradeID()
execID, _ := msg.GetExecID()
lastQty, _ := msg.GetLastQty()
lastPx, _ := msg.GetLastPx()
spread, _ := msg.GetSpread()
settlDate, _ := msg.GetSettlDate()
tradeDate, _ := msg.GetTradeDate()
grossTradeAmt, _ := msg.GetGrossTradeAmt()
tradeReportRefID, _ := msg.GetTradeReportRefID()
// Custom fields.
twTradeID := getCustomString(&msg.Message.Body.FieldMap, tagTWTradeID)
twOrigTradeID := getCustomString(&msg.Message.Body.FieldMap, tagTWOrigTradeID)
// Extract side-level fields (parties, accrued interest, net money).
var side string
var accruedInterest, netMoney decimal.Decimal
var clearingFirm, twTraderID string
sides, sideErr := msg.GetNoSides()
if sideErr == nil && sides.Len() > 0 {
s := sides.Get(0)
sideVal, _ := s.GetSide()
side = string(sideVal)
accruedInterest, _ = s.GetAccruedInterestAmt()
netMoney, _ = s.GetNetMoney()
clearingFirm = extractPartyByRole(s, enum.PartyRole_CLEARING_FIRM)
// 1007 is a custom Tradeweb role, access via string comparison.
twTraderID = extractPartyByRole(s, "1007")
}
trade := domain.Trade{
TradeReportID: tradeReportID,
TradeID: tradeID,
ExecID: execID,
LastQty: lastQty,
LastPx: lastPx,
Spread: spread,
Side: side,
AccruedInterest: accruedInterest,
NetMoney: netMoney,
GrossTradeAmt: grossTradeAmt,
SettlDate: settlDate,
TradeDate: tradeDate,
TradeReportType: string(tradeReportType),
TradeReportRefID: tradeReportRefID,
ClearingFirm: clearingFirm,
TradewebTraderID: twTraderID,
TWTradeID: twTradeID,
TWOrigTradeID: twOrigTradeID,
}
slog.Info("TradeCaptureReport parsed",
"tradeReportID", trade.TradeReportID,
"tradeReportType", trade.TradeReportType,
"tradeID", trade.TradeID,
"lastQty", trade.LastQty.String(),
"lastPx", trade.LastPx.String(),
"side", trade.Side,
"settlDate", trade.SettlDate,
)
switch string(tradeReportType) {
case "101": // TRDCONF — trade confirmation
// TODO: upsert trade en BD
slog.Info("TRDCONF: trade confirmation", "tradeReportID", tradeReportID, "tradeID", tradeID)
case "102": // TRDBLOCK — block trade, log only
slog.Info("TRDBLOCK: block trade", "tradeReportID", tradeReportID, "tradeID", tradeID)
case "103": // TRDCORR — trade correction
// TODO: update/correct trade en BD (usando TradeReportRefID)
slog.Info("TRDCORR: trade correction", "tradeReportID", tradeReportID, "refID", tradeReportRefID)
case "104": // TRDCXL — trade cancellation
// TODO: cancel trade en BD (usando TradeReportRefID)
slog.Info("TRDCXL: trade cancel", "tradeReportID", tradeReportID, "refID", tradeReportRefID)
case "105", "106", "107", "108": // log and ack only, no business logic
slog.Info("trade report (no business logic)", "tradeReportType", string(tradeReportType), "tradeReportID", tradeReportID)
default:
slog.Warn("unexpected TradeReportType", "tradeReportType", string(tradeReportType), "tradeReportID", tradeReportID)
}
}
// handleAllocationReport processes an incoming AllocationReport (35=AS).
func (m *Manager) handleAllocationReport(msg allocationreport.AllocationReport, sessionID quickfix.SessionID) {
allocReportID, _ := msg.GetAllocReportID()
// ACK immediately.
ack := allocationreportack.New(field.NewAllocReportID(allocReportID))
if err := quickfix.SendToTarget(ack, sessionID); err != nil {
slog.Error("failed to send AllocationReportAck", "allocReportID", allocReportID, "error", err.Error())
} else {
slog.Info("AllocationReportAck sent", "allocReportID", allocReportID)
}
// Check if test message.
testMessage := getCustomBool(&msg.Message.Body.FieldMap, tagTestMessage)
if testMessage {
slog.Info("test AllocationReport, skipping", "allocReportID", allocReportID)
return
}
allocTransType, _ := msg.GetAllocTransType()
// Extract TradeID from the first NoExecs entry if available.
tradeID := getCustomString(&msg.Message.Body.FieldMap, 1003)
alloc := domain.Allocation{
AllocReportID: allocReportID,
AllocTransType: string(allocTransType),
TradeID: tradeID,
}
// Parse NoAllocs repeating group.
allocs, allocErr := msg.GetNoAllocs()
if allocErr == nil {
for i := 0; i < allocs.Len(); i++ {
entry := allocs.Get(i)
account, _ := entry.GetAllocAccount()
qty, _ := entry.GetAllocQty()
individualID, _ := entry.GetIndividualAllocID()
price, _ := entry.GetAllocPrice()
alloc.Entries = append(alloc.Entries, domain.AllocEntry{
AllocAccount: account,
AllocQty: qty,
IndividualAllocID: individualID,
AllocPrice: price,
})
}
}
slog.Info("AllocationReport parsed",
"allocReportID", alloc.AllocReportID,
"allocTransType", alloc.AllocTransType,
"tradeID", alloc.TradeID,
"numEntries", len(alloc.Entries),
)
switch allocTransType {
case enum.AllocTransType_NEW:
// TODO: insert allocations en BD
slog.Info("new allocation", "allocReportID", allocReportID)
case enum.AllocTransType_REPLACE:
// TODO: update allocations en BD
slog.Info("replace allocation", "allocReportID", allocReportID)
case enum.AllocTransType_CANCEL:
// TODO: cancel allocations en BD
slog.Info("cancel allocation", "allocReportID", allocReportID)
default:
slog.Warn("unhandled AllocTransType", "allocTransType", string(allocTransType), "allocReportID", allocReportID)
}
}
// handleConfirmation processes an incoming Confirmation (35=AK).
func (m *Manager) handleConfirmation(msg confirmation.Confirmation, sessionID quickfix.SessionID) {
confirmID, _ := msg.GetConfirmID()
tradeDate, _ := msg.GetTradeDate()
transactTime, _ := msg.GetTransactTime()
// ACK immediately.
ack := confirmationack.New(
field.NewConfirmID(confirmID),
field.NewTradeDate(tradeDate),
field.NewTransactTime(transactTime),
field.NewAffirmStatus(enum.AffirmStatus_RECEIVED),
)
if err := quickfix.SendToTarget(ack, sessionID); err != nil {
slog.Error("failed to send ConfirmationAck", "confirmID", confirmID, "error", err.Error())
} else {
slog.Info("ConfirmationAck sent", "confirmID", confirmID)
}
// Check if test message.
testMessage := getCustomBool(&msg.Message.Body.FieldMap, tagTestMessage)
if testMessage {
slog.Info("test Confirmation, skipping", "confirmID", confirmID)
return
}
confirmStatus, _ := msg.GetConfirmStatus()
confirmTransType, _ := msg.GetConfirmTransType()
individualAllocID, _ := msg.GetIndividualAllocID()
// Custom fields.
twClrID := getCustomString(&msg.Message.Body.FieldMap, tagTWClrID)
dlrClrID := getCustomString(&msg.Message.Body.FieldMap, tagDlrClrID)
clearingStatus := getCustomString(&msg.Message.Body.FieldMap, tagClearingStatus)
conf := domain.ConfirmationMsg{
ConfirmID: confirmID,
ConfirmStatus: string(confirmStatus),
ConfirmTransType: string(confirmTransType),
IndividualAllocID: individualAllocID,
TWClrID: twClrID,
DlrClrID: dlrClrID,
ClearingStatus: clearingStatus,
TradeDate: tradeDate,
}
slog.Info("Confirmation parsed",
"confirmID", conf.ConfirmID,
"confirmStatus", conf.ConfirmStatus,
"confirmTransType", conf.ConfirmTransType,
"individualAllocID", conf.IndividualAllocID,
"clearingStatus", conf.ClearingStatus,
)
// TODO: persistir/actualizar confirmation status en BD
}
// Ensure transactTime has a sensible default if not present in the message.
var _ = time.Now // used by confirmationack.New via field.NewTransactTime

63
src/client/fix/parser.go Normal file
View File

@ -0,0 +1,63 @@
package fix
import (
"quantex.com/qfixpt/quickfix"
"quantex.com/qfixpt/quickfix/gen/enum"
"quantex.com/qfixpt/quickfix/gen/fix50sp2/tradecapturereport"
)
func getCustomString(body *quickfix.FieldMap, t int) string {
v, err := body.GetString(quickfix.Tag(t))
if err != nil {
return ""
}
return v
}
func getCustomInt(body *quickfix.FieldMap, t int) int {
v, err := body.GetInt(quickfix.Tag(t))
if err != nil {
return 0
}
return v
}
func getCustomBool(body *quickfix.FieldMap, t int) bool {
v, err := body.GetBool(quickfix.Tag(t))
if err != nil {
return false
}
return v
}
// extractPartyByRole iterates the Parties repeating group inside a NoSides entry
// and returns the PartyID for the given target role.
func extractPartyByRole(side tradecapturereport.NoSides, targetRole enum.PartyRole) string {
parties, err := side.GetNoPartyIDs()
if err != nil {
return ""
}
for i := 0; i < parties.Len(); i++ {
party := parties.Get(i)
role, roleErr := party.GetPartyRole()
if roleErr != nil {
continue
}
if role == targetRole {
id, idErr := party.GetPartyID()
if idErr != nil {
return ""
}
return id
}
}
return ""
}