Repository Public
1// Perp agents (V6): lifecycle and views. A perp agent is bound to ONE venue (Hyperliquid), 1 to 8 of its instruments, one2// interval and ONE derivatives paper account created for it (collateral = the agent's capital, risk policy = the owner's limits).3// It is created in SIMULATION: no vault, no mandate, creation never asks the signer for anything. V7: it can go LIVE on a4// venue account under a derivative mandate enforced by the isolated signer (perp-live-cycle.ts) — the routes are at the end.5// The routes live in the `/api/v1/agents` family; the shared ones (list, create, detail, mode, approve) delegate here from6// routes.ts when the agent's kind is 'perp'.7import { randomUUID } from 'node:crypto';8import type { FastifyInstance } from 'fastify';9import { z } from 'zod';10import { formatUnits, parseUnits } from '@spw/core';11import { D } from '@spw/derivatives';12import { UserError } from '../config.ts';13import { audit, sql } from '../db.ts';14import type { LiveService } from '../derivatives/exec.ts';15import { derivatives, type Derivatives } from '../derivatives/index.ts';16import { liveLocale } from '../derivatives/live-texts.ts';17import { approveLive, isLiveActive, LiveApproveBody, liveBlock, LivePrepareBody, liveReadiness, pauseLive, prepareLive, resumeLive, stopLive } from './perp-live-cycle.ts';18import { GATE, PERP_INTERVALS } from './perp-data.ts';19import { CAP_HEADROOM, flattenPerp, instrumentId, LEVERAGE_HEADROOM, PERP_VENUE, perpPolicy, PerpRisk, type PerpAgentRow, type PerpBook } from './perp-engine.ts';20import { createPerpRuntime, perpRuntime, setPerpRuntime } from './perp-live.ts';21import { perpLibrary, perpReportProblem, registerPerpStrategy } from './perp-strategies.ts';22import type { AgentRow } from './routes.ts';2324export const MAX_PERP_INSTRUMENTS = 8;25export const LIVE_UNAVAILABLE = 'Live trading of a perp agent needs a derivatives mandate enforced by the isolated signer (instruments, leverage, notional and loss limits checked by the signer on every order). A perp agent has no spot vault and no spot mandate: it goes live from its own page (venue account, then mandate).';26/** symbols the laboratory accepts as they are (upper-case tickers); mixed-case listings (kPEPE…) are not offered */27const SYMBOL = /^[A-Z0-9]{2,10}$/;28const PerpCreate = z.strictObject({29 kind: z.literal('perp'), name: z.string().min(2).max(40), mission: z.string().max(400).default(''), venue: z.literal(PERP_VENUE).default(PERP_VENUE),30 instruments: z.array(z.string().regex(SYMBOL)).min(1).max(MAX_PERP_INSTRUMENTS).refine((x) => new Set(x).size === x.length, 'duplicate instrument'),31 interval: z.enum(PERP_INTERVALS).default('1h'),32 /** collateral of the paper account, USDC */33 capitalUsdc: z.string().regex(/^\d{2,7}(\.\d{1,6})?$/), risk: PerpRisk.default(PerpRisk.parse({})) });3435export const isPerp = (a: Pick<AgentRow, 'id'> & { kind?: string }): a is PerpAgentRow => a.kind === 'perp';36/** routes that only make sense for a spot agent's vault and mandate */37export function spotOnly(a: Pick<AgentRow, 'id'> & { kind?: string }): void {38 if (isPerp(a)) throw new UserError('perp_agent', `not available for a perp agent. ${LIVE_UNAVAILABLE}`, 409);39}4041export const perpView = (a: PerpAgentRow) => ({42 id: a.id, kind: 'perp' as const, simulation: a.mode !== 'LIVE', name: a.name, mission: a.mission, state: a.state, mode: a.mode === 'LIVE' ? 'LIVE' as const : 'PAPER' as const,43 /** V7: the network of the venue account of an agent whose mode is LIVE (testnet = the money is not real); null in simulation */44 liveNetwork: a.mode === 'LIVE' ? a.perp?.live?.network ?? null : null, venue: PERP_VENUE, network: PERP_VENUE, networkName: 'Hyperliquid', universe: a.universe,45 instruments: a.universe.map(instrumentId), cash: 'USDC', interval: a.interval, risk: a.risk, capitalUsdc: formatUnits(BigInt(a.capital_usdc), 6), accountId: a.deriv_account_id,46 strategyVersionId: a.strategy_version_id, mandateId: null, mandate: null, vaultAddress: '', holdings: null,47 flag: a.perp?.flag ?? null, hold: a.perp?.hold ?? null, flattening: a.perp?.flatten ?? null,48 lastBar: a.last_bar, stopReason: a.stop_reason, createdAt: a.created_at, updatedAt: a.updated_at });4950const rt = () => perpRuntime();51const paper = () => rt().deriv.paper;52const data = () => rt().deriv.data;5354export async function createPerpAgent(body: unknown) {55 const b = PerpCreate.parse(body);56 const capital = D.str(D.from(b.capitalUsdc));57 if (D.lt(D.from(capital), D.from(50n))) throw new UserError('bad_request', 'a perp agent needs at least 50 USDC of simulated capital (the venue\'s minimum order is 10 USD)', 400);58 if (b.risk.maxDailyLossPct > b.risk.maxTotalLossPct) throw new UserError('bad_request', 'the daily loss limit cannot exceed the total loss limit', 400);59 // every instrument must exist on the venue, be active and accept cross margin (the paper account is cross-margin)60 const list = (await data().instruments(PERP_VENUE)).value, warnings: { code: string; message: string }[] = [];61 for (const s of b.instruments) {62 const inst = list.find((i) => i.symbol === s);63 if (!inst) throw new UserError('unknown_instrument', `${s} is not a Hyperliquid perpetual`, 422);64 if (inst.status !== 'active') throw new UserError('instrument_halted', `${inst.id} is ${inst.status} on the venue`, 422);65 if (!inst.marginModes.includes('cross')) throw new UserError('unsupported_instrument', `${inst.id} is isolated-margin only; the paper account is cross-margin`, 422);66 if (D.lt(D.from(inst.maxLeverage), D.from(BigInt(b.risk.maxLeverage)))) warnings.push({ code: 'leverage', message: `${s}: the venue allows ${inst.maxLeverage}x at most; its orders use that setting, the account limit stays ${b.risk.maxLeverage}x` });67 }68 // the paper account of the agent: collateral = capital, policy = the owner's limits, only the agent's instruments allowed69 const account = await paper().create({ venue: PERP_VENUE, label: `Agent · ${b.name}`.slice(0, 60), collateral: capital, policy: perpPolicy(b.instruments, capital, b.risk) });70 const id = randomUUID();71 let row: PerpAgentRow;72 try {73 const rows = await sql.begin(async (tx) => {74 await tx`select pg_advisory_xact_lock(7707002)`;75 // no vault: a negative index keeps the unique column and can never collide with a signer-derived vault (index ≥ 1)76 const rows = await tx<{ next: number }[]>`select least(coalesce(min(vault_index), 0), 0) - 1 as next from agents`;77 return tx<PerpAgentRow[]>`insert into agents (id, kind, name, mission, network, universe, cash, assets, interval, risk, vault_index, vault_address, capital_usdc, deriv_account_id, mode)78 values (${id}, 'perp', ${b.name}, ${b.mission}, ${PERP_VENUE}, ${tx.json(b.instruments)}, 'USDC', ${tx.json({})}, ${b.interval}, ${tx.json(b.risk as never)}, ${rows[0]!.next}, '', ${parseUnits(capital, 6).toString()}, ${account.id}, 'PAPER') returning *`;79 }) as unknown as PerpAgentRow[];80 row = rows[0]!;81 } catch (e) { await sql`delete from deriv_paper_accounts where id = ${account.id}`.catch(() => {}); throw e; }82 await audit('agent.created', 'owner', { id, kind: 'perp', name: b.name, venue: PERP_VENUE, instruments: b.instruments, account: account.id, capital, risk: b.risk });83 return { ...perpView(row), warnings };84}8586const pick = (o: Record<string, unknown> | undefined, keys: string[]): Record<string, unknown> | null => (o ? Object.fromEntries(keys.map((k) => [k, o[k] ?? null])) : null);87const SPLIT_KEYS = ['total_return', 'sharpe', 'max_drawdown', 'worst_intrabar_drawdown', 'trades', 'fees_paid', 'slippage_paid', 'funding_pnl', 'liquidations', 'wiped_out', 'gross_leverage_max', 'margin_utilisation_max', 'orders_cut', 'orders_refused'];88/** What the owner must read before approving: the figures, and every reason to distrust them. */89export function perpReportSummary(report: any, sha256: string, symbols: string[]) {90 if (!report) return null;91 const c = report.config ?? {}, notes: string[] = Array.isArray(report.checks?.notes) ? report.checks.notes : [];92 const days = c.start && c.end ? Math.round((Date.parse(c.end) - Date.parse(c.start)) / 86_400_000) : null;93 const liq = (split: string): number => (Array.isArray(report.liquidations?.[split]) ? report.liquidations[split].length : 0);94 return {95 schemaVersion: report.schema_version ?? null, generatedAt: report.generated_at ?? null,96 config: pick(c, ['symbols', 'interval', 'start', 'end', 'bars', 'fee_bps', 'slippage_bps', 'leverage', 'max_leverage', 'max_gross_exposure', 'liquidation_penalty_bps', 'funding_reference_price', 'initial_cash', 'source']),97 historyDays: days, splits: report.splits ?? null,98 metrics: Object.fromEntries(['train', 'validation', 'test'].map((s) => [s, pick(report.metrics?.[s], SPLIT_KEYS)])),99 benchmarks: { buy_and_hold: pick(report.benchmarks?.buy_and_hold, ['total_return', 'sharpe', 'max_drawdown']), perp_long_1x: pick(report.benchmarks?.perp_long_1x, ['total_return', 'sharpe', 'max_drawdown']) },100 /** false: no spot candles were supplied, the "buy and hold" benchmark is the perpetual's own price held */101 buyAndHoldIsSpot: !notes.some((n) => n.startsWith('no spot candles were supplied')),102 robustness: pick(report.robustness, ['trials_tested', 'deflated_sharpe', 'bootstrap_sharpe_ci']),103 checks: pick(report.checks, ['lookahead', 'data_quality', 'ledger', 'funding_alignment']),104 liquidations: { train: liq('train'), validation: liq('validation'), test: liq('test') },105 /** non-null: the version cannot be approved */106 problem: perpReportProblem(report, sha256, symbols),107 };108}109110export async function perpDetail(a: PerpAgentRow) {111 const account = await paper().get(a.deriv_account_id).catch(() => null);112 const rows = await sql`select id, name, version, code_sha256, status, notes, created_at, approved_at, meta, report from strategy_versions where agent_id = ${a.id} order by created_at desc`;113 const versions = rows.map((v) => ({ id: v.id, name: v.name, version: v.version, code_sha256: v.code_sha256, status: v.status, notes: v.notes, created_at: v.created_at, approved_at: v.approved_at,114 meta: v.meta, summary: perpReportSummary(v.report, v.code_sha256, a.universe) }));115 const signals = await sql`select bar, status, reason, strategy_sha256, weights, scaled, scale, equity::text as equity, targets, orders, updated_at from perp_agent_signals where agent_id = ${a.id} order by bar desc limit 60`;116 const equity = await sql`select ts, mode, equity_usd from agent_equity where agent_id = ${a.id} order by ts desc limit 500`;117 const log = await sql`select ts, kind, title, body from research_log where agent_id = ${a.id} order by ts desc limit 40`;118 return { ...perpView(a),119 account: account ? { id: account.id, label: account.label, state: account.state, policy: account.policy, settings: account.settings, totals: account.totals, initialCollateral: account.initialCollateral,120 positions: account.positions, openOrders: account.openOrders, orders: account.orders.slice(0, 60), fills: account.fills.slice(0, 60), funding: account.funding.slice(0, 60),121 riskEvents: account.riskEvents.slice(0, 60), equity: account.equity, updatedAt: account.updatedAt } : null,122 versions, signals, equity: equity.reverse(), log,123 execution: a.mode === 'LIVE' && a.perp?.live124 ? { kind: 'live' as const, network: a.perp.live.network, note: `Orders are REAL orders on the Hyperliquid ${a.perp.live.network} account${a.perp.live.network === 'testnet' ? ' (test network: the money is not real)' : ' (real money)'}: signed by the isolated signer under the mandate, without a passkey, and confirmed by the venue's own records. Positions, fills, funding and equity are read from the venue.` }125 : { kind: 'paper' as const, note: 'Orders are simulated against the venue\'s live order book by the paper engine. Nothing is signed and no order reaches the venue.' },126 live: await liveBlock(a, rt(), true),127 rules: { markIndexBps: GATE.markIndexBps, lastCloseBps: GATE.lastCloseBps, leverageHeadroom: LEVERAGE_HEADROOM, capHeadroom: CAP_HEADROOM,128 simplifications: ['flat 2 % maintenance margin fraction', 'cross margin only', 'the liquidation price is a single-position estimate', 'a simulated liquidation closes the whole account at the mark',129 'fills walk the live book with no latency, no queue position and no market impact', 'funding is applied on the mark at the venue\'s funding times with the venue\'s published rate'] } };130}131132/** Owner changes of state. PAPER needs an approved version and an account whose circuit breaker is not set. */133export async function perpMode(a: PerpAgentRow, mode: 'PAPER' | 'PAUSED' | 'STOPPED' | 'RESEARCH') {134 // V7: an agent that is on a venue account changes state through the live life cycle only (pause keeps positions; stop135 // revokes the mandate and closes them). It returns to simulation once it is STOPPED.136 if (isLiveActive(a)) {137 if (mode === 'PAUSED') return perpView(a.state === 'PAUSED' ? a : await pauseLive(a));138 if (mode === 'STOPPED') { await stopLive(a, rt(), 'en'); return perpView((await sql<PerpAgentRow[]>`select * from agents where id = ${a.id}`)[0]!); }139 throw new UserError('bad_state', 'this agent is on a venue account: stop it first (the mandate is revoked and its positions are closed), then return to simulation', 409);140 }141 if (mode === 'PAPER') {142 if (!a.strategy_version_id) throw new UserError('no_strategy', 'approve a strategy version first', 409);143 const acct = await paper().get(a.deriv_account_id);144 if (acct.policy.halt !== null) throw new UserError('halted', `the paper account is halted (${acct.policy.halt}); replace the limits and lift the halt, or reset the account in the Futures terminal`, 409);145 }146 const state = mode === 'PAPER' ? 'PAPER_TRADING' : mode;147 const book: PerpBook = { ...(a.perp ?? {}) };148 if (mode === 'PAPER') { book.flatten = null; book.hold = null; }149 const [r] = await sql<PerpAgentRow[]>`update agents set state = ${state}, mode = 'PAPER', perp = ${sql.json(book as never)}, stop_reason = ${mode === 'STOPPED' ? 'stopped by the owner' : null}, updated_at = now() where id = ${a.id} returning *`;150 await audit('agent.mode', 'owner', { id: a.id, kind: 'perp', state });151 return perpView(r!);152}153154/** Approval of a perp version: the report must be a version-2 report for this hash and these instruments, with its integrity checks passing. */155export function assertPerpApprovable(a: PerpAgentRow, v: { code_sha256: string; report: unknown; meta: { kind?: string } }): void {156 if (v.meta?.kind !== 'perp') throw new UserError('wrong_kind', 'this version is not a perpetual-futures strategy', 409);157 const why = perpReportProblem(v.report, v.code_sha256, a.universe);158 if (why) throw new UserError('bad_report', why, 409);159}160161export const registerPerp = (a: PerpAgentRow, rel: string) => registerPerpStrategy(a, rel, rt().lab);162export { perpLibrary };163164const loadPerp = async (id: string): Promise<PerpAgentRow> => {165 if (!z.uuid().safeParse(id).success) throw new UserError('not_found', 'agent not found', 404);166 const [a] = await sql<PerpAgentRow[]>`select * from agents where id = ${id}`;167 if (!a || a.kind !== 'perp') throw new UserError('not_found', 'perp agent not found', 404);168 return a;169};170171export function perpAgentRoutes(app: FastifyInstance, d?: Derivatives, l?: LiveService): void {172 // an injected derivatives instance or live engine (tests, the simulation scripts) gets its own runtime: same caches, same rate budget173 if (d || l) setPerpRuntime(createPerpRuntime(d ?? derivatives(), l ? { live: l } : {}));174 app.addHook('onClose', async () => { if (d || l) setPerpRuntime(null); });175176 // what a perp agent can be built from: the venue, its instruments as the venue lists them now, the limits' bounds177 app.get('/api/v1/agents/perp/venues', async () => {178 const [list, tickers] = await Promise.all([data().instruments(PERP_VENUE), data().tickers(PERP_VENUE).catch(() => null)]);179 const instruments = list.value.map((i) => {180 const t = tickers?.value.find((x) => x.instrument === i.id);181 const why = !SYMBOL.test(i.symbol) ? 'ticker format not supported by the laboratory' : i.status !== 'active' ? `${i.status} on the venue` : !i.marginModes.includes('cross') ? 'isolated margin only' : null;182 return { symbol: i.symbol, id: i.id, maxLeverage: i.maxLeverage, lotSize: i.lotSize, minNotional: i.minNotional, mark: t?.mark ?? null, funding: t?.funding ?? null, volume24h: t?.volume24h ?? null, eligible: why === null, why };183 }).sort((x, y) => Number(y.volume24h ?? 0) - Number(x.volume24h ?? 0));184 return { venues: [{ id: PERP_VENUE, name: 'Hyperliquid', network: data().registry.get(PERP_VENUE).adapter.network, paper: true, instruments }], maxInstruments: MAX_PERP_INSTRUMENTS, intervals: PERP_INTERVALS,185 riskDefaults: PerpRisk.parse({}), minCapital: '50', live: (() => { const m = rt().live().config('en').mandateLive; return { available: m.available, reason: m.reason }; })(), ts: data().now() };186 });187188 // the owner replaces the limits: the agent's own and the account's risk policy change together189 app.post('/api/v1/agents/:id/perp/risk', async (req) => {190 const a = await loadPerp((req.params as { id: string }).id);191 const b = z.strictObject({ risk: PerpRisk, liftHalt: z.boolean().default(false) }).parse(req.body);192 if (isLiveActive(a)) throw new UserError('bad_state', 'the limits of an agent that is on a venue account are those of its mandate: stop the agent, change the limits, then approve a new mandate', 409);193 if (b.risk.maxDailyLossPct > b.risk.maxTotalLossPct) throw new UserError('bad_request', 'the daily loss limit cannot exceed the total loss limit', 400);194 const acct = await paper().get(a.deriv_account_id);195 await paper().setPolicy(a.deriv_account_id, perpPolicy(a.universe, formatUnits(BigInt(a.capital_usdc), 6), b.risk, b.liftHalt ? null : acct.policy.halt));196 const [r] = await sql<PerpAgentRow[]>`update agents set risk = ${sql.json(b.risk as never)}, updated_at = now() where id = ${a.id} returning *`;197 await audit('agent.perp_risk', 'owner', { id: a.id, risk: b.risk, liftHalt: b.liftHalt });198 return perpView(r!);199 });200201 // close every position of the agent's account with reduce-only orders (always possible, also when stopped or halted)202 app.post('/api/v1/agents/:id/perp/flatten', async (req) => {203 const a = await loadPerp((req.params as { id: string }).id), run = rt();204 if (a.mode === 'LIVE') throw new UserError('bad_state', 'this agent is (or was) on a venue account: "stop" closes its positions with reduce-only orders under the mandate; afterwards, close by hand in the Futures terminal with your passkey', 409);205 const f = await flattenPerp(a, run.deps, run.deps.executor(a), `m${Math.floor(run.deps.now() / 1000)}`);206 await audit('agent.perp_flatten', 'owner', { id: a.id, orders: f.results.length, left: f.left.length });207 return { orders: f.results, positionsLeft: f.left.length };208 });209210 // >>> v7 perp agents LIVE — going live on a venue account under a signer-enforced mandate, and leaving it. No route places211 // an order. Pause, resume and stop need no passkey; going live needs exactly one (judged by the signer, not here).212 const param = (req: { params: unknown }) => loadPerp((req.params as { id: string }).id);213 /** the checklist: approved version · authorised venue account · funded · mandate — and the mandate that would be proposed */214 app.get('/api/v1/agents/:id/perp/live', async (req) => {215 const q = z.object({ account: z.uuid().optional(), locale: z.string().max(5).optional(), days: z.coerce.number().int().min(1).max(30).optional() }).parse(req.query);216 return liveReadiness(await param(req), rt(), { account: q.account, locale: liveLocale(q.locale), days: q.days });217 });218 app.post('/api/v1/agents/:id/perp/live/prepare', async (req) => prepareLive(await param(req), rt(), LivePrepareBody.parse(req.body)));219 app.post('/api/v1/agents/:id/perp/live/approve', async (req) => perpDetail(await approveLive(await param(req), rt(), LiveApproveBody.parse(req.body))));220 app.post('/api/v1/agents/:id/perp/live/pause', async (req) => perpDetail(await pauseLive(await param(req))));221 app.post('/api/v1/agents/:id/perp/live/resume', async (req) => perpDetail(await resumeLive(await param(req), rt())));222 app.post('/api/v1/agents/:id/perp/live/stop', async (req) => {223 const a = await param(req), b = z.strictObject({ locale: z.enum(['fr', 'en']).default('en') }).parse(req.body ?? {});224 const r = await stopLive(a, rt(), b.locale);225 return { ...(await perpDetail(await loadPerp(a.id))), windDown: r.windDown, result: r.result };226 });227 // <<< v7 perp agents LIVE228}229