mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
The gateway spoke 8=FIX.4.4 and reported a fill as ExecType 1/2 — the pair FIX Latest retired precisely because it duplicated order state into the event field. Both fields carried the same thing, so nothing on the wire distinguished "a trade happened" from "the order is done", and a client could read either. Session layer is now FIXT.1.1; the application layer is FIX.5.0SP2 + EP307, named on the Logon by DefaultApplVerID(1137) and ApplExtID(1156). Every fill is ExecType=F (Trade) and OrdStatus alone moves Partial -> Filled. Nothing depended on the old BeginString, so the wire moved with the semantics instead of straddling both. This is the wire pkg/dexprotocol already documents (150=F, 39=1|2). A partially-filled IOC also never reached a terminal state: the last report said Partial and nothing followed, leaving a client waiting for an end that never came. It now closes with Canceled. Vocabulary, one word per concept: execReport -> execution, gateway OrderFill -> Fill, multisig ExecutionResult -> Result (it holds an error string, never an execution). Deletes the SDK's hand-rolled FIX encoder — it pinned a second protocol version, omitted BodyLength entirely so the acceptor would have rejected every frame, and sendFIXMessage returned nil without sending. Co-authored-by: Hanzo Dev <[email protected]>
158 lines
5.3 KiB
Go
158 lines
5.3 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package fix
|
|
|
|
import (
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// send.go is the session's outbound side: it stamps the standard FIX header
|
|
// (SenderCompID/TargetCompID/MsgSeqNum/SendingTime), serializes via the codec
|
|
// (which appends BodyLength + CheckSum), and writes the frame to the conn under
|
|
// the session mutex so outbound sequence numbers stay monotonic and writes never
|
|
// interleave across goroutines.
|
|
|
|
// sendingTimeFormat is the FIX UTCTimestamp format (millisecond precision).
|
|
const sendingTimeFormat = "20060102-15:04:05.000"
|
|
|
|
// send stamps the standard header onto msg, serializes it, and writes it to the
|
|
// peer. It assigns the next outbound MsgSeqNum and SendingTime. Safe for
|
|
// concurrent callers (the heartbeat goroutine and the read loop both send).
|
|
func (s *session) send(msg *Message) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Standard header: identity + sequence + time. These precede the body fields
|
|
// the caller already Set; FIX requires 49/56/34/52 in the header, but since
|
|
// BodyLength is computed over the whole body the exact ordering among header
|
|
// and body app-fields is not integrity-relevant — we prepend them logically by
|
|
// setting before serialize. (MsgType stays first via Serialize.)
|
|
msg.Set(TagSenderCompID, s.senderID).
|
|
Set(TagTargetCompID, s.targetID).
|
|
SetInt(TagMsgSeqNum, s.outSeq).
|
|
Set(TagSendingTime, time.Now().UTC().Format(sendingTimeFormat))
|
|
s.outSeq++
|
|
|
|
_ = s.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
if _, err := s.conn.Write(msg.Serialize()); err != nil {
|
|
s.log.Debug("fix write failed", "err", err)
|
|
}
|
|
}
|
|
|
|
// sendLogout emits a Logout (35=5) with an optional reason in Text (58).
|
|
func (s *session) sendLogout(reason string) {
|
|
m := NewMessage(MsgTypeLogout)
|
|
if reason != "" {
|
|
m.Set(TagText, reason)
|
|
}
|
|
// A pre-logon Logout has no established identity; send still stamps empty
|
|
// 49/56, which is acceptable for a rejection handshake.
|
|
s.send(m)
|
|
}
|
|
|
|
// sendSessionReject emits a session-level Reject (35=3) referencing the offending
|
|
// inbound sequence number with a reason in Text. Used for malformed frames and
|
|
// sequencing errors.
|
|
func (s *session) sendSessionReject(refSeq int, reason string) {
|
|
m := NewMessage(MsgTypeReject)
|
|
if refSeq > 0 {
|
|
m.SetInt(45, refSeq) // RefSeqNum
|
|
}
|
|
m.Set(TagText, reason)
|
|
s.send(m)
|
|
}
|
|
|
|
// sendCancelReject emits an OrderCancelReject (35=9) for a failed cancel.
|
|
func (s *session) sendCancelReject(clOrdID, origClOrdID, reason string) {
|
|
m := NewMessage(MsgTypeOrderCancelReject)
|
|
if clOrdID != "" {
|
|
m.Set(TagClOrdID, clOrdID)
|
|
}
|
|
if origClOrdID != "" {
|
|
m.Set(TagOrigClOrdID, origClOrdID)
|
|
}
|
|
m.Set(TagOrdStatus, OrdStatusRejected).
|
|
Set(434, "1"). // CxlRejResponseTo = OrderCancelRequest
|
|
Set(TagText, reason)
|
|
s.send(m)
|
|
}
|
|
|
|
// sendOrderReject emits an ExecutionReport(Rejected) (35=8, 150=8, 39=8) for an
|
|
// order the gateway or venue refused.
|
|
func (s *session) sendOrderReject(clOrdID, symbol, reason string) {
|
|
s.sendExecution(execution{
|
|
clOrdID: clOrdID,
|
|
symbol: symbol,
|
|
execType: ExecTypeRejected,
|
|
ordStatus: OrdStatusRejected,
|
|
text: reason,
|
|
})
|
|
}
|
|
|
|
// execution is one concrete result — what filled, at what price, and where that
|
|
// leaves the order — rendered as an ExecutionReport (35=8). Zero-valued numeric
|
|
// fields are still emitted where FIX expects them (e.g. LeavesQty on an ack).
|
|
type execution struct {
|
|
clOrdID string
|
|
orderID string
|
|
symbol string
|
|
side string // tag 54 value ("1"/"2"); empty omits it (e.g. a bare reject)
|
|
execType string // tag 150
|
|
ordStatus string // tag 39
|
|
orderQty float64
|
|
lastQty float64
|
|
lastPx float64
|
|
leavesQty float64
|
|
cumQty float64
|
|
avgPx float64
|
|
text string
|
|
}
|
|
|
|
// sendExecution renders and sends an ExecutionReport. It always carries an
|
|
// ExecID (a fresh per-report id), the OrderID/ClOrdID linkage, ExecType (150),
|
|
// OrdStatus (39), and the quantity/price fields. This is the spec-correct shape a
|
|
// FIX client decodes to learn an ack, a fill, a cancel, or a reject.
|
|
func (s *session) sendExecution(r execution) {
|
|
m := NewMessage(MsgTypeExecutionReport)
|
|
if r.orderID != "" {
|
|
m.Set(TagOrderID, r.orderID)
|
|
}
|
|
if r.clOrdID != "" {
|
|
m.Set(TagClOrdID, r.clOrdID)
|
|
}
|
|
m.Set(TagExecID, strconv.FormatInt(time.Now().UnixNano(), 10)).
|
|
Set(TagExecType, r.execType).
|
|
Set(TagOrdStatus, r.ordStatus)
|
|
if r.symbol != "" {
|
|
m.Set(TagSymbol, r.symbol)
|
|
}
|
|
if r.side != "" {
|
|
m.Set(TagSide, r.side)
|
|
}
|
|
if r.orderQty > 0 {
|
|
m.Set(TagOrderQty, formatQty(r.orderQty))
|
|
}
|
|
if r.lastQty > 0 {
|
|
m.Set(TagLastQty, formatQty(r.lastQty))
|
|
m.Set(TagLastPx, formatPx(r.lastPx))
|
|
}
|
|
m.Set(TagLeavesQty, formatQty(r.leavesQty)).
|
|
Set(TagCumQty, formatQty(r.cumQty))
|
|
if r.avgPx > 0 {
|
|
m.Set(TagAvgPx, formatPx(r.avgPx))
|
|
}
|
|
if r.text != "" {
|
|
m.Set(TagText, r.text)
|
|
}
|
|
s.send(m)
|
|
}
|
|
|
|
// formatQty / formatPx render numeric quantities/prices with full float64
|
|
// precision (strconv 'g' with -1 precision is round-trippable). FIX carries these
|
|
// as decimal strings; the venue's authoritative units are integer (the custody
|
|
// ledger), and the float fill price/size are exact for the test grid.
|
|
func formatQty(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
|
|
func formatPx(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
|