← Back to roadmap
Phase 2Week 15~105 min read
0%·0/4

Pre-Trade Risk Checks

Fat fingers, position limits, and the kill switch.

Production · Gateway, Protocols, Risk & Resilience

Why this week

On 1 August 2012, Knight Capital deployed new code to its order router. One of eight servers didn't get the update, and a repurposed flag reactivated long-dead code (an old function called Power Peg) on that server. In about 45 minutes it sent millions of orders into the US equity market and built positions that cost the firm roughly $460 million. The SEC later charged Knight under the Market Access Rule, because its controls weren't designed to stop that kind of runaway order flow.

Pre-trade risk is the code that says no before an order touches the book: no to a price far from the market, no to a quantity nobody meant, no to a position the account can't afford, and no to everything once someone hits the kill switch. It sits on the latency-critical path of every order, so it must be both paranoid and fast: tens of nanoseconds, no locks, no allocation, no hash lookups. This week you'll build the two checks that are easiest to get subtly wrong, price bands and projected position, and in the capstone the full per-account risk gate.

⚠️
Warning

Risk checks fail in two directions. A check that's too loose loses money, as Knight's did. A check that's too tight or buggy rejects good orders, and a market maker who's rejected during a fast market is also exposed. Every limit in this week needs a test for "accepts exactly at the edge" as well as "rejects just past it".

Day 1 — Why risk sits in front of the book

Day 01

Rules written in other people's losses

15c3-5, Knight Capital, and who checks what

Docsby U.S. Securities and Exchange Commission
Risk Management Controls for Brokers or Dealers with Market Access (Rule 15c3-5, adopting release, 2010)

The 'Market Access Rule'. Read the description of required controls: credit and capital thresholds, erroneous-order prevention through price and size parameters, duplicative-order checks, and the requirement that controls be pre-trade, automated, and under the broker-dealer's direct and exclusive control.

Docsby U.S. Securities and Exchange Commission
In the Matter of Knight Capital Americas LLC (order instituting proceedings, 2013)

A rare, detailed public post-mortem of a trading-system failure. Read the factual sections: the deployment, the repurposed flag, the missing capital threshold that would have stopped the flow, and the alerts that were generated but not acted on.

Docsby European Commission
Commission Delegated Regulation (EU) 2017/589 (MiFID II RTS 6)

The EU's equivalent for algorithmic trading firms. Find the article on pre-trade controls on order entry (price collars, maximum order value, maximum order volume, maximum message limits) and the article on kill functionality. Notice how closely the list matches what you'll build.

Three different parties run pre-trade checks, for different reasons:

  • The trading firm checks its own strategies: position, loss and message-rate limits per strategy and per trader.
  • The broker with market access checks every client order it forwards (that's what 15c3-5 requires): credit, capital and erroneous-order limits.
  • The exchange protects the market itself: price bands around a reference, maximum order size, message throttles, kill switches and cancel-on-disconnect for each participant.

Your exchange plays the third role, with per-account limits that a broker would set for its clients. The checks are the same in all three places. Only the owner and the configuration change.

Tick-to-trade budget (editable)2.4 µs
Wire to wire2.4 µs
Network I/OYour code

Starting values are illustrative for a kernel-bypass setup. The point: a budget only helps if every stage has an owner and a number.

Recall#knight-lesson

What's the general lesson of Knight Capital for someone writing a risk engine, beyond 'test your deployments'?

Day 2 — Price bands and fat fingers

Day 02

How far from the market is too far?

Drill 1: a band check that is one-sided, inclusive and overflow-proof

In December 2005, a Mizuho Securities trader meant to sell 1 share of J-Com at ¥610,000 and instead entered 610,000 shares at ¥1. The firm lost roughly ¥40 billion. A fat-finger check catches orders whose price or size is implausible:

  • Price band: reject a buy priced above reference + width or a sell priced below reference − width. It's one-sided on purpose. A buy far below the market is harmless: it rests and probably never fills. CME Globex documents exactly this rule for its price banding.
  • Reference price: last trade, mid, an auction price, or the previous close, but always one with a known age. A stale reference after a halt makes the band either useless or a wall. Reject when the reference is missing or too old, and let operations override.
  • Width: in basis points of the reference, with a minimum in ticks. For a 20-tick instrument, 5% is one tick, which would reject normal orders.
  • Size and notional: maximum quantity per order and maximum price × qty × multiplier. Notional multiplies two large integers, so compute it in i128.
  • Market orders have no price to band. Venues convert them to limit orders at a protection price (CME calls these protection points) or reject them when the book is thin.
Drill

Price band check against reference price

Target: 6/6 cases · inclusive edges, no overflowNot yet run
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
    Buy,
    Sell,
}

