Repository Public
1/**2 * In-browser stand-in for the perp-agent routes (`/api/v1/agents*`) — DEVELOPMENT BUILDS ONLY (imported by `mock.ts` alone,3 * behind `import.meta.env.DEV`). ONE scripted perp agent on Hyperliquid BTC, 1 h, with an approved strategy version, whose4 * real mode runs on the venue accounts of `mockLive.ts`: readiness checklist, mandate preparation from the agent's risk5 * settings (the canonical mandate, its hash and its validity rules are the repository's own), one passkey, LIVE, pause, stop →6 * wind-down → reduce-only closes → STOPPED.7 *8 * What is simulated: the engine (a few scripted bars: one fill, one refusal by the simulated signer, one deferred order),9 * the venue (a paper account filled against the mock's book — play money whatever the network is called) and the signer.10 * Nothing here proves that the backend behaves the same way; shapes follow services/api/src/agents/perp-routes.ts.11 *12 * Test hooks: `window.__spwMock.perp` — see `PerpHooks`.13 */14import { D } from '../../../../packages/derivatives/src/decimal.ts';15import type { VenuePolicy } from '../../../../packages/derivatives/src/live/canonical.ts';16import type { createLiveMock } from './mockLive';1718type Side = ReturnType<typeof createLiveMock>['agentSide'];19type Out = { status: number; json: any };20type Loc = 'fr' | 'en';21export interface PerpHooks {22 /** the scripted agent's id (the page is /agents/<id>) */ id: string;23 /**24 * `paper`: approved strategy, no venue account · `no_strategy`: nothing approved · `ready`: testnet account funded and25 * authorized · `small`: authorized but below the minimum equity · `live`: LIVE on testnet with a position, a refusal and a26 * deferred order · `mainnet_locked`: an authorized mainnet account, autonomous trading locked by the server setting27 */28 scenario: (name: 'paper' | 'no_strategy' | 'ready' | 'small' | 'live' | 'mainnet_locked') => void;29 /** keep the agent in STOPPING (the engine does not close yet) until released */ holdStop: (on: boolean) => void;30 /** the agent leaves LIVE for this reason (an exit code of the backend) */ exit: (code: string, reason: string) => void;31 state: () => { state: string; mode: string; accountId: string | null };32 /** the agent leaves LIVE without being able to close (the signer signs nothing any more): STOPPED, positions left open on the venue */ stopOpen: (code: string) => void;33}3435const ID = '7e57a9e1-0000-4000-8000-000000000001', SV = '7e57a9e1-0000-4000-8000-0000000000a1', SHA = 'c0de5eed'.repeat(8), BTC = 'hyperliquid:BTC';36const RISK = { maxLeverage: 2, maxGrossExposureX: 1.5, maxPositionX: 1, maxDailyLossPct: 3, maxTotalLossPct: 10, slippageBps: 30, minTradePct: 2 };37const POLICY: VenuePolicy = { allowedInstruments: [BTC], maxLeverage: 3, maxOrderNotional: '1000', maxPositionNotional: '1000', maxGrossExposure: '1500', maxOrdersPerDay: 100, maxPriceDeviationBps: 1000 };38const ORDERS_PER_POSITION = 5, MIN_ORDER = '10.5', H = 3_600_000;39const d = (x: string) => D.from(x), cents = (x: ReturnType<typeof d>) => D.str(D.quantize(x, d('0.01'), 'down'));40class Refuse extends Error { status: number; code: string; constructor(code: string, message: string, status: number) { super(message); this.code = code; this.status = status; } }4142export function createPerpMock(live: Side, env: { mark: (instrument: string) => string | null; lot: (instrument: string) => string; /** collateral the next venue account of a network starts with */ fund: (network: 'testnet' | 'mainnet', usdc: string) => void }) {43 const now = () => Date.now(), created = now() - 6 * 86_400_000;44 const A = {45 name: 'BTC momentum', mission: 'Suit la tendance du BTC sur barres d’une heure.', state: 'PAPER_TRADING', mode: 'PAPER' as 'PAPER' | 'LIVE', approved: true, stopReason: null as string | null, lastBar: null as number | null,46 live: null as null | { accountId: string; network: 'testnet' | 'mainnet'; mandateId: string; since: number; capital: string; exit: { ts: number; code: string; reason: string } | null },47 signals: [] as any[], log: [] as { ts: number; kind: string; title: string; body: string }[],48 };49 let HOLD_STOP = false, SEQ = 0;50 const log = (kind: string, title: string, body = '') => { A.log.unshift({ ts: now(), kind, title, body }); };51 const acct = () => (A.live ? live.get(A.live.accountId) ?? null : null);52 const active = () => A.mode === 'LIVE' && ['LIVE', 'PAUSED', 'STOPPING'].includes(A.state);53 const bar = (k = 0) => Math.floor(now() / H) * H - H - k * H;5455 // ------------------------------------------------------------------ views (perp-routes.ts `perpView` / `perpDetail`)56 const view = () => ({ id: ID, kind: 'perp' as const, simulation: A.mode !== 'LIVE', name: A.name, mission: A.mission, state: A.state, mode: A.mode, venue: 'hyperliquid', network: 'hyperliquid', networkName: 'Hyperliquid', universe: ['BTC'],57 liveNetwork: A.mode === 'LIVE' ? A.live?.network ?? null : null, instruments: [BTC], cash: 'USDC', interval: '1h', risk: RISK, capitalUsdc: '1000', accountId: 'paper-of-agent', strategyVersionId: A.approved ? SV : null, mandateId: null, mandate: null, vaultAddress: '', holdings: null,58 flag: null, hold: null, flattening: null, lastBar: A.lastBar === null ? null : new Date(A.lastBar).toISOString(), stopReason: A.stopReason, createdAt: new Date(created).toISOString(), updatedAt: new Date(now()).toISOString() });59 const versions = () => [{ id: SV, name: 'ts_momentum_perp', version: '1', code_sha256: SHA, status: A.approved ? 'approved' : 'backtested', notes: '', created_at: new Date(created).toISOString(), approved_at: A.approved ? new Date(created + H).toISOString() : null,60 meta: { kind: 'perp', universe: ['BTC'], interval: '1h', lookback: 240, max_leverage: 2, max_gross_exposure: 2 },61 summary: { schemaVersion: 2, generatedAt: new Date(created).toISOString(), config: { symbols: ['BTC'], interval: '1h', bars: 4990, fee_bps: 4.5, slippage_bps: 3, liquidation_penalty_bps: 50 }, historyDays: 208, splits: null,62 metrics: { train: null, validation: null, test: { total_return: 0.031, sharpe: 0.84, max_drawdown: -0.062, worst_intrabar_drawdown: -0.081, trades: 61, liquidations: 0, wiped_out: false } },63 benchmarks: { buy_and_hold: { total_return: -0.04 }, perp_long_1x: { total_return: -0.047, sharpe: -0.3, max_drawdown: -0.19 } }, buyAndHoldIsSpot: false,64 robustness: { trials_tested: 9, deflated_sharpe: 0.21, bootstrap_sharpe_ci: [-0.4, 1.9] }, checks: { lookahead: 'pass', data_quality: 'pass', ledger: 'pass', funding_alignment: 'pass' }, liquidations: { train: 0, validation: 0, test: 0 }, problem: null } }];65 const paperAccount = () => ({ id: 'paper-of-agent', label: `Agent · ${A.name}`, state: { venue: 'hyperliquid', account: 'paper', equity: '1000', collateral: '1000', freeCollateral: '1000', marginUsed: '0', maintenanceMargin: '0', withdrawable: null, ts: now(), source: 'paper' },66 policy: { mode: 'PAPER', allowedInstruments: [BTC], halt: null, maxDataAgeMs: 10_000, maxDeviationBps: '200', maxMarkIndexBps: '100', maxLeverage: '2', maxOrderNotional: '1000', maxGrossExposure: '1500', maxPositionNotional: '1000', minFreeCollateral: '50',67 minLiquidationDistanceBps: '1000', maxDailyLoss: '30', maxTotalLoss: '100', maxOrdersPerMinute: 40, maxOrdersPerDay: 400 },68 settings: {}, totals: { realizedPnl: '0', feesPaid: '0', fundingNet: '0', liquidations: 0 }, initialCollateral: '1000', positions: [], openOrders: [], orders: [], fills: [], funding: [], riskEvents: [], equity: [], updatedAt: now() });69 const holds = () => A.signals.flatMap((s) => ((s.orders?.list ?? []) as any[]).filter((o) => o.status === 'refused' || o.status === 'deferred').map((o) => ({ ts: s.ts as number, bar: s.bar as string, symbol: o.symbol as string, code: o.code as string, reason: String(o.why ?? '') })));70 function detail() {71 const a = acct(), L = A.live;72 return { ...view(), account: paperAccount(), versions: versions(), signals: A.signals, equity: [], log: A.log,73 execution: A.mode === 'LIVE' && L ? { kind: 'live', network: L.network, note: 'Orders are placed on the venue account under the derivative mandate the owner approved; the isolated signer enforces its limits on every order.' }74 : { kind: 'paper', note: 'Orders are simulated against the venue\'s live order book by the paper engine. Nothing is signed and no order reaches the venue.' },75 live: { available: true, reason: null, active: active(), accountId: L?.accountId ?? null, network: L?.network ?? null, mandateId: L?.mandateId ?? null, strategySha256: L ? SHA : null, since: L?.since ?? null, capital: L?.capital ?? null,76 paused: A.mode === 'LIVE' && A.state === 'PAUSED', exit: L?.exit ?? null, account: a ? live.detail(a) : null, signer: a ? live.mandateStatus(a).signer : null, holds: holds() },77 rules: { markIndexBps: 100, lastCloseBps: 1000, leverageHeadroom: '0.9', capHeadroom: '0.02', simplifications: [] } };78 }7980 // ------------------------------------------------------------------ readiness and the mandate derived from the agent's limits81 function readiness(accountId: string | null, locale: Loc, wantDays?: number) {82 const all = live.accounts(), cfg = live.config(locale);83 const pick = all.find((x) => x.id === accountId) ?? (A.live ? all.find((x) => x.id === A.live!.accountId) : undefined) ?? all.find((x) => x.network === 'testnet') ?? all[0];84 const accounts = all.map((x) => { const s = live.summary(x), v = x === pick ? live.venueState(x) : null; return { id: x.id, network: x.network, label: x.label, account: x.account, mode: x.mode, modes: s.modes, tradable: s.tradable,85 authorization: s.authorization ? { active: s.authorization.active, expiresAt: s.authorization.expiresAt } : null, mandate: s.mandate ? { id: s.mandate.id, phase: s.mandate.phase, agentId: s.mandate.agentId } : null,86 usedBy: s.mandate && s.mandate.phase !== 'ended' ? { agentId: s.mandate.agentId, name: s.mandate.agentId === ID ? A.name : s.mandate.agentId } : null, equity: v?.state?.equity ?? null, error: v?.error ?? null }; }); // read on the venue for the selected account only87 const a = pick;88 const sum = a ? live.summary(a) : null, vs = a ? live.venueState(a) : null, nowMs = now();89 const strategy = { ok: A.approved, versionId: A.approved ? SV : null, sha256: A.approved ? SHA : null, name: A.approved ? 'ts_momentum_perp' : null, version: A.approved ? '1' : null, reason: A.approved ? null : 'no strategy version is approved for this agent' };90 const auth = sum?.authorization ?? null, maxDays = auth ? Math.min(30, Math.floor((auth.expiresAt - nowMs - 86_400_000) / 86_400_000)) : 0;91 const missing = auth ? [BTC].filter((i) => !auth.policy.allowedInstruments.includes(i)) : [];92 let code: string | null = null, reason: string | null = null;93 if (!a || !sum) { code = 'no_account'; reason = 'no venue account exists yet: create and authorise one in Futures → Accounts'; }94 else if (a.network === 'mainnet' && !live.mainnet()) { code = 'mainnet_disabled'; reason = cfg.reasons.mainnet_disabled[locale]; }95 else if (a.mode !== 'MANUAL_LIVE' || !auth) { code = 'read_only'; reason = 'this venue account is read-only: authorise live trading on it with your passkey (Futures → Accounts)'; }96 else if (auth.expired) { code = 'authorization_expired'; reason = 'the venue authorization has expired: approve a new one'; }97 else if (maxDays < 1) { code = 'authorization_too_short'; reason = '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'; }98 else if (missing.length) { code = 'instruments_not_allowed'; reason = `the venue authorization does not allow ${missing.join(', ')}`; }99 else if (sum.mandate && sum.mandate.phase === 'active' && sum.mandate.agentId !== ID) { code = 'used_by_other_agent'; reason = 'another agent already trades this venue account'; }100 else if (sum.mandate && sum.mandate.phase === 'active' && !active()) { code = 'mandate_exists'; reason = 'a mandate is already active on this venue account: revoke it in Futures → Accounts'; }101 else if (!active() && vs && !vs.error && (vs.positions.length > 0 || vs.openOrders.length > 0)) { code = 'account_not_flat'; reason = 'the venue account already holds positions or resting orders: an agent starts on a flat account — close or cancel them in the Futures terminal'; }102 const account = { ok: code === null, code, reason, network: a?.network ?? null, authorization: auth ? { active: auth.active, expiresAt: auth.expiresAt, maxMandateDays: Math.max(0, maxDays) } : null, instrumentsMissing: missing };103 const minEquity = D.str(D.max(d('50'), D.quantize(D.div(D.mul(d(String(ORDERS_PER_POSITION)), d(MIN_ORDER)), d(String(RISK.maxGrossExposureX)), 2, 'up'), d('1'), 'up')));104 const equity = vs?.state?.equity ?? null, never = !!a && !a.venue;105 const capital = equity ? cents(d(equity)) : '0', floor = cents(D.mul(d(capital), d(String(1 - RISK.maxTotalLossPct / 100))));106 let fcode: string | null = null, freason: string | null = null;107 if (!a) { fcode = 'no_account'; freason = 'no venue account'; }108 else if (vs?.error) { fcode = 'venue_unreadable'; freason = vs.error; }109 else if (never || !equity || D.isZero(d(equity))) { fcode = 'unfunded'; freason = 'the venue account never received a deposit: the venue refuses every action of such an account ("Must deposit before performing actions")'; }110 else if (D.lt(d(equity), d(minEquity))) { fcode = 'below_minimum'; freason = `account value ${equity} USDC is below the ${minEquity} USDC this agent needs`; }111 const funded = { ok: fcode === null, code: fcode, equity, minEquity, basis: { instruments: [{ instrument: BTC, minOrder: MIN_ORDER }], ordersPerPosition: ORDERS_PER_POSITION, grossX: RISK.maxGrossExposureX, floor }, reason: freason };112 const lockErr = a ? live.mandateLock(a) : null;113 const lock = lockErr ? { code: lockErr.code, reason: (cfg.reasons as Record<string, Record<Loc, string>>)[lockErr.code]?.[locale] ?? lockErr.message } : null;114 const mine = sum?.mandate && sum.mandate.agentId === ID ? sum.mandate : null;115 const mandate = { ok: !!mine && mine.phase === 'active' && A.state === 'LIVE', phase: mine?.phase ?? null, mandateId: mine?.id ?? null, lock };116 const next = !strategy.ok ? 'strategy' : !account.ok ? 'account' : !funded.ok ? 'fund' : lock ? 'locked' : mandate.ok || active() ? 'live' : 'mandate';117 return { agentId: ID, locale, config: cfg, accounts, accountId: a?.id ?? null, steps: { strategy, account, funded, mandate }, next,118 proposal: strategy.ok && account.ok && funded.ok && auth ? proposal(auth.policy, capital, maxDays, wantDays && wantDays >= 1 ? Math.min(wantDays, maxDays) : undefined) : null, ts: nowMs };119 }120 function proposal(p: VenuePolicy, capital: string, maxDays: number, days = Math.min(30, maxDays)) {121 const c = d(capital), min = (x: string, y: string) => D.str(D.min(d(x), d(y))), notes: string[] = [];122 const wantPos = cents(D.mul(c, d(String(RISK.maxPositionX)))), wantGross = cents(D.mul(c, d(String(RISK.maxGrossExposureX))));123 const pos = min(wantPos, p.maxPositionNotional), gross = min(wantGross, p.maxGrossExposure), order = min(pos, p.maxOrderNotional), lev = Math.min(RISK.maxLeverage, p.maxLeverage);124 if (pos !== wantPos || gross !== wantGross || order !== pos || lev !== RISK.maxLeverage) notes.push('some limits are lower than the agent\'s own because the venue authorization is tighter: a mandate can never be wider than its authorization');125 const perDay = Math.min(p.maxOrdersPerDay, Math.max(10, 2 * 24)), perHour = Math.min(perDay, 60, Math.max(4, 2)), turnover = cents(D.mul(d(gross), d('4')));126 const floorEquity = cents(D.mul(c, d(String(1 - RISK.maxTotalLossPct / 100))));127 const body = { instruments: [{ instrument: BTC, maxLeverage: lev, maxPositionNotional: pos }], maxOrderNotional: order, maxGrossExposure: gross, maxDailyTurnover: turnover, floorEquity, maxOrdersPerDay: perDay, maxOrdersPerHour: perHour,128 maxPriceDeviationBps: p.maxPriceDeviationBps, days, strategySha256: SHA, agentId: ID };129 const derived = [130 { field: 'instruments', value: 'BTC', from: 'the agent\'s instruments' },131 { field: 'maxLeverage', value: `${lev}`, from: `the agent's maximum leverage (${RISK.maxLeverage}x), never above the authorization's` },132 { field: 'maxPositionNotional', value: pos, from: `${RISK.maxPositionX} x the allocated capital (${capital} USDC)` },133 { field: 'maxOrderNotional', value: order, from: 'the position limit: one order can close a whole position' },134 { field: 'maxGrossExposure', value: gross, from: `${RISK.maxGrossExposureX} x the allocated capital` },135 { field: 'maxDailyTurnover', value: turnover, from: '4 x the gross exposure limit per UTC day' },136 { field: 'floorEquity', value: floorEquity, from: `the allocated capital less the total-loss limit (${RISK.maxTotalLossPct} %)` },137 { field: 'maxOrdersPerDay', value: `${perDay}`, from: 'two orders per instrument and closed bar (1 h bars)' },138 { field: 'maxOrdersPerHour', value: `${perHour}`, from: 'two orders per instrument and bar, at least 4' },139 { field: 'maxPriceDeviationBps', value: `${p.maxPriceDeviationBps}`, from: 'the price band of the venue authorization' },140 { field: 'days', value: `${days}`, from: `at most 30 days, and ending at least 24 h before the authorization expires (at most ${maxDays} days here)` },141 ];142 return { capital, days, maxDays, body, derived, notes };143 }144 function need(accountId: unknown, locale: Loc) {145 const r = readiness(typeof accountId === 'string' ? accountId : null, locale), a = r.accountId ? live.get(r.accountId) : undefined;146 for (const k of ['strategy', 'account', 'funded'] as const) if (!r.steps[k].ok) throw new Refuse('not_ready', `not ready to go live — ${k}: ${r.steps[k].reason}`, 409);147 if (!a || a.id !== accountId) throw new Refuse('not_ready', 'not ready to go live — account: choose a venue account', 409);148 return { r, a };149 }150151 // ------------------------------------------------------------------ the scripted engine152 const size = (usd: string) => { const mark = env.mark(BTC) ?? '70000'; return D.str(D.quantize(D.div(d(usd), d(mark), 12, 'down'), d(env.lot(BTC)), 'down')); };153 function trade(side: 'buy' | 'sell', sz: string, reduceOnly: boolean, k: number, reasonText: string) {154 const a = acct()!, clientId = `ag-${ID}-${SHA.slice(0, 8)}-${Math.floor(bar(k) / 1000)}-BTC-${reduceOnly ? 'r' : 'o'}${SEQ++ ? `-${SEQ}` : ''}`;155 const base = { symbol: 'BTC', side, size: sz, reduceOnly, reason: reasonText, clientId };156 try { const o = live.placeMandate(a, { instrument: BTC, side, size: sz, reduceOnly, clientId, slippageBps: RISK.slippageBps });157 return { ...base, status: o.state === 'filled' ? 'filled' : o.state, filled: o.filled, avgFillPrice: o.avgFillPrice, worstPrice: o.price, why: o.reason, idempotent: false }; }158 catch (e) { const x = e as Refuse; return { ...base, status: 'refused', code: x.code ?? 'error', why: x.message }; }159 }160 const signal = (k: number, weight: number, list: any[], targets: string) => { A.signals.unshift({ ts: bar(k) + H + 20_000, bar: new Date(bar(k)).toISOString(), status: 'done', reason: '', strategy_sha256: SHA, weights: { BTC: weight }, scaled: { BTC: String(weight) }, scale: '1',161 equity: A.live?.capital ?? '1000', targets: { BTC: targets }, orders: { binding: 'none', threshold: '20', slippageBps: RISK.slippageBps, list }, updated_at: new Date(now()).toISOString() }); A.lastBar = bar(k); };162 /** what the engine did since the mandate: a long, then a bar the signer held, then an order waiting for the signer's 40 s over-count */163 function script() {164 const a = acct()!, cap = A.live!.capital, first = size(D.str(D.mul(d(cap), d('0.5'))));165 signal(2, 0.5, [trade('buy', first, false, 2, 'open long')], first);166 const more = size(D.str(D.mul(d(cap), d('0.6'))));167 signal(1, 1.1, [trade('buy', more, false, 1, 'increase toward target')], D.str(D.add(d(first), d(more))));168 const small = size(D.str(D.mul(d(cap), d('0.2'))));169 signal(0, 0.7, [{ symbol: 'BTC', side: 'buy', size: small, reduceOnly: false, reason: 'increase toward target', status: 'deferred', code: 'live_cooldown',170 why: 'an order of this account reached the signer less than 45 s ago: the signer still counts it twice, so the next order that adds exposure waits for the next pass' }], D.str(D.add(d(first), d(small))));171 A.signals[0].status = 'decided';172 if (a.venue) { a.venue.applyFunding('0.0000125', now() - 1000, BTC); const t0 = Math.floor(now() / 60_000) * 60_000, st = a.venue.accountState(now());173 a.equity = [...Array.from({ length: 5 }, (_, i) => ({ ts: t0 - (5 - i) * 60_000, equity: cap, collateral: cap, marginUsed: '0', unrealizedPnl: '0', source: 'venue' as const })),174 { ts: t0, equity: st.equity, collateral: st.collateral, marginUsed: st.marginUsed, unrealizedPnl: D.str(D.sub(d(st.equity), d(st.collateral))), source: 'venue' as const }]; }175 }176 function leave(code: string, reason: string, locale: Loc = 'en') {177 const a = acct();178 let windDown: unknown = null;179 if (a?.mandate) { const r = live.revokeMandate(a, { cancelOpenOrders: true, locale }); windDown = r.windDown; }180 if (A.live) A.live.exit = { ts: now(), code, reason };181 A.state = 'STOPPING'; log('risk', 'Leaving LIVE', reason);182 return windDown;183 }184 /** one engine pass of a stopping agent: reduce-only closes, then STOPPED */185 function step() {186 if (A.state !== 'STOPPING' || HOLD_STOP) return;187 const a = acct(), L = A.live; if (!a || !L) { A.state = 'STOPPED'; return; }188 const results = live.venueState(a).positions.map((p) => trade(p.side === 'long' ? 'sell' : 'buy', p.size, true, 0, 'leaving LIVE: close'));189 if (results.length) log('risk', 'Reduce-only closing orders', results.map((r) => `${r.symbol} ${r.side} ${r.size} → ${r.status}`).join('\n'));190 const left = live.venueState(a).positions.length;191 if (left) return;192 A.state = 'STOPPED'; A.stopReason = `${L.exit?.reason ?? 'stopped'}; ${results.length ? 'every position was closed with reduce-only orders' : 'no position was open'}`;193 log('stop', 'Agent stopped', A.stopReason);194 }195196 function reset() { for (const a of live.accounts()) if (a.mandate && a.mandate.revokedAt === null) a.mandate.revokedAt = now() - 2 * 86_400_000; Object.assign(A, { state: 'PAPER_TRADING', mode: 'PAPER', approved: true, stopReason: null, lastBar: null, live: null, signals: [], log: [] }); HOLD_STOP = false; }197 function goLive(a: NonNullable<ReturnType<Side['get']>>, capital: string) {198 A.live = { accountId: a.id, network: a.network, mandateId: a.mandate!.mandate.mandateId, since: now(), capital, exit: null };199 A.state = 'LIVE'; A.mode = 'LIVE'; A.stopReason = null; A.signals = [];200 // the engine sets the venue's leverage to the agent's before its first order (one leverage change, counted by the signer)201 a.leverage[BTC] = { leverage: String(RISK.maxLeverage), marginMode: 'cross' }; if (a.venue) a.venue.state.config.leverageByInstrument[BTC] = String(RISK.maxLeverage); if (a.mstats) a.mstats.leverage = 1;202 log('live', 'Agent is LIVE', `mandate ${a.mandate!.mandate.mandateId} on the ${a.network} venue account; strategy ${SHA.slice(0, 12)}…`);203 script();204 }205 const hooks: PerpHooks = {206 id: ID,207 scenario(name) {208 reset();209 if (name === 'no_strategy') { A.approved = false; A.state = 'CREATED'; return; }210 if (name === 'paper') return;211 if (name === 'small') { env.fund('testnet', '20'); live.force('testnet', POLICY); env.fund('testnet', '1000'); return; } // on a fresh page: the account is created with 20 USDC212 if (name === 'mainnet_locked') { live.force('mainnet', { ...POLICY, maxPriceDeviationBps: 200 }); return; }213 const a = live.force('testnet', POLICY);214 if (name === 'live') {215 const r = readiness(a.id, 'fr'), p = live.prepareMandate(a, { ...r.proposal!.body, locale: 'fr' });216 a.pendingMandate = null; a.mandate = { mandate: p.mandate, hash: p.hash, approvedAt: now(), revokedAt: null }; a.mstats = { orders: 0, reduceOnly: 0, cancels: 0, leverage: 1, turnover: '0', stamps: [] };217 goLive(a, r.proposal!.capital);218 }219 },220 holdStop: (on) => { HOLD_STOP = on; },221 exit: (code, reason) => { if (active()) leave(code, reason); },222 state: () => ({ state: A.state, mode: A.mode, accountId: A.live?.accountId ?? null }),223 stopOpen: (code) => { if (!active()) return; const a = acct()!; HOLD_STOP = true; leave(code, 'the signer no longer signs for this mandate');224 const left = live.venueState(a).positions.map((p) => `${p.side} ${p.size} ${p.instrument.slice(p.instrument.indexOf(':') + 1)}`);225 A.state = 'STOPPED'; A.stopReason = `the signer no longer signs for this mandate; ${left.length} position(s) REMAIN OPEN on the venue account (${left.join(', ')}) and the signer no longer signs for this mandate: close them in the Futures terminal with your passkey`;226 log('stop', 'Agent stopped', A.stopReason); },227 };228229 // ------------------------------------------------------------------ HTTP230 function handle(method: string, path: string, b: any, q: URLSearchParams): Out {231 const ok = (json: unknown): Out => ({ status: 200, json }), locale: Loc = (q.get('locale') ?? b?.locale) === 'fr' ? 'fr' : 'en';232 if (path === '/strategies') return ok({ strategies: [] });233 if (path === '/agents' && method === 'GET') return ok({ agents: [view()] });234 if (path === '/agents/networks') return ok({ networks: [], maxSymbols: 12, maxLiveSymbols: 11 });235 if (path === '/agents/perp/venues') return ok({ venues: [{ id: 'hyperliquid', name: 'Hyperliquid', network: 'mainnet', paper: true, instruments: [{ symbol: 'BTC', id: BTC, maxLeverage: '40', lotSize: env.lot(BTC), minNotional: '10', mark: env.mark(BTC), funding: null, volume24h: null, eligible: true, why: null }] }],236 maxInstruments: 8, intervals: ['1h', '4h'], riskDefaults: RISK, minCapital: '50', live: { available: true, reason: null }, ts: now() });237 const m = /^\/agents\/([^/]+)(\/.*)?$/.exec(path);238 if (!m || m[1] !== ID) throw new Refuse('not_found', 'agent not found (mock mode simulates one perp agent)', 404);239 const rest = m[2] ?? '';240 if (rest === '' && method === 'GET') return ok(detail());241 if (rest === '/perp/live' && method === 'GET') return ok(readiness(q.get('account'), locale, Number(q.get('days')) || undefined));242 if (rest === '/perp/live/prepare') {243 if (active()) throw new Refuse('bad_state', 'this agent is already live', 409);244 const { r, a } = need(b.accountId, locale), lock = live.mandateLock(a); if (lock) throw lock;245 // the body is strict: { accountId, days?, locale } — the reference capital is always the venue account value at preparation246 if (Object.keys(b).some((k) => !['accountId', 'days', 'locale'].includes(k)) || (b.days !== undefined && (!Number.isInteger(b.days) || b.days < 1 || b.days > 30))) throw new Refuse('bad_request', 'invalid body', 400);247 const days = b.days === undefined ? r.proposal!.days : Math.min(b.days, r.proposal!.maxDays), auth = live.summary(a).authorization!;248 const prop = proposal(auth.policy, r.proposal!.capital, r.proposal!.maxDays, days);249 return ok({ ...live.prepareMandate(a, { ...prop.body, locale }), proposal: prop });250 }251 if (rest === '/perp/live/approve') {252 const { a } = need(b.accountId, locale), pending = a.pendingMandate;253 if (!pending) throw new Refuse('bad_state', 'prepare the mandate first', 409);254 const floor = pending.mandate.floorEquity, capital = cents(D.div(d(floor), d(String(1 - RISK.maxTotalLossPct / 100)), 6, 'half_even'));255 live.approveMandate(a, b); goLive(a, capital);256 return ok(detail());257 }258 if (rest === '/perp/live/pause') { if (A.mode !== 'LIVE' || A.state !== 'LIVE') throw new Refuse('bad_state', 'the agent is not live', 409); A.state = 'PAUSED'; log('pause', 'Paused by the owner', 'no new exposure; positions are kept; the loss limits are still watched'); return ok(detail()); }259 if (rest === '/perp/live/resume') { if (A.mode !== 'LIVE' || A.state !== 'PAUSED') throw new Refuse('bad_state', 'the agent is not paused', 409); A.state = 'LIVE'; log('resume', 'Resumed by the owner'); return ok(detail()); }260 if (rest === '/perp/live/stop') { if (!active()) throw new Refuse('bad_state', 'the agent is not live', 409);261 const windDown = A.state === 'STOPPING' ? null : leave('owner_stop', 'stopped by the owner', locale); step(); const left = acct() ? live.venueState(acct()!).positions : [];262 return ok({ ...detail(), windDown, result: A.state === 'STOPPED' ? 'stopped' : `stopping: ${left.length} position(s) still open (${left.map((p) => `${p.side} ${p.size} ${p.instrument.slice(p.instrument.indexOf(':') + 1)}`).join(', ')})` }); }263 if (rest === '/mode') {264 if (active()) { if (b.mode === 'STOPPED') { leave('owner_stop', 'stopped by the owner', locale); step(); return ok(view()); } throw new Refuse('bad_state', 'stop the live agent first', 409); }265 if (b.mode === 'PAPER' && !A.approved) throw new Refuse('no_strategy', 'approve a strategy version first', 409);266 A.state = b.mode === 'PAPER' ? 'PAPER_TRADING' : String(b.mode); A.mode = 'PAPER'; A.stopReason = b.mode === 'STOPPED' ? 'stopped by the owner' : null; return ok(view());267 }268 if (/^\/strategies\/[^/]+\/approve$/.test(rest)) { A.approved = true; if (['CREATED', 'AWAITING_APPROVAL'].includes(A.state)) A.state = 'AWAITING_APPROVAL'; return ok(view()); }269 if (rest === '/perp/flatten' || rest === '/perp/risk') { if (A.mode === 'LIVE') throw new Refuse('bad_state', 'the agent is on a venue account: its limits are the mandate\'s; stop it first', 409); return ok(rest === '/perp/risk' ? view() : { orders: [], positionsLeft: 0 }); }270 throw new Refuse('not_found', 'unknown route', 404);271 }272 return {273 hooks, step,274 handles: (path: string) => path === '/strategies' || path === '/agents' || path.startsWith('/agents/'),275 route(method: string, path: string, b: any, q: URLSearchParams): Out {276 try { return handle(method, path, b, q); }277 catch (e) { const x = e as { code?: string; status?: number; message?: string }; if (typeof x?.code === 'string' && typeof x?.status === 'number') return { status: x.status, json: { error: x.code, message: x.message } }; throw e; }278 },279 };280}281