Repository Public
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