WEEK 11 · 15-WEEK TRACK · SESSION 8 · 9-SESSION TRACK

Network Protocols & Market Data

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.

Lab: 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
Part 1

1What is it? Three delivery models, three failure modes

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.

Delivery model

What this model gives you

Delivery
—
Ordering
—
Fan-out
—
Retransmit
—

Under this loss rate

p50 one-way
—
p99.9 one-way
—
Venue egress
—
Gaps you must handle
—

Head-of-line blocking

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.

A / B feeds

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 %.

Snapshot channels

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.

Part 2

2How does it work? Bytes, fields, checksums, gaps

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.

Same message, three encodings

EncodingBytesDecodeAllocationsWhat decode means

Selected encoding

—

—

Per 1 M messages

Wire volume
—
CPU time
—
vs ITCH binary
—
10 GbE ceiling
—

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.

a · Step through a FIX message

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.

FIX 4.4 NewOrderSingle · | shown for SOH (0x01)

Decoded NewOrder

—

Validation

Fields seen
—
Bytes scanned
—
Checksum (tag 10)
—
Required tags
—
—
// 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.

b · Endianness and fixed-width integers

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.

One field, two byte orders

Big-endian — as it appears on the wire

Little-endian — as your CPU wants it

—

The ITCH-style message this field lives in

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);
Prices are scaled integers, never floats. 185.50 goes on the wire as 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.

c · Sequence numbers, gap detection and recovery

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.

Incoming stream · seq 1001 onward

LIVE

Feed handler state

Next expected
1001
Applied
0
Held in buffer
0
Book is
TRADEABLE

Recovery

—

—

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.

Part 3

3Why it matters: the arena's wire, and whose clock you trust

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.

The arena feed you actually receive

—
JSON bytes
—
Equivalent binary
—
Overhead
—
Fields you trade on
—

What a full DOM parse buys you

Decode cost
—
Heap allocations
—
Bytes copied
—
Fields you needed
—

The framing you inherit

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.

What to take from this. The arena's wire is not a flaw to be complained about; it is the realistic case. Most of the feeds you will meet outside a colocated cage are JSON or protobuf over TCP, and the skill that pays is knowing which fields you actually trade on and refusing to pay for the rest. Next session you replace the DOM with a targeted extract and measure the win on a replay tape.

Timestamps and whose clock you trust

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.

Venue clock vs receiver clock

What your log will say

—
measured one-way latency
True latency
—
Error
—
Relative error
—
Sync accuracy
—
—

The one measurement that never lies

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.

Part 4

4Five ways this goes wrong

Four of these five are silent. Open them.

One recv() is treated as one message

The 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.

The gap is detected, and the increments keep being applied

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.

The binary struct is cast without a bounds check

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.

The byte swap is forgotten, and the price is astronomical

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.

Latency is measured across two unsynchronised clocks

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.

Part 5

5Check yourself

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?

Part 6

6Do the work

In the lab (~45–60 min)

  • Finish parse_new_order in include/fix_parser.hpp until make fix prints RESULT|fix_parse|pass. Record your fix_parse_ns_per_op.
  • Keep clordid a view — pointer plus length into the caller's buffer. No std::string, no std::map, no allocation anywhere in the function.
  • Make malformed input return false: a tag with no =, a non-digit in tag 38. Trace why the scan loop already does this.
  • Run the TCP-versus-UDP demo on localhost. Send two messages in one write() over TCP and confirm your framing survives; drop a datagram over UDP and confirm your gap detector fires.

After the session

  • Stretch: replace strtod for tag 44 with a hand-rolled fixed-point parse (integer part, ., fraction). Re-measure ns/op.
  • Stretch: branch-light tag dispatch. Most tags are two digits — read the tag as you scan rather than building a general integer.
  • Write down, for every 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.
  • Turn in a FIX parser or a fixed-width binary parser that frames a byte stream and extracts fields, with the ns/op number. Due at the start of the next session.
Get the code
15-week track starter
Header stubs, the autograder and the C++ arena client, one topic per session.
Get the code
9-session track starter
The same stubs and the same grader, packaged for the condensed nine-session schedule.
Back
← Week 10 · Concurrency II — Lock-Free Pipelines
The ring that sits between the socket thread and the strategy thread.
Next
Week 12 · Async I/O & Serialization →
You know the formats. Now make the codec and the socket loop fast.