#[derive(Debug, Clone, Copy)]
pub struct BandConfig {
    /// Band half-width in basis points of |reference price|.
    pub band_bps: u32,
    /// The band is never narrower than this (protects low-priced instruments).
    pub min_band_ticks: i64,
    /// A reference older than this is unusable.
    pub max_ref_age_ns: u64,
}

#[derive(Debug, Clone, Copy)]
pub struct Reference {
    pub price: i64, // ticks; may be negative
    pub as_of_ns: u64,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BandCheck {
    Accept,
    /// Buy priced above ref + width; `limit` = ref + width.
    AboveBand { limit: i64 },
    /// Sell priced below ref - width; `limit` = ref - width.
    BelowBand { limit: i64 },
    NoReference,
    StaleReference { age_ns: u64 },
}

/// width = max(floor(|ref| * band_bps / 10_000), min_band_ticks).
/// Order of checks: missing reference, stale (age = now - as_of, a future
/// as_of counts as age 0, stale if age > max), then the band for this side.
/// Edges are inclusive. Must not overflow for any i64 reference.
pub fn check_band(cfg: &BandConfig, reference: Option<Reference>, now_ns: u64, side: Side, price: i64) -> BandCheck {
    todo!()
}
Rust
Recall#one-sided-band

Why does a price band reject only buys above the upper edge and sells below the lower edge, rather than any price outside the band?

Day 3 — Position limits with open orders

Day 03

Count what could happen, not what has

Drill 2: worst-case projection with reservations

A position limit that only counts filled quantity is easy to beat. Send ten buy orders of 100 each while your position is 0. Each passes "position + 100 ≤ max", and if they all fill you're 1,000 long. The correct check is the worst case: every open order on the side being increased fills, and nothing on the other side does.

long side:   position + open_buy  + new_buy  ≤  max_long
short side:  position − open_sell − new_sell ≥ −max_short

Implementing that means reservations. An accepted order adds its quantity to open_buy or open_sell. A fill moves quantity from open to position. A cancel, expiry or IOC remainder releases it. All of it is integer bookkeeping on one record per account, updated from the engine's own events. Open sells do not offset a new buy: if the sells are cancelled or never fill, the buys alone must still be within limits.

Drill

Projected position limit with open orders

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

/// Both limits are non-negative magnitudes.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
    pub max_long: i64,
    pub max_short: i64,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Breach {
    /// projected = position + open_buy + qty (saturating); limit = max_long
    Long { projected: i64, limit: i64 },
    /// projected = position - open_sell - qty (saturating); limit = -max_short
    Short { projected: i64, limit: i64 },
}

pub struct Exposure {
    limits: Limits,
    position: i64,
    open_buy: i64,
    open_sell: i64,
}

impl Exposure {
    pub fn new(limits: Limits) -> Self {
        Exposure { limits, position: 0, open_buy: 0, open_sell: 0 }
    }
    pub fn position(&self) -> i64 { self.position }
    pub fn open_buy(&self) -> i64 { self.open_buy }
    pub fn open_sell(&self) -> i64 { self.open_sell }

    /// Check a new order (qty > 0) against the worst case for ITS side only.
    /// On success reserve qty as open; on breach change nothing.
    pub fn check_new(&mut self, side: Side, qty: i64) -> Result<(), Breach> {
        todo!()
    }

    /// `qty` of one of our working orders filled.
    pub fn on_fill(&mut self, side: Side, qty: i64) {
        todo!()
    }

    /// `qty` of a working order was cancelled, expired or killed: release it.
    pub fn on_release(&mut self, side: Side, qty: i64) {
        todo!()
    }
}
Rust
Recall#no-netting

An account is long 90 with a 50-lot sell order working and a max long of 100. Why reject a new 20-lot buy, when the sell would bring the position down?

Day 4 — The kill switch

Day 04

Stop everything, deterministically

Scope, actions, and where the switch lives

A kill switch has a scope (one account, one session, one firm, or the whole venue) and actions:

