← Back to roadmap
Phase 4Week 46~110 min read
0%·0/4

Market Replay from Captures

From packets to books, exactly.

Mastery · Markets, Strategies & Getting Hired

Why this week

Sooner or later someone asks you: what did the book look like at 14:31:07.123456789, when our strategy sent that order? Logs can't answer it. Your strategy's own view can't answer it either, because that view is the thing under suspicion. The packets can. Serious trading firms record every feed and order-entry packet at the network edge, timestamp it in hardware, and build tools that turn those captures back into books. When the tools are deterministic, an argument about what happened becomes a test you can run.

This week you build that tool for your own exchange. You'll parse the classic pcap file format, peel Ethernet, IPv4 and UDP off each frame, put sequenced feed packets back in order (A/B arbitration, duplicates, gaps), rebuild books with the week 10 book builder and check them against the snapshots the publisher recorded. The skill transfers directly: replaying a Nasdaq ITCH capture or a CME MDP 3.0 capture is the same pipeline with a different decoder at the end.

💡
Tip

Treat replay as a pure function: capture bytes → sequence of book states. No wall clock, no thread timing, no hash-map iteration order. If two runs on two machines can disagree, you don't have a debugging tool, you have a second source of bugs.

Day 1 — Where captures come from

Day 01

Taps, mirrors and timestamps

A capture is only as good as where and when it was taken

There are three places to record a feed, and they give different answers:

  • Host capture. tcpdump on the trading server, using libpcap over AF_PACKET. It's cheap and needs nothing extra. It's also the weakest option. The kernel timestamps packets in software somewhere in the receive path, the capture ring can overflow under bursts (tcpdump reports "packets dropped by kernel" when it exits), and the capture competes with your application for CPU and cache. Use it on loopback in development. Don't build production evidence on it.
  • Switch mirror (SPAN). The switch copies a port's traffic to a capture port. It's convenient, but the mirror is best effort: an oversubscribed mirror port drops packets silently, and the switch may reorder traffic across mirrored ports.
  • Passive TAP plus a capture appliance. An optical tap splits the light, and an FPGA-based capture card timestamps each frame as it arrives, typically with nanosecond resolution and a PTP-disciplined clock. This is what firms use for compliance records and for measuring wire-to-wire latency.

The timestamp is the part people get wrong. A software timestamp includes interrupt coalescing, softirq scheduling and anything else the kernel did first, so tens of microseconds of jitter under load is normal. A hardware timestamp at the tap is taken before any of that. When you later compute "feed packet in → order packet out" from a capture, both timestamps come from the same clock at the same point in the network. That's why a capture is the most trustworthy tick-to-trade measurement you can get.

Docsby The Tcpdump Group
pcap-savefile(5) — libpcap savefile format

Short and complete: the 24-byte file header, the 16-byte per-packet header, the two magic numbers and the byte-order rule. Everything in Day 2's drill comes from this page.

Docs
Timestamping (Linux kernel documentation)

SO_TIMESTAMPING and the difference between software and hardware RX timestamps. Skim it for the vocabulary: you'll need it to explain why a host capture's timestamps aren't evidence.

Recall#tap-vs-host

Why does a capture from a passive tap with a hardware-timestamping capture card give better latency numbers than tcpdump on the trading host?

Design note#capture-placement

Draw where you would put capture points for your exchange plus one strategy client: feed out, feed in at the client, orders out, acks in. Which pairs of capture points give you which latency (publisher internal, network, client tick-to-trade)? Which of them can share a clock?

0 chars

Day 2 — The bytes, layer by layer

Day 02

pcap → Ethernet → IPv4 → UDP → feed

Two byte orders in one file

A classic pcap file is a 24-byte file header followed by records. Each record is a 16-byte header plus incl_len captured bytes:

file header (24 B)   magic u32 | ver_major u16 | ver_minor u16 | thiszone i32 | sigfigs u32 | snaplen u32 | linktype u32
record header (16 B) ts_sec u32 | ts_frac u32 | incl_len u32 | orig_len u32
record data          incl_len bytes (the start of the frame)

