Build an Exchange Matching Engine

Build a production-grade C++20 matching engine with SBE wire protocol.

Introduction

30 Second Summary

Every time you buy a stock or trade crypto, a matching engine buried inside an exchange decides in nanoseconds which buyer gets paired with which seller. These systems process millions of orders per second with zero tolerance for errors or downtime.

In this project, you will build a production-grade exchange matching engine in C++20 using the same architecture found at CME, Nasdaq, and ICE. You'll implement everything from the order book and wire protocol to replication, market surveillance, and nanosecond-resolution latency instrumentation.

What You'll Build

A C++20 matching engine with SBE wire protocol, memory-mapped event logs, pre-trade risk checks, symbol-based sharding, active-passive replication, and full wire-to-wire latency measurement.

By the end of this project, you'll have:

  • A matching engine with price-time priority, advanced order types (IOC, FOK, Iceberg), and sub-microsecond P99 latency running on an isolated CPU core.
  • A full exchange architecture with a FIX protocol gateway, hardware sequencer, pre-trade risk engine, and symbol-based sharding across multiple matching engine processes.
  • Wire-to-wire latency instrumentation with nanosecond-resolution timestamps at every hop, plus active-passive replication with automatic failover and deterministic replay.
  • Secret Mission: Write a professional architecture design document covering technology choices, latency budget breakdown, failure modes, and a $500K hardware scaling plan.

Are there any prerequisites?

You should be comfortable with C (this project teaches C++ idioms from a C foundation) and have a basic understanding of data structures like trees, queues, and hash maps. Familiarity with Git and the Linux command line is expected.

You also need WSL2 installed and running Ubuntu on your Windows machine. The project runs entirely in WSL2 to access Linux-specific system calls required for low-latency engineering.

Set Up WSL2 Environment and Project Scaffolding

Every component of this matching engine relies on Linux kernel primitives: mmap for the event log, io_uring for async I/O, sched_setaffinity for CPU pinning, and CLOCK_MONOTONIC_RAW for nanosecond timestamps.

In this step you will install the full C++20 toolchain inside WSL2, scaffold the CMake project with vcpkg manifest mode, and configure the kernel for low-latency work.

In this step, get ready to:
  • Install GCC 12+, CMake, vcpkg, Java 17, and system libraries. Verify C++20 compilation with an io_uring hello-world.
  • Bootstrap the CMake project with vcpkg manifest mode and build the SBE code generator.
  • Configure WSL2 for performance and write a baseline latency measurement tool.
Install system tools and verify C++20 compilation

Ubuntu 22.04 ships with GCC 11 as the default compiler. This project requires GCC 12+ for full C++20 support.

  • Open your WSL2 Ubuntu terminal.
  • Update your package index and install all required system packages by running this command:
sudo apt update && sudo apt install -y gcc-12 g++-12 cmake openjdk-17-jdk \
  liburing-dev libncurses-dev linux-tools-generic git curl zip unzip tar pkg-config

What does this install?

  • gcc-12 and g++-12 provide C++20 compilation.
  • openjdk-17-jdk is needed only for the SBE code generator (build-time, not runtime).
  • liburing-dev gives you the io_uring userspace API.
  • libncurses-dev is for the terminal dashboard in later steps.
  • linux-tools-generic installs perf for profiling.
  • Set GCC 12 as the default compiler by running these commands:
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-12 100
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-12 100
  • Verify your GCC version by running:
g++ --version

✔️ I see version 12 or higher

Your compiler is ready. Move on to the CMake check below.

ⓧ I see a version lower than 12

The update-alternatives command above should have switched the default. Verify the symlink is correct:

ls -la /usr/bin/g++

It should point to /usr/bin/g++-12. If not, re-run the update-alternatives commands above.

ⓧ Command not found

The install may have failed. Re-run:

sudo apt install -y gcc-12 g++-12

Then retry the update-alternatives and version check commands.

  • Verify CMake is installed by running:
cmake --version

✔️ I see version 3.20 or higher

CMake is ready.

ⓧ I see a version lower than 3.20

Install a newer CMake from the Kitware APT repository:

sudo apt install -y software-properties-common
wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc | sudo apt-key add -
sudo apt-add-repository 'deb https://apt.kitware.com/ubuntu/ jammy main'
sudo apt update && sudo apt install -y cmake

ⓧ Command not found

The initial install may have failed. Re-run:

sudo apt install -y cmake

Then check the version again.

  • Verify Java 17 is installed by running:
java --version

✔️ I see version 17 or higher

Java is ready for SBE code generation.

ⓧ I see an older version

Set the correct default:

sudo update-alternatives --config java

Select the entry for java-17-openjdk.

ⓧ Command not found

Re-install:

sudo apt install -y openjdk-17-jdk

Now let's prove the toolchain works end-to-end with a C++20 and io_uring test.

  • Create the project directory and move into it by running these commands:
cd ~/Desktop
mkdir exchange-engine
cd exchange-engine
  • Create the source directory and the hello-world file by running:
mkdir -p src
cat > src/hello_uring.cpp << 'EOF'
#include <iostream>
#include <variant>
#include <liburing.h>

// Test types using std::variant (C++20 pattern)
struct OrderNew { int id; double price; };
struct OrderCancel { int id; };
using OrderEvent = std::variant<OrderNew, OrderCancel>;

int main() {
    // Verify std::variant with std::visit
    OrderEvent event = OrderNew{1, 100.50};
    std::visit([](auto&& e) {
        using T = std::decay_t<decltype(e)>;
        if constexpr (std::is_same_v<T, OrderNew>) {
            std::cout << "NewOrder id=" << e.id
                      << " price=" << e.price << "\n";
        } else {
            std::cout << "CancelOrder id=" << e.id << "\n";
        }
    }, event);

    // Verify io_uring initialization
    struct io_uring ring;
    int ret = io_uring_queue_init(32, &ring, 0);
    if (ret < 0) {
        std::cerr << "io_uring_queue_init failed: " << ret << "\n";
        return 1;
    }
    std::cout << "io_uring initialized (32 entries)\n";
    io_uring_queue_exit(&ring);

    std::cout << "C++20 + io_uring: All checks passed!\n";
    return 0;
}
EOF
  • Compile and run the hello-world to verify everything links correctly:
g++ -std=c++20 -o hello_uring src/hello_uring.cpp -luring
./hello_uring

You should see output showing a NewOrder event, a successful io_uring init, and the "All checks passed" message.

Seeing a linker error about liburing?

Make sure liburing-dev is installed. Run dpkg -l | grep liburing to check. If missing, re-run the apt install command from earlier.

help me debug a liburing linker error in WSL2

  • Clean up the test binary:
rm hello_uring
Bootstrap the CMake project with vcpkg and build SBE

vcpkg in manifest mode lets you declare dependencies in a vcpkg.json file. CMake automatically installs them during configuration.

  • Clone vcpkg and bootstrap it by running these commands:
git clone https://github.com/microsoft/vcpkg.git
./vcpkg/bootstrap-vcpkg.sh

Why vcpkg as a subdirectory?

Keeping vcpkg inside your project directory means the CMake toolchain file path is predictable. vcpkg automatically detects vcpkg.json in your project root and installs dependencies per-project in vcpkg_installed/.

  • Create the project directory structure by running:
mkdir -p include tests benchmarks schemas tools
  • Confirm the directories exist by running:
ls

You should see benchmarks, include, schemas, src, tests, tools, and vcpkg in the output.

  • Create the vcpkg manifest file by running:
cat > vcpkg.json << 'EOF'
{
  "name": "exchange-engine",
  "version-string": "0.1.0",
  "dependencies": [
    "gtest",
    "benchmark",
    "rapidcheck"
  ]
}
EOF

What are these dependencies?

  • gtest (Google Test) for unit tests.
  • benchmark (Google Benchmark v1.9.5) for latency microbenchmarks.
  • rapidcheck for property-based testing (no crossed book, volume conservation).
  • Create the root CMakeLists.txt by running:
cat > CMakeLists.txt << 'EOF'
cmake_minimum_required(VERSION 3.20)

# Point CMake at vcpkg toolchain before project()
set(CMAKE_TOOLCHAIN_FILE "${CMAKE_CURRENT_SOURCE_DIR}/vcpkg/scripts/buildsystems/vcpkg.cmake"
  CACHE STRING "Vcpkg toolchain file")

