All work

NASDAQ ITCH Replay Engine

NASDAQ publishes its full order flow as TotalView-ITCH 5.0, a stream of binary messages that add, execute, cancel, delete and replace orders. This engine plays a recorded day back over UDP multicast as if it were live, and a separate participant program receives it, checks the sequence numbers, and rebuilds the order book for every security. It never matches orders. It applies what the feed reports.

Role
Independent - design & implementation
Stack
C++23, CMake, GoogleTest, Linux UDP multicast, network namespaces
Status
Personal project · public on GitHub

How it works

An exchange feed and a participant

The replay server streams the ITCH file from disk through a fixed 512-byte buffer, so a 12.9 GB day never has to fit in memory. It sends one message per datagram behind a 10-byte big-endian header: a 64-bit sequence number and a 16-bit message count. It can send as fast as possible, or at a multiple of the original speed, using the messages’ own timestamps.

The participant runs two threads. The feed thread receives each datagram, checks its sequence number, and decodes the ITCH bytes into a fixed-size event. The book thread applies those events to the books. A lock-free single-producer, single-consumer ring of 131,072 events is the only state they share.

A script sets up two Linux network namespaces joined by a virtual Ethernet pair, so the feed can run between two isolated network stacks instead of over loopback.

A terminal book viewer listens to the same feed with the same two-thread design. It lists every security with its best bid and offer, and opens a depth ladder for any one of them, drawn with plain ANSI escapes.

The recording at the top is the viewer during a real-time replay of the first 30 seconds of the 27 March 2019 open. The replay starts at 09:30 with no resting orders, so the first spreads are wide until the book fills in.

Architecture

NASDAQ ITCH replay engine architecturereplay_server, one thread, streams a NASDAQ ITCH 5.0 file through a 512-byte buffer, normalizes each message and skips unsupported types, then paces, re-encodes and frames it with a 10-byte header of sequence number and message count, and sends one message per datagram to UDP multicast group 239.1.1.1 port 9000, over loopback or a veth pair between two network namespaces. market_participant has two threads: the feed thread receives each datagram, checks its sequence number and decodes it into a 48-byte MarketEvent, then pushes it into a lock-free single-producer, single-consumer ring of 131,072 events using release and acquire ordering. The book thread pops events and applies them to per-security order books built from std::map price ladders, intrusive FIFO levels and pooled 40-byte order nodes, and writes a deterministic report. book_viewer is a separate process with the same feed design and a terminal UI thread. Offline, book_checker builds the same books from the same file with no network and no queue, and a diff of the two reports must be identical.replay_server · 1 threadITCH 5.0 fileStreamed through a 512 B bufferRead + normalizeUnsupported types skippedPace, encode, frame10 B header: sequence + countUDP multicast 239.1.1.1:9000One message per datagram · loopback or vethbook_viewerSame feed, UImarket_participant · 2 threadsFeed threadrecv · sequence check · decodeLock-free SPSC ring131,072 × 48 B · release / acquireBook threadBookProcessor applies each eventOrder booksPer security · std::map laddersIntrusive FIFO levels · pooled ordersParticipant reportDeterministic text dumpVerification · offlinebook_checkerSame parser and books, from the fileOracle reportSame formatdiffIdentical: the replay is correct1234
Data pathSeparate process on the same feed
  1. 1Sequence check. Each datagram is classified as in order, a gap, a duplicate or late. Duplicates are dropped; gaps are counted, not recovered.
  2. 2The queue. Producer and consumer state sit on separate cache lines, and each side reads the other’s index only when the ring looks full or empty.
  3. 3The books. Price levels are FIFOs linked through the orders themselves, and orders come from a fixed pool, so time priority is arrival order.
  4. 4The oracle. book_checker runs the same parser and book code straight from the file. Identical reports mean the network and threads changed nothing.
Book viewer showing the AAPL depth ladder: 15 ask levels above a spread line of 0.24 at a mid of 188.45, and 15 bid levels below, each with price, size, order count and a size bar
AAPL about 15 seconds into the replayed open: the 15 best of 150 bid and 149 ask levels, with size, order count and a bar per level.

Verification

Checking a replay with diff

A separate program, book_checker, builds the same books straight from the file, with no network and no queue, and writes the same deterministic report. A replay is correct when the two reports are identical.