  1. Block: reject every new order and modify in the scope from now on. Cancels are still accepted, so a client can always reduce risk.
  2. Mass-cancel every resting order in the scope.
  3. Leave positions alone. Flattening a position is a trading decision, so the switch stops new risk and doesn't create more.

The design question is what "from now on" means. A tempting implementation is an AtomicBool that the risk check loads with Relaxed on every order. It's cheap, but it makes the cut point timing-dependent. Which orders were accepted after the operator pressed the button depends on when the store became visible to the engine thread, and replaying the journal can't reproduce that decision.

The deterministic design makes kill a command in the sequenced input stream, like an order. The engine applies it at a precise sequence number. Every order sequenced before it was checked under the old state, and every order after it is rejected. Replay reproduces exactly the same cut. The mass-cancel is emitted as ordinary cancel events, so market data and the journal explain every one. An out-of-band flag at the gateway can additionally stop new orders from entering the queue, but the authoritative decision is the sequenced one.

Recall#kill-sequenced

Why should the kill switch be a sequenced command processed by the engine, rather than a shared atomic flag checked on each order?

Design note#kill-runbook

Write the runbook entry for 'kill account 4711': who can trigger it and through which interface, what the client sees (rejects, cancels, session state), what the operator sees to confirm it took effect, and the exact steps to re-enable trading afterwards. What must be true before re-enabling?

0 chars

Day 5 — Making it fast

Day 05

A risk check that fits in a cache line

Layout, precomputation and the accept path

The week's target is a full pre-trade check at p99 within 65 ns. That is roughly 200–250 cycles, and a single DRAM miss (~80–100 ns) blows it on its own. So the design rule is that everything the check touches is already in cache:

  • Dense arrays, not hash maps. Assign each account a small integer at logon and keep risk state in a Vec<AccountRisk> indexed by it. Rust's default HashMap uses SipHash-1-3, which is DoS-resistant and costs tens of nanoseconds per lookup once you add the probable cache miss. You control the keys, so you don't need it.
  • One or two cache lines per account. Keep the hot fields (limits, position, open quantities, the kill flag) together and cold fields (names, audit info) elsewhere.
  • Precompute what changes rarely. Band edges change when the reference price updates, not when an order arrives. Compute upper and lower per instrument on reference updates, and the order-time check becomes two comparisons. The same goes for any division or i128 multiplication that can move off the order path.
  • A straight-line accept path. Accepts vastly outnumber rejects. Evaluate all checks, combine them, and branch once. Build the reject reason in a separate cold function (week 38 formalizes this).
#[repr(C, align(64))]
pub struct AccountRisk {
    pub max_order_qty: i64,
    pub max_long: i64,
    pub max_short: i64,
    pub max_notional: i64,   // in price-ticks × qty units, precomputed per instrument
    pub position: i64,
    pub open_buy: i64,
    pub open_sell: i64,
    pub killed: bool,        // set by the sequenced kill command
}                            // 57 bytes of fields, padded to one 64-byte line
Recall#precompute-bands

The band check from Day 2 multiplies and divides on every order. How do you take that off the order path, and what does the order-time check become?

Day 6 — Where risk runs, and how to trust it

Day 06

Placement and property tests

Engine thread or gateway, and the invariant that matters

Where risk runs is a real trade-off:

  • On the engine thread, before matching. Risk state is updated by the same deterministic event stream that produces fills, so open quantities and positions are exact, replay reproduces every decision, and there's nothing to synchronize. The cost is latency on the single most contended thread.
  • In the gateway, before sequencing. Checks run in parallel across sessions and rejects never consume engine time. But fills happen on the engine, so the gateway's exposure state must be updated from engine events. It's always slightly behind, which is why reservations (count it when you send it) matter even more there.

Many real systems do both: cheap stateless checks (size, price band, throttles) at the gateway, and stateful position and credit checks where fills are known exactly.

Test it with the week 12 machinery. The core property: for any generated stream of orders, cancels and fill patterns, an account's position never exceeds its limits under any subset of its working orders filling. Add a differential test against a naive model that recomputes exposure from scratch by scanning all of the account's working orders on every check.

Design note#risk-placement

Decide where each check in your exchange runs (gateway, engine, or both): max order qty, price band, notional, projected position, kill switch, message throttle. Justify each choice by what state it needs and how stale that state may be.

0 chars

Capstone

Build crates/risk's RiskEngine, the gate in front of the engine. It's single-threaded and owned by the thread that sequences orders into the engine: the caller runs risk.check(&order, reference), then risk.on_new(&order) when it forwards the order, and feeds every engine event back through risk.on_event(&event). The week's tests drive it directly, with no gateway or engine.