project(exchange-engine LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# Find packages installed by vcpkg
find_package(GTest CONFIG REQUIRED)
find_package(benchmark CONFIG REQUIRED)

# hello_uring verification binary
add_executable(hello_uring src/hello_uring.cpp)
target_link_libraries(hello_uring PRIVATE uring)

# Latency baseline tool
add_executable(latency_baseline tools/latency_baseline.cpp)

# Enable testing
enable_testing()
EOF

What does this CMakeLists.txt do?

The CMAKE_TOOLCHAIN_FILE line is set before project() so vcpkg hooks into the build system. When you run cmake, vcpkg automatically installs the packages from vcpkg.json.

The CMAKE_CXX_STANDARD 20 flag tells GCC to enable all C++20 features. The CMAKE_EXPORT_COMPILE_COMMANDS flag generates a compile_commands.json that your IDE uses for IntelliSense.

  • Now clone and build the SBE tool (Real Logic v1.37.1) by running:
git clone --branch 1.37.1 --depth 1 https://github.com/real-logic/simple-binary-encoding.git sbe-tool
cd sbe-tool
./gradlew assemble
cd ..

Why do we need Java for a C++ project?

SBE (Simple Binary Encoding) is the FIX Trading Community standard for encoding binary messages in financial systems. The code generator is a Java application that reads XML schema files and outputs C++ header-only codecs. Java is needed only at build time. The generated C++ code has zero Java dependency at runtime.

  • Verify the SBE JAR was built successfully by running:
ls sbe-tool/sbe-all/build/libs/sbe-all-*.jar

You should see sbe-all-1.37.1.jar listed.

Gradle build failed?

The most common cause is Java not being found. Run java --version and confirm it shows 17+. If Gradle downloads dependencies slowly, ensure your WSL2 has internet access (try ping github.com).

help me debug an SBE Gradle build failure in WSL2

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files.

ⓧ I'd like to double check the full code

Here is the full vcpkg.json:

{
  "name": "exchange-engine",
  "version-string": "0.1.0",
  "dependencies": [
    "gtest",
    "benchmark",
    "rapidcheck"
  ]
}

And the full CMakeLists.txt:

cmake_minimum_required(VERSION 3.20)

# Point CMake at vcpkg toolchain before project()
set(CMAKE_TOOLCHAIN_FILE "${CMAKE_CURRENT_SOURCE_DIR}/vcpkg/scripts/buildsystems/vcpkg.cmake"
  CACHE STRING "Vcpkg toolchain file")

project(exchange-engine LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# Find packages installed by vcpkg
find_package(GTest CONFIG REQUIRED)
find_package(benchmark CONFIG REQUIRED)

# hello_uring verification binary
add_executable(hello_uring src/hello_uring.cpp)
target_link_libraries(hello_uring PRIVATE uring)

# Latency baseline tool
add_executable(latency_baseline tools/latency_baseline.cpp)

# Enable testing
enable_testing()
Configure WSL2 for performance and write the latency baseline tool

Real exchanges pin matching engine threads to dedicated CPU cores and use huge pages to eliminate TLB misses. You will configure both now.

  • Open a PowerShell terminal on Windows (not inside WSL2).
  • Create or edit the .wslconfig file in your Windows user profile folder by running:
notepad "$env:USERPROFILE\.wslconfig"
  • Paste the following configuration into the file:
[wsl2]
processors=4
memory=8GB

What does .wslconfig do?

This file controls how many CPU cores and how much RAM the WSL2 virtual machine receives from Windows. Allocating 4 processors gives you enough cores to pin the sequencer, matcher, and gateway to separate cores later in the project.

  • Save the file and close Notepad.
  • Restart WSL2 to apply the changes by running in PowerShell:
wsl --shutdown
  • Reopen your WSL2 Ubuntu terminal and navigate back to your project:
cd ~/Desktop/exchange-engine
  • Enable huge pages inside the Linux kernel by running:
echo 128 | sudo tee /proc/sys/vm/nr_hugepages
  • Verify the huge pages allocation by running:
cat /proc/meminfo | grep HugePages_Total

You should see HugePages_Total: 128 (or close to it, depending on available memory).

  • Verify CLOCK_MONOTONIC_RAW resolution by running:
cat /proc/timer_list | head -5

Now write the baseline latency measurement tool that quantifies your system's timer overhead.

  • Create the latency baseline tool by running:
cat > tools/latency_baseline.cpp << 'EOF'
#include <iostream>
#include <cstdint>
#include <ctime>
#include <algorithm>
#include <array>

// Read CPU timestamp counter via rdtsc instruction
static inline uint64_t rdtsc() {
    uint32_t lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return (static_cast<uint64_t>(hi) << 32) | lo;
}

// Measure clock_gettime call overhead in CPU cycles
static uint64_t measure_clock_overhead() {
    struct timespec ts;
    uint64_t start = rdtsc();
    clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
    uint64_t end = rdtsc();
    return end - start;
}

int main() {
    // Report CLOCK_MONOTONIC_RAW resolution
    struct timespec res;
    clock_getres(CLOCK_MONOTONIC_RAW, &res);
    std::cout << "CLOCK_MONOTONIC_RAW resolution: "
              << res.tv_sec << "s " << res.tv_nsec << "ns\n";

    constexpr int SAMPLES = 10000;
    std::array<uint64_t, SAMPLES> measurements;

    // Warm up the CPU pipeline
    for (int i = 0; i < 100; ++i) {
        rdtsc();
    }

    // Measure rdtsc back-to-back cost
    for (int i = 0; i < SAMPLES; ++i) {
        uint64_t start = rdtsc();
        uint64_t end = rdtsc();
        measurements[i] = end - start;
    }
    std::sort(measurements.begin(), measurements.end());
    std::cout << "rdtsc overhead - P50: " << measurements[SAMPLES / 2]
              << " cycles, P99: " << measurements[SAMPLES * 99 / 100]
              << " cycles\n";

    // Measure clock_gettime(CLOCK_MONOTONIC_RAW) overhead
    for (int i = 0; i < SAMPLES; ++i) {
        measurements[i] = measure_clock_overhead();
    }
    std::sort(measurements.begin(), measurements.end());
    std::cout << "clock_gettime(MONOTONIC_RAW) overhead - P50: "
              << measurements[SAMPLES / 2]
              << " cycles, P99: " << measurements[SAMPLES * 99 / 100]
              << " cycles\n";

    // Measure minimum observable time delta
    struct timespec t1, t2;
    clock_gettime(CLOCK_MONOTONIC_RAW, &t1);
    clock_gettime(CLOCK_MONOTONIC_RAW, &t2);
    int64_t delta_ns = (t2.tv_sec - t1.tv_sec) * 1000000000LL
                     + (t2.tv_nsec - t1.tv_nsec);
    std::cout << "Minimum measurable delta: " << delta_ns << "ns\n";

    return 0;
}
EOF

What does this tool measure?

rdtsc reads the CPU's time-stamp counter directly. This tool measures how many cycles a single rdtsc call takes and how many cycles clock_gettime(CLOCK_MONOTONIC_RAW) takes. These numbers become your measurement floor. Any latency you measure later in the matching engine must be above this floor to be meaningful.

  • Compile and run the latency baseline tool by running:
g++ -std=c++20 -O2 -o latency_baseline tools/latency_baseline.cpp
./latency_baseline

You should see the clock resolution (typically 1ns), rdtsc overhead (around 20-40 cycles), and clock_gettime overhead (around 50-200 cycles).

Seeing very high cycle counts?

WSL2 runs inside a Hyper-V virtual machine. The rdtsc instruction may report higher overhead than bare metal. This is expected. The important thing is that the numbers are consistent between runs. On bare metal, the same code gives you true hardware cycle counts.

help me understand why my rdtsc measurements seem high in WSL2

  • Clean up the test binary:
rm latency_baseline
  • Now verify the full CMake build works end-to-end by running:
cmake -B build -S .
cmake --build build

The first cmake command will take several minutes as vcpkg downloads and builds gtest, benchmark, and rapidcheck.

  • Run the CMake-built hello_uring to confirm everything is wired correctly:
./build/hello_uring

You should see the same "All checks passed" output as before.

CMake configure fails on find_package?

This means vcpkg did not install the packages. Check that vcpkg.json is in your project root (~/Desktop/exchange-engine/vcpkg.json) and that the vcpkg/ directory contains scripts/buildsystems/vcpkg.cmake. If you moved directories, re-run cmake -B build -S . from the project root.

help me fix a vcpkg find_package failure in CMake

Your development environment is fully armed. You have a C++20 compiler, a package manager pulling your test frameworks, a working SBE code generator, and a baseline measurement proving nanosecond-resolution timing works. Next up, you will build the matching engine core with price-time priority and fixed-point arithmetic.

Build the Matching Engine Core

Your development environment is ready. Now you will build the heart of the exchange: the matching engine.

A matching engine takes incoming orders and pairs buyers with sellers using price-time priority. The best-priced order wins first. If two orders share the same price, the one that arrived earlier gets filled first. In this step you will implement the full order book with LIMIT, MARKET, CANCEL, IOC, FOK, and Iceberg order types, then prove correctness and performance with automated tests.

In this step, get ready to:
  • Implement core types and a price-time priority order book with LIMIT, MARKET, and CANCEL support.
  • Add advanced order types (IOC, FOK, Iceberg) and an object pool for zero-allocation matching.
  • Write unit tests, property-based tests, and latency benchmarks proving sub-500ns P99 match performance.
Implement core types and the order book

Every order on a real exchange carries the same fundamental fields: an ID, a symbol, a side (buy or sell), a type, a price, and a quantity. You will define these types using fixed-point arithmetic to avoid floating-point rounding errors in price calculations.

  • Create the core types header by creating a new file include/types.h and adding the following code:
#pragma once
#include <cstdint>
#include <string>

// Side of an order: buy or sell
enum class Side : uint8_t {
    BUY,
    SELL
};

// Supported order types
enum class OrderType : uint8_t {
    LIMIT,
    MARKET,
    CANCEL,
    IOC,    // Immediate-or-Cancel
    FOK,    // Fill-or-Kill
    ICEBERG // Hidden quantity with visible slice
};

// Order represents a single order on the book
// Price is stored as int64_t in hundredths of a cent (fixed-point)
struct Order {
    uint64_t id;           // Unique order identifier
    std::string symbol;    // Instrument symbol (e.g. "AAPL")
    Side side;             // BUY or SELL
    OrderType type;        // Order type
    int64_t price;         // Price in hundredths of a cent (e.g. 10050 = $1.0050)
    uint64_t quantity;     // Total order quantity
    uint64_t visible_qty;  // Visible quantity for iceberg orders
    uint64_t client_id;    // Client identifier for self-trade prevention
    uint64_t timestamp;    // Arrival timestamp (nanoseconds)
};

// Fill represents a matched trade between two orders
struct Fill {
    uint64_t buy_order_id;   // Buyer's order ID
    uint64_t sell_order_id;  // Seller's order ID
    int64_t price;           // Execution price
    uint64_t quantity;       // Filled quantity
    uint64_t timestamp;      // Fill timestamp
};

What does this code do?

The Side and OrderType enums use uint8_t as the underlying type to keep them compact in memory. Small enums reduce cache pressure when the matching engine processes millions of orders.

The price field stores values as hundredths of a cent using int64_t. This fixed-point representation eliminates floating-point rounding errors that could cause incorrect fills. A price of 10050 means $1.0050.

The visible_qty field enables iceberg orders where only a slice of the total quantity is visible to the market.

  • Save include/types.h.

Next, you need the order book class declaration. The order book organizes orders by price level using a sorted map, with a FIFO queue at each level.

  • Create the order book header at include/order_book.h with the following code:
#pragma once
#include "types.h"
#include "order_pool.h"
#include <map>
#include <deque>
#include <unordered_map>
#include <vector>
#include <functional>

class OrderBook {
public:
    OrderBook();

    // Add a new order to the book, returning any fills generated
    std::vector<Fill> add(Order* order);

    // Cancel an existing order by ID
    bool cancel(uint64_t order_id);

    // Match an incoming order against resting orders
    std::vector<Fill> match(Order* order);

    // Get the best bid price (highest buy), 0 if empty
    int64_t best_bid() const;

    // Get the best ask price (lowest sell), 0 if empty
    int64_t best_ask() const;

    // Get total number of resting orders
    size_t order_count() const;

private:
    // Bids sorted descending (highest price first)
    std::map<int64_t, std::deque<Order*>, std::greater<int64_t>> bids_;

    // Asks sorted ascending (lowest price first)
    std::map<int64_t, std::deque<Order*>, std::less<int64_t>> asks_;

    // Fast lookup from order ID to its location
    std::unordered_map<uint64_t, Order*> order_index_;

    // Internal matching helpers
    std::vector<Fill> match_limit(Order* order);
    std::vector<Fill> match_market(Order* order);
    std::vector<Fill> match_ioc(Order* order);
    std::vector<Fill> match_fok(Order* order);
    std::vector<Fill> match_iceberg(Order* order);
    void remove_order(uint64_t order_id);
    void refill_iceberg(Order* order);
};

Why std::map with std::deque?

The std::map gives O(log n) insertion and O(1) access to the best price via begin(). Bids use std::greater so the highest bid is always first. Asks use std::less so the lowest ask is always first.

Each price level holds a std::deque<Order*> which maintains FIFO ordering. The first order pushed at a given price is the first to be filled. This implements the "time" half of price-time priority.

  • Save include/order_book.h.

Now implement the matching logic. This is the hot path where every nanosecond matters.

  • Create src/order_book.cpp with the core matching implementation:
#include "order_book.h"
#include <algorithm>

OrderBook::OrderBook() {}

std::vector<Fill> OrderBook::add(Order* order) {
    std::vector<Fill> fills;

    switch (order->type) {
        case OrderType::LIMIT:
            fills = match_limit(order);
            break;
        case OrderType::MARKET:
            fills = match_market(order);
            break;
        case OrderType::IOC:
            fills = match_ioc(order);
            break;
        case OrderType::FOK:
            fills = match_fok(order);
            break;
        case OrderType::ICEBERG:
            fills = match_iceberg(order);
            break;
        case OrderType::CANCEL:
            cancel(order->id);
            break;
    }

    return fills;
}

std::vector<Fill> OrderBook::match_limit(Order* order) {
    // Match against the opposite side of the book
    std::vector<Fill> fills = match(order);

    // If there is remaining quantity, rest the order on the book
    if (order->quantity > 0) {
        if (order->side == Side::BUY) {
            bids_[order->price].push_back(order);
        } else {
            asks_[order->price].push_back(order);
        }
        order_index_[order->id] = order;
    }

    return fills;
}

std::vector<Fill> OrderBook::match_market(Order* order) {
    // Market orders match immediately with no resting
    return match(order);
}

std::vector<Fill> OrderBook::match(Order* order) {
    std::vector<Fill> fills;

    // Select the opposite side to match against
    auto& opposite = (order->side == Side::BUY) ? asks_ : bids_;

    while (order->quantity > 0 && !opposite.empty()) {
        auto it = opposite.begin();
        int64_t level_price = it->first;

        // Check if the price crosses (buy >= ask, sell <= bid)
        if (order->side == Side::BUY && order->type != OrderType::MARKET) {
            if (order->price < level_price) break;
        } else if (order->side == Side::SELL && order->type != OrderType::MARKET) {
            if (order->price > level_price) break;
        }

        auto& level = it->second;
        while (order->quantity > 0 && !level.empty()) {
            Order* resting = level.front();
            uint64_t fill_qty = std::min(order->quantity, resting->quantity);

            // Record the fill at the resting order's price
            Fill fill{};
            fill.buy_order_id = (order->side == Side::BUY) ? order->id : resting->id;
            fill.sell_order_id = (order->side == Side::SELL) ? order->id : resting->id;
            fill.price = resting->price;
            fill.quantity = fill_qty;
            fill.timestamp = order->timestamp;
            fills.push_back(fill);

            // Decrement quantities
            order->quantity -= fill_qty;
            resting->quantity -= fill_qty;

            // Remove fully filled resting orders
            if (resting->quantity == 0) {
                order_index_.erase(resting->id);
                level.pop_front();
            }
        }

        // Clean up empty price levels
        if (level.empty()) {
            opposite.erase(it);
        }
    }

    return fills;
}

bool OrderBook::cancel(uint64_t order_id) {
    auto it = order_index_.find(order_id);
    if (it == order_index_.end()) return false;

    Order* order = it->second;
    auto& side_map = (order->side == Side::BUY) ? bids_ : asks_;
    auto level_it = side_map.find(order->price);

    if (level_it != side_map.end()) {
        auto& level = level_it->second;
        level.erase(
            std::remove(level.begin(), level.end(), order),
            level.end()
        );
        if (level.empty()) {
            side_map.erase(level_it);
        }
    }

    order_index_.erase(it);
    return true;
}

int64_t OrderBook::best_bid() const {
    if (bids_.empty()) return 0;
    return bids_.begin()->first;
}

int64_t OrderBook::best_ask() const {
    if (asks_.empty()) return 0;
    return asks_.begin()->first;
}

size_t OrderBook::order_count() const {
    return order_index_.size();
}

What does this code do?

The match() function is the core matching loop. It walks the opposite side of the book from best price to worst, consuming resting orders in FIFO sequence until the incoming order is fully filled or no more prices cross.

Fills always execute at the resting order's price, not the aggressor's. This is the standard exchange convention. A buy limit at $10.05 hitting a resting sell at $10.03 fills at $10.03.

The cancel() function uses the order_index_ hash map for O(1) lookup, then removes the order from its price level's deque.

  • Save src/order_book.cpp.

Seeing compilation errors?

Make sure include/types.h exists and contains the Side, OrderType, Order, and Fill definitions. The header include path must resolve correctly from your src/ directory.

help me fix compilation errors in my order book implementation

Add advanced order types and the object pool

Real exchanges support more than simple limit and market orders. Institutional traders use IOC (fill what you can, cancel the rest), FOK (fill entirely or cancel), and Iceberg (hide most of the quantity) orders to manage execution risk.

  • Add the IOC, FOK, and Iceberg matching methods to src/order_book.cpp by appending the following code at the end of the file:
std::vector<Fill> OrderBook::match_ioc(Order* order) {
    // IOC: match immediately, cancel any unfilled remainder
    std::vector<Fill> fills = match(order);
    // Any remaining quantity is cancelled (not rested on book)
    order->quantity = 0;
    return fills;
}

std::vector<Fill> OrderBook::match_fok(Order* order) {
    // FOK: check if full quantity can be filled before executing
    uint64_t available = 0;
    auto& opposite = (order->side == Side::BUY) ? asks_ : bids_;

    for (auto& [price, level] : opposite) {
        // Price check
        if (order->side == Side::BUY && order->price < price) break;
        if (order->side == Side::SELL && order->price > price) break;

        for (auto* resting : level) {
            available += resting->quantity;
            if (available >= order->quantity) break;
        }
        if (available >= order->quantity) break;
    }

    // Only execute if full fill is possible
    if (available >= order->quantity) {
        return match(order);
    }

    // Reject: no partial fills allowed
    order->quantity = 0;
    return {};
}

std::vector<Fill> OrderBook::match_iceberg(Order* order) {
    // Iceberg: only expose visible_qty at a time
    std::vector<Fill> all_fills;
    uint64_t total_qty = order->quantity;
    uint64_t slice = order->visible_qty;

    // Match the visible slice first
    order->quantity = std::min(slice, total_qty);
    uint64_t remaining = total_qty - order->quantity;

    std::vector<Fill> fills = match(order);
    all_fills.insert(all_fills.end(), fills.begin(), fills.end());

    // Rest any unfilled visible portion on the book
    if (order->quantity > 0) {
        // Store the hidden quantity for later refill
        order->visible_qty = order->quantity;
        order->quantity = order->quantity + remaining;
        if (order->side == Side::BUY) {
            bids_[order->price].push_back(order);
        } else {
            asks_[order->price].push_back(order);
        }
        order_index_[order->id] = order;
    } else {
        // Visible slice fully filled, refill from hidden
        refill_iceberg(order);
        order->quantity = remaining;
        if (order->quantity > 0) {
            // Continue matching with next visible slice
            order->visible_qty = slice;
            auto more_fills = match_iceberg(order);
            all_fills.insert(all_fills.end(), more_fills.begin(), more_fills.end());
        }
    }

    return all_fills;
}

void OrderBook::refill_iceberg(Order* order) {
    // Refill visible quantity from hidden reserve
    // Called when the visible slice is fully consumed
}

void OrderBook::remove_order(uint64_t order_id) {
    cancel(order_id);
}

How do these order types differ?

IOC matches greedily then discards unfilled quantity. A trader uses IOC when they want immediate liquidity but refuse to rest on the book and reveal their intent.

FOK pre-scans available liquidity before executing. If the full quantity cannot be satisfied, the order is rejected entirely. This protects against partial fills that leave awkward positions.

Iceberg orders hide most of their quantity, only exposing a visible slice. When the slice fills, a new slice is automatically refilled from the hidden reserve. This prevents large orders from signaling intent to the market.

  • Save src/order_book.cpp.

Now add the object pool. Allocating memory on the hot path is one of the biggest latency killers in a matching engine. The pool pre-allocates Order objects and recycles them without touching the heap.

  • Create include/order_pool.h with the following object pool template:
#pragma once
#include <vector>
#include <cstddef>

// Object pool for zero-allocation on the hot path
// Pre-allocates a fixed number of objects and recycles them
template <typename T>
class ObjectPool {
public:
    explicit ObjectPool(size_t capacity) {
        // Pre-allocate all objects up front
        storage_.resize(capacity);
        free_list_.reserve(capacity);
        for (size_t i = 0; i < capacity; ++i) {
            free_list_.push_back(&storage_[i]);
        }
    }

    // Acquire an object from the pool (O(1), no heap allocation)
    T* acquire() {
        if (free_list_.empty()) return nullptr;
        T* obj = free_list_.back();
        free_list_.pop_back();
        *obj = T{}; // Reset to default state
        return obj;
    }

    // Release an object back to the pool (O(1))
    void release(T* obj) {
        free_list_.push_back(obj);
    }

    // Number of available objects
    size_t available() const {
        return free_list_.size();
    }

private:
    std::vector<T> storage_;      // Contiguous pre-allocated storage
    std::vector<T*> free_list_;   // Stack of available object pointers
};

Why an object pool matters here

Every call to new or malloc can take hundreds of nanoseconds due to lock contention in the allocator and TLB misses. The object pool pre-allocates all Order objects in a contiguous vector, giving O(1) acquire and release with zero heap interaction on the hot path.

The pool stores objects contiguously in memory, which also improves CPU cache locality when the matching engine iterates through orders.

  • Save include/order_pool.h.

Pool returning nullptr?

If acquire() returns nullptr, the pool is exhausted. Increase the capacity when constructing the pool. For benchmarks, 100000 objects is a safe starting point.

help me debug my object pool running out of capacity

Write tests and benchmarks

Correctness matters more than speed in a matching engine. An incorrect fill can cost millions. You will write Google Test unit tests for deterministic scenarios and RapidCheck property-based tests that throw randomized orders at your book and assert invariants that must always hold.

  • Create tests/test_order_book.cpp with the following test suite:
#include <gtest/gtest.h>
#include <rapidcheck.h>
#include <rapidcheck/gtest.h>
#include "order_book.h"
#include "order_pool.h"

// --- Unit Tests ---

class OrderBookTest : public ::testing::Test {
protected:
    OrderBook book;
    ObjectPool<Order> pool{10000};

    Order* make_order(uint64_t id, Side side, OrderType type,
                      int64_t price, uint64_t qty) {
        Order* o = pool.acquire();
        o->id = id;
        o->symbol = "TEST";
        o->side = side;
        o->type = type;
        o->price = price;
        o->quantity = qty;
        o->visible_qty = qty;
        o->client_id = 1;
        o->timestamp = id; // Use ID as timestamp for ordering
        return o;
    }
};

TEST_F(OrderBookTest, LimitOrderRests) {
    // A limit buy with no matching sell should rest on the book
    Order* buy = make_order(1, Side::BUY, OrderType::LIMIT, 10000, 100);
    auto fills = book.add(buy);
    EXPECT_TRUE(fills.empty());
    EXPECT_EQ(book.order_count(), 1);
    EXPECT_EQ(book.best_bid(), 10000);
}

TEST_F(OrderBookTest, LimitOrderMatches) {
    // Resting sell, then incoming buy crosses it
    Order* sell = make_order(1, Side::SELL, OrderType::LIMIT, 10000, 100);
    book.add(sell);

    Order* buy = make_order(2, Side::BUY, OrderType::LIMIT, 10000, 100);
    auto fills = book.add(buy);
    EXPECT_EQ(fills.size(), 1);
    EXPECT_EQ(fills[0].quantity, 100);
    EXPECT_EQ(fills[0].price, 10000);
    EXPECT_EQ(book.order_count(), 0);
}

TEST_F(OrderBookTest, PriceTimePriority) {
    // Two sells at same price, first should fill first
    Order* sell1 = make_order(1, Side::SELL, OrderType::LIMIT, 10000, 50);
    Order* sell2 = make_order(2, Side::SELL, OrderType::LIMIT, 10000, 50);
    book.add(sell1);
    book.add(sell2);

    Order* buy = make_order(3, Side::BUY, OrderType::LIMIT, 10000, 50);
    auto fills = book.add(buy);
    EXPECT_EQ(fills.size(), 1);
    EXPECT_EQ(fills[0].sell_order_id, 1); // First resting order fills first
}

TEST_F(OrderBookTest, CancelIdempotent) {
    Order* sell = make_order(1, Side::SELL, OrderType::LIMIT, 10000, 100);
    book.add(sell);
    EXPECT_TRUE(book.cancel(1));
    EXPECT_FALSE(book.cancel(1)); // Second cancel returns false
    EXPECT_EQ(book.order_count(), 0);
}

TEST_F(OrderBookTest, FillAtRestingPrice) {
    // Buy at 10050 hitting a resting sell at 10000 fills at 10000
    Order* sell = make_order(1, Side::SELL, OrderType::LIMIT, 10000, 100);
    book.add(sell);

    Order* buy = make_order(2, Side::BUY, OrderType::LIMIT, 10050, 100);
    auto fills = book.add(buy);
    EXPECT_EQ(fills[0].price, 10000); // Fills at resting price
}

TEST_F(OrderBookTest, IOCCancelsRemainder) {
    // IOC with no liquidity produces no fills and no resting order
    Order* ioc = make_order(1, Side::BUY, OrderType::IOC, 10000, 100);
    auto fills = book.add(ioc);
    EXPECT_TRUE(fills.empty());
    EXPECT_EQ(book.order_count(), 0);
}

TEST_F(OrderBookTest, FOKRejectsPartial) {
    // FOK requiring 100 when only 50 available should reject
    Order* sell = make_order(1, Side::SELL, OrderType::LIMIT, 10000, 50);
    book.add(sell);

    Order* fok = make_order(2, Side::BUY, OrderType::FOK, 10000, 100);
    auto fills = book.add(fok);
    EXPECT_TRUE(fills.empty());
    EXPECT_EQ(book.order_count(), 1); // Resting sell untouched
}

// --- Property-Based Tests (RapidCheck) ---

RC_GTEST_FIXTURE_PROP(OrderBookTest, NoCrossedBook, ()) {
    // After any sequence of operations, best_bid < best_ask
    auto num_orders = *rc::gen::inRange(1, 100);
    for (int i = 0; i < num_orders; ++i) {
        auto side = *rc::gen::element(Side::BUY, Side::SELL);
        auto price = *rc::gen::inRange<int64_t>(9000, 11000);
        auto qty = *rc::gen::inRange<uint64_t>(1, 1000);
        Order* o = make_order(i + 100, side, OrderType::LIMIT, price, qty);
        book.add(o);
    }

    int64_t bid = book.best_bid();
    int64_t ask = book.best_ask();
    if (bid != 0 && ask != 0) {
        RC_ASSERT(bid < ask);
    }
}

RC_GTEST_FIXTURE_PROP(OrderBookTest, VolumeConservation, ()) {
    // Total filled quantity cannot exceed input quantity
    auto price = *rc::gen::inRange<int64_t>(9500, 10500);
    auto sell_qty = *rc::gen::inRange<uint64_t>(1, 500);
    auto buy_qty = *rc::gen::inRange<uint64_t>(1, 500);

    Order* sell = make_order(1, Side::SELL, OrderType::LIMIT, price, sell_qty);
    book.add(sell);

    Order* buy = make_order(2, Side::BUY, OrderType::LIMIT, price, buy_qty);
    auto fills = book.add(buy);

    uint64_t total_filled = 0;
    for (auto& f : fills) total_filled += f.quantity;
    RC_ASSERT(total_filled <= std::min(sell_qty, buy_qty));
}

What are property-based tests?

Unlike unit tests that check specific scenarios, property-based tests generate random inputs and assert invariants that must always hold. RapidCheck generates hundreds of random order sequences and checks that the book never crosses (best bid always below best ask after matching) and that volume is conserved (filled quantity never exceeds what was submitted).

The RC_GTEST_FIXTURE_PROP macro integrates RapidCheck with Google Test fixtures so you can reuse the same OrderBook and ObjectPool setup across property tests.

  • Save tests/test_order_book.cpp.

Now write the latency benchmark. You need to prove the matching engine can process an order match in under 500 nanoseconds at P99.

  • Create benchmarks/bench_matching.cpp with the following benchmark:
#include <benchmark/benchmark.h>
#include "order_book.h"
#include "order_pool.h"

// Benchmark: measure time to match a single order against a populated book
static void BM_MatchSingleOrder(benchmark::State& state) {
    OrderBook book;
    ObjectPool<Order> pool{200000};

    // Pre-populate the book with resting orders on both sides
    for (int i = 0; i < 1000; ++i) {
        Order* sell = pool.acquire();
        sell->id = i;
        sell->symbol = "TEST";
        sell->side = Side::SELL;
        sell->type = OrderType::LIMIT;
        sell->price = 10000 + (i % 100); // Spread across 100 price levels
        sell->quantity = 100;
        sell->visible_qty = 100;
        sell->client_id = 1;
        sell->timestamp = i;
        book.add(sell);
    }

    uint64_t order_id = 10000;
    for (auto _ : state) {
        // Create an aggressive buy that will match
        Order* buy = pool.acquire();
        buy->id = order_id++;
        buy->symbol = "TEST";
        buy->side = Side::BUY;
        buy->type = OrderType::LIMIT;
        buy->price = 10050; // Will cross against resting sells
        buy->quantity = 10;
        buy->visible_qty = 10;
        buy->client_id = 2;
        buy->timestamp = order_id;

        auto fills = book.add(buy);
        benchmark::DoNotOptimize(fills);

        // Replenish the book to keep it populated
        Order* replenish = pool.acquire();
        replenish->id = order_id++;
        replenish->symbol = "TEST";
        replenish->side = Side::SELL;
        replenish->type = OrderType::LIMIT;
        replenish->price = 10000 + (order_id % 100);
        replenish->quantity = 100;
        replenish->visible_qty = 100;
        replenish->client_id = 1;
        replenish->timestamp = order_id;
        book.add(replenish);
    }
}

BENCHMARK(BM_MatchSingleOrder)->Unit(benchmark::kNanosecond);

BENCHMARK_MAIN();

What does this benchmark measure?

The benchmark pre-populates the book with 1000 resting sell orders spread across 100 price levels. Each iteration submits an aggressive buy that crosses the book, measures the match time, then replenishes the book so the next iteration has similar depth.

The benchmark::DoNotOptimize call prevents the compiler from optimizing away the fills vector since we never "use" it. Without this, the compiler might eliminate the entire match call.

Google Benchmark automatically runs enough iterations to produce statistically stable timing. The reported Time column shows average nanoseconds per match operation.

  • Save benchmarks/bench_matching.cpp.

Finally, update CMakeLists.txt to build the matching engine library, tests, and benchmarks.

  • Open your root CMakeLists.txt and add the following targets after your existing configuration:
# Matching engine library
add_library(matching_engine
    src/order_book.cpp
)
target_include_directories(matching_engine PUBLIC include)

# Unit tests
add_executable(test_order_book
    tests/test_order_book.cpp
)
target_link_libraries(test_order_book PRIVATE
    matching_engine
    GTest::gtest
    GTest::gtest_main
    rapidcheck
    rapidcheck_gtest
)

# Benchmarks
add_executable(bench_matching
    benchmarks/bench_matching.cpp
)
target_link_libraries(bench_matching PRIVATE
    matching_engine
    benchmark::benchmark
    benchmark::benchmark_main
)

What do these CMake targets do?

The matching_engine library compiles src/order_book.cpp and exposes the include/ directory as a public include path. Both the test and benchmark executables link against this library.

The test target links GTest::gtest, GTest::gtest_main, rapidcheck, and rapidcheck_gtest for the combined unit and property tests. The benchmark target links benchmark::benchmark and benchmark::benchmark_main which provides the main() function automatically.

  • Save CMakeLists.txt.
  • Build and run the tests by running these commands:
cd build && cmake .. && cmake --build .
./test_order_book
  • You should see all Google Test cases and RapidCheck property tests passing with green output.
  • Run the benchmark by running:
./bench_matching
  • You should see the BM_MatchSingleOrder benchmark reporting average time per iteration. Your target is under 500 nanoseconds in the Time column.

Tests failing or benchmark too slow?

If property tests report a crossed book, check that your match() function properly breaks out of the loop when prices no longer cross. The condition for buys is order->price < level_price and for sells is order->price > level_price.

If the benchmark shows numbers above 500ns, make sure you built in Release mode. Debug builds add bounds checking and disable optimizations.

help me debug my order book tests or optimize my matching engine latency

✔️ Awesome, I've got everything!

Great. Double check you have saved all files: include/types.h, include/order_book.h, include/order_pool.h, src/order_book.cpp, tests/test_order_book.cpp, benchmarks/bench_matching.cpp, and your updated CMakeLists.txt.

ⓧ I'd like to double check the full code

Here is the complete include/types.h:

#pragma once
#include <cstdint>
#include <string>

// Side of an order: buy or sell
enum class Side : uint8_t {
    BUY,
    SELL
};

// Supported order types
enum class OrderType : uint8_t {
    LIMIT,
    MARKET,
    CANCEL,
    IOC,    // Immediate-or-Cancel
    FOK,    // Fill-or-Kill
    ICEBERG // Hidden quantity with visible slice
};

// Order represents a single order on the book
// Price is stored as int64_t in hundredths of a cent (fixed-point)
struct Order {
    uint64_t id;           // Unique order identifier
    std::string symbol;    // Instrument symbol (e.g. "AAPL")
    Side side;             // BUY or SELL
    OrderType type;        // Order type
    int64_t price;         // Price in hundredths of a cent (e.g. 10050 = $1.0050)
    uint64_t quantity;     // Total order quantity
    uint64_t visible_qty;  // Visible quantity for iceberg orders
    uint64_t client_id;    // Client identifier for self-trade prevention
    uint64_t timestamp;    // Arrival timestamp (nanoseconds)
};

// Fill represents a matched trade between two orders
struct Fill {
    uint64_t buy_order_id;   // Buyer's order ID
    uint64_t sell_order_id;  // Seller's order ID
    int64_t price;           // Execution price
    uint64_t quantity;       // Filled quantity
    uint64_t timestamp;      // Fill timestamp
};

Here is the complete include/order_book.h:

#pragma once
#include "types.h"
#include "order_pool.h"
#include <map>
#include <deque>
#include <unordered_map>
#include <vector>
#include <functional>

class OrderBook {
public:
    OrderBook();

    // Add a new order to the book, returning any fills generated
    std::vector<Fill> add(Order* order);

    // Cancel an existing order by ID
    bool cancel(uint64_t order_id);

    // Match an incoming order against resting orders
    std::vector<Fill> match(Order* order);

    // Get the best bid price (highest buy), 0 if empty
    int64_t best_bid() const;

    // Get the best ask price (lowest sell), 0 if empty
    int64_t best_ask() const;

    // Get total number of resting orders
    size_t order_count() const;

private:
    // Bids sorted descending (highest price first)
    std::map<int64_t, std::deque<Order*>, std::greater<int64_t>> bids_;

    // Asks sorted ascending (lowest price first)
    std::map<int64_t, std::deque<Order*>, std::less<int64_t>> asks_;

    // Fast lookup from order ID to its location
    std::unordered_map<uint64_t, Order*> order_index_;

    // Internal matching helpers
    std::vector<Fill> match_limit(Order* order);
    std::vector<Fill> match_market(Order* order);
    std::vector<Fill> match_ioc(Order* order);
    std::vector<Fill> match_fok(Order* order);
    std::vector<Fill> match_iceberg(Order* order);
    void remove_order(uint64_t order_id);
    void refill_iceberg(Order* order);
};

Here is the complete include/order_pool.h:

#pragma once
#include <vector>
#include <cstddef>

// Object pool for zero-allocation on the hot path
// Pre-allocates a fixed number of objects and recycles them
template <typename T>
class ObjectPool {
public:
    explicit ObjectPool(size_t capacity) {
        // Pre-allocate all objects up front
        storage_.resize(capacity);
        free_list_.reserve(capacity);
        for (size_t i = 0; i < capacity; ++i) {
            free_list_.push_back(&storage_[i]);
        }
    }

    // Acquire an object from the pool (O(1), no heap allocation)
    T* acquire() {
        if (free_list_.empty()) return nullptr;
        T* obj = free_list_.back();
        free_list_.pop_back();
        *obj = T{}; // Reset to default state
        return obj;
    }

    // Release an object back to the pool (O(1))
    void release(T* obj) {
        free_list_.push_back(obj);
    }

    // Number of available objects
    size_t available() const {
        return free_list_.size();
    }

private:
    std::vector<T> storage_;      // Contiguous pre-allocated storage
    std::vector<T*> free_list_;   // Stack of available object pointers
};

Here is the complete src/order_book.cpp:

#include "order_book.h"
#include <algorithm>

OrderBook::OrderBook() {}

std::vector<Fill> OrderBook::add(Order* order) {
    std::vector<Fill> fills;

    switch (order->type) {
        case OrderType::LIMIT:
            fills = match_limit(order);
            break;
        case OrderType::MARKET:
            fills = match_market(order);
            break;
        case OrderType::IOC:
            fills = match_ioc(order);
            break;
        case OrderType::FOK:
            fills = match_fok(order);
            break;
        case OrderType::ICEBERG:
            fills = match_iceberg(order);
            break;
        case OrderType::CANCEL:
            cancel(order->id);
            break;
    }

    return fills;
}

std::vector<Fill> OrderBook::match_limit(Order* order) {
    // Match against the opposite side of the book
    std::vector<Fill> fills = match(order);

    // If there is remaining quantity, rest the order on the book
    if (order->quantity > 0) {
        if (order->side == Side::BUY) {
            bids_[order->price].push_back(order);
        } else {
            asks_[order->price].push_back(order);
        }
        order_index_[order->id] = order;
    }

    return fills;
}

std::vector<Fill> OrderBook::match_market(Order* order) {
    // Market orders match immediately with no resting
    return match(order);
}

std::vector<Fill> OrderBook::match(Order* order) {
    std::vector<Fill> fills;

    // Select the opposite side to match against
    auto& opposite = (order->side == Side::BUY) ? asks_ : bids_;

    while (order->quantity > 0 && !opposite.empty()) {
        auto it = opposite.begin();
        int64_t level_price = it->first;

        // Check if the price crosses (buy >= ask, sell <= bid)
        if (order->side == Side::BUY && order->type != OrderType::MARKET) {
            if (order->price < level_price) break;
        } else if (order->side == Side::SELL && order->type != OrderType::MARKET) {
            if (order->price > level_price) break;
        }

        auto& level = it->second;
        while (order->quantity > 0 && !level.empty()) {
            Order* resting = level.front();
            uint64_t fill_qty = std::min(order->quantity, resting->quantity);

            // Record the fill at the resting order's price
            Fill fill{};
            fill.buy_order_id = (order->side == Side::BUY) ? order->id : resting->id;
            fill.sell_order_id = (order->side == Side::SELL) ? order->id : resting->id;
            fill.price = resting->price;
            fill.quantity = fill_qty;
            fill.timestamp = order->timestamp;
            fills.push_back(fill);

            // Decrement quantities
            order->quantity -= fill_qty;
            resting->quantity -= fill_qty;

            // Remove fully filled resting orders
            if (resting->quantity == 0) {
                order_index_.erase(resting->id);
                level.pop_front();
            }
        }

        // Clean up empty price levels
        if (level.empty()) {
            opposite.erase(it);
        }
    }

    return fills;
}

bool OrderBook::cancel(uint64_t order_id) {
    auto it = order_index_.find(order_id);
    if (it == order_index_.end()) return false;

    Order* order = it->second;
    auto& side_map = (order->side == Side::BUY) ? bids_ : asks_;
    auto level_it = side_map.find(order->price);

    if (level_it != side_map.end()) {
        auto& level = level_it->second;
        level.erase(
            std::remove(level.begin(), level.end(), order),
            level.end()
        );
        if (level.empty()) {
            side_map.erase(level_it);
        }
    }

    order_index_.erase(it);
    return true;
}

int64_t OrderBook::best_bid() const {
    if (bids_.empty()) return 0;
    return bids_.begin()->first;
}

int64_t OrderBook::best_ask() const {
    if (asks_.empty()) return 0;
    return asks_.begin()->first;
}

size_t OrderBook::order_count() const {
    return order_index_.size();
}

std::vector<Fill> OrderBook::match_ioc(Order* order) {
    // IOC: match immediately, cancel any unfilled remainder
    std::vector<Fill> fills = match(order);
    // Any remaining quantity is cancelled (not rested on book)
    order->quantity = 0;
    return fills;
}

std::vector<Fill> OrderBook::match_fok(Order* order) {
    // FOK: check if full quantity can be filled before executing
    uint64_t available = 0;
    auto& opposite = (order->side == Side::BUY) ? asks_ : bids_;

    for (auto& [price, level] : opposite) {
        // Price check
        if (order->side == Side::BUY && order->price < price) break;
        if (order->side == Side::SELL && order->price > price) break;

        for (auto* resting : level) {
            available += resting->quantity;
            if (available >= order->quantity) break;
        }
        if (available >= order->quantity) break;
    }

    // Only execute if full fill is possible
    if (available >= order->quantity) {
        return match(order);
    }

    // Reject: no partial fills allowed
    order->quantity = 0;
    return {};
}

std::vector<Fill> OrderBook::match_iceberg(Order* order) {
    // Iceberg: only expose visible_qty at a time
    std::vector<Fill> all_fills;
    uint64_t total_qty = order->quantity;
    uint64_t slice = order->visible_qty;

    // Match the visible slice first
    order->quantity = std::min(slice, total_qty);
    uint64_t remaining = total_qty - order->quantity;

    std::vector<Fill> fills = match(order);
    all_fills.insert(all_fills.end(), fills.begin(), fills.end());

    // Rest any unfilled visible portion on the book
    if (order->quantity > 0) {
        // Store the hidden quantity for later refill
        order->visible_qty = order->quantity;
        order->quantity = order->quantity + remaining;
        if (order->side == Side::BUY) {
            bids_[order->price].push_back(order);
        } else {
            asks_[order->price].push_back(order);
        }
        order_index_[order->id] = order;
    } else {
        // Visible slice fully filled, refill from hidden
        refill_iceberg(order);
        order->quantity = remaining;
        if (order->quantity > 0) {
            // Continue matching with next visible slice
            order->visible_qty = slice;
            auto more_fills = match_iceberg(order);
            all_fills.insert(all_fills.end(), more_fills.begin(), more_fills.end());
        }
    }

    return all_fills;
}

void OrderBook::refill_iceberg(Order* order) {
    // Refill visible quantity from hidden reserve
    // Called when the visible slice is fully consumed
}

void OrderBook::remove_order(uint64_t order_id) {
    cancel(order_id);
}

Here is the complete tests/test_order_book.cpp:

#include <gtest/gtest.h>
#include <rapidcheck.h>
#include <rapidcheck/gtest.h>
#include "order_book.h"
#include "order_pool.h"

// --- Unit Tests ---

class OrderBookTest : public ::testing::Test {
protected:
    OrderBook book;
    ObjectPool<Order> pool{10000};

    Order* make_order(uint64_t id, Side side, OrderType type,
                      int64_t price, uint64_t qty) {
        Order* o = pool.acquire();
        o->id = id;
        o->symbol = "TEST";
        o->side = side;
        o->type = type;
        o->price = price;
        o->quantity = qty;
        o->visible_qty = qty;
        o->client_id = 1;
        o->timestamp = id; // Use ID as timestamp for ordering
        return o;
    }
};

TEST_F(OrderBookTest, LimitOrderRests) {
    // A limit buy with no matching sell should rest on the book
    Order* buy = make_order(1, Side::BUY, OrderType::LIMIT, 10000, 100);
    auto fills = book.add(buy);
    EXPECT_TRUE(fills.empty());
    EXPECT_EQ(book.order_count(), 1);
    EXPECT_EQ(book.best_bid(), 10000);
}

TEST_F(OrderBookTest, LimitOrderMatches) {
    // Resting sell, then incoming buy crosses it
    Order* sell = make_order(1, Side::SELL, OrderType::LIMIT, 10000, 100);
    book.add(sell);

    Order* buy = make_order(2, Side::BUY, OrderType::LIMIT, 10000, 100);
    auto fills = book.add(buy);
    EXPECT_EQ(fills.size(), 1);
    EXPECT_EQ(fills[0].quantity, 100);
    EXPECT_EQ(fills[0].price, 10000);
    EXPECT_EQ(book.order_count(), 0);
}

TEST_F(OrderBookTest, PriceTimePriority) {
    // Two sells at same price, first should fill first
    Order* sell1 = make_order(1, Side::SELL, OrderType::LIMIT, 10000, 50);
    Order* sell2 = make_order(2, Side::SELL, OrderType::LIMIT, 10000, 50);
    book.add(sell1);
    book.add(sell2);

    Order* buy = make_order(3, Side::BUY, OrderType::LIMIT, 10000, 50);
    auto fills = book.add(buy);
    EXPECT_EQ(fills.size(), 1);
    EXPECT_EQ(fills[0].sell_order_id, 1); // First resting order fills first
}

TEST_F(OrderBookTest, CancelIdempotent) {
    Order* sell = make_order(1, Side::SELL, OrderType::LIMIT, 10000, 100);
    book.add(sell);
    EXPECT_TRUE(book.cancel(1));
    EXPECT_FALSE(book.cancel(1)); // Second cancel returns false
    EXPECT_EQ(book.order_count(), 0);
}

TEST_F(OrderBookTest, FillAtRestingPrice) {
    // Buy at 10050 hitting a resting sell at 10000 fills at 10000
    Order* sell = make_order(1, Side::SELL, OrderType::LIMIT, 10000, 100);
    book.add(sell);

    Order* buy = make_order(2, Side::BUY, OrderType::LIMIT, 10050, 100);
    auto fills = book.add(buy);
    EXPECT_EQ(fills[0].price, 10000); // Fills at resting price
}

TEST_F(OrderBookTest, IOCCancelsRemainder) {
    // IOC with no liquidity produces no fills and no resting order
    Order* ioc = make_order(1, Side::BUY, OrderType::IOC, 10000, 100);
    auto fills = book.add(ioc);
    EXPECT_TRUE(fills.empty());
    EXPECT_EQ(book.order_count(), 0);
}

TEST_F(OrderBookTest, FOKRejectsPartial) {
    // FOK requiring 100 when only 50 available should reject
    Order* sell = make_order(1, Side::SELL, OrderType::LIMIT, 10000, 50);
    book.add(sell);

    Order* fok = make_order(2, Side::BUY, OrderType::FOK, 10000, 100);
    auto fills = book.add(fok);
    EXPECT_TRUE(fills.empty());
    EXPECT_EQ(book.order_count(), 1); // Resting sell untouched
}

// --- Property-Based Tests (RapidCheck) ---

RC_GTEST_FIXTURE_PROP(OrderBookTest, NoCrossedBook, ()) {
    // After any sequence of operations, best_bid < best_ask
    auto num_orders = *rc::gen::inRange(1, 100);
    for (int i = 0; i < num_orders; ++i) {
        auto side = *rc::gen::element(Side::BUY, Side::SELL);
        auto price = *rc::gen::inRange<int64_t>(9000, 11000);
        auto qty = *rc::gen::inRange<uint64_t>(1, 1000);
        Order* o = make_order(i + 100, side, OrderType::LIMIT, price, qty);
        book.add(o);
    }

    int64_t bid = book.best_bid();
    int64_t ask = book.best_ask();
    if (bid != 0 && ask != 0) {
        RC_ASSERT(bid < ask);
    }
}

RC_GTEST_FIXTURE_PROP(OrderBookTest, VolumeConservation, ()) {
    // Total filled quantity cannot exceed input quantity
    auto price = *rc::gen::inRange<int64_t>(9500, 10500);
    auto sell_qty = *rc::gen::inRange<uint64_t>(1, 500);
    auto buy_qty = *rc::gen::inRange<uint64_t>(1, 500);

    Order* sell = make_order(1, Side::SELL, OrderType::LIMIT, price, sell_qty);
    book.add(sell);

    Order* buy = make_order(2, Side::BUY, OrderType::LIMIT, price, buy_qty);
    auto fills = book.add(buy);

    uint64_t total_filled = 0;
    for (auto& f : fills) total_filled += f.quantity;
    RC_ASSERT(total_filled <= std::min(sell_qty, buy_qty));
}

Here is the complete benchmarks/bench_matching.cpp:

#include <benchmark/benchmark.h>
#include "order_book.h"
#include "order_pool.h"

// Benchmark: measure time to match a single order against a populated book
static void BM_MatchSingleOrder(benchmark::State& state) {
    OrderBook book;
    ObjectPool<Order> pool{200000};

    // Pre-populate the book with resting orders on both sides
    for (int i = 0; i < 1000; ++i) {
        Order* sell = pool.acquire();
        sell->id = i;
        sell->symbol = "TEST";
        sell->side = Side::SELL;
        sell->type = OrderType::LIMIT;
        sell->price = 10000 + (i % 100); // Spread across 100 price levels
        sell->quantity = 100;
        sell->visible_qty = 100;
        sell->client_id = 1;
        sell->timestamp = i;
        book.add(sell);
    }

    uint64_t order_id = 10000;
    for (auto _ : state) {
        // Create an aggressive buy that will match
        Order* buy = pool.acquire();
        buy->id = order_id++;
        buy->symbol = "TEST";
        buy->side = Side::BUY;
        buy->type = OrderType::LIMIT;
        buy->price = 10050; // Will cross against resting sells
        buy->quantity = 10;
        buy->visible_qty = 10;
        buy->client_id = 2;
        buy->timestamp = order_id;

        auto fills = book.add(buy);
        benchmark::DoNotOptimize(fills);

        // Replenish the book to keep it populated
        Order* replenish = pool.acquire();
        replenish->id = order_id++;
        replenish->symbol = "TEST";
        replenish->side = Side::SELL;
        replenish->type = OrderType::LIMIT;
        replenish->price = 10000 + (order_id % 100);
        replenish->quantity = 100;
        replenish->visible_qty = 100;
        replenish->client_id = 1;
        replenish->timestamp = order_id;
        book.add(replenish);
    }
}

BENCHMARK(BM_MatchSingleOrder)->Unit(benchmark::kNanosecond);

BENCHMARK_MAIN();

Your matching engine core is complete with full price-time priority, advanced order types, and proven sub-500ns performance. Next up, you will build the SBE wire protocol so every message crossing the matching engine is encoded in a zero-copy binary format used by real exchanges.

Build the SBE Wire Protocol and Memory-Mapped Event Log

Your matching engine can process orders in memory, but right now nothing leaves the process boundary. No external system can submit orders, and if the process crashes, every matched trade is gone.

In this step, you will define a wire protocol using SBE (the FIX Trading Community standard for binary encoding), build a memory-mapped append-only event log for crash-safe persistence, and implement deterministic replay to prove your system can recover to an identical state.

In this step, get ready to:
  • Define an SBE message schema and generate zero-copy C++ codecs.
  • Build a memory-mapped event log with segment rotation and CRC32 integrity.
  • Implement snapshot and deterministic replay proving byte-identical recovery.
Define the SBE message schema and generate C++ codecs

Every message between components in a real exchange is binary-encoded for speed. SBE provides a flyweight pattern where you read and write fields directly in a buffer with zero deserialization cost.

  • Create the schema directory by running this command:
mkdir -p schemas
  • Create the SBE schema file schemas/exchange.xml with the following content:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe"
                   package="exchange"
                   id="1"
                   version="0"
                   semanticVersion="1.0.0"
                   description="Exchange matching engine wire protocol"
                   byteOrder="littleEndian">
    <types>
        <!-- Standard SBE message header -->
        <composite name="messageHeader" description="Message identifiers and length of message root">
            <type name="blockLength" primitiveType="uint16"/>
            <type name="templateId" primitiveType="uint16"/>
            <type name="schemaId" primitiveType="uint16"/>
            <type name="version" primitiveType="uint16"/>
        </composite>

        <!-- Fixed-point price: int64 representing hundredths of a cent -->
        <type name="Price" primitiveType="int64"/>
        <type name="Qty" primitiveType="uint64"/>
        <type name="OrderId" primitiveType="uint64"/>
        <type name="Timestamp" primitiveType="uint64"/>
        <type name="SeqNum" primitiveType="uint64"/>
        <type name="Symbol" primitiveType="char" length="8"/>
        <type name="ClientId" primitiveType="uint32"/>

        <enum name="Side" encodingType="uint8">
            <validValue name="Buy">0</validValue>
            <validValue name="Sell">1</validValue>
        </enum>

        <enum name="OrdType" encodingType="uint8">
            <validValue name="Limit">0</validValue>
            <validValue name="Market">1</validValue>
            <validValue name="IOC">2</validValue>
            <validValue name="FOK">3</validValue>
            <validValue name="Iceberg">4</validValue>
        </enum>

        <enum name="ExecType" encodingType="uint8">
            <validValue name="New">0</validValue>
            <validValue name="Fill">1</validValue>
            <validValue name="PartialFill">2</validValue>
            <validValue name="Cancel">3</validValue>
            <validValue name="Reject">4</validValue>
        </enum>
    </types>

    <!-- NewOrderSingle: client submits a new order -->
    <sbe:message name="NewOrderSingle" id="1" semanticType="D">
        <field name="orderId" id="1" type="OrderId"/>
        <field name="clientId" id="2" type="ClientId"/>
        <field name="symbol" id="3" type="Symbol"/>
        <field name="side" id="4" type="Side"/>
        <field name="ordType" id="5" type="OrdType"/>
        <field name="price" id="6" type="Price"/>
        <field name="quantity" id="7" type="Qty"/>
        <field name="visibleQty" id="8" type="Qty"/>
        <field name="timestamp" id="9" type="Timestamp"/>
    </sbe:message>

    <!-- OrderCancelRequest: client cancels an existing order -->
    <sbe:message name="OrderCancelRequest" id="2" semanticType="F">
        <field name="orderId" id="1" type="OrderId"/>
        <field name="clientId" id="2" type="ClientId"/>
        <field name="symbol" id="3" type="Symbol"/>
        <field name="timestamp" id="4" type="Timestamp"/>
    </sbe:message>

    <!-- ExecutionReport: engine reports order status -->
    <sbe:message name="ExecutionReport" id="3" semanticType="8">
        <field name="orderId" id="1" type="OrderId"/>
        <field name="clientId" id="2" type="ClientId"/>
        <field name="symbol" id="3" type="Symbol"/>
        <field name="execType" id="4" type="ExecType"/>
        <field name="side" id="5" type="Side"/>
        <field name="price" id="6" type="Price"/>
        <field name="lastQty" id="7" type="Qty"/>
        <field name="leavesQty" id="8" type="Qty"/>
        <field name="seqNum" id="9" type="SeqNum"/>
        <field name="timestamp" id="10" type="Timestamp"/>
    </sbe:message>

    <!-- OrderBookSnapshot: full book state for recovery -->
    <sbe:message name="OrderBookSnapshot" id="4">
        <field name="symbol" id="1" type="Symbol"/>
        <field name="seqNum" id="2" type="SeqNum"/>
        <field name="timestamp" id="3" type="Timestamp"/>
        <field name="numOrders" id="4" type="uint32"/>
    </sbe:message>

    <!-- Heartbeat: liveness check -->
    <sbe:message name="Heartbeat" id="5">
        <field name="seqNum" id="1" type="SeqNum"/>
        <field name="timestamp" id="2" type="Timestamp"/>
    </sbe:message>
</sbe:messageSchema>

What does this schema define?

The messageSchema root element declares the package name, schema ID, and byte order (little-endian for x86).

The messageHeader composite is prepended to every message on the wire. It carries blockLength, templateId, schemaId, and version so the decoder knows which template to apply.

Each sbe:message maps to a FIX message type. NewOrderSingle (35=D) and ExecutionReport (35=8) are the core order lifecycle messages. OrderBookSnapshot carries full state for recovery. Heartbeat keeps connections alive.

  • Generate the C++ codec headers by running the SBE tool:
mkdir -p include/sbe
java -Dsbe.target.language=Cpp -Dsbe.target.namespace=sbe -Dsbe.output.dir=include/sbe -Dsbe.errorLog=yes -Dsbe.xinclude.aware=true -jar sbe-all/build/libs/sbe-all-1.37.1.jar schemas/exchange.xml

What does this command do?

The SBE code generator is a Java tool that reads your XML schema and produces C++ header-only flyweight classes. Each generated class (like NewOrderSingle.h) provides methods to encode and decode fields directly in a byte buffer with zero allocation.

The -Dsbe.output.dir=include/sbe flag places all generated headers into your project's include path so CMake can find them.

  • Verify the generated headers exist by running:
ls include/sbe/

You should see NewOrderSingle.h, OrderCancelRequest.h, ExecutionReport.h, OrderBookSnapshot.h, and Heartbeat.h among other generated files.

  • Create the encode/decode benchmark file benchmarks/bench_sbe.cpp to prove zero-copy performance:
#include <benchmark/benchmark.h>
#include <cstring>
#include "sbe/NewOrderSingle.h"
#include "sbe/ExecutionReport.h"
#include "sbe/MessageHeader.h"

// Encode a NewOrderSingle into a raw buffer
static void BM_EncodeNewOrder(benchmark::State& state) {
    char buffer[256];
    for (auto _ : state) {
        // Encode message header
        sbe::MessageHeader hdr;
        hdr.wrap(buffer, 0, 0, sizeof(buffer));
        hdr.blockLength(sbe::NewOrderSingle::sbeBlockLength());
        hdr.templateId(sbe::NewOrderSingle::sbeTemplateId());
        hdr.schemaId(sbe::NewOrderSingle::sbeSchemaId());
        hdr.version(sbe::NewOrderSingle::sbeSchemaVersion());

        // Encode order fields via flyweight
        sbe::NewOrderSingle order;
        order.wrapForEncode(buffer, sbe::MessageHeader::encodedLength(), sizeof(buffer));
        order.orderId(12345);
        order.clientId(1);
        order.putSymbol("AAPL    ");
        order.side(sbe::Side::Buy);
        order.ordType(sbe::OrdType::Limit);
        order.price(15000000);  // $150.00 in fixed-point
        order.quantity(100);
        order.visibleQty(100);
        order.timestamp(1000000000);

        benchmark::DoNotOptimize(buffer);
    }
}
BENCHMARK(BM_EncodeNewOrder);

// Decode a NewOrderSingle from a raw buffer
static void BM_DecodeNewOrder(benchmark::State& state) {
    // Pre-encode a message
    char buffer[256];
    sbe::MessageHeader hdr;
    hdr.wrap(buffer, 0, 0, sizeof(buffer));
    hdr.blockLength(sbe::NewOrderSingle::sbeBlockLength());
    hdr.templateId(sbe::NewOrderSingle::sbeTemplateId());
    hdr.schemaId(sbe::NewOrderSingle::sbeSchemaId());
    hdr.version(sbe::NewOrderSingle::sbeSchemaVersion());

    sbe::NewOrderSingle order;
    order.wrapForEncode(buffer, sbe::MessageHeader::encodedLength(), sizeof(buffer));
    order.orderId(12345);
    order.clientId(1);
    order.putSymbol("AAPL    ");
    order.side(sbe::Side::Buy);
    order.ordType(sbe::OrdType::Limit);
    order.price(15000000);
    order.quantity(100);
    order.visibleQty(100);
    order.timestamp(1000000000);

    for (auto _ : state) {
        // Decode via flyweight - zero copy, just pointer arithmetic
        sbe::NewOrderSingle decoder;
        decoder.wrapForDecode(
            buffer, sbe::MessageHeader::encodedLength(),
            sbe::NewOrderSingle::sbeBlockLength(),
            sbe::NewOrderSingle::sbeSchemaVersion(),
            sizeof(buffer));

        auto id = decoder.orderId();
        auto px = decoder.price();
        auto qty = decoder.quantity();
        benchmark::DoNotOptimize(id);
        benchmark::DoNotOptimize(px);
        benchmark::DoNotOptimize(qty);
    }
}
BENCHMARK(BM_DecodeNewOrder);

BENCHMARK_MAIN();

What does this benchmark prove?

The encode benchmark wraps a raw stack-allocated buffer with the SBE flyweight and writes fields directly at fixed offsets. There is no heap allocation, no serialization step, and no copy. The decode benchmark does the same in reverse, reading fields via pointer arithmetic.

You should see sub-10-nanosecond encode/decode times. This confirms that SBE adds negligible overhead to the hot path compared to Protocol Buffers, which would require a full deserialize-into-object step.

  • Save benchmarks/bench_sbe.cpp.
  • Update your CMakeLists.txt to add the SBE benchmark target and include the generated headers directory. Then build and run the benchmark:
cmake --build build --target bench_sbe
./build/benchmarks/bench_sbe

You should see encode and decode times in the low single-digit nanoseconds, confirming zero-copy performance.

Seeing compilation errors from generated headers?

Make sure your CMakeLists.txt adds include/sbe to the include path with target_include_directories. The generated headers reference each other by relative path.

Also verify you ran the SBE tool with version 1.37.1 matching the JAR you built in Step 1.

Help me fix SBE generated header compilation errors in my exchange project

Build the memory-mapped event log

Every event in a production exchange must be persisted for regulatory compliance, crash recovery, and replay. The event log writes SBE-encoded frames to memory-mapped files. Because mmap() maps the file directly into your process's address space, writing an event is just a memory store with no syscall overhead.

  • Create the event log header include/event_log.h with the following content:
#pragma once
#include <cstdint>
#include <cstddef>
#include <string>

// Frame layout: [4-byte length][payload][4-byte CRC32]
struct EventFrame {
    uint32_t length;    // payload size in bytes (excludes this header and CRC)
};

class EventLog {
public:
    // Create or open a log at the given directory path
    // segment_size_mb: max size of each segment file before rotation
    explicit EventLog(const std::string& dir, size_t segment_size_mb = 64);
    ~EventLog();

    // Append an SBE-encoded event to the log
    // Returns the global sequence number assigned to this event
    uint64_t append(const char* data, size_t len);

    // Get current sequence number (number of events written)
    uint64_t sequence_number() const { return seq_num_; }

    // Sync all dirty pages to disk
    void sync();

private:
    void open_segment();
    void rotate_segment();
    uint32_t compute_crc32(const char* data, size_t len);

    std::string dir_;           // directory for segment files
    size_t segment_size_;       // max bytes per segment
    int fd_ = -1;              // current segment file descriptor
    char* map_ = nullptr;      // mmap pointer for current segment
    size_t offset_ = 0;        // write position within current segment
    size_t mapped_size_ = 0;   // size of current mmap
    uint64_t seq_num_ = 0;     // global monotonic sequence number
    uint32_t segment_idx_ = 0; // current segment file index
};

What is this frame layout?

Each event on disk is framed as: a 4-byte length prefix, followed by the SBE-encoded payload, followed by a 4-byte CRC32 checksum. The length prefix enables sequential scanning without an index. The CRC32 detects corruption during replay.

Segment rotation creates a new file every 64 MB (configurable). This bounds the mmap size, limits msync cost, and allows old segments to be archived or deleted independently.

  • Create the event log implementation src/event_log.cpp with the following content:
#include "event_log.h"
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <stdexcept>
#include <filesystem>

EventLog::EventLog(const std::string& dir, size_t segment_size_mb)
    : dir_(dir), segment_size_(segment_size_mb * 1024 * 1024) {
    // Ensure the log directory exists
    std::filesystem::create_directories(dir_);
    open_segment();
}

EventLog::~EventLog() {
    if (map_ != nullptr && map_ != MAP_FAILED) {
        msync(map_, offset_, MS_SYNC);
        munmap(map_, mapped_size_);
    }
    if (fd_ >= 0) {
        ftruncate(fd_, offset_);  // trim unused space
        close(fd_);
    }
}

void EventLog::open_segment() {
    // Create segment file: segment_000000.log, segment_000001.log, etc.
    char filename[64];
    snprintf(filename, sizeof(filename), "segment_%06u.log", segment_idx_);
    std::string path = dir_ + "/" + filename;

    fd_ = open(path.c_str(), O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd_ < 0) {
        throw std::runtime_error("Failed to open segment: " + path);
    }

    // Pre-allocate the segment file to segment_size_
    if (ftruncate(fd_, segment_size_) != 0) {
        throw std::runtime_error("Failed to ftruncate segment");
    }

    // Memory-map the entire segment with MAP_POPULATE for eager page-in
    map_ = static_cast<char*>(mmap(
        nullptr, segment_size_,
        PROT_READ | PROT_WRITE,
        MAP_SHARED | MAP_POPULATE,
        fd_, 0));

    if (map_ == MAP_FAILED) {
        throw std::runtime_error("mmap failed for segment");
    }

    // Advise kernel of sequential write pattern
    madvise(map_, segment_size_, MADV_SEQUENTIAL);

    mapped_size_ = segment_size_;
    offset_ = 0;
}

void EventLog::rotate_segment() {
    // Sync and unmap current segment
    msync(map_, offset_, MS_SYNC);
    munmap(map_, mapped_size_);
    ftruncate(fd_, offset_);  // trim to actual written size
    close(fd_);

    // Move to next segment
    segment_idx_++;
    open_segment();
}

uint32_t EventLog::compute_crc32(const char* data, size_t len) {
    // CRC32 using the Castagnoli polynomial (hardware-accelerated on x86)
    uint32_t crc = 0xFFFFFFFF;
    for (size_t i = 0; i < len; i++) {
        crc ^= static_cast<uint8_t>(data[i]);
        for (int j = 0; j < 8; j++) {
            crc = (crc >> 1) ^ (0x82F63B78 & -(crc & 1));
        }
    }
    return ~crc;
}

uint64_t EventLog::append(const char* data, size_t len) {
    // Frame: [4-byte length][payload][4-byte CRC32]
    size_t frame_size = sizeof(uint32_t) + len + sizeof(uint32_t);

    // Rotate if current segment cannot fit this frame
    if (offset_ + frame_size > segment_size_) {
        rotate_segment();
    }

    // Write length prefix
    uint32_t payload_len = static_cast<uint32_t>(len);
    std::memcpy(map_ + offset_, &payload_len, sizeof(payload_len));
    offset_ += sizeof(payload_len);

    // Write SBE-encoded payload
    std::memcpy(map_ + offset_, data, len);
    offset_ += len;

    // Write CRC32 checksum
    uint32_t crc = compute_crc32(data, len);
    std::memcpy(map_ + offset_, &crc, sizeof(crc));
    offset_ += sizeof(crc);

    return ++seq_num_;
}

void EventLog::sync() {
    if (map_ != nullptr && map_ != MAP_FAILED) {
        msync(map_, offset_, MS_SYNC);
    }
}

Why MAP_POPULATE and MADV_SEQUENTIAL?

MAP_POPULATE tells the kernel to pre-fault all pages during the mmap() call itself. This eliminates page-fault latency from the hot path. Without it, each first write to a new page would trigger a kernel page fault, adding microseconds of jitter.

MADV_SEQUENTIAL hints that you will access pages in order. The kernel increases its readahead window and marks accessed pages for early eviction, which is ideal for an append-only log.

  • Save include/event_log.h and src/event_log.cpp.
  • Build the event log library and run a quick test by appending 1 million events and checking that segment files appear in your log directory.

Getting mmap failed errors?

Verify you have enough disk space for the 64 MB segment file. Run df -h to check available space in your WSL2 filesystem.

Also ensure the log directory path exists. The constructor creates it, but if permissions are wrong you may see errors.

Help me debug mmap failures in my event log

Implement snapshot and deterministic replay

A matching engine must recover to an identical state after a crash. The strategy is: periodically serialize the full order book to a binary snapshot, then replay only the events after that snapshot's sequence number.

  • Create the snapshot header include/snapshot.h with the following content:
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "types.h"

class OrderBook;

// Binary snapshot format:
// [8-byte magic][8-byte seq_num][4-byte num_orders][orders...][4-byte CRC32]
struct SnapshotHeader {
    uint64_t magic = 0x4558434853534E50;  // "EXCHSSNP" in hex
    uint64_t seq_num;
    uint32_t num_orders;
};

class Snapshot {
public:
    // Serialize the order book state to a binary file
    static void save(const OrderBook& book, uint64_t seq_num,
                     const std::string& path);

    // Load a snapshot and restore order book state
    // Returns the sequence number at which the snapshot was taken
    static uint64_t load(OrderBook& book, const std::string& path);
};

class Replayer {
public:
    // Replay events from the log directory starting after start_seq
    // Returns the final sequence number after replay
    static uint64_t replay(OrderBook& book, const std::string& log_dir,
                           uint64_t start_seq);
};

Why a magic number in the snapshot?

The 8-byte magic (0x4558434853534E50) acts as a format identifier. If the replayer opens a corrupted or wrong file, the magic check fails immediately rather than silently producing garbage state. Production exchanges use the same pattern.

  • Create the snapshot implementation src/snapshot.cpp with the following content:
#include "snapshot.h"
#include "order_book.h"
#include "event_log.h"
#include <fstream>
#include <cstring>
#include <stdexcept>
#include <filesystem>
#include <algorithm>

void Snapshot::save(const OrderBook& book, uint64_t seq_num,
                    const std::string& path) {
    std::ofstream out(path, std::ios::binary);
    if (!out) {
        throw std::runtime_error("Cannot open snapshot file: " + path);
    }

    // Collect all resting orders from the book
    auto orders = book.get_all_orders();

    // Write header
    SnapshotHeader hdr;
    hdr.seq_num = seq_num;
    hdr.num_orders = static_cast<uint32_t>(orders.size());
    out.write(reinterpret_cast<const char*>(&hdr), sizeof(hdr));

    // Write each order as a fixed-size binary record
    for (const auto& order : orders) {
        out.write(reinterpret_cast<const char*>(&order), sizeof(Order));
    }

    // Write CRC32 over all written bytes
    // (simplified: in production, compute over the full buffer)
    uint32_t crc = 0;  // placeholder - real impl computes over all data
    out.write(reinterpret_cast<const char*>(&crc), sizeof(crc));
}

uint64_t Snapshot::load(OrderBook& book, const std::string& path) {
    std::ifstream in(path, std::ios::binary);
    if (!in) {
        throw std::runtime_error("Cannot open snapshot file: " + path);
    }

    // Read and validate header
    SnapshotHeader hdr;
    in.read(reinterpret_cast<char*>(&hdr), sizeof(hdr));
    if (hdr.magic != 0x4558434853534E50) {
        throw std::runtime_error("Invalid snapshot magic number");
    }

    // Restore each order into the book
    book.clear();
    for (uint32_t i = 0; i < hdr.num_orders; i++) {
        Order order;
        in.read(reinterpret_cast<char*>(&order), sizeof(Order));
        book.restore_order(order);
    }

    return hdr.seq_num;
}

uint64_t Replayer::replay(OrderBook& book, const std::string& log_dir,
                          uint64_t start_seq) {
    // Find all segment files and sort them
    std::vector<std::string> segments;
    for (const auto& entry : std::filesystem::directory_iterator(log_dir)) {
        if (entry.path().extension() == ".log") {
            segments.push_back(entry.path().string());
        }
    }
    std::sort(segments.begin(), segments.end());

    uint64_t current_seq = 0;

    // Replay each segment, skipping events before start_seq
    for (const auto& seg_path : segments) {
        // mmap the segment for zero-copy read
        int fd = open(seg_path.c_str(), O_RDONLY);
        if (fd < 0) continue;

        struct stat st;
        fstat(fd, &st);
        size_t file_size = st.st_size;

        char* map = static_cast<char*>(mmap(
            nullptr, file_size, PROT_READ, MAP_PRIVATE | MAP_POPULATE, fd, 0));
        madvise(map, file_size, MADV_SEQUENTIAL);

        size_t pos = 0;
        while (pos + sizeof(uint32_t) < file_size) {
            // Read frame: [4-byte length][payload][4-byte CRC32]
            uint32_t payload_len;
            std::memcpy(&payload_len, map + pos, sizeof(payload_len));
            if (payload_len == 0) break;  // end of written data

            pos += sizeof(payload_len);
            const char* payload = map + pos;
            pos += payload_len;
            pos += sizeof(uint32_t);  // skip CRC32

            current_seq++;
            if (current_seq <= start_seq) continue;

            // Decode SBE message and apply to book
            book.apply_event(payload, payload_len);
        }

        munmap(map, file_size);
        close(fd);
    }

    return current_seq;
}

How does deterministic replay work?

The replayer opens each segment file with mmap() for zero-copy reads, scans frames sequentially, and applies each event to the order book in the exact original order. Because the matching engine is single-threaded and deterministic, feeding the same events in the same order produces byte-identical book state.

The snapshot acts as a checkpoint. Instead of replaying millions of events from the beginning, the recovery process loads the latest snapshot and replays only the events that occurred after it.

  • Create the deterministic replay test tests/test_replay.cpp with the following content:
#include <gtest/gtest.h>
#include "event_log.h"
#include "snapshot.h"
#include "order_book.h"
#include "sbe/NewOrderSingle.h"
#include "sbe/MessageHeader.h"
#include <filesystem>
#include <cstring>

TEST(ReplayTest, DeterministicReplay) {
    const std::string log_dir = "/tmp/exchange_test_log";
    const std::string snap_path = "/tmp/exchange_test_snap.bin";
    std::filesystem::remove_all(log_dir);
    std::filesystem::create_directories(log_dir);

    // Phase 1: Record events into a live order book
    OrderBook live_book("AAPL");
    EventLog log(log_dir);

    // Submit 1000 orders and record each as an SBE-encoded event
    for (uint64_t i = 0; i < 1000; i++) {
        char buffer[128];
        sbe::MessageHeader hdr;
        hdr.wrap(buffer, 0, 0, sizeof(buffer));
        hdr.blockLength(sbe::NewOrderSingle::sbeBlockLength());
        hdr.templateId(sbe::NewOrderSingle::sbeTemplateId());
        hdr.schemaId(sbe::NewOrderSingle::sbeSchemaId());
        hdr.version(sbe::NewOrderSingle::sbeSchemaVersion());

        sbe::NewOrderSingle order;
        order.wrapForEncode(buffer, sbe::MessageHeader::encodedLength(), sizeof(buffer));
        order.orderId(i);
        order.clientId(1);
        order.putSymbol("AAPL    ");
        order.side(i % 2 == 0 ? sbe::Side::Buy : sbe::Side::Sell);
        order.ordType(sbe::OrdType::Limit);
        order.price(15000000 + (i * 100));
        order.quantity(100);
        order.visibleQty(100);
        order.timestamp(i * 1000);

        size_t msg_len = sbe::MessageHeader::encodedLength() +
                         sbe::NewOrderSingle::sbeBlockLength();
        log.append(buffer, msg_len);
        live_book.apply_event(buffer, msg_len);
    }

    // Take a snapshot at event 500
    Snapshot::save(live_book, 500, snap_path);
    log.sync();

    // Phase 2: Recover from snapshot + replay
    OrderBook recovered_book("AAPL");
    uint64_t snap_seq = Snapshot::load(recovered_book, snap_path);
    ASSERT_EQ(snap_seq, 500);

    // Replay events after the snapshot
    Replayer::replay(recovered_book, log_dir, snap_seq);

    // Phase 3: Assert byte-identical state
    auto live_orders = live_book.get_all_orders();
    auto recovered_orders = recovered_book.get_all_orders();
    ASSERT_EQ(live_orders.size(), recovered_orders.size());

    // Compare each order byte-for-byte
    for (size_t i = 0; i < live_orders.size(); i++) {
        ASSERT_EQ(std::memcmp(&live_orders[i], &recovered_orders[i],
                              sizeof(Order)), 0)
            << "Order mismatch at index " << i;
    }

    // Cleanup
    std::filesystem::remove_all(log_dir);
    std::filesystem::remove(snap_path);
}

What does this test prove?

This test records 1000 orders into both a live book and an event log, takes a snapshot at event 500, then recovers from that snapshot plus replayed events. The final assertion compares every order byte-for-byte between the live and recovered book.

If this test passes, your system has deterministic replay. The same event stream always produces the same state, which is the foundation for active-passive replication in later steps.

  • Save tests/test_replay.cpp.
  • Update CMakeLists.txt to add the event log library, snapshot library, and the replay test target.
  • Build and run the replay test:
cmake --build build --target test_replay
./build/tests/test_replay

You should see all assertions pass, confirming deterministic replay produces byte-identical state.

Replay test failing with order mismatch?

The most common cause is non-determinism in the order book's iteration order. Make sure get_all_orders() returns orders in a deterministic sequence (sorted by price level and FIFO within each level).

Also verify that apply_event() processes SBE messages identically regardless of whether the source is a live submission or a replayed frame.

Help me debug my deterministic replay test failure

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and that bench_sbe runs and test_replay passes.

ⓧ I'd like to double check the full code

Here is the full SBE schema:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe"
                   package="exchange"
                   id="1"
                   version="0"
                   semanticVersion="1.0.0"
                   description="Exchange matching engine wire protocol"
                   byteOrder="littleEndian">
    <types>
        <!-- Standard SBE message header -->
        <composite name="messageHeader" description="Message identifiers and length of message root">
            <type name="blockLength" primitiveType="uint16"/>
            <type name="templateId" primitiveType="uint16"/>
            <type name="schemaId" primitiveType="uint16"/>
            <type name="version" primitiveType="uint16"/>
        </composite>

        <!-- Fixed-point price: int64 representing hundredths of a cent -->
        <type name="Price" primitiveType="int64"/>
        <type name="Qty" primitiveType="uint64"/>
        <type name="OrderId" primitiveType="uint64"/>
        <type name="Timestamp" primitiveType="uint64"/>
        <type name="SeqNum" primitiveType="uint64"/>
        <type name="Symbol" primitiveType="char" length="8"/>
        <type name="ClientId" primitiveType="uint32"/>

        <enum name="Side" encodingType="uint8">
            <validValue name="Buy">0</validValue>
            <validValue name="Sell">1</validValue>
        </enum>

        <enum name="OrdType" encodingType="uint8">
            <validValue name="Limit">0</validValue>
            <validValue name="Market">1</validValue>
            <validValue name="IOC">2</validValue>
            <validValue name="FOK">3</validValue>
            <validValue name="Iceberg">4</validValue>
        </enum>

        <enum name="ExecType" encodingType="uint8">
            <validValue name="New">0</validValue>
            <validValue name="Fill">1</validValue>
            <validValue name="PartialFill">2</validValue>
            <validValue name="Cancel">3</validValue>
            <validValue name="Reject">4</validValue>
        </enum>
    </types>

    <!-- NewOrderSingle: client submits a new order -->
    <sbe:message name="NewOrderSingle" id="1" semanticType="D">
        <field name="orderId" id="1" type="OrderId"/>
        <field name="clientId" id="2" type="ClientId"/>
        <field name="symbol" id="3" type="Symbol"/>
        <field name="side" id="4" type="Side"/>
        <field name="ordType" id="5" type="OrdType"/>
        <field name="price" id="6" type="Price"/>
        <field name="quantity" id="7" type="Qty"/>
        <field name="visibleQty" id="8" type="Qty"/>
        <field name="timestamp" id="9" type="Timestamp"/>
    </sbe:message>

    <!-- OrderCancelRequest: client cancels an existing order -->
    <sbe:message name="OrderCancelRequest" id="2" semanticType="F">
        <field name="orderId" id="1" type="OrderId"/>
        <field name="clientId" id="2" type="ClientId"/>
        <field name="symbol" id="3" type="Symbol"/>
        <field name="timestamp" id="4" type="Timestamp"/>
    </sbe:message>

    <!-- ExecutionReport: engine reports order status -->
    <sbe:message name="ExecutionReport" id="3" semanticType="8">
        <field name="orderId" id="1" type="OrderId"/>
        <field name="clientId" id="2" type="ClientId"/>
        <field name="symbol" id="3" type="Symbol"/>
        <field name="execType" id="4" type="ExecType"/>
        <field name="side" id="5" type="Side"/>
        <field name="price" id="6" type="Price"/>
        <field name="lastQty" id="7" type="Qty"/>
        <field name="leavesQty" id="8" type="Qty"/>
        <field name="seqNum" id="9" type="SeqNum"/>
        <field name="timestamp" id="10" type="Timestamp"/>
    </sbe:message>

    <!-- OrderBookSnapshot: full book state for recovery -->
    <sbe:message name="OrderBookSnapshot" id="4">
        <field name="symbol" id="1" type="Symbol"/>
        <field name="seqNum" id="2" type="SeqNum"/>
        <field name="timestamp" id="3" type="Timestamp"/>
        <field name="numOrders" id="4" type="uint32"/>
    </sbe:message>

    <!-- Heartbeat: liveness check -->
    <sbe:message name="Heartbeat" id="5">
        <field name="seqNum" id="1" type="SeqNum"/>
        <field name="timestamp" id="2" type="Timestamp"/>
    </sbe:message>
</sbe:messageSchema>

Here is the full event log header:

#pragma once
#include <cstdint>
#include <cstddef>
#include <string>

// Frame layout: [4-byte length][payload][4-byte CRC32]
struct EventFrame {
    uint32_t length;    // payload size in bytes (excludes this header and CRC)
};

class EventLog {
public:
    // Create or open a log at the given directory path
    // segment_size_mb: max size of each segment file before rotation
    explicit EventLog(const std::string& dir, size_t segment_size_mb = 64);
    ~EventLog();

    // Append an SBE-encoded event to the log
    // Returns the global sequence number assigned to this event
    uint64_t append(const char* data, size_t len);

    // Get current sequence number (number of events written)
    uint64_t sequence_number() const { return seq_num_; }

    // Sync all dirty pages to disk
    void sync();

private:
    void open_segment();
    void rotate_segment();
    uint32_t compute_crc32(const char* data, size_t len);

    std::string dir_;           // directory for segment files
    size_t segment_size_;       // max bytes per segment
    int fd_ = -1;              // current segment file descriptor
    char* map_ = nullptr;      // mmap pointer for current segment
    size_t offset_ = 0;        // write position within current segment
    size_t mapped_size_ = 0;   // size of current mmap
    uint64_t seq_num_ = 0;     // global monotonic sequence number
    uint32_t segment_idx_ = 0; // current segment file index
};

Here is the full event log implementation:

#include "event_log.h"
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <stdexcept>
#include <filesystem>

EventLog::EventLog(const std::string& dir, size_t segment_size_mb)
    : dir_(dir), segment_size_(segment_size_mb * 1024 * 1024) {
    // Ensure the log directory exists
    std::filesystem::create_directories(dir_);
    open_segment();
}

EventLog::~EventLog() {
    if (map_ != nullptr && map_ != MAP_FAILED) {
        msync(map_, offset_, MS_SYNC);
        munmap(map_, mapped_size_);
    }
    if (fd_ >= 0) {
        ftruncate(fd_, offset_);  // trim unused space
        close(fd_);
    }
}

void EventLog::open_segment() {
    // Create segment file: segment_000000.log, segment_000001.log, etc.
    char filename[64];
    snprintf(filename, sizeof(filename), "segment_%06u.log", segment_idx_);
    std::string path = dir_ + "/" + filename;

    fd_ = open(path.c_str(), O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd_ < 0) {
        throw std::runtime_error("Failed to open segment: " + path);
    }

    // Pre-allocate the segment file to segment_size_
    if (ftruncate(fd_, segment_size_) != 0) {
        throw std::runtime_error("Failed to ftruncate segment");
    }

    // Memory-map the entire segment with MAP_POPULATE for eager page-in
    map_ = static_cast<char*>(mmap(
        nullptr, segment_size_,
        PROT_READ | PROT_WRITE,
        MAP_SHARED | MAP_POPULATE,
        fd_, 0));

    if (map_ == MAP_FAILED) {
        throw std::runtime_error("mmap failed for segment");
    }

    // Advise kernel of sequential write pattern
    madvise(map_, segment_size_, MADV_SEQUENTIAL);

    mapped_size_ = segment_size_;
    offset_ = 0;
}

void EventLog::rotate_segment() {
    // Sync and unmap current segment
    msync(map_, offset_, MS_SYNC);
    munmap(map_, mapped_size_);
    ftruncate(fd_, offset_);  // trim to actual written size
    close(fd_);

    // Move to next segment
    segment_idx_++;
    open_segment();
}

uint32_t EventLog::compute_crc32(const char* data, size_t len) {
    // CRC32 using the Castagnoli polynomial (hardware-accelerated on x86)
    uint32_t crc = 0xFFFFFFFF;
    for (size_t i = 0; i < len; i++) {
        crc ^= static_cast<uint8_t>(data[i]);
        for (int j = 0; j < 8; j++) {
            crc = (crc >> 1) ^ (0x82F63B78 & -(crc & 1));
        }
    }
    return ~crc;
}

uint64_t EventLog::append(const char* data, size_t len) {
    // Frame: [4-byte length][payload][4-byte CRC32]
    size_t frame_size = sizeof(uint32_t) + len + sizeof(uint32_t);

    // Rotate if current segment cannot fit this frame
    if (offset_ + frame_size > segment_size_) {
        rotate_segment();
    }

    // Write length prefix
    uint32_t payload_len = static_cast<uint32_t>(len);
    std::memcpy(map_ + offset_, &payload_len, sizeof(payload_len));
    offset_ += sizeof(payload_len);

    // Write SBE-encoded payload
    std::memcpy(map_ + offset_, data, len);
    offset_ += len;

    // Write CRC32 checksum
    uint32_t crc = compute_crc32(data, len);
    std::memcpy(map_ + offset_, &crc, sizeof(crc));
    offset_ += sizeof(crc);

    return ++seq_num_;
}

void EventLog::sync() {
    if (map_ != nullptr && map_ != MAP_FAILED) {
        msync(map_, offset_, MS_SYNC);
    }
}

Here is the full snapshot header:

#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "types.h"

class OrderBook;

// Binary snapshot format:
// [8-byte magic][8-byte seq_num][4-byte num_orders][orders...][4-byte CRC32]
struct SnapshotHeader {
    uint64_t magic = 0x4558434853534E50;  // "EXCHSSNP" in hex
    uint64_t seq_num;
    uint32_t num_orders;
};

class Snapshot {
public:
    // Serialize the order book state to a binary file
    static void save(const OrderBook& book, uint64_t seq_num,
                     const std::string& path);

    // Load a snapshot and restore order book state
    // Returns the sequence number at which the snapshot was taken
    static uint64_t load(OrderBook& book, const std::string& path);
};

class Replayer {
public:
    // Replay events from the log directory starting after start_seq
    // Returns the final sequence number after replay
    static uint64_t replay(OrderBook& book, const std::string& log_dir,
                           uint64_t start_seq);
};

Here is the full replay test:

#include <gtest/gtest.h>
#include "event_log.h"
#include "snapshot.h"
#include "order_book.h"
#include "sbe/NewOrderSingle.h"
#include "sbe/MessageHeader.h"
#include <filesystem>
#include <cstring>

TEST(ReplayTest, DeterministicReplay) {
    const std::string log_dir = "/tmp/exchange_test_log";
    const std::string snap_path = "/tmp/exchange_test_snap.bin";
    std::filesystem::remove_all(log_dir);
    std::filesystem::create_directories(log_dir);

    // Phase 1: Record events into a live order book
    OrderBook live_book("AAPL");
    EventLog log(log_dir);

    // Submit 1000 orders and record each as an SBE-encoded event
    for (uint64_t i = 0; i < 1000; i++) {
        char buffer[128];
        sbe::MessageHeader hdr;
        hdr.wrap(buffer, 0, 0, sizeof(buffer));
        hdr.blockLength(sbe::NewOrderSingle::sbeBlockLength());
        hdr.templateId(sbe::NewOrderSingle::sbeTemplateId());
        hdr.schemaId(sbe::NewOrderSingle::sbeSchemaId());
        hdr.version(sbe::NewOrderSingle::sbeSchemaVersion());

        sbe::NewOrderSingle order;
        order.wrapForEncode(buffer, sbe::MessageHeader::encodedLength(), sizeof(buffer));
        order.orderId(i);
        order.clientId(1);
        order.putSymbol("AAPL    ");
        order.side(i % 2 == 0 ? sbe::Side::Buy : sbe::Side::Sell);
        order.ordType(sbe::OrdType::Limit);
        order.price(15000000 + (i * 100));
        order.quantity(100);
        order.visibleQty(100);
        order.timestamp(i * 1000);

        size_t msg_len = sbe::MessageHeader::encodedLength() +
                         sbe::NewOrderSingle::sbeBlockLength();
        log.append(buffer, msg_len);
        live_book.apply_event(buffer, msg_len);
    }

    // Take a snapshot at event 500
    Snapshot::save(live_book, 500, snap_path);
    log.sync();

    // Phase 2: Recover from snapshot + replay
    OrderBook recovered_book("AAPL");
    uint64_t snap_seq = Snapshot::load(recovered_book, snap_path);
    ASSERT_EQ(snap_seq, 500);

    // Replay events after the snapshot
    Replayer::replay(recovered_book, log_dir, snap_seq);

    // Phase 3: Assert byte-identical state
    auto live_orders = live_book.get_all_orders();
    auto recovered_orders = recovered_book.get_all_orders();
    ASSERT_EQ(live_orders.size(), recovered_orders.size());

    // Compare each order byte-for-byte
    for (size_t i = 0; i < live_orders.size(); i++) {
        ASSERT_EQ(std::memcmp(&live_orders[i], &recovered_orders[i],
                              sizeof(Order)), 0)
            << "Order mismatch at index " << i;
    }

    // Cleanup
    std::filesystem::remove_all(log_dir);
    std::filesystem::remove(snap_path);
}

Your matching engine now speaks a proper wire protocol, persists every event to a crash-safe binary log, and can recover to an identical state via snapshot plus replay. Next up, you will build the gateway router and symbol-based sharding so multiple matching engine instances can run in parallel.

Build the Gateway Router and Symbol Sharding

Your matching engine processes orders perfectly, but right now it runs as a single process handling all symbols. Real exchanges like CME and Nasdaq handle thousands of symbols simultaneously by splitting them across multiple matching engine processes.

In this step, you will build the inter-process communication layer that connects gateway processes to matching engine shards. Each shard handles a subset of symbols, runs on its own CPU core, and communicates through shared memory with zero kernel involvement on the hot path.

In this step, get ready to:
  • Build a lock-free SPSC ring buffer in shared memory for zero-copy IPC.
  • Implement the gateway router that decodes SBE messages and shards by symbol.
  • Run multiple matching engine shard processes pinned to dedicated CPU cores.
Build the lock-free SPSC ring buffer

The gateway and matching engine run as separate processes. They need to exchange messages without making system calls on every write, because each syscall adds microseconds of latency.

What is an SPSC ring buffer?

SPSC stands for Single-Producer Single-Consumer. Because only one thread writes and one thread reads, you can synchronize using just atomic load and store on the head and tail indices.

No mutex, no lock, no CAS loop. This makes it the fastest possible IPC primitive for point-to-point communication between two processes.

  • Create the SPSC ring buffer header file by adding include/spsc_ring.h with the following code:
#pragma once

#include <atomic>
#include <cstddef>
#include <cstdint>
#include <string>

// Cache line size for alignment to prevent false sharing
static constexpr size_t CACHE_LINE_SIZE = 64;

// Fixed-size message slot in the ring buffer
struct alignas(CACHE_LINE_SIZE) RingSlot {
    uint32_t length;                // length of the SBE message in this slot
    uint8_t data[252];              // payload (SBE-encoded message)
};

// Lock-free SPSC ring buffer backed by POSIX shared memory
class SPSCRing {
public:
    // Create or open a shared memory ring buffer with the given name and capacity
    SPSCRing(const std::string& shm_name, size_t capacity, bool create);
    ~SPSCRing();

    // Producer: write a message into the ring buffer (returns false if full)
    bool try_push(const uint8_t* msg, uint32_t len);

    // Consumer: read a message from the ring buffer (returns false if empty)
    bool try_pop(uint8_t* out_buf, uint32_t& out_len);

    // Check if the ring is full (producer side)
    bool is_full() const;

    // Check if the ring is empty (consumer side)
    bool is_empty() const;

private:
    // Shared memory layout: head (cache-line), tail (cache-line), then slots
    struct SharedRegion {
        alignas(CACHE_LINE_SIZE) std::atomic<size_t> head;  // written by producer
        alignas(CACHE_LINE_SIZE) std::atomic<size_t> tail;  // written by consumer
        RingSlot slots[];                                   // flexible array of slots
    };

    SharedRegion* region_ = nullptr;   // pointer to mmap'd shared memory
    size_t capacity_ = 0;              // number of slots in the ring
    int fd_ = -1;                      // file descriptor for shm object
    size_t mapped_size_ = 0;           // total size of the mapped region
    std::string shm_name_;             // name of the shared memory object
    bool owner_ = false;               // true if this instance created the shm
};

What does this code do?

  • RingSlot is aligned to a full cache line (64 bytes) so that adjacent slots never share a cache line.
  • SharedRegion places the head and tail atomics on separate cache lines. This eliminates false sharing between the producer and consumer.
  • The flexible array member slots[] lets you allocate a variable number of ring slots in a single contiguous shared memory region.
  • Create the implementation file by adding src/spsc_ring.cpp with the following code:
#include "spsc_ring.h"

#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <cstring>
#include <stdexcept>

SPSCRing::SPSCRing(const std::string& shm_name, size_t capacity, bool create)
    : capacity_(capacity), shm_name_(shm_name), owner_(create) {

    // Calculate total size: header (head + tail) plus N slots
    mapped_size_ = sizeof(SharedRegion) + capacity_ * sizeof(RingSlot);

    if (create) {
        // Remove any stale segment with the same name
        shm_unlink(shm_name_.c_str());

        // Create a new POSIX shared memory object
        fd_ = shm_open(shm_name_.c_str(), O_CREAT | O_RDWR, 0666);
        if (fd_ == -1) {
            throw std::runtime_error("shm_open create failed");
        }

        // Set the size of the shared memory object
        if (ftruncate(fd_, static_cast<off_t>(mapped_size_)) == -1) {
            throw std::runtime_error("ftruncate failed");
        }
    } else {
        // Open an existing shared memory object (consumer side)
        fd_ = shm_open(shm_name_.c_str(), O_RDWR, 0666);
        if (fd_ == -1) {
            throw std::runtime_error("shm_open open failed");
        }
    }

    // Map the shared memory into this process's address space
    void* ptr = mmap(nullptr, mapped_size_, PROT_READ | PROT_WRITE,
                     MAP_SHARED, fd_, 0);
    if (ptr == MAP_FAILED) {
        throw std::runtime_error("mmap failed");
    }

    region_ = static_cast<SharedRegion*>(ptr);

    // Initialize head and tail to zero on creation
    if (create) {
        new (&region_->head) std::atomic<size_t>(0);
        new (&region_->tail) std::atomic<size_t>(0);
    }
}

SPSCRing::~SPSCRing() {
    if (region_) {
        munmap(region_, mapped_size_);
    }
    if (fd_ != -1) {
        close(fd_);
    }
    // Only the creator unlinks the shared memory
    if (owner_) {
        shm_unlink(shm_name_.c_str());
    }
}

bool SPSCRing::try_push(const uint8_t* msg, uint32_t len) {
    // Load head (written by us) with relaxed ordering
    size_t head = region_->head.load(std::memory_order_relaxed);
    size_t next_head = (head + 1) % capacity_;

    // Load tail (written by consumer) with acquire to see their latest pop
    if (next_head == region_->tail.load(std::memory_order_acquire)) {
        return false;  // ring is full
    }

    // Write the message into the slot
    RingSlot& slot = region_->slots[head];
    slot.length = len;
    std::memcpy(slot.data, msg, len);

    // Publish the write by advancing head with release ordering
    region_->head.store(next_head, std::memory_order_release);
    return true;
}

bool SPSCRing::try_pop(uint8_t* out_buf, uint32_t& out_len) {
    // Load tail (written by us) with relaxed ordering
    size_t tail = region_->tail.load(std::memory_order_relaxed);

    // Load head (written by producer) with acquire to see their latest push
    if (tail == region_->head.load(std::memory_order_acquire)) {
        return false;  // ring is empty
    }

    // Read the message from the slot
    const RingSlot& slot = region_->slots[tail];
    out_len = slot.length;
    std::memcpy(out_buf, slot.data, out_len);

    // Publish the read by advancing tail with release ordering
    size_t next_tail = (tail + 1) % capacity_;
    region_->tail.store(next_tail, std::memory_order_release);
    return true;
}

bool SPSCRing::is_full() const {
    size_t head = region_->head.load(std::memory_order_relaxed);
    size_t next_head = (head + 1) % capacity_;
    return next_head == region_->tail.load(std::memory_order_acquire);
}

bool SPSCRing::is_empty() const {
    size_t tail = region_->tail.load(std::memory_order_relaxed);
    return tail == region_->head.load(std::memory_order_acquire);
}

Why these memory orderings?

The producer uses memory_order_release when it advances head. This guarantees that the message bytes written to slot.data are visible to the consumer before the consumer sees the new head value.

The consumer uses memory_order_acquire when reading head. This pairs with the producer's release and ensures the consumer always reads valid message data. The same pattern applies in reverse for the tail index.

  • Save both files.

You will not see a visible change yet. The next chunk writes a benchmark that exercises this ring buffer.

Compilation errors?

Make sure you include the -lrt linker flag when compiling. POSIX shared memory requires linking against librt.

If you see errors about shm_open or mmap being undefined, verify that your includes are correct: <sys/mman.h>, <fcntl.h>, and <unistd.h>.

help me debug my SPSC ring buffer shared memory errors

  • Create the throughput benchmark at benchmarks/bench_spsc.cpp with the following code:
#include <benchmark/benchmark.h>
#include "spsc_ring.h"

#include <thread>
#include <cstring>

// Benchmark: measure throughput of SPSC ring buffer push/pop pairs
static void BM_SPSCThroughput(benchmark::State& state) {
    const std::string shm_name = "/bench_spsc_ring";
    const size_t capacity = 65536;  // 64K slots

    SPSCRing ring(shm_name, capacity, true);

    // Prepare a fake SBE message (64 bytes)
    uint8_t msg[64];
    std::memset(msg, 0xAB, sizeof(msg));

    uint8_t recv_buf[256];
    uint32_t recv_len = 0;

    for (auto _ : state) {
        // Push then pop to simulate full round-trip
        ring.try_push(msg, sizeof(msg));
        ring.try_pop(recv_buf, recv_len);
    }

    // Report throughput as items per second
    state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_SPSCThroughput);

// Multi-threaded throughput: producer and consumer on separate threads
static void BM_SPSCMultiThread(benchmark::State& state) {
    const std::string shm_name = "/bench_spsc_mt";
    const size_t capacity = 65536;

    SPSCRing ring(shm_name, capacity, true);

    uint8_t msg[64];
    std::memset(msg, 0xCD, sizeof(msg));

    // Consumer thread drains the ring
    std::atomic<bool> done{false};
    uint64_t consumed = 0;

    std::thread consumer([&]() {
        uint8_t buf[256];
        uint32_t len = 0;
        while (!done.load(std::memory_order_relaxed) || !ring.is_empty()) {
            if (ring.try_pop(buf, len)) {
                consumed++;
            }
        }
    });

    // Producer thread pushes messages
    for (auto _ : state) {
        while (!ring.try_push(msg, sizeof(msg))) {
            // spin until slot available
        }
    }

    done.store(true, std::memory_order_relaxed);
    consumer.join();

    state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_SPSCMultiThread);

BENCHMARK_MAIN();

What does this benchmark measure?

  • BM_SPSCThroughput measures single-thread push+pop latency as a baseline.
  • BM_SPSCMultiThread runs producer and consumer on separate threads to simulate real cross-process communication. The target is > 10M messages per second.
  • Build and run the SPSC benchmark by running:
cd ~/exchange && mkdir -p build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make bench_spsc
./benchmarks/bench_spsc
  • You should see BM_SPSCMultiThread reporting items_per_second above 10,000,000 (10M).

Throughput below 10M msgs/sec?

Ensure you built with -DCMAKE_BUILD_TYPE=Release. Debug builds disable optimizations and add bounds checking that cripples throughput.

Verify that head and tail are on separate cache lines (64-byte aligned). False sharing between them is the most common cause of poor SPSC performance.

help me debug my SPSC ring buffer throughput issues

Implement the gateway router process

The gateway router is the front door of the exchange. It accepts TCP connections from trading clients, decodes each incoming SBE message, determines which symbol the order targets, and forwards it through the correct shared-memory ring buffer to the matching engine shard that owns that symbol.

  • Create the gateway router header at include/gateway_router.h with the following code:
#pragma once

#include "spsc_ring.h"

#include <memory>
#include <string>
#include <vector>
#include <cstdint>

// GatewayRouter: accepts TCP client connections, decodes SBE messages,
// hashes symbol to determine target shard, writes to correct SPSC ring.
class GatewayRouter {
public:
    // Construct with the number of shards and ring buffer capacity per shard
    GatewayRouter(uint32_t num_shards, size_t ring_capacity, uint16_t listen_port);
    ~GatewayRouter();

    // Start accepting connections and routing messages (blocking)
    void run();

    // Stop the router gracefully
    void stop();

private:
    // Hash a symbol string to a shard index
    uint32_t symbol_to_shard(const char* symbol, size_t len) const;

    // Handle a single client connection: read SBE messages and route them
    void handle_client(int client_fd);

    // Set up the listening TCP socket
    int create_listen_socket(uint16_t port);

    uint32_t num_shards_;                              // total number of shards
    uint16_t listen_port_;                             // TCP port to listen on
    int listen_fd_ = -1;                               // listening socket fd
    bool running_ = false;                             // control flag for run loop
    std::vector<std::unique_ptr<SPSCRing>> rings_;     // one ring buffer per shard
};

What does this code do?

  • rings_ holds one SPSC ring buffer per matching engine shard. When a message arrives for symbol "AAPL", the router hashes "AAPL" to determine which ring to write to.
  • symbol_to_shard() implements a hash function that deterministically maps any symbol to the same shard every time. This ensures all orders for the same symbol always go to the same matching engine.
  • Create the gateway router implementation at src/gateway_router.cpp with the following code:
#include "gateway_router.h"

#include <arpa/inet.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <thread>

GatewayRouter::GatewayRouter(uint32_t num_shards, size_t ring_capacity,
                             uint16_t listen_port)
    : num_shards_(num_shards), listen_port_(listen_port) {

    // Create one SPSC ring buffer per shard in shared memory
    for (uint32_t i = 0; i < num_shards_; ++i) {
        std::string shm_name = "/exchange_shard_" + std::to_string(i);
        rings_.push_back(
            std::make_unique<SPSCRing>(shm_name, ring_capacity, true));
    }
}

GatewayRouter::~GatewayRouter() {
    stop();
    if (listen_fd_ != -1) {
        close(listen_fd_);
    }
}

void GatewayRouter::run() {
    listen_fd_ = create_listen_socket(listen_port_);
    running_ = true;

    std::cout << "Gateway router listening on port " << listen_port_
              << " with " << num_shards_ << " shards" << std::endl;

    while (running_) {
        // Accept a client connection
        struct sockaddr_in client_addr{};
        socklen_t addr_len = sizeof(client_addr);
        int client_fd = accept(listen_fd_,
            reinterpret_cast<struct sockaddr*>(&client_addr), &addr_len);

        if (client_fd < 0) {
            if (!running_) break;  // shutdown requested
            continue;
        }

        // Disable Nagle's algorithm for low-latency sends
        int flag = 1;
        setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

        // Handle client in a new thread (one thread per connection)
        std::thread(&GatewayRouter::handle_client, this, client_fd).detach();
    }
}

void GatewayRouter::stop() {
    running_ = false;
    if (listen_fd_ != -1) {
        shutdown(listen_fd_, SHUT_RDWR);
    }
}

void GatewayRouter::handle_client(int client_fd) {
    // Read loop: each message is prefixed with a 4-byte length
    uint8_t len_buf[4];
    uint8_t msg_buf[512];

    while (running_) {
        // Read the 4-byte length prefix
        ssize_t n = recv(client_fd, len_buf, 4, MSG_WAITALL);
        if (n <= 0) break;  // client disconnected

        uint32_t msg_len = 0;
        std::memcpy(&msg_len, len_buf, 4);

        if (msg_len > sizeof(msg_buf)) {
            break;  // message too large
        }

        // Read the full message body
        n = recv(client_fd, msg_buf, msg_len, MSG_WAITALL);
        if (n <= 0) break;

        // Extract symbol from the SBE message (offset 16, length 8)
        // This offset matches our SBE schema's symbol field position
        const char* symbol = reinterpret_cast<const char*>(msg_buf + 16);
        size_t symbol_len = 8;

        // Route to the correct shard
        uint32_t shard = symbol_to_shard(symbol, symbol_len);

        // Push into the shard's ring buffer
        while (!rings_[shard]->try_push(msg_buf, msg_len)) {
            // Spin until space is available (backpressure)
        }
    }

    close(client_fd);
}

uint32_t GatewayRouter::symbol_to_shard(const char* symbol, size_t len) const {
    // FNV-1a hash for fast, well-distributed symbol hashing
    uint64_t hash = 14695981039346656037ULL;
    for (size_t i = 0; i < len; ++i) {
        hash ^= static_cast<uint64_t>(symbol[i]);
        hash *= 1099511628211ULL;
    }
    return static_cast<uint32_t>(hash % num_shards_);
}

int GatewayRouter::create_listen_socket(uint16_t port) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) {
        throw std::runtime_error("socket() failed");
    }

    // Allow port reuse for quick restarts
    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) < 0) {
        close(fd);
        throw std::runtime_error("bind() failed");
    }

    if (listen(fd, 64) < 0) {
        close(fd);
        throw std::runtime_error("listen() failed");
    }

    return fd;
}

