Market data¶
The engine emits OutboundEvents about individual orders. A market data feed
is an ordered, sequenced stream that subscribers can apply to rebuild the
book. MarketDataPublisher is the bridge between the two.
MarketDataPublisher<> md([](const MdMessage& m) { multicast(m); },
/*tickSize*/ Price::fromDouble(0.01), /*symbol*/ 1);
MatchingEngine<MatchingBook> venue(cfg, [&](const OutboundEvent& e)
{
// The engine's sequencer time stamps the feed;
// the publisher never reads a clock for it.
md.onEvent(e, venue.engineTimeNs());
ledgerOrMetrics(e);
});
md.book(); // flox::NLevelOrderBook kept in step with the venue book
Messages¶
MdType |
Meaning | price field |
qty field |
|---|---|---|---|
AddOrder |
an order joined the book | limit price | shown quantity |
Trade |
a print (id is the taker) |
trade price | executed quantity |
Executed |
a resting order was hit | resting price | displayed remaining |
Cancel |
an order left the book | resting price | — |
Replace |
an order was repriced/resized | new price | displayed remaining |
Triggered |
a stop fired | reference price | — |
TradingStatus |
halt / pause / auction transition | — | — |
DerivativesUpdate |
mark, funding, open interest | mark price | open interest |
Every message carries (symbol, epoch, seq): seq is contiguous from 1
within one publisher lifetime, epoch is a random value minted when the
publisher is constructed. One publisher serves one symbol; several publishers
may share a multicast group, and the symbol in every root block is what a
consumer demultiplexes on before any gap accounting.
Time: engineTsNs and sendTsNs¶
Every message on every path carries two nanosecond timestamps, and they mean different things on purpose.
| Field | Source | Reproducible on replay? |
|---|---|---|
engineTsNs |
the sequencer-ts of the command that caused the event (MatchingEngine::engineTimeNs()) |
yes |
sendTsNs |
wall clock when the publisher sent this copy | no |
engineTsNs is journaled input, so replaying the same command stream produces
the same value on every message — it is what time bars, a faithful tape and
any metric with time as a variable are built on, and it is safe to fold into a
determinism digest. The publisher does not invent it: an OutboundEvent
carries no time of its own, so the caller — which knows the engine's time —
passes it to onEvent.
sendTsNs is a wall-clock read at send time and is deliberately not
reproducible. A resend of the same message carries a later sendTsNs than the
original, which is exactly how a consumer tells a replayed copy from live
flow. sendTsNs - engineTsNs is the venue's own outbound latency. It must
never enter an event hash or a determinism comparison (hashEvent does not
see it: the timestamps are added by the publisher, below the event layer).
Both are non-decreasing within one publisher: engineTsNs because sequencer
time is, sendTsNs because the publisher latches the maximum it has issued —
a backwards step of the system clock cannot make the feed look reordered.
On the wire:
- SBE — two trailing
int64fields (engineTs,sendTs,sinceVersion=3) appended to the end of every incremental root block, exactly asepochwas in version 1. A version-1/2 reader skips them via the header'sblockLengthand decodes everything else byte for byte. - FIX —
52 SendingTimecarriessendTsNs,60 TransactTimeand273 MDEntryTimecarryengineTsNs. All standard fields; a message from a publisher that has no timestamp (0) omits the tag rather than printing the epoch.
Trading status¶
Halts, volatility pauses and auction phases are published as transitions, from the engine's own state changes — a subscriber never has to infer a halt from a feed that went quiet, and never sees a repeat for a state that did not change.
TradingStatus |
Meaning | untilNs |
|---|---|---|
Trading |
continuous matching | 0 |
Halted |
operator halt; new orders rejected | 0 |
LuldPause |
timed limit-up/limit-down volatility pause | pause deadline (sequencer time) |
AuctionPreOpen |
pre-open accumulation, no matching | 0 |
AuctionUncross |
the uncross itself | 0 |
Closed |
the session is closed; new orders rejected, the book stands | 0 |
TradingStatusReason says why: Administrative (an operator AdminCmd),
LuldBreach, LuldPauseElapsed (the timed pause ran out and trading
resumed), Auction, Session (a session boundary).
untilNs is the deadline of the timed pause and of nothing else. Any other
state publishes 0, even when a pause deadline is still stored underneath it — a
subscriber is never handed an expiry for a state that does not expire.
An opening auction publishes three transitions — AuctionPreOpen,
AuctionUncross, Trading — so the uncross fills sit between the last two and
can be attributed to the auction rather than to continuous trading.
Closed is not Halted¶
They are different states on purpose. A halt is an exception — something went
wrong and an operator stopped the instrument, with no promise about when it
resumes. A closed session is the instrument's normal out-of-hours condition.
A subscriber that cannot tell them apart cannot tell a broken market from a
sleeping one, and the two reject an order with different reasons
(Halted vs MarketClosed).
Closedis the outermost state: it wins over an auction phase and over a halt intradingStatus(), and the halt or auction underneath it is left untouched — a close during a halt reopens still halted, because a session boundary must not silently clear an exception an operator raised.- Closing does not pull the book (that is
HaltAndCancelAll). Resting orders stand across the close, and a cancel is still accepted: a client must be able to get out of what it cannot add to. - Transitions arrive as the sequenced
AdminCmdactionsCloseSession/OpenSession, so they journal, replay, hash and checkpoint like a halt.
What the engine deliberately does not own is the calendar. Nothing here
fires on a clock; the schedule that decides when to send CloseSession /
OpenSession belongs to the operator / control plane (see
runtime.md), which drives it through the control-plane session
verb.
On the wire the value is appended at the end of the enum, so no root block
changes and a reader of the previous schema decodes every field of the message
and sees only an unrecognised status code — which it must treat as not
tradeable, never as Trading. On FIX it is 326 = 18 (Not available for
trading, FIX 4.4's end-of-session value), deliberately not the halt code 2.
The engine reaches the feed through a new OutboundEvent,
TradingStatusChanged, which is hashed like every other event
(untilNs is sequencer time, so it replays). It carries no account: the
order-entry codecs have no exec-report form for it, exactly like
MmpTriggered / FeeCharged / Liquidation.
On FIX it is the standard 35=f SecurityStatus:
326 SecurityTradingStatus = 17 trading, 2 halt (both an operator halt
and a volatility pause), 21 pre-open, 22 opening rotation, with the
distinction FIX 4.4 cannot express in 326 spelled out in 58 Text. It is
sent regardless of the subscription's MDEntryType filter — a subscriber that
asked for trades only still has to be told the instrument stopped trading.
Derivatives¶
DerivativesUpdate carries what a perp holder needs to value a position:
| Field | Meaning |
|---|---|
price |
mark price (the last SetMark the engine was given) |
fundingRateRaw |
last applied funding rate, at kFundingRateScale (= the price/qty scale, 1e-8). A rate never travels as a float |
nextFundingNs |
next settlement boundary; 0 = the venue does not fund this instrument |
qty |
open interest: the long side of the open positions the engine tracks (equal to the short side) |
It is emitted from the sequenced SetMark, ApplyFunding and
SetFundingSchedule commands — all journaled, so mark, rate, calendar and open
interest all replay. Open interest is published with the mark, not on every
fill: a per-trade open-interest message would multiply the feed's rate for a
number consumers read at mark cadence.
The funding calendar is state, not a formula¶
nextFundingNs is engine state, not a computation over
(now, SymbolConfig::fundingIntervalNs). Deriving it from startup config would
drift from reality the moment an operator changes the interval or shifts a
settlement:
SetFundingSchedule{symbol, intervalNs, nextFundingNs}— a sequenced, journaled command (control-plane verb of the same name). The operator owns the calendar; the engine holds it.ApplyFundingadvances it by one whole interval, and by further whole intervals if the settlement ran late enough that one step would still leave the boundary in the past (an operator catching up after an outage does not leave a stale boundary in the feed).- It is hashed and checkpointed (
RestoreFunding), so a restored engine publishes the boundary the venue will actually settle on. - With no schedule set the historical derivation from
SymbolConfig::fundingIntervalNsstill applies, unchanged — an engine that never learned a schedule behaves exactly as it did before the command existed. A non-positive interval or boundary clears the schedule back to it.
The engine still settles funding only when told to (ApplyFunding). This is
the calendar it publishes and advances, not a timer that fires payments.
The funding rate likewise survives a checkpoint now (same RestoreFunding
record), so an engine restored from a snapshot publishes the real rate instead
of 0 until the next ApplyFunding.
One honest limit remains: on an instrument the engine has never been given a
mark for, no DerivativesUpdate is published at all — the feed says nothing
rather than publishing a zero mark. A schedule set before the first mark is in
force but silent until that mark arrives.
On FIX the layer travels as a 35=X with two standard MDEntries —
269=6 SettlementPrice for the mark and 269=C OpenInterest — plus the
funding rate and next funding time in the user-defined tag range, because FIX
4.4 predates the instrument and has no field for either:
| Tag | Field |
|---|---|
5001 |
funding rate (exact decimal, same printing path as prices) |
5002 |
next funding time (UTCTimestamp) |
5003 |
volatility-pause deadline on 35=f |
Sequencing, restarts and epochs¶
Sequence numbers are deliberately not persisted across restarts. The book is
rebuildable from matching state, so a restarted publisher mints a new epoch
and starts over at seq=1 instead of carrying seq durability it does not
need. A consumer that sees the epoch change knows every prior seq is void and
re-snapshots; without the epoch, a restarted feed would look like an endless
stream of duplicates to a surviving consumer (a live but empty feed).
Gap detection (client side)¶
GapDetector (resend_buffer.h) is the client-side sequencer. Fed the raw
(possibly reordered, duplicated, gapped) datagram stream, it delivers messages
strictly in seq order per (symbol, epoch):
- A message ahead of the next expected seq is held, not delivered; when the
missing seq arrives — a late, reordered datagram, not a duplicate — the
contiguous run drains in order. If held messages exceed
maxHeld, the gap is abandoned and the held run is delivered in seq order rather than lost. onGap(symbol, epoch, fromSeq)fires when a gap opens and re-fires everyreraiseAfterfurther messages while it stays unfilled, so a lost resend request does not strand the consumer.- An epoch change drops held state, resets the stream to seq 1 and fires
onEpochso the consumer can re-snapshot. reset()fast-forwards a stream after a snapshot was applied.
UdpMdSubscriber::setGapDetector wires this into recv(): with a detector
attached, recv() only surfaces in-order messages; without one it is the raw
decode path, unchanged.
Snapshot and recovery (TCP)¶
MarketDataPublisher::snapshotAtomic() returns a consistent
(orders, lastSeq, epoch) triple — taken under the same lock as the live
emit path, so no increment with seq <= lastSeq is missing from the snapshot
and none with seq > lastSeq is included. The publisher also keeps a bounded
ring of recent increments (resendCapacity, default 1024) for tail replay.
MdRecoveryServer (md_recovery.h) serves both over TCP with
length-prefixed frames, one request per connection:
- client sends
ResendRequest{symbol, fromSeq}(fromSeq=0= late joiner); - if
fromSeqis inside the ring, the server replays the increments withseq >= fromSeqand closes (EOF terminates the replay); - otherwise it answers
SnapshotRequiredfollowed bySnapshotBegin+ the instrument's current state +AddOrderbody messages (seq=0, epoch set) +SnapshotEnd{lastSeq}; the consumer applies the body and resumes the incremental feed atlastSeq+1.
What a snapshot carries¶
A book alone is not a startable state: a halted, paused or pre-open instrument
looks exactly like a quiet one, and a perp position cannot be valued without a
mark. So a snapshot carries the current TradingStatus and
DerivativesUpdate (whichever the publisher has published) as seq=0 body
records placed between SnapshotBegin and the orders, so a subscriber
knows the instrument is halted before it applies a single order.
SnapshotBegin::orderCount still counts AddOrder messages only, and
MdRecoveryClient::Result keeps them out of messages (the book body) in
hasStatus/status and hasDerivatives/derivatives. On a resend the
same two message types are ordinary sequenced increments and stay in the
stream — diverting them there would punch a hole in the seq run.
RecoveringMdSubscriber::onSnapshotState is the optional hook for consumers
that track halts or value positions; a consumer that only rebuilds a book
ignores it.
On FIX the snapshot leads with 35=f SecurityStatus and the derivatives
35=X before the 35=W full refresh, whose entries each carry 273
MDEntryTime.
MdRecoveryClient (same header) is the consumer-side counterpart: one
recover(symbol, fromSeq, out) call opens the connection, sends the request
and returns the classified reply — snapshot flag, the replayed increments or
the snapshot body, and the lastSeq/epoch resume point (feed
resetSequencer(symbol, epoch, lastSeq + 1) to the GapDetector and continue
the incremental feed). The socket carries a receive timeout; an unreachable
server is a ConnectError status, a stalled reply a Timeout, and a
truncated snapshot a ProtocolError — never an exception. The low-level
protocol cases in test_venue_md_recovery drive it directly (they are the
protocol's documentation); the late-joiner and publisher-restart cases run
through RecoveringMdSubscriber below.
MdCounters tracks the paths: resendServed, snapshotsServed, and
sendDrops (below).
Exposing the recovery channel¶
MdRecoveryServer::start(port, bindIp) binds loopback by default
(bindIp = nullptr, the historical behaviour). Passing an explicit address
("0.0.0.0", a NIC address) exposes the channel to other hosts, and an
unparseable address fails the start rather than silently falling back to
loopback.
The hardening contract is the same as for the order-entry gateways: the recovery protocol carries no authentication and no encryption of its own, so anything reachable off-host must sit behind TLS termination and/or a firewall / private network, exactly like the control plane. Binding externally is a deployment decision, not a default.
Automatic client recovery (RecoveringMdSubscriber)¶
GapDetector reports; MdRecoveryClient fetches; RecoveringMdSubscriber
(md_recovery.h) is the two composed into the whole consumer protocol, so a
subscriber implements neither. It wraps a UdpMdSubscriber (which must NOT
have its own detector attached — this class owns sequencing) and keeps a
recv()-style API: only in-order messages surface, recovery is invisible
except through the onSnapshot callback and the counters.
UdpMdSubscriber sub;
sub.join(group, port, /*multicast*/ true);
RecoveringMdSubscriber::Config cfg{host, recoveryPort, /*maxGapBeforeSnapshot*/ 256};
RecoveringMdSubscriber rsub(sub, cfg);
rsub.onSnapshot([&](SymbolId sym, const MdSnapshotBegin& b, const std::vector<MdMessage>& orders)
{ book.clear(); for (const auto& m : orders) book.apply(m); });
MdMessage m;
while (rsub.recv(m)) book.apply(m); // strictly in seq order, holes already recovered
- Gap →
ResendRequest{fromSeq}; the replayed increments are woven back through the detector, so they fill the hole in seq order and drain whatever was held behind it. - Epoch change (publisher restart) or a gap deeper than
maxGapBeforeSnapshot→ snapshot recovery (fromSeq = 0): the body goes toonSnapshot, the sequencer fast-forwards tolastSeq + 1and the incremental feed continues. A deep gap takes this path because the server would route it there anyway once the ring has trimmed. - Failure → bounded retries with doubling backoff (
maxAttempts,initialBackoff), never an eternal loop. On exhaustion the incident is counted inrecoveriesFailed()and the stream falls back to plain gap semantics — the detector re-raises on further traffic and recovery re-arms (a test drives exactly that: server down → bounded failure → cleanrecv()timeout → server back → the re-raised gap recovers through the ring). - Counters:
gapsDetected,epochChanges,resendsRecovered,snapshotsRecovered,recoveriesFailed.
Single-threaded like the subscriber itself: all recovery work happens inside
recv() on the calling thread (the backoff sleeps block the caller, as any
synchronous recovery would).
Choosing a transport¶
Two transports carry the same feed. They differ in reach, not in content: a
subscriber on either sees the same messages with the same seq under the same
epoch.
Multicast (udp_multicast.h) |
Unicast TCP (md_distribution.h) |
|
|---|---|---|
| Reach | one layer-2 segment | anywhere the venue is reachable |
| Fan-out cost | one datagram per message, any number of consumers | one encode + one write per subscriber |
| Delivery | best effort; holes are the consumer's to recover | in order, on a reliable stream |
| Snapshot / resend | separate channel (MdRecoveryServer) |
inline on the same connection |
| Encodings | SBE | SBE, FIX, or another registered one |
Pick multicast when the consumer is colocated — inside the same segment, on the data NIC. It is the lowest-latency, lowest-cost fan-out there is, and the venue does the same work for one consumer as for a hundred.
Pick unicast TCP for everybody else. Multicast does not survive a WAN hop, most cloud networks do not route it, and a consumer behind NAT cannot join a group. That is not a tuning problem — it is the reason an external consumer has no multicast option at all.
Running both is the normal arrangement: fan the publisher's sink into the multicast publisher and the distribution server together.
MdDistributionServer dist(&counters);
MarketDataPublisher<> md([&](const MdMessage& m)
{
udpPub.publish(m); // colocated consumers
dist.publish(m); // everybody else
},
/*tickSize*/ Price::fromDouble(0.01), /*symbol*/ 1);
dist.addPublisher(md);
dist.start(/*port*/ 9010, /*bindIp*/ "0.0.0.0");
Unicast distribution (MdDistributionServer)¶
A long-lived TCP session per subscriber. Nothing about sequencing is
reinvented: MarketDataPublisher stays the source of truth for seq, the
epoch, snapshotAtomic() and the resend ring, and the server is a fan-out
in front of it.
Verbs (SBE templates 11/12 and the existing 7; the FIX equivalents are below):
Subscribe{symbol, fromSeq, snapshotOnly}— start streaming.fromSeq = 0asks for the atomic snapshot followed by increments;fromSeq > 0resumes at that seq.snapshotOnlyserves one book and leaves no subscription.Unsubscribe{symbol}— stop that symbol; the others keep flowing. One connection carries any number of symbols.ResendRequest{symbol, fromSeq}— the same verb the recovery channel uses, answered inline on this connection.
Subscribing mid-stream. A snapshot taken while the feed is moving must
neither lose an increment nor let one jump ahead of the book it belongs to.
The symbol is registered unarmed first, so every increment from that instant
is encoded and parked in a bounded buffer; the snapshot is then taken outside
the session lock; finally the snapshot frames are queued, the parked
increments past lastSeq are queued behind them, and the subscription arms at
lastSeq + 1. A test drives exactly that race and checks the joiner's book
against a consumer that watched from the first message.
Resend. fromSeq inside the ring is replayed from it
(MdCounters::resendServed). fromSeq older than the ring's tail gets the
explicit SnapshotRequired followed by a full snapshot
(MdCounters::snapshotsServed) — the same two-way answer the recovery channel
gives, without a second connection. Replayed increments may arrive behind
newer live ones; a consumer sequences with GapDetector exactly as on the
multicast path.
Slow consumers. Each subscriber has a bounded outbound queue drained by
its own writer thread, the same shape as the order-entry session writer. The
publishing thread encodes and enqueues; it never waits on a socket. A full
queue is the verdict: the connection is shut down, its frames are dropped and
MdCounters::slowConsumerDisconnects is bumped. One wedged subscriber cannot
delay another and cannot delay the engine. There is deliberately no send
timeout on the socket — a blocked writer thread is fine, and a socket timer
would misreport a slow reader as a broken connection. sendBufferBytes bounds
SO_SNDBUF so the kernel does not hide backlog the queue is meant to see.
Conflation is not the v1 policy; the bounded queue and the disconnect are.
Liveness. No inbound byte within idleTimeoutMs drops the subscriber
(MdCounters::idleDisconnects), the same policy the order-entry gateways
hold. The server sends its own beat every heartbeatMs when the connection is
quiet — an empty frame on the binary encoding, 35=0 on FIX. A subscriber is
expected to beat back.
Publisher restart. A message whose epoch differs from the
subscription's resets the delivery position, so a feed that restarted at
seq = 1 is not filtered out as stale by a subscription still expecting
seq = 500. The subscriber sees the epoch change and re-subscribes.
Counters. subscribers (live sessions), slowConsumerDisconnects,
idleDisconnects, subscribeRejects, plus the shared resendServed /
snapshotsServed.
Exposure. start(port, bindIp) binds loopback unless given an explicit
address. The protocol carries no authentication and no encryption of its own,
so anything reachable off-host belongs behind TLS termination and/or a
firewall — the same contract as the recovery channel and the order-entry
gateways.
Pluggable encodings¶
Transport and encoding are independent axes. MdEncoder (md_encoder.h) owns
the bytes and knows nothing about subscriptions, snapshots or sequencing; the
distribution server owns those and knows nothing about the bytes. Framing
belongs to the encoder — every method appends complete, self-framed wire bytes
that the session writes verbatim, which is what lets one port carry a
length-prefixed binary encoding and a self-delimiting text one. Adding an
encoding is writing one MdEncoder and registering it; no distribution logic
changes.
The encoding is chosen per connection from its first byte, peeked and not consumed, so both protocols still start at their own first byte and an off-the-shelf implementation of either connects unmodified:
| First byte | Encoding | Why it is unambiguous |
|---|---|---|
0x00 |
SBE | the high byte of the u32 frame length; feed frames are tens of bytes |
'8' |
FIX | the first byte of the BeginString 8=FIX.4.4 |
registerEncoding(leadByte, factory) adds a third.
MdDistributionConfig::encoding pins a port to one encoding instead, for a
deployment that prefers a port per protocol. One encoder instance lives per
connection, so an encoder may hold session state; every call on it is
serialized by the session.
FIX market data¶
fix_md_codec.h. The framing, the checksum, the exact decimal printing and
the UTCTimestamp all come from the order-entry codec unchanged — prices and
sizes go through decwire, never through a double, so 100.25 prints as
100.25.
It is a separate header because market data is a different message family:
order entry is flat tag=value and FixCodec::parseFields deliberately
collapses repeats into a map, while market data is built on repeating groups
(146, 267, 268) where the repeats are the payload.
Inbound — MarketDataRequest (35=V)
| Tag | Field | Handling |
|---|---|---|
| 262 | MDReqID | required; echoed on every W/X/Y |
| 263 | SubscriptionRequestType | 0 snapshot, 1 snapshot + updates, 2 unsubscribe; anything else → 35=Y, 281=4 |
| 264 | MarketDepth | 0 or absent = full book; a positive depth → 35=Y, 281=5 |
| 267 / 269 | MDEntryTypes | 0 bid, 1 offer, 2 trade; omitted = all three; unknown → 35=Y, 281=8 |
| 146 / 55 | Symbols | one subscription per symbol; none → 35=Y, 281=0 |
Outbound
- 35=W MarketDataSnapshotFullRefresh — 262, 55, 268
NoMDEntries, then per entry 269MDEntryType, 270MDEntryPx, 271MDEntrySize, 273MDEntryTime(the engine time the order was accepted at), 278MDEntryID. The feed is order-level, so every entry carries its order id. The current35=fand the derivatives35=Xare sent ahead of it. - 35=X MarketDataIncrementalRefresh — 262, 60
TransactTime, 268, then 279MDUpdateAction(0New,1Change,2Delete), 269, 55, 270, 271, 273, 278.AddOrderand a trade print map to New,Canceland a fully consumed order to Delete,Replaceand a partial fill to Change. A stop trigger has no market-data entry and is not sent. The derivatives layer uses the same message with two entries (269=6mark,269=Copen interest) plus tags 5001/5002. - 35=f SecurityStatus — 324, 55, 326
SecurityTradingStatus, 60, 58, and 5003 for a volatility-pause deadline. Sent on every trading-status transition regardless of theMDEntryTypefilter. - 35=Y MarketDataRequestReject — 262, 281
MDReqRejReason, 58.
Session layer. Deliberately a light one — Logon (35=A), Heartbeat (35=0),
TestRequest (35=1), Logout (35=5) and a monotonic outbound MsgSeqNum — not
FixSessionHost/FixConnection. That session layer is built around order
entry: accounts, the SessionRegistry exec-report log, application-level
resend with PossDup. None of it has a meaning for a broadcast feed. Market
data is not account-scoped, and a consumer recovers from a hole with the
feed's own tools (a fresh MarketDataRequest, or a seq-based resend on the
binary encoding), never by replaying a per-account message log. FIX market
data has no seq-based resend at all, so a re-request is answered with a fresh
full refresh. A message before the Logon is answered with a Logout; an
unsupported MsgType with a session Reject (35=3) rather than silence.
Icebergs and the public feed¶
An iceberg shows a peak and hides the rest. The public feed must only ever see the peak, or the book can be probed for hidden size by watching what the feed reports.
That is why OrderExecuted carries two quantities:
leavesQty: the whole remaining (peak + hidden), for the owner's execution report. You should see your own order in full.displayLeaves: the displayed peak, for the public feed.
The publisher drives depth from displayLeaves. Using the whole remaining
would leak the reserve and, on a partial peak fill, corrupt the level
aggregate.
Depth is the aggregate, kept in step¶
The publisher maintains a flox::NLevelOrderBook, the same aggregate book
type strategies consume elsewhere in FLOX, so market-data depth and the
venue's internal order-level book stay in agreement. A test drives 300k
commands and periodically compares the publisher's full depth against the
engine's book.
SBE codec and multicast¶
sbe_md_codec.h encodes MdMessage in SBE (Simple Binary Encoding): a
canonical SBE message header (blockLength, templateId, schemaId,
version) followed by a per-type little-endian root block. One template per
MdType, each carrying only the fields that type needs, plus the recovery
templates (ResendRequest, SnapshotBegin, SnapshotEnd,
SnapshotRequired) and the distribution verbs (Subscribe, Unsubscribe,
SubscribeReject), the instrument-wide messages (TradingStatus,
DerivativesUpdate). Schema version 1 appended a trailing epoch (u64) to
every incremental template — the SBE-sanctioned extension path: a version-0
reader skips it via the header's blockLength, and the decoder accepts v0
frames (epoch reads as 0). Version 2 added the three distribution templates
and nothing else: no existing root block changed, so a version-1 reader
decodes every incremental and recovery message byte for byte as before.
Version 3 added the two instrument-wide templates and, on every incremental
template, the two trailing timestamps (engineTs, sendTs) at the end of the
root block — again the same path, so a version-1/2 reader decodes every field
it knows out of a version-3 frame unchanged and skips the rest. Template ids
1..6 mirror the order-level MdTypes (templateId = MdType + 1); the two new
ones are 14 and 15 and are mapped explicitly, since 7..13 were already taken by
the recovery and session verbs. Enumerations extend the same way fields do —
new values are appended (TradingStatus::Closed, StatusReason::Session), so
no root block changes and an older reader decodes every field of the message,
seeing only a status code it does not recognise. A consumer must treat an
unknown TradingStatus as not tradeable. The schema in venue/schema/md-sbe.xml is the
source of truth; the codec is hand-written against it (no Java codegen in the
build). This is the venue's own SBE schema, not Nasdaq ITCH.
UdpMdPublisher (udp_multicast.h) publishes frames over IP multicast, with
UdpMdSubscriber on the receiving end. Both take an optional interface selector
(ifaceIp): the default (nullptr) lets the kernel routing table pick the
egress NIC -- the production default so the feed reaches co-located clients on
other hosts; pass "127.0.0.1" to pin loopback for same-host use.
The publisher socket is non-blocking and publish() checks the sendto
result: a datagram that cannot be handed to the kernel (full socket buffer,
closed fd) is a counted drop (MdCounters::sendDrops), never a stall of the
matching thread and never a silent loss the venue cannot see. Consumers
recover the hole through gap detection and the recovery channel.
std::vector<uint8_t> buf;
SbeMdCodec::encode(msg, buf); // header + template block
MdMessage out;
const bool ok = SbeMdCodec::decode(buf.data(), buf.size(), out); // false if short/foreign
The decoder validates the SBE header (rejects a foreign schemaId), uses
blockLength to skip trailing fields a newer schema version appended (forward
compatible), and bounds-checks before reading. The parser fuzz drives it with
hostile input alongside the FIX / SBE order-entry / REST parsers.
Tape¶
tape_recorder.h records trade prints only: it filters Trade events out of
the outbound stream and persists them through BinaryLogWriter, the same
binary format the rest of FLOX reads. The trade tape a venue produces can be
replayed by ordinary FLOX tooling; the non-trade events (adds, cancels,
replaces) are not on the tape.