Skip to content
ZYNQORALABS

Pre-trade risk checks: designing the gate on the order path

How to design risk controls that run before every order: which checks to include, in what order, how to keep state without locks, and why to fail closed.

By ZynqoraLabs · · 4 min read

ShareLinkedInXWhatsApp

Risk controls that run after execution only measure the damage. In trading systems, the controls that actually protect the operation sit before the order goes out: on the path between the strategy (or the trader) and the venue. Regulators recognize this. In the United States, for example, the SEC's Market Access Rule requires broker-dealers with market access to maintain pre-trade controls. And regardless of regulation, it's the last line of defense against code bugs, bad data and fast fingers.

This article covers how to design that gate so it's correct, predictable and auditable.

Which checks to include

The set varies by market and institution, but a core shows up in almost every system:

  • Kill switch: immediately blocks new orders per account, strategy or the whole system.
  • Maximum order size: in quantity and in notional value.
  • Price band: rejects orders priced too far from a recent reference, catching typos and unit errors.
  • Position and exposure limit: the projected position, if the order fills, must stay within the limit.
  • Rate limit: a maximum number of orders per interval, to contain runaway loops.
  • Duplicate detection: the same order sent twice by a retry must not execute twice.
  • Restricted list: instruments the account isn't allowed to trade.

Order of checks

Run checks from cheapest to most expensive and stop at the first failure. The kill switch is a boolean; an exposure calculation may span several instruments. Since most rejections happen on the cheap rules, the common path stays fast and latency stays predictable.

Each check should be a pure function: it takes the order, the account state and the market view, and returns approval or a rejection reason. Pure functions are easy to test exhaustively and to reproduce.

checks.rs
fn within_price_band(price: Decimal, reference: Decimal, band_bps: u32) -> bool {
    // |price - reference| / reference <= band
    let deviation = (price - reference).abs() / reference;
    deviation <= Decimal::from(band_bps) / Decimal::from(10_000)
}
 
fn is_duplicate(order: &Order, recent: &RecentOrders) -> bool {
    // client_order_id is unique per session; a retry reuses it.
    recent.contains(&(order.session, order.client_order_id))
}

Note the decimal types. Prices and money in binary floating point produce rounding errors that, in a limit comparison, turn into wrong approvals or rejections.

Lock-free state: one writer per account

Position limits depend on state: open positions, pending orders, consumed limits. If several threads update that state behind locks, latency starts depending on contention, and processing order stops being deterministic.

A robust alternative is to shard accounts and have a single writer per shard. Every order for an account goes through the same thread, in sequence. There are no locks, latency stays stable and, for the same input events, decisions are always the same.

One important detail: the limit must account for pending orders, not just filled positions. Otherwise several orders sent in quick succession each pass individually and together breach the limit.

Fail closed

What should happen when reference data is stale, the position service connection is down, or state is inconsistent? The safe answer is to reject. A price band computed on a five-minute-old price approves exactly the orders it should block.

That requires every input to carry its timestamp and the system to know the maximum acceptable age for each. Failing closed has an operational cost, so rejection reasons must be clear and monitored: a wave of "stale data" rejections is an infrastructure alert, not a risk problem.

Audit and replay

Every decision must be explainable later: "why was this order accepted at 10:31:07?". The most reliable way to answer is to record every input event (orders, fills, reference prices, limit changes) in an append-only log and derive risk state from it.

With deterministic processing, replaying the log recreates exactly the same decisions. That serves audits, incident investigations and testing: a regression test can reprocess a full day of events and verify that no decision changed.

How to test

Beyond per-rule unit tests, three kinds of tests build real confidence:

  1. Property tests: for any randomly generated sequence of orders, the projected position never exceeds the limit.
  2. Deterministic replay: the same log, processed twice, yields identical decisions.
  3. Counterparty simulator: an exchange simulator lets you test rejections, partial fills and disconnections before any real connection.

Summary

A well-designed pre-trade risk gate is simple on the outside and rigorous inside: pure checks ordered by cost, one writer per account, decimal types, failing closed under uncertainty and a log that can rebuild any decision. It doesn't decide what to trade. It makes sure whatever is traded stays within the rules.