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.
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
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.
tcpdumpon the trading server, using libpcap overAF_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.
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.
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.
Why does a capture from a passive tap with a hardware-timestamping capture card give better latency numbers than tcpdump on the trading host?
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?
Day 2 — The bytes, layer by layer
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.
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.
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.
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.
Parse a pcap record header
// 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!()
}
Day 3 — Putting packets back in order
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:
- 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.
- 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.
- 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.
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.
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.
Reorder out-of-sequence packets
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 }
}
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
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()orSystemTime::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::HashMapuses a randomly seededRandomState, so iteration order differs between processes. If you ever emit levels, symbols or orders by iterating aHashMap, two replays disagree. UseBTreeMap, aVecsorted 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.
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?
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.
Day 5 — Debugging an incident with a capture
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:
- Cut the window.
editcap -A "<start>" -B "<stop>" day.pcap window.pcapkeeps a few seconds around the event. Usemergecapif the A and B lines were captured on different ports. Keep enough history before the event to start from a snapshot. - 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.
- 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.
- 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.
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.
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.
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.
Day 6 — Plan the capstone
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.
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?
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::readthe file first) and borrows every record. It accepts both timestamp resolutions and both byte orders, and supports linktype 1 (Ethernet) only. - Frames.
parse_udpstrips 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_framewrites it), all zero-copy. - Feed packets. The UDP payload is one feed packet in the repo's own layout, defined in
feed.rsthis 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 carriescountconsecutive updates and only the first sequence number. Unlike the frame headers around it, it is little-endian.FeedPacket::decodeis strict. - No group filter.
Replayerdoes not filter on destination group and port. The packet'skindbyte 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 ofwindowslots (rounded up to a power of two), whosepushreturnsAccepted,DuplicateorSkipped { lost }and whosepopreleases updates in order. A hole given up after a full window reaches the week 10BookBuilderas 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.
crates/marketdataCapture → 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.