Repository Public
1/**2 * Risk policy engine (docs/v4/DERIVATIVES-ARCHITECTURE.md, "Risk policy engine"). Deterministic and pure: no clock, no network,3 * no state. `checkOrder` answers one question — may this order be sent under this policy, given this context? — and names the4 * first rule that says no. Circuit breakers (`policy.halt`) are inputs, never side effects.5 *6 * Order of the checks (each has a stable code):7 * 0 inputs well formed bad_policy · bad_order · bad_input8 * 1 mode allows trading; circuit breaker mode_read_only · halted (the halt and the order-rate limits do not block a reduce-only order that reduces)9 * 2 instrument allowed and active instrument_mismatch · instrument_not_allowed · instrument_halted10 * 3 data age (ticker.ts, book.ts ≤ maxDataAgeMs) data_mismatch · stale_ticker · stale_book11 * 4 price sanity price_required · trigger_required · bad_mark · price_deviation · mark_index_divergence12 * 5 tick / lot / minimum size price_not_on_tick · size_not_on_lot · below_min_size · below_min_notional13 * 5b reduce-only really reduces reduce_only_would_increase · reduce_only_would_flip · instrument_reduce_only14 * --- a reduce-only order that reduces skips 6–13 (closing must always be possible) ---15 * 6 leverage after the order ≤ maxLeverage leverage_exceeded16 * 7 notional of the order ≤ maxOrderNotional order_notional_exceeded17 * 8 resulting gross exposure ≤ maxGrossExposure gross_exposure_exceeded18 * 9 resulting position ≤ maxPositionNotional position_notional_exceeded19 * 10 free collateral after initial margin insufficient_collateral20 * 11 estimated liquidation distance liquidation_too_close21 * 12 daily loss daily_loss_exceeded22 * 13 total loss total_loss_exceeded23 * 14 order rate order_rate_minute · order_rate_day24 * 15 duplicate clientId duplicate_client_id25 *26 * Valuation rules (all exact, comparisons are done by cross-multiplication, never by a rounded division):27 * - positions are valued at their mark (`ticker.mark` for the order's instrument, `position.markPrice` for the others);28 * - the ORDER is valued at the highest of the mark, its price and its trigger price (cautious upper bound of its notional);29 * - resting open orders are NOT counted in exposure (only positions and the order under review are);30 * - fees and funding accrual are ignored.31 */32import { z } from 'zod';33import { D, type Dec, type DecStr } from './decimal.ts';34import { initialMargin, liquidationPriceCross, liquidationPriceIsolated, signedSize } from './margin.ts';35import { DecStrSchema, OrderRequestSchema, type AccountState, type Book, type Instrument, type Order, type OrderRequest, type Position, type Ticker } from './models.ts';3637export const ACCOUNT_MODES = ['READ_ONLY', 'PAPER', 'MANUAL_LIVE', 'MANDATE_LIVE'] as const;38export const AccountModeSchema = z.enum(ACCOUNT_MODES);39export type AccountMode = z.infer<typeof AccountModeSchema>;4041const NonNegDec = DecStrSchema.refine((x) => !x.startsWith('-'), 'must not be negative');42const PosDec = NonNegDec.refine((x) => /[1-9]/.test(x), 'must be positive');4344export const RiskPolicySchema = z.object({45 mode: AccountModeSchema,46 /** instrument ids (`${venue}:${symbol}`) the account may trade; an instrument that is not listed is refused */47 allowedInstruments: z.array(z.string().min(1)),48 /** circuit breaker: a non-null reason refuses every order */49 halt: z.string().nullable(),50 maxDataAgeMs: z.number().int().nonnegative(),51 /** limit / worst price vs mark (vs trigger price for trigger orders), in basis points */52 maxDeviationBps: NonNegDec,53 /** mark vs index, in basis points (skipped when the venue publishes no index) */54 maxMarkIndexBps: NonNegDec,55 /** account leverage after the order: gross exposure / equity */56 maxLeverage: PosDec,57 maxOrderNotional: NonNegDec,58 maxGrossExposure: NonNegDec,59 maxPositionNotional: NonNegDec,60 minFreeCollateral: NonNegDec,61 minLiquidationDistanceBps: NonNegDec,62 /** positive amounts: an order is refused once the loss reaches them */63 maxDailyLoss: NonNegDec,64 maxTotalLoss: NonNegDec,65 maxOrdersPerMinute: z.number().int().nonnegative(),66 maxOrdersPerDay: z.number().int().nonnegative(),67});68export type RiskPolicy = z.infer<typeof RiskPolicySchema>;6970export interface RiskUsage {71 /** PnL realised since the start of the day (negative = loss), fees and funding included by the caller */72 realizedToday: DecStr;73 /** PnL realised since the account was opened (negative = loss) */74 realizedTotal: DecStr;75 /** orders already sent in the last minute / today, NOT counting the one under review */76 ordersLastMinute: number;77 ordersToday: number;78 /** client ids already used on this account (open orders are checked as well) */79 clientIds?: readonly string[];80}8182export interface RiskContext {83 instrument: Instrument;84 account: AccountState;85 positions: readonly Position[];86 openOrders: readonly Order[];87 book: Book;88 ticker: Ticker;89 /** the caller's clock, ms */90 now: number;91 usage: RiskUsage;92 /** leverage setting of this instrument on the venue; default: the existing position's, else min(instrument.maxLeverage, policy.maxLeverage) */93 leverage?: DecStr;94 /** maintenance margin fraction of this instrument; default: 1 / (2 · instrument.maxLeverage) */95 maintenanceMarginFraction?: DecStr;96}9798export const RISK_CODES = [99 'bad_policy', 'bad_order', 'bad_input',100 'mode_read_only', 'halted',101 'instrument_mismatch', 'instrument_not_allowed', 'instrument_halted', 'instrument_reduce_only',102 'data_mismatch', 'stale_ticker', 'stale_book',103 'price_required', 'trigger_required', 'bad_mark', 'price_deviation', 'mark_index_divergence',104 'price_not_on_tick', 'size_not_on_lot', 'below_min_size', 'below_min_notional',105 'reduce_only_would_increase', 'reduce_only_would_flip',106 'leverage_exceeded', 'order_notional_exceeded', 'gross_exposure_exceeded', 'position_notional_exceeded',107 'insufficient_collateral', 'liquidation_too_close',108 'daily_loss_exceeded', 'total_loss_exceeded',109 'order_rate_minute', 'order_rate_day', 'duplicate_client_id',110] as const;111export type RiskCode = (typeof RISK_CODES)[number];112export type RiskResult = { ok: true } | { ok: false; code: RiskCode; reason: string };113114const BPS = D.from(10000n);115const TWO = D.from(2n);116const no = (code: RiskCode, reason: string): RiskResult => ({ ok: false, code, reason });117const isTrigger = (t: OrderRequest['type']): boolean => t !== 'limit' && t !== 'market';118119/** |a − ref| / ref > bps / 10000, without dividing: |a − ref| · 10000 > bps · ref (ref > 0) */120function deviates(a: Dec, ref: Dec, bps: Dec): boolean {121 return D.gt(D.mul(D.abs(D.sub(a, ref)), BPS), D.mul(bps, ref));122}123124export function checkOrder(policy: RiskPolicy, req: OrderRequest, ctx: RiskContext): RiskResult {125 const p = RiskPolicySchema.safeParse(policy);126 if (!p.success) return no('bad_policy', `invalid risk policy: ${p.error.issues[0]?.path.join('.') ?? ''} ${p.error.issues[0]?.message ?? ''}`.trim());127 const r = OrderRequestSchema.safeParse(req);128 if (!r.success) return no('bad_order', `invalid order: ${r.error.issues[0]?.path.join('.') ?? ''} ${r.error.issues[0]?.message ?? ''}`.trim());129 try {130 return run(p.data, r.data, ctx);131 } catch (e) {132 // fail closed: a context the engine cannot read is a refusal, never an acceptance133 return no('bad_input', `risk context could not be evaluated: ${e instanceof Error ? e.message : String(e)}`);134 }135}136137function run(policy: RiskPolicy, req: OrderRequest, ctx: RiskContext): RiskResult {138 const { instrument, ticker, book, account, usage } = ctx;139140 // 1 — mode and circuit breaker141 if (policy.mode === 'READ_ONLY') return no('mode_read_only', 'the account is read-only: no order may be sent');142 if (policy.halt !== null && !req.reduceOnly) return no('halted', `trading is halted: ${policy.halt}`);143144 // 2 — instrument145 if (req.instrument !== instrument.id) return no('instrument_mismatch', `order is for ${req.instrument}, context is for ${instrument.id}`);146 if (!policy.allowedInstruments.includes(instrument.id)) return no('instrument_not_allowed', `${instrument.id} is not in the allowed instruments`);147 if (instrument.status === 'halted') return no('instrument_halted', `${instrument.id} is halted on the venue`);148149 // 3 — data age150 if (ticker.instrument !== instrument.id || book.instrument !== instrument.id) return no('data_mismatch', 'ticker or book is for another instrument');151 if (!Number.isFinite(ctx.now)) return no('bad_input', 'now is not a finite timestamp');152 if (!Number.isFinite(ticker.ts) || Math.abs(ctx.now - ticker.ts) > policy.maxDataAgeMs) return no('stale_ticker', `ticker is ${ctx.now - ticker.ts} ms old (limit ${policy.maxDataAgeMs})`);153 if (!Number.isFinite(book.ts) || Math.abs(ctx.now - book.ts) > policy.maxDataAgeMs) return no('stale_book', `book is ${ctx.now - book.ts} ms old (limit ${policy.maxDataAgeMs})`);154155 // 4 — price sanity156 const size = D.from(req.size);157 if (!D.isPos(size)) return no('bad_order', 'size must be positive');158 if (req.price === null) return no('price_required', 'a price is required: limit price, or worst acceptable price for market orders');159 const price = D.from(req.price);160 if (!D.isPos(price)) return no('bad_order', 'price must be positive');161 const trigger = req.triggerPrice === null ? null : D.from(req.triggerPrice);162 if (isTrigger(req.type)) {163 if (trigger === null) return no('trigger_required', `${req.type} needs a trigger price`);164 if (!D.isPos(trigger)) return no('bad_order', 'trigger price must be positive');165 } else if (trigger !== null) return no('bad_order', `${req.type} takes no trigger price`);166 const mark = D.from(ticker.mark);167 if (!D.isPos(mark)) return no('bad_mark', 'mark price is not positive');168 const maxDev = D.from(policy.maxDeviationBps);169 // a trigger order executes around its trigger price, so its limit/worst price is compared with the trigger, not with the mark170 const ref = trigger ?? mark;171 if (deviates(price, ref, maxDev)) return no('price_deviation', `price ${req.price} is more than ${policy.maxDeviationBps} bps away from ${trigger ? 'the trigger price' : 'the mark'} ${D.str(ref)}`);172 if (ticker.index !== null) {173 const index = D.from(ticker.index);174 if (!D.isPos(index)) return no('bad_mark', 'index price is not positive');175 if (deviates(mark, index, D.from(policy.maxMarkIndexBps))) return no('mark_index_divergence', `mark ${ticker.mark} is more than ${policy.maxMarkIndexBps} bps away from the index ${ticker.index}`);176 }177178 // position on this instrument, and what the order does to it179 const pos = ctx.positions.find((x) => x.instrument === instrument.id) ?? null;180 const before = pos ? signedSize({ side: pos.side, size: D.from(pos.size) }) : D.zero;181 const after = req.side === 'buy' ? D.add(before, size) : D.sub(before, size);182 const opposite = !D.isZero(before) && (D.isPos(before) ? req.side === 'sell' : req.side === 'buy');183 const reduces = opposite && D.lte(size, D.abs(before)); // strictly inside the position, or closes it184 const closesAll = opposite && D.eq(size, D.abs(before));185186 // 5 — tick, lot, minimum size187 const tick = D.from(instrument.tickSize), lot = D.from(instrument.lotSize);188 if (!D.isMultiple(price, tick)) return no('price_not_on_tick', `price ${req.price} is not a multiple of the tick ${instrument.tickSize}`);189 if (trigger !== null && !D.isMultiple(trigger, tick)) return no('price_not_on_tick', `trigger price ${req.triggerPrice} is not a multiple of the tick ${instrument.tickSize}`);190 if (!D.isMultiple(size, lot)) return no('size_not_on_lot', `size ${req.size} is not a multiple of the lot ${instrument.lotSize}`);191 // the order is valued at the highest of mark, price and trigger: an upper bound of what it can cost192 const valuePrice = D.max(D.max(mark, price), trigger ?? mark);193 const orderNotional = D.mul(size, valuePrice);194 // a reduce-only order that closes the whole position may be smaller than the minimums (dust must stay closable)195 if (!(req.reduceOnly && closesAll)) {196 if (instrument.minOrderSize !== null && D.lt(size, D.from(instrument.minOrderSize))) return no('below_min_size', `size ${req.size} is below the minimum ${instrument.minOrderSize}`);197 if (instrument.minNotional !== null && D.lt(D.mul(size, price), D.from(instrument.minNotional))) return no('below_min_notional', `notional ${D.str(D.mul(size, price))} is below the minimum ${instrument.minNotional}`);198 }199200 // 5b — reduce-only must really reduce201 if (req.reduceOnly && !reduces) {202 if (!opposite) return no('reduce_only_would_increase', 'reduce-only order would open or increase the position');203 return no('reduce_only_would_flip', `reduce-only order of ${req.size} is larger than the position ${pos!.size}: it would flip it`);204 }205 if (instrument.status === 'reduce_only' && !reduces) return no('instrument_reduce_only', `${instrument.id} only accepts orders that reduce a position`);206 const exempt = req.reduceOnly && reduces;207 // a halt stops new risk, not the way out: closing a position stays possible (the brief separates the two)208 if (policy.halt !== null && !exempt) return no('halted', `trading is halted: ${policy.halt}`);209210 if (!exempt) {211 const equity = D.from(account.equity);212 const afterNotional = D.mul(D.abs(after), mark);213 let gross = afterNotional;214 for (const x of ctx.positions) if (x.instrument !== instrument.id) gross = D.add(gross, D.mul(D.abs(D.from(x.size)), D.from(x.markPrice)));215216 // 6 — leverage after the order: gross / equity ≤ maxLeverage ⇔ gross ≤ maxLeverage · equity (equity ≤ 0 refuses any exposure)217 if (D.gt(gross, D.mul(D.from(policy.maxLeverage), equity)) && D.isPos(gross)) return no('leverage_exceeded', `gross exposure ${D.str(gross)} on equity ${account.equity} exceeds ${policy.maxLeverage}x`);218 // 7 — notional of the order219 if (D.gt(orderNotional, D.from(policy.maxOrderNotional))) return no('order_notional_exceeded', `order notional ${D.str(orderNotional)} exceeds ${policy.maxOrderNotional}`);220 // 8 — gross exposure221 if (D.gt(gross, D.from(policy.maxGrossExposure))) return no('gross_exposure_exceeded', `resulting gross exposure ${D.str(gross)} exceeds ${policy.maxGrossExposure}`);222 // 9 — position per instrument223 if (D.gt(afterNotional, D.from(policy.maxPositionNotional))) return no('position_notional_exceeded', `resulting position ${D.str(afterNotional)} on ${instrument.id} exceeds ${policy.maxPositionNotional}`);224225 // 10 — free collateral after the initial margin of the added size (valued at the order's value price)226 const instMaxLev = D.from(instrument.maxLeverage);227 const lev = ctx.leverage !== undefined ? D.from(ctx.leverage) : pos ? D.from(pos.leverage) : D.min(instMaxLev, D.from(policy.maxLeverage));228 if (!D.isPos(lev) || !D.isPos(instMaxLev)) return no('bad_input', 'leverage must be positive');229 const added = D.sub(D.abs(after), D.abs(before));230 const addedMargin = D.isPos(added) ? initialMargin(D.mul(added, valuePrice), lev) : D.zero;231 const freeAfter = D.sub(D.from(account.freeCollateral), addedMargin);232 if (D.lt(freeAfter, D.from(policy.minFreeCollateral))) return no('insufficient_collateral', `free collateral after initial margin would be ${D.str(freeAfter)}, below ${policy.minFreeCollateral}`);233234 // 11 — estimated liquidation distance of the resulting position, taken as if entered at the mark235 if (!D.isZero(after)) {236 const side = D.isPos(after) ? 'long' as const : 'short' as const;237 const mmf = ctx.maintenanceMarginFraction !== undefined ? D.from(ctx.maintenanceMarginFraction) : D.div(D.one, D.mul(TWO, instMaxLev), 18, 'up');238 const absAfter = D.abs(after);239 let liq: Dec | null;240 if (pos && pos.marginMode === 'isolated') {241 // isolated: same direction → the position's margin (with its PnL at mark) scaled to the new size, plus the added margin;242 // flipped → a fresh position at its initial margin243 const sameDirection = D.sign(after) === D.sign(before);244 const kept = D.min(absAfter, D.abs(before));245 const carried = sameDirection ? D.div(D.mul(D.add(D.from(pos.margin), D.from(pos.unrealizedPnl)), kept), D.abs(before), 18, 'down') : D.zero;246 const margin = sameDirection ? D.add(carried, addedMargin) : initialMargin(afterNotional, lev);247 liq = liquidationPriceIsolated({ side, size: absAfter, entryPrice: mark, margin, maintenanceFraction: mmf }).value;248 } else {249 // cross, single-position approximation: the whole equity backs this position, other positions frozen250 liq = liquidationPriceCross({ side, size: absAfter, entryPrice: mark, accountCollateral: equity, maintenanceFraction: mmf }).value;251 }252 if (liq !== null) {253 const wrongSide = side === 'long' ? D.gte(liq, mark) : D.lte(liq, mark);254 // distance = |mark − liq| / mark ≥ min/10000 ⇔ |mark − liq| · 10000 ≥ min · mark255 if (wrongSide || D.lt(D.mul(D.abs(D.sub(mark, liq)), BPS), D.mul(D.from(policy.minLiquidationDistanceBps), mark))) {256 return no('liquidation_too_close', `estimated liquidation price ${D.str(liq)} is closer than ${policy.minLiquidationDistanceBps} bps to the mark ${ticker.mark}`);257 }258 }259 }260261 // 12, 13 — losses: realised + unrealised must stay above −max262 const unrealized = ctx.positions.reduce((acc, x) => D.add(acc, D.from(x.unrealizedPnl)), D.zero);263 const daily = D.add(D.from(usage.realizedToday), unrealized);264 if (D.lte(daily, D.neg(D.from(policy.maxDailyLoss)))) return no('daily_loss_exceeded', `daily PnL ${D.str(daily)} has reached the limit −${policy.maxDailyLoss}`);265 const total = D.add(D.from(usage.realizedTotal), unrealized);266 if (D.lte(total, D.neg(D.from(policy.maxTotalLoss)))) return no('total_loss_exceeded', `total PnL ${D.str(total)} has reached the limit −${policy.maxTotalLoss}`);267 }268269 // 14 — order rate (counts exclude this order)270 if (!Number.isInteger(usage.ordersLastMinute) || !Number.isInteger(usage.ordersToday)) return no('bad_input', 'order counters must be integers');271 if (!exempt && usage.ordersLastMinute >= policy.maxOrdersPerMinute) return no('order_rate_minute', `${usage.ordersLastMinute} orders in the last minute (limit ${policy.maxOrdersPerMinute})`);272 if (!exempt && usage.ordersToday >= policy.maxOrdersPerDay) return no('order_rate_day', `${usage.ordersToday} orders today (limit ${policy.maxOrdersPerDay})`);273274 // 15 — duplicate clientId275 if (ctx.openOrders.some((o) => o.clientId === req.clientId) || (usage.clientIds ?? []).includes(req.clientId)) return no('duplicate_client_id', `client id ${req.clientId} was already used on this account`);276277 return { ok: true };278}279