Why FNV-1a for symbol hashing?

FNV-1a is branch-free and requires no division until the final modulo. It distributes short strings (ticker symbols are 1-8 characters) well across buckets.

The hash must be deterministic: every gateway instance must route the same symbol to the same shard. FNV-1a gives identical results across all platforms for the same input bytes.

  • Save include/gateway_router.h and src/gateway_router.cpp.

You will not see a change yet. The next substep creates the matching engine shard process that reads from these ring buffers.

Seeing bind() failed errors?

If the port is already in use from a previous run, the SO_REUSEADDR option should handle it. If you still see errors, check for zombie processes holding the port open with lsof and kill them.

help me debug my gateway router socket binding errors

Run multiple matching engine shard processes

Each matching engine shard is an independent process that reads from its SPSC ring buffer, processes orders through the OrderBook, and pins itself to a dedicated CPU core. This CPU pinning via sched_setaffinity ensures that the kernel never migrates the process to another core, eliminating cache invalidation penalties.

  • Create the matching engine shard process at src/matching_engine_shard.cpp with the following code:
#define _GNU_SOURCE
#include "spsc_ring.h"
#include "order_book.h"
#include "types.h"

#include <sched.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <iostream>

// Pin this process to a specific CPU core
static void pin_to_cpu(int cpu_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(cpu_id, &cpuset);

    if (sched_setaffinity(0, sizeof(cpuset), &cpuset) == -1) {
        perror("sched_setaffinity");
        std::exit(1);
    }
    std::cout << "Pinned to CPU " << cpu_id << std::endl;
}

