SPB Git

Repository Public

perp-routes.ts 20.3 KB 229 lines typescript

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

Theme