Replaying the first 30 seconds of the 27 March 2019 open, 1.82 million messages, over loopback multicast gave 0 gaps, 0 duplicates, and books byte-identical to the offline build, both at full speed and in real time. Offline, book_checker rebuilds the full day, 417 million events across 8,693 securities, in 3 minutes 41 seconds on one thread with about 218 MiB of memory.

60 GoogleTest tests cover the queue, the object pool, ITCH encoding and decoding for seven message types, sequence validation, and the order book. They pass in plain, ThreadSanitizer, and AddressSanitizer with UBSan builds.

Decisions

Three choices in the hot path

A queue that rarely touches the other side’s index

Producer and consumer state sit on separate cache lines. Each side keeps its own index as a plain integer, plus a cached copy of the other side’s. It reads the other side’s atomic, with acquire, only when the cached copy says the ring is full or empty, and publishes with release. There is no read-modify-write and no sequentially consistent ordering, and all slots are usable because the counters only grow.

Orders from a pool, levels as intrusive lists

Each price level is a FIFO of orders linked through the orders themselves, so arrival order is time priority and no timestamps are compared. Orders come from a fixed-capacity pool whose free list is threaded through the unused slots, so adding or removing an order never calls the allocator for the order itself.

Reconstruct, never match

A replace is a delete plus an add, so the order goes to the back of its new level and loses time priority, as it does on the exchange. A crossed book is left crossed. The participant shows the state NASDAQ published, not a corrected one.

The tradeoff

Price levels live in std::map and order lookup in std::unordered_map, so a new order or a new price level still allocates. Gaps are detected and counted but not recovered. The publisher makes one blocking sendto per message, which holds an untuned loopback replay to between about 100,000 and 168,000 messages a second, while the offline path runs at about 1.9 million events a second.

Technical deep dive

Sequence numbers and gaps

Each datagram is classified as in order, a gap, a duplicate, or late. Duplicates are dropped so they can’t be applied twice, and processing continues across a gap.

The end of the stream is a frame with a message count of zero, sent three times 10 ms apart so a single lost datagram can’t leave the participant waiting.

Reading ITCH fields

ITCH integers are big-endian and some are odd widths, like the 6-byte timestamp. Each field is copied into a zero-initialized integer, byte-swapped, and shifted, which avoids misaligned access and packed structs.

Prices are 32-bit fixed point with four implied decimals. Both add messages become one ADD event and both execute messages one EXECUTE event, and the event type stays trivially copyable so it moves through the queue by value.

Throughput, with context

Measured on an AMD Ryzen AI 9 365 laptop, Linux, GCC 14, RelWithDebInfo, with no thread pinning or tuning. The networked range comes from five replays of the 30-second slice over loopback. The participant’s queue never filled, which suggests the publisher is the limit.

There are no per-message latency measurements yet, so the site doesn’t quote any.

Build and tooling

CMake presets build with Ninja: a development build, a test build, ThreadSanitizer on clang-18, AddressSanitizer with UBSan, and an -O3 -march=native benchmark build. GoogleTest is fetched and pinned by hash.

Technical details

The queue’s push and pop

The real code, lightly trimmed. The producer only loads the consumer’s index when its cached copy says the ring is full, and the consumer does the same when it looks empty.

src/common/include/common/spsc_queue.hpp (excerpt)
template <typename... Args>
[[nodiscard]] bool try_emplace(Args&&... args)
{
    auto const write = producer_.next_;
    if (write - producer_.cached_tail_ >= capacity_)
    {
        producer_.cached_tail_ =
            consumer_.published_.load(std::memory_order_acquire);
        if (write - producer_.cached_tail_ >= capacity_) {
            return false;
        }
    }

    alloc_traits::construct(allocator_,
        storage_ + (write & mask_),
        std::forward<Args>(args)...);

    ++producer_.next_;
    producer_.published_.store(producer_.next_, std::memory_order_release);
    return true;
}

bool try_pop(reference val)
{
    auto const read = consumer_.next_;
    if (read == consumer_.cached_head_)
    {
        consumer_.cached_head_ =
            producer_.published_.load(std::memory_order_acquire);
        if (read == consumer_.cached_head_) {
            return false;
        }
    }

    auto* const element = storage_ + (read & mask_);
    val = std::move(*element);
    alloc_traits::destroy(allocator_, element);

    ++consumer_.next_;
    consumer_.published_.store(consumer_.next_,
        std::memory_order_release);
    return true;
}