The magic tells you two things. 0xa1b2c3d4 means the fractional timestamp is in microseconds, and 0xa1b23c4d means nanoseconds. The header fields are in the writing host's byte order: if the magic reads back as 0xd4c3b2a1, every header field in the file is byte-swapped. The packet bytes inside a record are untouched, and network protocols are big-endian ("network byte order"). So a typical file written on x86 has little-endian pcap headers wrapped around big-endian protocol fields. Mixing the two up is the most common pcap bug.

incl_len < orig_len means the capture was cut at snaplen. That's fine if you only need headers, and fatal if you need the whole feed message, so a replay tool should count truncated records and refuse to build books from them. The newer pcapng format starts with a Section Header Block (0x0A0D0D0A) and isn't a classic pcap file; detect it and say so instead of reporting a corrupt file.

Inside each record (linktype 1, Ethernet) you peel headers off the frame in order:

// Ethernet II: dst MAC 6 | src MAC 6 | EtherType 2. All multi-byte
// protocol fields are big-endian.
fn udp_payload(frame: &[u8]) -> Option<(u16, &[u8])> {
    let mut off = 12;
    let mut ethertype = u16::from_be_bytes([frame[off], frame[off + 1]]);
    if ethertype == 0x8100 {                    // 802.1Q VLAN tag: 4 more bytes
        off += 4;
        ethertype = u16::from_be_bytes([frame[off], frame[off + 1]]);
    }
    if ethertype != 0x0800 { return None; }     // not IPv4
    let ip = &frame[off + 2..];
    let ihl = ((ip[0] & 0x0f) as usize) * 4;   // usually 20, never assume it
    if ip[9] != 17 { return None; }             // protocol 17 = UDP
    let udp = &ip[ihl..];
    let dst_port = u16::from_be_bytes([udp[2], udp[3]]);
    let udp_len = u16::from_be_bytes([udp[4], udp[5]]) as usize; // header + payload
    Some((dst_port, &udp[8..udp_len]))
}

(Production code checks every slice length before indexing. A capture is untrusted input, and a truncated record will turn each [] above into a panic.)

The UDP payload is the feed's own framing. Nasdaq's MoldUDP64, which carries ITCH, is a good model because many binary feeds use the same shape: a 20-byte header (10-byte session id, 8-byte sequence number of the first message, 2-byte message count), then each message as a 2-byte length followed by the message bytes. One packet carries messages seq .. seq + count − 1. A count of zero is a heartbeat that tells you the next expected sequence number, and 0xFFFF marks end of session. The repo's feed packet for this week (feed.rs, described in the capstone) has the same shape.

Docsby Nasdaq
Nasdaq TotalView-ITCH 5.0 specification

Read the message formats with your week 11 codec in mind: fixed-length, big-endian, 48-bit nanosecond timestamps since midnight, and a stock locate code so the hot path never touches a symbol string.

Docsby Nasdaq
MoldUDP64 Protocol Specification

Short. The packet header, message blocks, heartbeats, end of session and the re-request (retransmission) packet. Notice how little the transport promises: ordering and recovery are the client's job.

Recall#two-byte-orders

A pcap written on an x86 laptop contains MoldUDP64 packets. Which fields do you read little-endian and which big-endian, and how do you know?

Now write the record parser. It borrows the payload from the input buffer instead of copying it: a 10 GB capture is replayed by walking a memory map, not by allocating 100 million Vecs.

Drill

Parse a pcap record header

