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

Simulation & Integration Testing

Simulated clients, scripted scenarios, deterministic replays.

Production · Gateway, Protocols, Risk & Resilience

Why this week

Your components are tested. The order book has unit tests, the engine has property tests against a reference model (week 12), the gateway has framing and session tests. The bugs that are left live in the seams, and no component test can see them:

  • A session receives its fill before its ack, because the ack and the fill took different queues.
  • Market data still shows a level the engine cancelled, because the publisher conflated an update away (week 16).
  • The first order after a reconnect is throttled, because the token bucket wasn't reset with the session (week 17).
  • One slow market-data subscriber backs up into the engine thread.

Finding these takes the whole exchange, driven by realistic traffic, with assertions on what every client and subscriber observed. And it has to be deterministic. A simulation that fails once in a thousand runs and can't reproduce the failure is worse than no simulation: it teaches the team to ignore red builds.

This week you build that harness: simulated traders, a scenario language for the edge cases you already know about, and enough determinism that any failing run can be replayed from its seed.

Day 1 — Deterministic simulation

Day 01

Control every source of nondeterminism

Seeds, virtual time, a single-threaded scheduler, and the HashMap trap

A simulation is deterministic only if every input that can vary between runs is either fixed or derived from the seed. Here is the list, roughly in order of how often each one bites:

  • Randomness. Every random choice comes from a seeded PRNG. Give each simulated agent its own generator, seeded from (global seed, agent id). Then adding a new agent doesn't change every existing agent's stream, and a scenario stays recognizable as it evolves.
  • Time. Nothing reads a real clock. The simulation owns a virtual clock (a manual clock like the one sketched in week 21) and advances it to the next scheduled event. A simulated hour can run in a second. The repo's runner goes one step simpler: its now is the step count.
  • Scheduling. Real threads interleave differently every run. In simulation mode, run every component (agents, gateway sessions, engine, publisher) on one thread, stepped by a scheduler in a fixed order, with in-memory transports instead of sockets.
  • Hash iteration order. Rust's HashMap uses a randomly keyed hasher (RandomState) that differs per process, so iterating one gives a different order every run. Anywhere iteration order can leak into output (which session gets its events first, which order is cancelled in a mass cancel), use a BTreeMap, a Vec, or a fixed hasher.
  • Addresses and ids. Pointer values, thread ids and anything derived from the OS must never become keys or tie-breakers.

This is the approach FoundationDB made famous: the entire distributed database runs in a deterministic simulator with simulated network, disks and failures, and any failing seed reproduces exactly. TigerBeetle does the same with its VOPR simulator. You need far less than they built, because your exchange is already single-threaded where it matters.

Keep two modes and share as much as possible between them:

  1. Simulation mode: one thread, virtual time, in-memory transport. Used for correctness: scenarios, invariants, seed sweeps, replay of failures.
  2. Load mode: real threads, real sockets on loopback, real clocks. Used for performance and concurrency (week 24). The same agents and scenarios run here, but you assert outcomes, not orderings.
Recall#seed-everything

Your simulation seeds its RNG, uses a virtual clock and runs single-threaded, yet two runs with the same seed produce different output. Name the likeliest culprit in Rust and the fix.

Paperby Zhou et al., SIGMOD 2021
FoundationDB: A Distributed Unbundled Transactional Key Value Store

Read the section on simulation testing: one thread, simulated network and disks, injected failures, and reproduction from a seed. Ask what the smallest version of that is for your exchange.

Videoby Will Wilson, Strange Loop 2014
Testing Distributed Systems w/ Deterministic Simulation

The talk that introduced FoundationDB's simulator to a wide audience. Note the claim that the simulator found bugs faster than real clusters did, and why determinism is the reason.

Day 2 — Order flow models

Day 02

Poisson arrivals, and what they miss

Inverse transforms, thinning, and zero-intelligence traders

