Repository Public
1// Perp agents LIVE (V7): `LivePerpExecutor` and the life cycle, inside the process — the REAL engine (perp-engine.ts), the REAL2// live execution engine and mandate helpers, the REAL isolated signer class with its own venue reader and a software passkey3// (perp-live-world.ts), PostgreSQL (database spw_perp_live_test, created here if missing) and the scripted Hyperliquid fake4// that derivatives-mandate.test.ts uses (hl-fake.ts). The gate and the signal are scripted. No network.5import { createHash, randomUUID } from 'node:crypto';6import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';7import { D } from '@spw/derivatives';8import { useDatabase } from './derivatives-helpers.ts';9import { BTC, liveWorld, type LiveWorld } from './perp-live-world.ts';1011let sql: any, cyc: typeof import('../src/agents/perp-live-cycle.ts'), xl: typeof import('../src/agents/perp-exec-live.ts'), eng: typeof import('../src/agents/perp-engine.ts'),12 pl: typeof import('../src/agents/perp-live.ts'), pp: typeof import('../src/derivatives/paper.ts');1314beforeAll(async () => {15 await useDatabase('perp_live');16 const db = await import('../src/db.ts');17 sql = db.sql;18 await db.migrate(); await db.migrate();19 eng = await import('../src/agents/perp-engine.ts');20 await import('../src/agents/perp-strategies.ts'); // module order of the agents' routes (as in perp-agent.test.ts)21 pl = await import('../src/agents/perp-live.ts'); cyc = await import('../src/agents/perp-live-cycle.ts'); xl = await import('../src/agents/perp-exec-live.ts'); pp = await import('../src/derivatives/paper.ts');22});23/** an empty database: every world starts with its own venue account (the test mnemonic is the same in all of them) */24const reset = async () => {25 await sql`truncate deriv_accounts, deriv_orders, deriv_fills, deriv_funding, deriv_risk_events, deriv_equity, deriv_actions, deriv_mandates`;26 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`;27};28beforeEach(reset);29afterAll(async () => { await sql?.end(); });3031const H = 3_600_000;32const RISK = { maxLeverage: 2, maxGrossExposureX: 1.5, maxPositionX: 1, maxDailyLossPct: 3, maxTotalLossPct: 10, slippageBps: 30, minTradePct: 2 };3334interface Agent { id: string; sha: string; bar: number; weights: Record<string, number> | Error; signals: number; rt: import('../src/agents/perp-live-cycle.ts').PerpLiveRuntime;35 row: () => Promise<any>; tick: () => Promise<string>; pass: () => Promise<string>; nextBar: (w: Record<string, number>) => Promise<string>; signalRows: () => Promise<any[]> }3637/** a perp agent on BTC with an approved contract-2 version; the gate and the signal are scripted */38async function agent(w: LiveWorld, o: { risk?: Partial<typeof RISK>; approved?: boolean; name?: string } = {}): Promise<Agent> {39 const id = randomUUID(), sv = randomUUID(), sha = createHash('sha256').update(id).digest('hex');40 const [{ next }] = await sql`select least(coalesce(min(vault_index), 0), 0) - 1 as next from agents`;41 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)42 values (${id}, 'perp', ${o.name ?? 'live-test'}, 'hyperliquid', ${sql.json(['BTC'])}, '1h', ${sql.json({ ...RISK, ...o.risk })}, ${next}, '', '1000000000', ${randomUUID()}, 'AWAITING_APPROVAL', 'PAPER', ${o.approved === false ? null : sv})`;43 await sql`insert into strategy_versions (id, agent_id, name, version, code_sha256, path, meta, status, report) values (${sv}, ${id}, 'fixed_perp', '1', ${sha}, 'strategies/fixed_perp/1',44 ${sql.json({ kind: 'perp', lookback: 10, max_leverage: 2, max_gross_exposure: 2 })}, ${o.approved === false ? 'draft' : 'approved'}, ${sql.json({})})`;45 const never = async (): Promise<never> => { throw new Error('the paper side of the runtime must not be used by a live agent'); };46 const a: Agent = { id, sha, bar: Math.floor(w.clock.now() / H) * H - H, weights: { BTC: 0.5 }, signals: 0,47 rt: { live: () => w.svc, deps: { now: w.clock.now, gate: async () => ({ bar: a.bar, data: {} }), market: never, executor: () => { throw new Error('no paper executor'); },48 signal: async () => { a.signals += 1; if (a.weights instanceof Error) throw a.weights; return { weights: a.weights, maxLeverage: 2, maxGrossExposure: 2 }; } } },49 row: async () => (await sql`select * from agents where id = ${id}`)[0],50 tick: async () => cyc.tickLivePerpAgent(await a.row(), a.rt),51 pass: async () => { w.next(); return a.tick(); },52 nextBar: async (weights) => { a.bar += H; a.weights = weights; w.next(); return a.tick(); },53 signalRows: () => sql`select bar, status, orders, targets from perp_agent_signals where agent_id = ${id} order by bar` };54 return a;55}56/** the ONE passkey: prepare the mandate derived from the agent's limits, approve it */57async function goLive(w: LiveWorld, a: Agent, accountId: string) {58 const prep = await cyc.prepareLive(await a.row(), a.rt, { accountId, locale: 'fr' });59 const row = await cyc.approveLive(await a.row(), a.rt, { accountId, assertion: w.pk.assert(w.b64(prep.hash)) as never, acknowledged: true });60 return { prep, row };61}62async function liveAgent(o: { risk?: Partial<typeof RISK>; fund?: string } = {}) {63 await reset();64 const w = await liveWorld(), acct = await w.authorise('testnet', o.fund ?? '1000'), a = await agent(w, o.risk ? { risk: o.risk } : {});65 const { prep } = await goLive(w, a, acct);66 return { w, a, acct, prep };67}68/** the market moves away between the engine's read of the book and the venue's matching: every order finds nothing to match */69function noLiquidity(w: LiveWorld): () => void {70 const real = w.t.fetch;71 w.t.fetch = (async (url: string, init?: RequestInit) => {72 if (!String(url).endsWith('/exchange') || JSON.parse(String(init?.body)).action?.type !== 'order') return real(url as never, init);73 const saved = [w.t.bids, w.t.asks] as const;74 w.t.bids = [['61000', '1']]; w.t.asks = [['79000', '1']];75 try { return await real(url as never, init); } finally { [w.t.bids, w.t.asks] = [saved[0], saved[1]]; }76 }) as never;77 return () => { w.t.fetch = real; };78}79const refusal = async (p: Promise<unknown>) => { const e = await p.then(() => null, (x) => x); return e ? { code: e.code as string, status: e.status as number, message: e.message as string } : null; };80const pos = (w: LiveWorld) => w.t.user(w.ACCOUNT).positions.get('BTC')?.szi ?? '0';81const orders = (acct: string) => sql`select client_id, side, reduce_only, state, final, size::text, filled::text, origin, reject_reason from deriv_orders where account_id = ${acct} order by created_at, nonce` as Promise<any[]>;82const leverageActions = async (acct: string) => (await sql`select count(*)::int as n from deriv_actions where account_id = ${acct} and kind = 'leverage'`)[0].n as number;83const logs = async (id: string) => (await sql`select kind, title, body from research_log where agent_id = ${id} order by ts, id`) as { kind: string; title: string; body: string }[];84const statuses = async (a: Agent) => (await a.signalRows()).map((r: any) => (r.orders?.list ?? []).map((x: any) => `${x.side}${x.reduceOnly ? ' RO' : ''} ${x.status}${x.code ? `:${x.code}` : ''}`));8586describe('going live: the checklist, the mandate derived from the limits, one passkey', () => {87 it('readiness says what is missing, step by step, and computes the funding minimum and the mandate', async () => {88 const w = await liveWorld(), draft = await agent(w, { approved: false }), a = await agent(w);89 const ready = async (x: Agent, account?: string) => cyc.liveReadiness(await x.row(), x.rt, { account, locale: 'fr' });90 expect(await ready(draft)).toMatchObject({ next: 'strategy', steps: { strategy: { ok: false }, account: { ok: false, code: 'no_account' } }, proposal: null, accounts: [] });91 expect(await ready(a)).toMatchObject({ next: 'account', steps: { strategy: { ok: true, sha256: a.sha }, account: { code: 'no_account' }, funded: { ok: false } } });92 // a venue account that the owner has not authorised yet93 const ro = (await w.svc.createAccount({ venue: 'hyperliquid', network: 'testnet', label: 'Testnet' })).id as string;94 expect(await ready(a)).toMatchObject({ next: 'account', accountId: ro, steps: { account: { ok: false, code: 'read_only', network: 'testnet', authorization: null }, funded: { ok: false, code: 'unfunded' } } });95 await expect(cyc.prepareLive(await a.row(), a.rt, { accountId: ro, locale: 'en' })).rejects.toMatchObject({ code: 'not_ready', status: 409 });96 // authorised (the owner's passkey, in Futures → Accounts), then the funding step from the venue's own account value97 const acct = await w.authorise('testnet', '1000');98 expect(acct).toBe(ro);99 w.t.user(w.ACCOUNT).usdc = '0';100 expect(await ready(a)).toMatchObject({ next: 'fund', steps: { account: { ok: true, authorization: { active: true, maxMandateDays: 29 } }, funded: { ok: false, code: 'unfunded', equity: '0', minEquity: '50' } } });101 w.t.user(w.ACCOUNT).usdc = '49.5';102 const low = await ready(a);103 expect(low.steps.funded).toMatchObject({ ok: false, code: 'below_minimum', equity: '49.5', minEquity: '50', basis: { instruments: [{ instrument: BTC, minOrder: '10.5' }], ordersPerPosition: 5, grossX: 1.5, floor: '50' } });104 w.t.user(w.ACCOUNT).usdc = '1000';105 const r = await ready(a);106 expect(r).toMatchObject({ next: 'mandate', accountId: acct, locale: 'fr', config: { locale: 'fr', mandateLive: { available: true } }, steps: { strategy: { ok: true }, account: { ok: true }, funded: { ok: true, equity: '1000' }, mandate: { ok: false, lock: null } },107 accounts: [{ id: acct, network: 'testnet', equity: '1000', usedBy: null, mandate: null, authorization: { active: true } }] });108 // the mandate is DERIVED from the agent's limits on the venue's account value — nothing is chosen freely109 expect(r.proposal).toMatchObject({ capital: '1000', days: 29, maxDays: 29, notes: [],110 body: { instruments: [{ instrument: BTC, maxLeverage: 2, maxPositionNotional: '1000' }], maxOrderNotional: '1000', maxGrossExposure: '1500', maxDailyTurnover: '6000', floorEquity: '900',111 maxOrdersPerDay: 48, maxOrdersPerHour: 4, days: 29, strategySha256: a.sha, agentId: a.id } });112 expect(r.proposal!.derived.map((d) => d.field)).toEqual(['capital', 'instruments', 'maxLeverage', 'maxPositionNotional', 'maxOrderNotional', 'maxGrossExposure', 'maxOrdersPerDay', 'maxOrdersPerHour', 'maxDailyTurnover', 'floorEquity', 'days']);113 // a larger agent on a tighter authorization: clamped, and said114 const big = cyc.deriveMandate({ id: a.id, universe: ['BTC'], interval: '4h', risk: { ...RISK, maxLeverage: 5, maxPositionX: 3, maxGrossExposureX: 4 } }, a.sha, 'testnet', '1000.129',115 { expiresAt: w.clock.now() + 3 * 86_400_000, policy: { maxLeverage: 3, maxOrderNotional: '1000', maxPositionNotional: '1000', maxGrossExposure: '1500', maxOrdersPerDay: 50 } }, w.clock.now(), 30);116 expect(big).toMatchObject({ capital: '1000.12', days: 2, maxDays: 2, body: { instruments: [{ maxLeverage: 3, maxPositionNotional: '1000' }], maxOrderNotional: '1000', maxGrossExposure: '1500', floorEquity: '900.11', maxOrdersPerDay: 12, maxOrdersPerHour: 4 } });117 expect(big.notes.join(' ')).toMatch(/gross exposure: 4000\.48 from the agent's limits, lowered to the venue authorization's 1500/);118 // an account that is not flat, an authorization about to expire, instruments outside the pinned table119 w.t.forcePosition(w.ACCOUNT, 'BTC', '0.001');120 expect((await ready(a)).steps.account).toMatchObject({ ok: false, code: 'account_not_flat' });121 w.t.forcePosition(w.ACCOUNT, 'BTC', '0');122 w.clock.advance(28.5 * 86_400_000);123 expect((await ready(a)).steps.account).toMatchObject({ ok: false, code: 'authorization_too_short', authorization: { maxMandateDays: 0 } });124 w.clock.advance(-28.5 * 86_400_000);125 await sql`update agents set universe = ${sql.json(['BTC', 'DOGE'])} where id = ${a.id}`;126 expect((await ready(a)).steps.account).toMatchObject({ ok: false, code: 'instruments_not_pinned', instrumentsMissing: ['hyperliquid:DOGE'] });127 await sql`update agents set universe = ${sql.json(['BTC', 'ETH'])} where id = ${a.id}`;128 expect((await ready(a)).steps.account).toMatchObject({ ok: false, code: 'instruments_not_allowed', instrumentsMissing: ['hyperliquid:ETH'] });129 });130131 it('prepare shows what the signer will enforce; ONE passkey makes the agent LIVE; one agent per venue account', async () => {132 const w = await liveWorld(), acct = await w.authorise(), a = await agent(w), b = await agent(w, { name: 'second' });133 const exchangeBefore = w.t.exchangeCalls();134 const prep = await cyc.prepareLive(await a.row(), a.rt, { accountId: acct, locale: 'fr', days: 7 });135 expect(prep).toMatchObject({ locale: 'fr', windDown: { seconds: 86400 }, proposal: { capital: '1000', days: 7 },136 mandate: { network: 'testnet', account: w.ACCOUNT, strategySha256: a.sha, agentId: a.id, maxOrderNotional: '1000', maxGrossExposure: '1500', floorEquity: '900', maxOrdersPerHour: 4 } });137 expect(prep.mandate.expiresAt - prep.mandate.createdAt).toBe(7 * 86400);138 expect(prep.acknowledgement.text.length).toBeGreaterThan(200);139 expect(prep.enforcedBySigner.length).toBeGreaterThan(4); expect(prep.trustedBySigner.length).toBeGreaterThan(1); expect(prep.notEnforced.length).toBeGreaterThan(1);140 expect(prep.options.challenge).toBe(w.b64(prep.hash).toString('base64url')); // the passkey signs the mandate hash141 // a passkey assertion over something else, the checkbox, another agent's preparation142 expect(cyc.LiveApproveBody.safeParse({ accountId: acct, assertion: {}, acknowledged: false }).success).toBe(false);143 expect(await refusal(cyc.approveLive(await b.row(), b.rt, { accountId: acct, assertion: w.pk.assert(w.b64(prep.hash)) as never, acknowledged: true }))).toMatchObject({ code: 'not_ready', status: 409 });144 expect(await refusal(cyc.approveLive(await a.row(), a.rt, { accountId: acct, assertion: w.pk.assert(Buffer.alloc(32, 7)) as never, acknowledged: true }))).toMatchObject({ code: 'signer_bad_approval' });145 expect(await a.row()).toMatchObject({ state: 'AWAITING_APPROVAL', mode: 'PAPER', live_account_id: null }); // a refused approval leaves the agent in simulation and releases the account146 expect(await refusal(cyc.approveLive(await a.row(), a.rt, { accountId: acct, assertion: w.pk.assert(w.b64(prep.hash)) as never, acknowledged: true }))).toMatchObject({ code: 'not_ready' }); // consumed: prepare again147148 const { row } = await goLive(w, a, acct);149 expect(row).toMatchObject({ state: 'LIVE', mode: 'LIVE', live_account_id: acct, perp: { live: { accountId: acct, network: 'testnet', strategySha256: a.sha, capital: '1000', exit: null } } });150 expect(w.signer.status().venueMandates).toHaveLength(1);151 expect(w.t.exchangeCalls()).toBe(exchangeBefore); // going live sends nothing to the venue152 expect(await cyc.liveReadiness(await a.row(), a.rt, { locale: 'en' })).toMatchObject({ next: 'live', accountId: acct, steps: { mandate: { ok: true, phase: 'active' } }, proposal: null,153 accounts: [{ usedBy: { agentId: a.id, name: 'live-test' }, mandate: { agentId: a.id, phase: 'active' } }] });154 // the second agent sees the account as taken; a live agent cannot be prepared again155 expect((await cyc.liveReadiness(await b.row(), b.rt, { locale: 'en' })).steps.account).toMatchObject({ ok: false, code: 'used_by_other_agent' });156 expect(await refusal(cyc.prepareLive(await b.row(), b.rt, { accountId: acct, locale: 'en' }))).toMatchObject({ code: 'not_ready' });157 expect(await refusal(cyc.prepareLive(await a.row(), a.rt, { accountId: acct, locale: 'en' }))).toMatchObject({ code: 'bad_state' });158 await expect(sql`update agents set live_account_id = ${acct} where id = ${b.id}`).rejects.toMatchObject({ code: '23505' }); // the database itself: one agent per venue account159 const block = await cyc.liveBlock(await a.row(), a.rt, true);160 expect(block).toMatchObject({ available: true, active: true, network: 'testnet', capital: '1000', paused: false, exit: null, holds: [],161 account: { id: acct, modes: ['MANUAL_LIVE', 'MANDATE_LIVE'], mandate: { agentId: a.id, phase: 'active' }, venueState: { source: 'venue', positions: [] } }, signer: { phase: 'active', today: { orders: 0 } } });162 });163164 it('mainnet autonomous trading is a SERVER setting: with it off nothing is prepared, whatever the owner does', async () => {165 const w = await liveWorld(), a = await agent(w);166 w.flags.mainnet = true; // SPW_DERIV_LIVE_MAINNET=on (manual live), SPW_DERIV_MANDATE_MAINNET off167 const acct = await w.authorise('mainnet');168 const r = await cyc.liveReadiness(await a.row(), a.rt, { account: acct, locale: 'fr' });169 expect(r).toMatchObject({ next: 'locked', steps: { strategy: { ok: true }, account: { ok: true, network: 'mainnet' }, funded: { ok: true }, mandate: { ok: false, lock: { code: 'mandate_mainnet_disabled' } } } });170 expect(r.steps.mandate.lock!.reason).toBe(r.config.reasons.mandate_mainnet_disabled.fr);171 expect(r.steps.mandate.lock!.reason).toMatch(/SPW_DERIV_MANDATE_MAINNET=on/);172 expect(r.proposal).not.toBeNull(); // what WOULD be approved is shown173 expect(await refusal(cyc.prepareLive(await a.row(), a.rt, { accountId: acct, locale: 'fr' }))).toMatchObject({ code: 'mandate_mainnet_disabled', status: 403 });174 expect(await a.row()).toMatchObject({ mode: 'PAPER', live_account_id: null });175 expect(w.m.exchangeCalls('order')).toBe(0); expect(w.mandateSigns).toHaveLength(0);176 // manual live off too: the account step says so with the server's sentence177 w.flags.mainnet = false;178 expect((await cyc.liveReadiness(await a.row(), a.rt, { account: acct, locale: 'en' })).steps.account).toMatchObject({ ok: false, code: 'mainnet_disabled', reason: expect.stringMatching(/SPW_DERIV_LIVE_MAINNET/) });179 });180});181182describe('LivePerpExecutor through the engine: the venue decides', () => {183 it('long, then a flip to short as close-then-open over two passes, then flat — decisions from the VENUE\'s state, leverage set once, no order storm', async () => {184 const { w, a, acct } = await liveAgent();185 // bar 1: long 0.5 × the venue's account value. The venue's default leverage is 20×: set to the agent's 2× first, once.186 expect(await a.tick()).toBe('1 order(s)');187 expect(pos(w)).toBe('0.00714'); // 0.5 × 1 000 / 70 000, on the lot188 expect(w.t.user(w.ACCOUNT).leverage.get('BTC')).toEqual({ type: 'cross', value: 2 });189 expect([await leverageActions(acct), w.orderSigns(), w.mandateSigns.length]).toEqual([1, 1, 2]);190 expect(w.signs).toHaveLength(0); // never the passkey path191 const [o1] = await orders(acct);192 expect(o1).toMatchObject({ side: 'buy', reduce_only: false, state: 'filled', final: true, origin: 'mandate', size: '0.00714', filled: '0.00714' });193 expect(o1.client_id).toBe(eng.orderClientId(a.id, a.sha, a.bar, 'BTC', 'open'));194 expect((await a.signalRows())[0]).toMatchObject({ status: 'done', targets: { BTC: '0.00714' } });195 expect((await sql`select mode, equity_usd from agent_equity where agent_id = ${a.id}`)[0]).toMatchObject({ mode: 'LIVE' });196 // the same bar, many passes: one decision per bar, and the signer is not asked again197 for (let k = 0; k < 5; k++) expect(await a.pass()).toBe('waiting for the next bar');198 expect([a.signals, w.orderSigns(), w.t.exchangeCalls('order')]).toEqual([1, 1, 1]);199200 // bar 2: short. The reduce-only close goes now; the open waits for the next pass (the signer still counts what it just signed)201 expect(await a.nextBar({ BTC: -0.5 })).toBe('1 order(s), 1 waiting for the next pass');202 expect(pos(w)).toBe('0');203 expect(await statuses(a)).toEqual([['buy filled'], ['sell RO filled', 'sell deferred:live_cooldown']]);204 expect((await a.row()).last_bar.getTime()).toBe(a.bar - H); // the bar stays open205 expect(await a.tick()).toBe('0 order(s), 1 waiting for the next pass'); // asked again at once: still waiting, nothing signed206 expect(w.orderSigns()).toBe(2);207 expect(await a.pass()).toBe('1 order(s)'); // a minute later: planned again from the venue's real (flat) position208 expect(D.isNeg(D.from(pos(w)))).toBe(true);209 expect((await orders(acct)).map((o) => `${o.side}${o.reduce_only ? ' RO' : ''} ${o.state}`)).toEqual(['buy filled', 'sell RO filled', 'sell filled']);210 expect((await statuses(a))[1]).toEqual(['sell RO filled', 'sell filled']); // the log of the bar keeps the close sent on the earlier pass211 expect([a.signals, w.orderSigns(), await leverageActions(acct)]).toEqual([2, 3, 1]); // one signal per bar; leverage was set once212 expect(await a.pass()).toBe('waiting for the next bar');213214 // bar 3: flat — a full close is never blocked215 expect(await a.nextBar({ BTC: 0 })).toBe('1 order(s)');216 expect(pos(w)).toBe('0');217 expect(w.signer.status().venueMandates[0]).toMatchObject({ today: { orders: 2, reduceOnly: 2 } });218 expect((await w.svc.reconcile(acct)).divergence).toBeNull(); // the agent's orders are the wallet's own219 expect((await a.row()).state).toBe('LIVE');220 // the leverage setting changed on the venue (someone else): brought back before the next order that adds exposure221 w.t.user(w.ACCOUNT).leverage.set('BTC', { type: 'cross', value: 1 });222 expect(await a.nextBar({ BTC: 0.5 })).toBe('1 order(s)');223 expect([await leverageActions(acct), w.t.user(w.ACCOUNT).leverage.get('BTC')!.value]).toEqual([2, 2]);224 });225226 it('a partial fill is what the venue says it is; the next bar plans from the real position', async () => {227 const { w, a, acct } = await liveAgent();228 w.t.asks = [['70005', '0.003']]; // a thin book: the IOC order fills 0.003 of 0.00714229 expect(await a.tick()).toBe('1 order(s)');230 expect(pos(w)).toBe('0.003');231 expect((await orders(acct))[0]).toMatchObject({ state: 'partially_filled', final: true, filled: '0.003', size: '0.00714' });232 expect(await statuses(a)).toEqual([['buy partially_filled']]);233 expect(await a.pass()).toBe('waiting for the next bar'); // the remainder is not chased inside the bar234 w.t.asks = [['70005', '1']];235 expect(await a.nextBar({ BTC: 0.5 })).toBe('1 order(s)');236 expect((await orders(acct))[1]).toMatchObject({ side: 'buy', state: 'filled' });237 expect(D.str(D.add(D.from((await orders(acct))[1].size), D.from('0.003')))).toBe(pos(w)); // target − the venue's position238 expect(w.orderSigns()).toBe(2);239 });240241 it('a venue refusal is a refusal, not a retry: nothing more on that bar', async () => {242 const { w, a, acct } = await liveAgent();243 const back = noLiquidity(w); // nothing to match: the venue refuses the IOC order244 expect(await a.tick()).toBe('1 order(s) not sent');245 expect((await orders(acct))[0]).toMatchObject({ state: 'rejected', final: true });246 expect((await orders(acct))[0].reject_reason).toMatch(/refused by the venue.*could not immediately match/i);247 expect(await statuses(a)).toEqual([['buy refused:venue_rejected']]);248 for (let k = 0; k < 3; k++) expect(await a.pass()).toBe('waiting for the next bar');249 expect([w.orderSigns(), w.t.exchangeCalls('order'), pos(w)]).toEqual([1, 1, '0']);250 expect((await logs(a.id)).filter((l) => l.title.startsWith('Order refused'))).toHaveLength(1); // said once251 back();252 expect(await a.nextBar({ BTC: 0.5 })).toBe('1 order(s)');253 expect(pos(w)).toBe('0.00714');254 });255256 it('`unknown`: no further order on that instrument until the venue resolves it — to filled, or to "never placed"', async () => {257 const { w, a, acct } = await liveAgent();258 await a.tick(); // long (and the leverage is set)259 // the answer is lost AFTER the venue took the order, and the venue cannot be asked right now260 w.t.fault = 'lost_after'; w.faults.info = 'orderStatus';261 expect(await a.nextBar({ BTC: 0 })).toBe('0 order(s), 1 waiting for the next pass');262 expect((await orders(acct))[1]).toMatchObject({ side: 'sell', reduce_only: true, state: 'unknown', final: false });263 expect(await statuses(a)).toEqual([['buy filled'], ['sell RO deferred:live_unknown_pending']]);264 expect(await a.tick()).toBe('0 order(s), 1 waiting for the next pass'); // still unknown: nothing is sent again265 expect(w.t.exchangeCalls('order')).toBe(2);266 w.faults.info = null;267 expect(await a.pass()).toBe('portfolio already at target'); // the venue's own record: it had filled — no second close268 expect([(await orders(acct))[1].state, pos(w), w.t.exchangeCalls('order'), w.orderSigns()]).toEqual(['filled', '0', 2, 2]);269 expect((await a.signalRows())[1].status).toBe('done');270271 // the request is lost BEFORE the venue saw it: unknown, never re-sent; after the signature's venue expiry it was never placed272 w.t.fault = 'lost_before';273 expect(await a.nextBar({ BTC: 0.5 })).toBe('0 order(s), 1 waiting for the next pass');274 expect((await orders(acct))[2]).toMatchObject({ side: 'buy', state: 'unknown' });275 w.clock.advance(20_000);276 expect(await a.tick()).toBe('0 order(s), 1 waiting for the next pass');277 expect(await a.pass()).toBe('1 order(s) not sent'); // > 35 s: final278 expect((await orders(acct))[2]).toMatchObject({ state: 'rejected', final: true });279 expect((await statuses(a))[2]).toEqual(['buy refused:never_placed']);280 expect([pos(w), w.t.exchangeCalls('order'), w.orderSigns()]).toEqual(['0', 3, 3]);281 expect(await a.pass()).toBe('waiting for the next bar');282 expect(await a.nextBar({ BTC: 0.5 })).toBe('1 order(s)'); // the next bar trades normally283 });284285 it('restart between signing and reconciliation: the stored order is looked up on the venue, never placed twice', async () => {286 const { w, a, acct } = await liveAgent();287 await a.tick();288 const before = [w.t.exchangeCalls('order'), w.orderSigns()];289 // the process died after the order was signed and sent, before it was reconciled and before the bar was marked complete290 await sql`update deriv_orders set state = 'signed', status = 'pending', final = false, filled = 0, avg_fill_price = null, venue_order_id = null where account_id = ${acct}`;291 await sql`update agents set last_bar = null where id = ${a.id}`;292 await sql`update perp_agent_signals set status = 'decided', orders = null where agent_id = ${a.id}`;293 a.weights = { BTC: -1 }; // a strategy that would now answer something else is not asked again294 expect(await a.pass()).toBe('portfolio already at target');295 expect((await orders(acct))[0]).toMatchObject({ state: 'filled', final: true, filled: '0.00714' });296 expect([w.t.exchangeCalls('order'), w.orderSigns(), a.signals, pos(w)]).toEqual([...before, 1, '0.00714']);297 // … and when the venue's positions were not visible either (worst case), the same key answers the same order298 await sql`update agents set last_bar = null where id = ${a.id}`; await sql`update perp_agent_signals set status = 'decided' where agent_id = ${a.id}`;299 const ex = new xl.LivePerpExecutor(w.svc, { accountId: acct, agentId: a.id, strategySha256: a.sha, symbols: ['BTC'], risk: RISK, capital: '1000' });300 const again = await ex.place({ instrument: BTC, side: 'buy', type: 'market', size: '0.00714', price: '70215', triggerPrice: null, reduceOnly: false, tif: 'ioc', clientId: (await orders(acct))[0].client_id }, '2');301 expect(again).toMatchObject({ status: 'placed', idempotent: true, order: { status: 'filled', filled: '0.00714' } });302 expect([w.t.exchangeCalls('order'), w.orderSigns()]).toEqual(before);303 });304});305306describe('signer refusals: hold until the next bar, or leave LIVE', () => {307 it('position limit, orders per hour, daily turnover: one refusal per bar, logged once, the agent stays LIVE', async () => {308 const { w, a, acct } = await liveAgent();309 for (const c of ['signer_position_exceeded', 'signer_gross_exceeded', 'signer_orders_hour', 'signer_turnover', 'signer_orders_day', 'signer_price', 'signer_venue_unreachable']) expect(xl.LIVE_EXIT_CODES.has(c)).toBe(false);310 // an order resting on the venue that the wallet did not place: the signer counts it (700) on top of the candidate (≈ 977)311 w.t.foreignOrder(w.ACCOUNT, { px: '60000', sz: '0.01' });312 a.weights = { BTC: 1 };313 expect(await a.tick()).toBe('1 order(s) not sent');314 expect(await statuses(a)).toEqual([['buy refused:signer_position_exceeded']]);315 expect((await orders(acct))[0]).toMatchObject({ state: 'rejected', final: true });316 const signs = w.orderSigns();317 for (let k = 0; k < 4; k++) expect(await a.pass()).toBe('waiting for the next bar'); // no storm: the signer is asked once per bar318 expect(w.orderSigns()).toBe(signs);319 expect(await a.nextBar({ BTC: 1 })).toBe('1 order(s) not sent');320 expect(w.orderSigns()).toBe(signs + 1);321 expect((await logs(a.id)).filter((l) => l.title.startsWith('Order refused')).length).toBe(1); // the same refusal is not logged again322 expect((await a.row()).state).toBe('LIVE');323 expect((await cyc.liveBlock(await a.row(), a.rt, true)).holds[0]).toMatchObject({ symbol: 'BTC', code: 'signer_position_exceeded', reason: expect.stringMatching(/worst-case BTC position/) });324 w.t.removeOrder(w.ACCOUNT, w.t.openOrders(w.ACCOUNT)[0]!.oid);325326 // four orders that add exposure in the hour are the mandate's limit: the fifth is refused by the signer, the agent holds327 const opens = async () => (await orders(acct)).filter((o) => !o.reduce_only && o.state === 'filled').length;328 let side = 1;329 while (await opens() < 4) {330 const r = await a.nextBar({ BTC: 0.9 * side });331 if (/waiting for the next pass/.test(r)) await a.pass();332 side = -side;333 }334 const r5 = await a.nextBar({ BTC: 0.9 * side });335 if (/waiting for the next pass/.test(r5)) expect(await a.pass()).toBe('1 order(s) not sent');336 expect((await statuses(a)).at(-1)!.at(-1)).toMatch(/refused:signer_orders_hour/);337 expect(await opens()).toBe(4);338 expect((await a.row()).state).toBe('LIVE');339 // an hour later the hourly allowance is back; the daily turnover (6 000) is then the limit340 w.clock.advance(H);341 for (let k = 0; k < 8 && !/signer_turnover/.test(String((await statuses(a)).at(-1)!.at(-1))); k++) {342 const r = await a.nextBar({ BTC: 0.9 * side });343 if (/waiting for the next pass/.test(r)) await a.pass();344 side = -side;345 if (await opens() === 6) w.clock.advance(H);346 }347 expect((await statuses(a)).at(-1)!.at(-1)).toMatch(/refused:signer_turnover/);348 expect(Number(w.signer.status().venueMandates[0]!.today.turnover)).toBeLessThanOrEqual(6000);349 expect((await a.row()).state).toBe('LIVE');350 });351352 it('the floor: no exposure is added well before the signer refuses; a loss limit takes the agent out', async () => {353 const { w, a, acct } = await liveAgent({ risk: { maxDailyLossPct: 10, maxTotalLossPct: 10 } }); // floor 900, guard 925354 await a.tick();355 w.t.user(w.ACCOUNT).usdc = '920'; // account value ≈ 920: above the floor and the loss limit, inside the guard356 const signs = w.orderSigns();357 expect(await a.nextBar({ BTC: 1 })).toBe('1 order(s) not sent');358 expect((await statuses(a))[1]).toEqual(['buy refused:floor_guard']);359 expect([w.orderSigns(), (await a.row()).state]).toEqual([signs, 'LIVE']); // the signer was not even asked360 expect(await a.nextBar({ BTC: 0.2 })).toBe('1 order(s)'); // reducing still passes361 // below the total-loss limit (= the mandate's floor): the mandate is revoked, the position closed, the agent stopped362 w.t.user(w.ACCOUNT).usdc = '890';363 expect(await a.pass()).toBe('stopped');364 const row = await a.row();365 expect(row).toMatchObject({ state: 'STOPPED', live_account_id: null, perp: { live: { exit: { code: 'loss_limit' } } } });366 expect(row.stop_reason).toMatch(/total loss limit reached.*every position was closed with reduce-only orders/);367 expect(pos(w)).toBe('0');368 expect(w.signer.status().venueMandates[0]).toMatchObject({ revoked: true, phase: 'wind_down' });369 expect((await w.svc.get(acct)).modes).toEqual(['MANUAL_LIVE']);370 });371372 it('refusals that mean the mandate is over: the agent leaves LIVE, closes with reduce-only orders if the signer still signs, and stops with the reason', async () => {373 // the mandate was revoked on the signer (the API does not know): the signer refuses new exposure, the wind-down still signs the close374 {375 const { w, a } = await liveAgent();376 await a.tick();377 await w.signer.venueMandateRevoke((await a.row()).perp.live.mandateId);378 expect(await a.nextBar({ BTC: 1 })).toBe('stopped');379 const row = await a.row();380 expect(row).toMatchObject({ state: 'STOPPED', mode: 'LIVE', live_account_id: null, perp: { live: { exit: { code: 'signer_wind_down' } } } });381 expect(row.stop_reason).toMatch(/the signer only signs reduce-only orders.*every position was closed with reduce-only orders/);382 expect(pos(w)).toBe('0');383 expect(await a.pass()).toBe('idle');384 }385 // the signer's emergency halt: reduce-only orders are still signed386 {387 const { w, a } = await liveAgent();388 await a.tick();389 w.signer.halt();390 expect(await a.nextBar({ BTC: 1 })).toBe('stopped');391 expect(await a.row()).toMatchObject({ state: 'STOPPED', perp: { live: { exit: { code: 'signer_halted' } } } });392 expect(pos(w)).toBe('0');393 }394 // the venue authorization is gone on the signer: nothing can be signed — the agent stops and SAYS that the position remains395 {396 const { w, a, acct } = await liveAgent();397 await a.tick();398 await w.signer.venueRevoke((await w.svc.get(acct)).authorization!.id);399 expect(await a.nextBar({ BTC: 1 })).toBe('stopped');400 const row = await a.row();401 expect(row.perp.live.exit.code).toMatch(/^signer_(revoked|no_authorization)$/);402 expect(row.stop_reason).toMatch(/1 position\(s\) REMAIN OPEN on the venue account \(long 0\.00714 BTC\).*close them in the Futures terminal with your passkey/);403 expect(pos(w)).toBe('0.00714');404 expect(row).toMatchObject({ state: 'STOPPED', live_account_id: null });405 }406 // the mandate expires: no new exposure from that instant; the wind-down closes407 {408 const { w, a } = await liveAgent();409 await a.tick();410 w.clock.advance(29 * 86_400_000 + 60_000);411 w.t.setMid(70_000);412 a.bar = Math.floor(w.clock.now() / H) * H - H;413 expect(await a.tick()).toBe('stopped');414 expect(await a.row()).toMatchObject({ state: 'STOPPED', perp: { live: { exit: { code: 'mandate_expired' } } } });415 expect(pos(w)).toBe('0');416 }417 });418419 it('owner halt, divergence, a manual order, the server lock: each takes the agent out of LIVE with its own reason', async () => {420 {421 const { w, a, acct } = await liveAgent();422 await a.tick();423 await w.svc.halt(acct, { reason: 'test' });424 expect(await a.pass()).toBe('stopped');425 const row = await a.row();426 expect(row).toMatchObject({ state: 'STOPPED', perp: { live: { exit: { code: 'halted' } } } });427 expect(row.stop_reason).toMatch(/trading was halted on the venue account — halted by the owner: test.*closed with reduce-only orders/);428 expect(pos(w)).toBe('0');429 }430 {431 // a position the wallet did not create (a trade from the venue's own interface): seen by the reconciliation worker on two passes432 const { w, a, acct } = await liveAgent();433 await a.tick();434 w.t.forcePosition(w.ACCOUNT, 'BTC', '0.012');435 await w.svc.reconcile(acct); w.next(); await w.svc.reconcile(acct);436 expect(await a.pass()).toBe('stopped');437 const row = await a.row();438 expect(row.perp.live.exit).toMatchObject({ code: 'divergence' });439 expect(row.stop_reason).toMatch(/the venue's state and the wallet's records differ/);440 expect(pos(w)).toBe('0'); // closed in full, with a reduce-only order under the wind-down441 }442 {443 // the owner places a manual order (passkey) on the account while the agent is live444 const { w, a, acct } = await liveAgent();445 await a.tick();446 w.next();447 const prop = await w.svc.propose(acct, pp.OrderBodySchema.parse({ instrument: BTC, side: 'buy', type: 'limit', size: '0.001', price: '69000' }), 'manual-order-key-1');448 await a.tick();449 expect((await a.row()).state).toBe('LIVE'); // a proposal that is not approved is nothing450 await w.svc.approveOrder(acct, 'manual-order-key-1', w.pk.assert(w.b64(prop.hash!)) as never);451 expect(await a.pass()).toBe('stopped');452 const row = await a.row();453 expect(row.perp.live.exit).toMatchObject({ code: 'manual_order' });454 expect(row.stop_reason).toMatch(/a manual order was placed on the venue account while the agent was live/);455 expect([pos(w), w.t.openOrders(w.ACCOUNT).length]).toEqual(['0', 0]); // the resting manual order was cancelled with the revocation456 }457 {458 // mainnet (the fake): both server settings on, then autonomous trading is switched off on the server459 const w = await liveWorld(), a = await agent(w);460 w.flags.mainnet = true; w.flags.mandateMainnet = true;461 const acct = await w.authorise('mainnet');462 await goLive(w, a, acct);463 expect(await a.tick()).toBe('1 order(s)');464 expect(w.m.user(w.ACCOUNT).positions.get('BTC')?.szi).toBe('0.00714');465 w.flags.mandateMainnet = false;466 expect(await a.pass()).toBe('stopped');467 const row = await a.row();468 expect(row.perp.live.exit).toMatchObject({ code: 'mandate_mainnet_disabled' });469 expect(row.stop_reason).toMatch(/autonomous \(mandate\) trading on mainnet is switched off on the server.*1 position\(s\) REMAIN OPEN/);470 expect(w.signer.status().venueMandates[0]).toMatchObject({ revoked: true }); // revoked on the signer all the same471 expect(w.t.exchangeCalls()).toBe(0);472 }473 });474});475476describe('stop, pause, wind-down', () => {477 it('"stop": no passkey — the mandate is revoked, the engine closes with reduce-only orders, in parts when the book is thin, then STOPPED', async () => {478 const { w, a, acct } = await liveAgent();479 a.weights = { BTC: 1 };480 await a.tick();481 expect(pos(w)).toBe('0.01395');482 w.next();483 w.t.bids = [['69995', '0.005']]; // only part of the close can fill now484 const stale = await a.row(); // a pass that read the agent just before the owner stopped it485 const r = await cyc.stopLive(await a.row(), a.rt, 'fr');486 expect(r.result).toMatch(/stopping: 1 position\(s\) still open \(long 0\.00895 BTC\)/);487 expect(r.windDown).toMatchObject({ openPositions: 1, openOrders: 0 });488 expect(r.windDown.text).toMatch(/Pendant 24 heures/);489 expect(r.windDown.until).toBe(w.clock.now() + 86_400_000);490 let row = await a.row();491 expect(row).toMatchObject({ state: 'STOPPING', mode: 'LIVE', live_account_id: acct, stop_reason: 'stopped by the owner', perp: { live: { exit: { code: 'owner_stop' } } } });492 expect(w.signer.status().venueMandates[0]).toMatchObject({ revoked: true, phase: 'wind_down' });493 expect(w.signs).toHaveLength(0); // no passkey anywhere494 // the pass that was already running finishes with its stale view: it cannot undo the stop, change its reason or add exposure495 const signsBefore = w.orderSigns();496 expect(await cyc.tickLivePerpAgent(stale, a.rt)).toMatch(/^stopping: /);497 const ex = new xl.LivePerpExecutor(w.svc, { accountId: acct, agentId: a.id, strategySha256: a.sha, symbols: ['BTC'], risk: RISK, capital: '1000' });498 expect(await eng.tickPerpAgent(stale, { ...a.rt.deps, executor: () => ex }, { capital: '1000', onBreach: async () => 'breach' })).toBe('waiting for the next bar');499 expect(await a.row()).toMatchObject({ state: 'STOPPING', stop_reason: 'stopped by the owner', perp: { live: { exit: { code: 'owner_stop' } } } });500 expect(w.orderSigns()).toBe(signsBefore);501 // a new signal that wants exposure changes nothing: a stopping agent only closes502 w.t.bids = [['69995', '1']];503 expect(await a.nextBar({ BTC: 1 })).toBe('stopped');504 row = await a.row();505 expect(row).toMatchObject({ state: 'STOPPED', live_account_id: null, stop_reason: 'stopped by the owner; every position was closed with reduce-only orders' });506 expect([pos(w), a.signals]).toEqual(['0', 1]);507 expect((await orders(acct)).slice(1).every((o) => o.reduce_only && o.origin === 'mandate')).toBe(true);508 expect(await refusal(cyc.stopLive(row, a.rt, 'en'))).toMatchObject({ code: 'bad_state' });509 // the account is free again; the agent can return to simulation or go live again with a NEW mandate510 expect((await cyc.liveReadiness(row, a.rt, { locale: 'en' })).next).toBe('mandate');511 await goLive(w, a, acct);512 expect(await a.row()).toMatchObject({ state: 'LIVE', perp: { live: { exit: null } } });513 });514515 it('a close larger than the mandate\'s notional per order is cut, not refused; when the wind-down ends with a position open, the agent says so', async () => {516 const { w, a } = await liveAgent();517 a.weights = { BTC: 1 };518 await a.tick(); // long 0.01395 ≈ 977519 w.next(); w.t.setMid(72_000); w.px.BTC = 72_000; // now worth 1 004: above the 1 000 per order520 w.t.bids = [['71995', '1']];521 const r = await cyc.stopLive(await a.row(), a.rt, 'en');522 expect(r.result).toMatch(/stopping: 1 position\(s\) still open/);523 const left = pos(w);524 expect(D.gt(D.from(left), D.zero) && D.lt(D.from(left), D.from('0.001'))).toBe(true); // 99 % of the per-order bound went in the first order525 // nothing to match any more: the venue refuses every close; retried at most every five minutes, then the wind-down ends526 noLiquidity(w);527 const before = w.orderSigns();528 expect(await a.pass()).toMatch(/stopping: 1 position\(s\) still open/);529 expect(await a.pass()).toMatch(/next closing attempt at/);530 expect(await a.pass()).toMatch(/next closing attempt at/);531 expect(w.orderSigns()).toBe(before + 1);532 w.clock.advance(86_400_000);533 expect(await a.tick()).toBe('stopped');534 const row = await a.row();535 expect(row.stop_reason).toMatch(/stopped by the owner; 1 position\(s\) REMAIN OPEN on the venue account \(long 0\.000\d+ BTC\) and the signer no longer signs for this mandate.*close them in the Futures terminal with your passkey/);536 expect(row).toMatchObject({ state: 'STOPPED', live_account_id: null });537 });538539 it('"pause": no new exposure and no signal, positions KEPT, loss limits still watched; resume trades again', async () => {540 const { w, a } = await liveAgent();541 await a.tick();542 expect(await cyc.pauseLive(await a.row())).toMatchObject({ state: 'PAUSED', mode: 'LIVE' });543 const signs = w.orderSigns();544 expect(await a.nextBar({ BTC: -0.5 })).toMatch(/^paused: positions kept, no new exposure/);545 expect([pos(w), w.orderSigns(), a.signals]).toEqual(['0.00714', signs, 1]);546 expect((await logs(a.id)).find((l) => l.title === 'Paused by the owner')!.body).toMatch(/Positions are KEPT/);547 expect(w.signer.status().venueMandates[0]).toMatchObject({ phase: 'active' }); // a pause does not revoke548 expect((await cyc.resumeLive(await a.row(), a.rt)).state).toBe('LIVE');549 expect(await a.tick()).toMatch(/1 order\(s\), 1 waiting/); // the bar that closed during the pause is decided now550 // a paused agent is still protected: a loss limit closes and stops551 await cyc.pauseLive(await a.row());552 w.t.user(w.ACCOUNT).usdc = '850';553 expect(await a.pass()).toBe('stopped');554 expect(await a.row()).toMatchObject({ state: 'STOPPED', perp: { live: { exit: { code: 'loss_limit' } } } });555 expect(await refusal(cyc.resumeLive(await a.row(), a.rt))).toMatchObject({ code: 'bad_state' });556 });557});558559describe('the engine pass: paper agents are unaffected', () => {560 it('a paper agent and a live agent in the same pass: the paper one never reaches the signer or the venue account', async () => {561 const { w, a, acct } = await liveAgent();562 // a paper agent (state PAPER_TRADING) with a fake paper executor, as in perp-agent.test.ts563 const paper = await agent(w, { name: 'paper' }), placed: string[] = [];564 await sql`update agents set state = 'PAPER_TRADING' where id = ${paper.id}`;565 const fakeExec = { account: async () => ({ state: { venue: 'hyperliquid', account: 'paper', equity: '1000', collateral: '1000', freeCollateral: '1000', marginUsed: '0', maintenanceMargin: null, withdrawable: null, ts: 0, source: 'paper' },566 policy: eng.perpPolicy(['BTC'], '1000', RISK), events: [] }), positions: async () => [], place: async (o: any) => { placed.push(o.clientId); return { status: 'refused', code: 'test', reason: 'paper test' }; } };567 const inst = await w.svc.deps.venues.get('testnet').instrument(BTC);568 const rt = { ...a.rt, deps: { ...a.rt.deps, executor: () => fakeExec, market: async () => ({ inst, ...(await w.svc.deps.venues.get('testnet').market(inst)) }) } } as unknown as import('../src/agents/perp-live.ts').PerpRuntime;569 const out = await pl.perpEnginePass(rt, w.clock.now());570 expect(out[a.id]).toBe('1 order(s)');571 expect(out[paper.id]).toBe('1 order(s) not sent');572 expect(placed).toHaveLength(1); // the paper agent went through ITS executor573 expect([w.orderSigns(), (await orders(acct)).length]).toEqual([1, 1]); // the signer and the venue account saw the live agent's order only574 expect(await eng.tickPerpAgent(await a.row(), rt.deps)).toBe('idle'); // the paper entry point never runs a live agent575 expect(await eng.tickPerpAgent({ ...(await paper.row()), mode: 'LIVE' }, a.rt.deps)).toBe('idle');576 expect(await cyc.tickLivePerpAgent(await paper.row(), a.rt)).toBe('idle'); // and the live one never runs a paper agent577 });578});579580describe('through the API routes: the owner\'s session, CSRF, no route that places an order', () => {581 it('create → checklist → prepare → approve (one passkey) → page → pause/resume → stop → back to simulation', async () => {582 await reset();583 const { randomBytes } = await import('node:crypto'), { fixtureVenues } = await import('./derivatives-helpers.ts'), mod = await import('../src/derivatives/index.ts');584 const w = await liveWorld(), ORIGIN = 'https://wallet.test';585 const dv = mod.createDerivatives({ registry: mod.createRegistry(fixtureVenues(w.clock as never).adapters, w.clock.now), now: w.clock.now });586 const app = await (await import('../src/server.ts')).buildServer({ derivatives: dv, live: w.svc });587 const script = { bar: Math.floor(w.clock.now() / H) * H - H, weights: { BTC: 0.5 } as Record<string, number> };588 const rt = pl.createPerpRuntime(dv, { live: w.svc, gate: async () => ({ bar: script.bar, data: {} }), signal: async () => ({ weights: script.weights, maxLeverage: 2, maxGrossExposure: 2 }) });589 pl.setPerpRuntime(rt);590 try {591 const token = randomBytes(32).toString('base64url');592 await sql`insert into sessions (id_hash, expires_at) values (${createHash('sha256').update(token).digest('hex')}, now() + interval '1 hour')`;593 const cookie = `${(await import('../src/auth.ts')).COOKIE}=${token}`;594 const api = async (method: 'GET' | 'POST', url: string, body?: unknown, auth = true) => {595 const r = await app.inject({ method, url: `/api/v1${url}`, headers: { ...(auth ? { cookie } : {}), ...(method === 'POST' ? { origin: ORIGIN, 'x-spw-csrf': '1', 'content-type': 'application/json' } : {}) }, ...(method === 'POST' ? { payload: JSON.stringify(body ?? {}) } : {}) });596 return { status: r.statusCode, json: r.json() as any };597 };598 const a = (await api('POST', '/agents', { kind: 'perp', name: 'Routes live', instruments: ['BTC'], interval: '1h', capitalUsdc: '1000', risk: RISK })).json;599 expect(a).toMatchObject({ kind: 'perp', mode: 'PAPER', simulation: true });600 expect((await api('GET', `/agents/${a.id}/perp/live`, undefined, false)).status).toBe(401); // the owner's session, like every route601 expect((await api('GET', `/agents/${a.id}/perp/live?locale=fr`)).json).toMatchObject({ next: 'strategy', locale: 'fr' });602 const sv = randomUUID(), sha = 'ab'.repeat(32);603 await sql`insert into strategy_versions (id, agent_id, name, version, code_sha256, path, meta, status, report) values (${sv}, ${a.id}, 'fixed_perp', '1', ${sha}, 'strategies/fixed_perp/1', ${sql.json({ kind: 'perp', lookback: 10, max_leverage: 2, max_gross_exposure: 2 })}, 'approved', ${sql.json({})})`;604 await sql`update agents set strategy_version_id = ${sv}, state = 'AWAITING_APPROVAL' where id = ${a.id}`;605 const acct = await w.authorise();606 expect((await api('GET', `/agents/${a.id}/perp/live?account=${acct}&days=5`)).json).toMatchObject({ next: 'mandate', proposal: { days: 5, maxDays: 29 } });607 expect((await api('POST', `/agents/${a.id}/perp/live/prepare`, { accountId: acct, locale: 'fr', capitalUsdc: '5' })).status).toBe(400); // nothing but the account, the duration and the language is chosen608 const prep = (await api('POST', `/agents/${a.id}/perp/live/prepare`, { accountId: acct, locale: 'fr' })).json;609 expect(prep).toMatchObject({ locale: 'fr', mandate: { agentId: a.id, strategySha256: sha }, proposal: { capital: '1000' } });610 expect((await api('POST', `/agents/${a.id}/perp/live/approve`, { accountId: acct, assertion: w.pk.assert(w.b64(prep.hash)), acknowledged: false })).status).toBe(400);611 const live = await api('POST', `/agents/${a.id}/perp/live/approve`, { accountId: acct, assertion: w.pk.assert(w.b64(prep.hash)), acknowledged: true });612 expect(live.json).toMatchObject({ state: 'LIVE', mode: 'LIVE', simulation: false, execution: { kind: 'live', network: 'testnet' },613 live: { available: true, active: true, accountId: acct, network: 'testnet', capital: '1000', account: { id: acct, mandate: { phase: 'active' } }, signer: { phase: 'active' } } });614 expect(live.json.execution.note).toMatch(/test network: the money is not real/);615 expect((await api('GET', '/agents')).json.agents.find((x: any) => x.id === a.id)).toMatchObject({ mode: 'LIVE', simulation: false, state: 'LIVE', liveNetwork: 'testnet' });616 // the engine pass (the worker's): an order on the venue account, visible on the page as the venue reports it617 expect((await pl.perpEnginePass(rt, w.clock.now()))[a.id]).toBe('1 order(s)');618 const det = (await api('GET', `/agents/${a.id}`)).json;619 expect(det.live.account.venueState).toMatchObject({ source: 'venue', positions: [{ instrument: BTC, side: 'long', size: '0.00714', source: 'venue' }] });620 expect(det.live.account.orders[0]).toMatchObject({ origin: 'mandate', state: 'filled' });621 expect(det.live.signer.today).toMatchObject({ orders: 1 });622 // what a live agent refuses: limits, the paper flatten, a return to simulation — and there is no route that places an order623 expect((await api('POST', `/agents/${a.id}/perp/risk`, { risk: RISK })).json).toMatchObject({ error: 'bad_state' });624 expect((await api('POST', `/agents/${a.id}/perp/flatten`)).json).toMatchObject({ error: 'bad_state' });625 expect((await api('POST', `/agents/${a.id}/mode`, { mode: 'PAPER' })).json).toMatchObject({ error: 'bad_state' });626 for (const r of ['orders', 'order', 'place']) expect((await api('POST', `/agents/${a.id}/perp/live/${r}`, {})).status).toBe(404);627 expect((await api('POST', `/agents/${a.id}/perp/live/pause`, {})).json).toMatchObject({ state: 'PAUSED', mode: 'LIVE', live: { paused: true, active: true } });628 expect((await api('POST', `/agents/${a.id}/perp/live/resume`, {})).json).toMatchObject({ state: 'LIVE', live: { paused: false } });629 w.next();630 const stop = (await api('POST', `/agents/${a.id}/perp/live/stop`, { locale: 'fr' })).json;631 expect(stop).toMatchObject({ state: 'STOPPED', mode: 'LIVE', result: 'stopped', live: { active: false, exit: { code: 'owner_stop' } }, stopReason: 'stopped by the owner; every position was closed with reduce-only orders', windDown: { openPositions: 0 } });632 expect(stop.windDown.text).toMatch(/Pendant 24 heures/);633 expect(w.signs).toHaveLength(0); // pause, resume and stop never asked for the passkey634 expect((await api('POST', `/agents/${a.id}/mode`, { mode: 'PAUSED' })).json).toMatchObject({ mode: 'PAPER', state: 'PAUSED', simulation: true }); // stopped: back to simulation635 expect((await api('GET', `/agents/${a.id}/perp/live?account=${acct}`)).json).toMatchObject({ next: 'mandate', steps: { mandate: { ok: false } } });636 } finally { pl.setPerpRuntime(null); app.server.closeAllConnections?.(); await app.close(); }637 });638});639