Skip to content

04 — Risk controls

As of 2026-05-28. Pre-trade risk is on the slippage-guard branch (PR#4) — the canonical post-merge shape.

TL;DR

Two services share the word "risk" and they do completely different things:

Service When it runs Authority
order-router/src/risk-checker.ts Pre-trade — before the order reaches the engine Rejects synchronously; the engine never sees a bad order
risk-service/src/main.ts Post-trade — reacts to position updates & mark-price moves Margin / liquidation prep for M8; not in the M1 hot path

If a check isn't enforced by one of these two services, it is not enforced anywhere — there is no per-user position limit, no daily-loss circuit-breaker, no STP, no cross-pair correlation check. The matching engine has its own internal balance check as the ultimate authority for "do you have enough", but that's it.

1. Pre-trade vs post-trade — the architectural divide

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
graph LR
    Client --> APIGW[api-gateway]
    APIGW -- "orders.submit" --> OR[order-router]
    OR -- "RiskChecker.check()" --> RC{Pass?}
    RC -- "no" --> Reject[risk.check.failed<br/>NATS]
    RC -- "yes" --> ME[exchange-core<br/>gRPC]
    ME -- "TradeEvent" --> OR
    OR -- "trades.executed" --> RS[risk-service<br/>post-trade]
    RS -- "margin.liquidation.warning<br/>commands.order.cancel" --> OR
    ME -- "balance check (in-engine)" --> ME
  • Pre-trade is a synchronous gate (risk-checker.ts is called from order-router/src/main.ts:541 before the gRPC call).
  • Post-trade is reactive: risk-service subscribes to trades.executed (services/risk-service/src/main.ts:88) and margin.position.updated (risk-service/src/main.ts:55), then publishes warnings or cancel commands.

The two never call each other directly.

2. Pre-trade checks (risk-checker.ts) in execution order

RiskChecker.check() (services/order-router/src/risk-checker.ts:106-230) runs the following gates in order; the first failure short-circuits and returns {passed: false, reason}:

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
flowchart TD
    A[check called] --> B[Market exists?<br/>line 109]
    B -- no --> R1[reject: Market not found]
    B -- yes --> C[Stop order?<br/>line 113]
    C -- yes --> R2[reject: Stop orders not yet supported]
    C -- no --> D[Quantity positive?<br/>line 119]
    D -- no --> R3[reject: Quantity must be positive]
    D -- yes --> E[Quantity >= min?<br/>line 123]
    E -- no --> R4[reject: Quantity below min]
    E -- yes --> F[Quantity <= max?<br/>line 127]
    F -- no --> R5[reject: Quantity above max]
    F -- yes --> G{order.type}
    G -- limit --> H[Notional >= min?<br/>line 138]
    H -- no --> R6[reject: Order value below min]
    H -- yes --> I[Fat-finger deviation<br/>line 142-154]
    I -- fail --> R7[reject: Price deviates X%]
    I -- pass --> J
    G -- market --> K["Last price known?<br/>line 165-170 [PR#4]"]
    K -- no --> R8[reject: no recent trade price]
    K -- yes --> L["maxSlippageBps OK?<br/>line 173-183 [PR#4]"]
    L -- fail --> R9[reject: bad maxSlippageBps]
    L -- pass --> M["Worst-case notional?<br/>line 185-196 [PR#4]"]
    M -- fail --> R10[reject: implied notional too low]
    M -- pass --> J
    J[Margin gating<br/>line 200-202] -- not enabled --> R11[reject: Margin not enabled]
    J -- ok --> N[Balance check fail-open<br/>line 205-227]
    N -- definite insufficient --> R12[reject: Insufficient balance]
    N -- ok or unknown --> P[passed: true]

Detail per gate:

2a. Market existence — risk-checker.ts:107-111

marketConfigs is a hardcoded Map of 12 entries (see §4). Symbols not in this map are rejected with Market <X> not found. The map is not loaded from the engine — it can drift. Documented as a follow-up.

2b. Stop-order rejection — risk-checker.ts:113-116

if (order.type === 'stop_limit' || order.type === 'stop_market') {
  return { passed: false, reason: 'Stop orders are not yet supported' };
}

Schema-accepted but always rejected. See 02-trading-system.md §2.

2c. Quantity bounds — risk-checker.ts:119-129

  • isPositive(order.quantity) — line 119
  • >= config.minQuantity — line 123
  • <= config.maxQuantity — line 127

Bounds vary per market (e.g. BTC max 100, SOL-USD max 100,000 — see §4).

2d. Notional minimum (limit orders only) — risk-checker.ts:131-140

const notional = multiply(order.quantity, order.price);
if (isLessThan(notional, config.minNotional)) {
  return { passed: false, reason: `Order value below minimum: ${config.minNotional}` };
}

Stops dust orders and keeps fee revenue meaningful. Skipped for market orders here — covered by the slippage-guard's worst-case projection (2g).

2e. Fat-finger deviation (limit orders only) — risk-checker.ts:142-154

const deviation = Math.abs(
  (parseFloat(order.price) - parseFloat(lastPrice)) / parseFloat(lastPrice) * 100
);
if (deviation > config.maxPriceDeviation) {
  return { passed: false, reason: `Price deviates ${deviation.toFixed(1)}% from last trade ...` };
}

Per-market threshold (maxPriceDeviation). Falls through (no check) if lastPrices has no entry for the symbol — i.e. fresh market with no trades yet, the limit-order fat-finger guard is inactive.

2f. Market-order slippage guard [PR#4] — risk-checker.ts:157-197

This is the new block landed on the fix/slippage-guard branch. Three sub-checks:

i. Reference price required (line 165-171). Market orders are rejected outright if lastPrices.get(symbol) is missing or <= 0. Rationale (comment at line 158-159): "an unbounded market order can sweep the book". You cannot place a market order on a never-traded market.

ii. maxSlippageBps field validation (line 173-183). If the caller supplied maxSlippageBps on NewOrderRequest: - Must be a finite, non-negative number (line 174-176). - Must be <= config.maxSlippageBps (the per-market ceiling — see §4). E.g. on BTC-USD the ceiling is 500 bps (5%); a request with maxSlippageBps: 600 is rejected with maxSlippageBps 600 exceeds market ceiling 500 (5.00%).

iii. Implied worst-case notional (line 185-196).

const ref = parseFloat(lastPrice);
const worstCasePrice = order.side === 'buy'
  ? ref * (1 + config.maxPriceDeviation / 100)
  : ref * (1 - config.maxPriceDeviation / 100);
const impliedNotional = multiply(order.quantity, worstCasePrice.toString());
if (isLessThan(impliedNotional, config.minNotional)) {
  return { passed: false, reason: `Market order implied value below minimum ...` };
}

A buy market projects lastPrice * (1 + maxPriceDeviation%) as the worst price it might fill at; a sell uses (1 - maxPriceDeviation%). If the resulting notional clears minNotional, accept; otherwise reject. This catches the case where a user submits a market order so small that even with adverse slippage it would never clear the minimum economic size.

⚠️ Important caveat. The slippage cap itself is not enforced at fill time by this layer. The pre-trade gate only validates that the cap is reasonable; the engine doesn't know about maxSlippageBps and will happily walk the book. The block comment at risk-checker.ts:163 calls this out:

Fill-time enforcement of the slippage cap belongs in the matching engine and is a follow-up.

This is the principal weakness of [PR#4] — it stops some griefing (empty market, fat-finger market order) but does not bound actual realised slippage on a thin book.

2g. Margin gating — risk-checker.ts:199-202

if (order.marginMode && !config.marginEnabled) {
  return { passed: false, reason: `Margin trading not enabled for ${order.symbol}` };
}

Only 4 markets have marginEnabled: true currently — see §4.

2h. Balance fail-open — risk-checker.ts:204-227

For non-margin orders, the checker calls getBalance (line 87-104) which sends a NATS request ledger.balances.get to ledger-service (5s timeout):

// risk-checker.ts:87-104
private async getBalance(userId: string, asset: string): Promise<string | null> {
  try {
    const publisher = getPublisher();
    const response = await publisher.request(...);
    return balance?.available || '0';
  } catch (error) {
    logger.warn('Failed to fetch balance from ledger service (fail-open)', ...);
    return null;  // <-- fail-open
  }
}

If ledger is unreachable, getBalance returns null and the check is skipped (line 209: if (balance !== null)). Rationale: the matching engine has its own balance check as the ultimate authority (line 101 comment). The pre-trade gate is best-effort.

Rejection conditions when balance is known (line 209-227): - Buy limit: balance < quantity * price → reject with Insufficient <quoteAsset>. - Sell (any type): balance < quantity → reject with Insufficient <baseAsset>. - Buy market: balance check is not run — the route falls through. The engine's reservePrice (set at main.ts:577 only for buy orders) handles this in the engine.

3. The lastPrices map

RiskChecker maintains a single Map<string, string> keyed by symbol (risk-checker.ts:73):

private lastPrices: Map<string, string> = new Map();

setLastPrice(symbol: string, price: string): void {
  this.lastPrices.set(symbol, price);
}

Populated by:

// services/order-router/src/main.ts:856 — inside handleTradeEvent
riskChecker.setLastPrice(event.symbol, externalPrice);

So every fill on the internal engine updates the reference price. No external feed seeds it today.

When the Binance read-only adapter (PR#6) is merged, the venue adapter will publish prices that could call setLastPrice — but the design intent is that venue prices never override internal price discovery. The shape will need to evolve to a tagged value like {price, source: 'internal' | 'binance'} so the checker can prefer internal trades when both are present.

🟡 Today the shape is string, not {price, source} — the {price, source} shape is the design target, not the merged state. Anyone wiring Binance prices into setLastPrice should change the map type at the same time.

Cold-start hazard. A freshly restarted order-router has an empty lastPrices map. Until the first trade event arrives, every market order is rejected (the "no recent trade price" branch from §2f-i) and limit orders skip the fat-finger check. For thin markets this matters.

4. Per-market configuration

Defined in services/order-router/src/risk-checker.ts:52-70 using two factory functions:

Factory: cryptoPair (line 23-36)

Defaults: minQuantity 0.0001, maxQuantity 100000, minNotional 1, maxPriceDeviation 10%, maxSlippageBps = maxPriceDeviation * 100. Overridable per-market.

Factory: stablePair (line 38-49)

Tighter: minQuantity 0.01, maxQuantity 10_000_000, maxPriceDeviation 2%, maxSlippageBps 200 (= 2%).

The 12 registered markets

Symbol maxQty minNotional maxPriceDeviation maxSlippageBps marginEnabled
BTC-USD 100 10 5% 500
ETH-USD 1,000 10 5% 500
SOL-USD 100,000 1 10% 1000
BTC-BRL 100 50 5% 500
ETH-BRL 1,000 50 5% 500
SOL-BRL 100,000 5 10% 1000
ETH-BTC 1,000 0.0001 5% 500
BTC-USDT 100 10 5% 500
ETH-USDT 1,000 10 5% 500
SOL-USDT 100,000 1 10% 1000
USDT-USD 10,000,000 1 2% 200
USDC-USD 10,000,000 1 2% 200

Note that the only markets configured for margin are the four USD/BRL crypto majors marked above. BTC-USDT and ETH-USDT have marginEnabled: false despite being natural margin candidates — anyone with a marginMode set on those will be rejected (risk-checker.ts:200-202).

5. The maxSlippageBps field

New optional field on NewOrderRequest added by [PR#4]:

// packages/types/src/index.ts:69-72
// Per-order slippage cap for market orders, in basis points.
// Pre-trade: validated against the per-market ceiling.
// Fill-time enforcement is a matching-engine follow-up.
maxSlippageBps?: number;

Surface area today: - 🟢 Pre-trade validationrisk-checker.ts:173-183. - 🔴 Not in the REST DTO yetapi-gateway/src/orders/orders.dto.ts (CreateOrderDto) does not expose it. Until that's added, external API clients can't actually set it; only internal NATS callers can. Tracked as a follow-up. - 🔴 Not enforced at fill time — the engine never sees it.

6. Margin / liquidation (M8 prep) — services/risk-service/

risk-service is scaffolded with real but not-yet-active margin engine logic. Status:

Component Status Cite
HTTP health server 🟢 services/risk-service/src/main.ts:18-30
NATS connect + subscribe 🟢 main.ts:40-46
MarginEngine.checkMarginRequirements 🟡 implemented, callable via risk.margin.check RPC margin-engine.ts:34-100
MarginEngine.calculateMarginLevel 🟡 implemented margin-engine.ts:172
LiquidationEngine.executeLiquidation 🟡 implemented but calls placeCloseOrder which itself depends on margin tables that have no test fixtures liquidation-engine.ts:37-100
Periodic margin monitor (5s) 🟢 wired in main loop main.ts:223-225
Periodic interest accrual (60s) 🟢 wired in main loop main.ts:228-230

Thresholds (hardcoded in margin-engine.ts:23-28): - MAINTENANCE_MARGIN_RATE = 0.05 (5%) - INITIAL_MARGIN_RATE = 0.10 (10%) - MAX_LEVERAGE = 10 - WARNING_THRESHOLD = 1.5 (150% margin level → emit margin.liquidation.warning) - LIQUIDATION_THRESHOLD = 1.05 (105% margin level → trigger liquidation)

Liquidation flow if triggered (liquidation-engine.ts:37-110): 1. Cancel all open orders for the symbol (commands.order.cancel). 2. Place market-IOC close order. 3. Compute liquidation fee (0.5%) + insurance-fund contribution (25% of fee). 4. Update DB tables: close marginPosition, liquidate any related marginLoan. 5. Publish margin.position.closed with reason: 'liquidation'.

🟡 Not exercised in production. There are no live margin positions and no integration tests gating M1. The plumbing is there for M8 (Margin & Derivatives) but the risk-service won't trip on M1 spot-only flow.

7. Kill-switch and operator overrides

Capability Status Mechanism
Force-cancel a user order 🟢 AdminService.CancelOrderAdmin (gRPC-web). Wired in admin-panel (admin-panel#2). Modal-gated UI; surfaces engine errors inline. See 02-trading-system.md §4b.
Force-cancel all of a user's orders 🟡 No dedicated RPC. Admin must iterate per-order. api-gateway has DELETE /orders (cancelAllOrders, orders.service.ts:199) but it's a user endpoint — uses the user's auth context, not operator override.
Halt a market 🔴 AdminService.HaltMarket does not exist in services/order-router/src/proto/exchange.proto (confirmed: grep -n HaltMarket → no match). Needs a proto change + Java AdminServiceImpl change before the admin panel can wire a button.
Pause / resume symbol 🔴 Same as above — no PauseSymbol / ResumeSymbol RPC.
Liquidation kill-switch 🟡 risk-service can be SIGTERMed; margin monitor stops in 5s. No "stop liquidating but keep matching" toggle.

The principal operator-facing gap is halt-market. Until the proto is extended, the only mitigation for a runaway market is force-cancel-all or shut down the matching engine entirely.

8. What is not enforced anywhere

These are the risks that no service guards against today. Listed because reviewers reliably ask for them:

Risk Current state
Per-user position limit 🔴 Not enforced. No max_open_position_quote_value per user. The matching engine enforces only balance sufficiency.
Per-user daily loss limit 🔴 Not tracked. No circuit-breaker.
Cross-pair correlation limit 🔴 Not tracked. No notional cap across BTC pairs etc.
Self-trade prevention (STP) 🔴 The forked exchange-core2 engine supports STP modes (CANCEL_NEWEST / CANCEL_OLDEST / CANCEL_BOTH) — but the platform doesn't configure any STP mode on order submission. The gRPC PlaceOrderRequest (proto/exchange.proto:52-61) has no STP field. Users can self-trade today.
Withdrawal risk checks Out of scope here — handled custody-side (BitGo policies + withdrawals workflow). See 10-planned-modules.md.
Sanctions / OFAC screening 🔴 Order-time. Done at KYC onboarding only, not on every order.
Wash-trade detection 🔴 Not implemented.
API-key per-symbol allowlist 🔴 No granular API-key permissions.

For an institutional-grade exchange these are eventually mandatory. They're tracked under M8 (margin/derivatives — position limits) and M9 (institutional liquidity programmes — STP) in the contract milestones.

9. Cross-references

Open questions for the platform team

  1. When maxSlippageBps is set, should the pre-trade gate also reject if worstCasePrice deviates more than maxSlippageBps/10000? Currently the field is only validated against the per-market ceiling — the order-level cap is informational until engine enforcement lands.
  2. Should lastPrices be persisted (Redis) to survive order-router restarts? Cold-start currently breaks market orders.
  3. Should there be a configurable per-symbol override for maxPriceDeviation that admins can hot-update without a deploy? Currently it requires a code change.
  4. Is the marginEnabled: false on BTC-USDT / ETH-USDT intentional?