Home › Financial infrastructure › Markets & trading
algorithm low

Pre-trade risk checks

Problem A firm that sends orders automatically must stop mistaken or runaway orders before they leave, in a time too short to slow trading, to protect itself and the market.

Constant-time gate in front of order entry that rejects fat-finger, over-limit and runaway orders using worst-case exposure counted over open orders.

O(1) per order and per execution report; order of 100 ns in software
riskfat-fingerprice-collarkill-switchthrottlemarket-access
Overview

Every order leaving a trading system passes a small set of checks: is this account allowed, is the order sane (size, notional, price near a reference), and would it push worst-case exposure past a limit if every open order filled? The trick is to keep running counters (position, open buy/sell quantity, open notional) that are reserved when an order is sent and released on fills, cancels and rejects, so each check is a few integer compares instead of a scan over open orders.

Key facts

Use when

  • Any automated order flow to an exchange or broker: algos, smart order routers, market makers, retail order entry.
  • In front of a matching engine for member-level credit limits.

Avoid when

  • Post-trade only (end-of-day margin): checks after execution cannot stop a runaway algo.
  • Do not move these checks off the order path to an async service to save latency; that is exactly the failure they exist to prevent.

Deep dive

How it works

In: an outgoing order, then execution reports · Out: pass (with reservation) or reject with a reason · State: per account and symbol: position, open buy/sell quantity, open notional, rate limiter, kill flag · Cost: O(1), order of 100 ns in software

Per account (and per account × symbol) keep: position, open_buy_qty, open_sell_qty, open_notional, a rate limiter and a kill flag. For each new order:

  1. Stateless sanity: kill switch, restricted symbol, 0 < qty ≤ max_qty, px > 0, px × qty ≤ max_notional.
  2. Price collar: |px − ref| × 10_000 ≤ collar_bps × ref, where ref is last trade, mid or a static reference. No reference → reject (or a tighter fallback), never pass.
  3. Worst-case position: buy: position + open_buy + qty ≤ max_pos; sell: position − open_sell − qty ≥ −max_pos.
  4. Credit: open_notional + px × qty ≤ credit_limit.
  5. Throttle: orders per second per account/session (token bucket).
  6. If all pass, reserve: open_buy += qty, open_notional += px × qty, consume a token, then send.

Execution reports release the reservation: fill q → open_side −= q, position ± q, open_notional −= limit_px × q; cancel/expire/reject of leaves → open_side −= leaves, open_notional −= limit_px × leaves. Cancel/replace: treat as release of the old order plus reservation of the new one, checking the new values before sending.

Code

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Side { Buy, Sell }

pub struct Order { pub symbol: u32, pub side: Side, pub px: i64, pub qty: i64 } // px in integer ticks

pub struct Limits {
    pub max_order_qty: i64,
    pub max_order_notional: i128,
    pub collar_bps: i64,
    pub max_position: i64,           // absolute, per symbol
    pub credit_limit: i128,          // gross notional of all open orders
    pub orders_per_sec: u64,
}

#[derive(Debug, PartialEq, Eq)]
pub enum Reject { Killed, Restricted, Qty, Price, Notional, NoReference, Collar, Position, Credit, Throttle }

#[derive(Default, Clone, Copy)]
pub struct Exposure { pub position: i64, pub open_buy: i64, pub open_sell: i64 }

pub struct Account {
    pub limits: Limits,
    pub killed: bool,
    pub restricted: Vec<bool>,       // by dense symbol index
    pub exp: Vec<Exposure>,          // by dense symbol index
    pub open_notional: i128,
    bucket: u64,                     // tokens × 1e9 (integer token bucket)
    last_ns: u64,
}

const NS: u64 = 1_000_000_000;

impl Account {
    pub fn new(limits: Limits, symbols: usize) -> Self {
        let bucket = limits.orders_per_sec * NS;
        Account { limits, killed: false, restricted: vec![false; symbols], exp: vec![Exposure::default(); symbols],
                  open_notional: 0, bucket, last_ns: 0 }
    }

    /// Returns Ok after reserving exposure; the caller must then send the order.
    pub fn check_and_reserve(&mut self, o: &Order, ref_px: i64, now_ns: u64) -> Result<(), Reject> {
        let l = &self.limits;
        if self.killed { return Err(Reject::Killed); }
        let s = o.symbol as usize;
        if self.restricted[s] { return Err(Reject::Restricted); }
        if o.qty <= 0 || o.qty > l.max_order_qty { return Err(Reject::Qty); }
        if o.px <= 0 { return Err(Reject::Price); }
        let notional = o.px as i128 * o.qty as i128;                 // i128: no overflow
        if notional > l.max_order_notional { return Err(Reject::Notional); }
        if ref_px <= 0 { return Err(Reject::NoReference); }
        let dev = (o.px as i128 - ref_px as i128).abs() * 10_000;
        if dev > l.collar_bps as i128 * ref_px as i128 { return Err(Reject::Collar); }
        let e = self.exp[s];
        let ok = match o.side {
            Side::Buy => e.position + e.open_buy + o.qty <= l.max_position,
            Side::Sell => e.position - e.open_sell - o.qty >= -l.max_position,
        };
        if !ok { return Err(Reject::Position); }
        if self.open_notional + notional > l.credit_limit { return Err(Reject::Credit); }
        // Token bucket, burst = one second of rate; refill in integer token·ns units.
        let rate = l.orders_per_sec;
        self.bucket = (self.bucket + now_ns.saturating_sub(self.last_ns).saturating_mul(rate)).min(rate * NS);
        self.last_ns = now_ns;
        if self.bucket < NS { return Err(Reject::Throttle); }
        // Everything passed: reserve.
        self.bucket -= NS;
        self.open_notional += notional;
        let e = &mut self.exp[s];
        match o.side { Side::Buy => e.open_buy += o.qty, Side::Sell => e.open_sell += o.qty }
        Ok(())
    }

