Last week you built the pipeline inside your process and treated “a tick arrived” as given. Something put those bytes on your socket first, and that something made three decisions you now have to understand: which transport carries the feed, what encoding the fields are in, and how you find out that you missed a message. Get the third one wrong and your book is silently, profitably wrong — for someone else.
tag=value message and a JSON message of the same order, and state the bytes and the nanoseconds for each.be64toh is for.include/fix_parser.hpp — tags 11 / 55 / 54 / 38 / 44
Target: tens of ns per message, not hundreds
Your feed is a stream of bytes, not a stream of messages
A market-data feed and an order session want opposite things. The feed wants the newest bytes at the lowest latency to the largest number of subscribers, and it can survive losing one. The order session must never lose a message and only ever talks to one counterparty. That is the whole reason real venues ship market data over UDP multicast and order entry over TCP. Pick a model and turn up the loss.
TCP guarantees in-order delivery, which means a single lost segment stalls every byte behind it until the retransmission arrives — one RTT later. On a firehose feed that is a hole in your book plus a burst of stale updates when the dam breaks. The guarantee you wanted for orders is the pathology you cannot afford for data.
Venues publish two identical multicast streams over physically separate paths. You subscribe to both, key on the sequence number, and take whichever copy arrives first — arbitration. Uncorrelated loss on the two paths means a gap needs both to drop the same packet, which turns a 0.1 % loss rate into roughly 0.0001 %.
Because multicast has no retransmit, every venue also publishes a periodic full snapshot — often on a separate group, often over TCP on request. That is your recovery path when arbitration fails, and the reason a feed handler is always two state machines: apply increments, or rebuild from a snapshot.
One order — buy 100 AAPL at 185.50 — in three encodings. Same information, wildly different cost to put on a wire and take off it. Race them.
| Encoding | Bytes | Decode | Allocations | What decode means |
|---|
—
Click a bar to inspect that encoding. The decode numbers are for extracting all five fields into a struct on one ~3 GHz core: a pointer cast plus two byte-swaps for binary, a single forward scan with hand-rolled digit maths for FIX, and a general DOM build for JSON. Nothing here is a library benchmark — it is the shape of the work each encoding forces on you.
FIX is tag=value pairs, each terminated by the SOH byte 0x01. It is
self-describing and human-readable, and both of those properties cost you nanoseconds: you must scan
for =, scan for SOH, and convert ASCII digits to numbers on every single field. Edit the
message and step it.
| shown for SOH (0x01)NewOrder—
// include/fix_parser.hpp — one forward pass, no allocation while (p < end) { int tag = 0; // digits up to '=' while (p < end && *p != '=') { if (*p < '0' || *p > '9') return false; tag = tag * 10 + (*p++ - '0'); } if (p >= end) return false; // no '=' ++p; // consume '=' const char* v = p; // value slice while (p < end && *p != SOH) ++p; const int vlen = static_cast<int>(p - v); if (p < end) ++p; // consume SOH // switch (tag) { case 11: … case 44: … } }
Two details carry the whole exercise. First, clordid in the starter's
NewOrder struct is a pointer plus a length into the caller's
buffer — a view, not a copy. That is why the struct hands you a clordid_len, and it
is the difference between tens of nanoseconds and hundreds.
Second, std::strtod for tag 44 is safe here because it stops at the
first non-numeric byte, which is the SOH — and it does not allocate. Replacing it with a
hand-rolled fixed-point parse is the stretch goal, and it is worth roughly a third of your ns/op.
The grader parses the same message 2,000,000 times and prints
METRIC|fix_parse_ns_per_op. A tight single-pass parser lands in the tens of
nanoseconds. If you see hundreds, something is allocating or copying — usually
a std::string you did not mean to construct.
Binary protocols put the fields at known byte offsets, which only works if everyone agrees on
which bytes. Network byte order is big-endian; every x86-64 and every ARM64 you will run on is
little-endian. So a binary feed decode is a cast plus a byte swap per multi-byte field
— one bswap instruction, about a cycle, and completely non-optional.
18 bytes, #pragma pack(push, 1) so the
compiler inserts no padding, and every field at a fixed offset. Green is
order_id, blue is qty, amber is price.
#pragma pack(push, 1) struct AddOrder { // ITCH-style, 18 bytes std::uint8_t msg_type; // 'A' off 0 std::uint64_t order_id; // big-endian off 1 std::uint8_t side; // 'B' / 'S' off 9 std::uint32_t qty; // off 10 std::uint32_t price; // scaled ×1e4 off 14 }; #pragma pack(pop) static_assert(sizeof(AddOrder) == 18);
1855000 with an implied divisor of 104. You get exact arithmetic, exact
equality, a 4-byte field instead of 8, and no rounding surprise when you compare your price to the
venue's. Every real feed does this, and so should your internal book.Multicast has no retransmit, so the sequence number is the only thing that tells you a message existed. The feed handler keeps one integer — the next expected sequence — and every arriving message either matches it, is a duplicate, or proves a gap. Step the stream.
—
—
The discipline that matters: a gap makes the book untradeable until recovery completes. Not degraded, not probably-fine — untradeable. A feed handler that keeps applying increments across a hole produces a book that diverges silently from the venue's and looks perfectly healthy in every log you have.
The arena speaks JSON over WebSocket — a discriminated union keyed on
a type field, defined in shared/messages.py. It is convenient, it is what
the Python bots speak, and it is the opposite of everything a real venue does. Knowing exactly what
it costs you is the point of this section, because next session you will cut that cost.
—
WebSocket rides one TCP connection and adds its own frame header (2–14 bytes, plus a 4-byte mask from the client). So you get message boundaries for free — but underneath sits TCP, which means ordering, retransmission and head-of-line blocking. The arena's feed cannot lose a message, and it also cannot skip one that is late.
Every message you receive carries a venue timestamp, and you will stamp your own on arrival. The difference is what you call latency — which means your latency measurement is only as good as the agreement between two clocks. Drag the skew and watch the measurement lie.
A round trip measured on a single clock. Stamp
steady_clock when you send, stamp it again when the ack returns, and no
cross-machine synchronisation is involved at all. That is why the course grades tick-to-trade
from your own process — and why the offline replay harness is deterministic. Cross-machine
one-way numbers are for capacity planning, not for grading.
Four of these five are silent. Open them.
recv() is treated as one messageThe single most common networking bug there is. TCP is a byte stream: it does not preserve the
boundaries the sender wrote. A recv() can hand you half a message, or two and a half,
or one message split across four calls — and on a quiet localhost test it will hand you exactly one
message every time, so your code passes.
Then you deploy, the rate climbs, Nagle coalesces two writes into one segment, and your parser reads a tag out of the middle of the next message and returns nonsense that looks like data.
Fix: accumulate into a reusable buffer, frame from it, dispatch complete messages only, and compact the partial tail forward. FIX gives you tag 9 (body length); binary feeds give you a length prefix. Test it by deliberately feeding your parser one byte at a time.
You wrote the gap detector — if (seq != expected_) ++gaps_; — and then carried on
applying the message anyway. Now your book has a hole: some price level was added and never
removed, or a size was decremented from a value you never saw. Nothing crashes. The book stays
plausible, and your quotes are priced against a level the venue deleted forty seconds ago.
This is the failure that costs real money, because you are not wrong about the market — you are wrong about the book, in a direction that makes your stale quote look attractive to exactly the counterparty who can see the truth.
Fix: a gap is a state transition, not a counter. Move to
RECOVERING, stop quoting the affected symbol, buffer the arriving increments, request
a snapshot, replay the buffer from the snapshot's sequence, and only then return to
LIVE. Log every transition with both sequence numbers.
auto* m = reinterpret_cast<const AddOrder*>(p); is 18 bytes of read from a
buffer that you have not confirmed holds 18 bytes. If a partial frame arrived, or a malformed
length prefix claims 18 when 4 are present, you have just read past your buffer — an information
leak on a good day and a segfault on a better one.
The related trap is trusting the length prefix itself. A field that says 0xFFFFFFFF
is a 4-gigabyte memcpy if you believe it.
Fix: if (n < sizeof(AddOrder)) return; before the cast, and clamp
every length against both the frame remaining and a hard protocol maximum. Also
static_assert your sizeof — a forgotten
#pragma pack silently pads the struct to 24 bytes and shifts every offset.
Reading a big-endian uint32_t as little-endian does not produce a subtly wrong
number; it produces a wildly wrong one. 1855000 (0x001C_FD58) read without swapping is
1483833344. As a price scaled by 104 that is $148,383.33, which your risk
check will hopefully reject.
The dangerous case is a small field where the swap happens to be harmless — a single-byte
side that needs no swap, or a quantity of 256 that swaps to 65,536 and still looks
like a plausible order. Then you ship, and the first four-digit quantity breaks you.
Fix: swap at exactly one place — the decode function — never at the call site.
be64toh / be32toh on Linux, OSSwapBigToHostInt32 on macOS,
or std::byteswap in C++23. Then unit-test the decoder against a hard-coded byte array
with known field values.
You subtract the venue's timestamp from your own arrival timestamp and report the difference as one-way latency. With NTP over a WAN the two clocks may disagree by a millisecond, so your “45 µs” measurement has a ±1000 µs systematic error. The number is not noisy; it is meaningless.
The tell is a negative latency — a message that arrived before it was sent. If you have ever seen one in a log and clamped it to zero, you were clamping the evidence.
Fix: for anything you optimise against, measure a round trip on one clock
with steady_clock. For cross-machine numbers, know your sync accuracy and quote the
error bar with the measurement: PTP with hardware timestamping gets you into the tens of
nanoseconds, LAN NTP to a few hundred microseconds, WAN NTP to a few milliseconds.
Three questions, instant feedback, nothing recorded.
Q1Why is TCP a poor transport for a high-rate market-data feed, even though it never loses a message?
Q2Your feed handler expects sequence 1042 and receives 1045. What must happen next?
Q3Why does an ITCH-style binary message decode roughly an order of magnitude faster than the same order in FIX?
parse_new_order in include/fix_parser.hpp until
make fix prints RESULT|fix_parse|pass. Record your
fix_parse_ns_per_op.clordid a view — pointer plus length into the caller's buffer. No
std::string, no std::map, no allocation anywhere in the function.false: a tag with no =, a non-digit in
tag 38. Trace why the scan loop already does this.write() over
TCP and confirm your framing survives; drop a datagram over UDP and confirm your gap detector fires.strtod for tag 44 with a hand-rolled fixed-point parse (integer
part, ., fraction). Re-measure ns/op.shared/messages.py type your bot touches
(book_snapshot, place_order, order_ack), every field you
send and receive. That table is your input to the next session.