SPB Git

Repository Public

perp-agent-live.test.ts 54.4 KB 639 lines typescript

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
Files main
Menu

Theme