The default model for "orders arrive at random" is a Poisson process: arrivals at a constant average rate λ, each independent of the last. Two facts make it the right starting point. It's memoryless: the time until the next arrival doesn't depend on how long you've already waited. And the superposition of many independent senders tends towards a Poisson process (the Palm–Khintchine theorem), which is roughly what a venue's aggregate flow looks like over short windows.

The gaps between arrivals are exponentially distributed with mean 1/λ, and you draw them by inverse transform: if u is uniform on [0, 1), then −ln(1 − u) / λ is exponential. (1 − u rather than u because a uniform generator can return exactly 0, and ln(0) is −∞: one infinite gap and your simulation stops producing orders.)

Real flow is not that tidy, and it's worth knowing how:

  • Intraday seasonality. Volume is typically heaviest around the open and the close. You can model a time-varying rate λ(t) by thinning: generate candidates at the maximum rate and keep each one with probability λ(t)/λ_max (Lewis and Shedler's method).
  • Clustering. Orders cause orders: a trade triggers quote updates, which trigger more trades. Self-exciting (Hawkes) processes capture this, and plain Poisson underestimates bursts.
  • Cancellations dominate. Most orders on modern electronic venues are cancelled rather than filled. A flow model without heavy cancel traffic doesn't exercise the paths that real traffic hammers.

For testing an exchange, coverage matters more than realism. You want flow that crosses the spread, sweeps several levels, empties one side of the book, cancels orders a microsecond after they filled, and hits every reject path. The academic "zero-intelligence" models are a good base precisely because they're simple. Random limit orders, market orders and cancels at Poisson rates already reproduce realistic-looking spreads and depth.

Drill

Poisson order arrival generator

Target: 6/6 casesNot yet run
/// SplitMix64 (given): tiny, fast, good enough for simulation, and seeded.
pub struct Rng(u64);

impl Rng {
    pub fn new(seed: u64) -> Self {
        Rng(seed)
    }

    pub fn next_u64(&mut self) -> u64 {
        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut z = self.0;
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        z ^ (z >> 31)
    }

    /// Uniform in [0, 1) with 53 random bits. CAN return exactly 0.0.
    pub fn next_f64(&mut self) -> f64 {
        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
    }
}

/// Inverse-transform sample of an exponential gap, in seconds, for an
/// arrival rate of `rate` per second: −ln(1 − u) / rate. Finite for u = 0.
pub fn exp_from_uniform(u: f64, rate: f64) -> f64 {
    let _ = (u, rate);
    todo!()
}

/// Arrival times (ns) of a Poisson process with `rate` arrivals per second
/// on [0, horizon_ns). Each gap is rounded to whole ns and is at least
/// 1 ns, so timestamps strictly increase. Same seed → same output.
pub fn arrivals(seed: u64, rate: f64, horizon_ns: u64) -> Vec<u64> {
    let _ = (seed, rate, horizon_ns);
    todo!()
}

/// Time-varying rate by thinning (Lewis–Shedler): generate candidates at
/// `max_rate` exactly like `arrivals`, and keep a candidate at time t with
/// probability rate_at(t) / max_rate (draw one more uniform per candidate).
pub fn arrivals_varying(seed: u64, max_rate: f64, rate_at: &dyn Fn(u64) -> f64, horizon_ns: u64) -> Vec<u64> {
    let _ = (seed, max_rate, rate_at, horizon_ns);
    todo!()
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
    Limit,
    Market,
    Cancel,
}

/// `n` order kinds drawn with relative weights [limit, market, cancel].
/// A zero weight must never be drawn.
pub fn order_kinds(seed: u64, n: usize, weights: [u32; 3]) -> Vec<Kind> {
    let _ = (seed, n, weights);
    todo!()
}
Rust
Recall#ln-one-minus-u

Why does the exponential sampler use −ln(1 − u)/λ rather than −ln(u)/λ when u comes from a generator on [0, 1)?

Paperby Smith, Farmer, Gillemot and Krishnamurthy, Quantitative Finance (2003)
Statistical theory of the continuous double auction

A zero-intelligence order-flow model (Poisson limit orders, market orders and cancels) whose predictions for spread and depth are surprisingly good. It's a ready-made spec for your noise traders.

Paperby Dhananjay K. Gode and Shyam Sunder, Journal of Political Economy (1993)
Allocative Efficiency of Markets with Zero-Intelligence Traders

The original zero-intelligence result: much of a double auction's efficiency comes from the market rules, not from trader intelligence. A useful reminder that your matching rules shape outcomes.

Day 3 — Simulated traders

Day 03

A small cast of agents

Market makers, takers, noise, and the troublemakers

Give every simulated participant the same small interface. It reacts to what its session receives and to timers it scheduled, and it acts through a context that owns the RNG, the clock and the outbound queue:

pub trait Agent {
    fn on_start(&mut self, ctx: &mut Ctx);
    /// Acks, fills, rejects and market-data updates for this agent's session.
    fn on_event(&mut self, ev: &ClientEvent, ctx: &mut Ctx);
    /// A wake-up the agent asked for with ctx.schedule(at).
    fn on_timer(&mut self, now: u64, ctx: &mut Ctx);
}

pub struct Ctx<'a> {
    pub now: u64,
    pub rng: &'a mut Rng,          // this agent's own seeded stream
    out: &'a mut Vec<ClientMsg>,   // drained into the agent's gateway session
    timers: &'a mut TimerQueue,
}