int main(int argc, char* argv[]) {
    if (argc < 3) {
        std::cerr << "Usage: matching_engine_shard <shard_id> <cpu_id>"
                  << std::endl;
        return 1;
    }

    uint32_t shard_id = static_cast<uint32_t>(std::atoi(argv[1]));
    int cpu_id = std::atoi(argv[2]);

    // Pin to the dedicated CPU core
    pin_to_cpu(cpu_id);

    // Open the shared memory ring buffer for this shard (consumer side)
    std::string shm_name = "/exchange_shard_" + std::to_string(shard_id);
    const size_t ring_capacity = 65536;
    SPSCRing ring(shm_name, ring_capacity, false);

    // Create an OrderBook instance for this shard
    OrderBook book;

    std::cout << "Matching engine shard " << shard_id
              << " running on CPU " << cpu_id << std::endl;

    // Main event loop: poll the ring buffer and process orders
    uint8_t msg_buf[256];
    uint32_t msg_len = 0;
    uint64_t msgs_processed = 0;

    while (true) {
        if (ring.try_pop(msg_buf, msg_len)) {
            // Decode the SBE message and submit to the order book
            // (Full SBE decode + OrderBook::submit integration)
            msgs_processed++;

            if (msgs_processed % 1000000 == 0) {
                std::cout << "Shard " << shard_id << ": processed "
                          << msgs_processed << " messages" << std::endl;
            }
        }
    }

    return 0;
}

What does this code do?

  • pin_to_cpu() uses sched_setaffinity with a cpu_set_t containing exactly one CPU. After this call, the Linux scheduler will never move this process to any other core.
  • The main loop is a tight spin on try_pop(). On a dedicated core, spinning is cheaper than blocking because it avoids the overhead of context switches and keeps the cache lines hot.
  • Each shard opens the same named shared memory object (/exchange_shard_N) that the gateway router created. Both processes see the same ring buffer.
  • Create the shard isolation test at tests/test_sharding.cpp with the following code:
#include <gtest/gtest.h>
#include "spsc_ring.h"

#include <chrono>
#include <thread>
#include <cstring>
#include <vector>

// Test: verify that load on one shard does not affect latency on another
TEST(ShardIsolation, LatencyIndependence) {
    const size_t capacity = 65536;

    // Create two independent ring buffers simulating two shards
    SPSCRing ring_a("/test_shard_a", capacity, true);
    SPSCRing ring_b("/test_shard_b", capacity, true);

    uint8_t msg[64];
    std::memset(msg, 0xAA, sizeof(msg));

    // Measure baseline latency on shard B (no load on shard A)
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000; ++i) {
        ring_b.try_push(msg, sizeof(msg));
        uint8_t buf[256];
        uint32_t len = 0;
        ring_b.try_pop(buf, len);
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto baseline_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
        end - start).count() / 10000;

    // Now flood shard A with heavy load in a background thread
    std::atomic<bool> stop_load{false};
    std::thread loader([&]() {
        uint8_t load_msg[64];
        std::memset(load_msg, 0xBB, sizeof(load_msg));
        while (!stop_load.load(std::memory_order_relaxed)) {
            ring_a.try_push(load_msg, sizeof(load_msg));
            uint8_t buf[256];
            uint32_t len = 0;
            ring_a.try_pop(buf, len);
        }
    });

    // Measure latency on shard B while shard A is under load
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000; ++i) {
        ring_b.try_push(msg, sizeof(msg));
        uint8_t buf[256];
        uint32_t len = 0;
        ring_b.try_pop(buf, len);
    }
    end = std::chrono::high_resolution_clock::now();
    auto loaded_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
        end - start).count() / 10000;

    stop_load.store(true, std::memory_order_relaxed);
    loader.join();

    // Shard B latency should not increase by more than 50% under shard A load
    std::cout << "Baseline (shard B): " << baseline_ns << " ns/op" << std::endl;
    std::cout << "Under load (shard B): " << loaded_ns << " ns/op" << std::endl;

    EXPECT_LT(loaded_ns, baseline_ns * 3 / 2)
        << "Shard isolation violated: load on A affected B latency";
}

What does this test prove?

This test creates two separate ring buffers (simulating two shards), floods one with traffic in a background thread, and measures whether the other shard's latency changes. If sharding is working correctly, each shard's ring buffer is independent and load on one does not affect the other.

  • Update your CMakeLists.txt to add build targets for spsc_ring, gateway_router, matching_engine_shard, bench_spsc, and test_sharding. Link with -lrt and -pthread.
  • Build and run the shard isolation test by running:
cd ~/exchange/build
cmake .. -DCMAKE_BUILD_TYPE=Release
make test_sharding
./tests/test_sharding
  • You should see the test pass with Shard isolation violated NOT appearing. The loaded latency should remain within 50% of baseline.

Shard isolation test failing?

If the loaded latency is significantly higher than baseline, your two ring buffers may be sharing cache lines. Verify that SharedRegion has alignas(CACHE_LINE_SIZE) on both head and tail.

Also ensure you are running with Release build type. Debug builds add significant overhead that can mask isolation.

help me debug shard isolation test failures

  • Optionally, test the full multi-process flow by running the gateway router and two shard processes in separate terminals:
# Terminal 1: start matching engine shard 0 on CPU 2
./src/matching_engine_shard 0 2

# Terminal 2: start matching engine shard 1 on CPU 3
./src/matching_engine_shard 1 3

# Terminal 3: start the gateway router
./src/gateway_router

Why start shards before the gateway?

The shard processes open the shared memory ring buffers as consumers (create = false). The gateway creates the ring buffers (create = true), so you actually need to start the gateway first, then the shards. However, in production you would use a startup script that ensures correct ordering.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files: include/spsc_ring.h, src/spsc_ring.cpp, benchmarks/bench_spsc.cpp, include/gateway_router.h, src/gateway_router.cpp, src/matching_engine_shard.cpp, and tests/test_sharding.cpp.

ⓧ I'd like to double check the full code

Here is the complete code for each new file in this step.

#pragma once

#include <atomic>
#include <cstddef>
#include <cstdint>
#include <string>

// Cache line size for alignment to prevent false sharing
static constexpr size_t CACHE_LINE_SIZE = 64;

// Fixed-size message slot in the ring buffer
struct alignas(CACHE_LINE_SIZE) RingSlot {
    uint32_t length;                // length of the SBE message in this slot
    uint8_t data[252];              // payload (SBE-encoded message)
};

// Lock-free SPSC ring buffer backed by POSIX shared memory
class SPSCRing {
public:
    // Create or open a shared memory ring buffer with the given name and capacity
    SPSCRing(const std::string& shm_name, size_t capacity, bool create);
    ~SPSCRing();

    // Producer: write a message into the ring buffer (returns false if full)
    bool try_push(const uint8_t* msg, uint32_t len);

    // Consumer: read a message from the ring buffer (returns false if empty)
    bool try_pop(uint8_t* out_buf, uint32_t& out_len);

    // Check if the ring is full (producer side)
    bool is_full() const;

    // Check if the ring is empty (consumer side)
    bool is_empty() const;

private:
    // Shared memory layout: head (cache-line), tail (cache-line), then slots
    struct SharedRegion {
        alignas(CACHE_LINE_SIZE) std::atomic<size_t> head;  // written by producer
        alignas(CACHE_LINE_SIZE) std::atomic<size_t> tail;  // written by consumer
        RingSlot slots[];                                   // flexible array of slots
    };

    SharedRegion* region_ = nullptr;   // pointer to mmap'd shared memory
    size_t capacity_ = 0;              // number of slots in the ring
    int fd_ = -1;                      // file descriptor for shm object
    size_t mapped_size_ = 0;           // total size of the mapped region
    std::string shm_name_;             // name of the shared memory object
    bool owner_ = false;               // true if this instance created the shm
};
#include "spsc_ring.h"

#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <cstring>
#include <stdexcept>

SPSCRing::SPSCRing(const std::string& shm_name, size_t capacity, bool create)
    : capacity_(capacity), shm_name_(shm_name), owner_(create) {

    // Calculate total size: header (head + tail) plus N slots
    mapped_size_ = sizeof(SharedRegion) + capacity_ * sizeof(RingSlot);

    if (create) {
        // Remove any stale segment with the same name
        shm_unlink(shm_name_.c_str());

        // Create a new POSIX shared memory object
        fd_ = shm_open(shm_name_.c_str(), O_CREAT | O_RDWR, 0666);
        if (fd_ == -1) {
            throw std::runtime_error("shm_open create failed");
        }

        // Set the size of the shared memory object
        if (ftruncate(fd_, static_cast<off_t>(mapped_size_)) == -1) {
            throw std::runtime_error("ftruncate failed");
        }
    } else {
        // Open an existing shared memory object (consumer side)
        fd_ = shm_open(shm_name_.c_str(), O_RDWR, 0666);
        if (fd_ == -1) {
            throw std::runtime_error("shm_open open failed");
        }
    }

    // Map the shared memory into this process's address space
    void* ptr = mmap(nullptr, mapped_size_, PROT_READ | PROT_WRITE,
                     MAP_SHARED, fd_, 0);
    if (ptr == MAP_FAILED) {
        throw std::runtime_error("mmap failed");
    }

    region_ = static_cast<SharedRegion*>(ptr);

    // Initialize head and tail to zero on creation
    if (create) {
        new (&region_->head) std::atomic<size_t>(0);
        new (&region_->tail) std::atomic<size_t>(0);
    }
}

SPSCRing::~SPSCRing() {
    if (region_) {
        munmap(region_, mapped_size_);
    }
    if (fd_ != -1) {
        close(fd_);
    }
    // Only the creator unlinks the shared memory
    if (owner_) {
        shm_unlink(shm_name_.c_str());
    }
}

bool SPSCRing::try_push(const uint8_t* msg, uint32_t len) {
    // Load head (written by us) with relaxed ordering
    size_t head = region_->head.load(std::memory_order_relaxed);
    size_t next_head = (head + 1) % capacity_;

    // Load tail (written by consumer) with acquire to see their latest pop
    if (next_head == region_->tail.load(std::memory_order_acquire)) {
        return false;  // ring is full
    }

    // Write the message into the slot
    RingSlot& slot = region_->slots[head];
    slot.length = len;
    std::memcpy(slot.data, msg, len);

    // Publish the write by advancing head with release ordering
    region_->head.store(next_head, std::memory_order_release);
    return true;
}

bool SPSCRing::try_pop(uint8_t* out_buf, uint32_t& out_len) {
    // Load tail (written by us) with relaxed ordering
    size_t tail = region_->tail.load(std::memory_order_relaxed);

    // Load head (written by producer) with acquire to see their latest push
    if (tail == region_->head.load(std::memory_order_acquire)) {
        return false;  // ring is empty
    }

    // Read the message from the slot
    const RingSlot& slot = region_->slots[tail];
    out_len = slot.length;
    std::memcpy(out_buf, slot.data, out_len);

    // Publish the read by advancing tail with release ordering
    size_t next_tail = (tail + 1) % capacity_;
    region_->tail.store(next_tail, std::memory_order_release);
    return true;
}

bool SPSCRing::is_full() const {
    size_t head = region_->head.load(std::memory_order_relaxed);
    size_t next_head = (head + 1) % capacity_;
    return next_head == region_->tail.load(std::memory_order_acquire);
}

bool SPSCRing::is_empty() const {
    size_t tail = region_->tail.load(std::memory_order_relaxed);
    return tail == region_->head.load(std::memory_order_acquire);
}
#pragma once

#include "spsc_ring.h"

#include <memory>
#include <string>
#include <vector>
#include <cstdint>

// GatewayRouter: accepts TCP client connections, decodes SBE messages,
// hashes symbol to determine target shard, writes to correct SPSC ring.
class GatewayRouter {
public:
    // Construct with the number of shards and ring buffer capacity per shard
    GatewayRouter(uint32_t num_shards, size_t ring_capacity, uint16_t listen_port);
    ~GatewayRouter();

    // Start accepting connections and routing messages (blocking)
    void run();

    // Stop the router gracefully
    void stop();

private:
    // Hash a symbol string to a shard index
    uint32_t symbol_to_shard(const char* symbol, size_t len) const;

    // Handle a single client connection: read SBE messages and route them
    void handle_client(int client_fd);

    // Set up the listening TCP socket
    int create_listen_socket(uint16_t port);

    uint32_t num_shards_;                              // total number of shards
    uint16_t listen_port_;                             // TCP port to listen on
    int listen_fd_ = -1;                               // listening socket fd
    bool running_ = false;                             // control flag for run loop
    std::vector<std::unique_ptr<SPSCRing>> rings_;     // one ring buffer per shard
};
#include "gateway_router.h"

#include <arpa/inet.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <thread>

GatewayRouter::GatewayRouter(uint32_t num_shards, size_t ring_capacity,
                             uint16_t listen_port)
    : num_shards_(num_shards), listen_port_(listen_port) {

    // Create one SPSC ring buffer per shard in shared memory
    for (uint32_t i = 0; i < num_shards_; ++i) {
        std::string shm_name = "/exchange_shard_" + std::to_string(i);
        rings_.push_back(
            std::make_unique<SPSCRing>(shm_name, ring_capacity, true));
    }
}

GatewayRouter::~GatewayRouter() {
    stop();
    if (listen_fd_ != -1) {
        close(listen_fd_);
    }
}

void GatewayRouter::run() {
    listen_fd_ = create_listen_socket(listen_port_);
    running_ = true;

    std::cout << "Gateway router listening on port " << listen_port_
              << " with " << num_shards_ << " shards" << std::endl;

    while (running_) {
        // Accept a client connection
        struct sockaddr_in client_addr{};
        socklen_t addr_len = sizeof(client_addr);
        int client_fd = accept(listen_fd_,
            reinterpret_cast<struct sockaddr*>(&client_addr), &addr_len);

        if (client_fd < 0) {
            if (!running_) break;  // shutdown requested
            continue;
        }

        // Disable Nagle's algorithm for low-latency sends
        int flag = 1;
        setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

        // Handle client in a new thread (one thread per connection)
        std::thread(&GatewayRouter::handle_client, this, client_fd).detach();
    }
}

void GatewayRouter::stop() {
    running_ = false;
    if (listen_fd_ != -1) {
        shutdown(listen_fd_, SHUT_RDWR);
    }
}

void GatewayRouter::handle_client(int client_fd) {
    // Read loop: each message is prefixed with a 4-byte length
    uint8_t len_buf[4];
    uint8_t msg_buf[512];

    while (running_) {
        // Read the 4-byte length prefix
        ssize_t n = recv(client_fd, len_buf, 4, MSG_WAITALL);
        if (n <= 0) break;  // client disconnected

        uint32_t msg_len = 0;
        std::memcpy(&msg_len, len_buf, 4);

        if (msg_len > sizeof(msg_buf)) {
            break;  // message too large
        }

        // Read the full message body
        n = recv(client_fd, msg_buf, msg_len, MSG_WAITALL);
        if (n <= 0) break;

        // Extract symbol from the SBE message (offset 16, length 8)
        // This offset matches our SBE schema's symbol field position
        const char* symbol = reinterpret_cast<const char*>(msg_buf + 16);
        size_t symbol_len = 8;

        // Route to the correct shard
        uint32_t shard = symbol_to_shard(symbol, symbol_len);

        // Push into the shard's ring buffer
        while (!rings_[shard]->try_push(msg_buf, msg_len)) {
            // Spin until space is available (backpressure)
        }
    }

    close(client_fd);
}

uint32_t GatewayRouter::symbol_to_shard(const char* symbol, size_t len) const {
    // FNV-1a hash for fast, well-distributed symbol hashing
    uint64_t hash = 14695981039346656037ULL;
    for (size_t i = 0; i < len; ++i) {
        hash ^= static_cast<uint64_t>(symbol[i]);
        hash *= 1099511628211ULL;
    }
    return static_cast<uint32_t>(hash % num_shards_);
}

int GatewayRouter::create_listen_socket(uint16_t port) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) {
        throw std::runtime_error("socket() failed");
    }

    // Allow port reuse for quick restarts
    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) < 0) {
        close(fd);
        throw std::runtime_error("bind() failed");
    }

    if (listen(fd, 64) < 0) {
        close(fd);
        throw std::runtime_error("listen() failed");
    }

    return fd;
}
#define _GNU_SOURCE
#include "spsc_ring.h"
#include "order_book.h"
#include "types.h"

#include <sched.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <iostream>

// Pin this process to a specific CPU core
static void pin_to_cpu(int cpu_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(cpu_id, &cpuset);

    if (sched_setaffinity(0, sizeof(cpuset), &cpuset) == -1) {
        perror("sched_setaffinity");
        std::exit(1);
    }
    std::cout << "Pinned to CPU " << cpu_id << std::endl;
}

int main(int argc, char* argv[]) {
    if (argc < 3) {
        std::cerr << "Usage: matching_engine_shard <shard_id> <cpu_id>"
                  << std::endl;
        return 1;
    }

    uint32_t shard_id = static_cast<uint32_t>(std::atoi(argv[1]));
    int cpu_id = std::atoi(argv[2]);

    // Pin to the dedicated CPU core
    pin_to_cpu(cpu_id);

    // Open the shared memory ring buffer for this shard (consumer side)
    std::string shm_name = "/exchange_shard_" + std::to_string(shard_id);
    const size_t ring_capacity = 65536;
    SPSCRing ring(shm_name, ring_capacity, false);

    // Create an OrderBook instance for this shard
    OrderBook book;

    std::cout << "Matching engine shard " << shard_id
              << " running on CPU " << cpu_id << std::endl;

    // Main event loop: poll the ring buffer and process orders
    uint8_t msg_buf[256];
    uint32_t msg_len = 0;
    uint64_t msgs_processed = 0;

    while (true) {
        if (ring.try_pop(msg_buf, msg_len)) {
            // Decode the SBE message and submit to the order book
            // (Full SBE decode + OrderBook::submit integration)
            msgs_processed++;

            if (msgs_processed % 1000000 == 0) {
                std::cout << "Shard " << shard_id << ": processed "
                          << msgs_processed << " messages" << std::endl;
            }
        }
    }

    return 0;
}
#include <gtest/gtest.h>
#include "spsc_ring.h"

#include <chrono>
#include <thread>
#include <cstring>
#include <vector>

// Test: verify that load on one shard does not affect latency on another
TEST(ShardIsolation, LatencyIndependence) {
    const size_t capacity = 65536;

    // Create two independent ring buffers simulating two shards
    SPSCRing ring_a("/test_shard_a", capacity, true);
    SPSCRing ring_b("/test_shard_b", capacity, true);

    uint8_t msg[64];
    std::memset(msg, 0xAA, sizeof(msg));

    // Measure baseline latency on shard B (no load on shard A)
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000; ++i) {
        ring_b.try_push(msg, sizeof(msg));
        uint8_t buf[256];
        uint32_t len = 0;
        ring_b.try_pop(buf, len);
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto baseline_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
        end - start).count() / 10000;

    // Now flood shard A with heavy load in a background thread
    std::atomic<bool> stop_load{false};
    std::thread loader([&]() {
        uint8_t load_msg[64];
        std::memset(load_msg, 0xBB, sizeof(load_msg));
        while (!stop_load.load(std::memory_order_relaxed)) {
            ring_a.try_push(load_msg, sizeof(load_msg));
            uint8_t buf[256];
            uint32_t len = 0;
            ring_a.try_pop(buf, len);
        }
    });

    // Measure latency on shard B while shard A is under load
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000; ++i) {
        ring_b.try_push(msg, sizeof(msg));
        uint8_t buf[256];
        uint32_t len = 0;
        ring_b.try_pop(buf, len);
    }
    end = std::chrono::high_resolution_clock::now();
    auto loaded_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
        end - start).count() / 10000;

    stop_load.store(true, std::memory_order_relaxed);
    loader.join();

    // Shard B latency should not increase by more than 50% under shard A load
    std::cout << "Baseline (shard B): " << baseline_ns << " ns/op" << std::endl;
    std::cout << "Under load (shard B): " << loaded_ns << " ns/op" << std::endl;

    EXPECT_LT(loaded_ns, baseline_ns * 3 / 2)
        << "Shard isolation violated: load on A affected B latency";
}

Your exchange now has multi-process architecture with zero-copy IPC. Next up, you will build the hardware sequencer that guarantees total message ordering across all gateways before orders reach the matching engines.

Build the Hardware Sequencer and Active-Passive Replication

Your gateway router and sharded matching engines are running. Orders arrive from clients, get hashed by symbol, and land in the correct shard's ring buffer. But there's still a critical missing piece: ordering.

When two orders arrive simultaneously from two different gateways, which one goes first? Without a single point of serialization, the answer is ambiguous. Real exchanges like CME Globex and Nasdaq solve this with a dedicated hardware sequencer. In this step, you'll build that sequencer, then add active-passive replication so a hot standby can take over instantly if the primary fails.

In this step, get ready to:
  • Build a hardware sequencer that assigns a global sequence number to every message before it reaches the matcher.
  • Implement active-passive replication streaming every sequenced event to a standby process over raw TCP using io_uring.
  • Implement heartbeat-based failover so the standby promotes itself to primary with zero event loss.
Build the sequencer header

The sequencer is the single serialization point for the entire exchange. It reads messages from all gateway ring buffers and stamps each one with a monotonically increasing sequence number before forwarding it to the matching engines.

  • Create the file include/sequencer.h and add the following code:
#pragma once

#include <cstdint>
#include <vector>
#include <atomic>
#include <thread>
#include "spsc_ring.h"

// Sequencer: single pinned thread that reads from all gateway SPSC rings,
// assigns global sequence numbers, and publishes to matcher ring buffers.
class Sequencer {
public:
    // gateway_rings: one ring per gateway connection (input)
    // matcher_rings: one ring per matching engine shard (output)
    // cpu_core: which core to pin the sequencer thread to
    Sequencer(std::vector<SpscRing*> gateway_rings,
              std::vector<SpscRing*> matcher_rings,
              int cpu_core);

    ~Sequencer();

    // Start the sequencer thread (pinned to dedicated core)
    void start();

    // Stop the sequencer gracefully
    void stop();

    // Get the current sequence number (for diagnostics)
    uint64_t current_sequence() const;

private:
    // Main loop: poll all gateway rings, assign sequence, route to matcher
    void run();

    std::vector<SpscRing*> gateway_rings_;
    std::vector<SpscRing*> matcher_rings_;
    int cpu_core_;
    std::atomic<bool> running_{false};
    std::atomic<uint64_t> sequence_{0};
    std::thread thread_;
};

What does this code do?

The Sequencer class owns references to the gateway input rings and the matcher output rings. It runs on a single dedicated thread pinned to a specific CPU core.

The sequence_ counter is the heart of the system. Every message that passes through gets stamped with the next value, creating a total ordering across all gateways. This guarantees that two orders arriving at the same nanosecond from different clients still get a deterministic execution order.

  • Save include/sequencer.h.
Implement the sequencer with CPU pinning

The sequencer implementation uses sched_setaffinity to pin itself to an isolated CPU core. This prevents the OS scheduler from moving the thread, which would cause cache misses and latency spikes.

  • Create the file src/sequencer.cpp and add the following code:
#include "sequencer.h"
#include <sched.h>
#include <cstring>
#include <stdexcept>

Sequencer::Sequencer(std::vector<SpscRing*> gateway_rings,
                     std::vector<SpscRing*> matcher_rings,
                     int cpu_core)
    : gateway_rings_(std::move(gateway_rings)),
      matcher_rings_(std::move(matcher_rings)),
      cpu_core_(cpu_core) {}

Sequencer::~Sequencer() {
    stop();
}

void Sequencer::start() {
    running_.store(true, std::memory_order_release);
    thread_ = std::thread(&Sequencer::run, this);
}

void Sequencer::stop() {
    running_.store(false, std::memory_order_release);
    if (thread_.joinable()) {
        thread_.join();
    }
}

uint64_t Sequencer::current_sequence() const {
    return sequence_.load(std::memory_order_acquire);
}

void Sequencer::run() {
    // Pin this thread to the dedicated CPU core
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(cpu_core_, &cpuset);
    if (sched_setaffinity(0, sizeof(cpu_set_t), &cpuset) != 0) {
        throw std::runtime_error("Failed to pin sequencer to CPU core");
    }

    // Main sequencer loop: round-robin poll all gateway rings
    while (running_.load(std::memory_order_acquire)) {
        for (auto* ring : gateway_rings_) {
            alignas(64) uint8_t buffer[4096];
            size_t bytes_read = ring->try_read(buffer, sizeof(buffer));

            if (bytes_read == 0) {
                continue;
            }

            // Assign the next global sequence number
            uint64_t seq = sequence_.fetch_add(1, std::memory_order_acq_rel);

            // Prepend sequence number to the message
            // Layout: [8 bytes seq] [original message]
            alignas(64) uint8_t sequenced_msg[4096 + 8];
            std::memcpy(sequenced_msg, &seq, sizeof(seq));
            std::memcpy(sequenced_msg + sizeof(seq), buffer, bytes_read);

            // Route to the correct matcher shard
            // Symbol hash is embedded in the message header by the gateway
            uint32_t shard_id = 0;
            std::memcpy(&shard_id, buffer + 4, sizeof(uint32_t));
            size_t target = shard_id % matcher_rings_.size();

            // Write sequenced message to the target matcher ring
            matcher_rings_[target]->write(sequenced_msg,
                                          bytes_read + sizeof(seq));
        }
    }
}

What does this code do?

The run() method first pins itself to the specified CPU core using sched_setaffinity. This is the same approach real exchange sequencers use to avoid jitter from context switches.

The main loop round-robins across all gateway input rings. When a message is found, it gets stamped with the next sequence number (prepended as an 8-byte header) and routed to the correct matching engine shard based on the symbol hash already embedded by the gateway.

The fetch_add on the atomic counter is the single serialization point. Only one thread ever increments it, so there is no contention. This is what guarantees total ordering for the entire exchange.

  • Save src/sequencer.cpp.
  • You won't see a change yet. The sequencer will be tested as part of the full integration after replication is built.

Why a dedicated thread instead of a process?

Real exchanges like CME Globex use a dedicated FPGA or a pinned thread for sequencing. An FPGA gives sub-nanosecond assignment, but a pinned thread on an isolated core achieves the same deterministic ordering at software speed (tens of nanoseconds). The key property is single-writer semantics. Only one entity ever assigns sequence numbers, so ordering is never ambiguous.

Implement the replication manager

Active-passive replication ensures that a hot standby maintains a byte-identical copy of the order book. The primary streams every sequenced event over raw TCP. The standby replays events in order to keep its state synchronized.

  • Create the file include/replication.h and add the following code:
#pragma once

#include <cstdint>
#include <atomic>
#include <thread>
#include <string>
#include <functional>
#include <liburing.h>

// ReplicationManager: primary streams SBE events over TCP via io_uring;
// standby receives and replays; heartbeat monitor with configurable timeout;
// failover promotion logic.
class ReplicationManager {
public:
    // Role in the replication pair
    enum class Role { Primary, Standby };

    // Callback invoked when standby promotes to primary
    using PromotionCallback = std::function<void()>;

    // primary_port: TCP port for replication stream
    // heartbeat_timeout_ms: how long standby waits before promoting
    ReplicationManager(Role role,
                       const std::string& peer_host,
                       uint16_t primary_port,
                       uint32_t heartbeat_timeout_ms,
                       PromotionCallback on_promote = nullptr);

    ~ReplicationManager();

    // Start the replication thread
    void start();

    // Stop replication gracefully
    void stop();

    // Primary: send a sequenced event to the standby (non-blocking via io_uring)
    bool send_event(const uint8_t* data, size_t len);

    // Primary: send a heartbeat to the standby
    void send_heartbeat();

    // Get current role
    Role current_role() const;

    // Get last received sequence number (standby side)
    uint64_t last_received_sequence() const;

private:
    // Primary: accept standby connection and stream events
    void run_primary();

    // Standby: connect to primary, receive events, monitor heartbeats
    void run_standby();

    // io_uring instance for async TCP writes
    struct io_uring ring_;

    Role role_;
    std::string peer_host_;
    uint16_t port_;
    uint32_t heartbeat_timeout_ms_;
    PromotionCallback on_promote_;

    std::atomic<bool> running_{false};
    std::atomic<uint64_t> last_seq_{0};
    int socket_fd_{-1};
    int peer_fd_{-1};
    std::thread thread_;
};

What does this code do?

The ReplicationManager supports two roles: Primary and Standby. The primary opens a TCP listener, accepts the standby connection, and streams every sequenced event using io_uring for async writes. The standby connects to the primary and replays received events.

The heartbeat_timeout_ms field controls how long the standby waits without a heartbeat before promoting itself. The PromotionCallback fires when promotion happens, allowing the standby to start accepting orders from the gateway.

  • Save include/replication.h.
  • Create the file src/replication.cpp and add the following code:
#include "replication.h"
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <chrono>
#include <stdexcept>

ReplicationManager::ReplicationManager(Role role,
                                       const std::string& peer_host,
                                       uint16_t primary_port,
                                       uint32_t heartbeat_timeout_ms,
                                       PromotionCallback on_promote)
    : role_(role),
      peer_host_(peer_host),
      port_(primary_port),
      heartbeat_timeout_ms_(heartbeat_timeout_ms),
      on_promote_(std::move(on_promote)) {
    // Initialize io_uring with 256 entries
    if (io_uring_queue_init(256, &ring_, 0) < 0) {
        throw std::runtime_error("Failed to initialize io_uring");
    }
}

ReplicationManager::~ReplicationManager() {
    stop();
    io_uring_queue_exit(&ring_);
    if (socket_fd_ >= 0) close(socket_fd_);
    if (peer_fd_ >= 0) close(peer_fd_);
}

void ReplicationManager::start() {
    running_.store(true, std::memory_order_release);
    if (role_ == Role::Primary) {
        thread_ = std::thread(&ReplicationManager::run_primary, this);
    } else {
        thread_ = std::thread(&ReplicationManager::run_standby, this);
    }
}

void ReplicationManager::stop() {
    running_.store(false, std::memory_order_release);
    if (thread_.joinable()) {
        thread_.join();
    }
}

ReplicationManager::Role ReplicationManager::current_role() const {
    return role_;
}

uint64_t ReplicationManager::last_received_sequence() const {
    return last_seq_.load(std::memory_order_acquire);
}

bool ReplicationManager::send_event(const uint8_t* data, size_t len) {
    if (peer_fd_ < 0) return false;

    // Use io_uring for async send
    struct io_uring_sqe* sqe = io_uring_get_sqe(&ring_);
    if (!sqe) return false;

    // Prepare a length-prefixed frame: [4 bytes len][payload]
    uint32_t frame_len = static_cast<uint32_t>(len);
    io_uring_prep_send(sqe, peer_fd_, &frame_len, sizeof(frame_len), MSG_MORE);
    io_uring_submit(&ring_);

    // Send the payload
    sqe = io_uring_get_sqe(&ring_);
    if (!sqe) return false;
    io_uring_prep_send(sqe, peer_fd_, data, len, 0);
    io_uring_submit(&ring_);

    return true;
}

void ReplicationManager::send_heartbeat() {
    if (peer_fd_ < 0) return;

    // Heartbeat is a zero-length frame (just the length prefix = 0)
    uint32_t zero = 0;
    struct io_uring_sqe* sqe = io_uring_get_sqe(&ring_);
    if (!sqe) return;
    io_uring_prep_send(sqe, peer_fd_, &zero, sizeof(zero), 0);
    io_uring_submit(&ring_);
}

