SPB Git

Repository Public

risk.test.ts 28.7 KB 388 lines typescript

1import { describe, expect, it } from 'vitest';2import {3  checkOrder, RISK_CODES, RiskPolicySchema,4  type AccountState, type Book, type Instrument, type Order, type OrderRequest, type Position, type RiskCode, type RiskContext, type RiskPolicy, type RiskResult, type Ticker,5} from '../src/index.ts';67const ID = 'hyperliquid:BTC';8const NOW = 1_700_000_000_000;910const instrument: Instrument = {11  venue: 'hyperliquid', symbol: 'BTC', id: ID, base: 'BTC', quote: 'USD', kind: 'perpetual', collateral: 'USDC', settlement: 'USDC',12  tickSize: '1', lotSize: '0.001', minOrderSize: '0.001', minNotional: '10', maxLeverage: '50', marginModes: ['cross', 'isolated'],13  fundingIntervalSec: 3600, markPriceMethod: 'oracle median', status: 'active',14};15const ticker: Ticker = { instrument: ID, mark: '60000', index: '60000', mid: '60000', funding: '0.00001', openInterest: null, volume24h: null, change24h: null, ts: NOW - 500 };16const book: Book = { instrument: ID, bids: [['59999', '5']], asks: [['60001', '5']], ts: NOW - 500 };17const account: AccountState = { venue: 'hyperliquid', account: '0xabc', equity: '10000', collateral: '10000', freeCollateral: '10000', marginUsed: '0', maintenanceMargin: '0', withdrawable: '10000', ts: NOW - 500, source: 'venue' };1819/** the policy under test: every limit is binding somewhere below */20const policy: RiskPolicy = {21  mode: 'PAPER', allowedInstruments: [ID], halt: null, maxDataAgeMs: 5000, maxDeviationBps: '100', maxMarkIndexBps: '50', maxLeverage: '5',22  maxOrderNotional: '20000', maxGrossExposure: '40000', maxPositionNotional: '30000', minFreeCollateral: '1000', minLiquidationDistanceBps: '500',23  maxDailyLoss: '500', maxTotalLoss: '2000', maxOrdersPerMinute: 10, maxOrdersPerDay: 100,24};25/** a policy with every limit out of the way, to isolate one rule */26const loose: RiskPolicy = {27  ...policy, maxDeviationBps: '5000', maxMarkIndexBps: '5000', maxLeverage: '1000', maxOrderNotional: '1000000000', maxGrossExposure: '1000000000',28  maxPositionNotional: '1000000000', minFreeCollateral: '0', minLiquidationDistanceBps: '0', maxDailyLoss: '1000000000', maxTotalLoss: '1000000000',29  maxOrdersPerMinute: 1000000, maxOrdersPerDay: 1000000,30};3132const order = (o: Partial<OrderRequest> = {}): OrderRequest => ({ instrument: ID, side: 'buy', type: 'limit', size: '0.1', price: '60000', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: 'c-1', ...o });33const ctx = (o: Partial<RiskContext> = {}): RiskContext => ({34  instrument, account, positions: [], openOrders: [], book, ticker, now: NOW,35  usage: { realizedToday: '0', realizedTotal: '0', ordersLastMinute: 0, ordersToday: 0 }, ...o,36});37const position = (o: Partial<Position> = {}): Position => ({38  instrument: ID, side: 'long', size: '0.5', entryPrice: '60000', markPrice: '60000', liquidationPrice: null, leverage: '5', marginMode: 'cross',39  margin: '6000', unrealizedPnl: '0', realizedPnl: null, fundingPaid: null, source: 'venue', ...o,40});41const eth = (o: Partial<Position> = {}): Position => position({ instrument: 'hyperliquid:ETH', size: '10', entryPrice: '2500', markPrice: '2500', margin: '5000', ...o });4243const ok = (r: RiskResult): void => { expect(r).toEqual({ ok: true }); };44const refused = (r: RiskResult, code: RiskCode): void => {45  expect(r.ok).toBe(false);46  if (!r.ok) { expect(r.code).toBe(code); expect(r.reason.length).toBeGreaterThan(5); }47};4849describe('risk: policy schema', () => {50  it('accepts the policy and rejects malformed ones', () => {51    expect(RiskPolicySchema.parse(policy)).toEqual(policy);52    for (const bad of [{ ...policy, mode: 'LIVE' }, { ...policy, maxLeverage: '0' }, { ...policy, maxLeverage: 5 }, { ...policy, maxDailyLoss: '-1' }, { ...policy, maxOrderNotional: '1e6' },53      { ...policy, halt: undefined }, { ...policy, maxDataAgeMs: 1.5 }, { ...policy, allowedInstruments: 'all' }]) {54      expect(RiskPolicySchema.safeParse(bad).success).toBe(false);55    }56    expect(new Set(RISK_CODES).size).toBe(RISK_CODES.length);57  });5859  it('refuses when the policy, the order or the context cannot be read (fail closed)', () => {60    refused(checkOrder({ ...policy, maxLeverage: 'x' } as RiskPolicy, order(), ctx()), 'bad_policy');61    refused(checkOrder(policy, order({ size: '1e-1' }), ctx()), 'bad_order');62    refused(checkOrder(policy, order({ size: '0' }), ctx()), 'bad_order');63    refused(checkOrder(policy, order({ price: '0' }), ctx()), 'bad_order');64    refused(checkOrder(policy, order({ triggerPrice: '59000' }), ctx()), 'bad_order');65    refused(checkOrder(policy, order(), ctx({ ticker: { ...ticker, mark: 'abc' } })), 'bad_input');66    refused(checkOrder(policy, order(), ctx({ account: { ...account, equity: 'NaN' } })), 'bad_input');67    refused(checkOrder(policy, order(), ctx({ now: Number.NaN })), 'bad_input');68    refused(checkOrder(policy, order(), ctx({ ticker: { ...ticker, mark: '0' } })), 'bad_mark');69  });70});7172describe('risk: one test per rule (accepted just inside, refused just outside)', () => {73  it('baseline order is accepted', () => { ok(checkOrder(policy, order(), ctx())); });7475  it('1 — mode', () => {76    refused(checkOrder({ ...policy, mode: 'READ_ONLY' }, order(), ctx()), 'mode_read_only');77    for (const mode of ['PAPER', 'MANUAL_LIVE', 'MANDATE_LIVE'] as const) ok(checkOrder({ ...policy, mode }, order(), ctx()));78  });7980  it('1 — circuit breaker', () => {81    refused(checkOrder({ ...policy, halt: 'daily loss breaker' }, order(), ctx()), 'halted');82    const r = checkOrder({ ...policy, halt: 'daily loss breaker' }, order(), ctx());83    expect(!r.ok && r.reason).toMatch(/daily loss breaker/);84    // a halt stops new risk, not the way out: a reduce-only order that reduces still passes, one that does not is refused85    expect(checkOrder({ ...policy, halt: 'venue down' }, order({ side: 'sell', reduceOnly: true }), ctx({ positions: [position()] })).ok).toBe(true);86    expect(checkOrder({ ...policy, halt: 'venue down' }, order({ side: 'sell', reduceOnly: true }), ctx()).ok).toBe(false);87  });8889  it('2 — instrument allowed and active', () => {90    refused(checkOrder({ ...policy, allowedInstruments: ['hyperliquid:ETH'] }, order(), ctx()), 'instrument_not_allowed');91    refused(checkOrder({ ...policy, allowedInstruments: [] }, order(), ctx()), 'instrument_not_allowed');92    refused(checkOrder(policy, order({ instrument: 'hyperliquid:ETH' }), ctx()), 'instrument_mismatch');93    refused(checkOrder(policy, order(), ctx({ instrument: { ...instrument, status: 'halted' } })), 'instrument_halted');94    // reduce_only instrument: opening is refused, reducing is accepted (flagged or not)95    const ro = { ...instrument, status: 'reduce_only' as const };96    refused(checkOrder(policy, order(), ctx({ instrument: ro })), 'instrument_reduce_only');97    ok(checkOrder(policy, order({ side: 'sell', size: '0.2', reduceOnly: true }), ctx({ instrument: ro, positions: [position()] })));98    ok(checkOrder(policy, order({ side: 'sell', size: '0.2' }), ctx({ instrument: ro, positions: [position({ size: '0.3' })] })));99    refused(checkOrder(policy, order({ side: 'buy', size: '0.1' }), ctx({ instrument: ro, positions: [position({ size: '0.3' })] })), 'instrument_reduce_only');100  });101102  it('3 — data age (stale price)', () => {103    ok(checkOrder(policy, order(), ctx({ ticker: { ...ticker, ts: NOW - 5000 } })));104    refused(checkOrder(policy, order(), ctx({ ticker: { ...ticker, ts: NOW - 5001 } })), 'stale_ticker');105    ok(checkOrder(policy, order(), ctx({ book: { ...book, ts: NOW - 5000 } })));106    refused(checkOrder(policy, order(), ctx({ book: { ...book, ts: NOW - 5001 } })), 'stale_book');107    // data from the future beyond the tolerance is not trusted either108    refused(checkOrder(policy, order(), ctx({ ticker: { ...ticker, ts: NOW + 5001 } })), 'stale_ticker');109    refused(checkOrder(policy, order(), ctx({ ticker: { ...ticker, instrument: 'hyperliquid:ETH' } })), 'data_mismatch');110    refused(checkOrder(policy, order(), ctx({ book: { ...book, instrument: 'hyperliquid:ETH' } })), 'data_mismatch');111  });112113  it('4 — price within maxDeviationBps of the mark (excessive slippage bound)', () => {114    // 100 bps of 60 000 = 600115    ok(checkOrder(policy, order({ price: '60600' }), ctx()));116    refused(checkOrder(policy, order({ price: '60601' }), ctx()), 'price_deviation');117    ok(checkOrder(policy, order({ side: 'sell', price: '59400', reduceOnly: true }), ctx({ positions: [position()] })));118    refused(checkOrder(policy, order({ side: 'sell', price: '59399', reduceOnly: true }), ctx({ positions: [position()] })), 'price_deviation');119    // a market order must carry its worst price, and that bound is checked the same way120    refused(checkOrder(policy, order({ type: 'market', tif: 'ioc', price: null }), ctx()), 'price_required');121    ok(checkOrder(policy, order({ type: 'market', tif: 'ioc', price: '60300' }), ctx()));122    refused(checkOrder(policy, order({ type: 'market', tif: 'ioc', price: '63000' }), ctx()), 'price_deviation');123  });124125  it('4 — trigger orders: the price is compared with the trigger', () => {126    const pos = [position()];127    const stop = (o: Partial<OrderRequest>) => order({ side: 'sell', type: 'stop_limit', reduceOnly: true, size: '0.5', triggerPrice: '57000', price: '56900', ...o });128    ok(checkOrder(policy, stop({}), ctx({ positions: pos })));129    // 100 bps of 57 000 = 570130    ok(checkOrder(policy, stop({ price: '56430' }), ctx({ positions: pos })));131    refused(checkOrder(policy, stop({ price: '56429' }), ctx({ positions: pos })), 'price_deviation');132    refused(checkOrder(policy, stop({ triggerPrice: null }), ctx({ positions: pos })), 'trigger_required');133    refused(checkOrder(policy, stop({ type: 'take_profit_market', triggerPrice: null }), ctx({ positions: pos })), 'trigger_required');134  });135136  it('4 — mark within maxMarkIndexBps of the index', () => {137    // 50 bps of 60 000 = 300138    ok(checkOrder(policy, order({ price: '60300' }), ctx({ ticker: { ...ticker, mark: '60300' } })));139    refused(checkOrder(policy, order({ price: '60301' }), ctx({ ticker: { ...ticker, mark: '60301' } })), 'mark_index_divergence');140    ok(checkOrder(policy, order({ price: '59700' }), ctx({ ticker: { ...ticker, mark: '59700' } })));141    refused(checkOrder(policy, order({ price: '59699' }), ctx({ ticker: { ...ticker, mark: '59699' } })), 'mark_index_divergence');142    // no index published: the check is skipped143    ok(checkOrder(policy, order({ price: '60301' }), ctx({ ticker: { ...ticker, mark: '60301', index: null } })));144  });145146  it('5 — tick, lot, minimum size, minimum notional', () => {147    const half = { ...instrument, tickSize: '0.5' };148    ok(checkOrder(policy, order({ price: '60000.5' }), ctx({ instrument: half })));149    refused(checkOrder(policy, order({ price: '60000.3' }), ctx({ instrument: half })), 'price_not_on_tick');150    refused(checkOrder(policy, order({ price: '60000.5' }), ctx()), 'price_not_on_tick');151    refused(checkOrder(policy, order({ side: 'sell', type: 'stop_market', triggerPrice: '59000.5', price: '58900', reduceOnly: true }), ctx({ positions: [position()] })), 'price_not_on_tick');152    ok(checkOrder(policy, order({ size: '0.101' }), ctx()));153    refused(checkOrder(policy, order({ size: '0.1005' }), ctx()), 'size_not_on_lot');154    const fine = { ...instrument, lotSize: '0.0001' };155    ok(checkOrder(policy, order({ size: '0.001' }), ctx({ instrument: fine })));156    refused(checkOrder(policy, order({ size: '0.0009' }), ctx({ instrument: fine })), 'below_min_size');157    // 0.001 · 60 000 = 60158    ok(checkOrder(policy, order({ size: '0.001' }), ctx({ instrument: { ...instrument, minNotional: '60' } })));159    refused(checkOrder(policy, order({ size: '0.001' }), ctx({ instrument: { ...instrument, minNotional: '60.01' } })), 'below_min_notional');160    ok(checkOrder(policy, order({ size: '0.001' }), ctx({ instrument: { ...instrument, minOrderSize: null, minNotional: null } })));161  });162163  it('6 — leverage after the order', () => {164    // equity 10 000, limit 5x → gross ≤ 50 000 ; 0.833 · 60 000 = 49 980 ; 0.834 · 60 000 = 50 040165    const p = { ...loose, maxLeverage: '5' };166    ok(checkOrder(p, order({ size: '0.833' }), ctx({ leverage: '50' })));167    refused(checkOrder(p, order({ size: '0.834' }), ctx({ leverage: '50' })), 'leverage_exceeded');168    // other positions count: 10 ETH · 2 500 = 25 000 → BTC up to 25 000 = 0.416 (24 960) ; 0.417 = 25 020169    ok(checkOrder(p, order({ size: '0.416' }), ctx({ leverage: '50', positions: [eth()] })));170    refused(checkOrder(p, order({ size: '0.417' }), ctx({ leverage: '50', positions: [eth()] })), 'leverage_exceeded');171    // no equity: nothing may be opened172    refused(checkOrder(p, order({ size: '0.001' }), ctx({ account: { ...account, equity: '0' } })), 'leverage_exceeded');173  });174175  it('7 — notional per order', () => {176    // 0.333 · 60 000 = 19 980 ; 0.334 · 60 000 = 20 040 ; limit 20 000177    ok(checkOrder(policy, order({ size: '0.333' }), ctx()));178    refused(checkOrder(policy, order({ size: '0.334' }), ctx()), 'order_notional_exceeded');179    // the order is valued at its price when that is above the mark: 0.333 · 60 100 = 20 013.3180    refused(checkOrder(policy, order({ size: '0.333', price: '60100' }), ctx()), 'order_notional_exceeded');181  });182183  it('8 — resulting gross exposure (limit exceeded)', () => {184    // ETH 25 000 already there ; limit 40 000 → BTC up to 15 000 = 0.25 ; 0.251 = 15 060185    ok(checkOrder(policy, order({ size: '0.25' }), ctx({ positions: [eth()] })));186    refused(checkOrder(policy, order({ size: '0.251' }), ctx({ positions: [eth()] })), 'gross_exposure_exceeded');187    // a short adds to gross exposure just the same188    refused(checkOrder(policy, order({ side: 'sell', size: '0.251' }), ctx({ positions: [eth()] })), 'gross_exposure_exceeded');189  });190191  it('9 — resulting position per instrument', () => {192    // long 0.4 (24 000) ; limit 30 000 → +0.1 = 30 000 ; +0.101 = 30 060193    const pos = [position({ size: '0.4' })];194    ok(checkOrder(policy, order({ size: '0.1' }), ctx({ positions: pos })));195    refused(checkOrder(policy, order({ size: '0.101' }), ctx({ positions: pos })), 'position_notional_exceeded');196    // a flip is judged on the position it leaves: long 0.4, sell 0.9 → short 0.5 = 30 000 ; sell 0.901 → 30 060197    const p = { ...policy, maxOrderNotional: '100000' };198    ok(checkOrder(p, order({ side: 'sell', size: '0.9' }), ctx({ positions: pos })));199    refused(checkOrder(p, order({ side: 'sell', size: '0.901' }), ctx({ positions: pos })), 'position_notional_exceeded');200  });201202  it('10 — free collateral after initial margin (insufficient collateral)', () => {203    // 0.1 · 60 000 / 5x = 1 200 of initial margin ; minimum free collateral 1 000204    ok(checkOrder(policy, order(), ctx({ account: { ...account, freeCollateral: '2200' } })));205    refused(checkOrder(policy, order(), ctx({ account: { ...account, freeCollateral: '2199.99' } })), 'insufficient_collateral');206    // the leverage setting of the instrument changes the margin: at 2x it is 3 000207    ok(checkOrder(policy, order(), ctx({ leverage: '2', account: { ...account, freeCollateral: '4000' } })));208    refused(checkOrder(policy, order(), ctx({ leverage: '2', account: { ...account, freeCollateral: '3999' } })), 'insufficient_collateral');209    // an order that only reduces needs no margin, even unflagged210    ok(checkOrder(policy, order({ side: 'sell', size: '0.2' }), ctx({ positions: [position({ size: '0.4' })], account: { ...account, freeCollateral: '1000' } })));211  });212213  it('11 — estimated liquidation distance', () => {214    // cross, single position, maintenance 1 %: long 0.5 @ 60 000 (30 000) on equity E liquidates at (30 000 − E) / (0.5 · 0.99)215    //   E = 1 785    → 28 215 / 0.495 = 57 000 → distance 3 000 / 60 000 = 500 bps exactly → accepted216    //   E = 1 784.99 → 28 215.01 / 0.495 = 57 000.02… → below 500 bps → refused217    const p = { ...loose, minLiquidationDistanceBps: '500' };218    const c = (equity: string) => ctx({ leverage: '50', maintenanceMarginFraction: '0.01', account: { ...account, equity, freeCollateral: equity } });219    ok(checkOrder(p, order({ size: '0.5' }), c('1785')));220    refused(checkOrder(p, order({ size: '0.5' }), c('1784.99')), 'liquidation_too_close');221    // short: (30 000 + E) / (0.5 · 1.01) ; E = 1 815 → 31 815 / 0.505 = 63 000 → 500 bps exactly222    ok(checkOrder(p, order({ side: 'sell', size: '0.5' }), c('1815')));223    refused(checkOrder(p, order({ side: 'sell', size: '0.5' }), c('1814.99')), 'liquidation_too_close');224    // default maintenance fraction = 1 / (2 · maxLeverage) = 1 % for a 50x instrument: same boundary225    ok(checkOrder(p, order({ size: '0.5' }), ctx({ leverage: '50', account: { ...account, equity: '1785', freeCollateral: '1785' } })));226    refused(checkOrder(p, order({ size: '0.5' }), ctx({ leverage: '50', account: { ...account, equity: '1784.99', freeCollateral: '1784.99' } })), 'liquidation_too_close');227    // isolated position: long 0.5 with 3 000 of margin, +0.5 at 20x adds 1 500 → 1.0 @ 60 000 with 4 500:228    //   (60 000 − 4 500) / 0.99 = 56 060.6… → 656 bps → accepted at 500, refused at 700229    const iso = [position({ marginMode: 'isolated', margin: '3000', leverage: '20' })];230    ok(checkOrder(p, order({ size: '0.5' }), ctx({ positions: iso, maintenanceMarginFraction: '0.01' })));231    refused(checkOrder({ ...p, minLiquidationDistanceBps: '700' }, order({ size: '0.5' }), ctx({ positions: iso, maintenanceMarginFraction: '0.01' })), 'liquidation_too_close');232  });233234  it('12 — daily loss', () => {235    const u = (realizedToday: string) => ({ realizedToday, realizedTotal: '0', ordersLastMinute: 0, ordersToday: 0 });236    ok(checkOrder(policy, order(), ctx({ usage: u('-499.99') })));237    refused(checkOrder(policy, order(), ctx({ usage: u('-500') })), 'daily_loss_exceeded');238    // unrealized PnL counts: −400 realised and −100 unrealised reach the limit239    const losing = [position({ size: '0.1', unrealizedPnl: '-100' })];240    refused(checkOrder(policy, order(), ctx({ usage: u('-400'), positions: losing })), 'daily_loss_exceeded');241    ok(checkOrder(policy, order(), ctx({ usage: u('-399.99'), positions: losing })));242    ok(checkOrder(policy, order(), ctx({ usage: u('-600'), positions: [position({ size: '0.1', unrealizedPnl: '100.01' })] })));243  });244245  it('13 — total loss', () => {246    const u = (realizedTotal: string) => ({ realizedToday: '0', realizedTotal, ordersLastMinute: 0, ordersToday: 0 });247    ok(checkOrder(policy, order(), ctx({ usage: u('-1999.99') })));248    refused(checkOrder(policy, order(), ctx({ usage: u('-2000') })), 'total_loss_exceeded');249  });250251  it('14 — order rate', () => {252    const u = (ordersLastMinute: number, ordersToday: number) => ({ realizedToday: '0', realizedTotal: '0', ordersLastMinute, ordersToday });253    ok(checkOrder(policy, order(), ctx({ usage: u(9, 99) })));254    refused(checkOrder(policy, order(), ctx({ usage: u(10, 50) })), 'order_rate_minute');255    refused(checkOrder(policy, order(), ctx({ usage: u(5, 100) })), 'order_rate_day');256    refused(checkOrder(policy, order(), ctx({ usage: u(1.5, 0) })), 'bad_input');257  });258259  it('15 — duplicate clientId', () => {260    const open: Order = { ...order({ clientId: 'dup', price: '59000' }), venueOrderId: '1', status: 'open', filled: '0', avgFillPrice: null, createdAt: NOW, updatedAt: NOW, rejectReason: null };261    refused(checkOrder(policy, order({ clientId: 'dup' }), ctx({ openOrders: [open] })), 'duplicate_client_id');262    ok(checkOrder(policy, order({ clientId: 'other' }), ctx({ openOrders: [open] })));263    refused(checkOrder(policy, order({ clientId: 'old' }), ctx({ usage: { realizedToday: '0', realizedTotal: '0', ordersLastMinute: 0, ordersToday: 0, clientIds: ['old'] } })), 'duplicate_client_id');264  });265});266267describe('risk: reduce-only', () => {268  /** an account in bad shape: every exposure and loss limit is already exceeded */269  const stressed = (o: Partial<RiskContext> = {}): RiskContext => ctx({270    positions: [position({ size: '2', unrealizedPnl: '-5000' }), eth({ size: '40' })],     // BTC 120 000 + ETH 100 000 of exposure271    account: { ...account, equity: '100', freeCollateral: '-4000' },272    usage: { realizedToday: '-900', realizedTotal: '-9000', ordersLastMinute: 0, ordersToday: 0 },273    maintenanceMarginFraction: '0.01',274    ...o,275  });276  const reduce = (o: Partial<OrderRequest> = {}): OrderRequest => order({ side: 'sell', size: '0.5', reduceOnly: true, ...o });277278  it('an order that really reduces stays possible when exposure and loss limits are exceeded', () => {279    ok(checkOrder(policy, reduce(), stressed()));280    ok(checkOrder(policy, reduce({ size: '2' }), stressed()));                                   // full close281    ok(checkOrder(policy, reduce({ type: 'market', tif: 'ioc', price: '59500' }), stressed()));282    // the same order without the flag is judged like any other283    refused(checkOrder(policy, reduce({ reduceOnly: false }), stressed()), 'leverage_exceeded');284    // and so is an opening order285    refused(checkOrder(policy, order({ size: '0.001' }), stressed()), 'leverage_exceeded');286  });287288  it('each exposure and loss limit is skipped for a reducing reduce-only order, enforced otherwise', () => {289    const pos = [position({ size: '0.4' })];290    const sell = (reduceOnly: boolean) => order({ side: 'sell', size: '0.1', reduceOnly });291    const cases: [RiskCode, RiskPolicy, Partial<RiskContext>][] = [292      ['leverage_exceeded', { ...loose, maxLeverage: '1' }, { positions: pos, account: { ...account, equity: '100' } }],293      ['order_notional_exceeded', { ...loose, maxOrderNotional: '100' }, { positions: pos }],294      ['gross_exposure_exceeded', { ...loose, maxGrossExposure: '100' }, { positions: pos }],295      ['position_notional_exceeded', { ...loose, maxPositionNotional: '100' }, { positions: pos }],296      ['insufficient_collateral', { ...loose, minFreeCollateral: '100' }, { positions: pos, account: { ...account, freeCollateral: '50' } }],297      ['liquidation_too_close', { ...loose, minLiquidationDistanceBps: '9000' }, { positions: pos, account: { ...account, equity: '2000' } }],298      ['daily_loss_exceeded', { ...loose, maxDailyLoss: '10' }, { positions: pos, usage: { realizedToday: '-50', realizedTotal: '0', ordersLastMinute: 0, ordersToday: 0 } }],299      ['total_loss_exceeded', { ...loose, maxTotalLoss: '10' }, { positions: pos, usage: { realizedToday: '0', realizedTotal: '-50', ordersLastMinute: 0, ordersToday: 0 } }],300    ];301    for (const [code, p, c] of cases) {302      refused(checkOrder(p, sell(false), ctx(c)), code);303      ok(checkOrder(p, sell(true), ctx(c)));304    }305  });306307  it('a reduce-only order that would increase or flip the position is refused', () => {308    refused(checkOrder(policy, reduce({ size: '2.001' }), stressed()), 'reduce_only_would_flip');309    refused(checkOrder(policy, reduce({ side: 'buy' }), stressed()), 'reduce_only_would_increase');310    refused(checkOrder(policy, reduce(), ctx()), 'reduce_only_would_increase');                  // no position at all311    refused(checkOrder(policy, reduce({ side: 'buy' }), ctx({ positions: [position()] })), 'reduce_only_would_increase');312    ok(checkOrder(policy, reduce({ side: 'buy' }), ctx({ positions: [position({ side: 'short' })] })));313    refused(checkOrder(policy, reduce({ side: 'buy', size: '0.501' }), ctx({ positions: [position({ side: 'short' })] })), 'reduce_only_would_flip');314  });315316  it('it must still pass data freshness, price sanity and tick/lot', () => {317    refused(checkOrder(policy, reduce(), stressed({ ticker: { ...ticker, ts: NOW - 60_000 } })), 'stale_ticker');318    refused(checkOrder(policy, reduce(), stressed({ book: { ...book, ts: NOW - 60_000 } })), 'stale_book');319    refused(checkOrder(policy, reduce({ price: '50000' }), stressed()), 'price_deviation');320    refused(checkOrder(policy, reduce(), stressed({ ticker: { ...ticker, mark: '61000' } })), 'price_deviation');321    refused(checkOrder(policy, reduce({ price: '60301' }), stressed({ ticker: { ...ticker, mark: '60301' } })), 'mark_index_divergence');322    refused(checkOrder(policy, reduce({ price: '60000.5' }), stressed()), 'price_not_on_tick');323    refused(checkOrder(policy, reduce({ size: '0.5005' }), stressed()), 'size_not_on_lot');324    refused(checkOrder(policy, reduce({ price: null }), stressed()), 'price_required');325    refused(checkOrder({ ...policy, mode: 'READ_ONLY' }, reduce(), stressed()), 'mode_read_only');326    refused(checkOrder(policy, reduce(), stressed({ instrument: { ...instrument, status: 'halted' } })), 'instrument_halted');327    // the order-rate budget does not block the way out; a duplicate client id still does328    expect(checkOrder(policy, reduce(), stressed({ usage: { realizedToday: '0', realizedTotal: '0', ordersLastMinute: 10, ordersToday: 10 } })).ok).toBe(true);329    refused(checkOrder(policy, reduce(), stressed({ usage: { realizedToday: '0', realizedTotal: '0', ordersLastMinute: 0, ordersToday: 0, clientIds: ['c-1'] } })), 'duplicate_client_id');330  });331332  it('dust below the minimums can be closed in full, not in part', () => {333    const fine = { ...instrument, lotSize: '0.0001' };334    const dust = [position({ size: '0.0005' })];335    ok(checkOrder(policy, reduce({ size: '0.0005' }), ctx({ instrument: fine, positions: dust })));336    refused(checkOrder(policy, reduce({ size: '0.0002' }), ctx({ instrument: fine, positions: dust })), 'below_min_size');337    refused(checkOrder(policy, order({ size: '0.0005' }), ctx({ instrument: fine })), 'below_min_size');338  });339});340341describe('risk: order of the checks', () => {342  it('reports the first failing rule in the documented order', () => {343    const everythingWrong: RiskContext = ctx({344      instrument: { ...instrument, status: 'halted' },345      ticker: { ...ticker, ts: NOW - 60_000, mark: '70000' }, book: { ...book, ts: NOW - 60_000 },346      account: { ...account, equity: '1', freeCollateral: '0' },347      usage: { realizedToday: '-9999', realizedTotal: '-9999', ordersLastMinute: 999, ordersToday: 999, clientIds: ['c-1'] },348    });349    const bad = order({ size: '5.0005', price: '60000.5' });350    const sequence: [RiskCode, RiskPolicy, RiskContext][] = [351      ['mode_read_only', { ...policy, mode: 'READ_ONLY', halt: 'x', allowedInstruments: [] }, everythingWrong],352      ['halted', { ...policy, halt: 'x', allowedInstruments: [] }, everythingWrong],353      ['instrument_not_allowed', { ...policy, allowedInstruments: [] }, everythingWrong],354      ['instrument_halted', policy, everythingWrong],355      ['stale_ticker', policy, { ...everythingWrong, instrument }],356      ['stale_book', policy, { ...everythingWrong, instrument, ticker: { ...ticker, mark: '70000' } }],357      ['price_deviation', policy, { ...everythingWrong, instrument, ticker: { ...ticker, mark: '70000' }, book }],358      ['mark_index_divergence', { ...policy, maxDeviationBps: '5000' }, { ...everythingWrong, instrument, ticker: { ...ticker, mark: '70000' }, book }],359      ['price_not_on_tick', policy, { ...everythingWrong, instrument, ticker, book }],360    ];361    for (const [code, p, c] of sequence) refused(checkOrder(p, bad, c), code);362    const c: RiskContext = { ...everythingWrong, instrument, ticker, book };363    refused(checkOrder(policy, order({ size: '5.0005' }), c), 'size_not_on_lot');364    refused(checkOrder(policy, order({ size: '5' }), c), 'leverage_exceeded');365    const rich: RiskContext = { ...c, account: { ...account, equity: '1000000', freeCollateral: '0' } };366    refused(checkOrder(policy, order({ size: '5' }), rich), 'order_notional_exceeded');367    refused(checkOrder({ ...policy, maxOrderNotional: '1000000' }, order({ size: '5' }), rich), 'gross_exposure_exceeded');368    refused(checkOrder({ ...policy, maxOrderNotional: '1000000', maxGrossExposure: '1000000' }, order({ size: '5' }), rich), 'position_notional_exceeded');369    refused(checkOrder(policy, order(), rich), 'insufficient_collateral');370    const funded: RiskContext = { ...rich, account: { ...account, equity: '6100', freeCollateral: '100000' } };371    // long 0.1 @ 60 000 on 6 100 of equity: margin covers the notional → no liquidation price → next rule372    refused(checkOrder(policy, order(), funded), 'daily_loss_exceeded');373    refused(checkOrder({ ...policy, maxDailyLoss: '100000' }, order(), funded), 'total_loss_exceeded');374    refused(checkOrder({ ...policy, maxDailyLoss: '100000', maxTotalLoss: '100000' }, order(), funded), 'order_rate_minute');375    refused(checkOrder({ ...policy, maxDailyLoss: '100000', maxTotalLoss: '100000', maxOrdersPerMinute: 1000 }, order(), funded), 'order_rate_day');376    refused(checkOrder({ ...policy, maxDailyLoss: '100000', maxTotalLoss: '100000', maxOrdersPerMinute: 1000, maxOrdersPerDay: 1000 }, order(), funded), 'duplicate_client_id');377    ok(checkOrder({ ...policy, maxDailyLoss: '100000', maxTotalLoss: '100000', maxOrdersPerMinute: 1000, maxOrdersPerDay: 1000 }, order({ clientId: 'fresh' }), funded));378  });379380  it('is pure: same inputs, same answer, inputs untouched', () => {381    const c = ctx({ positions: [position()] });382    const before = JSON.stringify([policy, order(), c]);383    const a = checkOrder(policy, order(), c), b = checkOrder(policy, order(), c);384    expect(a).toEqual(b);385    expect(JSON.stringify([policy, order(), c])).toBe(before);386  });387});388
Files main
Menu

Theme