SPB Git

Repository Public

perp-engine.ts 34.3 KB 442 lines typescript

1// Perp agent engine (V6 PAPER; V7 also LIVE on a venue account — see perp-exec-live.ts and perp-live-cycle.ts). Same discipline as the spot engine (engine.ts): deterministic, no LLM, decisions on closed2// bars only, a data gate in front of the signal, a strategy pinned by its hash, and "hold" whenever anything is not trustworthy.3//4// Per closed bar:  gate → signal (spq.signal --contract 2, a separate process) → weights validated against the META bounds5// pinned at registration → scaled DOWN together to the owner's limits (never up) → target sizes in base units from equity and6// mark (exact decimals) → orders = deltas, reduce first → the executor (risk engine, then the paper engine) → signal log.7// On every pass, before anything else: risk events of the account (a liquidation stops the agent, a margin warning flags it)8// and the loss limits (breach → flatten with reduce-only orders, then stop).9//10// Execution goes through ONE narrow interface, `PerpExecutor`: the paper account (perp-exec.ts) or, for a LIVE agent, a venue11// account whose orders the isolated signer signs under a derivative mandate (perp-exec-live.ts). The engine itself never12// signs, never calls the signer and never reaches a venue's trading endpoint.13//14// Numbers: the strategy's weights are the only floats; each is converted once to an 8-decimal string. Equity, marks, sizes,15// notionals and prices are `D` decimals from there on.16import { z } from 'zod';17import { formatUnits } from '@spw/core';18import { D, type AccountState, type Book, type Dec, type DecStr, type Fill, type Instrument, type Order, type OrderRequest, type Position, type RiskPolicy, type Ticker } from '@spw/derivatives';19import { audit, sql } from '../db.ts';20import { worstPrice } from '../derivatives/paper.ts';21import type { PerpInterval, PerpSeries } from './perp-data.ts';22import type { AgentRow } from './routes.ts';2324// ------------------------------------------------------------------ the owner's limits2526/** Set by the owner at creation. They feed BOTH the derivatives risk engine (`perpPolicy`) and the engine's own sizing. */27export const PerpRisk = z.strictObject({28  /** account leverage: gross exposure / equity. Also the leverage setting of every order (an integer on Hyperliquid). */29  maxLeverage: z.number().int().min(1).max(5).default(2),30  /** gross exposure (sum of |notional|) as a multiple of the capital */31  maxGrossExposureX: z.number().min(0.1).max(5).default(1.5),32  /** position on one instrument as a multiple of the capital */33  maxPositionX: z.number().min(0.05).max(5).default(1),34  /** loss in one UTC day / since creation, in percent of the capital: flatten, then stop (can be overshot by a gap) */35  maxDailyLossPct: z.number().min(0.01).max(50).default(3),36  maxTotalLossPct: z.number().min(0.01).max(90).default(10),37  /** worst price of a market order: this far beyond the best opposite price, in basis points */38  slippageBps: z.number().int().min(5).max(100).default(30),39  /** a rebalance smaller than this share of equity is not traded (a full exit always is), percent */40  minTradePct: z.number().min(0.5).max(20).default(2),41});42export type PerpRisk = z.infer<typeof PerpRisk>;4344/** bookkeeping of the engine on the agent row (`agents.perp`) */45export interface PerpBook {46  lastEventId?: number;47  /** a margin warning seen on the account */48  flag?: { ts: number; message: string } | null;49  /** why the last pass held */50  hold?: { ts: number; reason: string } | null;51  /** equity at the first pass of the UTC day (daily loss reference) */52  day?: { date: string; equity: DecStr };53  /** set when a loss limit is breached: the agent closes everything, then stops */54  flatten?: { ts: number; reason: string } | null;55  /** V7: the agent trades (or traded) a venue account under a derivative mandate — written by perp-live-cycle.ts only */56  live?: PerpLiveBook | null;57}58/** bookkeeping of the LIVE life cycle (perp-live-cycle.ts) */59export interface PerpLiveBook {60  accountId: string; network: 'testnet' | 'mainnet'; mandateId: string; strategySha256: string; since: number;61  /** reference capital of the loss limits and of the absolute caps: the venue's account value when the mandate was prepared */62  capital: DecStr;63  /** why the agent left LIVE; `done` once it is STOPPED; `retryAt`: no closing attempt before this time (after a refused one) */64  exit?: { ts: number; code: string; reason: string; retryAt?: number; done?: number; closed?: number } | null;65}66export type PerpAgentRow = Omit<AgentRow, 'risk'> & { kind: 'perp'; risk: PerpRisk; deriv_account_id: string; perp: PerpBook | null };6768export const PERP_VENUE = 'hyperliquid' as const;69/** margin kept free: the engine never targets more than this share of leverage × equity */70export const LEVERAGE_HEADROOM = '0.9';71/** the absolute caps are approached to within 2 % plus the slippage bound (the risk engine values an order at its worst price) */72export const CAP_HEADROOM = '0.02';73/** an order must clear the venue's minimum notional by this factor at the mark (its worst price is below the mark for a sell) */74const MIN_NOTIONAL_FACTOR = D.from('1.05');75const HUNDRED = D.from(100n), BPS = D.from(10000n);7677const numDec = (n: number, dp = 8): Dec => {78  if (!Number.isFinite(n)) throw new Error('not a finite number');79  return D.from(n.toFixed(dp).replace(/^-0(\.0+)?$/, '0'));80};81const capitalOf = (a: Pick<AgentRow, 'capital_usdc'>): Dec => D.from(formatUnits(BigInt(a.capital_usdc), 6));82export const instrumentId = (symbol: string): string => `${PERP_VENUE}:${symbol}`;8384/** The risk policy of the agent's account, derived from the owner's limits: the derivatives risk engine enforces it on every order. */85export function perpPolicy(symbols: string[], capital: DecStr, r: PerpRisk, halt: string | null = null): RiskPolicy {86  const c = D.from(capital), times = (k: Dec): DecStr => D.str(D.mul(c, k));87  return {88    mode: 'PAPER', allowedInstruments: symbols.map(instrumentId), halt,89    maxDataAgeMs: 10_000, maxDeviationBps: '200', maxMarkIndexBps: '100', maxLeverage: String(r.maxLeverage),90    maxOrderNotional: times(numDec(r.maxPositionX, 4)), maxGrossExposure: times(numDec(r.maxGrossExposureX, 4)), maxPositionNotional: times(numDec(r.maxPositionX, 4)),91    minFreeCollateral: times(D.from('0.05')), minLiquidationDistanceBps: '1000',92    maxDailyLoss: times(D.div(numDec(r.maxDailyLossPct, 4), HUNDRED, 8, 'down')), maxTotalLoss: times(D.div(numDec(r.maxTotalLossPct, 4), HUNDRED, 8, 'down')),93    maxOrdersPerMinute: 40, maxOrdersPerDay: 400,94  };95}9697// ------------------------------------------------------------------ the one interface a live path will implement9899export interface ExecEvent { id: number; ts: number; kind: string; code: string | null; message: string }100export interface ExecAccount { state: AccountState; /** the policy in force, with its circuit breaker (`halt`) */ policy: RiskPolicy; /** liquidations and margin warnings recorded after `sinceEventId` */ events: ExecEvent[] }101export type PlaceResult =102  | { status: 'placed'; order: Order; fills: Fill[]; idempotent: boolean }103  /** refused by the risk engine (or by the signer's mandate): nothing was sent, or the venue refused it — nothing more on this bar */104  | { status: 'refused'; code: string; reason: string }105  /** LIVE only: nothing was sent NOW (an earlier order is still being checked on the venue, or the signer still counts the106   *  last one): the bar stays open and the next pass plans again from the venue's real positions */107  | { status: 'deferred'; code: string; reason: string };108109/**110 * Everything the engine needs from "an account that can hold perpetual positions". PAPER: the derivatives paper account111 * (perp-exec.ts). LIVE (V7, perp-exec-live.ts): the same three calls over a venue account whose orders the signer signs under a112 * derivative mandate; the account state and the positions are then the VENUE's own (`source: 'venue'`).113 *  - `place` takes a complete order: `price` is the worst acceptable price (market orders are IOC with that bound), `clientId`114 *    is the idempotency key (placing it twice returns the first result). A refusal is returned, never thrown; an exception115 *    means "could not be attempted now" and the engine retries the bar on its next pass.116 */117export interface PerpExecutor {118  account(sinceEventId?: number): Promise<ExecAccount>;119  positions(): Promise<Position[]>;120  place(order: OrderRequest, leverage: DecStr): Promise<PlaceResult>;121  /** LIVE only: instruments on which an order of the agent is not confirmed by the venue yet — the bar stays open for them */122  unconfirmed?(): string[];123}124125/** What a LIVE agent adds to an engine step (perp-live-cycle.ts). Absent = PAPER, exactly as before. */126export interface PerpLiveHooks {127  /** reference capital of the loss limits and of the absolute caps (the venue's account value at go-live), instead of the simulated capital */128  capital: DecStr;129  /** a paused live agent: account events and loss limits only — no signal, no order; its positions are kept */130  riskOnly?: boolean;131  /** a loss limit is breached: the live life cycle takes over (revoke the mandate, close with reduce-only orders, stop) */132  onBreach(reason: string): Promise<string>;133}134135export interface PerpSignal { weights: Record<string, number>; maxLeverage: number; maxGrossExposure: number }136export interface PerpMarket { inst: Instrument; ticker: Ticker; book: Book }137export interface PerpEngineDeps {138  now(): number;139  /** closed bars + aligned funding of every symbol, fresh, gap-free and cross-checked right now; throws otherwise (→ hold) */140  gate(symbols: string[], interval: PerpInterval, lookback: number): Promise<{ bar: number; data: Record<string, PerpSeries> }>;141  /** run the approved strategy version (contract 2) on these series; throws on any problem (→ hold) */142  signal(strategy: { path: string; sha256: string }, data: Record<string, PerpSeries>, interval: PerpInterval): Promise<PerpSignal>;143  /** instrument, live ticker and live book of one symbol */144  market(symbol: string): Promise<PerpMarket>;145  executor(a: PerpAgentRow): PerpExecutor;146}147148// ------------------------------------------------------------------ pure parts149150export interface PerpMetaBounds { max_leverage: number; max_gross_exposure: number }151/** Validate a contract-2 answer against the META bounds pinned at registration. Anything else means "hold". */152export function checkPerpWeights(sig: unknown, symbols: string[], meta: PerpMetaBounds): Record<string, Dec> {153  const s = sig as Partial<PerpSignal> | null;154  if (!s || typeof s !== 'object') throw new Error('no signal');155  if (s.maxLeverage !== meta.max_leverage || s.maxGrossExposure !== meta.max_gross_exposure) throw new Error('the signal declares other leverage bounds than the registered version');156  const w = s.weights;157  if (!w || typeof w !== 'object' || Array.isArray(w)) throw new Error('weights are not an object');158  const out: Record<string, Dec> = {}; let gross = 0;159  for (const [k, v] of Object.entries(w as Record<string, unknown>)) {160    if (!symbols.includes(k)) throw new Error(`weight for ${k}, which is outside the agent's instruments`);161    if (typeof v !== 'number' || !Number.isFinite(v)) throw new Error(`weight for ${k} is not a finite number`);162    if (Math.abs(v) > meta.max_leverage + 1e-9) throw new Error(`weight for ${k} is outside ±${meta.max_leverage} (META max_leverage)`);163    gross += Math.abs(v); out[k] = numDec(v);164  }165  if (gross > meta.max_gross_exposure + 1e-9) throw new Error(`gross exposure ${gross} exceeds META max_gross_exposure ${meta.max_gross_exposure}`);166  for (const sym of symbols) out[sym] ??= D.zero;167  return out;168}169170/**171 * The owner's caps, applied to all weights TOGETHER by one factor ≤ 1 (the strategy's proportions are kept; nothing is ever172 * scaled up):  gross ≤ min(maxLeverage · equity · 0.9, maxGrossExposureX · capital · h),  each position ≤ maxPositionX · capital · h,173 * with h = 1 − 2 % − slippage bound.174 */175export function scalePerpWeights(w: Record<string, Dec>, equity: Dec, capital: Dec, r: PerpRisk): { scaled: Record<string, Dec>; factor: Dec; binding: 'none' | 'leverage' | 'gross' | 'position' | 'no_equity' } {176  const syms = Object.keys(w), zero = Object.fromEntries(syms.map((s) => [s, D.zero]));177  if (!D.isPos(equity)) return { scaled: zero, factor: D.zero, binding: 'no_equity' };178  const gross = syms.reduce((a, s) => D.add(a, D.abs(w[s]!)), D.zero), largest = syms.reduce((a, s) => D.max(a, D.abs(w[s]!)), D.zero);179  if (D.isZero(gross)) return { scaled: zero, factor: D.one, binding: 'none' };180  const h = D.sub(D.sub(D.one, D.from(CAP_HEADROOM)), D.div(D.from(BigInt(r.slippageBps)), BPS, 6, 'up'));181  const caps: [Dec, Dec, 'leverage' | 'gross' | 'position'][] = [182    [D.mul(D.mul(D.from(BigInt(r.maxLeverage)), equity), D.from(LEVERAGE_HEADROOM)), D.mul(gross, equity), 'leverage'],183    [D.mul(D.mul(numDec(r.maxGrossExposureX, 4), capital), h), D.mul(gross, equity), 'gross'],184    [D.mul(D.mul(numDec(r.maxPositionX, 4), capital), h), D.mul(largest, equity), 'position'],185  ];186  let factor = D.one, binding: 'none' | 'leverage' | 'gross' | 'position' = 'none';187  for (const [cap, wanted, name] of caps) {188    if (D.lte(wanted, cap)) continue;189    const f = D.div(cap, wanted, 12, 'down');190    if (D.lt(f, factor)) { factor = f; binding = name; }191  }192  return { scaled: Object.fromEntries(syms.map((s) => [s, D.mul(w[s]!, factor)])), factor, binding };193}194195/** Signed target size in base units: |weight| · equity / mark, truncated to the lot. */196export function targetSize(weight: Dec, equity: Dec, mark: Dec, lot: Dec): Dec {197  if (!D.isPos(mark) || !D.isPos(equity) || D.isZero(weight)) return D.zero;198  const size = D.quantize(D.div(D.mul(D.abs(weight), equity), mark, 18, 'down'), lot, 'down');199  return D.isNeg(weight) ? D.neg(size) : size;200}201202export interface PlannedPerpOrder {203  symbol: string; side: 'buy' | 'sell'; size: DecStr; reduceOnly: boolean;204  /** 'reduce' orders go first: they free margin */205  phase: 'reduce' | 'open';206  /** closes the whole position */207  closes: boolean;208  /** the opening half of a flip: only sent once the closing half is completely filled */209  afterClose: boolean;210  notional: DecStr; reason: string;211}212213/**214 * Orders = target − current, per instrument. Pure.215 *  - target 0 → close everything (never blocked by the threshold);216 *  - opposite sign (long → short, short → long) → TWO orders: a reduce-only close of the whole position, then an open;217 *  - same sign, smaller → one reduce-only order; same sign, larger (or from flat) → one opening order;218 *  - an order below `threshold` (or the venue's minimum notional) is not sent, except a full close;219 *  - order: every reduce first (largest notional first), then every open (largest first); ties by symbol.220 */221export function planPerpOrders(targets: Record<string, Dec>, current: Record<string, Dec>, marks: Record<string, Dec>, minNotional: Record<string, Dec | null>, threshold: Dec): PlannedPerpOrder[] {222  const out: PlannedPerpOrder[] = [];223  for (const symbol of Object.keys(targets).sort()) {224    const tgt = targets[symbol]!, cur = current[symbol] ?? D.zero, mark = marks[symbol];225    if (!mark || !D.isPos(mark) || D.eq(tgt, cur)) continue;226    const floor = D.max(threshold, minNotional[symbol] ? D.mul(minNotional[symbol]!, MIN_NOTIONAL_FACTOR) : D.zero);227    const worth = (size: Dec): Dec => D.mul(D.abs(size), mark), big = (size: Dec): boolean => D.gte(worth(size), floor);228    const order = (side: 'buy' | 'sell', size: Dec, o: Pick<PlannedPerpOrder, 'reduceOnly' | 'phase' | 'closes' | 'afterClose' | 'reason'>): void => {229      out.push({ symbol, side, size: D.str(D.abs(size)), notional: D.str(worth(size)), ...o });230    };231    const curLong = D.isPos(cur), closeSide = curLong ? 'sell' as const : 'buy' as const;232    if (!D.isZero(cur) && (D.isZero(tgt) || D.sign(tgt) !== D.sign(cur))) {233      order(closeSide, cur, { reduceOnly: true, phase: 'reduce', closes: true, afterClose: false, reason: D.isZero(tgt) ? 'target flat: close' : `flip ${curLong ? 'long → short' : 'short → long'}: close first` });234      if (!D.isZero(tgt) && big(tgt)) order(D.isPos(tgt) ? 'buy' : 'sell', tgt, { reduceOnly: false, phase: 'open', closes: false, afterClose: true, reason: `flip: open ${D.isPos(tgt) ? 'long' : 'short'}` });235      continue;236    }237    const delta = D.sub(D.abs(tgt), D.abs(cur));238    if (!big(delta)) continue;239    if (D.isNeg(delta)) order(closeSide, delta, { reduceOnly: true, phase: 'reduce', closes: false, afterClose: false, reason: 'reduce toward target' });240    else order(D.isPos(tgt) ? 'buy' : 'sell', delta, { reduceOnly: false, phase: 'open', closes: false, afterClose: false, reason: D.isZero(cur) ? `open ${D.isPos(tgt) ? 'long' : 'short'}` : 'increase toward target' });241  }242  const rank = (o: PlannedPerpOrder): number => (o.phase === 'reduce' ? 0 : 1);243  return out.sort((a, b) => rank(a) - rank(b) || D.cmp(D.from(b.notional), D.from(a.notional)) || a.symbol.localeCompare(b.symbol));244}245246/** signed size of a position */247const signed = (p: Pick<Position, 'side' | 'size'>): Dec => (p.side === 'long' ? D.from(p.size) : D.neg(D.from(p.size)));248/** equity = collateral + unrealised PnL of every position at the given marks (a position without a mark keeps its own) */249export function equityAt(collateral: DecStr, positions: Position[], marks: Record<string, Dec>): Dec {250  return positions.reduce((acc, p) => {251    const sym = p.instrument.slice(p.instrument.indexOf(':') + 1), mark = marks[sym] ?? D.from(p.markPrice);252    return D.add(acc, D.mul(signed(p), D.sub(mark, D.from(p.entryPrice))));253  }, D.from(collateral));254}255256/** Loss limits of the engine, on equity against the capital and against the equity at the start of the UTC day. */257export function lossBreach(equity: Dec, capital: Dec, dayEquity: Dec, r: PerpRisk): string | null {258  const total = D.sub(capital, equity), daily = D.sub(dayEquity, equity);259  const limit = (pct: number): Dec => D.div(D.mul(capital, numDec(pct, 4)), HUNDRED, 8, 'down');260  if (D.gte(total, limit(r.maxTotalLossPct))) return `total loss limit reached: equity ${D.str(D.quantize(equity, D.from('0.01'), 'down'))} USDC against ${D.str(capital)} allocated (limit ${r.maxTotalLossPct} %)`;261  if (D.gte(daily, limit(r.maxDailyLossPct))) return `daily loss limit reached: equity ${D.str(D.quantize(equity, D.from('0.01'), 'down'))} USDC against ${D.str(D.quantize(dayEquity, D.from('0.01'), 'down'))} at the start of the day (limit ${r.maxDailyLossPct} % of the capital)`;262  return null;263}264265// ------------------------------------------------------------------ one engine step266267const 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}, ${data === undefined ? null : sql.json(data as never)})`;268/** The engine's own bookkeeping. The keys of the LIVE life cycle (`live`, `livePending`) are never written from here: they are269 *  kept as they are in the database, so that a pass that started before the owner stopped the agent cannot undo the stop. */270const saveBook = (a: { id: string }, book: PerpBook) => {271  const own: Record<string, unknown> = { ...book }; delete own.live; delete own.livePending;272  return sql`update agents set perp = jsonb_strip_nulls(jsonb_build_object('live', perp->'live', 'livePending', perp->'livePending')) || ${sql.json(own as never)}, updated_at = now() where id = ${a.id}`;273};274const str = (o: Record<string, Dec>): Record<string, DecStr> => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, D.str(v)]));275276async function stop(a: PerpAgentRow, reason: string): Promise<void> {277  await sql`update agents set state = 'STOPPED', stop_reason = ${reason}, updated_at = now() where id = ${a.id}`;278  await log(a, 'stop', 'Agent stopped', reason);279  await audit('agent.stopped', 'engine', { id: a.id, kind: 'perp', reason });280}281282/** client id of an agent order: one per agent, strategy version, bar, instrument and phase — a restart cannot place it twice */283export const orderClientId = (agentId: string, sha256: string, bar: number, symbol: string, phase: 'reduce' | 'open'): string => `ag-${agentId}-${sha256.slice(0, 8)}-${Math.floor(bar / 1000)}-${symbol}-${phase === 'reduce' ? 'r' : 'o'}`;284285function request(m: PerpMarket, side: 'buy' | 'sell', size: DecStr, reduceOnly: boolean, slippageBps: number, clientId: string): OrderRequest {286  // a "market" order is immediate-or-cancel at a worst price this far beyond the best opposite price, on the venue's price grid287  return { instrument: m.inst.id, side, type: 'market', size, price: worstPrice(m.inst, m.book, side, slippageBps), triggerPrice: null, reduceOnly, tif: 'ioc', clientId };288}289const leverageFor = (a: PerpAgentRow, inst: Instrument): DecStr => D.str(D.quantize(D.min(D.from(BigInt(a.risk.maxLeverage)), D.from(inst.maxLeverage)), D.one, 'down'));290const symbolOf = (instrument: string): string => instrument.slice(instrument.indexOf(':') + 1);291292/** Close every position with reduce-only IOC orders (works under a halt). Returns the positions still open afterwards. */293export async function flattenPerp(a: PerpAgentRow, d: PerpEngineDeps, ex: PerpExecutor, tag: string, only?: (p: Position) => boolean): Promise<{ left: Position[]; results: Record<string, unknown>[] }> {294  const results: Record<string, unknown>[] = [];295  for (const p of await ex.positions()) {296    if (only && !only(p)) continue;297    const symbol = symbolOf(p.instrument), m = await d.market(symbol), side = p.side === 'long' ? 'sell' as const : 'buy' as const;298    const r = await ex.place(request(m, side, p.size, true, a.risk.slippageBps, `ag-${a.id}-x${tag}-${symbol}`), leverageFor(a, m.inst));299    results.push(r.status === 'placed' ? { symbol, side, size: p.size, status: r.order.status, filled: r.order.filled, avgFillPrice: r.order.avgFillPrice } : { symbol, side, size: p.size, status: r.status, code: r.code, reason: r.reason });300  }301  return { left: await ex.positions(), results };302}303304/** One engine step for one perp agent. Safe to call as often as wanted: it decides at most once per closed bar. */305export async function tickPerpAgent(a: PerpAgentRow, d: PerpEngineDeps, live?: PerpLiveHooks): Promise<string> {306  // PAPER: state PAPER_TRADING, no hooks. LIVE: only through perp-live-cycle.ts, which passes the hooks (a paused live agent runs `riskOnly`).307  const runs = live ? a.mode === 'LIVE' && (a.state === 'LIVE' || (a.state === 'PAUSED' && live.riskOnly === true)) : a.state === 'PAPER_TRADING' && a.mode !== 'LIVE';308  if (a.kind !== 'perp' || !runs) return 'idle';309  const ex = d.executor(a), book: PerpBook = { ...(a.perp ?? {}) }, now = d.now(), capital = live ? D.from(live.capital) : capitalOf(a);310  const what = live ? 'venue account' : 'paper account';311  /** equity: the venue's own account value when the state comes from the venue; the paper account's collateral + PnL at the live marks otherwise */312  const equityOf = (state: AccountState, pos: Position[]): Dec => (state.source === 'venue' ? D.from(state.equity) : equityAt(state.collateral, pos, marks));313314  // 1 — what happened to the account since the last pass315  const acct = await ex.account(book.lastEventId ?? 0);316  let liquidated: ExecEvent | null = null;317  for (const e of acct.events) {318    book.lastEventId = Math.max(book.lastEventId ?? 0, e.id);319    if (e.kind === 'liquidation') liquidated = e;320    else if (e.kind === 'margin_warning') { book.flag = { ts: e.ts, message: e.message }; await log(a, 'flag', `Margin warning on the ${what}`, e.message); }321  }322  if (liquidated) { await saveBook(a, book); await stop(a, live ? `the venue account was liquidated: ${liquidated.message}` : `the paper account was liquidated (simulated): ${liquidated.message}`); return 'stopped: liquidation'; }323324  // 2 — loss limits, on every pass (a gap can carry the loss past them before this runs)325  let positions = await ex.positions();326  const marks: Record<string, Dec> = {}, markets = new Map<string, PerpMarket>();327  const market = async (symbol: string): Promise<PerpMarket> => { let m = markets.get(symbol); if (!m) { m = await d.market(symbol); markets.set(symbol, m); marks[symbol] = D.from(m.ticker.mark); } return m; };328  let liveMarks = true;329  for (const p of positions) { try { await market(symbolOf(p.instrument)); } catch { liveMarks = false; } }   // no live mark: the account's own last mark is used330  const equity = equityOf(acct.state, positions);331  const date = new Date(now).toISOString().slice(0, 10);332  if (book.day?.date !== date) book.day = { date, equity: D.str(equity) };333  const breach = book.flatten?.reason ?? lossBreach(equity, capital, D.from(book.day.equity), a.risk) ?? (acct.policy.halt !== null ? `the account's circuit breaker is set: ${acct.policy.halt}` : null);334  if (breach && live) { await saveBook(a, book); return live.onBreach(breach); }335  if (live?.riskOnly) { await saveBook(a, book); return 'paused: positions kept, no new exposure (loss limits still watched)'; }336  if (breach) {337    if (!book.flatten) { book.flatten = { ts: now, reason: breach }; await log(a, 'risk', 'Loss limit: closing every position', breach); }338    await saveBook(a, book);339    const f = await flattenPerp(a, d, ex, String(Math.floor(now / 60_000)));340    if (f.results.length) await log(a, 'risk', 'Reduce-only closing orders', '', f.results);341    if (f.left.length) return `flattening: ${f.left.length} position(s) still open`;342    await stop(a, `${breach}; every position was closed with reduce-only orders`);343    return 'stopped: loss limit';344  }345346  // 3 — the approved strategy version and the data gate347  if (!a.strategy_version_id) { await saveBook(a, book); return 'no strategy'; }348  const [sv] = await sql<{ path: string; code_sha256: string; status: string; meta: { kind?: string; lookback?: number; max_leverage?: number; max_gross_exposure?: number } }[]>`select path, code_sha256, status, meta from strategy_versions where id = ${a.strategy_version_id}`;349  if (!sv || sv.status !== 'approved') { await saveBook(a, book); return 'strategy not approved'; }350  if (sv.meta.kind !== 'perp' || typeof sv.meta.lookback !== 'number' || typeof sv.meta.max_leverage !== 'number' || typeof sv.meta.max_gross_exposure !== 'number') { await stop(a, 'the approved version is not a perpetual-futures strategy (contract 2)'); return 'stopped'; }351  const symbols = a.universe, interval = a.interval as PerpInterval;352  const foreign = positions.filter((p) => !symbols.includes(symbolOf(p.instrument)));353  if (foreign.length) { book.hold = { ts: now, reason: `the account holds ${foreign.map((p) => p.instrument).join(', ')}, which this agent does not manage` }; await saveBook(a, book); return `hold: ${book.hold.reason}`; }354  let gated: Awaited<ReturnType<PerpEngineDeps['gate']>>;355  try { gated = await d.gate(symbols, interval, sv.meta.lookback); }356  catch (e) { book.hold = { ts: now, reason: (e as Error).message.slice(0, 240) }; await saveBook(a, book); return `hold: ${book.hold.reason.slice(0, 120)}`; }   // stale or inconsistent data: never trade on it357  const bar = gated.bar;358  if (a.last_bar && a.last_bar.getTime() >= bar) { book.hold = null; await saveBook(a, book); return 'waiting for the next bar'; }359360  // 4 — ONE decision per bar: the row is written before any order, and a restart reads it back instead of deciding again361  const complete = async (): Promise<void> => { await sql`update agents set last_bar = ${new Date(bar)}, updated_at = now() where id = ${a.id}`; };362  let [row] = await sql<{ status: string; weights: Record<string, number> | null; strategy_sha256: string; orders?: { list?: Record<string, unknown>[] } | null }[]>`select status, weights, strategy_sha256, orders from perp_agent_signals where agent_id = ${a.id} and bar = ${new Date(bar)}`;363  if (!row) {364    let sig: PerpSignal | null = null, why = '';365    try { sig = await d.signal({ path: sv.path, sha256: sv.code_sha256 }, gated.data, interval); checkPerpWeights(sig, symbols, sv.meta as PerpMetaBounds); }366    catch (e) { sig = null; why = (e as Error).message.slice(0, 300); }367    [row] = await sql<{ status: string; weights: Record<string, number> | null; strategy_sha256: string }[]>`insert into perp_agent_signals (agent_id, bar, status, reason, strategy_sha256, weights)368      values (${a.id}, ${new Date(bar)}, ${sig ? 'decided' : 'hold'}, ${why}, ${sv.code_sha256}, ${sig ? sql.json(sig.weights as never) : null})369      on conflict (agent_id, bar) do update set updated_at = now() returning status, weights, strategy_sha256`;370    if (!sig) await log(a, 'hold', 'No valid signal: holding', why);371  }372  book.hold = null;373  if (row!.status === 'hold') { await saveBook(a, book); await complete(); return 'hold: no valid signal'; }374  if (row!.status === 'done') { await saveBook(a, book); await complete(); return 'waiting for the next bar'; }375  if (row!.strategy_sha256 !== sv.code_sha256) { await saveBook(a, book); await complete(); return 'hold: this bar was decided by another strategy version'; }376  const weights = checkPerpWeights({ weights: row!.weights, maxLeverage: sv.meta.max_leverage, maxGrossExposure: sv.meta.max_gross_exposure }, symbols, sv.meta as PerpMetaBounds);377378  // 5 — targets and orders, from the account as it stands now379  try { for (const s of symbols) await market(s); liveMarks = true; }380  catch (e) { book.hold = { ts: now, reason: `no live market for the orders: ${(e as Error).message.slice(0, 160)}` }; await saveBook(a, book); return `hold: ${book.hold.reason.slice(0, 120)}`; }  // the bar stays open: retried on the next pass381  void liveMarks;382  const eq = equityOf(acct.state, positions);383  const { scaled, factor, binding } = scalePerpWeights(weights, eq, capital, a.risk);384  const targets: Record<string, Dec> = {}, current: Record<string, Dec> = {}, minNotional: Record<string, Dec | null> = {};385  for (const s of symbols) {386    const m = markets.get(s)!;387    targets[s] = targetSize(scaled[s]!, eq, marks[s]!, D.from(m.inst.lotSize));388    minNotional[s] = m.inst.minNotional === null ? null : D.from(m.inst.minNotional);389    const p = positions.find((x) => x.instrument === m.inst.id);390    current[s] = p ? signed(p) : D.zero;391  }392  const threshold = D.div(D.mul(eq, numDec(a.risk.minTradePct, 4)), HUNDRED, 8, 'up');393  const plan = planPerpOrders(targets, current, marks, minNotional, threshold);394  const results: Record<string, unknown>[] = [], closed = new Set<string>();395  let placed = 0, deferred = 0;396  for (const o of plan) {397    const base = { symbol: o.symbol, side: o.side, size: o.size, reduceOnly: o.reduceOnly, reason: o.reason };398    if (o.afterClose && !closed.has(o.symbol)) { results.push({ ...base, status: 'skipped', why: 'the closing order was not completely filled' }); continue; }399    const m = markets.get(o.symbol)!, clientId = orderClientId(a.id, sv.code_sha256, bar, o.symbol, o.phase);400    const r = await ex.place(request(m, o.side, o.size, o.reduceOnly, a.risk.slippageBps, clientId), leverageFor(a, m.inst));   // throws = not attempted: the bar stays open401    if (r.status === 'refused') { results.push({ ...base, status: 'refused', code: r.code, why: r.reason, clientId }); continue; }402    if (r.status === 'deferred') { deferred += 1; results.push({ ...base, status: 'deferred', code: r.code, why: r.reason, clientId }); continue; }403    if (!live || !r.idempotent) placed += 1;404    if (o.closes && r.order.status === 'filled') closed.add(o.symbol);405    results.push({ ...base, status: r.order.status, filled: r.order.filled, avgFillPrice: r.order.avgFillPrice, worstPrice: r.order.price, why: r.order.rejectReason, clientId, idempotent: r.idempotent });406  }407408  // LIVE: an order the venue has not confirmed keeps the bar open, also when nothing is planned on its instrument right now409  // (the venue's positions may already show it): the bar is complete only once the venue has said what became of it410  for (const instrument of ex.unconfirmed?.() ?? []) {411    const symbol = symbolOf(instrument);412    if (!symbols.includes(symbol) || results.some((x) => x.symbol === symbol && x.status === 'deferred')) continue;413    deferred += 1; results.push({ symbol, status: 'deferred', code: 'live_unknown_pending', why: `an order on ${instrument} is still being checked on the venue` });414  }415  const sentNow = results.length;416  // LIVE: orders of this bar sent on an earlier pass and no longer planned stay in the log of the bar417  const earlier = live ? (row!.orders?.list ?? []).filter((x) => typeof x.clientId === 'string' && x.status !== 'deferred' && x.status !== 'skipped' && !results.some((y) => y.clientId === x.clientId)) : [];418  if (earlier.length) results.unshift(...earlier);419  if (deferred > 0) {420    // LIVE: something waits (the venue has not confirmed an order, or the signer still counts the last one). The bar stays421    // open: the next pass reads the decision back and plans again from the venue's real positions — same client ids, no new signal.422    await sql`update perp_agent_signals set scaled = ${sql.json(str(scaled) as never)}, scale = ${D.str(factor)}, equity = ${D.str(eq)}, targets = ${sql.json(str(targets) as never)},423      orders = ${sql.json({ binding, threshold: D.str(threshold), slippageBps: a.risk.slippageBps, list: results } as never)}, updated_at = now() where agent_id = ${a.id} and bar = ${new Date(bar)}`;424    await saveBook(a, book);425    if (placed) await audit('agent.perp_orders', 'engine', { agent: a.id, bar: new Date(bar).toISOString(), orders: results.length, placed, deferred, sha256: sv.code_sha256 });426    return `${placed} order(s), ${deferred} waiting for the next pass`;427  }428429  // 6 — the bar is complete: signal log, equity point430  positions = await ex.positions();431  const after = await ex.account(book.lastEventId ?? 0);432  const eqAfter = equityOf(after.state, positions);433  await sql`update perp_agent_signals set status = 'done', scaled = ${sql.json(str(scaled) as never)}, scale = ${D.str(factor)}, equity = ${D.str(eq)}, targets = ${sql.json(str(targets) as never)},434    orders = ${sql.json({ binding, threshold: D.str(threshold), slippageBps: a.risk.slippageBps, list: results } as never)}, updated_at = now() where agent_id = ${a.id} and bar = ${new Date(bar)}`;435  await sql`insert into agent_equity (agent_id, ts, mode, equity_usd, positions) values (${a.id}, ${new Date(bar)}, ${live ? 'LIVE' : 'PAPER'}, ${D.toNumber(eqAfter)}, ${sql.json(Object.fromEntries(positions.map((p) => [symbolOf(p.instrument), D.str(signed(p))])))})436    on conflict (agent_id, ts, mode) do update set equity_usd = excluded.equity_usd, positions = excluded.positions`;437  await saveBook(a, book);438  await complete();439  if (placed) await audit('agent.perp_orders', 'engine', { agent: a.id, bar: new Date(bar).toISOString(), orders: results.length, placed, sha256: sv.code_sha256 });440  return placed ? `${placed} order(s)${sentNow > placed ? `, ${sentNow - placed} not sent` : ''}` : plan.length ? `${sentNow} order(s) not sent` : 'portfolio already at target';441}442
Files main
Menu

Theme