Repository Public
1// The LIVE implementation of `PerpExecutor` (perp-engine.ts): a venue account (Hyperliquid, testnet or mainnet) whose orders2// the isolated signer signs under a derivative mandate the owner approved with ONE passkey (docs/v6/DERIVATIVE-MANDATE.md).3// It reaches live trading only through the narrow server-side interface of derivatives/mandate.ts: it holds no key, cannot4// widen anything, and every order is checked by the API's risk engine against the venue's real state and then by the signer5// against the mandate (positions and exposure included, from the signer's OWN read of the venue).6//7// What this executor guarantees to the engine:8// - VENUE TRUTH. `account()` and `positions()` are the venue's own account state and positions (`source: 'venue'`). There is9// no local model of the account: an unreadable venue throws (the engine retries the pass), it is never shown as empty.10// - CONFIRMED RESULTS. `placed` is returned only when the venue's own records confirm the order (filled, partly filled with11// the remainder cancelled, or cancelled). An order the venue has not confirmed (`unknown`, signed but not acknowledged…)12// is `deferred`, and NOTHING more is sent on that instrument until the venue resolves it.13// - IDEMPOTENCE. The engine's client id is the idempotency key of the live module: the same key never places a second order —14// after a crash between signing and reconciliation the stored order is looked up on the venue by its client order id.15// - NO HAMMERING. After any order reached the signer, an order that can add exposure waits for the next pass (the signer16// counts a signed order as exposure until its venue expiry + grace, ≈ 40 s): at most one exposure-adding order per pass and17// account. A signer or risk refusal is returned as `refused` — the engine holds that instrument until the next bar, and the18// refused order is stored under its key, so it cannot be retried in a loop.19// - LEVERAGE. Before an order that can add exposure, the venue's leverage setting of the instrument is read and, when it20// differs from the agent's policy (never above the mandate's), set through `setMandateLeverage` — once, not per order.21// - FLOOR. No exposure is added once the account value is within a margin of the mandate's `floorEquity` (well before the22// signer would refuse); reduce-only orders always pass.23// - PER-ORDER BOUND. The mandate bounds EVERY order by its notional, closes included: a larger order is cut to the bound and24// reported as partly done — the engine continues from the real position.25// - THE MANDATE IS OVER. A refusal that means so (revoked, expired, wind-down, authorization gone, server lock, halt) is26// recorded in `exit`: perp-live-cycle.ts then takes the agent out of LIVE.27import { D, type DecStr, type Order, type OrderRequest, type Position } from '@spw/derivatives';28import { UserError } from '../config.ts';29import { sql } from '../db.ts';30import type { LiveService } from '../derivatives/exec.ts';31import { mandateAccountState, mandateOrder, placeMandateOrder, setMandateLeverage, type MandateAccountState, type MandateLiveOrder } from '../derivatives/mandate.ts';32import { perpPolicy, type ExecAccount, type PerpExecutor, type PerpRisk, type PlaceResult } from './perp-engine.ts';3334/** after an order reached the signer, an order that can add exposure waits this long: the signer's ledger keeps a signed order35 * until its venue expiry (30 s for mandate orders) + 10 s of grace; 5 s more for clocks */36export const LIVE_COOLDOWN_MS = 45_000;37/** share of the loss budget (reference capital − floor) kept as a margin above the mandate's floor: below it no exposure is added */38export const FLOOR_GUARD_FRACTION = '0.25';39/** an order is cut to this share of the mandate's per-order notional (its price can move between here and the signer) */40const ORDER_CAP_HEADROOM = '0.99';4142/** refusals that mean "this mandate will not sign new exposure again": the agent leaves LIVE */43export const LIVE_EXIT_CODES: ReadonlySet<string> = new Set([44 'signer_revoked', 'signer_expired', 'signer_wind_down', 'signer_no_authorization', 'signer_not_found', 'signer_halted',45 'mandate_mainnet_disabled', 'mainnet_disabled', 'mandate_none', 'mandate_wind_down', 'mandate_mismatch', 'mandate_unavailable',46 'mode_read_only', 'authorization_expired', 'halted',47]);48/** states of a live order that the venue's own records confirm */49const CONFIRMED = new Set(['filled', 'partially_filled', 'cancelled']);5051export interface LiveExecOptions {52 accountId: string; agentId: string; strategySha256: string; symbols: string[]; risk: PerpRisk;53 /** reference capital (the venue's account value at go-live): the floor guard is measured against it */54 capital: DecStr;55}56export interface LiveHold { instrument: string; code: string; reason: string; kind: 'refused' | 'deferred' }5758const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e)).slice(0, 300);5960export class LivePerpExecutor implements PerpExecutor {61 readonly kind = 'live' as const;62 private readonly svc: LiveService;63 private readonly o: LiveExecOptions;64 private snap: MandateAccountState | null = null;65 private dirty = true;66 /** instrument → client id of an order of this agent that the venue has not confirmed yet */67 private readonly pending = new Map<string, string>();68 private reconciled = false;69 /** a refusal that means the mandate is over (first one wins) */70 exit: { code: string; reason: string } | null = null;71 /** what was refused or deferred during this pass */72 readonly holds: LiveHold[] = [];73 /** requests of this pass that reached the mandate helpers (tests, diagnostics) */74 readonly stats = { orders: 0, leverage: 0 };7576 constructor(svc: LiveService, o: LiveExecOptions) { this.svc = svc; this.o = o; }7778 /** the venue's own state, read now; an unreadable venue is an error, never an empty account */79 private async read(): Promise<MandateAccountState> {80 const s = await mandateAccountState(this.o.accountId, this.svc);81 if (s.error !== null || s.state === null) throw new UserError('venue_unavailable', `the venue account could not be read: ${s.error ?? 'no state'}`, 503);82 this.snap = s; this.dirty = false;83 return s;84 }85 private async state(): Promise<MandateAccountState> { return this.dirty || !this.snap ? this.read() : this.snap; }8687 /** every order of this agent that is not final is looked up on the venue (the only way out of `unknown`) */88 private async reconcilePending(): Promise<void> {89 const rows = await sql<{ client_id: string; instrument: string }[]>`select client_id, instrument from deriv_orders90 where account_id = ${this.o.accountId} and origin = 'mandate' and agent->>'agentId' = ${this.o.agentId} and state is not null and not final order by created_at`;91 this.pending.clear();92 for (const r of rows) {93 const o = await mandateOrder(this.o.accountId, r.client_id, this.svc);94 if (o && !o.final && !CONFIRMED.has(o.state)) this.pending.set(r.instrument, r.client_id);95 }96 this.reconciled = true;97 if (rows.length) this.dirty = true;98 }99100 async account(_sinceEventId = 0): Promise<ExecAccount> {101 if (!this.reconciled) await this.reconcilePending();102 const s = await this.read();103 // The circuit breakers of a venue account (owner halt, divergence) and its liquidations are handled by the live life cycle104 // BEFORE the engine runs (perp-live-cycle.ts): the engine sees the venue's state and the agent's own limits.105 return { state: s.state!, policy: { ...perpPolicy(this.o.symbols, this.o.capital, this.o.risk, null), mode: 'MANDATE_LIVE' as never }, events: [] };106 }107108 async positions(): Promise<Position[]> { return (await this.state()).positions; }109110 /** instruments on which an order of this agent is still unconfirmed by the venue */111 unconfirmed(): string[] { return [...this.pending.keys()]; }112113 private refuse(instrument: string, code: string, reason: string): PlaceResult {114 if (LIVE_EXIT_CODES.has(code)) this.exit ??= { code, reason };115 this.holds.push({ instrument, code, reason, kind: 'refused' });116 return { status: 'refused', code, reason };117 }118 private defer(instrument: string, code: string, reason: string): PlaceResult {119 this.holds.push({ instrument, code, reason, kind: 'deferred' });120 return { status: 'deferred', code, reason };121 }122 /** a thrown refusal: 5xx = "could not be attempted now" (thrown on: the engine retries the pass); anything else is a refusal */123 private refused(instrument: string, e: unknown): PlaceResult {124 if (!(e instanceof UserError)) throw e;125 if (e.status >= 500 && !LIVE_EXIT_CODES.has(e.code)) throw e;126 return this.refuse(instrument, e.code, e.message);127 }128129 /** the engine's answer for a stored live order, from the VENUE's view of it */130 private result(req: OrderRequest, wanted: DecStr, o: MandateLiveOrder, idempotent: boolean): PlaceResult {131 const order = (status: Order['status'], rejectReason: string | null): Order => ({ ...req, size: wanted, price: o.price, venueOrderId: o.venueOrderId, status, filled: o.filled, avgFillPrice: o.avgFillPrice,132 createdAt: o.createdAt, updatedAt: o.updatedAt, rejectReason });133 const cut = D.lt(D.from(o.size), D.from(wanted));134 if (o.state === 'filled') {135 return { status: 'placed', idempotent, fills: [], order: cut ? order('partially_filled', `cut to the mandate's notional per order: ${o.size} of ${wanted} sent and filled; the rest follows from the real position`) : order('filled', null) };136 }137 if (o.state === 'partially_filled' && (o.final || o.source === 'venue')) return { status: 'placed', idempotent, fills: [], order: order('partially_filled', o.reason ?? 'remainder not filled') };138 if (o.state === 'cancelled' && o.venueOrderId) return { status: 'placed', idempotent, fills: [], order: order('cancelled', o.reason ?? 'not filled') };139 if (o.state === 'cancelled') return this.refuse(req.instrument, 'withdrawn', o.reason ?? 'withdrawn before it was signed');140 if (o.state === 'rejected') {141 const why = o.reason ?? 'rejected';142 const code = /^never placed/.test(why) ? 'never_placed' : /^(refused by the venue|not sent)/.test(why) ? 'venue_rejected' : /^not signed/.test(why) ? 'not_signed' : (/^([a-z_]{3,40}): /.exec(why)?.[1] ?? 'rejected');143 // a stored refusal answered again is not a new fact: it holds the instrument until the next bar, it does not end the mandate144 this.holds.push({ instrument: req.instrument, code, reason: why, kind: 'refused' });145 return { status: 'refused', code, reason: why };146 }147 // signed / submitted / unknown / open: the venue has not confirmed anything yet148 this.pending.set(req.instrument, o.clientId);149 return this.defer(req.instrument, 'live_unknown_pending', `order ${o.clientId} is ${o.state}: ${o.reason ?? 'the venue has not confirmed it yet'}; nothing more is sent on ${req.instrument} until the venue resolves it`);150 }151152 /** the venue's leverage setting of the instrument, brought to the agent's policy (≤ the mandate's) when it differs */153 private async ensureLeverage(s: MandateAccountState, instrument: string, leverage: DecStr): Promise<PlaceResult | null> {154 const symbol = instrument.slice(instrument.indexOf(':') + 1), mi = s.mandate!.limits.instruments.find((i) => i.instrument === instrument);155 if (!mi) return this.refuse(instrument, 'instrument_not_allowed', `${instrument} is not in the mandate`);156 const want = Math.max(1, Math.min(Number(leverage), mi.maxLeverage));157 const cur = await this.svc.deps.venues.get(s.network).leverageOf(s.account, symbol);158 // the signer never changes the margin mode of an open position: keep the position's own mode159 const mode = s.positions.find((p) => p.instrument === instrument)?.marginMode ?? 'cross';160 if (cur.leverage === String(want) && cur.marginMode === mode) return null;161 this.stats.leverage += 1;162 const r = await setMandateLeverage(this.o.accountId, { instrument, leverage: want, marginMode: mode }, { strategySha256: this.o.strategySha256, agentId: this.o.agentId }, this.svc);163 if (r.venue.confirmed) return null;164 return this.refuse(instrument, 'leverage_not_set', `the venue did not confirm ${want}x ${mode} on ${symbol} (it reports ${r.venue.leverage ? `${r.venue.leverage.leverage}x ${r.venue.leverage.marginMode}` : 'nothing'}; ${r.action.error ?? r.action.state}): no order that can add exposure is sent on it`);165 }166167 async place(req: OrderRequest, leverage: DecStr): Promise<PlaceResult> {168 const ctx = { strategySha256: this.o.strategySha256, agentId: this.o.agentId, clientId: req.clientId }, instrument = req.instrument;169 if (req.price === null) return this.refuse(instrument, 'bad_request', 'a live order names its worst acceptable price');170 if (!this.reconciled) await this.reconcilePending();171 // 0 — this key already has an order: its state on the venue is the answer, whatever is asked now (never a second order)172 const prior = await mandateOrder(this.o.accountId, req.clientId, this.svc);173 if (prior) { this.dirty = true; return this.result(req, req.size, prior, true); }174 // 1 — an earlier order on this instrument that the venue has not confirmed: nothing more on it until it resolves175 const waiting = this.pending.get(instrument);176 if (waiting) return this.defer(instrument, 'live_unknown_pending', `order ${waiting} on ${instrument} is still being checked on the venue; nothing more is sent on this instrument until it resolves`);177 let s: MandateAccountState;178 try { s = await this.state(); } catch (e) { return this.refused(instrument, e); }179 const m = s.mandate;180 if (s.blocked) return this.refuse(instrument, s.blocked.code, s.blocked.message);181 if (!m || m.phase === 'ended') return this.refuse(instrument, 'mandate_none', 'no derivative mandate is in force on this venue account');182 let size = req.size;183 try {184 if (!req.reduceOnly) {185 if (m.phase !== 'active') return this.refuse(instrument, 'mandate_wind_down', 'the mandate is being wound down: only reduce-only orders are placed');186 if (s.halted) return this.refuse(instrument, 'halted', `trading is halted on the venue account: ${s.halted}`);187 // 2 — the signer still counts the last signed order: wait for the next pass before adding exposure188 const since = new Date(this.svc.now() - LIVE_COOLDOWN_MS);189 const [recent] = await sql<{ client_id: string }[]>`select client_id from deriv_orders where account_id = ${this.o.accountId} and origin = 'mandate' and signature is not null190 and created_at > ${since} and (not reduce_only or instrument = ${instrument}) order by created_at desc limit 1`;191 if (recent) return this.defer(instrument, 'live_cooldown', `order ${recent.client_id} reached the signer less than ${LIVE_COOLDOWN_MS / 1000} s ago, and the signer counts a signed order as exposure until it has expired: an order that can add exposure waits for the next pass`);192 // 3 — the mandate's floor on the account value: stop adding exposure well before the signer refuses193 const floor = D.from(m.limits.floorEquity), budget = D.max(D.zero, D.sub(D.from(this.o.capital), floor)), guard = D.add(floor, D.mul(budget, D.from(FLOOR_GUARD_FRACTION)));194 if (D.lt(D.from(s.state!.equity), guard)) {195 return this.refuse(instrument, 'floor_guard', `account value ${s.state!.equity} is within ${Number(FLOOR_GUARD_FRACTION) * 100} % of the loss budget above the mandate's floor ${m.limits.floorEquity} (guard ${D.str(D.quantize(guard, D.from('0.01'), 'up'))}): no exposure is added; reduce-only orders still pass`);196 }197 // 4 — the venue's leverage setting, once198 const lev = await this.ensureLeverage(s, instrument, leverage);199 if (lev) return lev;200 }201 // 5 — the mandate bounds every order by its notional, closes included: cut, never refuse a close for its size202 const cap = D.mul(D.from(m.limits.maxOrderNotional), D.from(ORDER_CAP_HEADROOM)), price = D.from(req.price);203 if (D.gt(D.mul(D.from(size), price), cap)) {204 const inst = await this.svc.deps.venues.get(s.network).instrument(instrument);205 const fit = D.quantize(D.div(cap, price, 18, 'down'), D.from(inst.lotSize), 'down');206 if (!D.isPos(fit)) return this.refuse(instrument, 'order_notional_exceeded', `the mandate's notional per order (${m.limits.maxOrderNotional}) is below one lot of ${instrument}`);207 size = D.str(fit);208 }209 this.stats.orders += 1; this.dirty = true;210 const r = await placeMandateOrder(this.o.accountId, { ...req, size }, ctx, this.svc);211 return this.result(req, req.size, r.order, r.idempotent);212 } catch (e) {213 this.dirty = true;214 if (!(e instanceof UserError)) throw new Error(`live order ${req.clientId} could not be attempted: ${msg(e)}`);215 return this.refused(instrument, e);216 }217 }218}219