SPB Git

Repository Public

perp-exec-live.ts 16.6 KB 219 lines typescript

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

Theme