SPB Git

Repository Public

engine.ts 13.7 KB 165 lines typescript

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

Theme