Repository Public
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