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.
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
Rules written in other people's losses
15c3-5, Knight Capital, and who checks what
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.
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.
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.
Starting values are illustrative for a kernel-bypass setup. The point: a budget only helps if every stage has an owner and a number.
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
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 + widthor a sell priced belowreference − 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 ini128. - 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.
Price band check against reference price
#[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!()
}
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
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_shortImplementing 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.
Projected position limit with open orders
#[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!()
}
}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
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:
- Block: reject every new order and modify in the scope from now on. Cancels are still accepted, so a client can always reduce risk.
- Mass-cancel every resting order in the scope.
- 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.
Why should the kill switch be a sequenced command processed by the engine, rather than a shared atomic flag checked on each order?
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?
Day 5 — Making it fast
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 defaultHashMapuses 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
upperandlowerper instrument on reference updates, and the order-time check becomes two comparisons. The same goes for any division ori128multiplication 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
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
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.
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.
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 }, withLimits::UNLIMITEDandLimits::BLOCKEDas starting points. Accounts withoutset_limitsget the engine's default, soRiskEngine::new(Limits::BLOCKED)forces explicit configuration.max_positionis 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_newreserves (so orders still in flight to the engine count), aTrademoves quantity from open to position,Rested/Reducedset the remaining quantity, andCancelled,ExpiredandRejectedrelease it, all from the engine's own events so it's exact and replayable. The records live in denseVecs, found throughIdMap, a stdHashMapwith a two-multiply integer hasher for ids the exchange assigns itself (about 2 ns a key, versus 15–20 ns for SipHash).checkdoes one lookup on the (account, instrument) key. - The check, first failure wins: global kill, account kill, max order qty, price band, notional (
|price| × qtyinu128, with market orders valued at the reference), projected position (ini128). It returnsResult<(), RejectReason>:RejectReason::PriceBandfor the band andRejectReason::Risk(RiskReason::…)for everything else, so the result goes straight intoEvent::Rejected(week 8's typed rejects). The repo's band is simpler than Day 2's:price_band_ticksper 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 theRiskEngineitself; 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 aKillSwitch, anArc<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 incheckoron_event, andw15_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.
crates/riskcrates/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.