SPB Git

Repository Public

perp-agent.test.ts 49.4 KB 599 lines typescript

1// Perp agents in SIMULATION (V6; the LIVE side is perp-agent-live.test.ts). Three layers, no network, no signer:2//  1. the pure parts of the engine (weights, scaling, targets, order plan, gate checks);3//  2. the engine step against PostgreSQL (database spw_perp_agent_test, created here if missing) with a FAKE executor, gate and4//     signal: one decision per bar, holds, restart idempotency, loss limits, account events;5//  3. the real thing end to end on fixture-backed venue adapters: routes, the bar store and its gate, the paper executor on a6//     real paper account (risk engine + paper engine), and the laboratory's refusal to mix spot and perp strategies.7import { createHash, randomBytes, randomUUID } from 'node:crypto';8import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';9import { D, type Dec, type Instrument, type OrderRequest, type Position } from '@spw/derivatives';10import { BTC, Clock, T0, fixtureVenues, setMarket, useDatabase, type Venues } from './derivatives-helpers.ts';1112type Eng = typeof import('../src/agents/perp-engine.ts');13let sql: any, eng: Eng, pd: typeof import('../src/agents/perp-data.ts'), ps: typeof import('../src/agents/perp-strategies.ts'), pl: typeof import('../src/agents/perp-live.ts');14let mod: typeof import('../src/derivatives/index.ts'), wk: typeof import('../src/derivatives/worker.ts');1516beforeAll(async () => {17  await useDatabase('perp_agent');18  const db = await import('../src/db.ts');19  sql = db.sql;20  await db.migrate(); await db.migrate();21  eng = await import('../src/agents/perp-engine.ts'); pd = await import('../src/agents/perp-data.ts'); ps = await import('../src/agents/perp-strategies.ts'); pl = await import('../src/agents/perp-live.ts');22  mod = await import('../src/derivatives/index.ts'); wk = await import('../src/derivatives/worker.ts');23});24afterAll(async () => { await sql?.end(); });2526const d = (x: string): Dec => D.from(x);27const RISK = { maxLeverage: 2, maxGrossExposureX: 1.5, maxPositionX: 1, maxDailyLossPct: 3, maxTotalLossPct: 10, slippageBps: 30, minTradePct: 2 };28const META = { max_leverage: 2, max_gross_exposure: 2 };29const strs = (o: Record<string, Dec>) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, D.str(v)]));3031describe('perp engine: signed weights', () => {32  it('accepts signed weights inside the META bounds pinned at registration, and nothing else', () => {33    const ok = eng.checkPerpWeights({ weights: { BTC: 0.5, ETH: -0.4 }, maxLeverage: 2, maxGrossExposure: 2 }, ['BTC', 'ETH', 'SOL'], META);34    expect(strs(ok)).toEqual({ BTC: '0.5', ETH: '-0.4', SOL: '0' });35    const bad: unknown[] = [null, { weights: [], maxLeverage: 2, maxGrossExposure: 2 }, { weights: { DOGE: 0.1 }, maxLeverage: 2, maxGrossExposure: 2 }, { weights: { BTC: 2.5 }, maxLeverage: 2, maxGrossExposure: 2 },36      { weights: { BTC: -2.5 }, maxLeverage: 2, maxGrossExposure: 2 }, { weights: { BTC: 1.5, ETH: -1.5 }, maxLeverage: 2, maxGrossExposure: 2 }, { weights: { BTC: NaN }, maxLeverage: 2, maxGrossExposure: 2 },37      { weights: { BTC: '0.5' }, maxLeverage: 2, maxGrossExposure: 2 }, { weights: { BTC: 0.5 }, maxLeverage: 5, maxGrossExposure: 2 }, { weights: { BTC: 0.5 } }];38    for (const b of bad) expect(() => eng.checkPerpWeights(b, ['BTC', 'ETH', 'SOL'], META)).toThrow();39  });4041  it('scales every weight by ONE factor ≤ 1 to the owner\'s caps, and never up', () => {42    const eq = d('10000'), cap = d('10000');43    // inside every cap: untouched44    const a = eng.scalePerpWeights({ BTC: d('0.5'), ETH: d('-0.4') }, eq, cap, RISK);45    expect([D.str(a.factor), a.binding, strs(a.scaled)]).toEqual(['1', 'none', { BTC: '0.5', ETH: '-0.4' }]);46    // tiny weights are not raised to the caps47    expect(D.str(eng.scalePerpWeights({ BTC: d('0.01'), ETH: d('0') }, eq, cap, RISK).factor)).toBe('1');48    // gross 4 × equity = 40 000 against: leverage 2 × 10 000 × 0.9 = 18 000, gross 1.5 × 10 000 × 0.977 = 14 655, position 9 770 (wanted 20 000)49    const b = eng.scalePerpWeights({ BTC: d('2'), ETH: d('-2') }, eq, cap, RISK);50    expect([D.str(b.factor), b.binding, strs(b.scaled)]).toEqual(['0.366375', 'gross', { BTC: '0.73275', ETH: '-0.73275' }]);51    // the leverage cap binds on a shrunken equity (2 × 3 000 × 0.9 = 5 400 against 0.9 × 2 × 3 000... wanted 6 000)52    const c = eng.scalePerpWeights({ BTC: d('1'), ETH: d('-1') }, d('3000'), cap, RISK);53    expect([D.str(c.factor), c.binding]).toEqual(['0.9', 'leverage']);54    // one instrument above the per-instrument cap pulls ALL weights down by the same factor (proportions kept)55    const e = eng.scalePerpWeights({ BTC: d('1.2'), ETH: d('-0.2') }, eq, cap, RISK);56    expect(e.binding).toBe('position');57    expect(D.str(D.div(e.scaled.BTC!, e.scaled.ETH!, 6, 'half_even'))).toBe('-6');58    expect(D.lte(D.mul(e.scaled.BTC!, eq), d('9770'))).toBe(true);59    // no equity: nothing is targeted60    expect(strs(eng.scalePerpWeights({ BTC: d('1') }, d('0'), cap, RISK).scaled)).toEqual({ BTC: '0' });61  });6263  it('computes target sizes in base units from equity and mark, exactly, truncated to the lot', () => {64    expect(D.str(eng.targetSize(d('0.5'), d('10000'), d('50000'), d('0.00001')))).toBe('0.1');65    expect(D.str(eng.targetSize(d('-0.4'), d('10000'), d('2500'), d('0.0001')))).toBe('-1.6');66    // 0.33 × 9 876.54 / 103 456.7 = 0.031504… → truncated toward zero on the lot, short or long67    expect(D.str(eng.targetSize(d('0.33'), d('9876.54'), d('103456.7'), d('0.00001')))).toBe('0.0315');68    expect(D.str(eng.targetSize(d('-0.33'), d('9876.54'), d('103456.7'), d('0.001')))).toBe('-0.031');69    expect(D.str(eng.targetSize(d('0.5'), d('10000'), d('0'), d('0.001')))).toBe('0');70    // exact where floats are not: 0.1 + 0.2 style inputs71    expect(D.str(eng.targetSize(d('0.3'), d('0.3'), d('0.1'), d('0.0000000001')))).toBe('0.9');72  });73});7475describe('perp engine: order plan', () => {76  const marks = { BTC: d('50000'), ETH: d('2500'), SOL: d('100') }, min = { BTC: d('10'), ETH: d('10'), SOL: d('10') }, thr = d('200');77  const plan = (t: Record<string, string>, c: Record<string, string>) => eng.planPerpOrders(Object.fromEntries(Object.entries(t).map(([k, v]) => [k, d(v)])), Object.fromEntries(Object.entries(c).map(([k, v]) => [k, d(v)])), marks, min, thr)78    .map((o) => `${o.phase} ${o.symbol} ${o.side} ${o.size}${o.reduceOnly ? ' RO' : ''}${o.afterClose ? ' after-close' : ''}`);7980  it('orders are the deltas; every reducing order comes before every opening one (margin is freed first)', () => {81    // BTC long 0.2 → 0.05 (reduce 7 500), ETH flat → short 1.6 (open 4 000), SOL long 30 → 60 (increase 3 000)82    expect(plan({ BTC: '0.05', ETH: '-1.6', SOL: '60' }, { BTC: '0.2', SOL: '30' })).toEqual(['reduce BTC sell 0.15 RO', 'open ETH sell 1.6', 'open SOL buy 30']);83    // the small reduce still goes before the large open84    expect(plan({ BTC: '1', SOL: '25' }, { SOL: '30' })).toEqual(['reduce SOL sell 5 RO', 'open BTC buy 1']);85  });8687  it('a flip long → short is a reduce-only close of the whole position, THEN an open — never one crossing order', () => {88    expect(plan({ BTC: '-0.1' }, { BTC: '0.2' })).toEqual(['reduce BTC sell 0.2 RO', 'open BTC sell 0.1 after-close']);89    expect(plan({ ETH: '2' }, { ETH: '-1' })).toEqual(['reduce ETH buy 1 RO', 'open ETH buy 2 after-close']);90    // two flips and an increase: both closes first (largest first), then the opens91    expect(plan({ BTC: '-0.1', ETH: '2', SOL: '40' }, { BTC: '0.2', ETH: '-1', SOL: '30' })).toEqual(['reduce BTC sell 0.2 RO', 'reduce ETH buy 1 RO', 'open BTC sell 0.1 after-close', 'open ETH buy 2 after-close', 'open SOL buy 10']);92  });9394  it('a rebalance below the threshold is not traded; a full exit always is', () => {95    expect(plan({ BTC: '0.203' }, { BTC: '0.2' })).toEqual([]);                    // 150 USD < 20096    expect(plan({ BTC: '0.197' }, { BTC: '0.2' })).toEqual([]);97    expect(plan({ BTC: '0' }, { BTC: '0.001' })).toEqual(['reduce BTC sell 0.001 RO']);   // 50 USD, but an exit98    expect(plan({ SOL: '-1' }, { SOL: '1' })).toEqual(['reduce SOL sell 1 RO']);    // the flip closes; the 100 USD open is below the threshold99    expect(plan({ BTC: '0.2' }, { BTC: '0.2' })).toEqual([]);100    // the venue's minimum notional (10 USD, with a 5 % margin) also applies when the threshold is lower101    expect(eng.planPerpOrders({ SOL: d('0.1') }, {}, marks, min, d('1'))).toEqual([]);102    expect(eng.planPerpOrders({ SOL: d('0.11') }, {}, marks, min, d('1'))).toHaveLength(1);103  });104105  it('the owner\'s limits become the account\'s risk policy, in USDC', () => {106    expect(eng.perpPolicy(['BTC', 'ETH'], '2000', RISK)).toEqual({ mode: 'PAPER', allowedInstruments: ['hyperliquid:BTC', 'hyperliquid:ETH'], halt: null, maxDataAgeMs: 10_000, maxDeviationBps: '200', maxMarkIndexBps: '100',107      maxLeverage: '2', maxOrderNotional: '2000', maxGrossExposure: '3000', maxPositionNotional: '2000', minFreeCollateral: '100', minLiquidationDistanceBps: '1000', maxDailyLoss: '60', maxTotalLoss: '200',108      maxOrdersPerMinute: 40, maxOrdersPerDay: 400 });109  });110111  it('loss limits: total against the capital, daily against the equity at the start of the day', () => {112    expect(eng.lossBreach(d('9100'), d('10000'), d('9100'), RISK)).toBeNull();113    expect(eng.lossBreach(d('9000'), d('10000'), d('9000'), RISK)).toMatch(/total loss limit/);114    expect(eng.lossBreach(d('9900'), d('10000'), d('10200'), RISK)).toMatch(/daily loss limit/);115    expect(eng.lossBreach(d('10500'), d('10000'), d('10700'), RISK)).toBeNull();116  });117});118119describe('perp data gate (pure checks)', () => {120  const H = 3_600_000, t0 = Math.floor(T0 / H) * H;          // T0 = 03:05:30 → the last closed bar opens at 02:00121  const series = (n = 6) => {122    const bars = Array.from({ length: n }, (_, i) => ({ ts: t0 - (n - i) * H, open: 100 + i, high: 103 + i, low: 99 + i, close: 101 + i, volume: 5 }));123    return { bars, funding: bars.map((b) => ({ ts: b.ts, rate: 0.0000125, premium: null })), lastClose: String(bars[n - 1]!.close) };124  };125  const check = (s: ReturnType<typeof series>, lookback = 6, now = T0) => pd.checkSeries('BTC', s, { interval: '1h', lookback, now });126  const code = (f: () => unknown) => { try { f(); return 'ok'; } catch (e) { return (e as { code?: string }).code ?? String(e); } };127128  it('passes on the latest closed bar with a gap-free lookback and aligned funding', () => { expect(check(series())).toBe(t0 - H); });129  it('fails closed on anything else', () => {130    expect(code(() => check(series(), 7))).toBe('history');131    expect(code(() => check(series(), 6, T0 + H))).toBe('stale');                                       // an hour later the newest closed bar is missing132    const gap = series(); gap.bars.splice(2, 1); gap.bars.unshift({ ...gap.bars[0]!, ts: gap.bars[0]!.ts - H });133    expect(code(() => check(gap))).toBe('gap');134    const open = series(); open.bars.push({ ...open.bars[5]!, ts: t0 });135    expect(code(() => check(open))).toBe('unclosed');136    const bad = series(); bad.bars[3]!.low = 200;137    expect(code(() => check(bad))).toBe('bad_bar');138    const noLast = series(); noLast.funding.pop();139    expect(code(() => check(noLast))).toBe('funding_stale');                                            // the settlement of the decision bar's open is not there140    const two = series(); two.funding.splice(1, 2);141    expect(code(() => check(two))).toBe('funding_gap');142    const one = series(); one.funding.splice(2, 1);143    expect(code(() => check(one))).toBe('ok');                                                          // one isolated settlement missing is tolerated (and stated)144    const future = series(); future.funding.push({ ts: t0, rate: 0.1, premium: null });145    expect(code(() => check(future))).toBe('future_funding');                                           // a rate settled after the decision bar is never an input146  });147  it('cross-checks the live mark against the venue\'s spot index, or the spot market when there is no index', () => {148    const tk = (o: Record<string, unknown>) => ({ instrument: BTC, mark: '100', index: '100', mid: '100', funding: '0', openInterest: null, volume24h: null, change24h: null, ts: T0, ...o }) as never;149    const run = (o: Record<string, unknown>, last: string | null = '100', spot: string | null = null) => code(() => pd.checkMark('BTC', tk(o), last, spot, T0));150    expect(run({})).toBe('ok');151    expect(run({ mark: '100.9' })).toBe('ok');152    expect(run({ mark: '101.1' })).toBe('mark_index');                                                  // band: 100 bps153    expect(run({ ts: T0 - 20_000 })).toBe('stale_ticker');154    expect(run({ index: null })).toBe('no_reference');155    expect(run({ index: null }, '100', '102')).toBe('ok');156    expect(run({ index: null }, '100', '104')).toBe('mark_spot');                                       // band: 300 bps157    expect(run({}, '80')).toBe('last_close');                                                           // the stored series is not this market's158    expect(run({ mark: '0' })).toBe('bad_mark');159  });160});161162describe('strategy kinds never cross', () => {163  it('fit: same instruments, same interval, no spot legs', () => {164    const info = { spotLegs: false, meta: { name: 'x', version: '1', kind: 'perp' as const, universe: ['BTC', 'ETH'], interval: '1h', lookback: 10, max_leverage: 2, max_gross_exposure: 2, params: {} } };165    expect(ps.fitsPerpAgent(info, ['ETH', 'BTC'], '1h')).toEqual({ ok: true });166    expect(ps.fitsPerpAgent(info, ['BTC'], '1h')).toMatchObject({ ok: false, code: 'universe_mismatch' });167    expect(ps.fitsPerpAgent(info, ['BTC', 'ETH'], '4h')).toMatchObject({ ok: false, code: 'interval_mismatch' });168    expect(ps.fitsPerpAgent({ ...info, spotLegs: true }, ['BTC', 'ETH'], '1h')).toMatchObject({ ok: false, code: 'spot_leg_unsupported' });169  });170  it('a report is approvable only as a version-2 report for this hash and these instruments with its integrity checks passing', () => {171    const good = { schema_version: 2, kind: 'perp', strategy_sha256: 'a'.repeat(64), config: { symbols: ['BTC'] }, checks: { lookahead: 'pass', ledger: 'pass', funding_alignment: 'pass', data_quality: 'fail' } };172    expect(ps.perpReportProblem(good, 'a'.repeat(64), ['BTC'])).toBeNull();173    expect(ps.perpReportProblem({ ...good, schema_version: 1 }, 'a'.repeat(64), ['BTC'])).toMatch(/version-2/);174    expect(ps.perpReportProblem({ ...good, kind: undefined }, 'a'.repeat(64), ['BTC'])).toMatch(/version-2/);175    expect(ps.perpReportProblem(good, 'b'.repeat(64), ['BTC'])).toMatch(/another code hash/);176    expect(ps.perpReportProblem(good, 'a'.repeat(64), ['BTC', 'ETH'])).toMatch(/other instruments/);177    expect(ps.perpReportProblem({ ...good, checks: { ...good.checks, lookahead: 'fail' } }, 'a'.repeat(64), ['BTC'])).toMatch(/lookahead/);178  });179  it('the laboratory itself refuses the wrong contract: describe and signal, both ways', async () => {180    const spot = await import('../src/agents/strategies.ts'), live = await import('../src/agents/live.ts'), sig = await import('../src/agents/perp-signal.ts');181    // spot listing path (no --contract 2): a perp strategy is "not loadable: not offered"182    await expect(spot.describe('ts_momentum_perp/1')).rejects.toMatchObject({ code: 'bad_strategy' });183    // perp listing path: a spot strategy is refused, a perp one is described with its bounds184    await expect(ps.describePerp('ts_momentum/3')).rejects.toMatchObject({ code: 'wrong_kind' });185    const p = await ps.describePerp('pairs_coint_perp/1');186    expect(p).toMatchObject({ kind: 'perp', spotLegs: false, meta: { kind: 'perp', universe: ['BTC', 'ETH'], interval: '1h', max_leverage: 2 } });187    expect((await ps.describePerp('funding_carry_neutral_perp/1')).spotLegs).toBe(true);188    // signal runners: the spot runner on a perp strategy, the perp runner on a spot strategy — both fail before any weight is read189    const H = 3_600_000, t0 = Math.floor(T0 / H) * H, bars = Array.from({ length: 400 }, (_, i) => ({ ts: t0 - (400 - i) * H, open: 100, high: 101, low: 99, close: 100, volume: 1 }));190    await expect(live.runSignal({ path: p.path, sha256: p.sha256 }, { BTC: bars, ETH: bars }, '1h')).rejects.toThrow(/signal exit 2/);191    const s3 = await spot.describe('ts_momentum/3'), series = { bars, funding: bars.map((b) => ({ ts: b.ts, rate: 0.00001, premium: null })), lastClose: '100' };192    await expect(sig.runPerpSignal({ path: s3.path, sha256: s3.sha256 }, { BTC: series }, '1h')).rejects.toThrow(/signal exit 2/);193  }, 120_000);194});195196// ------------------------------------------------------------------ engine step with fakes197198const inst = (symbol: string, lot: string): Instrument => ({ venue: 'hyperliquid', symbol, id: `hyperliquid:${symbol}`, base: symbol, quote: 'USD', kind: 'perpetual', collateral: 'USDC', settlement: '', tickSize: '0.01', lotSize: lot,199  minOrderSize: null, minNotional: '10', maxLeverage: '40', marginModes: ['cross', 'isolated'], fundingIntervalSec: 3600, markPriceMethod: '', status: 'active' });200const INST: Record<string, Instrument> = { BTC: inst('BTC', '0.00001'), ETH: inst('ETH', '0.0001') };201202/** an in-memory account that fills at the mark; it records every call and honours the idempotency key like the paper service */203class FakeExec {204  collateral = d('10000'); pos = new Map<string, { size: Dec; entry: Dec }>(); halt: string | null = null; events: { id: number; ts: number; kind: string; code: string | null; message: string }[] = [];205  calls: { clientId: string; symbol: string; side: string; size: string; reduceOnly: boolean; price: string | null; tif: string; type: string; leverage: string }[] = [];206  first = new Map<string, any>(); throwAt = 0; fillFraction = '1';207  marks: Record<string, string>;208  constructor(marks: Record<string, string>) { this.marks = marks; }209  equity(): Dec { let e = this.collateral; for (const [s, p] of this.pos) e = D.add(e, D.mul(p.size, D.sub(d(this.marks[s]!), p.entry))); return e; }210  async account(since = 0) {211    return { state: { venue: 'hyperliquid' as const, account: 'fake', equity: D.str(this.equity()), collateral: D.str(this.collateral), freeCollateral: D.str(this.equity()), marginUsed: '0', maintenanceMargin: null, withdrawable: null, ts: 0, source: 'paper' as const },212      policy: eng.perpPolicy(['BTC', 'ETH'], '10000', RISK, this.halt), events: this.events.filter((e) => e.id > since) };213  }214  async positions(): Promise<Position[]> {215    return [...this.pos].map(([s, p]) => ({ instrument: `hyperliquid:${s}`, side: D.isPos(p.size) ? 'long' as const : 'short' as const, size: D.str(D.abs(p.size)), entryPrice: D.str(p.entry), markPrice: this.marks[s]!, liquidationPrice: null,216      leverage: '2', marginMode: 'cross' as const, margin: '0', unrealizedPnl: '0', realizedPnl: '0', fundingPaid: '0', source: 'paper' as const }));217  }218  async place(o: OrderRequest, leverage: string) {219    const symbol = o.instrument.split(':')[1]!;220    this.calls.push({ clientId: o.clientId, symbol, side: o.side, size: o.size, reduceOnly: o.reduceOnly, price: o.price, tif: o.tif, type: o.type, leverage });221    if (this.throwAt && this.calls.length === this.throwAt) throw new Error('venue unavailable (scripted)');222    const prior = this.first.get(o.clientId);223    if (prior) return { ...prior, idempotent: true };224    const cur = this.pos.get(symbol), before = cur?.size ?? D.zero, sign = o.side === 'buy' ? D.one : D.neg(D.one);225    if (this.halt && !o.reduceOnly) return { status: 'refused' as const, code: 'halted', reason: `trading is halted: ${this.halt}` };226    if (o.reduceOnly && (D.isZero(before) || D.sign(before) === D.sign(sign) || D.gt(d(o.size), D.abs(before)))) return { status: 'refused' as const, code: 'reduce_only_would_increase', reason: 'reduce-only order would open or flip' };227    const size = D.mul(d(o.size), d(this.fillFraction)), mark = d(this.marks[symbol]!), after = D.add(before, D.mul(sign, size));228    if (cur && D.sign(before) !== D.sign(sign)) this.collateral = D.add(this.collateral, D.mul(D.mul(D.min(size, D.abs(before)), D.sub(mark, cur.entry)), D.from(BigInt(D.sign(before)))));   // realised PnL of the closed part229    if (D.isZero(after)) this.pos.delete(symbol);230    else this.pos.set(symbol, { size: after, entry: cur && D.sign(after) === D.sign(before) && D.gt(D.abs(after), D.abs(before)) ? D.div(D.add(D.mul(D.abs(before), cur.entry), D.mul(size, mark)), D.abs(after), 12, 'half_even') : cur && D.sign(after) === D.sign(before) ? cur.entry : mark });231    const full = this.fillFraction === '1';232    const res = { status: 'placed' as const, order: { ...o, venueOrderId: `f-${this.calls.length}`, status: full ? 'filled' as const : 'cancelled' as const, filled: D.str(size), avgFillPrice: D.str(mark), createdAt: 0, updatedAt: 0, rejectReason: full ? null : 'unfilled remainder cancelled' }, fills: [], idempotent: false };233    this.first.set(o.clientId, res);234    return res;235  }236}237238const H = 3_600_000, BAR0 = Math.floor(T0 / H) * H - H;239interface World { id: string; ex: FakeExec; clock: Clock; bar: number; weights: Record<string, number> | Error; gateError: Error | null; signals: number; deps: import('../src/agents/perp-engine.ts').PerpEngineDeps; row: () => Promise<any>; tick: () => Promise<string>; sha: string }240async function world(o: { risk?: Partial<typeof RISK>; approved?: boolean; meta?: Record<string, unknown> } = {}): Promise<World> {241  const id = randomUUID(), sv = randomUUID(), sha = createHash('sha256').update(id).digest('hex');242  const [{ next }] = await sql`select least(coalesce(min(vault_index), 0), 0) - 1 as next from agents`;243  await sql`insert into agents (id, kind, name, network, universe, interval, risk, vault_index, vault_address, capital_usdc, deriv_account_id, state, mode, strategy_version_id)244    values (${id}, 'perp', 'fake', 'hyperliquid', ${sql.json(['BTC', 'ETH'])}, '1h', ${sql.json({ ...RISK, ...o.risk })}, ${next}, '', '10000000000', ${randomUUID()}, 'PAPER_TRADING', 'PAPER', ${sv})`;245  await sql`insert into strategy_versions (id, agent_id, name, version, code_sha256, path, meta, status, report) values (${sv}, ${id}, 'fake_perp', '1', ${sha}, 'strategies/fake_perp/1',246    ${sql.json({ kind: 'perp', lookback: 10, max_leverage: 2, max_gross_exposure: 2, ...o.meta })}, ${o.approved === false ? 'draft' : 'approved'}, ${sql.json({})})`;247  const clock = new Clock(), ex = new FakeExec({ BTC: '50000', ETH: '2500' });248  const w: World = { id, ex, clock, bar: BAR0, weights: { BTC: 0.5, ETH: -0.4 }, gateError: null, signals: 0, sha,249    deps: {250      now: clock.now,251      gate: async () => { if (w.gateError) throw w.gateError; return { bar: w.bar, data: {} }; },252      signal: async () => { w.signals += 1; if (w.weights instanceof Error) throw w.weights; return { weights: w.weights, maxLeverage: 2, maxGrossExposure: 2 }; },253      market: async (s) => { const m = Number(ex.marks[s]); return { inst: INST[s]!, ticker: { instrument: `hyperliquid:${s}`, mark: ex.marks[s]!, index: ex.marks[s]!, mid: ex.marks[s]!, funding: '0', openInterest: null, volume24h: null, change24h: null, ts: clock.now() },254        book: { instrument: `hyperliquid:${s}`, bids: [[String(m - 1), '1000']], asks: [[String(m + 1), '1000']], ts: clock.now() } }; },255      executor: () => ex as never,256    },257    row: async () => (await sql`select * from agents where id = ${id}`)[0],258    tick: async () => eng.tickPerpAgent(await w.row(), w.deps) };259  return w;260}261const signalRows = (id: string) => sql`select bar, status, reason, weights, scaled, scale, targets, orders from perp_agent_signals where agent_id = ${id} order by bar`;262const size = (w: World, s: string) => { const p = w.ex.pos.get(s); return p ? D.str(p.size) : '0'; };263264describe('perp engine step (fake executor)', () => {265  it('opens a long and a short on the first closed bar, as IOC market orders bounded by the slippage limit, once', async () => {266    const w = await world();267    expect(await w.tick()).toBe('2 order(s)');268    expect([size(w, 'BTC'), size(w, 'ETH')]).toEqual(['0.1', '-1.6']);                 // 0.5 × 10 000 / 50 000 and −0.4 × 10 000 / 2 500269    expect(w.ex.calls.map((c) => `${c.symbol} ${c.side} ${c.size} ${c.type}/${c.tif} @${c.price} lev ${c.leverage}`)).toEqual(['BTC buy 0.1 market/ioc @50151 lev 2', 'ETH sell 1.6 market/ioc @2491.6 lev 2']);   // best ask + 30 bps (rounded down), best bid − 30 bps (rounded up), on the venue's 5-significant-figure grid270    const [s] = await signalRows(w.id);271    expect(s).toMatchObject({ status: 'done', weights: { BTC: 0.5, ETH: -0.4 }, scaled: { BTC: '0.5', ETH: '-0.4' }, scale: '1', targets: { BTC: '0.1', ETH: '-1.6' } });272    expect(s.orders.list.map((x: any) => x.status)).toEqual(['filled', 'filled']);273    expect((await w.row()).last_bar.getTime()).toBe(BAR0);274    // the same bar again: one decision per bar275    expect(await w.tick()).toBe('waiting for the next bar');276    expect([w.signals, w.ex.calls.length]).toEqual([1, 2]);277    expect((await sql`select equity_usd from agent_equity where agent_id = ${w.id}`)[0].equity_usd).toBe(10000);278  });279280  it('rebalances on the next bar: closes before it opens, flips as close-then-open, and skips the open when the close is not complete', async () => {281    const w = await world();282    await w.tick();283    w.bar += H; w.clock.advance(H); w.weights = { BTC: -0.3, ETH: -0.6 };            // BTC long → short, ETH short larger284    expect(await w.tick()).toBe('3 order(s)');285    expect(w.ex.calls.slice(2).map((c) => `${c.symbol} ${c.side} ${c.size}${c.reduceOnly ? ' RO' : ''}`)).toEqual(['BTC sell 0.1 RO', 'BTC sell 0.06', 'ETH sell 0.8']);   // the close, then the opens by size (3 000 USD, 2 000 USD)286    expect([size(w, 'BTC'), size(w, 'ETH')]).toEqual(['-0.06', '-2.4']);287    // next bar: flip back, but the closing order only half fills → the opening half of the flip is NOT sent288    w.bar += H; w.clock.advance(H); w.weights = { BTC: 0.3, ETH: -0.6 }; w.ex.fillFraction = '0.5';289    expect(await w.tick()).toBe('1 order(s), 1 not sent');290    expect(size(w, 'BTC')).toBe('-0.03');291    const rows = await signalRows(w.id);292    expect(rows[2].orders.list.map((x: any) => [x.symbol, x.status])).toEqual([['BTC', 'cancelled'], ['BTC', 'skipped']]);293  });294295  it('holds on stale or inconsistent data: no signal, no order, the bar stays undecided', async () => {296    const w = await world();297    w.gateError = new pd.PerpDataError('stale', 'BTC: the latest closed bar is missing');298    expect(await w.tick()).toMatch(/^hold: BTC: the latest closed bar is missing/);299    expect([w.signals, w.ex.calls.length, (await signalRows(w.id)).length, (await w.row()).last_bar]).toEqual([0, 0, 0, null]);300    expect((await w.row()).perp.hold.reason).toMatch(/latest closed bar/);301    w.gateError = null;                                                              // data back: the same bar is decided now302    expect(await w.tick()).toBe('2 order(s)');303    expect((await w.row()).perp.hold).toBeNull();304  });305306  it('holds on a failed or invalid signal: the bar is recorded as a hold and nothing is traded', async () => {307    for (const bad of [new Error('signal exit 5: strategy failed'), { BTC: 2.5 }, { BTC: 1.5, ETH: -1.5 }, { DOGE: 0.2 }, { BTC: Number.NaN }] as (Record<string, number> | Error)[]) {308      const w = await world(); w.weights = bad;309      expect(await w.tick()).toBe('hold: no valid signal');310      expect(w.ex.calls).toHaveLength(0);311      const [s] = await signalRows(w.id);312      expect(s.status).toBe('hold'); expect(s.reason.length).toBeGreaterThan(5);313      expect((await w.row()).last_bar.getTime()).toBe(BAR0);314      expect(await w.tick()).toBe('waiting for the next bar');315      expect(w.signals).toBe(1);316    }317    // a version that is not approved, or not a contract-2 strategy, never runs318    const draft = await world({ approved: false });319    expect(await draft.tick()).toBe('strategy not approved');320    const spot = await world({ meta: { kind: 'spot' } });321    expect(await spot.tick()).toBe('stopped');322    expect([draft.signals, spot.signals]).toEqual([0, 0]);323  });324325  it('restart in the middle of a bar: the decision is read back, not taken again, and no order is placed twice', async () => {326    const w = await world();327    w.ex.throwAt = 2;                                                                // the process "dies" on the second order328    await expect(w.tick()).rejects.toThrow(/scripted/);329    expect(size(w, 'BTC')).toBe('0.1'); expect(size(w, 'ETH')).toBe('0');330    expect((await signalRows(w.id))[0].status).toBe('decided');331    expect((await w.row()).last_bar).toBeNull();332    // restart: a strategy that would now answer something else must not be asked again for this bar333    w.ex.throwAt = 0; w.weights = { BTC: -1, ETH: 1 };334    expect(await w.tick()).toBe('1 order(s)');335    expect(w.signals).toBe(1);336    expect([size(w, 'BTC'), size(w, 'ETH')]).toEqual(['0.1', '-1.6']);337    // crash AFTER the orders but before the bar was marked complete: every order is sent again with the same key and changes nothing338    await sql`update agents set last_bar = null where id = ${w.id}`;339    await sql`update perp_agent_signals set status = 'decided' where agent_id = ${w.id}`;340    w.ex.pos.clear();                                                                // as if the engine did not know the fills (worst case: it plans both orders again)341    const before = w.ex.first.size;342    await w.tick();343    expect(w.ex.first.size).toBe(before);                                            // no new client id: the two orders were the same two344    expect(new Set(w.ex.calls.map((c) => c.clientId)).size).toBe(2);345    expect(w.ex.calls.every((c) => c.clientId.startsWith(`ag-${w.id}-${w.sha.slice(0, 8)}-${BAR0 / 1000}-`))).toBe(true);346    expect(w.signals).toBe(1);347  });348349  it('loss limit: flattens with reduce-only orders, then stops — also when the close needs two passes', async () => {350    const w = await world();351    await w.tick();352    w.ex.marks = { BTC: '40000', ETH: '2600' };                                      // −1 000 on the long, −160 on the short: equity 8 840, limit 10 % of 10 000353    w.ex.fillFraction = '0.5';354    expect(await w.tick()).toBe('flattening: 2 position(s) still open');355    expect(w.ex.calls.slice(2).map((c) => `${c.symbol} ${c.side} ${c.size} RO:${c.reduceOnly}`)).toEqual(['BTC sell 0.1 RO:true', 'ETH buy 1.6 RO:true']);356    let a = await w.row();357    expect([a.state, a.perp.flatten.reason]).toEqual(['PAPER_TRADING', expect.stringMatching(/total loss limit reached: equity 8840 USDC against 10000 allocated \(limit 10 %\)/)]);358    w.ex.fillFraction = '1'; w.clock.advance(60_000);359    expect(await w.tick()).toBe('stopped: loss limit');360    a = await w.row();361    expect(a.state).toBe('STOPPED'); expect(a.stop_reason).toMatch(/total loss limit reached.*closed with reduce-only orders/);362    expect(w.ex.pos.size).toBe(0);363    expect(w.ex.calls.every((c, k) => k < 2 || c.reduceOnly)).toBe(true);364    expect(w.signals).toBe(1);                                                       // no new decision while flattening365    expect(await w.tick()).toBe('idle');366  });367368  it('closing works under a halt: the breaker of the account flattens and stops the agent, nothing new is opened', async () => {369    const w = await world();370    await w.tick();371    w.ex.halt = 'daily loss limit reached (set by the paper worker)';372    w.bar += H; w.clock.advance(H); w.weights = { BTC: 1, ETH: -1 };                 // a signal that wants more exposure373    expect(await w.tick()).toBe('stopped: loss limit');374    expect(w.ex.calls.slice(2).map((c) => c.reduceOnly)).toEqual([true, true]);375    expect([w.ex.pos.size, w.signals, (await w.row()).stop_reason]).toEqual([0, 1, expect.stringMatching(/circuit breaker/)]);376  });377378  it('daily loss limit, against the equity at the first pass of the UTC day', async () => {379    const w = await world({ risk: { maxDailyLossPct: 1 } });380    await w.tick();381    w.ex.marks = { BTC: '49000', ETH: '2500' };                                      // −100 = 1 % of the capital382    expect(await w.tick()).toBe('stopped: loss limit');383    expect((await w.row()).stop_reason).toMatch(/daily loss limit reached/);384  });385386  it('a liquidation of the account stops the agent; a margin warning flags it and trading goes on', async () => {387    const w = await world();388    w.ex.events.push({ id: 7, ts: T0, kind: 'margin_warning', code: 'margin_warning', message: 'maintenance margin has reached 80 % of equity' });389    expect(await w.tick()).toBe('2 order(s)');390    let a = await w.row();391    expect([a.state, a.perp.flag.message, a.perp.lastEventId]).toEqual(['PAPER_TRADING', 'maintenance margin has reached 80 % of equity', 7]);392    w.ex.events.push({ id: 9, ts: T0, kind: 'liquidation', code: 'liquidated', message: 'simulated liquidation: 2 position(s) closed at the mark' });393    expect(await w.tick()).toBe('stopped: liquidation');394    a = await w.row();395    expect([a.state, a.perp.lastEventId]).toEqual(['STOPPED', 9]);396    expect(a.stop_reason).toMatch(/liquidated \(simulated\)/);397  });398399  it('holds when the account carries a position the agent does not manage', async () => {400    const w = await world();401    w.ex.marks.SOL = '100'; w.ex.pos.set('SOL', { size: d('5'), entry: d('100') });402    expect(await w.tick()).toMatch(/hold: the account holds hyperliquid:SOL/);403    expect(w.ex.calls).toHaveLength(0);404  });405406  it('the spot engine never trades a perp agent', async () => {407    const w = await world(), spot = await import('../src/agents/engine.ts');408    expect(await spot.tickAgent(await w.row(), {} as never)).toBe('idle');409  });410});411412// ------------------------------------------------------------------ end to end on fixture venues: routes, bar store, paper account413414describe('perp agent through the API (fixture venues, real paper service)', () => {415  const ORIGIN = 'https://wallet.test';416  let app: any, cookie = '', clock: Clock, v: Venues, dv: import('../src/derivatives/index.ts').Derivatives, rt: import('../src/agents/perp-live.ts').PerpRuntime;417  let next: Record<string, number> = { BTC: 0.5 }, calls = 0, mid = 0;418  const api = async (method: 'GET' | 'POST', url: string, body?: unknown) => {419    const r = await app.inject({ method, url: `/api/v1${url}`, headers: { cookie, ...(method === 'POST' ? { origin: ORIGIN, 'x-spw-csrf': '1', 'content-type': 'application/json' } : {}) }, ...(method === 'POST' ? { payload: JSON.stringify(body ?? {}) } : {}) });420    return { status: r.statusCode, json: r.json() };421  };422  /** a new scripted market, a few seconds later (the data service's one-second caches are then stale) */423  const market = (m: number) => { mid = m; clock.advance(6_000); setMarket(v, m, { bids: [[String(m - 5), '5'], [String(m - 10), '5']], asks: [[String(m + 5), '5'], [String(m + 10), '5']] }); };424  const create = async (name: string, risk: Record<string, unknown> = {}) => (await api('POST', '/agents', { kind: 'perp', name, instruments: ['BTC'], interval: '1h', capitalUsdc: '10000', risk: { ...RISK, ...risk } })).json;425  const arm = async (id: string) => {426    const sv = randomUUID();427    await sql`insert into strategy_versions (id, agent_id, name, version, code_sha256, path, meta, status, report) values (${sv}, ${id}, 'fixed_perp', '1', ${createHash('sha256').update(id).digest('hex')}, 'strategies/fixed_perp/1',428      ${sql.json({ kind: 'perp', lookback: 10, max_leverage: 2, max_gross_exposure: 2 })}, 'approved', ${sql.json({})})`;429    await sql`update agents set strategy_version_id = ${sv} where id = ${id}`;430    expect((await api('POST', `/agents/${id}/mode`, { mode: 'PAPER' })).json.state).toBe('PAPER_TRADING');431  };432  const pass = async (id: string) => (await pl.perpEnginePass(rt, clock.now()))[id];433  const orders = (acct: string) => sql`select client_id, side, reduce_only, status, size::text, origin, type, tif from deriv_orders where account_id = ${acct} order by created_at, venue_order_id`;434435  beforeAll(async () => {436    await sql`delete from perp_agent_signals`; await sql`delete from agent_equity`; await sql`delete from research_log`; await sql`delete from strategy_versions`; await sql`delete from agents`;437    await sql`truncate deriv_paper_accounts cascade`; await sql`delete from perp_candles`; await sql`delete from perp_funding`;438    const token = randomBytes(32).toString('base64url');439    await sql`insert into sessions (id_hash, expires_at) values (${createHash('sha256').update(token).digest('hex')}, now() + interval '1 hour')`;440    cookie = `${(await import('../src/auth.ts')).COOKIE}=${token}`;441    clock = new Clock(); v = fixtureVenues(clock);442    // the recorded answers are static: like the venue, serve only what is at or after the requested start time443    const hl = v.adapters.hyperliquid, candles = hl.candles.bind(hl), funding = hl.fundingHistory.bind(hl);444    hl.candles = async (s, i, o) => (await candles(s, i, { limit: 5000 })).filter((b) => b.ts >= (o.start ?? 0) && b.ts <= (o.end ?? Infinity)).slice(0, o.limit ?? 500);445    hl.fundingHistory = async (s, o) => (await funding(s, o)).filter((f) => f.ts >= o.start);446    dv = mod.createDerivatives({ registry: mod.createRegistry(v.adapters, clock.now), now: clock.now });447    app = await (await import('../src/server.ts')).buildServer({ derivatives: dv });448    rebuild();449  });450  /** a new process on the same database: fresh caches, the scripted signal */451  const rebuild = () => {452    dv = mod.createDerivatives({ registry: mod.createRegistry(v.adapters, clock.now), now: clock.now });453    rt = pl.createPerpRuntime(dv, { signal: async () => { calls += 1; return { weights: next, maxLeverage: 2, maxGrossExposure: 2 }; } });454    pl.setPerpRuntime(rt);455  };456  afterAll(async () => { app?.server.closeAllConnections?.(); await app?.close(); });457  beforeEach(() => { v.hl.ticker = null; v.hl.book = null; v.hl.down = false; });458459  it('the bar store keeps the venue\'s closed bars and funding, asks nothing twice, and its gate fails closed', async () => {460    const btc = await dv.data.instrument(BTC), store = rt.store;461    const g = await store.gate(btc, '1h', 10);462    expect(g.bar).toBe(BAR0);463    expect([g.series.bars.length, g.series.funding.length, g.series.bars.at(-1)!.ts, g.series.funding.at(-1)!.ts]).toEqual([10, 10, BAR0, BAR0]);   // the 03:00 settlement and the bar in progress are not inputs464    expect((await sql`select count(*)::int as n from perp_candles where ts > ${new Date(BAR0)}`)[0].n).toBe(0);            // the bar in progress is never stored465    expect(store.stats).toEqual({ candles: 1, funding: 1 });466    await store.gate(btc, '1h', 10); await store.gate(btc, '1h', 10);467    expect(store.stats).toEqual({ candles: 1, funding: 1 });                                                               // complete window: no request468    mid = Math.round(Number(g.series.lastClose));469    // mark far from the venue's spot index → hold470    v.hl.ticker = { mark: String(mid), index: String(Math.round(mid * 1.02)), mid: String(mid), funding: '0.0000125' }; clock.advance(6_000);471    await expect(store.gate(btc, '1h', 10)).rejects.toMatchObject({ code: 'mark_index' });472    // venue unreachable → hold473    v.hl.ticker = null; v.hl.down = true; clock.advance(20_000);474    await expect(store.gate(btc, '1h', 10)).rejects.toMatchObject({ code: 'venue' });475    v.hl.down = false;476    // an hour later the next bar has closed: ONE small request each; the venue now serves a stored bar with another close → held once, then fine477    clock.advance(H);478    await sql`update perp_candles set close = close + 1 where ts = ${new Date(BAR0)}`;479    await expect(store.gate(btc, '1h', 10)).rejects.toMatchObject({ code: 'revised' });480    expect(store.stats).toEqual({ candles: 2, funding: 1 });481    expect((await store.gate(btc, '1h', 10)).bar).toBe(BAR0 + H);482    expect(store.stats).toEqual({ candles: 2, funding: 1 });                                                               // the 03:00 settlement was already stored: no funding request483    // two hours later the venue (a recorded answer) has no newer bar: the latest closed bar is missing → hold484    clock.advance(H);485    await expect(store.gate(btc, '1h', 10)).rejects.toMatchObject({ code: 'stale' });486    // our share of the venue's rate limit is spent before the request is sent487    const tight = new pd.PerpDataStore(dv.data, { now: clock.now, weightPerMinute: 30 });488    await sql`delete from perp_candles where ts >= ${new Date(BAR0)}`;489    await expect(tight.sync(btc, '1h', 10)).rejects.toMatchObject({ code: 'rate_budget' });490    expect(tight.stats.candles).toBe(0);491    clock.t = T0; await sql`delete from perp_candles`; await sql`delete from perp_funding`; rebuild();492  });493494  it('creates a perp agent with its own paper account and policy, without a vault, a mandate or a signer call', async () => {495    const a = await create('Perp one');496    expect(a).toMatchObject({ kind: 'perp', simulation: true, state: 'CREATED', mode: 'PAPER', venue: 'hyperliquid', universe: ['BTC'], instruments: [BTC], capitalUsdc: '10000', vaultAddress: '', mandateId: null, risk: RISK });497    const row = (await sql`select * from agents where id = ${a.id}`)[0];498    expect(row.vault_index).toBeLessThan(0);499    const det = (await api('GET', `/agents/${a.id}`)).json;500    expect(det.account).toMatchObject({ id: a.accountId, positions: [], state: { equity: '10000' }, policy: { mode: 'PAPER', allowedInstruments: [BTC], maxLeverage: '2', maxGrossExposure: '15000', maxPositionNotional: '10000', maxDailyLoss: '300', maxTotalLoss: '1000', halt: null } });501    expect(det.live).toMatchObject({ available: true, active: false, accountId: null, account: null, exit: null });   // V7: going live exists (perp-agent-live.test.ts); a new agent is in simulation502    expect(det).toMatchObject({ mode: 'PAPER', simulation: true, execution: { kind: 'paper' } });503    expect((await api('GET', '/agents')).json.agents.find((x: any) => x.id === a.id)).toMatchObject({ kind: 'perp', networkName: 'Hyperliquid' });504    // vault and mandate routes do not exist for it (and would have needed the signer, which is unreachable in this test)505    for (const r of ['mandate/prepare', 'mandate/approve', 'revoke', 'withdraw']) expect((await api('POST', `/agents/${a.id}/${r}`, r === 'withdraw' ? { asset: 'native' } : r === 'mandate/approve' ? { assertion: {} } : {})).json.error).toBe('perp_agent');506    expect((await api('GET', `/agents/${a.id}/elsewhere`)).status).toBe(409);507    // refused creations508    for (const bad of [{ instruments: ['NOPE'] }, { instruments: [] }, { instruments: ['BTC', 'BTC'] }, { capitalUsdc: '20' }, { risk: { ...RISK, maxLeverage: 9 } }, { risk: { ...RISK, maxDailyLossPct: 20 } }, { interval: '5m' }, { vault: 1 }]) {509      expect((await api('POST', '/agents', { kind: 'perp', name: 'bad', instruments: ['BTC'], capitalUsdc: '10000', ...bad })).status).toBeGreaterThanOrEqual(400);510    }511    expect((await sql`select count(*)::int as n from deriv_paper_accounts`)[0].n).toBe(1);                                   // no orphan account512    const ven = (await api('GET', '/agents/perp/venues')).json;513    expect(ven).toMatchObject({ maxInstruments: 8, live: { available: true } });514    expect(ven.venues[0].instruments.find((x: any) => x.symbol === 'BTC')).toMatchObject({ id: BTC, eligible: true });515    // approval needs a version-2 report whose integrity checks pass516    const sv = randomUUID();517    await sql`insert into strategy_versions (id, agent_id, name, version, code_sha256, path, meta, report) values (${sv}, ${a.id}, 'x_perp', '1', ${'c'.repeat(64)}, 'strategies/x_perp/1', ${sql.json({ kind: 'perp' })}, ${sql.json({ schema_version: 1 })})`;518    expect((await api('POST', `/agents/${a.id}/strategies/${sv}/approve`)).json).toMatchObject({ error: 'bad_report' });519    await sql`update strategy_versions set report = ${sql.json({ schema_version: 2, kind: 'perp', strategy_sha256: 'c'.repeat(64), config: { symbols: ['BTC'], start: '2026-04-16T20:00:00+00:00', end: '2026-11-11T03:00:00+00:00' },520      checks: { lookahead: 'pass', ledger: 'pass', funding_alignment: 'pass', data_quality: 'pass', notes: [] }, liquidations: { train: [{}], validation: [], test: [] }, robustness: { deflated_sharpe: 0.01, trials_tested: 9 }, metrics: { test: { total_return: -0.02, liquidations: 0 } } })} where id = ${sv}`;521    expect((await api('POST', `/agents/${a.id}/strategies/${sv}/approve`)).json).toMatchObject({ kind: 'perp', state: 'AWAITING_APPROVAL', strategyVersionId: sv });522    const ver = (await api('GET', `/agents/${a.id}`)).json.versions[0];523    expect(ver.summary).toMatchObject({ historyDays: 208, liquidations: { train: 1, validation: 0, test: 0 }, robustness: { deflated_sharpe: 0.01, trials_tested: 9 }, checks: { lookahead: 'pass' }, problem: null });524    // a spot agent can never approve a contract-2 version525    const spot = randomUUID(), sv2 = randomUUID();526    await sql`insert into agents (id, name, universe, risk, vault_index, vault_address) values (${spot}, 'spot', ${sql.json(['ETH'])}, ${sql.json({})}, 1, '0x0000000000000000000000000000000000000001')`;527    await sql`insert into strategy_versions (id, agent_id, name, version, code_sha256, path, meta, report) values (${sv2}, ${spot}, 'x_perp', '1', ${'d'.repeat(64)}, 'strategies/x_perp/1', ${sql.json({ kind: 'perp' })}, ${sql.json({ schema_version: 2 })})`;528    expect((await api('POST', `/agents/${spot}/strategies/${sv2}/approve`)).json).toMatchObject({ error: 'wrong_kind' });529    await sql`delete from strategy_versions where agent_id = ${spot}`; await sql`delete from agents where id = ${spot}`;530  });531532  it('trades the paper account bar by bar: long, flip to short as close-then-open, idempotent after a restart, flat and stopped under a halt', async () => {533    const a = await create('Perp two'), acct = a.accountId as string;534    await arm(a.id);535    const g0 = await rt.store.gate(await dv.data.instrument(BTC), '1h', 10);536    market(Math.round(Number(g0.series.lastClose)));537    // bar 02:00 — long 0.5 × equity538    next = { BTC: 0.5 }; calls = 0;539    expect(await pass(a.id)).toBe('1 order(s)');540    let det = (await api('GET', `/agents/${a.id}`)).json;541    expect(det.account.positions).toHaveLength(1);542    expect(det.account.positions[0]).toMatchObject({ instrument: BTC, side: 'long', leverage: '2' });543    const want = D.quantize(D.div(d('5000'), d(String(mid)), 18, 'down'), d('0.00001'), 'down');544    expect(det.account.positions[0].size).toBe(D.str(want));545    expect(det.account.positions[0]).toHaveProperty('liquidationPrice');            // an estimate; null here (0.5× on a cross account cannot be liquidated above zero)546    expect(det.signals[0]).toMatchObject({ status: 'done', weights: { BTC: 0.5 }, scale: '1' });547    expect(await orders(acct)).toMatchObject([{ side: 'buy', reduce_only: false, status: 'filled', origin: 'agent', type: 'market', tif: 'ioc' }]);548    expect(await pass(a.id)).toBe('waiting for the next bar');549    // bar 03:00 — the strategy goes short: reduce-only close first, then the open (the paper engine would refuse one crossing reduce-only order)550    clock.t = T0 + H; market(mid); next = { BTC: -0.5 };551    expect(await pass(a.id)).toBe('2 order(s)');552    const o = await orders(acct);553    expect(o.slice(1)).toMatchObject([{ side: 'sell', reduce_only: true, status: 'filled', size: D.str(want), origin: 'agent' }, { side: 'sell', reduce_only: false, status: 'filled', origin: 'agent' }]);554    det = (await api('GET', `/agents/${a.id}`)).json;555    expect(det.account.positions[0]).toMatchObject({ side: 'short' });556    expect(det.account.fills.length).toBe(3);557    expect(calls).toBe(2);558    // restart after the orders, before the bar was marked complete: same keys, nothing placed twice, the signal is not asked again559    await sql`update agents set last_bar = ${new Date(BAR0)} where id = ${a.id}`;560    await sql`update perp_agent_signals set status = 'decided' where agent_id = ${a.id} and bar = ${new Date(BAR0 + H)}`;561    next = { BTC: 1 }; rebuild();                                                    // a strategy that would now answer something else562    await pass(a.id);563    expect([(await orders(acct)).length, calls, (await api('GET', `/agents/${a.id}`)).json.account.positions[0].size]).toEqual([3, 2, det.account.positions[0].size]);564    // the account's circuit breaker trips (as the paper worker sets it on a loss limit): closing still passes the real risk engine565    const pol = (await rt.deriv.paper.get(acct)).policy;566    await rt.deriv.paper.setPolicy(acct, { ...pol, halt: 'daily loss limit reached (test)' });567    market(mid);568    expect(await pass(a.id)).toBe('stopped: loss limit');569    det = (await api('GET', `/agents/${a.id}`)).json;570    expect([det.state, det.account.positions.length]).toEqual(['STOPPED', 0]);571    expect(det.stopReason).toMatch(/circuit breaker.*closed with reduce-only orders/);572    expect((await orders(acct)).at(-1)).toMatchObject({ side: 'buy', reduce_only: true, status: 'filled' });573    // it does not restart while the account is halted574    expect((await api('POST', `/agents/${a.id}/mode`, { mode: 'PAPER' })).json.error).toBe('halted');575  });576577  it('lowering a loss limit reaches the account policy and the engine: flatten, then stop', async () => {578    market(mid);579    const a = await create('Perp three'), acct = a.accountId as string;580    await arm(a.id);581    next = { BTC: 0.5 };582    expect(await pass(a.id)).toBe('1 order(s)');583    // the fee of that order (≈ 2.25 USDC) is already more than 0.01 % of the capital (1 USDC)584    const r = (await api('POST', `/agents/${a.id}/perp/risk`, { risk: { ...RISK, maxDailyLossPct: 0.01, maxTotalLossPct: 0.01 } })).json;585    expect(r.risk.maxTotalLossPct).toBe(0.01);586    expect((await rt.deriv.paper.get(acct)).policy).toMatchObject({ maxTotalLoss: '1', maxDailyLoss: '1', halt: null });587    market(mid);588    expect(await pass(a.id)).toBe('stopped: loss limit');589    const det = (await api('GET', `/agents/${a.id}`)).json;590    expect([det.state, det.account.positions.length]).toEqual(['STOPPED', 0]);591    expect(det.stopReason).toMatch(/loss limit reached/);592    expect(det.log.map((l: any) => l.title)).toEqual(expect.arrayContaining(['Agent stopped', 'Loss limit: closing every position']));593    // the owner can also close everything by hand: nothing left to close here594    expect((await api('POST', `/agents/${a.id}/perp/flatten`)).json).toEqual({ orders: [], positionsLeft: 0 });595    // the paper worker and the agent share the account: a worker step changes nothing on a flat account596    expect((await wk.paperStep(rt.deriv.paper)).accounts).toBe(0);597  });598});599
Files main
Menu

Theme