SPB Git

Repository Public

derivatives-live.test.ts 19.5 KB 215 lines typescript

1import { readFileSync } from 'node:fs';2import { dirname, resolve } from 'node:path';3import { fileURLToPath } from 'node:url';4import { describe, expect, it } from 'vitest';5import { ApiError } from '../src/api.ts';6import { buildLive, canonicalProblem, clock, closeProblem, defaultPolicyForm, inFlight, isUnfunded, liveError, newKey, policyFromForm, remainingMs, samePolicy, sha256Hex, toneOf, venueWords,7  type LiveConfig, type LiveDetail, type LiveOrder, type LiveOrderBody, type VenueOrder } from '../src/derivatives/live.ts';8import { applyLiveEvent, mergeOrder } from '../src/derivatives/liveStore.ts';910const BTC = { id: 'hyperliquid:BTC', venue: 'hyperliquid' as const, tickSize: '0.1', lotSize: '0.00001' };11const BOUNDS: LiveConfig['bounds'] = { maxLeverage: 20, maxOrdersPerDay: 500, minPriceBandBps: 10, maxPriceBandBps: 2000, maxInstruments: 8, maxAuthorizationDays: 90, orderApprovalTtlMs: 90_000 };12const PINNED = [{ instrument: 'hyperliquid:BTC', maxLeverage: 40 }, { instrument: 'hyperliquid:ETH', maxLeverage: 25 }, { instrument: 'hyperliquid:SOL', maxLeverage: 10 }];13const canonical = (o: Partial<VenueOrder> = {}): VenueOrder => ({ v: 1, authorizationId: '5f0c2c1e-8f1a-4d59-9a39-0d7a54a1b111', venue: 'hyperliquid', network: 'testnet', account: `0x${'ab'.repeat(20)}`, kind: 'order', instrument: 'hyperliquid:BTC', side: 'buy',14  type: 'limit', size: '0.001', price: '73000', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: `0x${'0'.repeat(32)}`, nonce: 1_800_000_000_000, expiresAt: 1_800_000_090_000, ...o });15const form = { side: 'buy' as const, kind: 'limit' as const, size: '', price: '', trigger: '', reduceOnly: false, postOnly: false };1617describe('order form → live order body', () => {18  it('sends sizes and prices as the decimal strings that were typed, put on the grid — never through a float', () => {19    const { body } = buildLive({ ...form, size: '0,1', price: '73 000,5' }, BTC);20    expect(body).toEqual({ instrument: 'hyperliquid:BTC', side: 'buy', type: 'limit', size: '0.1', price: '73000', triggerPrice: null, reduceOnly: false, tif: 'gtc' });   // 5 significant figures on Hyperliquid: 73000.5 → 7300021    // 0.1 + 0.2 in floats is 0.30000000000000004: a decimal string never is22    expect(buildLive({ ...form, size: '0.30000000000000004', price: '100' }, BTC).body!.size).toBe('0.3');23    expect(buildLive({ ...form, size: '0.123456789', price: '100' }, BTC).body!.size).toBe('0.12345');                   // rounded DOWN to the lot24    expect(buildLive({ ...form, size: '12345678901234567890.123456', price: '1' }, BTC).body!.size).toBe('12345678901234567890.12345');   // beyond double precision25    expect(typeof buildLive({ ...form, size: '1', price: '2' }, BTC).body!.size).toBe('string');26  });27  it('a market order carries no price (the server derives the worst price from the venue\'s book) and no leverage', () => {28    const { body } = buildLive({ ...form, kind: 'market', size: '0.002' }, BTC);29    expect(body).toEqual({ instrument: 'hyperliquid:BTC', side: 'buy', type: 'market', size: '0.002', price: null, triggerPrice: null, reduceOnly: false, tif: 'ioc', slippageBps: 50 });30    expect(body).not.toHaveProperty('leverage');31    expect(body).not.toHaveProperty('clientId');32  });33  it('a stop carries its trigger and a worst price inside the bound; post-only is alo', () => {34    const stop = buildLive({ ...form, side: 'sell', kind: 'stop', size: '0.01', trigger: '70000' }, BTC).body!;35    expect(stop.type).toBe('stop_market'); expect(stop.triggerPrice).toBe('70000'); expect(stop.price).toBe('69650');   // 50 bps below, on the grid36    expect(buildLive({ ...form, size: '0.01', price: '70000', postOnly: true }, BTC).body!.tif).toBe('alo');37  });38  it('says what is missing instead of building something', () => {39    expect(buildLive({ ...form, size: '0.01' }, BTC)).toEqual({ body: null, problem: null });                            // nothing typed yet: no reproach40    expect(buildLive({ ...form, size: '0.01', price: 'abc' }, BTC).problem).toBe('fxErrPrice');41    expect(buildLive({ ...form, size: '0.000001', price: '70000' }, BTC).problem).toBe('fxErrSizeSmall');42    expect(buildLive({ ...form, size: 'x', price: '70000' }, BTC).problem).toBe('fxErrSize');43    expect(buildLive({ ...form, size: '1', price: '1' }, { ...BTC, lotSize: '0' }).problem).toBe('fxErrInstrument');44  });45});4647describe('what the passkey signs must be what was asked for', () => {48  const body: LiveOrderBody = { instrument: 'hyperliquid:BTC', side: 'buy', type: 'limit', size: '0.001', price: '73000', triggerPrice: null, reduceOnly: false, tif: 'gtc' };49  it('accepts the canonical form of the same order', () => {50    expect(canonicalProblem(body, canonical())).toBeNull();51    expect(canonicalProblem({ ...body, size: '0.0010' }, canonical())).toBeNull();                                        // same decimal, other spelling52    // a market order is sent as a limit IOC at its worst price53    expect(canonicalProblem({ ...body, type: 'market', price: null, tif: 'ioc', slippageBps: 50 }, canonical({ type: 'limit', tif: 'ioc', price: '73365' }))).toBeNull();54  });55  it('names the first field that differs', () => {56    expect(canonicalProblem(body, null)).toBe('canonical');57    expect(canonicalProblem(body, canonical({ instrument: 'hyperliquid:ETH' }))).toBe('instrument');58    expect(canonicalProblem(body, canonical({ side: 'sell' }))).toBe('side');59    expect(canonicalProblem(body, canonical({ size: '0.01' }))).toBe('size');60    expect(canonicalProblem(body, canonical({ price: '73001' }))).toBe('price');61    expect(canonicalProblem(body, canonical({ reduceOnly: true }))).toBe('reduceOnly');62    expect(canonicalProblem(body, canonical({ tif: 'ioc' }))).toBe('tif');63    expect(canonicalProblem(body, canonical({ type: 'stop_market', triggerPrice: '70000' }))).toBe('type');64    expect(canonicalProblem(body, canonical({ triggerPrice: '70000' }))).toBe('triggerPrice');65    expect(canonicalProblem({ ...body, type: 'market', price: null }, canonical({ tif: 'gtc' }))).toBe('tif');           // a "market" order that would rest66  });67  it('a close is reduce-only, IOC, on the reducing side, and never larger than the position', () => {68    const pos = { instrument: 'hyperliquid:BTC', side: 'long' as const, size: '0.002' };69    const c = canonical({ side: 'sell', reduceOnly: true, tif: 'ioc', size: '0.002', price: '72000' });70    expect(closeProblem(pos, c)).toBeNull();71    expect(closeProblem(pos, { ...c, size: '0.001' })).toBeNull();72    expect(closeProblem(pos, { ...c, size: '0.003' })).toBe('size');73    expect(closeProblem(pos, { ...c, side: 'buy' })).toBe('side');74    expect(closeProblem(pos, { ...c, reduceOnly: false })).toBe('reduceOnly');75    expect(closeProblem(pos, { ...c, tif: 'gtc' })).toBe('type');76    expect(closeProblem({ ...pos, side: 'short' }, c)).toBe('side');77    expect(closeProblem(pos, null)).toBe('canonical');78  });79});8081describe('approval window', () => {82  it('stops three seconds before the server\'s expiry, and corrects the local clock', () => {83    expect(remainingMs(100_000, 10_000)).toBe(87_000);84    expect(remainingMs(100_000, 97_000)).toBe(0);85    expect(remainingMs(100_000, 99_999)).toBe(0);86    expect(remainingMs(null, 0)).toBe(0);87    expect(remainingMs(100_000, 30_000, 20_000)).toBe(87_000);                                                            // this clock runs 20 s ahead of the server's88    expect(clock(87_000)).toBe('1:27'); expect(clock(4_100)).toBe('0:05'); expect(clock(0)).toBe('0:00');89  });90  it('one key per review, in the shape the server accepts', () => {91    const a = newKey('order'), b = newKey('order');92    expect(a).not.toBe(b);93    expect(a).toMatch(/^[A-Za-z0-9._:-]{8,100}$/);94  });95});9697describe('authorization limits', () => {98  it('the defaults are valid, cautious, and differ by network only in the price band', () => {99    for (const n of ['testnet', 'mainnet'] as const) {100      const r = policyFromForm(defaultPolicyForm(n, PINNED), BOUNDS, PINNED);101      expect(r.ok).toBe(true);102      if (r.ok) { expect(r.policy.allowedInstruments).toEqual(['hyperliquid:BTC']); expect(r.policy.maxLeverage).toBe(2); expect(r.days).toBe(7); expect(r.policy.maxPriceDeviationBps).toBe(n === 'testnet' ? 1000 : 200); expect(r.policy.maxOrderNotional).toBe('50'); }103    }104  });105  it('refuses what the server would refuse, with the reason', () => {106    const f = defaultPolicyForm('testnet', PINNED), bad = (patch: Partial<typeof f>) => { const r = policyFromForm({ ...f, ...patch }, BOUNDS, PINNED); return r.ok ? null : r.problem; };107    expect(bad({ instruments: [] })).toBe('instruments');108    expect(bad({ instruments: ['hyperliquid:DOGE'] })).toBe('instruments');                                              // not pinned: cannot be allowed109    expect(bad({ maxLeverage: '0' })).toBe('leverage'); expect(bad({ maxLeverage: '21' })).toBe('leverage'); expect(bad({ maxLeverage: '2.5' })).toBe('leverage');110    expect(bad({ instruments: ['hyperliquid:SOL'], maxLeverage: '15' })).toBe('leverageVenue');                           // above SOL's venue maximum111    expect(bad({ maxOrderNotional: '0' })).toBe('order'); expect(bad({ maxOrderNotional: '' })).toBe('order');112    expect(bad({ maxOrderNotional: '200' })).toBe('orderAbovePosition');113    expect(bad({ maxPositionNotional: '200' })).toBe('positionAboveGross');114    expect(bad({ maxOrdersPerDay: '501' })).toBe('ordersPerDay');115    expect(bad({ bandBps: '5' })).toBe('band'); expect(bad({ bandBps: '2001' })).toBe('band');116    expect(bad({ days: '91' })).toBe('days'); expect(bad({ days: '0' })).toBe('days');117    const ok = policyFromForm({ ...f, maxOrderNotional: '1 234,50', maxPositionNotional: '2000', maxGrossExposure: '3000' }, BOUNDS, PINNED);118    expect(ok.ok && ok.policy.maxOrderNotional).toBe('1234.5');                                                           // French input → canonical decimal string119  });120  it('compares the limits the server prepared with those asked for', () => {121    const p = { allowedInstruments: ['hyperliquid:BTC', 'hyperliquid:ETH'], maxLeverage: 2, maxOrderNotional: '50', maxPositionNotional: '100', maxGrossExposure: '150', maxOrdersPerDay: 20, maxPriceDeviationBps: 1000 };122    expect(samePolicy(p, { ...p, allowedInstruments: ['hyperliquid:ETH', 'hyperliquid:BTC'], maxOrderNotional: '50.0' })).toBe(true);123    expect(samePolicy(p, { ...p, maxOrderNotional: '500' })).toBe(false);124    expect(samePolicy(p, { ...p, allowedInstruments: ['hyperliquid:BTC'] })).toBe(false);125    expect(samePolicy(p, { ...p, maxPriceDeviationBps: 2000 })).toBe(false);126  });127  it('hashes the acknowledgement text as displayed', async () => {128    expect(await sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');129    expect(await sha256Hex('é')).not.toBe(await sha256Hex('e'));130  });131});132133describe('errors: which sentence, and what may be offered next', () => {134  const api = (status: number, code: string, message = 'details from the server') => new ApiError(status, code, message);135  it('halted is two moments', () => {136    expect(liveError(api(422, 'halted'), 'propose')).toMatchObject({ key: 'halted', consumed: true, retry: false });137    expect(liveError(api(423, 'halted'), 'approve')).toMatchObject({ key: 'halted_423', consumed: true, retry: false });138  });139  it('a signer refusal or an expiry consumes the proposal; only a lost answer is sent again', () => {140    for (const c of ['signer_price', 'signer_no_price', 'signer_bad_approval', 'signer_unknown_credential', 'signer_policy', 'signer_mismatch', 'signer_expired', 'signer_revoked', 'signer_stale_nonce', 'signer_replay'])141      expect(liveError(api(422, c), 'approve')).toMatchObject({ key: c, consumed: true, retry: false });142    expect(liveError(api(422, 'signer_something_new'), 'approve')).toMatchObject({ key: 'signer_other', code: 'signer_something_new', consumed: true });143    expect(liveError(api(410, 'proposal_expired'), 'approve')).toMatchObject({ key: 'proposal_expired', consumed: true, retry: false });144    expect(liveError(api(503, 'signer_unreachable'), 'approve')).toMatchObject({ key: 'signer_unreachable', consumed: true, retry: false });   // the server marked the order "not signed"145    expect(liveError(api(0, 'offline', 'offline'), 'approve')).toMatchObject({ key: 'offline', consumed: false, retry: true });146    expect(liveError(api(500, 'error', 'error'), 'approve')).toMatchObject({ key: 'other', consumed: false, retry: true, detail: '' });147  });148  it('at proposal a 422 is stored under the key; a transport failure may be retried with the same key', () => {149    expect(liveError(api(422, 'price_deviation'), 'propose')).toMatchObject({ key: 'other', code: 'price_deviation', consumed: true, retry: false });   // a risk code: its sentence is `rk_price_deviation`150    expect(liveError(api(503, 'venue_unavailable'), 'propose')).toMatchObject({ key: 'venue_unavailable', consumed: false, retry: true });151    expect(liveError(api(503, 'asset_table_mismatch'), 'propose')).toMatchObject({ key: 'asset_table_mismatch', consumed: false, retry: false });152    expect(liveError(api(403, 'mainnet_disabled'), 'propose')).toMatchObject({ key: 'mainnet_disabled', status: 403, consumed: false, retry: false });153  });154  it('a dismissed passkey prompt is not an error of the server', () => {155    const e = Object.assign(new Error('The operation either timed out or was not allowed.'), { name: 'NotAllowedError' });156    expect(liveError(e, 'approve')).toMatchObject({ key: 'passkey_cancelled', consumed: true, retry: false });157    expect(liveError(new Error('boom'), 'approve')).toMatchObject({ key: 'passkey_failed', consumed: true });158  });159  it('extracts the venue\'s own words from the API message', () => {160    const m = 'the venue did not register the trading key: Must deposit before performing actions. User: 0xe06252f11d8beac14b7f8a026dd02febdba1400f — the authorization was revoked on the signer, nothing can trade. Fund the venue account first if the venue asks for a deposit, then prepare a new authorization.';161    expect(venueWords(m)).toBe('Must deposit before performing actions. User: 0xe06252f11d8beac14b7f8a026dd02febdba1400f');162    expect(isUnfunded(m)).toBe(true);163    expect(venueWords('something else entirely')).toBe('something else entirely');164    expect(isUnfunded('Order has invalid price.')).toBe(false);165  });166  it('every code has a French and an English sentence', () => {167    const src = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../src/i18n.ts'), 'utf8');168    const live = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../src/derivatives/live.ts'), 'utf8');169    const known = [...live.slice(live.indexOf('const KNOWN = new Set(['), live.indexOf(']);', live.indexOf('const KNOWN = new Set(['))).matchAll(/'([a-z_0-9]+)'/g)].map((m) => m[1]!);170    expect(known.length).toBeGreaterThan(40);171    const need = [...known.map((c) => `flE_${c}`), 'flE_halted_423', 'flE_signer_other', 'flE_other', 'flE_passkey_cancelled', 'flE_passkey_failed', 'flE_canonical_mismatch',172      ...['proposed', 'risk_checked', 'authorized', 'signed', 'submitted', 'unknown', 'open', 'partially_filled', 'filled', 'cancelled', 'rejected'].flatMap((s) => [`flSt_${s}`, `flOut_${s}`]), 'flOut_partially_filled_final',173      'flTif_gtc', 'flTif_ioc', 'flTif_alo', 'flLev_confirmed', 'flLev_rejected', 'flLev_unknown', 'flToneSim', 'flToneTestnet', 'flToneReal',174      ...['instruments', 'tooManyInstruments', 'leverage', 'leverageVenue', 'order', 'position', 'gross', 'orderAbovePosition', 'positionAboveGross', 'ordersPerDay', 'band', 'days'].map((p) => `flPol_${p}`)];175    for (const k of need) { const m = new RegExp(`\\b${k}: \\['([^']+)',\\s*'([^']+)'\\]`).exec(src); expect(m, k).not.toBeNull(); expect(m![1]!.length, k).toBeGreaterThan(3); expect(m![2]!.length, k).toBeGreaterThan(3); }176    // the three labels can never be confused with each other, in either language177    const tone = (k: string) => new RegExp(`\\b${k}: \\['([^']+)',\\s*'([^']+)'\\]`).exec(src)!;178    expect(tone('flToneTestnet')[1]).toMatch(/Testnet.*fictif/); expect(tone('flToneReal')[1]).toMatch(/Réel.*réel/); expect(tone('flToneReal')[2]).toMatch(/real money/i);179    // the live keys form ONE contiguous block180    const first = src.indexOf('\n  fl'), end = src.indexOf('// end of the `fl` block');181    expect(first).toBeGreaterThan(0); expect(end).toBeGreaterThan(first);182    expect(/\n  fl[A-Z]/.test(src.slice(end))).toBe(false);183  });184});185186describe('account state from the stream', () => {187  const order = (o: Partial<LiveOrder>): LiveOrder => ({ clientId: 'k1', cloid: '0x1', instrument: 'hyperliquid:BTC', side: 'buy', type: 'limit', size: '0.001', price: '73000', triggerPrice: null, reduceOnly: false, tif: 'gtc', state: 'unknown', final: false,188    venueOrderId: null, filled: '0', avgFillPrice: null, reason: null, origin: 'owner', hash: null, nonce: 1, expiresAt: 2, canonical: null, createdAt: 1, updatedAt: 10, submittedAt: 5, source: 'local', ...o });189  const detail = (): LiveDetail => ({ id: 'a', venue: 'hyperliquid', network: 'testnet', kind: 'venue', mode: 'MANUAL_LIVE', label: 'x', account: '0x1', agentAddress: '0x2', policy: {} as never, halt: null, divergence: null, halted: null, mainnetEnabled: false, tradable: true,190    authorization: null, pendingAuthorization: null, createdAt: 0, updatedAt: 0, venueState: { state: { equity: '10' } as never, positions: [], openOrders: [], error: null, ts: 1, source: 'venue' }, orders: [order({})], actions: [], fills: [], funding: [], riskEvents: [], equity: [], ts: 0 });191  it('an order event replaces the order with the same client id, never with an older state', () => {192    const d = applyLiveEvent(detail(), 'order', order({ state: 'open', updatedAt: 20, source: 'venue' }));193    expect(d.orders).toHaveLength(1); expect(d.orders[0]!.state).toBe('open');194    expect(mergeOrder(d.orders, order({ state: 'unknown', updatedAt: 15 }))[0]!.state).toBe('open');                     // a late, older event changes nothing195    expect(mergeOrder(d.orders, order({ clientId: 'k2' }))).toHaveLength(2);196  });197  it('an account event that could not read the venue never empties the account', () => {198    const d0 = detail(); d0.venueState.positions = [{ instrument: 'hyperliquid:BTC' } as never];199    const d = applyLiveEvent(d0, 'account', { state: null, positions: [], openOrders: [], error: 'timeout', ts: 9, divergence: null, halt: null, mode: 'MANUAL_LIVE' });200    expect(d.venueState.error).toBe('timeout'); expect(d.venueState.state).toEqual({ equity: '10' });201    const ok = applyLiveEvent(d, 'account', { state: { equity: '11' }, positions: [], openOrders: [], ts: 12, divergence: 'position …', halt: null, mode: 'MANUAL_LIVE', source: 'venue' });202    expect(ok.venueState.error).toBeNull(); expect(ok.venueState.state).toEqual({ equity: '11' }); expect(ok.divergence).toBe('position …');203  });204  it('risk and unknown events', () => {205    expect(applyLiveEvent(detail(), 'risk', { ts: 5, kind: 'halt', code: 'halted', message: 'halted by the owner', data: {} }).riskEvents[0]).toMatchObject({ kind: 'halt', code: 'halted' });206    const d = detail(); expect(applyLiveEvent(d, 'whatever', { x: 1 })).toBe(d); expect(applyLiveEvent(d, 'order', null)).toBe(d);207  });208  it('labels and in-flight states', () => {209    expect(toneOf({ kind: 'paper', network: 'mainnet' })).toBe('paper'); expect(toneOf({ kind: 'venue', network: 'testnet' })).toBe('testnet'); expect(toneOf({ kind: 'venue', network: 'mainnet' })).toBe('mainnet');210    expect(toneOf({ kind: 'venue', network: 'anything-else' })).toBe('mainnet');                                                   // never "play money" by default211    expect(['authorized', 'signed', 'submitted', 'unknown'].every((s) => inFlight(s as never))).toBe(true);212    expect(['open', 'filled', 'rejected', 'cancelled', 'risk_checked'].some((s) => inFlight(s as never))).toBe(false);213  });214});215
Files main
Menu

Theme