Target: 6/6 cases · payload borrowed, not copiedNot yet run
// libpcap savefile format (see pcap-savefile(5)).
//
// File header, 24 bytes:
//    0  magic          u32  0xa1b2c3d4 = µs timestamps, 0xa1b23c4d = ns timestamps
//    4  version_major  u16  (2)
//    6  version_minor  u16  (4)
//    8  thiszone       i32  (0 in practice)
//   12  sigfigs        u32  (0 in practice)
//   16  snaplen        u32  max bytes stored per packet
//   20  linktype       u32  1 = Ethernet
// The magic is written in the capturing host's byte order: read it as
// little-endian; 0xd4c3b2a1 / 0x4d3cb2a1 mean the whole file is big-endian.
//
// Record header, 16 bytes, in the file's byte order:
//    0  ts_sec    u32
//    4  ts_frac   u32  microseconds or nanoseconds, per the magic
//    8  incl_len  u32  bytes stored after this header
//   12  orig_len  u32  bytes that were on the wire

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Endian { Little, Big }

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileHeader {
    pub endian: Endian,
    pub nanos: bool,
    pub snaplen: u32,
    pub linktype: u32,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecordHeader {
    pub ts_ns: u64, // nanoseconds since the Unix epoch
    pub incl_len: u32,
    pub orig_len: u32,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PcapError {
    TooShort,  // fewer bytes than the header itself needs
    BadMagic,  // not a classic pcap file (pcapng starts with 0x0A0D0D0A)
    Truncated, // incl_len bytes should follow, but the buffer ends first
    Corrupt,   // incl_len > snaplen, or incl_len > orig_len
}

pub fn parse_file_header(buf: &[u8]) -> Result<FileHeader, PcapError> {
    // TODO: length check, magic → (endian, nanos), then snaplen and
    // linktype in the file's byte order.
    let _ = buf;
    todo!()
}

/// Parse the record that starts at buf[0]. Returns the header, the captured
/// bytes (a sub-slice of `buf`, no copy) and how far to advance (16 + incl_len).
/// Check Corrupt before Truncated: a garbage length usually also looks short.
pub fn parse_record<'a>(
    file: &FileHeader,
    buf: &'a [u8],
) -> Result<(RecordHeader, &'a [u8], usize), PcapError> {
    let _ = (file, buf);
    todo!()
}
Rust

Day 3 — Putting packets back in order

Day 03

Arbitration, reordering and gaps

UDP promises nothing; the sequence number promises everything

Exchanges publish every feed twice, on an A line and a B line: separate multicast groups, often over physically separate networks. A feed handler listens to both and keeps the first copy of each sequence number. This is A/B arbitration. It turns "a switch dropped a packet on A" into a non-event. It does nothing about a packet the publisher never sent or lost before the split, because then both lines miss the same sequence number.

When a sequence number is missing on both lines you have a gap, and three questions:

  1. How long do you wait? Packets on two paths can arrive out of order. Wait too briefly and you declare false gaps. Wait too long and your book is stale while you wait. Real handlers use a small time budget (often microseconds to low milliseconds, tuned per feed), a count limit, or both. The drill uses a count window because it's deterministic.
  2. How do you recover? Either ask for the missing range (MoldUDP64 re-request servers; CME MDP 3.0 offers TCP replay for small ranges) or discard the book and rebuild it from a snapshot (CME's market recovery feeds loop snapshots continuously). Small gaps go to retransmission. Large gaps, or gaps after a restart, go to snapshot.
  3. What do consumers see meanwhile? The book must be marked stale. Week 48 makes you prove this under random loss.

Turn reordering on with a window of 1 and count the gaps that were really just late packets. Then widen the reorder window until they stop costing snapshot waits, and watch what the window costs in held packets. Turn on the B line to see arbitration hide single-line loss.

Reorder window versus snapshot recoveryOpen full screen ↗

In a replay tool you have one extra advantage: if the capture contains both lines, arbitration recovers almost everything, and whatever is left is a real gap that any handler behind the same capture point must also have seen. Report it, with the sequence range and capture timestamp. That's often the bug you were looking for.

Recall#ab-limits

Your handler arbitrates A and B lines perfectly and still reports a gap. Name two causes, and what each implies for recovery.

Build the reorder buffer. It's the core of every feed handler: it accepts packets in any order, delivers them in sequence order exactly once, and gives up on a hole only when it has no room left to wait.

Drill

Reorder out-of-sequence packets

Target: 6/6 cases · in order, exactly onceNot yet run
use std::collections::BTreeMap;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Out {
    /// Deliver this packet to the book builder.
    Packet { seq: u64, payload: u32 },
    /// Sequence numbers from..=to are declared lost: recover them by
    /// retransmission or a snapshot before trusting the book.
    Gap { from: u64, to: u64 },
}

pub struct ReorderBuffer {
    next: u64,                   // next sequence number we may deliver
    window: u64,                 // buffer packets with seq < next + window (window >= 1)
    pending: BTreeMap<u64, u32>, // early packets waiting for the hole to fill
    duplicates: u64,
}

impl ReorderBuffer {
    pub fn new(first_seq: u64, window: u64) -> Self {
        Self { next: first_seq, window, pending: BTreeMap::new(), duplicates: 0 }
    }

    /// Rules, in order:
    ///  1. seq < next, or seq already buffered → duplicate: count it, return [].
    ///  2. seq >= next + window → stop waiting for the oldest holes: walk
    ///     s = next .. seq - window + 1, delivering buffered packets and
    ///     reporting each run of missing s as ONE Gap, in sequence order;
    ///     set next = seq - window + 1, then deliver any buffered run from
    ///     there. Then continue with rule 3 for this packet.
    ///  3. seq == next → deliver it, then every consecutive buffered packet.
    ///  4. otherwise → buffer it.
    pub fn push(&mut self, seq: u64, payload: u32) -> Vec<Out> {
        let _ = (seq, payload);
        todo!()
    }

    pub fn next_expected(&self) -> u64 { self.next }
    pub fn buffered(&self) -> usize { self.pending.len() }
    pub fn duplicates(&self) -> u64 { self.duplicates }
}
Rust
ℹ️
Note

A BTreeMap keeps the drill readable. A production handler uses a fixed ring of window slots indexed by seq % window, so a push is O(1) and never allocates. Rewrite it that way in the capstone and compare the cost per packet.

Day 4 — Deterministic replay

Day 04

Same bytes in, same books out

Every source of nondeterminism, named and removed

Week 9 made the engine a deterministic function of its input log. Replay extends that promise to the consumer side: the same capture must produce the same sequence of book states on every run and every machine. The usual ways to break it:

  • Wall-clock reads. Anything that calls Instant::now() or SystemTime::now() during replay (staleness timers, conflation intervals, timeouts) makes output depend on how fast the machine is. Drive every clock in the replay from the capture timestamps, using the injected clock from weeks 17 and 21.
  • Hash-map iteration order. std::collections::HashMap uses a randomly seeded RandomState, so iteration order differs between processes. If you ever emit levels, symbols or orders by iterating a HashMap, two replays disagree. Use BTreeMap, a Vec sorted by key, or a fixed hasher where iteration order matters.
  • Thread interleaving. If you shard replay across threads (by instrument, as in week 36), each shard is deterministic but the interleaving of their outputs isn't. Merge on (capture timestamp, sequence number), never on arrival order at a channel.
  • Floating point. Fixed-point prices in ticks are exact. A float average computed in a different order can differ in the last bit, and a state hash will catch it.

Verification is what makes replay trustworthy. The publisher already emits periodic snapshots (week 10). Record their state hash together with the sequence number they correspond to, and at that sequence number in replay compare your rebuilt book's hash against it. One mismatch tells you exactly which interval to bisect.

Replay runs in two modes. As fast as possible is for regression tests and backtests (week 44): the capture is just input and timestamps are just data. Paced (real time, or ×10) is for watching an incident unfold in a UI. Pacing belongs in a thin layer on top. The core replay must never sleep.

Recall#hashmap-replay

Your replay produces a different L2 snapshot text on every run even though the books hash identically. What's the most likely cause in Rust, and the fix?

Design note#replay-nondeterminism

Audit your marketdata and engine crates for nondeterminism: list every clock read, every HashMap iteration, every place thread scheduling could change an output. For each, say whether replay touches it and how you will remove it.

0 chars

Day 5 — Debugging an incident with a capture

Day 05

The replay is the bug report

From a complaint to a failing test in an hour

A typical incident: "At 14:31:07.1234 our strategy saw a best offer of 100.25 and sent an IOC buy at 100.25. Nothing filled. The exchange says that offer had been gone for 3 ms." Your workflow:

  1. Cut the window. editcap -A "<start>" -B "<stop>" day.pcap window.pcap keeps a few seconds around the event. Use mergecap if the A and B lines were captured on different ports. Keep enough history before the event to start from a snapshot.
  2. Replay to the moment. Start from the last snapshot before the window, replay incrementals up to the order's timestamp, and dump the book. Compare it with what the strategy logged. If they differ, the bug is in the strategy's feed handler (a missed gap, a wrong decode, a stale book used as live). If they match, the price moved between decision and arrival: compute the gap between the strategy's decision and its order reaching the wire, from the same capture.
  3. Bisect with hashes. If the replayed book itself looks wrong, compare state hashes against the recorded snapshots and bisect to the first divergent sequence number. Look at that message's bytes.
  4. Freeze it. Check the trimmed capture into the test fixtures with the assertion that would have caught it. From now on, this incident is a unit test.

Captures also settle latency arguments. With tap timestamps on the feed packet that triggered a decision and on the order packet that followed, order_ts − trigger_ts is the strategy's wire-to-wire tick-to-trade. No software clock on the host can be trusted for that.

Docsby Wireshark
editcap and mergecap manual pages

Time slicing (-A/-B), splitting by packet count or duration, removing duplicates, and converting pcapng to pcap. mergecap (linked from the same index) interleaves several captures by timestamp, which is what you need when A and B were recorded separately.

Bookby Larry Harris
Trading and Exchanges: Market Microstructure for Practitioners

Use it as a reference while you debug. The chapters on order books and market data explain what a 'correct' book means to the people who will question your replay, including why the touch you saw and the price you got can legitimately differ.

Design note#incident-runbook

Write the one-page runbook for 'our strategy traded on a price that wasn't there'. Which captures you need, which tool runs at each step, which outputs prove which hypothesis, and the exact command that turns the trimmed capture into a regression test.

0 chars

Day 6 — Plan the capstone

Day 06

Design before code

Where the nanoseconds per message go

Read the capstone below, then plan the pipeline as a chain of borrowed slices: &[u8] file → record → frame → UDP payload → feed packet → message. Nothing in that chain needs to allocate. The only state that grows is the book.

Design note#replay-pipeline

Sketch your replay pipeline stage by stage with the data type that crosses each boundary and who owns it. Where do you count truncated records, non-feed packets, duplicates and gaps? Which counters go in the replay summary?

0 chars
Recall#snaplen-meaning

A record has incl_len = 96 and orig_len = 1,242. What happened, and what should a replay tool do with it?

Capstone

Build replay in crates/marketdata as a library: pcap.rs (the reader), feed.rs (the feed's packet format), reorder.rs and replay.rs. The repo has no CLI; the w46_* tests and the benchmark drive the library directly. ReplayStats is the summary a small replay <capture.pcap> tool would print: records, malformed records, incremental and snapshot packets, updates applied, duplicates, updates lost, recoveries, and snapshots verified, mismatched or unverified. first_mismatch() gives the sequence number of the first bad snapshot.

The pipeline.

  • Records. PcapReader::new(&bytes) walks a capture held in memory (std::fs::read the file first) and borrows every record. It accepts both timestamp resolutions and both byte orders, and supports linktype 1 (Ethernet) only.
  • Frames. parse_udp strips Ethernet (handling an 802.1Q tag), IPv4 (respecting IHL, verifying the header checksum, refusing fragments and anything that isn't UDP) and UDP (verifying the RFC 768 checksum when it is set, so a flipped bit in a price is rejected rather than applied — udp_frame writes it), all zero-copy.
  • Feed packets. The UDP payload is one feed packet in the repo's own layout, defined in feed.rs this week (it is not week 11's wire codec). A 16-byte header (magic "MD", version, kind, two counts, the first sequence number) is followed by 17-byte updates or 16-byte snapshot levels. Like MoldUDP64, a packet carries count consecutive updates and only the first sequence number. Unlike the frame headers around it, it is little-endian. FeedPacket::decode is strict.
  • No group filter. Replayer does not filter on destination group and port. The packet's kind byte tells incrementals from snapshots, and anything that does not decode counts as malformed.
  • Reordering. It happens per update rather than per packet. Each update goes into a ReorderBuffer<L2Update>: a ring of window slots (rounded up to a power of two), whose push returns Accepted, Duplicate or Skipped { lost } and whose pop releases updates in order. A hole given up after a full window reaches the week 10 BookBuilder as a gap.
  • Arbitration. A capture holding both A and B lines needs no separate step: the second copy of each update is dropped as a duplicate.

Fixtures without root. Have your simulator write the capture directly: wrap each feed packet in a synthetic Ethernet/IPv4/UDP header and a pcap record with the simulated timestamp. Tests are then hermetic and deterministic. In the repo, the provided FeedRecorder does this. It batches consecutive updates into incremental packets (32 by default) sent to one multicast group, and writes each snapshot as its own packet to another. To check against real traffic, capture loopback with tcpdump -i lo -w feed.pcap udp port <port>. On Linux, lo captures are Ethernet-framed with zeroed MACs. On macOS, lo0 uses linktype 0 (a 4-byte BSD loopback header), and capturing on Linux's any pseudo-device gives a "cooked" header instead of Ethernet (linktype 113, or 276 for SLL2 on recent libpcap). Dispatch on the linktype instead of assuming Ethernet.

Verification. Record snapshot hashes alongside the capture (or carry the snapshot channel in the same capture) and compare at each snapshot's sequence number. The repo carries the snapshot channel in the same capture and compares the rebuilt book level by level rather than by hash. Each recorded snapshot either recovers the book (when the builder is out of sync, or the reorder buffer is waiting on a hole the snapshot covers) or is verified once the book reaches its sequence number.

Also inject faults into a copy of the fixture (drop a packet on one line, then on both lines, then truncate a record) and assert the summary reports exactly what you broke. The w46_* tests do this:

  • Reordered, duplicated and dropped packets still converge, and a capture that starts mid-feed recovers from its first snapshot.
  • A tampered snapshot is reported at its sequence number.
  • Truncated captures and damaged headers, records and frames are reported, and the book still converges.

Week 48 builds on that habit.

Thinking about the benchmark. The target is 20 million updates per second, a budget of 50 ns per update for everything: header parsing, reordering, decoding and the book update. The bench replays an in-memory capture of 1M updates, 32 per packet, with a snapshot to verify every 10,000, into a fresh Replayer (map book, 1,024-update reorder window). The book update should dominate. If parsing shows up in a profile, you're probably copying or allocating per packet. Batch-friendly choices matter here: deliver updates without building a fresh Vec per packet (the repo's push/pop pair returns them one at a time), and keep the reorder window as a ring. The bench already holds the capture in memory; do the same when you measure your own captures, and say so in the report, otherwise you're benchmarking your disk.

Capstone · in your exchange · crates/marketdata

Capture → books, bit for bit

Replay a pcap of your own feed into books that match every recorded snapshot.

cargo xtask test --week 46      # tests named w46_*
cargo xtask bench --week 46     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 46 tests pass w46_*
  • Capture replay into books w46.pcap_replaytarget ≥ 20.0M msgs/s

Feeds into

No later week lists this one as a hard prerequisite, but you'll keep using it. Week 48 injects packet loss into feeds, and captures plus replay are how you check what the handler did. Week 51 is a good place to tell the story of one incident you reproduced from a capture: it shows debugging discipline better than any benchmark.