    /// Fill of `q` on an order with limit price `limit_px` (release at the reserved price, not the fill price).
    pub fn on_fill(&mut self, symbol: u32, side: Side, limit_px: i64, q: i64) {
        let e = &mut self.exp[symbol as usize];
        match side {
            Side::Buy => { e.open_buy -= q; e.position += q; }
            Side::Sell => { e.open_sell -= q; e.position -= q; }
        }
        self.open_notional -= limit_px as i128 * q as i128;
    }

    /// Cancel, expiry or reject of the remaining `leaves` quantity.
    pub fn on_done(&mut self, symbol: u32, side: Side, limit_px: i64, leaves: i64) {
        let e = &mut self.exp[symbol as usize];
        match side { Side::Buy => e.open_buy -= leaves, Side::Sell => e.open_sell -= leaves }
        self.open_notional -= limit_px as i128 * leaves as i128;
    }
}

Complexity and constants

  • O(1): about 10 integer compares, two i128 multiplies, one cache line of account state and one of symbol exposure. Order of 100 ns in software including cache misses; FPGA/NIC-resident risk gateways do it in well under 1 µs wire-to-wire.
  • Memory: 24 B per account × symbol exposure; dense symbol indices let a Vec replace a hash map.

Efficiency

Cost: the bound is latency of a short compare chain plus cache misses for account and symbol state. About 10 integer compares, two i128 multiplies, one cache line of account state and one of symbol exposure: order of 100 ns in software; 24 B per account × symbol exposure. FPGA or smart-NIC gateways do it in well under 1 µs wire-to-wire.

Levers

  • Running counters, reserve on send and release on fill, cancel, reject — O(1) instead of O(open orders) — always.
  • Dense Vec by account and symbol index — hot set stays in L1/L2 — instead of a hash map.
  • Single writer per account — no atomics or locks on counters.
  • Stateless rejects first, reserve last — a reject writes nothing.
  • Collar bounds precomputed per symbol on each reference refresh — not per order.

Naive → standard → state of the art

  • Naive: scan open orders on every new order · O(open orders)
  • Standard: counters in software on the order path · order of 100 ns
  • State of the art: FPGA or smart-NIC risk gateway · well under 1 µs wire-to-wire Coarser second line: exchange-side per-member limits. Do not move the check off the order path to save latency.

Implementation notes

  • Order of checks: cheap stateless checks first, reservation last and only after all checks pass, so a reject never leaves partial state.
  • Single writer: the risk state for an account belongs to one thread, the same one that sends orders for it; execution reports are routed to that thread. Shared risk across gateways needs either one gateway per account or a central sequencer.
  • Units: prices as integer ticks or minor units (money representation); notional in i128; convert contract multipliers and FX into the limit currency with integer rates.
  • Reference price: refresh from market data; mark it stale after a timeout and fall back to rejecting or a static band. Opening auctions and halts need explicit handling.
  • Duplicate-order check: reject the same symbol/side/qty/px within a short window, a common fat-finger guard (double click, retry loop).
  • Kill switch: a flag checked first; flipping it also sends mass cancels (FIX OrderMassCancelRequest or exchange-specific cancel-on-disconnect).
  • Exchange-side limits: many venues offer their own per-member limits; use them as a second line, not as the only one.

Pitfalls

  • Counting exposure only from fills: an algo can send 1,000 orders before the first fill arrives.
  • Releasing reservations at the fill price instead of the reserved limit price: open_notional drifts negative or positive over the day.
  • Losing execution reports (disconnect, gap) leaves reservations stuck; reconcile open orders with the venue on reconnect.
  • Using floats for collars: 0.1 % of a price becomes a rounding-dependent pass/fail.
  • A missing reference price treated as "no collar".
  • Knight Capital, 2012: a faulty deployment sent millions of unintended orders in about 45 minutes; no effective aggregate exposure limit or kill switch stopped it.

Variants and alternatives

FPGA / smart-NIC risk gatewayssame checks in hardware inline with the order path.
Exchange pre-trade credit controls (per member, per clearing firm)coarser, but independent of the client's software.
Margin-based checksreplace position limits with incremental margin from a risk model; heavier, usually not in the microsecond path.

In production

Broker market-access gateways (15c3-5), exchange member risk controls, and every algorithmic trading system's order manager.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
NautilusTraderRust core, Python APInautechsystems/nautilus_traderpip install nautilus_trader————built-in risk engine in front of order submission

Sources

  • SEC, Risk Management Controls for Brokers or Dealers with Market Access, Rule 15c3-5, 17 CFR 240.15c3-5, 2010. rule
  • Commission Delegated Regulation (EU) 2017/589 (RTS 6), Articles 12 (kill functionality) and 15 (pre-trade controls). text
  • SEC, In the Matter of Knight Capital Americas LLC, SEC order, 2013.

Builds on

Used by

market making inventory model · plannedQuote skew and spread from inventory risk (Avellaneda-Stoikov style) for continuous two-sided quoting

Systems (12):

Alternatives and related