All systems operational--:--:-- UTC

Pre-trade risk checks in under a millisecond

Every order on ConsciousLab passes six risk checks before it routes. Here is how they stay fast enough that traders never notice.

Jonas Ehrlich

Co-founder, CTO

Published
Reading time
4 min read

Before I co-founded ConsciousLab I spent nine years building matching and risk infrastructure at an exchange. The lesson I took from that job is that risk checks are only respected if they are fast. A check that adds 50 milliseconds will be bypassed, quietly, by someone with a deadline. A check that adds 300 microseconds will be left alone.

When we built the Trading module, every order had to pass pre-trade checks, on paper and live accounts alike, with no flag to turn them off. This post describes how those checks work and how we keep them under a millisecond at p99.

The six checks

Every order, whether placed in the terminal, by API, over FIX or by a strategy, runs these checks in order. The first failure rejects the order with a reason the trader can read.

  1. Buying power. Settled cash plus available margin, minus the reserved value of all working orders, must cover the order's notional plus estimated fees.
  2. Position limit. The resulting position must stay within the per-symbol and per-sector limits, expressed as a share of account equity.
  3. Daily loss limit. If realised plus unrealised loss for the day has crossed the limit, only orders that reduce exposure are accepted.
  4. Price band. Limit prices more than 2% from the last trade, or 5% for crypto, require an explicit confirmation flag on the order. Market orders are converted to marketable limits at the band edge.
  5. Order rate. No more than 20 orders per second per account. A runaway loop in a script is the most common cause of trading losses we see.
  6. Kill switch. If an admin has engaged the account's kill switch, nothing new is accepted.

Keep the state next to the check

The slow way to check buying power is to query a database for cash, positions and working orders on every order. That is three round trips, and it races with fills arriving at the same time.

Instead, each account is owned by exactly one risk worker, a single-threaded process that holds the account's full state in memory: cash, positions, working orders, today's P&L and the limits. All order requests and all fill events for that account are routed to its worker through one queue. Because only one thread ever touches the account, there are no locks and no races. A fill and an order can never be processed at the same time.

Accounts are distributed across workers by consistent hashing. A worker typically owns a few thousand accounts. State is persisted as an event log and snapshotted every 10,000 events, so a worker that restarts recovers its accounts in under two seconds.

Prices without waiting

Several checks need a current price. Risk workers subscribe to our internal market data bus and keep the last trade and top of book for every symbol in a shared-memory table. Reading a price is a memory read, about 80 nanoseconds. If a symbol's price is older than its staleness limit, 5 seconds for equities during regular hours, the price band check fails closed and the order is rejected with "Price unavailable". We would rather reject an order than approve it against a stale price.

Measuring it

We measure risk latency from the moment an order request is dequeued by the worker to the moment the accept or reject decision is written. Over the last 30 days across all accounts:

PercentileLatency
p5038 µs
p99212 µs
p99.9640 µs
max2.8 ms

The maximum is always a worker resuming from a garbage collection pause or a snapshot write. We moved snapshotting off the hot thread last quarter, which brought p99.9 down from 1.9 ms.

End-to-end, from an order arriving at our New Jersey edge to acknowledgement, the median is 4.1 ms. Risk is less than 1% of that. Most of the rest is network and the broker's own checks, which we cannot skip and would not want to.

Explaining a rejection

A rejected order is frustrating. A rejected order with a vague message is infuriating. Every rejection carries the check that failed, the values it compared and what would make the order pass:

{
  "status": "rejected",
  "check": "position_limit",
  "detail": "Resulting NVDA position 24.6% of equity exceeds the per-symbol limit of 20.0%.",
  "max_qty": 1840
}

The terminal uses max_qty to offer a one-click resize. Support tickets about rejected orders fell by two thirds after we shipped detailed reasons.

Paper accounts too

Paper accounts run the same risk workers with the same checks. This is deliberate. A strategy that only works when risk checks are off will not work live, and the first time you find out should not be with real money. The paper trading guide describes how simulated fills interact with the checks.

The checks are boring, which is the point. Traders should only notice them when they are about to make a mistake.