A cast that covers the paths you care about:

  • Market makers quote both sides around a reference price, cancel and replace when filled or when the reference moves, and respect an inventory limit. They are what makes the book look like a book.
  • Takers send IOC or market orders at Poisson times, sometimes large enough to sweep several levels.
  • Noise traders place limit orders at random distances from the touch and cancel them at random. This is your cancel traffic.
  • Troublemakers exercise the defensive code: a fat-finger agent that occasionally sends absurd prices and sizes (week 15 risk), a burst agent that exceeds its throttle (week 17), a slow market-data subscriber (week 16), and a reconnector that drops its session mid-stream and recovers with sequence numbers (week 14).

Drive the reference price with a simple random walk, so makers move and takers chase. The goal isn't a realistic market. It's a busy one that touches every code path, every run, from a seed.

Design note#agent-roster

For your exchange, list the agents you would run in the default simulation and the feature each one exists to exercise. Which code path in your gateway, risk or market-data code would no agent currently reach?

0 chars

Day 4 — Scenarios and end-to-end assertions

Day 04

Scripts for the cases you already know

A scenario language, strict expectations, and invariants

Random flow finds the bugs you didn't think of. Scripted scenarios pin down the ones you did: partial fill then cancel, a cancel racing a fill, a sweep through three levels, a modify-down that keeps priority. They should read like specifications, so that someone who knows market structure but not Rust can review them.

The one design decision that matters most in the runner is strictness: every output must be expected. A runner that only checks what the script mentions will pass a script that forgot about a fill, which is exactly the bug class (an extra fill, a duplicate ack) you most need to catch. In the drill, an unconsumed fill fails the scenario and is blamed on the submit that produced it.

Drill

Tiny scenario DSL interpreter

Target: 6/6 casesNot yet run
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
    Buy,
    Sell,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fill {
    pub maker: u64,
    pub taker: u64,
    pub qty: u64,
    pub price: u64,
}