  • Configuration per account as Limits { max_order_qty, max_notional, max_position, price_band_ticks }, with Limits::UNLIMITED and Limits::BLOCKED as starting points. Accounts without set_limits get the engine's default, so RiskEngine::new(Limits::BLOCKED) forces explicit configuration. max_position is one symmetric magnitude applied to each instrument separately, and the limits are unsigned, so a negative limit can't be expressed at all.
  • State: an Exposure { position, open_buy, open_sell } per (account, instrument), following the reservation model. on_new reserves (so orders still in flight to the engine count), a Trade moves quantity from open to position, Rested/Reduced set the remaining quantity, and Cancelled, Expired and Rejected release it, all from the engine's own events so it's exact and replayable. The records live in dense Vecs, found through IdMap, a std HashMap with a two-multiply integer hasher for ids the exchange assigns itself (about 2 ns a key, versus 15–20 ns for SipHash). check does one lookup on the (account, instrument) key.
  • The check, first failure wins: global kill, account kill, max order qty, price band, notional (|price| × qty in u128, with market orders valued at the reference), projected position (in i128). It returns Result<(), RejectReason>: RejectReason::PriceBand for the band and RejectReason::Risk(RiskReason::…) for everything else, so the result goes straight into Event::Rejected (week 8's typed rejects). The repo's band is simpler than Day 2's: price_band_ticks per account, rejecting a limit price more than that many ticks from the reference on either side, against a reference the caller passes in, with no age check (0 turns it off). Ticks need no division, so there's nothing to precompute. Market orders skip it.
  • Kill switches, in two forms. kill_account(account) sets a flag on the RiskEngine itself; since that lives on the sequencing thread, calling it there puts the cut at a precise point in the order stream. global_kill_switch() returns a KillSwitch, an Arc<AtomicBool> an operator thread can flip, which is the out-of-band flag from Day 4. Neither cancels anything: open_orders(account) lists the ids for the caller to cancel through ordinary Cancel commands, which risk never checks.
  • Tests (w15_*): the documented check order, edges for every limit (w15_notional_and_band_edge_cases), same-side open orders counting and opposite-side ones never credited, every event type, both kill switches, duplicate and unknown ids, no allocation in check or on_event, and w15_randomized_exposure_matches_recomputation, a differential test against a model that recomputes exposure from the full history.

Pitfalls:

  • Release paths you forgot. IOC remainders, FOK kills, post-only rejects, self-trade cancels and mass-cancels all release reservations. Miss one and the account's open quantity drifts upward until it can't trade. Conservation from week 12 catches this: open == sum(leaves of working orders) is an invariant.
  • Modify-up. Increasing an order's quantity must reserve only the delta, and checking it as a fresh order double-counts.
  • Overflow. Quantities and prices come from clients. Use checked or saturating arithmetic, or i128, for anything multiplied.
  • Rejecting cancels. A killed or over-limit account must still be able to cancel. Risk checks never block risk reduction.

How to think about the 65 ns p99 target: w15.risk_check times check alone over 1,024 accounts × 4 instruments with about 60,000 open orders, on a stream where roughly 95% of orders pass, as real flow does. The arithmetic is a handful of compares, so the budget really goes to memory. If the account record and its exposure are in L1/L2, the check costs on the order of 10–30 ns. If your p99 is far above p50, look for the cache miss: an account array that's too sparse, a SipHash HashMap lookup, or risk state that is evicted between orders because the book walks through a lot of memory.

Capstone · in your exchange · crates/risk

crates/risk — the gate in front of the book

Per-account limits (max order qty, notional, projected position, price bands) and a kill switch, checked before matching in well under a cache miss's worth of time.

cargo xtask test --week 15      # tests named w15_*
cargo xtask bench --week 15     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 15 tests pass w15_*
  • Full pre-trade check w15.risk_checktarget p99 ≤ 65 ns

Feeds into

  • Week 17 adds message-rate throttles to the same crate, driven by an injected clock and keyed through the same IdMap.
  • Week 24 runs gateway → risk → engine end to end under load and attributes latency to the risk stage.
  • Week 45 adds post-trade risk: realized and unrealized PnL per fill, with limit breaches feeding this week's kill switch.