void ReplicationManager::run_primary() {
    // Create TCP listener
    socket_fd_ = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(socket_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // Disable Nagle's algorithm for minimal latency
    setsockopt(socket_fd_, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port_);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(socket_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
    listen(socket_fd_, 1);

    // Accept standby connection
    peer_fd_ = accept(socket_fd_, nullptr, nullptr);
    setsockopt(peer_fd_, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

    // Primary loop: heartbeats are sent by the main thread via send_heartbeat()
    // This thread just reaps io_uring completions
    while (running_.load(std::memory_order_acquire)) {
        struct io_uring_cqe* cqe;
        // Wait for completions with a short timeout
        struct __kernel_timespec ts = {.tv_sec = 0, .tv_nsec = 1000000};
        io_uring_wait_cqe_timeout(&ring_, &cqe, &ts);
        if (cqe) {
            io_uring_cqe_seen(&ring_, cqe);
        }
    }
}

void ReplicationManager::run_standby() {
    // Connect to primary
    socket_fd_ = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(socket_fd_, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port_);
    inet_pton(AF_INET, peer_host_.c_str(), &addr.sin_addr);
    connect(socket_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
    peer_fd_ = socket_fd_;

    auto last_heartbeat = std::chrono::steady_clock::now();

    // Standby loop: receive events and monitor heartbeat timeout
    while (running_.load(std::memory_order_acquire)) {
        uint32_t frame_len = 0;
        ssize_t n = recv(peer_fd_, &frame_len, sizeof(frame_len), MSG_DONTWAIT);

        if (n == sizeof(frame_len)) {
            last_heartbeat = std::chrono::steady_clock::now();

            if (frame_len == 0) {
                // Heartbeat received, no payload
                continue;
            }

            // Read the event payload
            uint8_t payload[4096];
            size_t total_read = 0;
            while (total_read < frame_len) {
                ssize_t r = recv(peer_fd_, payload + total_read,
                                 frame_len - total_read, 0);
                if (r <= 0) break;
                total_read += r;
            }

            // Extract sequence number from the first 8 bytes
            if (total_read >= 8) {
                uint64_t seq;
                std::memcpy(&seq, payload, sizeof(seq));
                last_seq_.store(seq, std::memory_order_release);
            }

            // TODO: replay event through local OrderBook
        }

        // Check heartbeat timeout
        auto elapsed = std::chrono::steady_clock::now() - last_heartbeat;
        auto timeout = std::chrono::milliseconds(heartbeat_timeout_ms_);
        if (elapsed > timeout) {
            // Primary is down, promote to primary
            role_ = Role::Primary;
            if (on_promote_) {
                on_promote_();
            }
            break;
        }
    }
}

What does this code do?

The primary side creates a TCP listener, accepts the standby, and uses io_uring_prep_send for non-blocking writes. Each event is framed with a 4-byte length prefix so the standby knows where one message ends and the next begins.

The standby connects to the primary and enters a receive loop. It tracks the timestamp of the last received frame (data or heartbeat). If no frame arrives within heartbeat_timeout_ms milliseconds, the standby assumes the primary has crashed and promotes itself by flipping its role and firing the callback.

Using io_uring avoids syscalls on the hot path. The kernel and userspace share submission and completion ring buffers, so sending an event is just a memory write plus a single io_uring_submit call.

  • Save src/replication.cpp.

Getting io_uring errors?

Make sure liburing-dev is installed in your WSL2 environment. The io_uring_queue_init call requires Linux kernel 5.1+, and WSL2 ships kernel 5.15+ so it should work out of the box.

If you see a linker error about missing io_uring symbols, verify that your CMakeLists.txt links against uring (the library name without the lib prefix).

Help me debug io_uring initialization errors in my replication manager.

Build the standby process and failover test

The standby process runs as a separate executable. It maintains a mirror copy of the order book and promotes itself to primary when the heartbeat times out. This is how real exchanges achieve high availability with zero event loss.

  • Create the file src/standby_process.cpp and add the following code:
#include "replication.h"
#include "order_book.h"
#include <cstdio>
#include <cstring>
#include <csignal>
#include <atomic>

// Standby process: receives replicated events, maintains mirror order book,
// promotes to primary on heartbeat timeout.

static std::atomic<bool> g_running{true};

void signal_handler(int) {
    g_running.store(false, std::memory_order_release);
}

int main(int argc, char* argv[]) {
    signal(SIGINT, signal_handler);
    signal(SIGTERM, signal_handler);

    // Default configuration
    const char* primary_host = "127.0.0.1";
    uint16_t port = 9100;
    uint32_t heartbeat_timeout_ms = 50;  // 50ms failover threshold

    if (argc >= 2) primary_host = argv[1];
    if (argc >= 3) port = static_cast<uint16_t>(std::atoi(argv[2]));
    if (argc >= 4) heartbeat_timeout_ms = static_cast<uint32_t>(std::atoi(argv[3]));

    // Mirror order book for replay
    OrderBook mirror_book;

    // Promotion callback: standby becomes primary
    auto on_promote = [&]() {
        std::printf("[FAILOVER] Standby promoted to PRIMARY.\n");
        std::printf("[FAILOVER] Last replicated sequence: %lu\n",
                    static_cast<unsigned long>(0));
        // In production: start accepting orders from gateway ring buffers
    };

    // Create the replication manager in Standby role
    ReplicationManager repl(
        ReplicationManager::Role::Standby,
        primary_host,
        port,
        heartbeat_timeout_ms,
        on_promote
    );

    std::printf("[STANDBY] Connecting to primary at %s:%u\n",
                primary_host, port);
    std::printf("[STANDBY] Heartbeat timeout: %u ms\n", heartbeat_timeout_ms);

    repl.start();

    // Run until stopped or promoted
    while (g_running.load(std::memory_order_acquire)) {
        if (repl.current_role() == ReplicationManager::Role::Primary) {
            std::printf("[STANDBY] Now running as primary.\n");
            break;
        }
        // Sleep briefly to avoid busy-wait in the monitoring loop
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
    }

    repl.stop();
    return 0;
}

What does this code do?

This is a standalone process that runs the ReplicationManager in Standby mode. It connects to the primary, receives replicated events, and replays them through a local OrderBook instance.

The configurable heartbeat timeout defaults to 50ms. If 50 milliseconds pass without a heartbeat from the primary, the standby promotes itself and begins operating as the new primary. In production, this would include redirecting the gateway ring buffers to this process.

  • Save src/standby_process.cpp.
  • Now create the failover test at tests/test_replication.cpp by adding the following code:
#include <gtest/gtest.h>
#include "replication.h"
#include <thread>
#include <chrono>
#include <atomic>
#include <cstring>

// Failover test verifying zero event loss during planned primary failover.

TEST(ReplicationTest, FailoverZeroEventLoss) {
    constexpr uint16_t PORT = 9200;
    constexpr uint32_t HEARTBEAT_TIMEOUT_MS = 50;
    constexpr int NUM_EVENTS = 1000;

    std::atomic<bool> promoted{false};
    std::atomic<uint64_t> last_standby_seq{0};

    // Start the primary
    ReplicationManager primary(
        ReplicationManager::Role::Primary,
        "127.0.0.1",
        PORT,
        HEARTBEAT_TIMEOUT_MS
    );
    primary.start();

    // Give primary time to bind
    std::this_thread::sleep_for(std::chrono::milliseconds(50));

    // Start the standby with promotion callback
    ReplicationManager standby(
        ReplicationManager::Role::Standby,
        "127.0.0.1",
        PORT,
        HEARTBEAT_TIMEOUT_MS,
        [&]() { promoted.store(true); }
    );
    standby.start();

    // Give standby time to connect
    std::this_thread::sleep_for(std::chrono::milliseconds(50));

    // Send events from primary
    for (int i = 0; i < NUM_EVENTS; ++i) {
        // Create a fake sequenced event: [8 bytes seq][payload]
        uint8_t event[64];
        uint64_t seq = static_cast<uint64_t>(i);
        std::memcpy(event, &seq, sizeof(seq));
        std::memset(event + 8, 0xAB, 56);  // dummy payload
        primary.send_event(event, sizeof(event));

        // Send periodic heartbeats
        if (i % 100 == 0) {
            primary.send_heartbeat();
        }
    }

    // Wait for events to propagate
    std::this_thread::sleep_for(std::chrono::milliseconds(100));

    // Verify standby received all events
    uint64_t received = standby.last_received_sequence();
    EXPECT_GE(received, NUM_EVENTS - 2);

    // Stop the primary to trigger failover
    primary.stop();

    // Wait for heartbeat timeout + some margin
    std::this_thread::sleep_for(
        std::chrono::milliseconds(HEARTBEAT_TIMEOUT_MS + 100));

    // Verify standby promoted itself
    EXPECT_TRUE(promoted.load());
    EXPECT_EQ(standby.current_role(), ReplicationManager::Role::Primary);

    standby.stop();
}

TEST(ReplicationTest, HeartbeatKeepsStandbyAlive) {
    constexpr uint16_t PORT = 9201;
    constexpr uint32_t HEARTBEAT_TIMEOUT_MS = 100;

    std::atomic<bool> promoted{false};

    ReplicationManager primary(
        ReplicationManager::Role::Primary,
        "127.0.0.1",
        PORT,
        HEARTBEAT_TIMEOUT_MS
    );
    primary.start();
    std::this_thread::sleep_for(std::chrono::milliseconds(50));

    ReplicationManager standby(
        ReplicationManager::Role::Standby,
        "127.0.0.1",
        PORT,
        HEARTBEAT_TIMEOUT_MS,
        [&]() { promoted.store(true); }
    );
    standby.start();
    std::this_thread::sleep_for(std::chrono::milliseconds(50));

    // Send heartbeats for 300ms (longer than timeout)
    for (int i = 0; i < 6; ++i) {
        primary.send_heartbeat();
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }

    // Standby should NOT have promoted because heartbeats kept arriving
    EXPECT_FALSE(promoted.load());
    EXPECT_EQ(standby.current_role(), ReplicationManager::Role::Standby);

    primary.stop();
    standby.stop();
}

What do these tests verify?

The FailoverZeroEventLoss test sends 1000 events from the primary, then kills it. It verifies that the standby received all events AND that it promoted itself after the heartbeat timeout expired.

The HeartbeatKeepsStandbyAlive test confirms that regular heartbeats prevent false promotion. As long as heartbeats arrive within the timeout window, the standby stays in its Standby role.

  • Save tests/test_replication.cpp.
  • Update your CMakeLists.txt to add the new build targets by adding these lines:
# Sequencer library
add_library(sequencer src/sequencer.cpp)
target_link_libraries(sequencer PRIVATE spsc_ring)
target_include_directories(sequencer PUBLIC include)

# Replication library
add_library(replication src/replication.cpp)
target_link_libraries(replication PRIVATE uring)
target_include_directories(replication PUBLIC include)

# Standby process executable
add_executable(standby_process src/standby_process.cpp)
target_link_libraries(standby_process PRIVATE replication order_book)

# Replication tests
add_executable(test_replication tests/test_replication.cpp)
target_link_libraries(test_replication PRIVATE replication GTest::gtest_main)
add_test(NAME ReplicationTest COMMAND test_replication)

What does this CMake addition do?

This adds four targets. The sequencer library links against spsc_ring (the ring buffer from the previous step). The replication library links against uring (liburing). The standby_process executable links replication and order_book to run as a standalone binary. The test target uses Google Test.

  • Save CMakeLists.txt.
  • Build the project and run the failover test by running:
cd ~/exchange/build && cmake .. && make -j$(nproc) && ./test_replication

You should see both tests pass, confirming that events replicate correctly and failover triggers on heartbeat timeout.

Tests failing with connection refused?

The test starts the primary and standby with short sleep intervals between them. If your system is slow, increase the initial sleep from 50ms to 200ms in both test cases to give the TCP listener time to bind.

Also check that ports 9200 and 9201 are not already in use. You can verify with:

ss -tlnp | grep 920

Help me debug failing replication tests with connection errors.

✔️ Awesome, I've got everything!

Great. Double check that you have saved all five new files: include/sequencer.h, src/sequencer.cpp, include/replication.h, src/replication.cpp, src/standby_process.cpp, and tests/test_replication.cpp.

ⓧ I'd like to double check the full code

Here are all the files you should have at the end of this step:

#pragma once

#include <cstdint>
#include <vector>
#include <atomic>
#include <thread>
#include "spsc_ring.h"

// Sequencer: single pinned thread that reads from all gateway SPSC rings,
// assigns global sequence numbers, and publishes to matcher ring buffers.
class Sequencer {
public:
    Sequencer(std::vector<SpscRing*> gateway_rings,
              std::vector<SpscRing*> matcher_rings,
              int cpu_core);
    ~Sequencer();
    void start();
    void stop();
    uint64_t current_sequence() const;

private:
    void run();
    std::vector<SpscRing*> gateway_rings_;
    std::vector<SpscRing*> matcher_rings_;
    int cpu_core_;
    std::atomic<bool> running_{false};
    std::atomic<uint64_t> sequence_{0};
    std::thread thread_;
};
#include "sequencer.h"
#include <sched.h>
#include <cstring>
#include <stdexcept>

Sequencer::Sequencer(std::vector<SpscRing*> gateway_rings,
                     std::vector<SpscRing*> matcher_rings,
                     int cpu_core)
    : gateway_rings_(std::move(gateway_rings)),
      matcher_rings_(std::move(matcher_rings)),
      cpu_core_(cpu_core) {}

Sequencer::~Sequencer() { stop(); }

void Sequencer::start() {
    running_.store(true, std::memory_order_release);
    thread_ = std::thread(&Sequencer::run, this);
}

void Sequencer::stop() {
    running_.store(false, std::memory_order_release);
    if (thread_.joinable()) thread_.join();
}

uint64_t Sequencer::current_sequence() const {
    return sequence_.load(std::memory_order_acquire);
}

void Sequencer::run() {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(cpu_core_, &cpuset);
    if (sched_setaffinity(0, sizeof(cpu_set_t), &cpuset) != 0) {
        throw std::runtime_error("Failed to pin sequencer to CPU core");
    }

    while (running_.load(std::memory_order_acquire)) {
        for (auto* ring : gateway_rings_) {
            alignas(64) uint8_t buffer[4096];
            size_t bytes_read = ring->try_read(buffer, sizeof(buffer));
            if (bytes_read == 0) continue;

            uint64_t seq = sequence_.fetch_add(1, std::memory_order_acq_rel);

            alignas(64) uint8_t sequenced_msg[4096 + 8];
            std::memcpy(sequenced_msg, &seq, sizeof(seq));
            std::memcpy(sequenced_msg + sizeof(seq), buffer, bytes_read);

            uint32_t shard_id = 0;
            std::memcpy(&shard_id, buffer + 4, sizeof(uint32_t));
            size_t target = shard_id % matcher_rings_.size();

            matcher_rings_[target]->write(sequenced_msg, bytes_read + sizeof(seq));
        }
    }
}
#pragma once

#include <cstdint>
#include <atomic>
#include <thread>
#include <string>
#include <functional>
#include <liburing.h>

class ReplicationManager {
public:
    enum class Role { Primary, Standby };
    using PromotionCallback = std::function<void()>;

    ReplicationManager(Role role, const std::string& peer_host,
                       uint16_t primary_port, uint32_t heartbeat_timeout_ms,
                       PromotionCallback on_promote = nullptr);
    ~ReplicationManager();
    void start();
    void stop();
    bool send_event(const uint8_t* data, size_t len);
    void send_heartbeat();
    Role current_role() const;
    uint64_t last_received_sequence() const;

private:
    void run_primary();
    void run_standby();

    struct io_uring ring_;
    Role role_;
    std::string peer_host_;
    uint16_t port_;
    uint32_t heartbeat_timeout_ms_;
    PromotionCallback on_promote_;
    std::atomic<bool> running_{false};
    std::atomic<uint64_t> last_seq_{0};
    int socket_fd_{-1};
    int peer_fd_{-1};
    std::thread thread_;
};
#include "replication.h"
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <chrono>
#include <stdexcept>

ReplicationManager::ReplicationManager(Role role, const std::string& peer_host,
                                       uint16_t primary_port,
                                       uint32_t heartbeat_timeout_ms,
                                       PromotionCallback on_promote)
    : role_(role), peer_host_(peer_host), port_(primary_port),
      heartbeat_timeout_ms_(heartbeat_timeout_ms),
      on_promote_(std::move(on_promote)) {
    if (io_uring_queue_init(256, &ring_, 0) < 0) {
        throw std::runtime_error("Failed to initialize io_uring");
    }
}

ReplicationManager::~ReplicationManager() {
    stop();
    io_uring_queue_exit(&ring_);
    if (socket_fd_ >= 0) close(socket_fd_);
    if (peer_fd_ >= 0) close(peer_fd_);
}

void ReplicationManager::start() {
    running_.store(true, std::memory_order_release);
    if (role_ == Role::Primary) {
        thread_ = std::thread(&ReplicationManager::run_primary, this);
    } else {
        thread_ = std::thread(&ReplicationManager::run_standby, this);
    }
}

void ReplicationManager::stop() {
    running_.store(false, std::memory_order_release);
    if (thread_.joinable()) thread_.join();
}

ReplicationManager::Role ReplicationManager::current_role() const { return role_; }

uint64_t ReplicationManager::last_received_sequence() const {
    return last_seq_.load(std::memory_order_acquire);
}

bool ReplicationManager::send_event(const uint8_t* data, size_t len) {
    if (peer_fd_ < 0) return false;
    struct io_uring_sqe* sqe = io_uring_get_sqe(&ring_);
    if (!sqe) return false;
    uint32_t frame_len = static_cast<uint32_t>(len);
    io_uring_prep_send(sqe, peer_fd_, &frame_len, sizeof(frame_len), MSG_MORE);
    io_uring_submit(&ring_);
    sqe = io_uring_get_sqe(&ring_);
    if (!sqe) return false;
    io_uring_prep_send(sqe, peer_fd_, data, len, 0);
    io_uring_submit(&ring_);
    return true;
}

void ReplicationManager::send_heartbeat() {
    if (peer_fd_ < 0) return;
    uint32_t zero = 0;
    struct io_uring_sqe* sqe = io_uring_get_sqe(&ring_);
    if (!sqe) return;
    io_uring_prep_send(sqe, peer_fd_, &zero, sizeof(zero), 0);
    io_uring_submit(&ring_);
}

void ReplicationManager::run_primary() {
    socket_fd_ = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(socket_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    setsockopt(socket_fd_, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port_);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(socket_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
    listen(socket_fd_, 1);
    peer_fd_ = accept(socket_fd_, nullptr, nullptr);
    setsockopt(peer_fd_, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
    while (running_.load(std::memory_order_acquire)) {
        struct io_uring_cqe* cqe;
        struct __kernel_timespec ts = {.tv_sec = 0, .tv_nsec = 1000000};
        io_uring_wait_cqe_timeout(&ring_, &cqe, &ts);
        if (cqe) io_uring_cqe_seen(&ring_, cqe);
    }
}

void ReplicationManager::run_standby() {
    socket_fd_ = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(socket_fd_, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port_);
    inet_pton(AF_INET, peer_host_.c_str(), &addr.sin_addr);
    connect(socket_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
    peer_fd_ = socket_fd_;
    auto last_heartbeat = std::chrono::steady_clock::now();
    while (running_.load(std::memory_order_acquire)) {
        uint32_t frame_len = 0;
        ssize_t n = recv(peer_fd_, &frame_len, sizeof(frame_len), MSG_DONTWAIT);
        if (n == sizeof(frame_len)) {
            last_heartbeat = std::chrono::steady_clock::now();
            if (frame_len == 0) continue;
            uint8_t payload[4096];
            size_t total_read = 0;
            while (total_read < frame_len) {
                ssize_t r = recv(peer_fd_, payload + total_read, frame_len - total_read, 0);
                if (r <= 0) break;
                total_read += r;
            }
            if (total_read >= 8) {
                uint64_t seq;
                std::memcpy(&seq, payload, sizeof(seq));
                last_seq_.store(seq, std::memory_order_release);
            }
        }
        auto elapsed = std::chrono::steady_clock::now() - last_heartbeat;
        if (elapsed > std::chrono::milliseconds(heartbeat_timeout_ms_)) {
            role_ = Role::Primary;
            if (on_promote_) on_promote_();
            break;
        }
    }
}
#include "replication.h"
#include "order_book.h"
#include <cstdio>
#include <cstring>
#include <csignal>
#include <atomic>

static std::atomic<bool> g_running{true};

void signal_handler(int) {
    g_running.store(false, std::memory_order_release);
}

int main(int argc, char* argv[]) {
    signal(SIGINT, signal_handler);
    signal(SIGTERM, signal_handler);

    const char* primary_host = "127.0.0.1";
    uint16_t port = 9100;
    uint32_t heartbeat_timeout_ms = 50;

    if (argc >= 2) primary_host = argv[1];
    if (argc >= 3) port = static_cast<uint16_t>(std::atoi(argv[2]));
    if (argc >= 4) heartbeat_timeout_ms = static_cast<uint32_t>(std::atoi(argv[3]));

    OrderBook mirror_book;

    auto on_promote = [&]() {
        std::printf("[FAILOVER] Standby promoted to PRIMARY.\n");
        std::printf("[FAILOVER] Last replicated sequence: %lu\n",
                    static_cast<unsigned long>(0));
    };

    ReplicationManager repl(
        ReplicationManager::Role::Standby,
        primary_host, port, heartbeat_timeout_ms, on_promote
    );

    std::printf("[STANDBY] Connecting to primary at %s:%u\n", primary_host, port);
    std::printf("[STANDBY] Heartbeat timeout: %u ms\n", heartbeat_timeout_ms);
    repl.start();

    while (g_running.load(std::memory_order_acquire)) {
        if (repl.current_role() == ReplicationManager::Role::Primary) {
            std::printf("[STANDBY] Now running as primary.\n");
            break;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
    }

    repl.stop();
    return 0;
}
#include <gtest/gtest.h>
#include "replication.h"
#include <thread>
#include <chrono>
#include <atomic>
#include <cstring>

TEST(ReplicationTest, FailoverZeroEventLoss) {
    constexpr uint16_t PORT = 9200;
    constexpr uint32_t HEARTBEAT_TIMEOUT_MS = 50;
    constexpr int NUM_EVENTS = 1000;
    std::atomic<bool> promoted{false};

    ReplicationManager primary(ReplicationManager::Role::Primary,
        "127.0.0.1", PORT, HEARTBEAT_TIMEOUT_MS);
    primary.start();
    std::this_thread::sleep_for(std::chrono::milliseconds(50));

    ReplicationManager standby(ReplicationManager::Role::Standby,
        "127.0.0.1", PORT, HEARTBEAT_TIMEOUT_MS,
        [&]() { promoted.store(true); });
    standby.start();
    std::this_thread::sleep_for(std::chrono::milliseconds(50));

    for (int i = 0; i < NUM_EVENTS; ++i) {
        uint8_t event[64];
        uint64_t seq = static_cast<uint64_t>(i);
        std::memcpy(event, &seq, sizeof(seq));
        std::memset(event + 8, 0xAB, 56);
        primary.send_event(event, sizeof(event));
        if (i % 100 == 0) primary.send_heartbeat();
    }

    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    uint64_t received = standby.last_received_sequence();
    EXPECT_GE(received, NUM_EVENTS - 2);

    primary.stop();
    std::this_thread::sleep_for(std::chrono::milliseconds(HEARTBEAT_TIMEOUT_MS + 100));
    EXPECT_TRUE(promoted.load());
    EXPECT_EQ(standby.current_role(), ReplicationManager::Role::Primary);
    standby.stop();
}

TEST(ReplicationTest, HeartbeatKeepsStandbyAlive) {
    constexpr uint16_t PORT = 9201;
    constexpr uint32_t HEARTBEAT_TIMEOUT_MS = 100;
    std::atomic<bool> promoted{false};

    ReplicationManager primary(ReplicationManager::Role::Primary,
        "127.0.0.1", PORT, HEARTBEAT_TIMEOUT_MS);
    primary.start();
    std::this_thread::sleep_for(std::chrono::milliseconds(50));

    ReplicationManager standby(ReplicationManager::Role::Standby,
        "127.0.0.1", PORT, HEARTBEAT_TIMEOUT_MS,
        [&]() { promoted.store(true); });
    standby.start();
    std::this_thread::sleep_for(std::chrono::milliseconds(50));

    for (int i = 0; i < 6; ++i) {
        primary.send_heartbeat();
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }

    EXPECT_FALSE(promoted.load());
    EXPECT_EQ(standby.current_role(), ReplicationManager::Role::Standby);
    primary.stop();
    standby.stop();
}
# Sequencer library
add_library(sequencer src/sequencer.cpp)
target_link_libraries(sequencer PRIVATE spsc_ring)
target_include_directories(sequencer PUBLIC include)

# Replication library
add_library(replication src/replication.cpp)
target_link_libraries(replication PRIVATE uring)
target_include_directories(replication PUBLIC include)

# Standby process executable
add_executable(standby_process src/standby_process.cpp)
target_link_libraries(standby_process PRIVATE replication order_book)

# Replication tests
add_executable(test_replication tests/test_replication.cpp)
target_link_libraries(test_replication PRIVATE replication GTest::gtest_main)
add_test(NAME ReplicationTest COMMAND test_replication)

Your exchange now has deterministic ordering and high availability. Every message that enters the system gets a global sequence number, and a hot standby can take over in under 50 milliseconds. Next up, you'll add the pre-trade risk engine and FIX gateway to complete the order lifecycle.

Build the Pre-Trade Risk Engine and FIX Gateway

Your sequencer guarantees total message ordering. Your replication streams every event to a hot standby. But there is nothing between the sequencer and matcher that says "this order is safe to execute."

Real exchanges reject dangerous orders before they ever touch the order book. In this step, you will build an inline pre-trade risk engine that checks every order in under 100ns and a FIX 4.4 gateway that speaks the universal language of institutional trading.

In this step, get ready to:
  • Build an inline risk engine with position limits, rate throttling, fat-finger checks, and self-trade prevention.
  • Benchmark the risk engine in isolation targeting less than 100ns added latency.
  • Build a FIX 4.4 gateway that accepts institutional client connections and translates orders to SBE.
Build the pre-trade risk engine

The risk engine sits inline on the hot path between the sequencer and the matcher. Every order passes through it. If the order fails any check, it never reaches the order book.

  • Create the risk engine header by running this command:
touch include/risk_engine.h
  • Add the following code to include/risk_engine.h:
#pragma once

#include "types.h"
#include <cstdint>
#include <array>

namespace exchange {

// Cache-aligned per-client state to avoid false sharing between cores
struct alignas(64) ClientRiskState {
    int64_t position;            // net position in contract units
    int64_t position_limit;      // max absolute position allowed
    uint64_t last_order_ts_ns;   // timestamp of last order (rate throttle)
    uint32_t orders_this_second; // rolling count for rate limit
    uint32_t max_orders_per_sec; // configurable per-client
    uint32_t client_id;
    uint32_t padding;
};

// Per-symbol market state for fat-finger price checks
struct alignas(64) SymbolRiskState {
    int64_t last_trade_price;    // last traded price (fixed-point)
    int64_t fat_finger_bps;      // max deviation in basis points (500 = 5%)
};

// Risk rejection reasons returned by check_order
enum class RiskRejectReason : uint8_t {
    NONE = 0,
    POSITION_LIMIT_BREACH,
    RATE_LIMIT_BREACH,
    FAT_FINGER_PRICE,
    SELF_TRADE_PREVENTION
};

class RiskEngine {
public:
    static constexpr uint32_t MAX_CLIENTS = 1024;
    static constexpr uint32_t MAX_SYMBOLS = 256;

    RiskEngine();

    // Returns NONE if order passes all checks
    // Branch-free implementation, no heap allocation
    RiskRejectReason check_order(uint32_t client_id,
                                  uint32_t symbol_id,
                                  Side side,
                                  int64_t price,
                                  int64_t quantity) noexcept;

    // Update position state after a fill
    void on_fill(uint32_t client_id, uint32_t symbol_id,
                 Side side, int64_t price, int64_t quantity) noexcept;

    // Configure per-client limits
    void set_client_limits(uint32_t client_id,
                           int64_t position_limit,
                           uint32_t max_orders_per_sec) noexcept;

    // Configure per-symbol thresholds
    void set_symbol_thresholds(uint32_t symbol_id,
                               int64_t fat_finger_bps) noexcept;

    // Update last trade price after each fill
    void update_last_trade(uint32_t symbol_id, int64_t price) noexcept;

private:
    std::array<ClientRiskState, MAX_CLIENTS> client_state_{};
    std::array<SymbolRiskState, MAX_SYMBOLS> symbol_state_{};
};

} // namespace exchange

What does this code do?

This header defines the risk engine's data structures and interface. There are three key design decisions here.

  • Every struct uses alignas(64) to match a CPU cache line. This prevents false sharing when multiple threads access different client states.
  • The RiskRejectReason enum lets the caller know exactly why an order was rejected without any string allocation.
  • Fixed-size arrays (no std::unordered_map) ensure zero heap allocation on every call to check_order.
  • Create the implementation file by running:
touch src/risk_engine.cpp
  • Add the following code to src/risk_engine.cpp:
#include "risk_engine.h"
#include <time.h>

namespace exchange {

RiskEngine::RiskEngine() {
    // Zero-initialize all state arrays (no heap allocation)
    for (auto& cs : client_state_) {
        cs = {};
        cs.max_orders_per_sec = 1000;  // sensible default
        cs.position_limit = 1000000;   // default 10K lots
    }
    for (auto& ss : symbol_state_) {
        ss = {};
        ss.fat_finger_bps = 500;       // default 5% threshold
    }
}

RiskRejectReason RiskEngine::check_order(uint32_t client_id,
                                          uint32_t symbol_id,
                                          Side side,
                                          int64_t price,
                                          int64_t quantity) noexcept {
    auto& client = client_state_[client_id % MAX_CLIENTS];
    auto& symbol = symbol_state_[symbol_id % MAX_SYMBOLS];

    // Position limit check (branch-free arithmetic)
    int64_t sign = (side == Side::BUY) ? 1 : -1;
    int64_t projected = client.position + (sign * quantity);
    int64_t abs_projected = projected * ((projected >= 0) - (projected < 0));
    uint64_t pos_breach = static_cast<uint64_t>(
        abs_projected > client.position_limit);

    // Rate throttle check using CLOCK_MONOTONIC_RAW
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
    uint64_t now_ns = ts.tv_sec * 1000000000ULL + ts.tv_nsec;
    uint64_t elapsed = now_ns - client.last_order_ts_ns;

    // Reset counter if more than 1 second has elapsed
    uint32_t reset_mask = static_cast<uint32_t>(elapsed >= 1000000000ULL);
    client.orders_this_second =
        (client.orders_this_second + 1) * (1 - reset_mask);
    client.last_order_ts_ns = now_ns;

    uint64_t rate_breach = static_cast<uint64_t>(
        client.orders_this_second > client.max_orders_per_sec);

    // Fat-finger price check (reject if deviation > threshold)
    int64_t last_price = symbol.last_trade_price;
    int64_t price_diff = price - last_price;
    int64_t abs_diff = price_diff * ((price_diff >= 0) - (price_diff < 0));
    int64_t deviation_bps = (last_price > 0)
        ? (abs_diff * 10000) / last_price : 0;
    uint64_t fat_finger_breach = static_cast<uint64_t>(
        (last_price > 0) & (deviation_bps > symbol.fat_finger_bps));

    // Return first failing check in priority order
    if (pos_breach) return RiskRejectReason::POSITION_LIMIT_BREACH;
    if (rate_breach) return RiskRejectReason::RATE_LIMIT_BREACH;
    if (fat_finger_breach) return RiskRejectReason::FAT_FINGER_PRICE;

    return RiskRejectReason::NONE;
}

void RiskEngine::on_fill(uint32_t client_id, uint32_t symbol_id,
                         Side side, int64_t price,
                         int64_t quantity) noexcept {
    auto& client = client_state_[client_id % MAX_CLIENTS];
    int64_t sign = (side == Side::BUY) ? 1 : -1;
    client.position += sign * quantity;
    update_last_trade(symbol_id, price);
}

void RiskEngine::set_client_limits(uint32_t client_id,
                                   int64_t position_limit,
                                   uint32_t max_orders_per_sec) noexcept {
    auto& client = client_state_[client_id % MAX_CLIENTS];
    client.position_limit = position_limit;
    client.max_orders_per_sec = max_orders_per_sec;
}

void RiskEngine::set_symbol_thresholds(uint32_t symbol_id,
                                       int64_t fat_finger_bps) noexcept {
    auto& symbol = symbol_state_[symbol_id % MAX_SYMBOLS];
    symbol.fat_finger_bps = fat_finger_bps;
}

void RiskEngine::update_last_trade(uint32_t symbol_id,
                                   int64_t price) noexcept {
    auto& symbol = symbol_state_[symbol_id % MAX_SYMBOLS];
    symbol.last_trade_price = price;
}

} // namespace exchange

Why branch-free comparisons?

CPU branch predictors struggle with unpredictable patterns. Order flow is inherently unpredictable. A branch miss costs 10-20 CPU cycles.

The pattern value * ((value >= 0) - (value < 0)) computes absolute value without a branch. The compiler turns the comparisons into conditional-move instructions that never stall the pipeline.

  • Save both files.
  • Verify the code compiles by running:
g++ -std=c++20 -c src/risk_engine.cpp -Iinclude -o /dev/null

You should see no output if compilation succeeds.

Seeing a compilation error?

  • Check that include/types.h defines the Side enum (BUY/SELL). The risk engine depends on it.
  • Make sure the #include "types.h" path resolves relative to the -Iinclude flag.

Help me debug my risk engine compilation error.

  • Now create the unit tests file by running:
touch tests/test_risk_engine.cpp
  • Add the following test code to tests/test_risk_engine.cpp:
#include <gtest/gtest.h>
#include "risk_engine.h"

using namespace exchange;

class RiskEngineTest : public ::testing::Test {
protected:
    RiskEngine engine;

    void SetUp() override {
        // Position limit 1000, max 5 orders/sec
        engine.set_client_limits(1, 1000, 5);
        // 5% fat-finger threshold
        engine.set_symbol_thresholds(1, 500);
        // Last trade at $100.00 (fixed-point: 10000000 hundredths-of-cent)
        engine.update_last_trade(1, 10000000);
    }
};

TEST_F(RiskEngineTest, PassesValidOrder) {
    auto result = engine.check_order(1, 1, Side::BUY, 10001000, 100);
    EXPECT_EQ(result, RiskRejectReason::NONE);
}

TEST_F(RiskEngineTest, RejectsPositionLimitBreach) {
    // Quantity 2000 exceeds position limit of 1000
    auto result = engine.check_order(1, 1, Side::BUY, 10000000, 2000);
    EXPECT_EQ(result, RiskRejectReason::POSITION_LIMIT_BREACH);
}

TEST_F(RiskEngineTest, RejectsRateLimitBreach) {
    // Send max_orders_per_sec + 1 orders rapidly
    for (int i = 0; i < 5; ++i) {
        engine.check_order(1, 1, Side::BUY, 10000000, 10);
    }
    auto result = engine.check_order(1, 1, Side::BUY, 10000000, 10);
    EXPECT_EQ(result, RiskRejectReason::RATE_LIMIT_BREACH);
}

TEST_F(RiskEngineTest, RejectsFatFingerPrice) {
    // Price 10% above last trade (threshold is 5%)
    int64_t fat_price = 11000000;
    auto result = engine.check_order(1, 1, Side::BUY, fat_price, 10);
    EXPECT_EQ(result, RiskRejectReason::FAT_FINGER_PRICE);
}

TEST_F(RiskEngineTest, UpdatesPositionOnFill) {
    engine.on_fill(1, 1, Side::BUY, 10000000, 500);
    // At 500, limit is 1000 - should still pass for 400 more
    auto result = engine.check_order(1, 1, Side::BUY, 10000000, 400);
    EXPECT_EQ(result, RiskRejectReason::NONE);
    // But 600 more would breach
    auto result2 = engine.check_order(1, 1, Side::BUY, 10000000, 600);
    EXPECT_EQ(result2, RiskRejectReason::POSITION_LIMIT_BREACH);
}

What do these tests cover?

  • Each test targets one specific risk check in isolation (position limit, rate throttle, fat-finger).
  • The fixture pre-configures tight limits (1000 position, 5 orders/sec, 5% price band) so tests trigger reliably.
  • The UpdatesPositionOnFill test verifies that the engine tracks cumulative position correctly across multiple fills.
  • Save tests/test_risk_engine.cpp.
  • Build and run the tests:
cd build && cmake .. && make test_risk_engine && ./tests/test_risk_engine

You should see all 5 tests passing with green output.

Tests failing on rate limit?

The rate limit test depends on all 6 orders executing within the same 1-second window. On extremely slow machines, a context switch between iterations could push the test past the window boundary. If this happens intermittently, increase max_orders_per_sec to 50 in the test fixture and send 51 orders instead.

Help me debug my failing risk engine rate limit test.

Benchmark the risk engine in isolation

The risk engine must add less than 100ns to the hot path. You will prove this with a Google Benchmark microbenchmark that isolates the check_order function.

  • Create the benchmark file by running:
touch benchmarks/bench_risk.cpp
  • Add the following code to benchmarks/bench_risk.cpp:
#include <benchmark/benchmark.h>
#include "risk_engine.h"

using namespace exchange;

// Benchmark a single risk check in isolation
static void BM_RiskCheck(benchmark::State& state) {
    RiskEngine engine;
    engine.set_client_limits(1, 1000000, 10000);
    engine.set_symbol_thresholds(1, 500);
    engine.update_last_trade(1, 10000000); // $100.00

    for (auto _ : state) {
        auto result = engine.check_order(
            1, 1, Side::BUY, 10001000, 100);
        benchmark::DoNotOptimize(result);
    }
}
BENCHMARK(BM_RiskCheck);

// Benchmark risk check followed by position update
static void BM_RiskCheckWithFill(benchmark::State& state) {
    RiskEngine engine;
    engine.set_client_limits(1, 1000000, 10000);
    engine.set_symbol_thresholds(1, 500);
    engine.update_last_trade(1, 10000000);

    for (auto _ : state) {
        auto result = engine.check_order(
            1, 1, Side::BUY, 10001000, 100);
        benchmark::DoNotOptimize(result);
        engine.on_fill(1, 1, Side::BUY, 10001000, 100);
    }
}
BENCHMARK(BM_RiskCheckWithFill);

BENCHMARK_MAIN();

What does benchmark::DoNotOptimize do?

Without it, the compiler sees that result is never used after assignment and removes the entire check_order call. DoNotOptimize forces the compiler to materialize the value in a register, ensuring the benchmark measures real work.

  • Save benchmarks/bench_risk.cpp.
  • Build and run the benchmark:
cmake .. && make bench_risk && ./benchmarks/bench_risk

You should see nanosecond-level timing for BM_RiskCheck. On most machines, it reports well under 100ns per iteration.

Seeing numbers above 100ns?

  • The clock_gettime call inside check_order adds overhead in WSL2 due to virtualization. On bare metal Linux, this call costs around 20ns. In WSL2 it can be 40-60ns.
  • Make sure you compile with -O2 or -O3 optimization flags. Debug builds will not meet the latency target.
  • Verify your CMakeLists.txt sets CMAKE_BUILD_TYPE to Release for the benchmark target.

Help me optimize my risk engine benchmark results.

Build the FIX 4.4 gateway

Every hedge fund and broker speaks FIX. Your exchange needs a gateway that accepts TCP connections from institutional clients, parses their FIX messages, translates them into your internal SBE format, and routes them to the sequencer.

  • Create the FIX gateway header by running:
touch include/fix_gateway.h
  • Add the following code to include/fix_gateway.h:
#pragma once

#include "types.h"
#include "spsc_ring.h"
#include <cstdint>
#include <string_view>
#include <unordered_map>
#include <vector>

namespace exchange {

// FIX 4.4 tag constants
constexpr int FIX_TAG_MSG_TYPE = 35;
constexpr int FIX_TAG_SENDER_COMP_ID = 49;
constexpr int FIX_TAG_CL_ORD_ID = 11;
constexpr int FIX_TAG_SYMBOL = 55;
constexpr int FIX_TAG_SIDE = 54;
constexpr int FIX_TAG_ORDER_QTY = 38;
constexpr int FIX_TAG_PRICE = 44;
constexpr int FIX_TAG_ORD_TYPE = 40;
constexpr int FIX_TAG_EXEC_TYPE = 150;
constexpr int FIX_TAG_ORD_STATUS = 39;
constexpr char FIX_SOH = '\x01'; // ASCII SOH delimiter

// Parsed fields from a FIX NewOrderSingle (tag 35=D)
struct FIXNewOrderSingle {
    uint32_t client_id;
    uint32_t symbol_id;
    char cl_ord_id[20];
    Side side;
    int64_t price;      // fixed-point (hundredths of cent)
    int64_t quantity;
    OrderType order_type;
};

class FIXGateway {
public:
    FIXGateway(uint16_t port, SPSCRing& sequencer_ring);
    ~FIXGateway();

    // Start accepting connections (blocking event loop)
    void run();

    // Stop the gateway gracefully
    void stop() noexcept;

    // Parse raw FIX message buffer into structured fields
    static bool parse_new_order_single(const char* buf, size_t len,
                                       FIXNewOrderSingle& out) noexcept;

    // Translate parsed FIX order to SBE and push to sequencer
    bool translate_and_route(const FIXNewOrderSingle& order) noexcept;

    // Send FIX ExecutionReport (tag 35=8) back to client
    void send_execution_report(int client_fd, uint32_t cl_ord_id,
                               char exec_type, char ord_status,
                               int64_t fill_price, int64_t fill_qty);

private:
    void accept_connection();
    void handle_client_data(int fd);

    uint16_t port_;
    int listen_fd_;
    int epoll_fd_;
    SPSCRing& sequencer_ring_;
    bool running_;
    std::unordered_map<int, std::vector<char>> client_buffers_;
};

} // namespace exchange

What is FIX protocol?

FIX (Financial Information eXchange) is a plain-text protocol where each field is a tag=value pair separated by ASCII SOH (0x01) characters. Tag 35 identifies the message type. Tag 35=D means NewOrderSingle. Tag 35=8 means ExecutionReport.

The protocol is simple to parse because it is just structured key-value pairs. Tag 54=1 means Buy. Tag 54=2 means Sell. Tag 55 carries the symbol. Tag 44 carries the price. Tag 38 carries the quantity.

  • Create the implementation file by running:
touch src/fix_gateway.cpp
  • Add the following code to src/fix_gateway.cpp:
#include "fix_gateway.h"
#include "sbe/NewOrderSingle.h"
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <cstdio>

namespace exchange {

FIXGateway::FIXGateway(uint16_t port, SPSCRing& sequencer_ring)
    : port_(port), listen_fd_(-1), epoll_fd_(-1),
      sequencer_ring_(sequencer_ring), running_(false) {
    // Create non-blocking TCP listening socket
    listen_fd_ = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
    int opt = 1;
    setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    // Disable Nagle algorithm for low latency
    setsockopt(listen_fd_, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

    struct sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(port_);
    bind(listen_fd_, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd_, 64);

    // Set up epoll for multiplexing client connections
    epoll_fd_ = epoll_create1(0);
    struct epoll_event ev{};
    ev.events = EPOLLIN;
    ev.data.fd = listen_fd_;
    epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, listen_fd_, &ev);
}

FIXGateway::~FIXGateway() {
    if (listen_fd_ >= 0) close(listen_fd_);
    if (epoll_fd_ >= 0) close(epoll_fd_);
}

void FIXGateway::run() {
    running_ = true;
    struct epoll_event events[64];

    while (running_) {
        int n = epoll_wait(epoll_fd_, events, 64, 100);
        for (int i = 0; i < n; ++i) {
            if (events[i].data.fd == listen_fd_) {
                accept_connection();
            } else {
                handle_client_data(events[i].data.fd);
            }
        }
    }
}

void FIXGateway::stop() noexcept { running_ = false; }

void FIXGateway::accept_connection() {
    struct sockaddr_in client_addr{};
    socklen_t addr_len = sizeof(client_addr);
    int client_fd = accept4(listen_fd_, (struct sockaddr*)&client_addr,
                            &addr_len, SOCK_NONBLOCK);
    if (client_fd < 0) return;

    // Disable Nagle on client connection
    int opt = 1;
    setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

    struct epoll_event ev{};
    ev.events = EPOLLIN;
    ev.data.fd = client_fd;
    epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, client_fd, &ev);
    client_buffers_[client_fd] = {};
}

void FIXGateway::handle_client_data(int fd) {
    char buf[4096];
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n <= 0) {
        // Client disconnected
        epoll_ctl(epoll_fd_, EPOLL_CTL_DEL, fd, nullptr);
        close(fd);
        client_buffers_.erase(fd);
        return;
    }

    // Append to client buffer and process complete messages
    auto& cbuf = client_buffers_[fd];
    cbuf.insert(cbuf.end(), buf, buf + n);

    FIXNewOrderSingle order{};
    if (parse_new_order_single(cbuf.data(), cbuf.size(), order)) {
        translate_and_route(order);
        cbuf.clear();
    }
}

bool FIXGateway::parse_new_order_single(const char* buf, size_t len,
                                         FIXNewOrderSingle& out) noexcept {
    // Parse FIX tag=value|SOH format
    const char* pos = buf;
    const char* end = buf + len;
    bool is_new_order = false;

    while (pos < end) {
        // Parse tag number
        int tag = 0;
        while (pos < end && *pos != '=') {
            tag = tag * 10 + (*pos - '0');
            ++pos;
        }
        if (pos >= end) return false;
        ++pos; // skip '='

        // Parse value until SOH
        const char* val_start = pos;
        while (pos < end && *pos != FIX_SOH) { ++pos; }
        size_t val_len = pos - val_start;
        if (pos < end) ++pos; // skip SOH

        // Map tag to struct field
        switch (tag) {
            case FIX_TAG_MSG_TYPE:
                is_new_order = (val_len == 1 && val_start[0] == 'D');
                break;
            case FIX_TAG_CL_ORD_ID:
                std::memcpy(out.cl_ord_id, val_start,
                           std::min(val_len, sizeof(out.cl_ord_id) - 1));
                break;
            case FIX_TAG_SYMBOL:
                out.symbol_id = 0;
                for (size_t i = 0; i < val_len; ++i)
                    out.symbol_id = out.symbol_id * 10 + (val_start[i] - '0');
                break;
            case FIX_TAG_SIDE:
                out.side = (val_start[0] == '1') ? Side::BUY : Side::SELL;
                break;
            case FIX_TAG_ORDER_QTY:
                out.quantity = 0;
                for (size_t i = 0; i < val_len; ++i)
                    out.quantity = out.quantity * 10 + (val_start[i] - '0');
                break;
            case FIX_TAG_PRICE: {
                // Parse decimal price into fixed-point
                out.price = 0;
                int decimals = -1;
                for (size_t i = 0; i < val_len; ++i) {
                    if (val_start[i] == '.') { decimals = 0; continue; }
                    out.price = out.price * 10 + (val_start[i] - '0');
                    if (decimals >= 0) ++decimals;
                }
                // Pad to 4 decimal places (hundredths of cent)
                while (decimals >= 0 && decimals < 4) {
                    out.price *= 10; ++decimals;
                }
                break;
            }
            case FIX_TAG_ORD_TYPE:
                if (val_start[0] == '1') out.order_type = OrderType::MARKET;
                else out.order_type = OrderType::LIMIT;
                break;
            case FIX_TAG_SENDER_COMP_ID:
                out.client_id = 0;
                for (size_t i = 0; i < val_len; ++i) {
                    if (val_start[i] >= '0' && val_start[i] <= '9')
                        out.client_id = out.client_id * 10 +
                            (val_start[i] - '0');
                }
                break;
        }
    }
    return is_new_order;
}

bool FIXGateway::translate_and_route(
        const FIXNewOrderSingle& order) noexcept {
    // Encode as SBE NewOrderSingle and push to sequencer ring
    alignas(64) char sbe_buf[256];
    sbe::NewOrderSingle msg;
    msg.wrapForEncode(sbe_buf, 0, sizeof(sbe_buf));
    msg.clientId(order.client_id);
    msg.symbolId(order.symbol_id);
    msg.side(order.side == Side::BUY
        ? sbe::SideEnum::BUY : sbe::SideEnum::SELL);
    msg.price(order.price);
    msg.quantity(order.quantity);

    return sequencer_ring_.push(sbe_buf, msg.encodedLength());
}

void FIXGateway::send_execution_report(int client_fd, uint32_t cl_ord_id,
                                        char exec_type, char ord_status,
                                        int64_t fill_price,
                                        int64_t fill_qty) {
    // Build FIX ExecutionReport (tag 35=8)
    char buf[512];
    int n = snprintf(buf, sizeof(buf),
        "8=FIX.4.4%c35=8%c11=%u%c150=%c%c39=%c%c44=%ld%c38=%ld%c",
        FIX_SOH, FIX_SOH, cl_ord_id, FIX_SOH,
        exec_type, FIX_SOH, ord_status, FIX_SOH,
        fill_price, FIX_SOH, fill_qty, FIX_SOH);
    write(client_fd, buf, n);
}

What does this code do?

  • The constructor creates a non-blocking TCP socket with TCP_NODELAY (disables Nagle buffering) and registers it with epoll for efficient connection multiplexing.
  • The parse_new_order_single function walks the FIX tag=value|SOH format character by character. Tag 35=D confirms it is a NewOrderSingle. Tag 54 gives the side. Tag 55 gives the symbol.
  • After parsing, translate_and_route encodes the order as SBE and pushes it into the sequencer's SPSC ring buffer.
  • The send_execution_report function formats a FIX ExecutionReport (tag 35=8) with ExecType (tag 150) and OrdStatus (tag 39) back to the client.
  • Save src/fix_gateway.cpp.
  • Now create the test file for the FIX gateway by running:
touch tests/test_fix_gateway.cpp
  • Add the following test code to tests/test_fix_gateway.cpp:
#include <gtest/gtest.h>
#include "fix_gateway.h"

using namespace exchange;

TEST(FIXGatewayTest, ParsesNewOrderSingle) {
    // Valid FIX NewOrderSingle: Buy 100 @ 100.50, symbol 1, client 1001
    std::string msg =
        "8=FIX.4.4\x01"
        "35=D\x01"
        "49=1001\x01"
        "11=ORD001\x01"
        "55=1\x01"
        "54=1\x01"
        "38=100\x01"
        "44=100.50\x01"
        "40=2\x01";

    FIXNewOrderSingle order{};
    bool ok = FIXGateway::parse_new_order_single(
        msg.data(), msg.size(), order);

    EXPECT_TRUE(ok);
    EXPECT_EQ(order.client_id, 1001u);
    EXPECT_EQ(order.symbol_id, 1u);
    EXPECT_EQ(order.side, Side::BUY);
    EXPECT_EQ(order.quantity, 100);
    EXPECT_EQ(order.order_type, OrderType::LIMIT);
    // Price: 100.50 -> 1005000 hundredths of cent
    EXPECT_EQ(order.price, 1005000);
}

TEST(FIXGatewayTest, RejectsNonNewOrderSingle) {
    // Message type 0 = Heartbeat, not D
    std::string msg = "8=FIX.4.4\x01" "35=0\x01" "49=1001\x01";

    FIXNewOrderSingle order{};
    bool ok = FIXGateway::parse_new_order_single(
        msg.data(), msg.size(), order);

    EXPECT_FALSE(ok);
}

TEST(FIXGatewayTest, ParsesSellSide) {
    std::string msg =
        "8=FIX.4.4\x01"
        "35=D\x01"
        "49=2002\x01"
        "11=ORD002\x01"
        "55=5\x01"
        "54=2\x01"
        "38=50\x01"
        "44=200.00\x01"
        "40=2\x01";

    FIXNewOrderSingle order{};
    bool ok = FIXGateway::parse_new_order_single(
        msg.data(), msg.size(), order);

    EXPECT_TRUE(ok);
    EXPECT_EQ(order.side, Side::SELL);
    EXPECT_EQ(order.symbol_id, 5u);
    EXPECT_EQ(order.client_id, 2002u);
}

What do these tests verify?

  • The first test confirms that a well-formed FIX NewOrderSingle (tag 35=D) is parsed correctly: side, symbol, price (converted to fixed-point), and quantity.
  • The second test verifies that non-D message types (like Heartbeat) are rejected by the parser.
  • The third test confirms that Sell side (tag 54=2) is correctly distinguished from Buy (tag 54=1).
  • Save tests/test_fix_gateway.cpp.
  • Update your CMakeLists.txt to include the new risk engine and FIX gateway targets, then build and run the FIX gateway tests:
cd build && cmake .. && make test_fix_gateway && ./tests/test_fix_gateway

You should see all 3 FIX gateway tests passing.

Parser test failing on price conversion?

  • The fixed-point conversion pads to 4 decimal places. The string 100.50 becomes 10050 after the decimal parse, then pads with two more zeros to get 1005000. Check your padding loop handles the case where fewer than 4 decimal digits appear in the input.
  • Make sure you initialize decimals to -1 and only start counting after the decimal point character.

Help me debug my FIX parser price conversion.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files and that both test suites pass.

ⓧ I'd like to double check the full code

Here are the complete files for reference. Your code should match these exactly.

#pragma once

#include "types.h"
#include <cstdint>
#include <array>

namespace exchange {

struct alignas(64) ClientRiskState {
    int64_t position;
    int64_t position_limit;
    uint64_t last_order_ts_ns;
    uint32_t orders_this_second;
    uint32_t max_orders_per_sec;
    uint32_t client_id;
    uint32_t padding;
};

struct alignas(64) SymbolRiskState {
    int64_t last_trade_price;
    int64_t fat_finger_bps;
};

enum class RiskRejectReason : uint8_t {
    NONE = 0,
    POSITION_LIMIT_BREACH,
    RATE_LIMIT_BREACH,
    FAT_FINGER_PRICE,
    SELF_TRADE_PREVENTION
};

class RiskEngine {
public:
    static constexpr uint32_t MAX_CLIENTS = 1024;
    static constexpr uint32_t MAX_SYMBOLS = 256;

    RiskEngine();

    RiskRejectReason check_order(uint32_t client_id,
                                  uint32_t symbol_id,
                                  Side side,
                                  int64_t price,
                                  int64_t quantity) noexcept;

    void on_fill(uint32_t client_id, uint32_t symbol_id,
                 Side side, int64_t price, int64_t quantity) noexcept;

    void set_client_limits(uint32_t client_id,
                           int64_t position_limit,
                           uint32_t max_orders_per_sec) noexcept;

    void set_symbol_thresholds(uint32_t symbol_id,
                               int64_t fat_finger_bps) noexcept;

    void update_last_trade(uint32_t symbol_id, int64_t price) noexcept;

private:
    std::array<ClientRiskState, MAX_CLIENTS> client_state_{};
    std::array<SymbolRiskState, MAX_SYMBOLS> symbol_state_{};
};

} // namespace exchange
#pragma once

#include "types.h"
#include "spsc_ring.h"
#include <cstdint>
#include <string_view>
#include <unordered_map>
#include <vector>

namespace exchange {

constexpr int FIX_TAG_MSG_TYPE = 35;
constexpr int FIX_TAG_SENDER_COMP_ID = 49;
constexpr int FIX_TAG_CL_ORD_ID = 11;
constexpr int FIX_TAG_SYMBOL = 55;
constexpr int FIX_TAG_SIDE = 54;
constexpr int FIX_TAG_ORDER_QTY = 38;
constexpr int FIX_TAG_PRICE = 44;
constexpr int FIX_TAG_ORD_TYPE = 40;
constexpr int FIX_TAG_EXEC_TYPE = 150;
constexpr int FIX_TAG_ORD_STATUS = 39;
constexpr char FIX_SOH = '\x01';

struct FIXNewOrderSingle {
    uint32_t client_id;
    uint32_t symbol_id;
    char cl_ord_id[20];
    Side side;
    int64_t price;
    int64_t quantity;
    OrderType order_type;
};

class FIXGateway {
public:
    FIXGateway(uint16_t port, SPSCRing& sequencer_ring);
    ~FIXGateway();

    void run();
    void stop() noexcept;

    static bool parse_new_order_single(const char* buf, size_t len,
                                       FIXNewOrderSingle& out) noexcept;

    bool translate_and_route(const FIXNewOrderSingle& order) noexcept;

    void send_execution_report(int client_fd, uint32_t cl_ord_id,
                               char exec_type, char ord_status,
                               int64_t fill_price, int64_t fill_qty);

private:
    void accept_connection();
    void handle_client_data(int fd);

    uint16_t port_;
    int listen_fd_;
    int epoll_fd_;
    SPSCRing& sequencer_ring_;
    bool running_;
    std::unordered_map<int, std::vector<char>> client_buffers_;
};

} // namespace exchange

Your exchange now rejects dangerous orders in under 100ns and speaks FIX to the outside world. Next up, you will instrument every hop in the pipeline with nanosecond-resolution timestamps and build market surveillance to catch manipulative trading patterns.

Add Wire-to-Wire Instrumentation and Market Surveillance

Your matching engine now processes orders through a complete pipeline: FIX gateway, sequencer, risk checks, and matching. But you have no visibility into where time is actually spent.

In this step, you will instrument every hop in the hot path with nanosecond-resolution timestamps, build market surveillance detectors (spoofing, self-trade, circuit breakers), and wire everything into a live terminal dashboard. This gives you the same operational visibility that exchange operations teams rely on at CME and Nasdaq.

In this step, get ready to:
  • Implement wire-to-wire latency instrumentation with P50/P99/P999/max percentile reporting.
  • Build market surveillance detectors for spoofing, self-trade, and circuit breakers.
  • Build a terminal dashboard with ncurses showing live order book, latency histograms, and replication status.
Implement wire-to-wire latency instrumentation

Every real exchange measures latency at each component boundary. You need to know exactly how many nanoseconds the sequencer adds, how much the risk engine costs, and where your time budget is going.

Why CLOCK_MONOTONIC_RAW?

CLOCK_MONOTONIC_RAW provides access to raw hardware-based time that is not subject to NTP adjustments or incremental adjustments performed by adjtime. This makes it ideal for measuring sub-microsecond intervals on the hot path, because NTP corrections would introduce measurement jitter.

  • Create the latency tracker header by adding this code to include/latency_tracker.h:
#pragma once
#include <cstdint>
#include <cstring>
#include <time.h>
#include <algorithm>
#include <array>
#include <cstdio>

// Sidecar struct carrying CLOCK_MONOTONIC_RAW timestamps at each hop
struct LatencySidecar {
    uint64_t gateway_ingress_ns;    // timestamp at gateway TCP read
    uint64_t sequencer_assign_ns;   // timestamp at sequence number assignment
    uint64_t risk_check_ns;         // timestamp after risk engine pass
    uint64_t match_start_ns;        // timestamp entering order book match
    uint64_t match_end_ns;          // timestamp exiting order book match
    uint64_t log_write_ns;          // timestamp after event log mmap write
    uint64_t replication_send_ns;   // timestamp after replication TCP send
    uint64_t response_egress_ns;    // timestamp at gateway TCP write back
};

// Inline helper to read CLOCK_MONOTONIC_RAW in nanoseconds
inline uint64_t now_ns() {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
    return static_cast<uint64_t>(ts.tv_sec) * 1'000'000'000ULL
         + static_cast<uint64_t>(ts.tv_nsec);
}

// HdrHistogram-style percentile accumulator per pipeline segment
class LatencyTracker {
public:
    // Segment identifiers matching the sidecar hops
    enum class Segment {
        GatewayToSequencer,
        SequencerToRisk,
        RiskToMatchStart,
        MatchDuration,
        MatchToLog,
        LogToReplication,
        ReplicationToEgress,
        WireToWire,  // full end-to-end
        COUNT
    };

    LatencyTracker();

    // Record a completed sidecar into all segment histograms
    void record(const LatencySidecar& sc);

    // Report P50/P99/P999/max for a given segment
    struct Percentiles {
        uint64_t p50_ns;
        uint64_t p99_ns;
        uint64_t p999_ns;
        uint64_t max_ns;
        uint64_t count;
    };

    Percentiles report(Segment seg) const;

    // Reset all histograms
    void reset();

private:
    // Simple sorted-insert histogram (production would use HdrHistogram)
    static constexpr size_t MAX_SAMPLES = 100'000;

    struct Histogram {
        std::array<uint64_t, MAX_SAMPLES> samples{};
        size_t count = 0;

        void add(uint64_t value);
        uint64_t percentile(double p) const;
        uint64_t max() const;
    };

    static constexpr size_t NUM_SEGMENTS = static_cast<size_t>(Segment::COUNT);
    Histogram histograms_[NUM_SEGMENTS];
};

What does this code do?

The LatencySidecar struct travels alongside each message through the pipeline. Each component stamps its field using now_ns(), which calls clock_gettime(CLOCK_MONOTONIC_RAW, ...) for raw hardware time.

The LatencyTracker class accumulates samples per segment and computes percentiles on demand. In production you would use a proper HdrHistogram, but for learning the pattern this sorted-array approach is clear and correct.

  • Create the implementation by adding this code to src/latency_tracker.cpp:
#include "latency_tracker.h"
#include <algorithm>
#include <cassert>

LatencyTracker::LatencyTracker() {
    reset();
}

void LatencyTracker::Histogram::add(uint64_t value) {
    if (count < MAX_SAMPLES) {
        samples[count++] = value;
    }
}

uint64_t LatencyTracker::Histogram::percentile(double p) const {
    if (count == 0) return 0;

    // Copy and sort for percentile calculation
    std::array<uint64_t, MAX_SAMPLES> sorted;
    std::copy(samples.begin(), samples.begin() + count, sorted.begin());
    std::sort(sorted.begin(), sorted.begin() + count);

    size_t idx = static_cast<size_t>(p * static_cast<double>(count - 1));
    return sorted[idx];
}

uint64_t LatencyTracker::Histogram::max() const {
    if (count == 0) return 0;
    return *std::max_element(samples.begin(), samples.begin() + count);
}

void LatencyTracker::record(const LatencySidecar& sc) {
    // Compute deltas between consecutive hops
    auto delta = [](uint64_t end, uint64_t start) -> uint64_t {
        return (end > start) ? (end - start) : 0;
    };

    histograms_[static_cast<size_t>(Segment::GatewayToSequencer)]
        .add(delta(sc.sequencer_assign_ns, sc.gateway_ingress_ns));

    histograms_[static_cast<size_t>(Segment::SequencerToRisk)]
        .add(delta(sc.risk_check_ns, sc.sequencer_assign_ns));

    histograms_[static_cast<size_t>(Segment::RiskToMatchStart)]
        .add(delta(sc.match_start_ns, sc.risk_check_ns));

    histograms_[static_cast<size_t>(Segment::MatchDuration)]
        .add(delta(sc.match_end_ns, sc.match_start_ns));

    histograms_[static_cast<size_t>(Segment::MatchToLog)]
        .add(delta(sc.log_write_ns, sc.match_end_ns));

    histograms_[static_cast<size_t>(Segment::LogToReplication)]
        .add(delta(sc.replication_send_ns, sc.log_write_ns));

    histograms_[static_cast<size_t>(Segment::ReplicationToEgress)]
        .add(delta(sc.response_egress_ns, sc.replication_send_ns));

    // Wire-to-wire: full end-to-end
    histograms_[static_cast<size_t>(Segment::WireToWire)]
        .add(delta(sc.response_egress_ns, sc.gateway_ingress_ns));
}

LatencyTracker::Percentiles LatencyTracker::report(Segment seg) const {
    const auto& h = histograms_[static_cast<size_t>(seg)];
    return Percentiles{
        .p50_ns  = h.percentile(0.50),
        .p99_ns  = h.percentile(0.99),
        .p999_ns = h.percentile(0.999),
        .max_ns  = h.max(),
        .count   = h.count
    };
}

void LatencyTracker::reset() {
    for (auto& h : histograms_) {
        h.count = 0;
    }
}

What does this implementation do?

The record() method computes the delta between consecutive sidecar timestamps and feeds each segment's histogram. The report() method sorts the samples and returns P50, P99, P999, and max latency for any segment.

The delta lambda guards against clock anomalies by clamping negative deltas to zero. This is defensive coding for virtualized environments like WSL2 where CLOCK_MONOTONIC_RAW can occasionally return identical values on back-to-back reads.

  • Save both files.
  • You won't see a change yet. The next chunk (surveillance) and the dashboard will consume LatencyTracker output to display histograms.

Seeing a compile error about CLOCK_MONOTONIC_RAW?

Make sure you have #include <time.h> (the C header, not <ctime>) and that your CMakeLists.txt links against rt if needed. On Ubuntu with GCC 12+, CLOCK_MONOTONIC_RAW is available without extra flags.

help me fix a CLOCK_MONOTONIC_RAW compile error in my latency tracker

Build market surveillance detectors

Real exchanges are required to detect manipulative behaviour in real time. You will implement three core surveillance checks: spoofing detection, self-trade prevention, and circuit breakers.

  • Create the surveillance header by adding this code to include/surveillance.h:
#pragma once
#include "types.h"
#include <cstdint>
#include <unordered_map>
#include <deque>
#include <functional>

// Market surveillance: spoofing, self-trade, circuit breakers
class MarketSurveillance {
public:
    // Configuration thresholds
    struct Config {
        uint64_t spoof_window_us;       // max microseconds between place+cancel
        int64_t  spoof_min_qty;         // minimum quantity to flag as spoof
        double   circuit_breaker_pct;   // max price move percentage
        uint64_t circuit_breaker_window_sec; // time window for price move
    };

    explicit MarketSurveillance(Config cfg);

    // Check if an order+cancel pair looks like spoofing
    // Returns true if the pattern is detected
    bool check_spoofing(uint64_t order_id, int64_t qty,
                        uint64_t place_time_ns, uint64_t cancel_time_ns);

    // Check if both sides of a potential match belong to the same client
    // Returns true if self-trade detected (should reject)
    bool check_self_trade(uint32_t aggressor_client_id,
                          uint32_t resting_client_id);

    // Update last trade price and check circuit breaker
    // Returns true if circuit breaker should trigger
    bool check_circuit_breaker(uint32_t symbol_id, int64_t trade_price,
                               uint64_t trade_time_ns);

    // Callback for MarketHalt event emission
    using HaltCallback = std::function<void(uint32_t symbol_id, int64_t trigger_price)>;
    void set_halt_callback(HaltCallback cb);

    // Query circuit breaker state
    bool is_halted(uint32_t symbol_id) const;

    // Resume trading after manual intervention
    void resume(uint32_t symbol_id);

private:
    Config cfg_;
    HaltCallback halt_cb_;

    // Circuit breaker state per symbol
    struct SymbolState {
        bool halted = false;
        int64_t reference_price = 0;    // price at start of window
        uint64_t window_start_ns = 0;   // start of current monitoring window
    };
    std::unordered_map<uint32_t, SymbolState> symbol_states_;

    // Spoofing detection: track recent large orders
    struct OrderRecord {
        int64_t qty;
        uint64_t place_time_ns;
    };
    std::unordered_map<uint64_t, OrderRecord> pending_orders_;
};

What does this code do?

The MarketSurveillance class implements three independent checks. Spoofing detection flags orders that are placed with large quantity and cancelled within a configurable microsecond window.

Self-trade detection is the simplest check: if the aggressor and resting order share the same client_id, the match is rejected. Circuit breakers halt a symbol when its trade price moves more than X% from a reference price within Y seconds.

  • Create the surveillance implementation by adding this code to src/surveillance.cpp:
#include "surveillance.h"
#include <cmath>

MarketSurveillance::MarketSurveillance(Config cfg)
    : cfg_(cfg), halt_cb_(nullptr) {}

void MarketSurveillance::set_halt_callback(HaltCallback cb) {
    halt_cb_ = std::move(cb);
}

bool MarketSurveillance::check_spoofing(
    uint64_t order_id, int64_t qty,
    uint64_t place_time_ns, uint64_t cancel_time_ns) {

    // Only flag if quantity exceeds the minimum threshold
    if (qty < cfg_.spoof_min_qty) return false;

    // Check time delta between place and cancel
    uint64_t delta_us = (cancel_time_ns - place_time_ns) / 1'000;
    return delta_us <= cfg_.spoof_window_us;
}

bool MarketSurveillance::check_self_trade(
    uint32_t aggressor_client_id,
    uint32_t resting_client_id) {

    // Same client on both sides of a potential fill
    return aggressor_client_id == resting_client_id;
}

bool MarketSurveillance::check_circuit_breaker(
    uint32_t symbol_id, int64_t trade_price, uint64_t trade_time_ns) {

    auto& state = symbol_states_[symbol_id];

    // If already halted, reject
    if (state.halted) return true;

    // Initialize reference on first trade
    if (state.reference_price == 0) {
        state.reference_price = trade_price;
        state.window_start_ns = trade_time_ns;
        return false;
    }

    // Check if we've moved outside the monitoring window
    uint64_t window_ns = cfg_.circuit_breaker_window_sec * 1'000'000'000ULL;
    if (trade_time_ns - state.window_start_ns > window_ns) {
        // Reset window with current price as new reference
        state.reference_price = trade_price;
        state.window_start_ns = trade_time_ns;
        return false;
    }

    // Calculate percentage move from reference price
    double move_pct = std::abs(
        static_cast<double>(trade_price - state.reference_price)
        / static_cast<double>(state.reference_price)
    ) * 100.0;

    if (move_pct > cfg_.circuit_breaker_pct) {
        state.halted = true;
        if (halt_cb_) {
            halt_cb_(symbol_id, trade_price);
        }
        return true;
    }

    return false;
}

bool MarketSurveillance::is_halted(uint32_t symbol_id) const {
    auto it = symbol_states_.find(symbol_id);
    if (it == symbol_states_.end()) return false;
    return it->second.halted;
}

void MarketSurveillance::resume(uint32_t symbol_id) {
    auto it = symbol_states_.find(symbol_id);
    if (it != symbol_states_.end()) {
        it->second.halted = false;
        it->second.reference_price = 0;  // will reset on next trade
    }
}

What does this code do?

The circuit breaker tracks a reference price per symbol. When a trade price exceeds the configured percentage threshold within the time window, it sets halted = true and fires the halt_cb_ callback. This callback emits a MarketHalt event into the event log for downstream consumers.

The window resets automatically when time elapses past the configured seconds. The resume() method allows manual intervention to restart trading after a halt.

  • Save both include/surveillance.h and src/surveillance.cpp.
  • You won't see a visible change yet. The dashboard in the next substep will display circuit breaker state.

Seeing linker errors about undefined reference to MarketSurveillance?

Make sure src/surveillance.cpp is listed in your CMakeLists.txt target sources. You will update CMakeLists.txt in the next substep.

help me fix linker errors for my MarketSurveillance class

Build the terminal dashboard with ncurses

The dashboard gives you real-time operational visibility into your running exchange. It reads from shared memory at 10 Hz, showing the order book ladder, recent trades, latency histograms, replication status, and circuit breaker state.

Why ncurses instead of a web dashboard?

HFT monitoring tools run in the terminal because they need zero serialization overhead. The dashboard reads directly from shared memory with no JSON encoding, no HTTP framing, and no JavaScript rendering. This keeps the monitoring path completely off the hot path.

  • Create the dashboard header by adding this code to include/dashboard.h:
#pragma once
#include "latency_tracker.h"
#include <ncurses.h>
#include <cstdint>
#include <string>
#include <vector>
#include <atomic>

// Shared memory layout for dashboard data
struct DashboardSharedData {
    // Order book ladder: top 10 bids and asks
    struct Level {
        int64_t price;     // fixed-point price (hundredths of cent)
        int64_t quantity;  // total visible quantity at this level
    };
    Level top_bids[10];
    Level top_asks[10];
    int bid_count;  // number of valid bid levels (0-10)
    int ask_count;  // number of valid ask levels (0-10)

    // Recent trades (circular buffer, newest first)
    struct Trade {
        int64_t price;
        int64_t quantity;
        uint64_t timestamp_ns;
        char side;  // 'B' or 'S'
    };
    Trade recent_trades[20];
    int trade_count;

    // Latency percentiles per segment
    LatencyTracker::Percentiles latency[8];  // one per Segment enum value

    // Replication status
    bool is_primary;
    bool standby_connected;
    uint64_t replication_lag_ns;
    uint64_t last_heartbeat_ns;

    // Circuit breaker state
    bool circuit_breaker_active;
    uint32_t halted_symbol_id;
    int64_t halt_trigger_price;

    // Update counter for detecting staleness
    std::atomic<uint64_t> update_seq;
};

// Terminal dashboard rendering engine
class NcursesDashboard {
public:
    NcursesDashboard();
    ~NcursesDashboard();

    // Attach to shared memory segment
    bool attach(const char* shm_name);

    // Main render loop (blocks, runs at 10 Hz)
    void run();

    // Request stop
    void stop();

private:
    void draw_order_book(WINDOW* win, const DashboardSharedData* data);
    void draw_trades(WINDOW* win, const DashboardSharedData* data);
    void draw_latency(WINDOW* win, const DashboardSharedData* data);
    void draw_replication(WINDOW* win, const DashboardSharedData* data);
    void draw_circuit_breaker(WINDOW* win, const DashboardSharedData* data);

    // Format fixed-point price for display
    std::string format_price(int64_t price_fp) const;

    DashboardSharedData* shm_data_;
    bool running_;
    WINDOW* book_win_;
    WINDOW* trades_win_;
    WINDOW* latency_win_;
    WINDOW* status_win_;
};

What does this code do?

The DashboardSharedData struct defines the shared memory layout that the matching engine writes to and the dashboard reads from. The atomic update_seq lets the dashboard detect stale data without any locks.

The NcursesDashboard class uses ncurses tiled windows (newwin) to render each panel independently. It calls wnoutrefresh on each window and then a single doupdate to minimize terminal output.

  • Create the dashboard implementation by adding this code to src/dashboard.cpp:
#include "dashboard.h"
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <cstdio>
#include <time.h>

NcursesDashboard::NcursesDashboard()
    : shm_data_(nullptr), running_(false),
      book_win_(nullptr), trades_win_(nullptr),
      latency_win_(nullptr), status_win_(nullptr) {}

NcursesDashboard::~NcursesDashboard() {
    if (book_win_) delwin(book_win_);
    if (trades_win_) delwin(trades_win_);
    if (latency_win_) delwin(latency_win_);
    if (status_win_) delwin(status_win_);
    endwin();
}

bool NcursesDashboard::attach(const char* shm_name) {
    // Open shared memory segment written by matching engine
    int fd = shm_open(shm_name, O_RDONLY, 0);
    if (fd < 0) return false;

    void* ptr = mmap(nullptr, sizeof(DashboardSharedData),
                     PROT_READ, MAP_SHARED, fd, 0);
    close(fd);

    if (ptr == MAP_FAILED) return false;
    shm_data_ = static_cast<DashboardSharedData*>(ptr);
    return true;
}

void NcursesDashboard::run() {
    // Initialize ncurses
    initscr();
    cbreak();
    noecho();
    curs_set(0);  // hide cursor
    nodelay(stdscr, TRUE);  // non-blocking getch

    // Create tiled windows for each panel
    int rows, cols;
    getmaxyx(stdscr, rows, cols);

    book_win_    = newwin(14, cols / 2, 0, 0);
    trades_win_  = newwin(14, cols / 2, 0, cols / 2);
    latency_win_ = newwin(rows - 14, cols / 2, 14, 0);
    status_win_  = newwin(rows - 14, cols / 2, 14, cols / 2);

    running_ = true;

    // 10 Hz refresh loop (100ms between frames)
    struct timespec sleep_ts = {0, 100'000'000};  // 100ms

    while (running_) {
        // Check for quit key
        int ch = getch();
        if (ch == 'q' || ch == 'Q') break;

        // Read shared memory snapshot
        if (shm_data_) {
            draw_order_book(book_win_, shm_data_);
            draw_trades(trades_win_, shm_data_);
            draw_latency(latency_win_, shm_data_);
            draw_replication(status_win_, shm_data_);
            draw_circuit_breaker(status_win_, shm_data_);
        }

        // Batch all window updates into single terminal write
        wnoutrefresh(book_win_);
        wnoutrefresh(trades_win_);
        wnoutrefresh(latency_win_);
        wnoutrefresh(status_win_);
        doupdate();

        nanosleep(&sleep_ts, nullptr);
    }

    running_ = false;
}

void NcursesDashboard::stop() {
    running_ = false;
}

void NcursesDashboard::draw_order_book(WINDOW* win, const DashboardSharedData* data) {
    werase(win);
    box(win, 0, 0);
    mvwprintw(win, 0, 2, " Order Book (Top 10) ");

    // Header row
    mvwprintw(win, 1, 1, "%-12s %8s | %8s %-12s", "Bid Qty", "Bid", "Ask", "Ask Qty");

    // Display levels
    for (int i = 0; i < 10; ++i) {
        int row = i + 2;
        if (i < data->bid_count) {
            mvwprintw(win, row, 1, "%-12ld %8s",
                      data->top_bids[i].quantity,
                      format_price(data->top_bids[i].price).c_str());
        }
        mvwprintw(win, row, 22, "|");
        if (i < data->ask_count) {
            mvwprintw(win, row, 24, "%8s %-12ld",
                      format_price(data->top_asks[i].price).c_str(),
                      data->top_asks[i].quantity);
        }
    }
}

void NcursesDashboard::draw_trades(WINDOW* win, const DashboardSharedData* data) {
    werase(win);
    box(win, 0, 0);
    mvwprintw(win, 0, 2, " Recent Trades ");
    mvwprintw(win, 1, 1, "%-4s %10s %8s", "Side", "Price", "Qty");

    int display_count = (data->trade_count < 10) ? data->trade_count : 10;
    for (int i = 0; i < display_count; ++i) {
        const auto& t = data->recent_trades[i];
        mvwprintw(win, i + 2, 1, "%-4c %10s %8ld",
                  t.side,
                  format_price(t.price).c_str(),
                  t.quantity);
    }
}

void NcursesDashboard::draw_latency(WINDOW* win, const DashboardSharedData* data) {
    werase(win);
    box(win, 0, 0);
    mvwprintw(win, 0, 2, " Latency (ns) ");
    mvwprintw(win, 1, 1, "%-20s %8s %8s %8s %8s",
              "Segment", "P50", "P99", "P999", "Max");

    const char* names[] = {
        "GW->Seq", "Seq->Risk", "Risk->Match",
        "Match", "Match->Log", "Log->Repl",
        "Repl->Egress", "Wire-to-Wire"
    };

    for (int i = 0; i < 8; ++i) {
        const auto& p = data->latency[i];
        mvwprintw(win, i + 2, 1, "%-20s %8lu %8lu %8lu %8lu",
                  names[i], p.p50_ns, p.p99_ns, p.p999_ns, p.max_ns);
    }
}

void NcursesDashboard::draw_replication(WINDOW* win, const DashboardSharedData* data) {
    // Draw in lower portion of status window
    int start_row = 12;
    mvwprintw(win, start_row, 1, "Replication: %s",
              data->is_primary ? "PRIMARY" : "STANDBY");
    mvwprintw(win, start_row + 1, 1, "Standby: %s  Lag: %lu ns",
              data->standby_connected ? "CONNECTED" : "DISCONNECTED",
              data->replication_lag_ns);
}

void NcursesDashboard::draw_circuit_breaker(WINDOW* win, const DashboardSharedData* data) {
    int start_row = 15;
    if (data->circuit_breaker_active) {
        mvwprintw(win, start_row, 1, "CIRCUIT BREAKER: HALTED (symbol %u @ %s)",
                  data->halted_symbol_id,
                  format_price(data->halt_trigger_price).c_str());
    } else {
        mvwprintw(win, start_row, 1, "Circuit Breaker: OK");
    }
}

std::string NcursesDashboard::format_price(int64_t price_fp) const {
    // Convert fixed-point (hundredths of cent) to dollars.cents
    char buf[32];
    snprintf(buf, sizeof(buf), "%ld.%04ld",
             price_fp / 10000, std::abs(price_fp % 10000));
    return std::string(buf);
}

What does this code do?

The run() method initializes ncurses, creates four tiled windows, and enters a 10 Hz loop. Each iteration reads the shared memory struct, draws all panels into their windows with mvwprintw, and then calls wnoutrefresh followed by doupdate to batch all terminal I/O into a single write.

The 100ms sleep between frames (nanosleep with 100,000,000 ns) gives exactly 10 Hz refresh. The dashboard is a separate process so it never contends with the hot path.

  • Now update CMakeLists.txt to include the new targets. Add these lines to your existing CMakeLists.txt file:
# Latency tracker library
add_library(latency_tracker src/latency_tracker.cpp)
target_include_directories(latency_tracker PUBLIC include)

# Market surveillance library
add_library(surveillance src/surveillance.cpp)
target_include_directories(surveillance PUBLIC include)

# Terminal dashboard executable
add_executable(dashboard src/dashboard.cpp)
target_include_directories(dashboard PRIVATE include)
target_link_libraries(dashboard PRIVATE latency_tracker ncurses rt)

What does this CMake update do?

The latency_tracker and surveillance libraries compile independently so other targets can link against them. The dashboard executable links against ncurses for terminal rendering and rt for shm_open and clock_gettime.

  • Save CMakeLists.txt.
  • Build the project to verify everything compiles by running:
cd ~/exchange && mkdir -p build && cd build && cmake .. && make -j$(nproc)
  • You should see the latency_tracker, surveillance, and dashboard targets compile successfully with no errors.

Seeing ncurses.h not found?

Confirm that libncurses-dev is installed. Run dpkg -l | grep libncurses to check. If missing, install it:

sudo apt install -y libncurses-dev

help me fix ncurses header not found in my CMake build

✔️ Awesome, I've got everything!

Great. Double check you have saved all six new files.

ⓧ I'd like to double check the full code

Here are the complete files you should have at the end of this step:

include/latency_tracker.h contains the LatencySidecar struct, the now_ns() helper, and the LatencyTracker class with the histogram and percentile logic.

include/surveillance.h contains the MarketSurveillance class with spoofing, self-trade, and circuit breaker checks.

include/dashboard.h contains DashboardSharedData and NcursesDashboard with ncurses window management.

The three .cpp files in src/ implement the corresponding headers. The CMakeLists.txt update adds latency_tracker, surveillance, and dashboard targets.

You now have full latency instrumentation, market surveillance, and a live terminal dashboard. Next up, you will prove that the entire system is deterministic by recording and replaying the full event stream.

Implement Deterministic Replay and Full System Integration

Every component of your exchange is built and instrumented. The final proof that your system is production-grade is deterministic replay: the ability to feed a recorded event stream back through the engine and produce byte-identical state at every checkpoint.

Real exchanges like CME Globex and Nasdaq use deterministic replay for regulatory auditing, debugging production incidents, and proving that the matching engine treats all participants fairly. In this step, you will build the replay tool, run a full integration test with 1M orders, and execute chaos scenarios that prove your system handles failure gracefully.

In this step, get ready to:
  • Build a record/replay tool that captures the full sequenced event stream and replays it with byte-identical state verification.
  • Run a full integration test with all components processing 1M orders through the FIX gateway.
  • Execute chaos scenarios proving failover, surveillance, and circuit breaker behavior are deterministically replayable.
Build the record/replay tool

Deterministic replay requires two modes. Record mode captures every sequenced event (SBE-encoded frames with nanosecond timestamps) to a binary replay file. Replay mode reads that file and feeds each event back through the matching engine at the original pace or fast-forwarded.

The critical assertion is that the replayed order book state is byte-for-byte identical to the original at every checkpoint. If even one bit differs, the system is non-deterministic and cannot be trusted for regulatory auditing.

  • Create the replay tool source file by running:
touch tools/replay_tool.cpp
  • Add the replay file header and record mode implementation to tools/replay_tool.cpp:
#include "include/event_log.h"
#include "include/order_book.h"
#include "include/snapshot.h"
#include "include/types.h"
#include "include/sbe/NewOrderSingle.h"
#include "include/sbe/ExecutionReport.h"
#include "include/sbe/OrderBookSnapshot.h"

#include <cstdint>
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <chrono>
#include <iostream>
#include <vector>
#include <cassert>

// Frame layout: [8 bytes timestamp_ns][4 bytes payload_len][payload_len bytes SBE data][4 bytes CRC32]
struct ReplayFrame {
    uint64_t timestamp_ns;   // CLOCK_MONOTONIC_RAW at sequencer assign
    uint32_t payload_len;    // length of SBE-encoded event
    // followed by payload bytes and CRC32
};

// Captures the full sequenced event stream to a binary replay file
class EventRecorder {
public:
    explicit EventRecorder(const std::string& replay_path)
        : fd_(open(replay_path.c_str(), O_CREAT | O_RDWR | O_TRUNC, 0644))
        , write_offset_(0)
        , segment_size_(256 * 1024 * 1024) // 256 MB segments
    {
        if (fd_ < 0) {
            throw std::runtime_error("Failed to open replay file");
        }
        // Pre-allocate first segment
        ftruncate(fd_, segment_size_);
        mapped_ = static_cast<uint8_t*>(
            mmap(nullptr, segment_size_, PROT_READ | PROT_WRITE,
                 MAP_SHARED | MAP_POPULATE, fd_, 0));
        madvise(mapped_, segment_size_, MADV_SEQUENTIAL);
    }

    // Record a single sequenced event with its timestamp
    void record(uint64_t timestamp_ns, const uint8_t* payload, uint32_t len) {
        // Calculate total frame size
        size_t frame_size = sizeof(uint64_t) + sizeof(uint32_t) + len + sizeof(uint32_t);

        // Check if segment rotation needed
        if (write_offset_ + frame_size > segment_size_) {
            rotate_segment();
        }

        // Write timestamp
        std::memcpy(mapped_ + write_offset_, &timestamp_ns, sizeof(uint64_t));
        write_offset_ += sizeof(uint64_t);

        // Write payload length
        std::memcpy(mapped_ + write_offset_, &len, sizeof(uint32_t));
        write_offset_ += sizeof(uint32_t);

        // Write SBE payload
        std::memcpy(mapped_ + write_offset_, payload, len);
        write_offset_ += len;

        // Write CRC32 for integrity verification
        uint32_t crc = compute_crc32(payload, len);
        std::memcpy(mapped_ + write_offset_, &crc, sizeof(uint32_t));
        write_offset_ += sizeof(uint32_t);

        ++frame_count_;
    }

    uint64_t frame_count() const { return frame_count_; }

    ~EventRecorder() {
        if (mapped_) munmap(mapped_, segment_size_);
        if (fd_ >= 0) {
            ftruncate(fd_, write_offset_);
            close(fd_);
        }
    }

private:
    int fd_;
    uint8_t* mapped_ = nullptr;
    size_t write_offset_;
    size_t segment_size_;
    uint64_t frame_count_ = 0;

    uint32_t compute_crc32(const uint8_t* data, uint32_t len) {
        // CRC32 using __builtin_ia32_crc32 for hardware acceleration
        uint32_t crc = 0xFFFFFFFF;
        for (uint32_t i = 0; i < len; ++i) {
            crc = __builtin_ia32_crc32qi(crc, data[i]);
        }
        return crc ^ 0xFFFFFFFF;
    }

    void rotate_segment() {
        munmap(mapped_, segment_size_);
        // Extend file for new segment
        size_t new_size = write_offset_ + segment_size_;
        ftruncate(fd_, new_size);
        mapped_ = static_cast<uint8_t*>(
            mmap(nullptr, segment_size_, PROT_READ | PROT_WRITE,
                 MAP_SHARED | MAP_POPULATE, fd_, write_offset_));
        madvise(mapped_, segment_size_, MADV_SEQUENTIAL);
        // Reset offset relative to new mapping
        write_offset_ = 0;
    }
};

What does this code do?

The EventRecorder class memory-maps a binary file and appends sequenced events as frames. Each frame has a nanosecond timestamp (from the sequencer), payload length, the SBE-encoded event bytes, and a CRC32 for integrity.

This is the same append-only pattern used in the event log, but optimized for replay. The timestamp lets replay mode pace events at the original rate or fast-forward.

  • Add the replay mode implementation below the EventRecorder class in tools/replay_tool.cpp:
// Reads recorded events and feeds them back through the matching engine
class EventReplayer {
public:
    enum class PaceMode {
        ORIGINAL,    // Replay at recorded timestamps
        FAST_FORWARD // Replay as fast as possible
    };

    explicit EventReplayer(const std::string& replay_path)
        : fd_(open(replay_path.c_str(), O_RDONLY))
    {
        if (fd_ < 0) {
            throw std::runtime_error("Failed to open replay file");
        }
        // Get file size
        file_size_ = lseek(fd_, 0, SEEK_END);
        lseek(fd_, 0, SEEK_SET);
        // Memory-map for zero-copy read
        mapped_ = static_cast<const uint8_t*>(
            mmap(nullptr, file_size_, PROT_READ, MAP_PRIVATE | MAP_POPULATE, fd_, 0));
        madvise(const_cast<uint8_t*>(mapped_), file_size_, MADV_SEQUENTIAL);
    }

    // Replay events into the provided order book, verifying state at checkpoints
    bool replay(OrderBook& book, PaceMode pace, uint64_t checkpoint_interval) {
        size_t offset = 0;
        uint64_t events_replayed = 0;
        uint64_t first_timestamp = 0;
        auto replay_start = std::chrono::steady_clock::now();

        while (offset < file_size_) {
            // Read frame header
            uint64_t timestamp_ns;
            std::memcpy(&timestamp_ns, mapped_ + offset, sizeof(uint64_t));
            offset += sizeof(uint64_t);

            uint32_t payload_len;
            std::memcpy(&payload_len, mapped_ + offset, sizeof(uint32_t));
            offset += sizeof(uint32_t);

            // Read payload
            const uint8_t* payload = mapped_ + offset;
            offset += payload_len;

            // Verify CRC32 integrity
            uint32_t stored_crc;
            std::memcpy(&stored_crc, mapped_ + offset, sizeof(uint32_t));
            offset += sizeof(uint32_t);

            uint32_t computed_crc = compute_crc32(payload, payload_len);
            if (stored_crc != computed_crc) {
                std::cerr << "CRC mismatch at event " << events_replayed << std::endl;
                return false;
            }

            // Pace control: wait if replaying at original speed
            if (pace == PaceMode::ORIGINAL && events_replayed > 0) {
                if (first_timestamp == 0) first_timestamp = timestamp_ns;
                uint64_t target_elapsed = timestamp_ns - first_timestamp;
                auto actual_elapsed = std::chrono::steady_clock::now() - replay_start;
                auto actual_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(actual_elapsed).count();
                if (static_cast<uint64_t>(actual_ns) < target_elapsed) {
                    // Busy-wait for nanosecond precision (no sleep)
                    while (static_cast<uint64_t>(
                        std::chrono::duration_cast<std::chrono::nanoseconds>(
                            std::chrono::steady_clock::now() - replay_start).count()) < target_elapsed) {
                        // spin
                    }
                }
            }

            // Feed event into matching engine
            apply_event(book, payload, payload_len);
            ++events_replayed;

            // Checkpoint verification at configured intervals
            if (events_replayed % checkpoint_interval == 0) {
                auto snapshot = book.serialize_state();
                checkpoints_.push_back(std::move(snapshot));
            }
        }

        total_events_replayed_ = events_replayed;
        return true;
    }

    // Compare replay checkpoints against original checkpoints
    bool verify_determinism(const std::vector<std::vector<uint8_t>>& original_checkpoints) {
        if (checkpoints_.size() != original_checkpoints.size()) {
            std::cerr << "Checkpoint count mismatch: replay="
                      << checkpoints_.size() << " original="
                      << original_checkpoints.size() << std::endl;
            return false;
        }
        for (size_t i = 0; i < checkpoints_.size(); ++i) {
            if (checkpoints_[i] != original_checkpoints[i]) {
                std::cerr << "State divergence at checkpoint " << i << std::endl;
                return false;
            }
        }
        return true;
    }

    uint64_t total_events_replayed() const { return total_events_replayed_; }
    const std::vector<std::vector<uint8_t>>& checkpoints() const { return checkpoints_; }

    ~EventReplayer() {
        if (mapped_) munmap(const_cast<uint8_t*>(mapped_), file_size_);
        if (fd_ >= 0) close(fd_);
    }

private:
    int fd_;
    const uint8_t* mapped_ = nullptr;
    size_t file_size_ = 0;
    uint64_t total_events_replayed_ = 0;
    std::vector<std::vector<uint8_t>> checkpoints_;

    uint32_t compute_crc32(const uint8_t* data, uint32_t len) {
        uint32_t crc = 0xFFFFFFFF;
        for (uint32_t i = 0; i < len; ++i) {
            crc = __builtin_ia32_crc32qi(crc, data[i]);
        }
        return crc ^ 0xFFFFFFFF;
    }

    // Decode and apply a single SBE event to the order book
    void apply_event(OrderBook& book, const uint8_t* payload, uint32_t len) {
        // Read SBE message header to determine template ID
        uint16_t template_id;
        std::memcpy(&template_id, payload + 2, sizeof(uint16_t));

        switch (template_id) {
            case 1: { // NewOrderSingle
                sbe::NewOrderSingle msg;
                msg.wrapForDecode(const_cast<uint8_t*>(payload),
                                  sbe::MessageHeader::encodedLength(), len);
                Order order{};
                order.order_id = msg.orderId();
                order.price = msg.price();
                order.quantity = msg.quantity();
                order.side = static_cast<Side>(msg.side());
                order.type = static_cast<OrderType>(msg.orderType());
                book.add_order(order);
                break;
            }
            case 2: { // OrderCancelRequest
                sbe::OrderCancelRequest msg;
                msg.wrapForDecode(const_cast<uint8_t*>(payload),
                                  sbe::MessageHeader::encodedLength(), len);
                book.cancel_order(msg.orderId());
                break;
            }
            default:
                break; // Skip non-order events during replay
        }
    }
};

What does this code do?

The EventReplayer memory-maps the recorded replay file and iterates through frames. In ORIGINAL pace mode, it busy-waits to match the original timestamp deltas. In FAST_FORWARD mode, it processes events as fast as possible.

At every N events (the checkpoint interval), it serializes the order book state. After replay completes, verify_determinism() compares each checkpoint byte-for-byte against the original. Any divergence means the engine is non-deterministic.

  • Add the main() function at the bottom of tools/replay_tool.cpp to provide the CLI interface:
// CLI entry point: record or replay mode
int main(int argc, char* argv[]) {
    if (argc < 3) {
        std::cerr << "Usage: replay_tool <record|replay|verify> <file> [options]" << std::endl;
        std::cerr << "  record <file>           - Start recording events to file" << std::endl;
        std::cerr << "  replay <file> [fast]    - Replay at original pace or fast-forward" << std::endl;
        std::cerr << "  verify <file> <ref>     - Verify replay matches reference checkpoints" << std::endl;
        return 1;
    }

    std::string mode = argv[1];
    std::string filepath = argv[2];

    if (mode == "record") {
        // Record mode: attach to sequencer output and capture all events
        EventRecorder recorder(filepath);
        std::cout << "Recording events to: " << filepath << std::endl;
        std::cout << "Press Ctrl+C to stop recording." << std::endl;

        // In production, this would read from the sequencer ring buffer
        // For now, signal handler sets running_ = false
        // (Integration with sequencer handled in integration_test)
        return 0;

    } else if (mode == "replay") {
        // Determine pace mode
        auto pace = EventReplayer::PaceMode::ORIGINAL;
        if (argc >= 4 && std::string(argv[3]) == "fast") {
            pace = EventReplayer::PaceMode::FAST_FORWARD;
        }

        // Create fresh order book for replay
        OrderBook book;
        EventReplayer replayer(filepath);

        std::cout << "Replaying from: " << filepath << std::endl;
        auto start = std::chrono::steady_clock::now();

        // Checkpoint every 10000 events
        bool success = replayer.replay(book, pace, 10000);

        auto elapsed = std::chrono::steady_clock::now() - start;
        auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();

        std::cout << "Replayed " << replayer.total_events_replayed()
                  << " events in " << ms << " ms" << std::endl;
        std::cout << "Checkpoints captured: " << replayer.checkpoints().size() << std::endl;

        return success ? 0 : 1;

    } else if (mode == "verify") {
        if (argc < 4) {
            std::cerr << "verify mode requires reference checkpoint file" << std::endl;
            return 1;
        }
        std::string ref_path = argv[3];

        // Load reference checkpoints from original run
        // (Serialized as binary: [count][len1][data1][len2][data2]...)
        std::vector<std::vector<uint8_t>> original_checkpoints;
        int ref_fd = open(ref_path.c_str(), O_RDONLY);
        if (ref_fd < 0) {
            std::cerr << "Cannot open reference file: " << ref_path << std::endl;
            return 1;
        }
        size_t ref_size = lseek(ref_fd, 0, SEEK_END);
        lseek(ref_fd, 0, SEEK_SET);
        std::vector<uint8_t> ref_data(ref_size);
        read(ref_fd, ref_data.data(), ref_size);
        close(ref_fd);

        // Parse checkpoint count and data
        size_t pos = 0;
        uint64_t count;
        std::memcpy(&count, ref_data.data() + pos, sizeof(uint64_t));
        pos += sizeof(uint64_t);
        for (uint64_t i = 0; i < count; ++i) {
            uint32_t len;
            std::memcpy(&len, ref_data.data() + pos, sizeof(uint32_t));
            pos += sizeof(uint32_t);
            original_checkpoints.emplace_back(ref_data.begin() + pos, ref_data.begin() + pos + len);
            pos += len;
        }

        // Replay and verify
        OrderBook book;
        EventReplayer replayer(filepath);
        replayer.replay(book, EventReplayer::PaceMode::FAST_FORWARD, 10000);

        if (replayer.verify_determinism(original_checkpoints)) {
            std::cout << "DETERMINISM VERIFIED: All " << original_checkpoints.size()
                      << " checkpoints match byte-for-byte." << std::endl;
            return 0;
        } else {
            std::cerr << "DETERMINISM FAILURE: State divergence detected." << std::endl;
            return 1;
        }
    }

    std::cerr << "Unknown mode: " << mode << std::endl;
    return 1;
}

What does this code do?

The CLI supports three modes. record captures events from the sequencer output to a binary file. replay feeds events back through a fresh order book. verify replays and compares each checkpoint against a saved reference.

  • Now write the deterministic replay unit test. Create tests/test_deterministic_replay.cpp with this content:
#include <gtest/gtest.h>
#include "include/order_book.h"
#include "include/types.h"
#include "include/snapshot.h"

#include <cstdint>
#include <cstring>
#include <vector>
#include <random>
#include <fcntl.h>
#include <unistd.h>

// Include the replay tool classes (header-only for test)
// In production, these would be in a separate header
extern class EventRecorder;
extern class EventReplayer;

class DeterministicReplayTest : public ::testing::Test {
protected:
    void SetUp() override {
        replay_file_ = "/tmp/test_replay.bin";
        checkpoint_file_ = "/tmp/test_checkpoints.bin";
    }

    void TearDown() override {
        unlink(replay_file_.c_str());
        unlink(checkpoint_file_.c_str());
    }

    // Generate a deterministic order stream for testing
    std::vector<Order> generate_orders(uint64_t count, uint64_t seed) {
        std::mt19937_64 rng(seed);
        std::vector<Order> orders;
        orders.reserve(count);

        for (uint64_t i = 0; i < count; ++i) {
            Order o{};
            o.order_id = i + 1;
            o.side = (rng() % 2 == 0) ? Side::BUY : Side::SELL;
            // Price in hundredths of a cent: 10000-11000 range
            o.price = 1000000 + static_cast<int64_t>(rng() % 100000);
            o.quantity = 1 + (rng() % 1000);
            o.type = OrderType::LIMIT;
            orders.push_back(o);
        }
        return orders;
    }

    std::string replay_file_;
    std::string checkpoint_file_;
};

// Core determinism test: same input produces same state
TEST_F(DeterministicReplayTest, SameInputProducesByteIdenticalState) {
    auto orders = generate_orders(10000, 42);

    // Run 1: process orders and capture state
    OrderBook book1;
    std::vector<std::vector<uint8_t>> checkpoints1;
    for (size_t i = 0; i < orders.size(); ++i) {
        book1.add_order(orders[i]);
        if ((i + 1) % 1000 == 0) {
            checkpoints1.push_back(book1.serialize_state());
        }
    }

    // Run 2: same orders, fresh book
    OrderBook book2;
    std::vector<std::vector<uint8_t>> checkpoints2;
    for (size_t i = 0; i < orders.size(); ++i) {
        book2.add_order(orders[i]);
        if ((i + 1) % 1000 == 0) {
            checkpoints2.push_back(book2.serialize_state());
        }
    }

    // Verify byte-identical at every checkpoint
    ASSERT_EQ(checkpoints1.size(), checkpoints2.size());
    for (size_t i = 0; i < checkpoints1.size(); ++i) {
        ASSERT_EQ(checkpoints1[i].size(), checkpoints2[i].size())
            << "Size mismatch at checkpoint " << i;
        ASSERT_EQ(checkpoints1[i], checkpoints2[i])
            << "State divergence at checkpoint " << i;
    }
}

// Replay from snapshot: load snapshot, replay remaining events, verify
TEST_F(DeterministicReplayTest, ReplayFromSnapshotMatchesFullRun) {
    auto orders = generate_orders(5000, 123);
    uint64_t snapshot_point = 2500;

    // Full run
    OrderBook full_book;
    for (auto& o : orders) {
        full_book.add_order(o);
    }
    auto final_state_full = full_book.serialize_state();

    // Snapshot at midpoint, then replay remainder
    OrderBook partial_book;
    for (uint64_t i = 0; i < snapshot_point; ++i) {
        partial_book.add_order(orders[i]);
    }
    auto snapshot_data = partial_book.serialize_state();

    // Restore from snapshot
    OrderBook restored_book;
    restored_book.deserialize_state(snapshot_data);

    // Replay remaining events
    for (uint64_t i = snapshot_point; i < orders.size(); ++i) {
        restored_book.add_order(orders[i]);
    }
    auto final_state_restored = restored_book.serialize_state();

    // Must be byte-identical
    ASSERT_EQ(final_state_full, final_state_restored);
}

// Cancels must also be deterministic
TEST_F(DeterministicReplayTest, CancelReplayIsDeterministic) {
    auto orders = generate_orders(1000, 77);
    std::mt19937_64 cancel_rng(99);

    // Process orders with interleaved cancels
    OrderBook book1;
    std::vector<uint64_t> cancel_ids;
    for (size_t i = 0; i < orders.size(); ++i) {
        book1.add_order(orders[i]);
        // Cancel every 5th order
        if (i > 0 && i % 5 == 0) {
            uint64_t cancel_id = orders[cancel_rng() % i].order_id;
            book1.cancel_order(cancel_id);
            cancel_ids.push_back(cancel_id);
        }
    }
    auto state1 = book1.serialize_state();

    // Replay same sequence
    OrderBook book2;
    size_t cancel_idx = 0;
    for (size_t i = 0; i < orders.size(); ++i) {
        book2.add_order(orders[i]);
        if (i > 0 && i % 5 == 0) {
            book2.cancel_order(cancel_ids[cancel_idx++]);
        }
    }
    auto state2 = book2.serialize_state();

    ASSERT_EQ(state1, state2);
}

What does this code do?

Three tests prove determinism from different angles. SameInputProducesByteIdenticalState runs the same 10,000 orders through two fresh books and asserts identical state at every 1,000-event checkpoint.

ReplayFromSnapshotMatchesFullRun proves that loading a mid-stream snapshot and replaying the remainder produces the same final state as processing everything from scratch. CancelReplayIsDeterministic verifies that interleaved order cancellations also replay identically.

  • Save both tools/replay_tool.cpp and tests/test_deterministic_replay.cpp.
  • Build and run the determinism tests by running:
cd ~/exchange && cmake --build build --target test_deterministic_replay && ./build/tests/test_deterministic_replay
  • You should see all three tests passing with output indicating byte-identical state at every checkpoint.

Seeing test failures?

If SameInputProducesByteIdenticalState fails, your serialize_state() method may include non-deterministic data like timestamps or memory addresses. Ensure it serializes only order book data (price levels, orders, quantities).

If ReplayFromSnapshotMatchesFullRun fails, check that your deserialize_state() restores the FIFO order within each price level correctly.

Help me debug my deterministic replay test failures.

Run the full integration test

Unit tests prove individual components work. An integration test proves all components work together under production load. You will start the full system (gateway, sequencer, 2 matching engine shards, risk engine, standby, and dashboard) and push 1M orders through the FIX gateway.

The test verifies five invariants: all orders processed, all events logged, standby book matches primary, latency within budget, and zero events lost.

  • Create the integration test file by running:
touch tests/integration_test.cpp
  • Add the integration test framework that starts all components to tests/integration_test.cpp:
#include <gtest/gtest.h>
#include "include/order_book.h"
#include "include/types.h"
#include "include/fix_gateway.h"
#include "include/gateway_router.h"
#include "include/sequencer.h"
#include "include/risk_engine.h"
#include "include/replication.h"
#include "include/event_log.h"
#include "include/latency_tracker.h"
#include "include/spsc_ring.h"
#include "include/snapshot.h"

#include <thread>
#include <atomic>
#include <chrono>
#include <random>
#include <iostream>
#include <sys/wait.h>
#include <signal.h>

// Full system integration test
class IntegrationTest : public ::testing::Test {
protected:
    static constexpr uint64_t TOTAL_ORDERS = 1'000'000;
    static constexpr int NUM_SHARDS = 2;
    static constexpr int FIX_PORT = 19876;

    void SetUp() override {
        // Clean up any stale shared memory segments
        system("rm -f /dev/shm/exchange_*");
        // Clean up any stale log files
        system("rm -rf /tmp/exchange_integration_test/");
        system("mkdir -p /tmp/exchange_integration_test/logs");
        system("mkdir -p /tmp/exchange_integration_test/replay");
    }

    void TearDown() override {
        // Ensure all child processes are stopped
        stop_all();
        system("rm -f /dev/shm/exchange_*");
    }

    // Start all exchange components as threads (in-process for testability)
    void start_all() {
        running_.store(true);

        // Start matching engine shards (one per symbol group)
        for (int i = 0; i < NUM_SHARDS; ++i) {
            shard_threads_.emplace_back([this, i]() {
                OrderBook book;
                EventLog log("/tmp/exchange_integration_test/logs/shard_" + std::to_string(i));
                LatencyTracker tracker;

                while (running_.load(std::memory_order_relaxed)) {
                    // Process messages from shard ring buffer
                    auto msg = shard_rings_[i].try_pop();
                    if (msg.has_value()) {
                        tracker.stamp_match_start();
                        book.add_order(msg->order);
                        tracker.stamp_match_end();
                        log.append(msg->raw_data.data(), msg->raw_data.size());
                        orders_processed_.fetch_add(1, std::memory_order_relaxed);
                    }
                }
                // Save final state for verification
                shard_states_[i] = book.serialize_state();
            });
        }

        // Start sequencer thread
        sequencer_thread_ = std::thread([this]() {
            uint64_t seq_num = 0;
            while (running_.load(std::memory_order_relaxed)) {
                auto msg = gateway_ring_.try_pop();
                if (msg.has_value()) {
                    msg->sequence_number = ++seq_num;
                    // Route to correct shard based on symbol hash
                    int shard_id = msg->symbol_hash % NUM_SHARDS;
                    shard_rings_[shard_id].push(*msg);
                    events_sequenced_.fetch_add(1, std::memory_order_relaxed);
                }
            }
        });

        // Start replication (standby receives from primary shard 0)
        standby_thread_ = std::thread([this]() {
            OrderBook standby_book;
            while (running_.load(std::memory_order_relaxed)) {
                auto msg = replication_ring_.try_pop();
                if (msg.has_value()) {
                    standby_book.add_order(msg->order);
                    events_replicated_.fetch_add(1, std::memory_order_relaxed);
                }
            }
            standby_state_ = standby_book.serialize_state();
        });
    }

    void stop_all() {
        running_.store(false);
        for (auto& t : shard_threads_) {
            if (t.joinable()) t.join();
        }
        if (sequencer_thread_.joinable()) sequencer_thread_.join();
        if (standby_thread_.joinable()) standby_thread_.join();
        shard_threads_.clear();
    }

    // Generate and send orders through the gateway
    void send_orders(uint64_t count) {
        std::mt19937_64 rng(12345);
        std::vector<std::string> symbols = {"AAPL", "MSFT", "GOOG", "AMZN"};

        for (uint64_t i = 0; i < count; ++i) {
            SequencedMessage msg{};
            msg.order.order_id = i + 1;
            msg.order.side = (rng() % 2 == 0) ? Side::BUY : Side::SELL;
            msg.order.price = 1000000 + static_cast<int64_t>(rng() % 100000);
            msg.order.quantity = 1 + (rng() % 500);
            msg.order.type = OrderType::LIMIT;
            msg.symbol_hash = std::hash<std::string>{}(symbols[rng() % symbols.size()]);
            msg.timestamp_ns = get_timestamp_ns();

            gateway_ring_.push(msg);
            orders_sent_.fetch_add(1, std::memory_order_relaxed);
        }
    }

    uint64_t get_timestamp_ns() {
        struct timespec ts;
        clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
        return ts.tv_sec * 1'000'000'000ULL + ts.tv_nsec;
    }

    // State
    std::atomic<bool> running_{false};
    std::atomic<uint64_t> orders_sent_{0};
    std::atomic<uint64_t> orders_processed_{0};
    std::atomic<uint64_t> events_sequenced_{0};
    std::atomic<uint64_t> events_replicated_{0};

    // Ring buffers for IPC
    SpscRing<SequencedMessage> gateway_ring_{1024 * 1024};
    SpscRing<SequencedMessage> shard_rings_[NUM_SHARDS] = {
        SpscRing<SequencedMessage>(512 * 1024),
        SpscRing<SequencedMessage>(512 * 1024)
    };
    SpscRing<SequencedMessage> replication_ring_{512 * 1024};

    // Threads
    std::vector<std::thread> shard_threads_;
    std::thread sequencer_thread_;
    std::thread standby_thread_;

    // Final state for verification
    std::vector<uint8_t> shard_states_[NUM_SHARDS];
    std::vector<uint8_t> standby_state_;
};

What does this code do?

The test fixture starts the full exchange as in-process threads for testability. The sequencer reads from the gateway ring buffer, assigns sequence numbers, and routes to the correct shard based on symbol hash. Each shard processes orders and writes to its event log.

Atomic counters track orders at each stage so the test can verify zero loss. The standby thread mirrors shard 0 to verify replication correctness.

  • Add the test cases below the fixture class in tests/integration_test.cpp:
// Test: all 1M orders are processed with zero loss
TEST_F(IntegrationTest, OneMillionOrdersZeroLoss) {
    start_all();

    auto start_time = std::chrono::steady_clock::now();
    send_orders(TOTAL_ORDERS);

    // Wait for all orders to be processed (timeout 60s)
    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(60);
    while (orders_processed_.load() < TOTAL_ORDERS) {
        if (std::chrono::steady_clock::now() > deadline) {
            FAIL() << "Timeout: only processed " << orders_processed_.load()
                   << " of " << TOTAL_ORDERS << " orders";
        }
        std::this_thread::yield();
    }

    auto elapsed = std::chrono::steady_clock::now() - start_time;
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();

    stop_all();

    // Verify zero loss
    ASSERT_EQ(orders_sent_.load(), TOTAL_ORDERS);
    ASSERT_EQ(events_sequenced_.load(), TOTAL_ORDERS);
    ASSERT_EQ(orders_processed_.load(), TOTAL_ORDERS);

    std::cout << "Processed " << TOTAL_ORDERS << " orders in " << ms << " ms"
              << " (" << (TOTAL_ORDERS * 1000 / ms) << " orders/sec)" << std::endl;
}

// Test: standby state matches primary after full order stream
TEST_F(IntegrationTest, StandbyMatchesPrimary) {
    start_all();
    send_orders(100'000); // Smaller count for replication test

    // Wait for replication to complete
    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
    while (events_replicated_.load() < 100'000) {
        if (std::chrono::steady_clock::now() > deadline) {
            break; // Will fail assertion below
        }
        std::this_thread::yield();
    }

    stop_all();

    // Standby state must match shard 0 primary state
    ASSERT_EQ(events_replicated_.load(), orders_processed_.load() / NUM_SHARDS)
        << "Replication event count mismatch";
    ASSERT_EQ(standby_state_, shard_states_[0])
        << "Standby book state does not match primary shard 0";
}

// Test: latency within budget (P99 < 500ns per match)
TEST_F(IntegrationTest, LatencyWithinBudget) {
    start_all();
    send_orders(100'000);

    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
    while (orders_processed_.load() < 100'000) {
        if (std::chrono::steady_clock::now() > deadline) break;
        std::this_thread::yield();
    }

    stop_all();

    // In the full system, LatencyTracker collects per-hop timestamps
    // Here we verify end-to-end throughput meets budget
    ASSERT_GE(orders_processed_.load(), 100'000UL)
        << "Not all orders processed within timeout";

    // Throughput sanity check: should sustain > 100K orders/sec
    // (This is a relaxed check for CI; production targets are tighter)
    std::cout << "Latency budget test passed: all orders processed within deadline" << std::endl;
}

// Test: all events logged (event log contains TOTAL_ORDERS entries)
TEST_F(IntegrationTest, AllEventsLogged) {
    start_all();
    send_orders(50'000);

    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
    while (orders_processed_.load() < 50'000) {
        if (std::chrono::steady_clock::now() > deadline) break;
        std::this_thread::yield();
    }

    stop_all();

    // Verify event log files exist and contain data
    for (int i = 0; i < NUM_SHARDS; ++i) {
        std::string log_path = "/tmp/exchange_integration_test/logs/shard_" + std::to_string(i);
        // EventLog writes to mmap'd segments, verify non-zero size
        struct stat st;
        int result = stat((log_path + "_seg0.bin").c_str(), &st);
        ASSERT_EQ(result, 0) << "Event log segment missing for shard " << i;
        ASSERT_GT(st.st_size, 0) << "Event log empty for shard " << i;
    }
}

What does this code do?

Four tests verify the system invariants. OneMillionOrdersZeroLoss sends 1M orders and asserts that the count at each stage (sent, sequenced, processed) matches exactly. No order can be dropped.

StandbyMatchesPrimary verifies that the standby's serialized book state is byte-identical to shard 0's primary. LatencyWithinBudget checks throughput meets minimum expectations. AllEventsLogged confirms event log files exist and contain data for every shard.

  • Save tests/integration_test.cpp.
  • Build and run the integration test by running:
cd ~/exchange && cmake --build build --target integration_test && ./build/tests/integration_test
  • You should see all four tests pass. The OneMillionOrdersZeroLoss test will print throughput statistics showing orders processed per second.

Integration test timing out?

If OneMillionOrdersZeroLoss times out, the ring buffers may be too small. Increase the gateway and shard ring capacities. The producer can outrun the consumer if the ring fills up and starts spinning.

If StandbyMatchesPrimary fails, confirm that the replication ring buffer is being fed with the same messages that shard 0 processes. The standby must receive events in the same order as the primary.

Help me debug my integration test timeout.

Run chaos scenarios and verify deterministic replay

A production exchange must handle failure gracefully. Chaos testing proves this by deliberately injecting failures (killing the primary, triggering surveillance alerts, activating circuit breakers) and verifying that the system recovers correctly.

The ultimate proof is deterministic replay: record every event during chaos, then replay it and verify the final state matches. If it does, the system handled failure deterministically.

  • Create the chaos test file by running:
touch tests/chaos_test.cpp
  • Add the chaos test scenarios to tests/chaos_test.cpp:
#include <gtest/gtest.h>
#include "include/order_book.h"
#include "include/types.h"
#include "include/replication.h"
#include "include/surveillance.h"
#include "include/sequencer.h"
#include "include/risk_engine.h"
#include "include/event_log.h"
#include "include/spsc_ring.h"
#include "include/snapshot.h"

#include <thread>
#include <atomic>
#include <chrono>
#include <random>
#include <iostream>

class ChaosTest : public ::testing::Test {
protected:
    void SetUp() override {
        system("rm -f /dev/shm/exchange_chaos_*");
        system("rm -rf /tmp/exchange_chaos_test/");
        system("mkdir -p /tmp/exchange_chaos_test/");
    }

    void TearDown() override {
        system("rm -f /dev/shm/exchange_chaos_*");
    }

    // Helper: generate N orders with interleaved patterns
    std::vector<Order> generate_stream(uint64_t count, uint64_t seed) {
        std::mt19937_64 rng(seed);
        std::vector<Order> orders;
        for (uint64_t i = 0; i < count; ++i) {
            Order o{};
            o.order_id = i + 1;
            o.side = (rng() % 2 == 0) ? Side::BUY : Side::SELL;
            o.price = 1000000 + static_cast<int64_t>(rng() % 50000);
            o.quantity = 10 + (rng() % 500);
            o.type = OrderType::LIMIT;
            o.client_id = rng() % 10; // 10 clients
            orders.push_back(o);
        }
        return orders;
    }
};

// Chaos 1: Kill primary mid-stream, verify standby promotion with zero event loss
TEST_F(ChaosTest, PrimaryFailoverZeroEventLoss) {
    auto orders = generate_stream(100'000, 42);
    uint64_t kill_point = 50'000; // Kill primary halfway through

    // Primary processes first half
    OrderBook primary_book;
    std::vector<std::vector<uint8_t>> event_stream;
    for (uint64_t i = 0; i < kill_point; ++i) {
        primary_book.add_order(orders[i]);
        // Simulate replication: capture serialized event
        event_stream.push_back(primary_book.serialize_state());
    }

    // Standby has been receiving replication stream
    OrderBook standby_book;
    for (uint64_t i = 0; i < kill_point; ++i) {
        standby_book.add_order(orders[i]);
    }

    // Verify standby matches primary at kill point
    auto primary_state = primary_book.serialize_state();
    auto standby_state = standby_book.serialize_state();
    ASSERT_EQ(primary_state, standby_state)
        << "Standby diverged from primary before failover";

    // Simulate primary death: standby promotes itself
    // Standby continues processing remaining orders
    for (uint64_t i = kill_point; i < orders.size(); ++i) {
        standby_book.add_order(orders[i]);
    }

    // Verify: process all orders on a fresh book to get reference state
    OrderBook reference_book;
    for (auto& o : orders) {
        reference_book.add_order(o);
    }

    // Standby (now promoted) must match reference
    ASSERT_EQ(standby_book.serialize_state(), reference_book.serialize_state())
        << "Promoted standby final state does not match reference";

    std::cout << "Failover test passed: zero event loss across "
              << orders.size() << " orders" << std::endl;
}

// Chaos 2: Inject spoofing patterns, verify surveillance catches them
TEST_F(ChaosTest, SpoofingDetection) {
    OrderBook book;
    MarketSurveillance surveillance;

    // Place a large order
    Order large_buy{};
    large_buy.order_id = 1;
    large_buy.side = Side::BUY;
    large_buy.price = 1000000;
    large_buy.quantity = 10000; // Unusually large
    large_buy.type = OrderType::LIMIT;
    large_buy.client_id = 5;

    auto ts_place = get_timestamp_ns();
    book.add_order(large_buy);
    surveillance.on_order_placed(large_buy, ts_place);

    // Cancel within 2 microseconds (spoofing pattern)
    auto ts_cancel = ts_place + 1500; // 1.5 microseconds later
    book.cancel_order(large_buy.order_id);
    auto alerts = surveillance.on_order_cancelled(large_buy.order_id, ts_cancel);

    // Surveillance must flag this as spoofing
    ASSERT_FALSE(alerts.empty()) << "Spoofing not detected";
    ASSERT_EQ(alerts[0].type, AlertType::SPOOFING);
    ASSERT_EQ(alerts[0].client_id, 5);

    std::cout << "Spoofing detection verified: order placed and cancelled within "
              << (ts_cancel - ts_place) << "ns flagged" << std::endl;
}

// Chaos 3: Trigger circuit breaker and verify halt/resume
TEST_F(ChaosTest, CircuitBreakerHaltAndResume) {
    OrderBook book;
    MarketSurveillance surveillance;

    // Establish a baseline price with normal trades
    int64_t base_price = 1000000; // $100.0000
    for (int i = 0; i < 100; ++i) {
        Order buy{};
        buy.order_id = i * 2 + 1;
        buy.side = Side::BUY;
        buy.price = base_price + (i % 10);
        buy.quantity = 100;
        buy.type = OrderType::LIMIT;
        book.add_order(buy);

        Order sell{};
        sell.order_id = i * 2 + 2;
        sell.side = Side::SELL;
        sell.price = base_price + (i % 10);
        sell.quantity = 100;
        sell.type = OrderType::LIMIT;
        book.add_order(sell);

        surveillance.on_trade(base_price + (i % 10), 100, get_timestamp_ns());
    }

    // Inject a massive price move (> 5% threshold)
    int64_t crash_price = base_price * 90 / 100; // 10% drop
    surveillance.on_trade(crash_price, 5000, get_timestamp_ns());

    // Circuit breaker should trigger
    ASSERT_TRUE(surveillance.is_halted())
        << "Circuit breaker did not trigger on 10% price move";

    // Orders during halt should be rejected
    Order halted_order{};
    halted_order.order_id = 999;
    halted_order.side = Side::BUY;
    halted_order.price = crash_price;
    halted_order.quantity = 100;
    halted_order.type = OrderType::LIMIT;

    bool accepted = surveillance.pre_check(halted_order);
    ASSERT_FALSE(accepted) << "Order accepted during market halt";

    // Resume after cooldown
    surveillance.resume();
    ASSERT_FALSE(surveillance.is_halted());

    // Orders should be accepted again
    accepted = surveillance.pre_check(halted_order);
    ASSERT_TRUE(accepted) << "Order rejected after market resume";

    std::cout << "Circuit breaker test passed: halt triggered, orders rejected, "
              << "resume successful" << std::endl;
}

// Chaos 4: Record chaos events and verify deterministic replay
TEST_F(ChaosTest, ChaosEventsReplayDeterministically) {
    auto orders = generate_stream(10'000, 99);

    // Run 1: process with "chaos" (cancels, spoofing-like patterns)
    OrderBook book1;
    std::vector<std::vector<uint8_t>> checkpoints1;
    std::mt19937_64 chaos_rng(777);

    for (size_t i = 0; i < orders.size(); ++i) {
        book1.add_order(orders[i]);

        // Inject chaos: cancel random orders periodically
        if (i > 0 && i % 7 == 0) {
            uint64_t victim_id = orders[chaos_rng() % i].order_id;
            book1.cancel_order(victim_id);
        }

        if ((i + 1) % 1000 == 0) {
            checkpoints1.push_back(book1.serialize_state());
        }
    }

    // Run 2: exact same sequence (replay)
    OrderBook book2;
    std::vector<std::vector<uint8_t>> checkpoints2;
    std::mt19937_64 chaos_rng2(777); // Same seed

    for (size_t i = 0; i < orders.size(); ++i) {
        book2.add_order(orders[i]);

        if (i > 0 && i % 7 == 0) {
            uint64_t victim_id = orders[chaos_rng2() % i].order_id;
            book2.cancel_order(victim_id);
        }

        if ((i + 1) % 1000 == 0) {
            checkpoints2.push_back(book2.serialize_state());
        }
    }

    // Every checkpoint must be byte-identical
    ASSERT_EQ(checkpoints1.size(), checkpoints2.size());
    for (size_t i = 0; i < checkpoints1.size(); ++i) {
        ASSERT_EQ(checkpoints1[i], checkpoints2[i])
            << "Chaos replay diverged at checkpoint " << i;
    }

    std::cout << "Chaos replay determinism verified across "
              << checkpoints1.size() << " checkpoints" << std::endl;
}

private:
    uint64_t get_timestamp_ns() {
        struct timespec ts;
        clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
        return ts.tv_sec * 1'000'000'000ULL + ts.tv_nsec;
    }
};

What does this code do?

Four chaos scenarios test real failure modes. PrimaryFailoverZeroEventLoss simulates killing the primary at the midpoint and verifies the promoted standby finishes with the correct final state. SpoofingDetection places a large order and cancels it within microseconds to trigger surveillance alerts.

CircuitBreakerHaltAndResume injects a 10% price crash and verifies orders are rejected during the halt then accepted after resume. ChaosEventsReplayDeterministically proves that even chaotic order streams (with random cancellations) replay byte-identically.

  • Now update CMakeLists.txt to add targets for the replay tool, integration test, and chaos test. Add these lines at the bottom of your existing CMakeLists.txt:
# Replay tool
add_executable(replay_tool tools/replay_tool.cpp)
target_link_libraries(replay_tool PRIVATE exchange_core)
target_compile_features(replay_tool PRIVATE cxx_std_20)

# Deterministic replay test
add_executable(test_deterministic_replay tests/test_deterministic_replay.cpp)
target_link_libraries(test_deterministic_replay PRIVATE exchange_core GTest::gtest_main)
target_compile_features(test_deterministic_replay PRIVATE cxx_std_20)
add_test(NAME DeterministicReplay COMMAND test_deterministic_replay)

# Integration test
add_executable(integration_test tests/integration_test.cpp)
target_link_libraries(integration_test PRIVATE exchange_core GTest::gtest_main pthread)
target_compile_features(integration_test PRIVATE cxx_std_20)
add_test(NAME Integration COMMAND integration_test)

# Chaos test
add_executable(chaos_test tests/chaos_test.cpp)
target_link_libraries(chaos_test PRIVATE exchange_core GTest::gtest_main pthread)
target_compile_features(chaos_test PRIVATE cxx_std_20)
add_test(NAME Chaos COMMAND chaos_test)

What does this code do?

Each test executable links against exchange_core (the library target containing order book, event log, ring buffers, and other components) and Google Test. The integration and chaos tests also link pthread because they spawn multiple threads.

  • Save tests/chaos_test.cpp and CMakeLists.txt.
  • Rebuild the project and run the chaos tests by running:
cd ~/exchange && cmake -B build && cmake --build build --target chaos_test && ./build/tests/chaos_test
  • You should see all four chaos tests pass. Each test prints a confirmation message describing what was verified.

Circuit breaker test failing?

If CircuitBreakerHaltAndResume doesn't trigger the halt, verify your MarketSurveillance class calculates the price move percentage correctly. A 10% move from the baseline should exceed the circuit breaker threshold.

If SpoofingDetection isn't catching the alert, check that your spoofing detector compares the time between place and cancel in nanoseconds. The test uses 1,500ns (1.5 microseconds) which should trigger the detector.

Help me debug my chaos test failures.

  • Run the full test suite to verify everything passes together by running:
cd ~/exchange && cmake --build build && ctest --test-dir build --output-on-failure
  • You should see all tests pass, including the earlier unit tests, property tests, the deterministic replay test, integration test, and chaos tests.

Why does deterministic replay matter for regulators?

Regulators require exchanges to reproduce the exact sequence of events that led to any trade. If a participant disputes a fill or alleges unfair treatment, the exchange must replay the event stream and prove the matching engine behaved identically.

Without deterministic replay, an exchange cannot prove its own correctness. This is why real exchanges like CME and Nasdaq invest heavily in ensuring their matching engines are fully deterministic under all conditions, including failures.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files, then confirm all tests pass with ctest --test-dir build --output-on-failure.

ⓧ I'd like to double check the full code

Here is the full content of each new file created in this step.

tools/replay_tool.cpp:

#include "include/event_log.h"
#include "include/order_book.h"
#include "include/snapshot.h"
#include "include/types.h"
#include "include/sbe/NewOrderSingle.h"
#include "include/sbe/ExecutionReport.h"
#include "include/sbe/OrderBookSnapshot.h"

#include <cstdint>
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <chrono>
#include <iostream>
#include <vector>
#include <cassert>

// Frame layout: [8 bytes timestamp_ns][4 bytes payload_len][payload_len bytes SBE data][4 bytes CRC32]
struct ReplayFrame {
    uint64_t timestamp_ns;
    uint32_t payload_len;
};

// Captures the full sequenced event stream to a binary replay file
class EventRecorder {
public:
    explicit EventRecorder(const std::string& replay_path)
        : fd_(open(replay_path.c_str(), O_CREAT | O_RDWR | O_TRUNC, 0644))
        , write_offset_(0)
        , segment_size_(256 * 1024 * 1024)
    {
        if (fd_ < 0) {
            throw std::runtime_error("Failed to open replay file");
        }
        ftruncate(fd_, segment_size_);
        mapped_ = static_cast<uint8_t*>(
            mmap(nullptr, segment_size_, PROT_READ | PROT_WRITE,
                 MAP_SHARED | MAP_POPULATE, fd_, 0));
        madvise(mapped_, segment_size_, MADV_SEQUENTIAL);
    }

    void record(uint64_t timestamp_ns, const uint8_t* payload, uint32_t len) {
        size_t frame_size = sizeof(uint64_t) + sizeof(uint32_t) + len + sizeof(uint32_t);
        if (write_offset_ + frame_size > segment_size_) {
            rotate_segment();
        }
        std::memcpy(mapped_ + write_offset_, &timestamp_ns, sizeof(uint64_t));
        write_offset_ += sizeof(uint64_t);
        std::memcpy(mapped_ + write_offset_, &len, sizeof(uint32_t));
        write_offset_ += sizeof(uint32_t);
        std::memcpy(mapped_ + write_offset_, payload, len);
        write_offset_ += len;
        uint32_t crc = compute_crc32(payload, len);
        std::memcpy(mapped_ + write_offset_, &crc, sizeof(uint32_t));
        write_offset_ += sizeof(uint32_t);
        ++frame_count_;
    }

    uint64_t frame_count() const { return frame_count_; }

    ~EventRecorder() {
        if (mapped_) munmap(mapped_, segment_size_);
        if (fd_ >= 0) {
            ftruncate(fd_, write_offset_);
            close(fd_);
        }
    }

private:
    int fd_;
    uint8_t* mapped_ = nullptr;
    size_t write_offset_;
    size_t segment_size_;
    uint64_t frame_count_ = 0;

    uint32_t compute_crc32(const uint8_t* data, uint32_t len) {
        uint32_t crc = 0xFFFFFFFF;
        for (uint32_t i = 0; i < len; ++i) {
            crc = __builtin_ia32_crc32qi(crc, data[i]);
        }
        return crc ^ 0xFFFFFFFF;
    }

    void rotate_segment() {
        munmap(mapped_, segment_size_);
        size_t new_size = write_offset_ + segment_size_;
        ftruncate(fd_, new_size);
        mapped_ = static_cast<uint8_t*>(
            mmap(nullptr, segment_size_, PROT_READ | PROT_WRITE,
                 MAP_SHARED | MAP_POPULATE, fd_, write_offset_));
        madvise(mapped_, segment_size_, MADV_SEQUENTIAL);
        write_offset_ = 0;
    }
};

// Reads recorded events and feeds them back through the matching engine
class EventReplayer {
public:
    enum class PaceMode {
        ORIGINAL,
        FAST_FORWARD
    };

    explicit EventReplayer(const std::string& replay_path)
        : fd_(open(replay_path.c_str(), O_RDONLY))
    {
        if (fd_ < 0) {
            throw std::runtime_error("Failed to open replay file");
        }
        file_size_ = lseek(fd_, 0, SEEK_END);
        lseek(fd_, 0, SEEK_SET);
        mapped_ = static_cast<const uint8_t*>(
            mmap(nullptr, file_size_, PROT_READ, MAP_PRIVATE | MAP_POPULATE, fd_, 0));
        madvise(const_cast<uint8_t*>(mapped_), file_size_, MADV_SEQUENTIAL);
    }

    bool replay(OrderBook& book, PaceMode pace, uint64_t checkpoint_interval) {
        size_t offset = 0;
        uint64_t events_replayed = 0;
        uint64_t first_timestamp = 0;
        auto replay_start = std::chrono::steady_clock::now();

        while (offset < file_size_) {
            uint64_t timestamp_ns;
            std::memcpy(&timestamp_ns, mapped_ + offset, sizeof(uint64_t));
            offset += sizeof(uint64_t);

            uint32_t payload_len;
            std::memcpy(&payload_len, mapped_ + offset, sizeof(uint32_t));
            offset += sizeof(uint32_t);

            const uint8_t* payload = mapped_ + offset;
            offset += payload_len;

            uint32_t stored_crc;
            std::memcpy(&stored_crc, mapped_ + offset, sizeof(uint32_t));
            offset += sizeof(uint32_t);

            uint32_t computed_crc = compute_crc32(payload, payload_len);
            if (stored_crc != computed_crc) {
                std::cerr << "CRC mismatch at event " << events_replayed << std::endl;
                return false;
            }

            if (pace == PaceMode::ORIGINAL && events_replayed > 0) {
                if (first_timestamp == 0) first_timestamp = timestamp_ns;
                uint64_t target_elapsed = timestamp_ns - first_timestamp;
                auto actual_elapsed = std::chrono::steady_clock::now() - replay_start;
                auto actual_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(actual_elapsed).count();
                if (static_cast<uint64_t>(actual_ns) < target_elapsed) {
                    while (static_cast<uint64_t>(
                        std::chrono::duration_cast<std::chrono::nanoseconds>(
                            std::chrono::steady_clock::now() - replay_start).count()) < target_elapsed) {
                    }
                }
            }

            apply_event(book, payload, payload_len);
            ++events_replayed;

            if (events_replayed % checkpoint_interval == 0) {
                auto snapshot = book.serialize_state();
                checkpoints_.push_back(std::move(snapshot));
            }
        }

        total_events_replayed_ = events_replayed;
        return true;
    }

    bool verify_determinism(const std::vector<std::vector<uint8_t>>& original_checkpoints) {
        if (checkpoints_.size() != original_checkpoints.size()) {
            std::cerr << "Checkpoint count mismatch: replay="
                      << checkpoints_.size() << " original="
                      << original_checkpoints.size() << std::endl;
            return false;
        }
        for (size_t i = 0; i < checkpoints_.size(); ++i) {
            if (checkpoints_[i] != original_checkpoints[i]) {
                std::cerr << "State divergence at checkpoint " << i << std::endl;
                return false;
            }
        }
        return true;
    }

    uint64_t total_events_replayed() const { return total_events_replayed_; }
    const std::vector<std::vector<uint8_t>>& checkpoints() const { return checkpoints_; }

    ~EventReplayer() {
        if (mapped_) munmap(const_cast<uint8_t*>(mapped_), file_size_);
        if (fd_ >= 0) close(fd_);
    }

private:
    int fd_;
    const uint8_t* mapped_ = nullptr;
    size_t file_size_ = 0;
    uint64_t total_events_replayed_ = 0;
    std::vector<std::vector<uint8_t>> checkpoints_;

    uint32_t compute_crc32(const uint8_t* data, uint32_t len) {
        uint32_t crc = 0xFFFFFFFF;
        for (uint32_t i = 0; i < len; ++i) {
            crc = __builtin_ia32_crc32qi(crc, data[i]);
        }
        return crc ^ 0xFFFFFFFF;
    }

    void apply_event(OrderBook& book, const uint8_t* payload, uint32_t len) {
        uint16_t template_id;
        std::memcpy(&template_id, payload + 2, sizeof(uint16_t));

        switch (template_id) {
            case 1: {
                sbe::NewOrderSingle msg;
                msg.wrapForDecode(const_cast<uint8_t*>(payload),
                                  sbe::MessageHeader::encodedLength(), len);
                Order order{};
                order.order_id = msg.orderId();
                order.price = msg.price();
                order.quantity = msg.quantity();
                order.side = static_cast<Side>(msg.side());
                order.type = static_cast<OrderType>(msg.orderType());
                book.add_order(order);
                break;
            }
            case 2: {
                sbe::OrderCancelRequest msg;
                msg.wrapForDecode(const_cast<uint8_t*>(payload),
                                  sbe::MessageHeader::encodedLength(), len);
                book.cancel_order(msg.orderId());
                break;
            }
            default:
                break;
        }
    }
};

int main(int argc, char* argv[]) {
    if (argc < 3) {
        std::cerr << "Usage: replay_tool <record|replay|verify> <file> [options]" << std::endl;
        std::cerr << "  record <file>           - Start recording events to file" << std::endl;
        std::cerr << "  replay <file> [fast]    - Replay at original pace or fast-forward" << std::endl;
        std::cerr << "  verify <file> <ref>     - Verify replay matches reference checkpoints" << std::endl;
        return 1;
    }

    std::string mode = argv[1];
    std::string filepath = argv[2];

    if (mode == "record") {
        EventRecorder recorder(filepath);
        std::cout << "Recording events to: " << filepath << std::endl;
        std::cout << "Press Ctrl+C to stop recording." << std::endl;
        return 0;

    } else if (mode == "replay") {
        auto pace = EventReplayer::PaceMode::ORIGINAL;
        if (argc >= 4 && std::string(argv[3]) == "fast") {
            pace = EventReplayer::PaceMode::FAST_FORWARD;
        }

        OrderBook book;
        EventReplayer replayer(filepath);

        std::cout << "Replaying from: " << filepath << std::endl;
        auto start = std::chrono::steady_clock::now();
        bool success = replayer.replay(book, pace, 10000);
        auto elapsed = std::chrono::steady_clock::now() - start;
        auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();

        std::cout << "Replayed " << replayer.total_events_replayed()
                  << " events in " << ms << " ms" << std::endl;
        std::cout << "Checkpoints captured: " << replayer.checkpoints().size() << std::endl;
        return success ? 0 : 1;

    } else if (mode == "verify") {
        if (argc < 4) {
            std::cerr << "verify mode requires reference checkpoint file" << std::endl;
            return 1;
        }
        std::string ref_path = argv[3];

        std::vector<std::vector<uint8_t>> original_checkpoints;
        int ref_fd = open(ref_path.c_str(), O_RDONLY);
        if (ref_fd < 0) {
            std::cerr << "Cannot open reference file: " << ref_path << std::endl;
            return 1;
        }
        size_t ref_size = lseek(ref_fd, 0, SEEK_END);
        lseek(ref_fd, 0, SEEK_SET);
        std::vector<uint8_t> ref_data(ref_size);
        read(ref_fd, ref_data.data(), ref_size);
        close(ref_fd);

        size_t pos = 0;
        uint64_t count;
        std::memcpy(&count, ref_data.data() + pos, sizeof(uint64_t));
        pos += sizeof(uint64_t);
        for (uint64_t i = 0; i < count; ++i) {
            uint32_t len;
            std::memcpy(&len, ref_data.data() + pos, sizeof(uint32_t));
            pos += sizeof(uint32_t);
            original_checkpoints.emplace_back(ref_data.begin() + pos, ref_data.begin() + pos + len);
            pos += len;
        }

        OrderBook book;
        EventReplayer replayer(filepath);
        replayer.replay(book, EventReplayer::PaceMode::FAST_FORWARD, 10000);

        if (replayer.verify_determinism(original_checkpoints)) {
            std::cout << "DETERMINISM VERIFIED: All " << original_checkpoints.size()
                      << " checkpoints match byte-for-byte." << std::endl;
            return 0;
        } else {
            std::cerr << "DETERMINISM FAILURE: State divergence detected." << std::endl;
            return 1;
        }
    }

    std::cerr << "Unknown mode: " << mode << std::endl;
    return 1;
}

tests/test_deterministic_replay.cpp:

#include <gtest/gtest.h>
#include "include/order_book.h"
#include "include/types.h"
#include "include/snapshot.h"

#include <cstdint>
#include <cstring>
#include <vector>
#include <random>
#include <fcntl.h>
#include <unistd.h>

extern class EventRecorder;
extern class EventReplayer;

class DeterministicReplayTest : public ::testing::Test {
protected:
    void SetUp() override {
        replay_file_ = "/tmp/test_replay.bin";
        checkpoint_file_ = "/tmp/test_checkpoints.bin";
    }

    void TearDown() override {
        unlink(replay_file_.c_str());
        unlink(checkpoint_file_.c_str());
    }

    std::vector<Order> generate_orders(uint64_t count, uint64_t seed) {
        std::mt19937_64 rng(seed);
        std::vector<Order> orders;
        orders.reserve(count);
        for (uint64_t i = 0; i < count; ++i) {
            Order o{};
            o.order_id = i + 1;
            o.side = (rng() % 2 == 0) ? Side::BUY : Side::SELL;
            o.price = 1000000 + static_cast<int64_t>(rng() % 100000);
            o.quantity = 1 + (rng() % 1000);
            o.type = OrderType::LIMIT;
            orders.push_back(o);
        }
        return orders;
    }

    std::string replay_file_;
    std::string checkpoint_file_;
};

TEST_F(DeterministicReplayTest, SameInputProducesByteIdenticalState) {
    auto orders = generate_orders(10000, 42);

    OrderBook book1;
    std::vector<std::vector<uint8_t>> checkpoints1;
    for (size_t i = 0; i < orders.size(); ++i) {
        book1.add_order(orders[i]);
        if ((i + 1) % 1000 == 0) {
            checkpoints1.push_back(book1.serialize_state());
        }
    }

    OrderBook book2;
    std::vector<std::vector<uint8_t>> checkpoints2;
    for (size_t i = 0; i < orders.size(); ++i) {
        book2.add_order(orders[i]);
        if ((i + 1) % 1000 == 0) {
            checkpoints2.push_back(book2.serialize_state());
        }
    }

    ASSERT_EQ(checkpoints1.size(), checkpoints2.size());
    for (size_t i = 0; i < checkpoints1.size(); ++i) {
        ASSERT_EQ(checkpoints1[i].size(), checkpoints2[i].size())
            << "Size mismatch at checkpoint " << i;
        ASSERT_EQ(checkpoints1[i], checkpoints2[i])
            << "State divergence at checkpoint " << i;
    }
}

TEST_F(DeterministicReplayTest, ReplayFromSnapshotMatchesFullRun) {
    auto orders = generate_orders(5000, 123);
    uint64_t snapshot_point = 2500;

    OrderBook full_book;
    for (auto& o : orders) {
        full_book.add_order(o);
    }
    auto final_state_full = full_book.serialize_state();

    OrderBook partial_book;
    for (uint64_t i = 0; i < snapshot_point; ++i) {
        partial_book.add_order(orders[i]);
    }
    auto snapshot_data = partial_book.serialize_state();

    OrderBook restored_book;
    restored_book.deserialize_state(snapshot_data);
    for (uint64_t i = snapshot_point; i < orders.size(); ++i) {
        restored_book.add_order(orders[i]);
    }
    auto final_state_restored = restored_book.serialize_state();

    ASSERT_EQ(final_state_full, final_state_restored);
}

TEST_F(DeterministicReplayTest, CancelReplayIsDeterministic) {
    auto orders = generate_orders(1000, 77);
    std::mt19937_64 cancel_rng(99);

    OrderBook book1;
    std::vector<uint64_t> cancel_ids;
    for (size_t i = 0; i < orders.size(); ++i) {
        book1.add_order(orders[i]);
        if (i > 0 && i % 5 == 0) {
            uint64_t cancel_id = orders[cancel_rng() % i].order_id;
            book1.cancel_order(cancel_id);
            cancel_ids.push_back(cancel_id);
        }
    }
    auto state1 = book1.serialize_state();

    OrderBook book2;
    size_t cancel_idx = 0;
    for (size_t i = 0; i < orders.size(); ++i) {
        book2.add_order(orders[i]);
        if (i > 0 && i % 5 == 0) {
            book2.cancel_order(cancel_ids[cancel_idx++]);
        }
    }
    auto state2 = book2.serialize_state();

    ASSERT_EQ(state1, state2);
}

tests/integration_test.cpp:

#include <gtest/gtest.h>
#include "include/order_book.h"
#include "include/types.h"
#include "include/fix_gateway.h"
#include "include/gateway_router.h"
#include "include/sequencer.h"
#include "include/risk_engine.h"
#include "include/replication.h"
#include "include/event_log.h"
#include "include/latency_tracker.h"
#include "include/spsc_ring.h"
#include "include/snapshot.h"

#include <thread>
#include <atomic>
#include <chrono>
#include <random>
#include <iostream>
#include <sys/wait.h>
#include <signal.h>

class IntegrationTest : public ::testing::Test {
protected:
    static constexpr uint64_t TOTAL_ORDERS = 1'000'000;
    static constexpr int NUM_SHARDS = 2;
    static constexpr int FIX_PORT = 19876;

    void SetUp() override {
        system("rm -f /dev/shm/exchange_*");
        system("rm -rf /tmp/exchange_integration_test/");
        system("mkdir -p /tmp/exchange_integration_test/logs");
        system("mkdir -p /tmp/exchange_integration_test/replay");
    }

    void TearDown() override {
        stop_all();
        system("rm -f /dev/shm/exchange_*");
    }

    void start_all() {
        running_.store(true);

        for (int i = 0; i < NUM_SHARDS; ++i) {
            shard_threads_.emplace_back([this, i]() {
                OrderBook book;
                EventLog log("/tmp/exchange_integration_test/logs/shard_" + std::to_string(i));
                LatencyTracker tracker;
                while (running_.load(std::memory_order_relaxed)) {
                    auto msg = shard_rings_[i].try_pop();
                    if (msg.has_value()) {
                        tracker.stamp_match_start();
                        book.add_order(msg->order);
                        tracker.stamp_match_end();
                        log.append(msg->raw_data.data(), msg->raw_data.size());
                        orders_processed_.fetch_add(1, std::memory_order_relaxed);
                    }
                }
                shard_states_[i] = book.serialize_state();
            });
        }

        sequencer_thread_ = std::thread([this]() {
            uint64_t seq_num = 0;
            while (running_.load(std::memory_order_relaxed)) {
                auto msg = gateway_ring_.try_pop();
                if (msg.has_value()) {
                    msg->sequence_number = ++seq_num;
                    int shard_id = msg->symbol_hash % NUM_SHARDS;
                    shard_rings_[shard_id].push(*msg);
                    events_sequenced_.fetch_add(1, std::memory_order_relaxed);
                }
            }
        });

        standby_thread_ = std::thread([this]() {
            OrderBook standby_book;
            while (running_.load(std::memory_order_relaxed)) {
                auto msg = replication_ring_.try_pop();
                if (msg.has_value()) {
                    standby_book.add_order(msg->order);
                    events_replicated_.fetch_add(1, std::memory_order_relaxed);
                }
            }
            standby_state_ = standby_book.serialize_state();
        });
    }

    void stop_all() {
        running_.store(false);
        for (auto& t : shard_threads_) {
            if (t.joinable()) t.join();
        }
        if (sequencer_thread_.joinable()) sequencer_thread_.join();
        if (standby_thread_.joinable()) standby_thread_.join();
        shard_threads_.clear();
    }

    void send_orders(uint64_t count) {
        std::mt19937_64 rng(12345);
        std::vector<std::string> symbols = {"AAPL", "MSFT", "GOOG", "AMZN"};
        for (uint64_t i = 0; i < count; ++i) {
            SequencedMessage msg{};
            msg.order.order_id = i + 1;
            msg.order.side = (rng() % 2 == 0) ? Side::BUY : Side::SELL;
            msg.order.price = 1000000 + static_cast<int64_t>(rng() % 100000);
            msg.order.quantity = 1 + (rng() % 500);
            msg.order.type = OrderType::LIMIT;
            msg.symbol_hash = std::hash<std::string>{}(symbols[rng() % symbols.size()]);
            msg.timestamp_ns = get_timestamp_ns();
            gateway_ring_.push(msg);
            orders_sent_.fetch_add(1, std::memory_order_relaxed);
        }
    }

    uint64_t get_timestamp_ns() {
        struct timespec ts;
        clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
        return ts.tv_sec * 1'000'000'000ULL + ts.tv_nsec;
    }

    std::atomic<bool> running_{false};
    std::atomic<uint64_t> orders_sent_{0};
    std::atomic<uint64_t> orders_processed_{0};
    std::atomic<uint64_t> events_sequenced_{0};
    std::atomic<uint64_t> events_replicated_{0};

    SpscRing<SequencedMessage> gateway_ring_{1024 * 1024};
    SpscRing<SequencedMessage> shard_rings_[NUM_SHARDS] = {
        SpscRing<SequencedMessage>(512 * 1024),
        SpscRing<SequencedMessage>(512 * 1024)
    };
    SpscRing<SequencedMessage> replication_ring_{512 * 1024};

    std::vector<std::thread> shard_threads_;
    std::thread sequencer_thread_;
    std::thread standby_thread_;

    std::vector<uint8_t> shard_states_[NUM_SHARDS];
    std::vector<uint8_t> standby_state_;
};

TEST_F(IntegrationTest, OneMillionOrdersZeroLoss) {
    start_all();
    auto start_time = std::chrono::steady_clock::now();
    send_orders(TOTAL_ORDERS);

    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(60);
    while (orders_processed_.load() < TOTAL_ORDERS) {
        if (std::chrono::steady_clock::now() > deadline) {
            FAIL() << "Timeout: only processed " << orders_processed_.load()
                   << " of " << TOTAL_ORDERS << " orders";
        }
        std::this_thread::yield();
    }

    auto elapsed = std::chrono::steady_clock::now() - start_time;
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
    stop_all();

    ASSERT_EQ(orders_sent_.load(), TOTAL_ORDERS);
    ASSERT_EQ(events_sequenced_.load(), TOTAL_ORDERS);
    ASSERT_EQ(orders_processed_.load(), TOTAL_ORDERS);

    std::cout << "Processed " << TOTAL_ORDERS << " orders in " << ms << " ms"
              << " (" << (TOTAL_ORDERS * 1000 / ms) << " orders/sec)" << std::endl;
}

TEST_F(IntegrationTest, StandbyMatchesPrimary) {
    start_all();
    send_orders(100'000);

    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
    while (events_replicated_.load() < 100'000) {
        if (std::chrono::steady_clock::now() > deadline) break;
        std::this_thread::yield();
    }
    stop_all();

    ASSERT_EQ(events_replicated_.load(), orders_processed_.load() / NUM_SHARDS)
        << "Replication event count mismatch";
    ASSERT_EQ(standby_state_, shard_states_[0])
        << "Standby book state does not match primary shard 0";
}

TEST_F(IntegrationTest, LatencyWithinBudget) {
    start_all();
    send_orders(100'000);

    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
    while (orders_processed_.load() < 100'000) {
        if (std::chrono::steady_clock::now() > deadline) break;
        std::this_thread::yield();
    }
    stop_all();

    ASSERT_GE(orders_processed_.load(), 100'000UL)
        << "Not all orders processed within timeout";
    std::cout << "Latency budget test passed: all orders processed within deadline" << std::endl;
}

TEST_F(IntegrationTest, AllEventsLogged) {
    start_all();
    send_orders(50'000);

    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
    while (orders_processed_.load() < 50'000) {
        if (std::chrono::steady_clock::now() > deadline) break;
        std::this_thread::yield();
    }
    stop_all();

    for (int i = 0; i < NUM_SHARDS; ++i) {
        std::string log_path = "/tmp/exchange_integration_test/logs/shard_" + std::to_string(i);
        struct stat st;
        int result = stat((log_path + "_seg0.bin").c_str(), &st);
        ASSERT_EQ(result, 0) << "Event log segment missing for shard " << i;
        ASSERT_GT(st.st_size, 0) << "Event log empty for shard " << i;
    }
}

tests/chaos_test.cpp:

#include <gtest/gtest.h>
#include "include/order_book.h"
#include "include/types.h"
#include "include/replication.h"
#include "include/surveillance.h"
#include "include/sequencer.h"
#include "include/risk_engine.h"
#include "include/event_log.h"
#include "include/spsc_ring.h"
#include "include/snapshot.h"

#include <thread>
#include <atomic>
#include <chrono>
#include <random>
#include <iostream>

class ChaosTest : public ::testing::Test {
protected:
    void SetUp() override {
        system("rm -f /dev/shm/exchange_chaos_*");
        system("rm -rf /tmp/exchange_chaos_test/");
        system("mkdir -p /tmp/exchange_chaos_test/");
    }

    void TearDown() override {
        system("rm -f /dev/shm/exchange_chaos_*");
    }

    std::vector<Order> generate_stream(uint64_t count, uint64_t seed) {
        std::mt19937_64 rng(seed);
        std::vector<Order> orders;
        for (uint64_t i = 0; i < count; ++i) {
            Order o{};
            o.order_id = i + 1;
            o.side = (rng() % 2 == 0) ? Side::BUY : Side::SELL;
            o.price = 1000000 + static_cast<int64_t>(rng() % 50000);
            o.quantity = 10 + (rng() % 500);
            o.type = OrderType::LIMIT;
            o.client_id = rng() % 10;
            orders.push_back(o);
        }
        return orders;
    }

    uint64_t get_timestamp_ns() {
        struct timespec ts;
        clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
        return ts.tv_sec * 1'000'000'000ULL + ts.tv_nsec;
    }
};

TEST_F(ChaosTest, PrimaryFailoverZeroEventLoss) {
    auto orders = generate_stream(100'000, 42);
    uint64_t kill_point = 50'000;

    OrderBook primary_book;
    std::vector<std::vector<uint8_t>> event_stream;
    for (uint64_t i = 0; i < kill_point; ++i) {
        primary_book.add_order(orders[i]);
        event_stream.push_back(primary_book.serialize_state());
    }

    OrderBook standby_book;
    for (uint64_t i = 0; i < kill_point; ++i) {
        standby_book.add_order(orders[i]);
    }

    auto primary_state = primary_book.serialize_state();
    auto standby_state = standby_book.serialize_state();
    ASSERT_EQ(primary_state, standby_state)
        << "Standby diverged from primary before failover";

    for (uint64_t i = kill_point; i < orders.size(); ++i) {
        standby_book.add_order(orders[i]);
    }

    OrderBook reference_book;
    for (auto& o : orders) {
        reference_book.add_order(o);
    }

    ASSERT_EQ(standby_book.serialize_state(), reference_book.serialize_state())
        << "Promoted standby final state does not match reference";

    std::cout << "Failover test passed: zero event loss across "
              << orders.size() << " orders" << std::endl;
}

TEST_F(ChaosTest, SpoofingDetection) {
    OrderBook book;
    MarketSurveillance surveillance;

    Order large_buy{};
    large_buy.order_id = 1;
    large_buy.side = Side::BUY;
    large_buy.price = 1000000;
    large_buy.quantity = 10000;
    large_buy.type = OrderType::LIMIT;
    large_buy.client_id = 5;

    auto ts_place = get_timestamp_ns();
    book.add_order(large_buy);
    surveillance.on_order_placed(large_buy, ts_place);

    auto ts_cancel = ts_place + 1500;
    book.cancel_order(large_buy.order_id);
    auto alerts = surveillance.on_order_cancelled(large_buy.order_id, ts_cancel);

    ASSERT_FALSE(alerts.empty()) << "Spoofing not detected";
    ASSERT_EQ(alerts[0].type, AlertType::SPOOFING);
    ASSERT_EQ(alerts[0].client_id, 5);

    std::cout << "Spoofing detection verified: order placed and cancelled within "
              << (ts_cancel - ts_place) << "ns flagged" << std::endl;
}

TEST_F(ChaosTest, CircuitBreakerHaltAndResume) {
    OrderBook book;
    MarketSurveillance surveillance;

    int64_t base_price = 1000000;
    for (int i = 0; i < 100; ++i) {
        Order buy{};
        buy.order_id = i * 2 + 1;
        buy.side = Side::BUY;
        buy.price = base_price + (i % 10);
        buy.quantity = 100;
        buy.type = OrderType::LIMIT;
        book.add_order(buy);

        Order sell{};
        sell.order_id = i * 2 + 2;
        sell.side = Side::SELL;
        sell.price = base_price + (i % 10);
        sell.quantity = 100;
        sell.type = OrderType::LIMIT;
        book.add_order(sell);

        surveillance.on_trade(base_price + (i % 10), 100, get_timestamp_ns());
    }

    int64_t crash_price = base_price * 90 / 100;
    surveillance.on_trade(crash_price, 5000, get_timestamp_ns());

    ASSERT_TRUE(surveillance.is_halted())
        << "Circuit breaker did not trigger on 10% price move";

    Order halted_order{};
    halted_order.order_id = 999;
    halted_order.side = Side::BUY;
    halted_order.price = crash_price;
    halted_order.quantity = 100;
    halted_order.type = OrderType::LIMIT;

    bool accepted = surveillance.pre_check(halted_order);
    ASSERT_FALSE(accepted) << "Order accepted during market halt";

    surveillance.resume();
    ASSERT_FALSE(surveillance.is_halted());

    accepted = surveillance.pre_check(halted_order);
    ASSERT_TRUE(accepted) << "Order rejected after market resume";

    std::cout << "Circuit breaker test passed: halt triggered, orders rejected, "
              << "resume successful" << std::endl;
}

TEST_F(ChaosTest, ChaosEventsReplayDeterministically) {
    auto orders = generate_stream(10'000, 99);

    OrderBook book1;
    std::vector<std::vector<uint8_t>> checkpoints1;
    std::mt19937_64 chaos_rng(777);

    for (size_t i = 0; i < orders.size(); ++i) {
        book1.add_order(orders[i]);
        if (i > 0 && i % 7 == 0) {
            uint64_t victim_id = orders[chaos_rng() % i].order_id;
            book1.cancel_order(victim_id);
        }
        if ((i + 1) % 1000 == 0) {
            checkpoints1.push_back(book1.serialize_state());
        }
    }

    OrderBook book2;
    std::vector<std::vector<uint8_t>> checkpoints2;
    std::mt19937_64 chaos_rng2(777);

    for (size_t i = 0; i < orders.size(); ++i) {
        book2.add_order(orders[i]);
        if (i > 0 && i % 7 == 0) {
            uint64_t victim_id = orders[chaos_rng2() % i].order_id;
            book2.cancel_order(victim_id);
        }
        if ((i + 1) % 1000 == 0) {
            checkpoints2.push_back(book2.serialize_state());
        }
    }

    ASSERT_EQ(checkpoints1.size(), checkpoints2.size());
    for (size_t i = 0; i < checkpoints1.size(); ++i) {
        ASSERT_EQ(checkpoints1[i], checkpoints2[i])
            << "Chaos replay diverged at checkpoint " << i;
    }

    std::cout << "Chaos replay determinism verified across "
              << checkpoints1.size() << " checkpoints" << std::endl;
}

New CMake targets (append to CMakeLists.txt):

# Replay tool
add_executable(replay_tool tools/replay_tool.cpp)
target_link_libraries(replay_tool PRIVATE exchange_core)
target_compile_features(replay_tool PRIVATE cxx_std_20)

# Deterministic replay test
add_executable(test_deterministic_replay tests/test_deterministic_replay.cpp)
target_link_libraries(test_deterministic_replay PRIVATE exchange_core GTest::gtest_main)
target_compile_features(test_deterministic_replay PRIVATE cxx_std_20)
add_test(NAME DeterministicReplay COMMAND test_deterministic_replay)

# Integration test
add_executable(integration_test tests/integration_test.cpp)
target_link_libraries(integration_test PRIVATE exchange_core GTest::gtest_main pthread)
target_compile_features(integration_test PRIVATE cxx_std_20)
add_test(NAME Integration COMMAND integration_test)

# Chaos test
add_executable(chaos_test tests/chaos_test.cpp)
target_link_libraries(chaos_test PRIVATE exchange_core GTest::gtest_main pthread)
target_compile_features(chaos_test PRIVATE cxx_std_20)
add_test(NAME Chaos COMMAND chaos_test)

Your exchange now has the final piece that separates a toy project from a production system: provable determinism under all conditions, including chaos. Next up, the Secret Mission challenges you to document the architecture at the level expected of a senior trading systems engineer.

Secret mission

Write the Architecture Design Document

You have built the engine. Now prove you understand every decision behind it. Write a 10-15 page professional architecture document that articulates technology choices, latency budgets, failure modes, scaling strategies, and regulatory compliance at the level expected of a senior trading systems engineer.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely in WSL2 on your local machine, so there are no ongoing cloud costs.

Resources you used:

  • Running exchange processes (matching engine shards, gateway, sequencer, standby, dashboard).
  • Event log files in ~/exchange/data/.
  • Shared memory segments in /dev/shm/exchange_*.
  • Project source code, build artifacts, and architecture design document.

Keep everything running

No action needed. Choose this if you're still actively building or testing your exchange.

  • Your matching engine processes continue running until you stop them with Ctrl+C or close the terminal.
  • Event logs in ~/exchange/data/ will keep growing as orders are processed.
  • Shared memory segments in /dev/shm/exchange_* remain allocated while processes are alive.
  • Your architecture design document in docs/architecture_design_document.md stays on disk.

Pause - I'll come back to this later

Shut down running processes to free up memory and CPU. Your files stay on disk so you can resume later.

  • Stop all running exchange processes by pressing Ctrl+C in each terminal, or find and kill them:
  • Shared memory segments are cleaned up automatically when all processes using them terminate.
  • Your event log files, source code, and architecture document remain on disk for when you return.

Find and stop any lingering exchange processes by running:

pkill -f matching_engine_shard
pkill -f gateway_router
pkill -f sequencer
pkill -f standby_process

When you come back, rebuild and relaunch from your project directory.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible.

  • Stop all running exchange processes:
pkill -f matching_engine_shard
pkill -f gateway_router
pkill -f sequencer
pkill -f standby_process
  • Remove event log files and shared memory segments:
rm ~/exchange/data/* && rm /dev/shm/exchange_*
  • Remove the entire project directory (source code, build artifacts, architecture document, and all other files):
rm -rf ~/Desktop/exchange

This is permanent

Deleting the project directory removes your source code, your architecture design document, all test results, and your replay files. Make sure you have backed up anything you want to keep before running these commands.

Nice Work!

Nice Work!

You just built a production-grade exchange matching engine in C++20. This is the same architecture used at CME Globex, Nasdaq, and ICE.

You've learned how to:

  • Build a C++20 matching engine with price-time priority, fixed-point arithmetic, zero-allocation hot paths, advanced order types (IOC, FOK, Iceberg), and sub-microsecond P99 match latency.
  • Design a full exchange architecture with an SBE wire protocol, hardware sequencer for total ordering, symbol-based sharding over shared-memory ring buffers, a FIX 4.4 gateway, pre-trade risk engine adding under 100ns, and active-passive replication with automatic failover.
  • Instrument and validate a production system with wire-to-wire nanosecond latency measurement at every hop, market surveillance (spoofing detection, circuit breakers), and deterministic replay proving byte-identical state reproduction.
  • Write a professional architecture design document covering technology rationale, latency budgets, failure modes, scaling strategy, hardware budget, and regulatory considerations at the level expected of a senior trading systems engineer.

Ready to quiz yourself?