/// What a scenario drives. In the repo: the whole exchange behind the
/// gateway. Here: whatever the harness plugs in (including buggy ones).
pub trait Venue {
    /// Submit a limit order; returns the fills it caused, in match order.
    fn submit(&mut self, id: u64, side: Side, qty: u64, price: u64) -> Vec<Fill>;
    /// Cancel a resting order; false if it was not resting.
    fn cancel(&mut self, id: u64) -> bool;
    /// Total resting quantity at one price level.
    fn level_qty(&self, side: Side, price: u64) -> u64;
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Failure {
    Parse(String),
    Mismatch { expected: String, got: String },
    UnexpectedFills(usize),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScenarioError {
    /// 1-based line number in the script.
    pub line: usize,
    pub failure: Failure,
}

/// One command per line; tokens are separated by whitespace; blank lines
/// and lines starting with '#' are skipped.
///
///   buy <id> <qty> @ <price>
///   sell <id> <qty> @ <price>
///   cancel <id>
///   expect fill <maker> <taker> <qty> @ <price>
///       pop the next unconsumed fill of the latest submit and compare
///   expect no-fills
///       the latest submit has no unconsumed fills left
///   expect level <buy|sell> <price> <qty>
///       resting quantity at that level
///   expect cancel-rejected
///       the latest cancel returned false
///
/// Anything else (unknown command, wrong token count, bad number, missing
/// '@') is Failure::Parse at that line. A failed expectation is
/// Failure::Mismatch at that line.
///
/// Strictness: every fill must be expected. If a buy/sell/cancel line is
/// reached, or the script ends, while fills from the latest submit are
/// still unconsumed, fail with UnexpectedFills(how many) reported at the
/// line of the SUBMIT that produced them.
///
/// Returns the number of `expect` lines checked.
pub fn run_scenario<V: Venue>(venue: &mut V, script: &str) -> Result<usize, ScenarioError> {
    let _ = (venue, script);
    todo!()
}
Rust

Scenarios check specific cases. Invariants check every event of every run, scripted or random. In simulation mode you can afford to check them after each event:

  • Conservation. For every order, original quantity = filled + resting + cancelled. For every trade, the buy and sell quantities match.
  • Market data agrees with the engine. After applying market-data sequence N, the subscriber's book equals the engine's book as of the command that produced N.
  • Per-session ordering. An ack precedes any fill of that order, nothing follows a terminal state, and a cancel ack is never followed by a fill.
  • Risk holds. No account's position or open exposure ever exceeds its configured limits (week 15).
  • Liveness. At the end of a run, every order is resting or has reached a terminal state. Nothing is stuck in flight.

Finally, a golden hash: hash the full output event stream of a fixed-seed run and commit it. Any change to that hash is either a bug or an intended behaviour change, and either way a human should look at it (the insta crate makes this kind of snapshot review pleasant).

Recall#strict-expectations

Why should a scenario runner fail when the system produces an event the script did not expect, instead of only checking the events the script mentions?

Day 5 — Replays and debugging

Day 05

From a red build to the first bad event

Seeds, shrinking, state-hash bisection, and asserting outcomes

When a simulation fails, the workflow should be mechanical:

  1. The failure prints its seed (and the scenario name), and rerunning with that seed reproduces it exactly.
  2. Shrink it with the week 12 machinery: fewer agents, a shorter run, fewer orders, until the failure is small enough to read.
  3. Replay the journal. The journal of the failing run is a complete recording of the inputs. Replay it into a fresh engine with tracing turned on and step to the interesting sequence number.
  4. Bisect divergence. When two runs that should match don't (before and after a refactor, primary and standby), compare the state hash at each sequence number and binary-search for the first one that differs. That command is your bug.

Load mode needs a different discipline. With real threads, the interleaving of events from different sessions legitimately varies between runs. Assert what must hold regardless of interleaving: per-session ordering, conservation, final book state, the invariants above. Never assert a global order of events across sessions. A test that does is flaky by construction.

Recall#outcomes-not-interleavings

In load mode, a test asserts that session A's fill arrives before session B's ack. It passes most of the time. What is wrong with it, and what should it assert instead?

Design note#divergence-hunt

Two runs of the same seed diverge at engine sequence number 48,211. Write down, step by step, how you would find the cause using only the journal, the state hashes and your simulation tooling.

0 chars

Day 6 — Build and reflect

Day 06

Ship the capstone

Then run a thousand seeds

Build the simulation crate below, then run a seed sweep (a thousand seeds of a few simulated seconds each is a reasonable CI job) and fix whatever falls out. Expect to find at least one real bug in an earlier week's code. That's the point.

Design note#week-reflection

Which bug did the simulation find that no unit or property test would have? What does that tell you about where the remaining bugs in your exchange are likely to be?

0 chars

Capstone

Build crates/sim's scenario module. The repo's version is smaller than the plan above in a few deliberate ways, noted as they come up:

  • Agents implement Agent { name, act(&mut AgentCtx), on_event(&Delivery) }. On its turn an agent sees AgentCtx (now, its account, the book as rebuilt from market data, its Inventory, its own Rng) and queues commands in an Outbox. Three are built in: MakerAgent (one bid and one ask around a fair value, skewed against inventory, cancel-and-replace when the wanted price moves), NoiseAgent (an uninformed taker sending market or IOC orders at random) and MomentumAgent (IOC at the touch after the mid moves by a threshold). There are no troublemaker agents yet. Agent i trades as AccountId(i + 1) with order ids from (i + 1) << 40, and its RNG is seeded from the scenario seed and its position in the list.
  • Turns instead of a scheduler. Runner::step gives every agent one turn, starting with agent step % n so nobody always goes first. A turn is act, send the agent's commands, flush the venue, route the deliveries to their owners, and apply the market data to the shared book, so the next agent sees the book as it now is. Time is the step count. There's no Poisson arrival model or timer queue in the runner: agents decide per turn with their own RNG, which is enough for determinism and coverage.
  • A Venue trait (send, flush, market_snapshot) between the runner and the exchange. InProcess is risk → engine → L2 publisher on one thread, so the gateway isn't in this week's path. The trait is shaped like a network session, and the module docs describe how the TCP gateway becomes a second implementation: flush waits until every command sent has been answered, which keeps a run over sockets deterministic too.
  • End-to-end checks as Rust tests, not a scenario language. There's no DSL in the repo: the drill's language is the lesson, and the repo's tests drive agents and assert outcomes directly.
  • Tests named w23_*: w23_same_seed_same_run_other_seed_differs (identical reports and engine state hash for one seed, different for another), w23_positions_and_cash_are_conserved (positions and cash sum to zero across agents over 5 seeds, and each agent's position matches pre-trade risk's), w23_market_data_book_equals_engine_book_after_every_step, w23_risk_rejects_reach_the_agent_and_never_the_engine (cancels still work with the kill switch on), w23_fills_reach_both_owners_and_makers_skew_after_a_fill, and w23_noise_and_momentum_agents_follow_their_rules. A golden hash, a seed sweep in CI, per-session ordering and liveness checks are the natural next additions.

On the benchmark, w23.sim_throughput builds a fresh market of 11 agents (3 makers, 6 noise takers, 2 momentum takers, about seven commands per step), runs 5,000 steps, and counts every command that goes through risk → engine → market data → agents. Agents are included, so it measures your harness as much as your exchange. The usual culprits when it's slow are per-event allocation in agents (build orders into reused buffers), formatting or logging in the loop, and a check that rescans the whole book after every event instead of updating incrementally.

Capstone · in your exchange · crates/sim

A simulated market you can replay

Seeded market makers, noise and momentum takers driving risk → engine → market data in process, end-to-end assertions (conservation, book agreement, risk rejects), and bit-identical reruns from a seed.

cargo xtask test --week 23      # tests named w23_*
cargo xtask bench --week 23     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 23 tests pass w23_*
  • Simulated orders through the full pipeline w23.sim_throughputtarget ≥ 3.0M ops/s

Feeds into

  • Week 24 runs these agents in load mode against exchange v2, over real sockets and threads, to measure end-to-end latency under realistic flow.
  • Week 41 computes microstructure analytics (spread, imbalance, queue position, markouts) on the flow your agents generate.
  • Week 48 adds fault injection to the simulation (dropped packets, crashed leaders, slow consumers) and asserts the invariants still hold.