SPB Git

Repository Public

risk.ts 18.7 KB 279 lines typescript

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
Files main
Menu

Theme