Repository Public
1// Trading engine (V3). Deterministic and independent of any LLM: at every newly closed bar it asks the approved strategy for2// target weights, checks its own risk rules, and trades the difference — on paper, or for real through the signer's mandate.3// It cannot sign, cannot change a limit, and holds (does nothing) whenever data or signal are not trustworthy.4import { randomUUID } from 'node:crypto';5import { formatUnits, getNetwork, parseUnits } from '@spw/core';6import { audit, sql } from '../db.ts';7import { agentAssets, agentSymbols, cashOf, type AgentRow } from './routes.ts';89export interface Bar { ts: number; open: number; high: number; low: number; close: number; volume: number }10export interface Holding { symbol: string; id: string; decimals: number; units: number }11export interface EngineDeps {12 /** closed bars, oldest first */13 series(symbol: string, interval: string, limit: number): Promise<Bar[]>;14 /** throws unless the series is fresh, gap-free and consistent across sources right now */15 assertFresh(symbol: string, interval: string): Promise<void>;16 /** run the approved strategy version on these bars; throws on any problem (timeout, wrong hash, invalid output) */17 signal(strategy: { path: string; sha256: string }, data: Record<string, Bar[]>): Promise<Record<string, number>>;18 /** holdings of the agent: the real vault in LIVE mode, the paper book otherwise */19 holdings(a: AgentRow, mode: 'PAPER' | 'LIVE'): Promise<Holding[]>;20 /** expected output of a swap right now (base units), from the same provider used for execution */21 quote(a: AgentRow, fromId: string, toId: string, amountIn: bigint): Promise<bigint>;22 /** LIVE only: prepare, obtain the delegated signature and record the order; returns once the order is in flight */23 placeLive(a: AgentRow, o: PlannedOrder, bar: number): Promise<void>;24 /** is a live order of this agent still unsettled? */25 inFlight(a: AgentRow): Promise<boolean>;26 halted(): Promise<boolean>;27 now(): number;28}29export interface PlannedOrder { fromSymbol: string; toSymbol: string; fromId: string; toId: string; fromDecimals: number; toDecimals: number; amountIn: bigint; usd: number; reason: string }3031const LOOKBACK = 2500, MIN_TRADE_USD = 2, MIN_TRADE_FRACTION = 0.02;32/** paper orders per bar: enough to sell and buy every symbol once (an agent trades up to 12 symbols) */33const maxOrdersPerBar = (symbols: number) => Math.max(6, 2 * symbols);34const log = (a: AgentRow, kind: string, title: string, body = '', data?: unknown) => sql`insert into research_log (agent_id, kind, title, body, data) values (${a.id}, ${kind}, ${title}, ${body}, ${data === undefined ? null : sql.json(data as never)})`;3536/** Validate a strategy's answer: only universe symbols, each weight in [0, 1], total at most 1. Anything else means "hold". */37export function checkWeights(w: unknown, universe: string[], cash = 'USDC'): Record<string, number> {38 if (!w || typeof w !== 'object' || Array.isArray(w)) throw new Error('weights are not an object');39 const out: Record<string, number> = {}; let sum = 0;40 for (const [k, v] of Object.entries(w as Record<string, unknown>)) {41 if (k === cash) continue;42 if (!universe.includes(k)) throw new Error(`weight for ${k}, which is outside the agent's universe`);43 if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) throw new Error(`weight for ${k} is not in [0, 1]`);44 out[k] = v; sum += v;45 }46 if (sum > 1 + 1e-9) throw new Error('weights sum to more than 1');47 for (const s of universe) if (s !== cash && out[s] === undefined) out[s] = 0;48 return out;49}5051/** Turn target weights into at most one order: sells first (largest), then buys (largest). Pure. */52export function planOrder(a: AgentRow, weights: Record<string, number>, holdings: Holding[], prices: Record<string, number>, spentTodayUsd: number, ordersToday: number): { order: PlannedOrder | null; equity: number; note: string } {53 const cash = cashOf(a), usdc = holdings.find((h) => h.symbol === cash); // the cash leg: USDC, or USDT when the owner chose it54 const value = (h: Holding) => (h.symbol === cash ? h.units : h.units * (prices[h.symbol] ?? 0));55 // the native coin held for gas is not part of the strategy's equity unless it is in the universe56 const tradable = holdings.filter((h) => h.symbol === cash || a.universe.includes(h.symbol));57 const equity = tradable.reduce((s, h) => s + value(h), 0);58 if (!usdc || !(equity > 0)) return { order: null, equity, note: 'the agent wallet holds nothing to trade' };59 // exposure cap: scale all weights down together if they exceed it60 const cap = a.risk.maxExposurePct / 100, total = Object.values(weights).reduce((s, v) => s + v, 0), scale = total > cap ? cap / total : 1;61 const threshold = Math.max(MIN_TRADE_USD, equity * MIN_TRADE_FRACTION);62 const deltas = a.universe.filter((s) => s !== cash).map((s) => { const h = tradable.find((x) => x.symbol === s)!; return { h, delta: (weights[s] ?? 0) * scale * equity - value(h) }; }).filter((d) => d.h && Math.abs(d.delta) >= threshold);63 if (!deltas.length) return { order: null, equity, note: 'portfolio already at target' };64 if (ordersToday >= a.risk.maxOrdersPerDay) return { order: null, equity, note: 'daily order count reached' };65 const room = a.risk.maxDailyUsd - spentTodayUsd;66 if (room < MIN_TRADE_USD) return { order: null, equity, note: 'daily volume limit reached' };67 const sells = deltas.filter((d) => d.delta < 0).sort((x, y) => x.delta - y.delta), buys = deltas.filter((d) => d.delta > 0).sort((x, y) => y.delta - x.delta);68 const pick = sells[0] ?? buys[0]!;69 let usd = Math.min(Math.abs(pick.delta), a.risk.maxOrderUsd, room);70 const price = prices[pick.h.symbol];71 if (!price || !(price > 0)) return { order: null, equity, note: `no price for ${pick.h.symbol}` };72 if (pick.delta < 0) { // sell the asset for cash73 const units = Math.min(pick.h.units, usd / price);74 const amountIn = parseUnits(units.toFixed(Math.min(pick.h.decimals, 10)), pick.h.decimals);75 if (amountIn <= 0n) return { order: null, equity, note: 'order too small' };76 return { equity, note: '', order: { fromSymbol: pick.h.symbol, toSymbol: cash, fromId: pick.h.id, toId: usdc.id, fromDecimals: pick.h.decimals, toDecimals: usdc.decimals, amountIn, usd: units * price, reason: `target ${(100 * (weights[pick.h.symbol] ?? 0) * scale).toFixed(1)} %` } };77 }78 usd = Math.min(usd, usdc.units); // buy with the cash actually held79 if (usd < MIN_TRADE_USD) return { order: null, equity, note: `not enough ${cash} to buy` };80 return { equity, note: '', order: { fromSymbol: cash, toSymbol: pick.h.symbol, fromId: usdc.id, toId: pick.h.id, fromDecimals: usdc.decimals, toDecimals: pick.h.decimals, amountIn: parseUnits(usd.toFixed(Math.min(6, usdc.decimals)), usdc.decimals), usd, reason: `target ${(100 * (weights[pick.h.symbol] ?? 0) * scale).toFixed(1)} %` } };81}8283async function usage(a: AgentRow, mode: string, now: number): Promise<{ usd: number; orders: number }> {84 const day = new Date(now).toISOString().slice(0, 10);85 const rows = await sql<{ usd: number; n: number }[]>`select coalesce(sum((aux->>'usd')::float), 0)::float as usd, count(*)::int as n from agent_orders where agent_id = ${a.id} and mode = ${mode} and ts >= ${day}::date and state <> 'skipped' and state <> 'failed'`;86 return { usd: rows[0]?.usd ?? 0, orders: rows[0]?.n ?? 0 };87}8889/** One engine step for one agent. Safe to call as often as wanted: it acts at most once per closed bar and per in-flight order. */90export async function tickAgent(a: AgentRow, d: EngineDeps): Promise<string> {91 if (a.state !== 'PAPER_TRADING' && a.state !== 'LIVE') return 'idle';92 if (a.kind === 'perp') return 'idle'; // v6: a perp agent is never traded by the spot engine93 const mode = a.state === 'LIVE' ? 'LIVE' : 'PAPER';94 if (mode === 'LIVE' && (await d.halted())) return 'halted';95 if (mode === 'LIVE' && (await d.inFlight(a))) return 'order in flight';96 if (!a.strategy_version_id) return 'no strategy';97 const [sv] = await sql<{ path: string; code_sha256: string; status: string }[]>`select path, code_sha256, status from strategy_versions where id = ${a.strategy_version_id}`;98 if (!sv || sv.status !== 'approved') return 'strategy not approved';99 // LIVE runs only the exact code the mandate was approved for100 if (mode === 'LIVE' && a.mandate?.strategySha256 !== sv.code_sha256) { await stop(a, 'the approved strategy no longer matches the mandate'); return 'stopped'; }101 const cash = cashOf(a), symbols = agentSymbols(a);102 const data: Record<string, Bar[]> = {};103 try { for (const s of symbols) { await d.assertFresh(s, a.interval); data[s] = await d.series(s, a.interval, LOOKBACK); if (data[s]!.length < 50) throw new Error(`not enough history for ${s}`); } }104 catch (e) { return `hold: ${(e as Error).message.slice(0, 120)}`; } // stale or inconsistent data: never trade on it105 const bar = Math.min(...symbols.map((s) => data[s]![data[s]!.length - 1]!.ts));106 const done = a.last_bar && a.last_bar.getTime() >= bar;107 const prices = Object.fromEntries(symbols.map((s) => [s, data[s]![data[s]!.length - 1]!.close]));108 const holdings = await d.holdings(a, mode);109 const capital = Number(formatUnits(BigInt(a.capital_usdc), 6));110111 // loss limit: monitored here on every tick; a gap can carry the loss past it before this runs112 const eqNow = holdings.filter((h) => h.symbol === cash || a.universe.includes(h.symbol)).reduce((s, h) => s + (h.symbol === cash ? h.units : h.units * (prices[h.symbol] ?? 0)), 0);113 const breached = capital > 0 && eqNow > 0 && eqNow < capital * (1 - a.risk.maxLossPct / 100);114 let weights: Record<string, number>;115 if (breached) weights = Object.fromEntries(symbols.map((s) => [s, 0])); // liquidate to USDC, then stop116 else {117 if (done) return 'waiting for the next bar';118 try { weights = checkWeights(await d.signal({ path: sv.path, sha256: sv.code_sha256 }, data), symbols, cash); }119 catch (e) { await log(a, 'hold', 'No valid signal: holding', (e as Error).message.slice(0, 300)); await sql`update agents set last_bar = ${new Date(bar)}, updated_at = now() where id = ${a.id}`; return 'hold: no valid signal'; }120 }121 let placed = 0, note = '';122 for (let k = 0; k < (mode === 'LIVE' ? 1 : maxOrdersPerBar(symbols.length)); k++) {123 const u = await usage(a, mode, d.now());124 const plan = planOrder(a, weights, k === 0 ? holdings : await d.holdings(a, mode), prices, u.usd, u.orders);125 note = plan.note;126 if (!plan.order) break;127 const o = plan.order;128 if (mode === 'LIVE') { await d.placeLive(a, o, bar); placed += 1; break; }129 // paper: fill at the provider's live quote for this exact size; nothing is signed130 let out: bigint;131 try { out = await d.quote(a, o.fromId, o.toId, o.amountIn); } catch (e) { await log(a, 'hold', 'No executable quote: order skipped', (e as Error).message.slice(0, 200)); break; }132 const unitsIn = Number(formatUnits(o.amountIn, o.fromDecimals)), unitsOut = Number(formatUnits(out, o.toDecimals));133 await sql.begin(async (tx) => {134 await tx`insert into agent_orders (id, agent_id, mode, from_asset, to_asset, from_symbol, to_symbol, amount_in, expected_out, received, state, reason, bar, aux)135 values (${randomUUID()}, ${a.id}, 'PAPER', ${o.fromId}, ${o.toId}, ${o.fromSymbol}, ${o.toSymbol}, ${o.amountIn.toString()}, ${out.toString()}, ${out.toString()}, 'filled', ${o.reason}, ${new Date(bar)}, ${tx.json({ usd: o.usd })})`;136 await tx`insert into agent_paper (agent_id, symbol, units) values (${a.id}, ${o.fromSymbol}, ${-unitsIn}) on conflict (agent_id, symbol) do update set units = agent_paper.units + excluded.units`;137 await tx`insert into agent_paper (agent_id, symbol, units) values (${a.id}, ${o.toSymbol}, ${unitsOut}) on conflict (agent_id, symbol) do update set units = agent_paper.units + excluded.units`;138 });139 placed += 1;140 }141 // the bar is complete when nothing more is to be done for it (in LIVE the next tick continues after the order settles)142 if (mode === 'PAPER' || placed === 0) {143 await sql`update agents set last_bar = ${new Date(bar)}, updated_at = now() where id = ${a.id}`;144 const h2 = await d.holdings(a, mode);145 const eq = h2.filter((h) => h.symbol === cash || a.universe.includes(h.symbol)).reduce((s, h) => s + (h.symbol === cash ? h.units : h.units * (prices[h.symbol] ?? 0)), 0);146 await sql`insert into agent_equity (agent_id, ts, mode, equity_usd, positions) values (${a.id}, ${new Date(bar)}, ${mode}, ${eq}, ${sql.json(Object.fromEntries(h2.map((h) => [h.symbol, h.units])))}) on conflict do nothing`;147 if (breached) { await stop(a, `loss limit reached: equity ${eqNow.toFixed(2)} USD against ${capital.toFixed(2)} USD allocated (limit ${a.risk.maxLossPct} %); positions were sold to ${cash}`); return 'stopped: loss limit'; }148 }149 return placed ? `${placed} order(s)` : note || 'no order';150}151152async function stop(a: AgentRow, reason: string): Promise<void> {153 await sql`update agents set state = 'STOPPED', stop_reason = ${reason}, updated_at = now() where id = ${a.id}`;154 await log(a, 'stop', 'Agent stopped', reason);155 await audit('agent.stopped', 'engine', { id: a.id, reason });156}157158/** The paper book: starts as the allocated capital in the cash leg. Keyed by market symbol (the cash symbol for cash). */159export async function paperHoldings(a: AgentRow): Promise<Holding[]> {160 const assets = agentAssets(a), cash = cashOf(a), capital = Number(formatUnits(BigInt(a.capital_usdc), 6));161 let rows: { symbol: string; units: number }[] = await sql<{ symbol: string; units: number }[]>`select symbol, units from agent_paper where agent_id = ${a.id}`;162 if (!rows.length) { await sql`insert into agent_paper (agent_id, symbol, units) values (${a.id}, ${cash}, ${capital}) on conflict do nothing`; rows = [{ symbol: cash, units: capital }]; }163 return assets.map((x) => ({ symbol: x.symbol, id: x.id, decimals: x.decimals, units: Math.max(0, rows.find((r) => r.symbol === x.symbol)?.units ?? 0) }));164}165