SPB Git

Repository Public

perp-live-cycle.ts 40.2 KB 455 lines typescript

1// Perp agents, LIVE (V7): the life cycle that connects an approved perpetual-futures strategy to a real venue account under a2// derivative mandate enforced by the isolated signer (docs/v6/DERIVATIVE-MANDATE.md), and takes it out again.3//4//   PAPER ──(readiness: approved version · authorised venue account · funded · mandate approved with ONE passkey)──▶ LIVE5//   LIVE ──pause──▶ PAUSED (no new exposure, positions kept, loss limits and stop conditions still watched) ──resume──▶ LIVE6//   LIVE | PAUSED ──(owner stop · mandate revoked/expired · authorization gone · server lock · halt · divergence · manual7//                    order · liquidation · loss limit · a signer refusal that says the mandate is over)──▶ STOPPING8//   STOPPING: the mandate is revoked on the signer at once (no passkey: nothing that adds exposure is signed any more), then9//             the engine closes the positions with reduce-only orders while the signer's wind-down allows it ──▶ STOPPED10//             (flat, or — when nothing can be signed any more — with the positions that remain named in the stop reason).11//12// Nothing here widens anything: the mandate is DERIVED from the agent's risk settings and clamped to the venue authorization the13// owner approved; the signer checks it again at approval and on every order. There is no route that places an order: orders14// come from the engine pass only (perp-engine.ts through perp-exec-live.ts through derivatives/mandate.ts).15// Mainnet: prepare/approve/place answer 403 unless SPW_DERIV_LIVE_MAINNET=on AND SPW_DERIV_MANDATE_MAINNET=on (read by the live16// module at every use). This file never reads or sets either.17import { z } from 'zod';18import { D, type DecStr, type Position } from '@spw/derivatives';19import { hlPinnedAsset, VENUE_MANDATE_BOUNDS, VENUE_MANDATE_WIND_DOWN_S } from '@spw/derivatives/live';20import { UserError } from '../config.ts';21import { audit, sql } from '../db.ts';22import type { LiveService } from '../derivatives/exec.ts';23import { liveReason, liveTexts, type LiveLocale, type LiveReasonCode } from '../derivatives/live-texts.ts';24import { mandateAccountState, type MandateAccountState } from '../derivatives/mandate.ts';25import { PERP_STEP_MS, type PerpInterval } from './perp-data.ts';26import { flattenPerp, instrumentId, tickPerpAgent, type PerpAgentRow, type PerpEngineDeps, type PerpLiveBook } from './perp-engine.ts';27import { LIVE_EXIT_CODES, LivePerpExecutor } from './perp-exec-live.ts';2829/** what the engine needs from the runtime (perp-live.ts builds it; tests and the simulation script build their own) */30export interface PerpLiveRuntime { deps: PerpEngineDeps; live: () => LiveService }3132export const LIVE_ACTIVE_STATES = ['LIVE', 'PAUSED', 'STOPPING'] as const;33/** the agent is on a venue account right now (trading, paused, or closing during the wind-down) */34export const isLiveActive = (a: Pick<PerpAgentRow, 'mode' | 'state'>): boolean => a.mode === 'LIVE' && (LIVE_ACTIVE_STATES as readonly string[]).includes(a.state);3536/** funding minimum: every instrument must be able to hold this many minimum orders when the gross limit is split equally */37export const MIN_ORDERS_PER_POSITION = 5;38/** … and never less than this (the venue's minimum order is 10 USD) */39export const MIN_LIVE_EQUITY = '50';40/** an order must clear the venue's minimum notional by this factor (the engine's own rule, perp-engine.ts) */41const MIN_ORDER_FACTOR = '1.05';42/** daily turnover of the mandate, as a multiple of its gross exposure limit */43export const TURNOVER_X = 4;44/** after a refused closing attempt during the wind-down, the next one waits this long */45const CLOSE_RETRY_MS = 5 * 60_000;46const CENT = D.from('0.01'), HUNDRED = D.from(100n);4748const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e)).slice(0, 240);49const numDec = (n: number, dp = 4) => D.from(n.toFixed(dp));50const log = (a: { id: string }, kind: string, title: string, body = '', data?: unknown) => sql`insert into research_log (agent_id, kind, title, body, data) values (${a.id}, ${kind}, ${title}, ${body.slice(0, 2000)}, ${data === undefined ? null : sql.json(data as never)})`;51const reload = async (id: string): Promise<PerpAgentRow> => (await sql<PerpAgentRow[]>`select * from agents where id = ${id}`)[0]!;52const setLive = (id: string, live: PerpLiveBook | null) => sql`update agents set perp = perp || ${sql.json({ live } as never)}, updated_at = now() where id = ${id}`;53const symbolOf = (instrument: string): string => instrument.slice(instrument.indexOf(':') + 1);5455/** why the agent left LIVE, in plain words (English; the UI has its own sentence per code) */56export const EXIT_TEXT: Record<string, string> = {57  owner_stop: 'stopped by the owner',58  mandate_revoked: 'the mandate was revoked',59  mandate_expired: 'the mandate has expired',60  mandate_ended: 'the mandate is no longer in force on the venue account',61  authorization_ended: 'the venue authorization was revoked or has expired',62  mainnet_disabled: 'live trading on mainnet is switched off on the server',63  mandate_mainnet_disabled: 'autonomous (mandate) trading on mainnet is switched off on the server',64  halted: 'trading was halted on the venue account',65  divergence: 'the venue\'s state and the wallet\'s records differ (an order or a position the wallet did not create)',66  manual_order: 'a manual order was placed on the venue account while the agent was live',67  liquidation: 'the venue liquidated a position of the account',68  strategy_changed: 'the approved strategy version is no longer the one pinned in the mandate',69  loss_limit: 'a loss limit of the agent was reached',70  engine_stop: 'the engine stopped the agent',71  signer_revoked: 'the signer reports the mandate as revoked',72  signer_expired: 'the signer reports the mandate as expired',73  signer_wind_down: 'the signer only signs reduce-only orders for this mandate (wind-down)',74  signer_no_authorization: 'the signer no longer holds the venue authorization',75  signer_not_found: 'the signer does not know this mandate',76  signer_halted: 'the signer\'s emergency halt is active',77};78const exitText = (code: string, detail = ''): string => `${EXIT_TEXT[code] ?? `the mandate no longer allows trading (${code})`}${detail ? ` — ${detail}` : ''}`;7980// ------------------------------------------------------------------ readiness and the mandate derived from the agent's limits8182type Summary = Awaited<ReturnType<LiveService['list']>>[number];83interface Version { id: string; name: string; version: string; code_sha256: string; status: string; meta: { kind?: string } | null }8485async function approvedVersion(a: PerpAgentRow): Promise<Version | null> {86  if (!a.strategy_version_id) return null;87  const [v] = await sql<Version[]>`select id, name, version, code_sha256, status, meta from strategy_versions where id = ${a.strategy_version_id}`;88  return v && v.status === 'approved' && v.meta?.kind === 'perp' ? v : null;89}9091/** the smallest account value on which the agent's orders make sense, and why */92async function minimumEquity(a: PerpAgentRow, svc: LiveService, network: string) {93  const venue = svc.deps.venues.get(network), instruments: { instrument: string; minOrder: DecStr }[] = [];94  let largest = D.zero;95  for (const s of a.universe) {96    const inst = await venue.instrument(instrumentId(s)), min = D.mul(D.from(inst.minNotional ?? '10'), D.from(MIN_ORDER_FACTOR));97    instruments.push({ instrument: inst.id, minOrder: D.str(min) }); largest = D.max(largest, min);98  }99  // gross exposure the engine may reach, as a multiple of equity: the tighter of the gross limit and 0.9 × leverage100  const grossX = Math.min(a.risk.maxGrossExposureX, a.risk.maxLeverage * 0.9);101  const need = D.div(D.mul(D.mul(D.from(BigInt(MIN_ORDERS_PER_POSITION)), D.from(BigInt(a.universe.length))), largest), numDec(grossX), 2, 'up');102  return { minEquity: D.str(D.quantize(D.max(D.from(MIN_LIVE_EQUITY), need), D.one, 'up')), basis: { instruments, ordersPerPosition: MIN_ORDERS_PER_POSITION, grossX, floor: MIN_LIVE_EQUITY } };103}104105export interface LiveProposal {106  capital: DecStr; days: number; maxDays: number;107  body: { instruments: { instrument: string; maxLeverage: number; maxPositionNotional: DecStr }[]; maxOrderNotional: DecStr; maxGrossExposure: DecStr; maxDailyTurnover: DecStr; floorEquity: DecStr;108    maxOrdersPerDay: number; maxOrdersPerHour: number; days: number; strategySha256: string; agentId: string };109  derived: { field: string; value: string; from: string }[]; notes: string[];110}111112/**113 * The mandate of a live agent, DERIVED from its risk settings and the account value, then clamped to the venue authorization114 * (a mandate can never be wider than the authorization; the signer checks it again). Pure.115 */116export function deriveMandate(a: Pick<PerpAgentRow, 'id' | 'universe' | 'interval' | 'risk'>, sha256: string, network: 'testnet' | 'mainnet', equity: DecStr,117  auth: { expiresAt: number; policy: { maxLeverage: number; maxOrderNotional: DecStr; maxPositionNotional: DecStr; maxGrossExposure: DecStr; maxOrdersPerDay: number } }, nowMs: number, days?: number): LiveProposal {118  const r = a.risk, C = D.quantize(D.from(equity), CENT, 'down'), p = auth.policy, notes: string[] = [];119  const usd = (x: ReturnType<typeof D.from>): DecStr => D.str(D.quantize(x, CENT, 'down'));120  const clamp = (name: string, wanted: ReturnType<typeof D.from>, limit: DecStr): ReturnType<typeof D.from> => {121    if (D.lte(wanted, D.from(limit))) return wanted;122    notes.push(`${name}: ${usd(wanted)} from the agent's limits, lowered to the venue authorization's ${D.str(D.from(limit))}`);123    return D.from(limit);124  };125  const gross = clamp('gross exposure', D.mul(C, numDec(r.maxGrossExposureX)), p.maxGrossExposure);126  const position = D.min(clamp('position per instrument', D.mul(C, numDec(r.maxPositionX)), p.maxPositionNotional), gross);127  const order = clamp('notional per order', position, p.maxOrderNotional);128  const instruments = a.universe.map((s) => {129    const id = instrumentId(s), pin = hlPinnedAsset(network, id);130    return { instrument: id, maxLeverage: Math.max(1, Math.min(r.maxLeverage, p.maxLeverage, pin?.maxLeverage ?? 1)), maxPositionNotional: usd(position) };131  });132  const barsPerDay = Math.round(86_400_000 / PERP_STEP_MS[a.interval as PerpInterval]), n = a.universe.length;133  const perDay = Math.min(500, p.maxOrdersPerDay, Math.max(12, 2 * n * barsPerDay));134  const perHour = Math.min(VENUE_MANDATE_BOUNDS.maxOrdersPerHour, perDay, Math.max(4, 2 * n));135  const turnover = D.max(order, D.mul(gross, D.from(BigInt(TURNOVER_X))));136  const floor = D.quantize(D.mul(C, D.sub(D.one, D.div(numDec(r.maxTotalLossPct), HUNDRED, 8, 'down'))), CENT, 'up');137  // the mandate ends at least one wind-down (24 h) before the authorization, so that positions can still be closed after it expires138  const maxDays = Math.min(VENUE_MANDATE_BOUNDS.maxDays, Math.floor((auth.expiresAt - nowMs - VENUE_MANDATE_WIND_DOWN_S * 1000) / 86_400_000));139  const d = Math.max(1, Math.min(days ?? maxDays, maxDays));140  const lev = instruments.map((i) => `${symbolOf(i.instrument)} ${i.maxLeverage}x`).join(', ');141  return {142    capital: D.str(C), days: d, maxDays,143    body: { instruments, maxOrderNotional: usd(order), maxGrossExposure: usd(gross), maxDailyTurnover: usd(turnover), floorEquity: D.str(floor), maxOrdersPerDay: perDay, maxOrdersPerHour: perHour, days: d, strategySha256: sha256, agentId: a.id },144    derived: [145      { field: 'capital', value: D.str(C), from: 'the venue\'s account value now: the reference of every limit below and of the loss limits' },146      { field: 'instruments', value: instruments.map((i) => i.instrument).join(', '), from: 'the agent\'s instruments' },147      { field: 'maxLeverage', value: lev, from: `the agent's leverage limit (${r.maxLeverage}x), never above the authorization's (${p.maxLeverage}x) or the venue's maximum` },148      { field: 'maxPositionNotional', value: usd(position), from: `position per instrument: ${r.maxPositionX} × the capital` },149      { field: 'maxOrderNotional', value: usd(order), from: 'one order may be as large as one position (a full close in one order); closes are bounded too' },150      { field: 'maxGrossExposure', value: usd(gross), from: `gross exposure: ${r.maxGrossExposureX} × the capital` },151      { field: 'maxOrdersPerDay', value: String(perDay), from: `${n} instrument(s) × ${barsPerDay} bar(s) a day × 2 orders that can add exposure, at least 12` },152      { field: 'maxOrdersPerHour', value: String(perHour), from: `${n} instrument(s) × 2 orders that can add exposure, at least 4` },153      { field: 'maxDailyTurnover', value: usd(turnover), from: `${TURNOVER_X} × the gross exposure limit a day (orders that can add exposure)` },154      { field: 'floorEquity', value: D.str(floor), from: `the total-loss limit: the capital less ${r.maxTotalLossPct} % — below it the signer signs only cancels and reduce-only orders` },155      { field: 'days', value: String(d), from: `at most ${VENUE_MANDATE_BOUNDS.maxDays} days, and ending at least 24 h before the venue authorization expires (${new Date(auth.expiresAt).toISOString()}) so that positions can be closed during the wind-down` },156    ],157    notes,158  };159}160161type Step = { ok: boolean; code: string | null; reason: string | null };162const bad = (code: string, reason: string): Step => ({ ok: false, code, reason }), good: Step = { ok: true, code: null, reason: null };163164/** Everything the "go live" checklist shows, computed from the server's own state. Read-only. */165export async function liveReadiness(a: PerpAgentRow, rt: PerpLiveRuntime, q: { account?: string | undefined; locale: LiveLocale; days?: number | undefined }) {166  const svc = rt.live(), cfg = svc.config(q.locale), now = svc.now();167  const sv = await approvedVersion(a);168  const list = await svc.list();169  const users = await sql<{ id: string; name: string; live_account_id: string }[]>`select id, name, live_account_id from agents where live_account_id is not null`;170  const usedBy = (id: string) => { const u = users.find((x) => x.live_account_id === id); return u ? { agentId: u.id, name: u.name } : null; };171  const current = isLiveActive(a) ? a.perp?.live?.accountId : undefined;172  const pick: Summary | undefined = list.find((x) => x.id === (q.account ?? current)) ?? (q.account ? undefined : list.find((x) => x.network === 'testnet') ?? list[0]);173  let state: MandateAccountState | null = null;174  if (pick) state = await mandateAccountState(pick.id, svc).catch(() => null);175  const accounts = list.map((x) => ({ id: x.id, network: x.network, label: x.label, account: x.account, mode: x.mode, modes: x.modes, tradable: x.tradable,176    authorization: x.authorization ? { active: x.authorization.active, expiresAt: x.authorization.expiresAt } : null,177    mandate: x.mandate ? { id: x.mandate.id, phase: x.mandate.phase, agentId: x.mandate.agentId } : null, usedBy: usedBy(x.id),178    equity: x.id === pick?.id ? state?.state?.equity ?? null : null, error: x.id === pick?.id ? (state ? state.error : 'the venue account could not be read') : null }));179180  // 1 — an approved strategy version: its hash is what the mandate pins181  const strategy = sv ? { ok: true, versionId: sv.id, sha256: sv.code_sha256, name: sv.name, version: sv.version, reason: null as string | null }182    : { ok: false, versionId: null, sha256: null, name: null, version: null, reason: 'approve a perpetual-futures strategy version first: its code hash is pinned in the mandate' as string | null };183184  // 2 — a venue account with an ACTIVE manual authorization (created and authorised by the owner in Futures → Accounts)185  const auth = pick?.authorization ?? null;186  const maxMandateDays = auth ? Math.min(VENUE_MANDATE_BOUNDS.maxDays, Math.floor((auth.expiresAt - now - VENUE_MANDATE_WIND_DOWN_S * 1000) / 86_400_000)) : 0;187  const ids = a.universe.map(instrumentId), other = pick ? usedBy(pick.id) : null;188  const notPinned = pick ? ids.filter((i) => !hlPinnedAsset(pick.network, i)) : [], notAllowed = auth ? ids.filter((i) => !auth.policy.allowedInstruments.includes(i)) : [];189  const mine = !!pick && isLiveActive(a) && a.perp?.live?.accountId === pick.id;190  let acct: Step;191  if (!pick) acct = bad('no_account', 'no venue account yet: create one and authorise it with your passkey in Futures → Accounts');192  else if (pick.network === 'mainnet' && !cfg.mainnet.enabled) acct = bad('mainnet_disabled', cfg.mainnet.reason ?? 'mainnet is disabled on this server');193  else if (!auth || pick.mode !== 'MANUAL_LIVE') acct = bad('read_only', 'this venue account is read-only: authorise live trading on it with your passkey in Futures → Accounts');194  else if (!auth.active) acct = bad('authorization_expired', 'the venue authorization has expired: approve a new one in Futures → Accounts');195  else if (other && other.agentId !== a.id) acct = bad('used_by_other_agent', `this venue account is used by the agent "${other.name}": one agent per venue account`);196  else if (notPinned.length) acct = bad('instruments_not_pinned', `${notPinned.join(', ')}: not in the asset table pinned in the wallet and in the signer for ${pick.network} (live trading exists for the pinned instruments only)`);197  else if (notAllowed.length) acct = bad('instruments_not_allowed', `${notAllowed.join(', ')}: not allowed by the venue authorization of this account (approve one that lists them)`);198  else if (maxMandateDays < 1) acct = bad('authorization_too_short', 'the venue authorization expires in less than 48 hours: a mandate must end at least 24 hours before it, so that positions can be closed during the wind-down. Approve a new authorization');199  else if (!mine && pick.mandate?.phase === 'active') acct = bad('mandate_exists', 'a mandate is already active on this venue account: revoke it in Futures → Accounts before preparing another one');200  else if (!mine && state && state.error === null && (state.positions.length > 0 || state.openOrders.length > 0)) acct = bad('account_not_flat', `the venue account holds ${state.positions.length} position(s) and ${state.openOrders.length} resting order(s): an agent starts on a flat account — close and cancel them in the Futures terminal`);201  else acct = good;202203  // 3 — funded: the venue's own account value against a minimum that makes the venue's minimum order sizes workable204  const min = pick ? await minimumEquity(a, svc, pick.network).catch(() => null) : null;205  const equity = state?.error === null ? state.state?.equity ?? null : null;206  let fund: Step;207  if (!pick) fund = bad('no_account', 'no venue account');208  else if (!state || state.error !== null || !min || equity === null) fund = bad('venue_unreadable', `the venue account could not be read${state?.error ? `: ${state.error}` : ''}`);209  else if (!D.isPos(D.from(equity))) fund = bad('unfunded', `the venue account holds no collateral: deposit USDC to it on the venue (at least ${min.minEquity} USDC for this agent)`);210  else if (D.lt(D.from(equity), D.from(min.minEquity))) fund = bad('below_minimum', `account value ${equity} USDC is below the ${min.minEquity} USDC this agent needs`);211  else fund = good;212213  // 4 — the mandate: one passkey. Mainnet needs the two server settings.214  const lock = !cfg.mandateLive.available ? { code: 'mandate_unavailable', reason: cfg.mandateLive.reason ?? '' }215    : pick?.network === 'mainnet' && !cfg.mainnet.enabled ? { code: 'mainnet_disabled', reason: cfg.mainnet.reason ?? '' }216    : pick?.network === 'mainnet' && !cfg.mandateLive.mainnet.enabled ? { code: 'mandate_mainnet_disabled', reason: cfg.mandateLive.mainnet.reason ?? '' } : null;217  const m = pick?.mandate ?? null, mandateOk = mine && !!m && m.phase === 'active' && m.agentId === a.id && m.id === a.perp?.live?.mandateId;218  const ready = strategy.ok && acct.ok && fund.ok;219  const proposal = ready && sv && pick && auth && equity !== null && !mandateOk ? deriveMandate(a, sv.code_sha256, pick.network, equity, auth, now, q.days) : null;220  const next = !strategy.ok ? 'strategy' : !acct.ok ? 'account' : !fund.ok ? 'fund' : mandateOk ? 'live' : lock ? 'locked' : 'mandate';221  return {222    agentId: a.id, locale: q.locale, config: cfg, accounts, accountId: pick?.id ?? null,223    steps: {224      strategy,225      account: { ...acct, network: pick?.network ?? null, authorization: auth ? { active: auth.active, expiresAt: auth.expiresAt, maxMandateDays } : null, instrumentsMissing: [...new Set([...notPinned, ...notAllowed])] },226      funded: { ...fund, equity, minEquity: min?.minEquity ?? MIN_LIVE_EQUITY, basis: min?.basis ?? { instruments: [], ordersPerPosition: MIN_ORDERS_PER_POSITION, grossX: Math.min(a.risk.maxGrossExposureX, a.risk.maxLeverage * 0.9), floor: MIN_LIVE_EQUITY } },227      mandate: { ok: mandateOk, phase: m?.phase ?? null, mandateId: m?.id ?? null, lock },228    },229    next: next as 'strategy' | 'account' | 'fund' | 'mandate' | 'live' | 'locked', proposal, ts: now,230  };231}232233export const LivePrepareBody = z.strictObject({ accountId: z.uuid(), days: z.number().int().min(1).max(VENUE_MANDATE_BOUNDS.maxDays).optional(), locale: z.enum(['fr', 'en']).default('en') });234export const LiveApproveBody = z.strictObject({ accountId: z.uuid(), assertion: z.record(z.string(), z.unknown()), acknowledged: z.literal(true) });235236/** Step 1 of going live: the mandate derived from the agent's limits, prepared on the live module (which builds the WebAuthn request). */237export async function prepareLive(a: PerpAgentRow, rt: PerpLiveRuntime, body: z.infer<typeof LivePrepareBody>) {238  if (isLiveActive(a)) throw new UserError('bad_state', 'this agent is already on a venue account: stop it first', 409);239  const r = await liveReadiness(a, rt, { account: body.accountId, locale: body.locale, days: body.days });240  if (r.accountId !== body.accountId) throw new UserError('unknown_account', 'unknown venue account', 404);241  const st = r.steps;242  if (r.next === 'locked') { const code = st.mandate.lock!.code as LiveReasonCode; throw new UserError(code, liveReason(code), code === 'mandate_unavailable' ? 503 : 403); }243  if (r.next !== 'mandate' || !r.proposal) {244    const why = !st.strategy.ok ? st.strategy.reason : !st.account.ok ? st.account.reason : !st.funded.ok ? st.funded.reason : 'the agent is already live';245    throw new UserError('not_ready', `not ready to go live (${r.next}): ${why}`, 409);246  }247  const prep = await rt.live().prepareMandate(body.accountId, { ...r.proposal.body, locale: body.locale });248  await sql`update agents set perp = perp || ${sql.json({ livePending: { accountId: body.accountId, mandateId: prep.mandate.mandateId, capital: r.proposal.capital, strategySha256: r.proposal.body.strategySha256, preparedAt: rt.live().now() } } as never)}, updated_at = now() where id = ${a.id}`;249  await audit('agent.perp_live_prepared', 'owner', { id: a.id, account: body.accountId, mandateId: prep.mandate.mandateId, capital: r.proposal.capital, limits: r.proposal.body });250  return { ...prep, proposal: r.proposal };251}252253/** Step 2: the ONE passkey approval. The signer judges it; on success the agent is LIVE on that venue account. */254export async function approveLive(a: PerpAgentRow, rt: PerpLiveRuntime, body: z.infer<typeof LiveApproveBody>): Promise<PerpAgentRow> {255  const svc = rt.live(), pend = (a.perp as { livePending?: { accountId: string; mandateId: string; capital: DecStr; strategySha256: string } } | null)?.livePending;256  if (isLiveActive(a)) throw new UserError('bad_state', 'this agent is already on a venue account', 409);257  const sv = await approvedVersion(a);258  const [prepared] = await sql<{ id: string; mandate: { agentId: string; strategySha256: string } }[]>`select id, mandate from deriv_mandates where account_id = ${body.accountId} and state = 'prepared' order by prepared_at desc limit 1`;259  if (!pend || pend.accountId !== body.accountId || !prepared || prepared.id !== pend.mandateId || prepared.mandate.agentId !== a.id || !sv || sv.code_sha256 !== prepared.mandate.strategySha256) {260    throw new UserError('not_ready', 'prepare the mandate of this agent on this venue account first (the preparation is used once, and only for the strategy version that is approved now)', 409);261  }262  // one agent per venue account: the account is taken before the passkey is judged, and released if the approval fails263  try { await sql`update agents set live_account_id = ${body.accountId}, updated_at = now() where id = ${a.id}`; }264  catch (e) { if ((e as { code?: string }).code === '23505') throw new UserError('used_by_other_agent', 'this venue account is used by another agent: one agent per venue account', 409); throw e; }265  let network: 'testnet' | 'mainnet';266  try { network = (await svc.approveMandate(body.accountId, { assertion: body.assertion, acknowledged: true })).network; }267  catch (e) { await sql`update agents set live_account_id = null, perp = perp - 'livePending', updated_at = now() where id = ${a.id}`; throw e; }268  const live: PerpLiveBook = { accountId: body.accountId, network, mandateId: pend.mandateId, strategySha256: pend.strategySha256, since: svc.now(), capital: pend.capital, exit: null };269  // the daily-loss reference, a paper hold and a paper flatten do not carry over to the venue account270  const [row] = await sql<PerpAgentRow[]>`update agents set state = 'LIVE', mode = 'LIVE', stop_reason = null,271    perp = (perp - 'livePending' - 'day' - 'hold' - 'flatten') || ${sql.json({ live } as never)}, updated_at = now() where id = ${a.id} returning *`;272  await log(a, 'live', `LIVE on the ${network} venue account`, `mandate ${pend.mandateId}, strategy ${pend.strategySha256.slice(0, 12)}…, reference capital ${pend.capital} USDC. Orders inside the mandate are placed without a passkey.`);273  await audit('agent.perp_live', 'owner', { id: a.id, account: body.accountId, network, mandateId: pend.mandateId, strategySha256: pend.strategySha256, capital: pend.capital });274  return row!;275}276277// ------------------------------------------------------------------ the engine pass of a live agent278279function liveDeps(rt: PerpLiveRuntime, network: string, ex: LivePerpExecutor): PerpEngineDeps {280  const venue = rt.live().deps.venues.get(network);281  return {282    ...rt.deps, executor: () => ex,283    // the market the orders are sent to: the account's own network (the signal still comes from the gate's closed bars)284    market: async (symbol) => { const inst = await venue.instrument(instrumentId(symbol)); const { ticker, book } = await venue.market(inst); return { inst, ticker, book }; },285  };286}287const executorOf = (a: PerpAgentRow, rt: PerpLiveRuntime, lb: PerpLiveBook): LivePerpExecutor =>288  new LivePerpExecutor(rt.live(), { accountId: lb.accountId, agentId: a.id, strategySha256: lb.strategySha256, symbols: a.universe, risk: a.risk, capital: lb.capital });289290/** a condition that takes the agent out of LIVE, seen from the server's own state (before any order is planned) */291async function exitCondition(a: PerpAgentRow, lb: PerpLiveBook, s: MandateAccountState): Promise<{ code: string; detail: string } | null> {292  if (s.blocked) return { code: s.blocked.code === 'mode_read_only' || s.blocked.code === 'authorization_expired' ? 'authorization_ended' : s.blocked.code, detail: s.blocked.message };293  const m = s.mandate;294  if (!m || m.id !== lb.mandateId) return { code: 'mandate_ended', detail: '' };295  if (m.phase !== 'active') return { code: m.revokedAt !== null ? 'mandate_revoked' : m.expired ? 'mandate_expired' : 'authorization_ended', detail: '' };296  const sv = await approvedVersion(a);297  if (!sv || sv.code_sha256 !== lb.strategySha256) return { code: 'strategy_changed', detail: '' };298  if (s.halted) return { code: s.divergence ? 'divergence' : 'halted', detail: s.halted };299  const [liq] = await sql<{ instrument: string }[]>`select instrument from deriv_fills where account_id = ${lb.accountId} and liquidation and ts >= ${new Date(lb.since)} limit 1`;300  if (liq) return { code: 'liquidation', detail: liq.instrument };301  // an order of the owner on this account that reached the signer: the agent no longer knows whose positions these are302  const [manual] = await sql<{ client_id: string; instrument: string }[]>`select client_id, instrument from deriv_orders where account_id = ${lb.accountId} and state is not null and origin <> 'mandate'303    and signature is not null and created_at >= ${new Date(lb.since)} order by created_at limit 1`;304  if (manual) return { code: 'manual_order', detail: `${manual.instrument}, order ${manual.client_id}` };305  return null;306}307308const lastHold = new Map<string, string>();309310/** One pass for one agent that is on a venue account. Safe to call as often as wanted. */311export async function tickLivePerpAgent(a: PerpAgentRow, rt: PerpLiveRuntime): Promise<string> {312  const lb = a.perp?.live;313  if (a.kind !== 'perp' || a.mode !== 'LIVE' || !lb) return 'idle';314  if (a.state === 'STOPPING') return windDownStep(a, rt);315  if (a.state !== 'LIVE' && a.state !== 'PAUSED') return 'idle';316  const s = await mandateAccountState(lb.accountId, rt.live());317  const over = await exitCondition(a, lb, s);318  if (over) return beginExit(a, rt, over.code, exitText(over.code, over.detail), 'engine');319  if (s.error !== null) return `hold: the venue account could not be read (${s.error.slice(0, 120)})`;320  const ex = executorOf(a, rt, lb);321  const out = await tickPerpAgent(a, liveDeps(rt, s.network, ex), { capital: lb.capital, riskOnly: a.state === 'PAUSED', onBreach: (reason) => beginExit(a, rt, 'loss_limit', reason, 'engine') });322  const now = await reload(a.id);323  if (!isLiveActive(now)) {324    // the engine stopped the agent itself (liquidation event, a version that is not a perp strategy): nothing may stay authorised325    if (now.state === 'STOPPED' && now.perp?.live && !now.perp.live.exit?.done) return beginExit({ ...now, state: 'LIVE' }, rt, 'engine_stop', now.stop_reason ?? exitText('engine_stop'), 'engine');326    return out;327  }328  if (now.state === 'STOPPING') return out;                                    // a loss limit took it out during the step329  if (ex.exit && LIVE_EXIT_CODES.has(ex.exit.code)) return beginExit(now, rt, ex.exit.code, exitText(ex.exit.code, ex.exit.reason), 'engine');330  // a signer or risk refusal holds the instrument until the next bar: said once, not on every pass331  const refused = ex.holds.filter((h) => h.kind === 'refused');332  if (refused.length) {333    const text = refused.map((h) => `${h.instrument}: ${h.code} — ${h.reason}`).join('\n');334    if (lastHold.get(a.id) !== text) { lastHold.set(a.id, text); await log(a, 'hold', 'Order refused: holding until the next bar', text, refused); }335  }336  return out;337}338339/**340 * The agent leaves LIVE. First the mandate is revoked on the signer (no passkey): from that instant nothing that can add341 * exposure is signed. Then the positions are closed with reduce-only orders, which the signer still signs during the wind-down.342 */343export async function beginExit(stale: PerpAgentRow, rt: PerpLiveRuntime, code: string, reason: string, actor: 'owner' | 'engine'): Promise<string> {344  // the row as it is NOW: the agent may have been stopped (by the owner, by another pass) since the caller read it — the first reason stands345  const a = await reload(stale.id);346  if (a.mode === 'LIVE' && a.state === 'STOPPING' && a.perp?.live?.exit) return windDownStep(a, rt);347  if (a.state === 'STOPPED' && a.perp?.live?.exit?.done) return 'stopped';348  const lb = a.perp?.live;349  if (!lb) return 'idle';350  const svc = rt.live();351  let note = '';352  try {353    const s = await mandateAccountState(lb.accountId, svc);354    if (s.mandate?.id === lb.mandateId && s.mandate.phase === 'active') await svc.revokeMandate(lb.accountId, { cancelOpenOrders: true });355  } catch (e) { note = ` The mandate could not be revoked at once (${msg(e)}); it is tried again on every pass.`; }356  const exit = { ts: svc.now(), code, reason };357  await sql`update agents set state = 'STOPPING', stop_reason = ${reason}, perp = perp || ${sql.json({ live: { ...lb, exit } } as never)}, updated_at = now() where id = ${a.id}`;358  await log(a, 'risk', 'Leaving LIVE: no new exposure; closing every position with reduce-only orders', `${reason}.${note}`);359  await audit('agent.perp_live_exit', actor, { id: a.id, account: lb.accountId, mandateId: lb.mandateId, code, reason });360  // the agent is out of LIVE whatever happens next: a closing attempt that cannot be made now is made on the next pass361  return windDownStep(await reload(a.id), rt).catch((e: unknown) => `stopping: the closing orders could not be attempted now (${msg(e)}); tried again on the next pass`);362}363364async function finish(a: PerpAgentRow, lb: PerpLiveBook, reason: string, at: number): Promise<string> {365  await sql`update agents set state = 'STOPPED', stop_reason = ${reason}, live_account_id = null, perp = perp || ${sql.json({ live: { ...lb, exit: { ...lb.exit, done: at } } } as never)}, updated_at = now() where id = ${a.id}`;366  await log(a, 'stop', 'Agent stopped', reason);367  await audit('agent.stopped', 'engine', { id: a.id, kind: 'perp', mode: 'LIVE', reason });368  return 'stopped';369}370371/** One pass of the wind-down: revoke if still needed, close what is open with reduce-only orders, stop when flat or when nothing can be signed. */372export async function windDownStep(a: PerpAgentRow, rt: PerpLiveRuntime): Promise<string> {373  const lb0 = a.perp?.live, svc = rt.live(), now = svc.now();374  if (!lb0) return 'idle';375  // a STOPPING agent always has a reason; if it were ever missing, the wind-down still runs376  const lb: PerpLiveBook & { exit: NonNullable<PerpLiveBook['exit']> } = { ...lb0, exit: lb0.exit ?? { ts: now, code: 'engine_stop', reason: a.stop_reason ?? exitText('engine_stop') } };377  const why = lb.exit.reason;378  let s = await mandateAccountState(lb.accountId, svc);379  if (s.mandate?.id === lb.mandateId && s.mandate.phase === 'active') {380    try { await svc.revokeMandate(lb.accountId, { cancelOpenOrders: true }); s = await mandateAccountState(lb.accountId, svc); }381    catch (e) { return `stopping: the mandate could not be revoked yet (${msg(e)})`; }382  }383  if (s.error !== null) return `stopping: the venue account could not be read (${s.error.slice(0, 120)})`;384  const allowed = new Set(s.mandate?.id === lb.mandateId ? s.mandate.limits.instruments.map((i) => i.instrument) : []);385  const mine = (p: Position): boolean => allowed.has(p.instrument);386  const names = (ps: Position[]): string => ps.map((p) => `${p.side} ${p.size} ${symbolOf(p.instrument)}`).join(', ');387  const closedNote = (lb.exit.closed ?? 0) > 0 ? 'every position was closed with reduce-only orders' : 'no position was open';388  if (s.positions.length === 0) return finish(a, lb, `${why}; ${closedNote}`, now);389  const manual = 'close them in the Futures terminal with your passkey (while the venue authorization lives), or on the venue\'s own interface';390  if (!s.positions.some(mine)) return finish(a, lb, `${why}; ${s.positions.length} position(s) remain open on the venue account (${names(s.positions)}) on instruments outside the mandate: ${manual}`, now);391  if (!s.canReduce) {392    const cause = s.blocked ? s.blocked.message : s.mandate?.phase === 'ended' ? 'the wind-down of the mandate is over, or the venue authorization is gone' : 'the mandate is no longer in force';393    return finish(a, lb, `${why}; ${s.positions.length} position(s) REMAIN OPEN on the venue account (${names(s.positions)}) and the signer no longer signs for this mandate (${cause}): ${manual}`, now);394  }395  if ((lb.exit.retryAt ?? 0) > now) return `stopping: ${s.positions.length} position(s) open; next closing attempt at ${new Date(lb.exit.retryAt!).toISOString()}`;396  const ex = executorOf(a, rt, lb);397  await ex.account();398  const f = await flattenPerp(a, liveDeps(rt, s.network, ex), ex, String(Math.floor(now / 60_000)), mine);399  const sent = f.results.filter((r) => r.status !== 'refused' && r.status !== 'deferred').length, refused = f.results.filter((r) => r.status === 'refused');400  const exit = { ...lb.exit, closed: (lb.exit.closed ?? 0) + sent, ...(refused.length ? { retryAt: now + CLOSE_RETRY_MS } : {}) };401  if (!refused.length) delete (exit as { retryAt?: number }).retryAt;402  await setLive(a.id, { ...lb, exit });403  if (f.results.length) await log(a, 'risk', 'Reduce-only closing orders (wind-down)', '', f.results);404  const left = f.left;405  if (left.length === 0) return finish(a, { ...lb, exit }, `${why}; every position was closed with reduce-only orders`, now);406  // the signer or the server said that nothing will be signed any more: say what remains instead of trying for 24 hours407  if (ex.exit && refused.length === f.results.length) return finish(a, { ...lb, exit }, `${why}; ${left.length} position(s) REMAIN OPEN on the venue account (${names(left)}) — the closing orders were refused (${ex.exit.code}: ${ex.exit.reason}): ${manual}`, now);408  return `stopping: ${left.length} position(s) still open (${names(left)})`;409}410411// ------------------------------------------------------------------ owner actions (no passkey: stopping never needs one)412413export async function stopLive(a: PerpAgentRow, rt: PerpLiveRuntime, locale: LiveLocale) {414  if (!isLiveActive(a) || !a.perp?.live) throw new UserError('bad_state', 'this agent is not on a venue account', 409);415  const lb = a.perp.live;416  const result = a.state === 'STOPPING' ? await windDownStep({ ...a, perp: { ...a.perp, live: { ...lb, exit: lb.exit ? { ...lb.exit, retryAt: 0 } : lb.exit } } }, rt) : await beginExit(a, rt, 'owner_stop', exitText('owner_stop'), 'owner');417  const s = await mandateAccountState(lb.accountId, rt.live()).catch(() => null);418  return { result, windDown: { until: s?.mandate?.windDownUntil ?? null, text: liveTexts(locale).windDown, openPositions: s?.positions.length ?? null, openOrders: s?.openOrders.length ?? null } };419}420421export async function pauseLive(a: PerpAgentRow): Promise<PerpAgentRow> {422  if (a.mode !== 'LIVE' || a.state !== 'LIVE') throw new UserError('bad_state', 'only an agent that is LIVE can be paused', 409);423  const [r] = await sql<PerpAgentRow[]>`update agents set state = 'PAUSED', updated_at = now() where id = ${a.id} returning *`;424  await log(a, 'live', 'Paused by the owner', 'No new exposure. Positions are KEPT on the venue account and nothing is closed; the loss limits and the stop conditions are still watched. The mandate stays active: stop the agent to revoke it.');425  await audit('agent.perp_live_pause', 'owner', { id: a.id });426  return r!;427}428429export async function resumeLive(a: PerpAgentRow, rt: PerpLiveRuntime): Promise<PerpAgentRow> {430  const lb = a.perp?.live;431  if (a.mode !== 'LIVE' || a.state !== 'PAUSED' || !lb) throw new UserError('bad_state', 'only a paused live agent can be resumed', 409);432  const s = await mandateAccountState(lb.accountId, rt.live());433  if (s.mandate?.id !== lb.mandateId || s.mandate.phase !== 'active' || s.blocked) throw new UserError('bad_state', 'the mandate of this agent is no longer active: stop the agent and approve a new mandate', 409);434  const [r] = await sql<PerpAgentRow[]>`update agents set state = 'LIVE', updated_at = now() where id = ${a.id} returning *`;435  await log(a, 'live', 'Resumed by the owner');436  await audit('agent.perp_live_resume', 'owner', { id: a.id });437  return r!;438}439440// ------------------------------------------------------------------ what the agent page shows441442/** the `live` block of the agent view; with `full`, the venue account as the live module reports it and the signer's counters */443export async function liveBlock(a: PerpAgentRow, rt: PerpLiveRuntime, full: boolean) {444  const svc = rt.live(), cfg = svc.config('en'), lb = a.perp?.live ?? null, active = isLiveActive(a);445  const base = { available: cfg.mandateLive.available, reason: cfg.mandateLive.reason, active, accountId: lb?.accountId ?? null, network: lb?.network ?? null, mandateId: lb?.mandateId ?? null,446    strategySha256: lb?.strategySha256 ?? null, since: lb?.since ?? null, capital: lb?.capital ?? null, paused: active && a.state === 'PAUSED', exit: lb?.exit ? { ts: lb.exit.ts, code: lb.exit.code, reason: lb.exit.reason } : null };447  if (!full || !lb || a.mode !== 'LIVE') return { ...base, account: null, signer: null, holds: [] as { ts: number; bar: string; symbol: string; code: string; reason: string }[] };448  const [account, status] = await Promise.all([svc.get(lb.accountId).catch(() => null), svc.mandateStatus(lb.accountId).catch(() => null)]);449  const rows = await sql<{ bar: Date; updated_at: Date; orders: { list?: { symbol?: string; status?: string; code?: string; why?: string }[] } | null }[]>`select bar, updated_at, orders from perp_agent_signals450    where agent_id = ${a.id} and bar >= ${new Date(lb.since - 86_400_000)} order by bar desc limit 30`;451  const holds = rows.flatMap((r) => (r.orders?.list ?? []).filter((o) => o.status === 'refused' || o.status === 'deferred')452    .map((o) => ({ ts: r.updated_at.getTime(), bar: r.bar.toISOString(), symbol: String(o.symbol ?? ''), code: String(o.code ?? o.status), reason: String(o.why ?? '') }))).slice(0, 20);453  return { ...base, account, signer: status?.signer ?? null, holds };454}455
Files main
Menu

Theme