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.tsis called fromorder-router/src/main.ts:541before the gRPC call). - Post-trade is reactive:
risk-servicesubscribes totrades.executed(services/risk-service/src/main.ts:88) andmargin.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 validation — risk-checker.ts:173-183.
- 🔴 Not in the REST DTO yet — api-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¶
- 02 — Trading system — order lifecycle, where the risk checker sits
- 03 — Ledger & accounting — what happens after a fill clears the gate
- 05 — Services reference — per-service deep dive incl.
risk-service - 10 — Planned modules — withdrawal risk, KYC/sanctions
docs/milestone-1-status.md— manual-override status, slippage PR statusdocs/architecture.md— original delivery-plan risk-control diagram
Open questions for the platform team¶
- When
maxSlippageBpsis set, should the pre-trade gate also reject ifworstCasePricedeviates more thanmaxSlippageBps/10000? Currently the field is only validated against the per-market ceiling — the order-level cap is informational until engine enforcement lands. - Should
lastPricesbe persisted (Redis) to surviveorder-routerrestarts? Cold-start currently breaks market orders. - Should there be a configurable per-symbol override for
maxPriceDeviationthat admins can hot-update without a deploy? Currently it requires a code change. - Is the
marginEnabled: falseonBTC-USDT/ETH-USDTintentional?