SPB Git

Repository Public

paper.ts 45.0 KB 613 lines typescript

1// Paper trading accounts (service). The simulated account itself is the paper engine of @spw/derivatives; this file stores it,2// feeds it live venue data, runs the risk engine in front of it and mirrors what happens into queryable tables.3//4// Storage: `deriv_paper_accounts.state` is the engine's serialised state (the truth of the account), `policy` the risk policy,5// `meta` the service's own bookkeeping (funding cursors, margin-warning flag, last equity minute). Orders, fills, funding6// payments, risk events and equity snapshots are copied to their tables in the SAME transaction as the state, under a row lock7// on the account, so a fill or a funding payment exists exactly once whatever is retried or restarted.8//9// Nothing here signs, calls the signer or talks to a venue's trading endpoint: the only venue calls are public reads through10// `DerivData`.11import { randomUUID } from 'node:crypto';12import { isDeepStrictEqual } from 'node:util';13import type { TransactionSql } from 'postgres';14import { z } from 'zod';15import {16  checkOrder, D, DecStrSchema, hlFormatPrice, hlQuantizePrice, initialMargin, freeCollateralAfterOrder, OrderSideSchema, OrderTypeSchema, PaperAccount, RiskPolicySchema, TifSchema,17  type AccountState, type Book, type Dec, type DecStr, type Fill, type FundingPayment, type Instrument, type Order, type OrderRequest, type PaperResult, type PaperState,18  type Position, type RiskPolicy, type RiskResult, type RiskUsage, type Ticker, type VenueId,19} from '@spw/derivatives';20import { UserError } from '../config.ts';21import { audit, sql } from '../db.ts';22import type { DerivData } from './data.ts';2324type Tx = TransactionSql;25const BPS = D.from(10000n);2627/** default slippage bound of `close` and of a market order sent without a price, in basis points from the best opposite price */28export const DEFAULT_SLIPPAGE_BPS = 50;29export const MAX_SLIPPAGE_BPS = 500;30/** an order is checked and filled on a ticker and a book read at most one second ago, never on an older cached value */31const LIVE = { within: 1_000 };32export const MAX_PAPER_ACCOUNTS = 20;33/** flat maintenance margin fraction of a Hyperliquid paper account (the engine has one fraction per account): ETH's 2 %, cautious for BTC (1.25 %) */34export const PAPER_MAINTENANCE_FRACTION = '0.02';35/** margin warning: maintenance margin ≥ 80 % of equity */36export const MARGIN_WARNING_RATIO = '0.8';37/** a funding time whose rate the venue has still not published after this long is skipped, with a risk event */38export const FUNDING_GRACE_MS = 15 * 60_000;39/** closed orders, fills and funding payments kept inside the engine state (the tables keep everything) */40const KEEP = { closedOrders: 200, fills: 500, funding: 500 };4142export interface Meta { fundingCursor: Record<string, number>; marginWarning: boolean; lastEquityMinute: number }43interface AccountRow { id: string; venue: VenueId; network: string; label: string; initial_collateral: string; state: PaperState; policy: RiskPolicy; meta: Partial<Meta>; created_at: Date; updated_at: Date }44interface RiskEvent { kind: string; code: string | null; message: string; data: Record<string, unknown>; dedupe: string | null; ts: number }45interface Loaded { row: AccountRow; acct: PaperAccount; policy: RiskPolicy; meta: Meta; events: RiskEvent[]; audits: { type: string; actor: string; data: Record<string, unknown> }[] }46export interface MarketFeed { inst: Instrument; ticker: Ticker; book: Book | null; fundingRates: Map<number, DecStr> | null }4748const PosDec = DecStrSchema.refine((x) => !x.startsWith('-') && /[1-9]/.test(x), 'must be positive');49/** a refinement on the decimal value; false (never a throw) when the string is not a decimal at all */50const dec = (test: (x: Dec) => boolean) => (x: string): boolean => D.isValid(x) && test(D.from(x));51export const OrderBodySchema = z.strictObject({52  instrument: z.string().min(3).max(120), side: OrderSideSchema, type: OrderTypeSchema, size: PosDec,53  price: PosDec.nullable().default(null), triggerPrice: PosDec.nullable().default(null),54  reduceOnly: z.boolean().default(false), tif: TifSchema.default('gtc'),55  /** leverage setting of this instrument on the account (initial margin = notional / leverage) */56  leverage: PosDec.optional(),57  /** market orders only: when `price` is null the worst acceptable price is derived from the book with this bound */58  slippageBps: z.number().int().min(1).max(MAX_SLIPPAGE_BPS).optional(),59});60export type OrderBody = z.infer<typeof OrderBodySchema>;61export const CloseBodySchema = z.strictObject({62  instrument: z.string().min(3).max(120),63  fraction: PosDec.refine(dec((x) => D.lte(x, D.one)), 'fraction must be in (0, 1]'),64  slippageBps: z.number().int().min(1).max(MAX_SLIPPAGE_BPS).optional(),65});66export const CreateBodySchema = z.strictObject({67  venue: z.string().min(1).max(40), label: z.string().trim().min(1).max(60),68  collateral: PosDec.refine(dec((x) => D.gte(x, D.from(10n)) && D.lte(x, D.from(1_000_000_000n))), 'collateral must be between 10 and 1000000000'),69  policy: RiskPolicySchema.partial().strict().optional(),70});7172export interface OrderEstimate {73  notional: DecStr; initialMargin: DecStr; fee: DecStr; avgFillPrice: DecStr | null; slippageBps: DecStr | null;74  liquidationPrice: { value: DecStr | null; source: 'estimate'; method: string } | null; freeCollateralAfter: DecStr;75  filledSize: DecStr; restingSize: DecStr; status: Order['status']; rejectReason: string | null; leverage: DecStr; price: DecStr | null; source: 'estimate';76}7778/** conservative, explicit default: every limit is stated and proportional to the collateral; the owner can replace it */79export function defaultPolicy(venue: VenueId, collateral: DecStr): RiskPolicy {80  const c = D.from(collateral), times = (k: string): DecStr => D.str(D.mul(c, D.from(k)));81  return {82    mode: 'PAPER', allowedInstruments: [`${venue}:BTC`, `${venue}:ETH`], halt: null,83    maxDataAgeMs: 10_000, maxDeviationBps: '200', maxMarkIndexBps: '100', maxLeverage: '3',84    maxOrderNotional: times('1'), maxGrossExposure: times('3'), maxPositionNotional: times('2'),85    minFreeCollateral: times('0.1'), minLiquidationDistanceBps: '1000',86    maxDailyLoss: times('0.05'), maxTotalLoss: times('0.2'), maxOrdersPerMinute: 10, maxOrdersPerDay: 200,87  };88}8990function assertPaperPolicy(p: RiskPolicy): RiskPolicy {91  if (p.mode !== 'PAPER' && p.mode !== 'READ_ONLY') throw new UserError('live_not_available', 'a paper account can only be PAPER or READ_ONLY: live trading does not exist in this delivery', 422);92  return p;93}9495const canon = (x: string): DecStr => D.str(D.from(x));96const canonN = (x: string | null): DecStr | null => (x === null ? null : canon(x));97const dayStart = (now: number): number => Math.floor(now / 86_400_000) * 86_400_000;98const isOpen = (o: Order): boolean => o.status === 'pending' || o.status === 'triggered' || o.status === 'open' || o.status === 'partially_filled';99const decimalsOf = (step: DecStr): number => { const i = step.indexOf('.'); return i < 0 ? 0 : step.length - i - 1; };100101/** venue price rules beyond the instrument's finest tick (Hyperliquid: 5 significant figures) */102function priceRules(inst: Instrument): { check: (price: DecStr) => string | null; quantize: (price: Dec, mode: 'down' | 'up') => DecStr } {103  if (inst.venue === 'hyperliquid') {104    const sz = decimalsOf(inst.lotSize);105    return {106      check: (p) => { const r = hlFormatPrice(canon(p), sz); return r.ok ? null : r.reason; },107      quantize: (p, mode) => {108        // the venue's grid needs at most 6 − szDecimals decimals: cut there first, then onto the grid at that price109        const pre = D.str(D.quantize(p, D.from(inst.tickSize), mode));110        return hlQuantizePrice(pre, sz, mode);111      },112    };113  }114  return { check: () => null, quantize: (p, mode) => D.str(D.quantize(p, D.from(inst.tickSize), mode)) };115}116117/** worst acceptable price of a taker order: the best opposite price moved by `bps`, onto the venue's price grid on the cautious side */118export function worstPrice(inst: Instrument, book: Book, side: 'buy' | 'sell', bps: number): DecStr {119  const best = side === 'buy' ? book.asks[0] : book.bids[0];120  if (!best) throw new UserError('no_liquidity', `the ${inst.venue} book of ${inst.symbol} has no ${side === 'buy' ? 'ask' : 'bid'}: no price to derive`, 503);121  const k = D.from(BigInt(bps)), px = D.from(best[0]);122  const raw = side === 'buy' ? D.div(D.mul(px, D.add(BPS, k)), BPS, 18, 'down') : D.div(D.mul(px, D.sub(BPS, k)), BPS, 18, 'up');123  // rounding stays inside the bound: a buy never pays above it, a sell never accepts below it124  const q = priceRules(inst).quantize(raw, side === 'buy' ? 'down' : 'up');125  if (!D.isPos(D.from(q))) throw new UserError('no_liquidity', 'no positive price can be derived from the book', 503);126  return q;127}128129function compact(state: PaperState): void {130  const closed = state.orders.filter((o) => !isOpen(o));131  if (closed.length > KEEP.closedOrders) {132    const drop = new Set(closed.slice(0, closed.length - KEEP.closedOrders).map((o) => o.venueOrderId));133    state.orders = state.orders.filter((o) => !drop.has(o.venueOrderId));134  }135  if (state.fills.length > KEEP.fills) state.fills = state.fills.slice(-KEEP.fills);136  if (state.funding.length > KEEP.funding) state.funding = state.funding.slice(-KEEP.funding);137}138139export class PaperService {140  readonly data: DerivData;141  readonly now: () => number;142  constructor(data: DerivData, now: () => number = Date.now) { this.data = data; this.now = now; }143144  // ------------------------------------------------------------------ storage145146  private load(row: AccountRow): Loaded {147    return { row, acct: PaperAccount.restore(row.state), policy: RiskPolicySchema.parse(row.policy),148      meta: { fundingCursor: { ...(row.meta.fundingCursor ?? {}) }, marginWarning: row.meta.marginWarning === true, lastEquityMinute: row.meta.lastEquityMinute ?? 0 }, events: [], audits: [] };149  }150151  /** one transaction, the account row locked: load, run `fn`, write back whatever changed, then (after commit) the audit events */152  private async mutate<T>(id: string, fn: (l: Loaded, tx: Tx) => Promise<T>): Promise<T> {153    let audits: Loaded['audits'] = [];154    const out = await sql.begin(async (tx) => {155      const [row] = await tx<AccountRow[]>`select * from deriv_paper_accounts where id = ${id} for update`;156      if (!row) throw new UserError('unknown_account', 'unknown derivatives account', 404);157      const l = this.load(row);158      const result = await fn(l, tx);159      compact(l.acct.state);160      const state = JSON.parse(l.acct.serialize()) as PaperState;161      if (!isDeepStrictEqual(state, row.state) || !isDeepStrictEqual(l.policy, row.policy) || !isDeepStrictEqual(l.meta, row.meta)) {162        await tx`update deriv_paper_accounts set state = ${tx.json(state as never)}, policy = ${tx.json(l.policy as never)}, meta = ${tx.json(l.meta as never)}, updated_at = ${new Date(this.now())} where id = ${id}`;163      }164      for (const e of l.events) {165        await tx`insert into deriv_risk_events (account_id, ts, kind, code, message, data, dedupe) values (${id}, ${new Date(e.ts)}, ${e.kind}, ${e.code}, ${e.message}, ${tx.json(e.data as never)}, ${e.dedupe})166          on conflict (account_id, dedupe) do nothing`;167      }168      audits = l.audits;169      return result;170    }) as T;171    for (const a of audits) await audit(a.type, a.actor, a.data).catch((e: Error) => console.error(`[derivatives] audit ${a.type} failed: ${e.message}`));172    return out;173  }174175  /** copies what an engine call produced into the tables (idempotent: fills and funding are keyed by their engine ids) */176  private async record(tx: Tx, l: Loaded, res: PaperResult, origin: 'owner' | 'worker'): Promise<void> {177    const id = l.row.id, orders = new Map<string, Order>();178    for (const o of res.orders) orders.set(o.clientId, o);179    if (res.order) orders.set(res.order.clientId, res.order);180    for (const o of orders.values()) {181      await tx`insert into deriv_orders (account_id, client_id, venue_order_id, instrument, side, type, size, price, trigger_price, reduce_only, tif, status, filled, avg_fill_price, reject_reason, origin, created_at, updated_at)182        values (${id}, ${o.clientId}, ${o.venueOrderId}, ${o.instrument}, ${o.side}, ${o.type}, ${o.size}, ${o.price}, ${o.triggerPrice}, ${o.reduceOnly}, ${o.tif}, ${o.status}, ${o.filled}, ${o.avgFillPrice},183          ${o.rejectReason}, ${origin}, ${new Date(o.createdAt)}, ${new Date(o.updatedAt)})184        on conflict (account_id, client_id) do update set status = excluded.status, filled = excluded.filled, avg_fill_price = excluded.avg_fill_price, reject_reason = excluded.reject_reason, updated_at = excluded.updated_at`;185    }186    for (const f of res.fills) {187      await tx`insert into deriv_fills (account_id, fill_id, instrument, order_id, client_id, side, price, size, fee, fee_asset, closed_pnl, liquidation, ts)188        values (${id}, ${f.id}, ${f.instrument}, ${f.orderId}, ${f.clientId}, ${f.side}, ${f.price}, ${f.size}, ${f.fee}, ${f.feeAsset}, ${f.closedPnl}, ${f.liquidation}, ${new Date(f.ts)})189        on conflict (account_id, fill_id) do nothing`;190    }191    for (const p of res.funding) {192      await tx`insert into deriv_funding (account_id, instrument, ts, amount, rate, size) values (${id}, ${p.instrument}, ${new Date(p.ts)}, ${p.amount}, ${p.rate}, ${p.size})193        on conflict (account_id, instrument, ts) do nothing`;194    }195    if (res.liquidated) {196      const fills = res.fills.filter((f) => f.liquidation), ts = fills[0]?.ts ?? this.now();197      const message = `simulated liquidation: equity fell below the maintenance margin; ${fills.length} position(s) closed at the mark, open orders cancelled`;198      l.events.push({ kind: 'liquidation', code: 'liquidated', message, ts, dedupe: `liquidation:${l.acct.state.liquidations}:${ts}`,199        data: { fills: fills.map((f) => ({ id: f.id, instrument: f.instrument, side: f.side, size: f.size, price: f.price, closedPnl: f.closedPnl })), collateralAfter: l.acct.state.collateral } });200      l.audits.push({ type: 'deriv.paper.liquidation', actor: 'paper-engine', data: { account: id, positions: fills.length, collateralAfter: l.acct.state.collateral } });201    }202  }203204  private async usage(tx: Tx, id: string, acct: PaperAccount, now: number): Promise<RiskUsage> {205    const day = new Date(dayStart(now));206    const [f] = await tx<{ pnl: string }[]>`select coalesce(sum(coalesce(closed_pnl, 0) - fee), 0)::text as pnl from deriv_fills where account_id = ${id} and ts >= ${day}`;207    const [g] = await tx<{ amount: string }[]>`select coalesce(sum(amount), 0)::text as amount from deriv_funding where account_id = ${id} and ts >= ${day}`;208    const [c] = await tx<{ minute: number; today: number }[]>`select count(*) filter (where created_at > ${new Date(now - 60_000)})::int as minute, count(*) filter (where created_at >= ${day})::int as today209      from deriv_orders where account_id = ${id}`;210    const s = acct.state;211    return {212      realizedToday: D.str(D.add(D.from(f!.pnl), D.from(g!.amount))),213      realizedTotal: D.str(D.add(D.sub(D.from(s.realizedPnl), D.from(s.feesPaid)), D.from(s.fundingNet))),214      ordersLastMinute: c!.minute, ordersToday: c!.today, clientIds: [],215    };216  }217218  /** funding cursors follow the positions: a new position owes nothing for the funding time already passed; a closed one owes nothing more */219  private syncCursors(l: Loaded, before: Set<string>, intervals: Map<string, number>, now: number): void {220    const after = new Set(l.acct.state.positions.map((p) => p.instrument));221    for (const k of Object.keys(l.meta.fundingCursor)) if (!after.has(k)) delete l.meta.fundingCursor[k];222    for (const k of after) {223      if (before.has(k) && l.meta.fundingCursor[k] !== undefined) continue;224      const iv = (intervals.get(k) ?? 3600) * 1000;225      l.meta.fundingCursor[k] = Math.floor(now / iv) * iv;226    }227  }228229  private async snapshotEquity(tx: Tx, l: Loaded, now: number): Promise<void> {230    const minute = Math.floor(now / 60_000) * 60_000, a = l.acct.accountState(now);231    const unrealized = D.str(D.sub(D.from(a.equity), D.from(a.collateral)));232    await tx`insert into deriv_equity (account_id, ts, equity, collateral, margin_used, unrealized_pnl) values (${l.row.id}, ${new Date(minute)}, ${a.equity}, ${a.collateral}, ${a.marginUsed}, ${unrealized})233      on conflict (account_id, ts) do update set equity = excluded.equity, collateral = excluded.collateral, margin_used = excluded.margin_used, unrealized_pnl = excluded.unrealized_pnl`;234    l.meta.lastEquityMinute = minute;235  }236237  // ------------------------------------------------------------------ views238239  private view(row: AccountRow, acct: PaperAccount, policy: RiskPolicy) {240    const ts = row.updated_at.getTime(), s = acct.state;241    return {242      id: row.id, venue: row.venue, network: row.network, kind: 'paper' as const, mode: policy.mode, label: row.label, policy,243      // figures at the marks of the last update (`state.ts`): the worker refreshes them every few seconds while the account has exposure244      state: acct.accountState(ts) as AccountState, positions: acct.positions() as Position[], openOrders: acct.openOrders(),245      initialCollateral: canon(row.initial_collateral),246      settings: { leverage: s.config.leverage, leverageByInstrument: s.config.leverageByInstrument, takerFeeRate: s.config.takerFeeRate, makerFeeRate: s.config.makerFeeRate,247        maintenanceMarginFraction: s.config.maintenanceMarginFraction, liquidationPrice: 'estimate (cross, single-position approximation)' },248      totals: { realizedPnl: s.realizedPnl, feesPaid: s.feesPaid, fundingNet: s.fundingNet, liquidations: s.liquidations },249      createdAt: row.created_at.getTime(), updatedAt: ts,250    };251  }252253  async list() {254    const rows = await sql<AccountRow[]>`select * from deriv_paper_accounts order by created_at`;255    return rows.map((r) => this.view(r, PaperAccount.restore(r.state), RiskPolicySchema.parse(r.policy)));256  }257258  async get(id: string) {259    const [row] = await sql<AccountRow[]>`select * from deriv_paper_accounts where id = ${id}`;260    if (!row) throw new UserError('unknown_account', 'unknown derivatives account', 404);261    const [orders, fills, funding, events, equity] = await Promise.all([262      sql`select * from deriv_orders where account_id = ${id} order by created_at desc, venue_order_id desc limit 100`,263      sql`select * from deriv_fills where account_id = ${id} order by ts desc, fill_id desc limit 100`,264      sql`select * from deriv_funding where account_id = ${id} order by ts desc limit 100`,265      sql`select * from deriv_risk_events where account_id = ${id} order by ts desc, id desc limit 100`,266      sql`select * from (select * from deriv_equity where account_id = ${id} order by ts desc limit 1500) e order by ts`,267    ]);268    return {269      ...this.view(row, PaperAccount.restore(row.state), RiskPolicySchema.parse(row.policy)),270      orders: orders.map(orderOf),271      fills: fills.map((f): Fill => ({ instrument: f.instrument, orderId: f.order_id, clientId: f.client_id, side: f.side, price: canon(f.price), size: canon(f.size), fee: canon(f.fee), feeAsset: f.fee_asset,272        closedPnl: canonN(f.closed_pnl), ts: f.ts.getTime(), id: f.fill_id, liquidation: f.liquidation })),273      funding: funding.map((f): FundingPayment => ({ instrument: f.instrument, amount: canon(f.amount), rate: canon(f.rate), size: canon(f.size), ts: f.ts.getTime() })),274      riskEvents: events.map((e) => ({ id: Number(e.id), ts: e.ts.getTime(), kind: e.kind as string, code: e.code as string | null, message: e.message as string, data: e.data as Record<string, unknown> })),275      equity: equity.map((e) => ({ ts: e.ts.getTime(), equity: canon(e.equity), collateral: canon(e.collateral), marginUsed: canon(e.margin_used), unrealizedPnl: canon(e.unrealized_pnl), source: 'paper' as const })),276    };277  }278279  // ------------------------------------------------------------------ commands280281  async create(body: z.infer<typeof CreateBodySchema>) {282    const entry = this.data.registry.get(body.venue);283    if (entry.restriction !== null) throw new UserError('venue_restricted', entry.restriction, 403);284    if (!entry.facts.paperFees || !entry.facts.hasOrderBook) throw new UserError('paper_unsupported', `${entry.name} has no order book: the paper engine fills against a live book, so no paper account is offered on this venue`, 422);285    const [n] = await sql<{ n: number }[]>`select count(*)::int as n from deriv_paper_accounts`;286    if (n!.n >= MAX_PAPER_ACCOUNTS) throw new UserError('too_many_accounts', `at most ${MAX_PAPER_ACCOUNTS} paper accounts`, 422);287    const collateral = canon(body.collateral), id = randomUUID(), now = this.now();288    const merged = RiskPolicySchema.safeParse({ ...defaultPolicy(entry.id, collateral), ...(body.policy ?? {}) });289    if (!merged.success) throw new UserError('bad_policy', 'invalid risk policy', 400);290    const policy = assertPaperPolicy(merged.data);291    const acct = PaperAccount.create({ venue: entry.id, account: `paper:${id}`, collateral, config: {292      takerFeeRate: entry.facts.paperFees.taker, makerFeeRate: entry.facts.paperFees.maker, liquidationFeeRate: '0', feeAsset: 'USDC',293      leverage: policy.maxLeverage, leverageByInstrument: {}, maintenanceMarginFraction: PAPER_MAINTENANCE_FRACTION } });294    const meta: Meta = { fundingCursor: {}, marginWarning: false, lastEquityMinute: 0 };295    await sql`insert into deriv_paper_accounts (id, venue, network, label, initial_collateral, state, policy, meta, created_at, updated_at)296      values (${id}, ${entry.id}, ${entry.adapter.network}, ${body.label}, ${collateral}, ${sql.json(JSON.parse(acct.serialize()) as never)}, ${sql.json(policy as never)}, ${sql.json(meta as never)}, ${new Date(now)}, ${new Date(now)})`;297    await this.mutate(id, async (l, tx) => { await this.snapshotEquity(tx, l, now); });298    await audit('deriv.paper.account_created', 'owner', { account: id, venue: entry.id, label: body.label, collateral });299    return this.get(id);300  }301302  async setPolicy(id: string, policy: RiskPolicy) {303    assertPaperPolicy(policy);304    await this.mutate(id, async (l) => {305      const was = l.policy;306      l.policy = policy;307      l.audits.push({ type: 'deriv.paper.policy_replaced', actor: 'owner', data: { account: id, mode: policy.mode, halt: policy.halt, maxLeverage: policy.maxLeverage, haltCleared: was.halt !== null && policy.halt === null } });308    });309    return this.get(id);310  }311312  async reset(id: string) {313    await this.mutate(id, async (l, tx) => {314      const s = l.acct.state, now = this.now();315      const fresh = PaperAccount.create({ venue: s.venue, account: s.account, collateral: canon(l.row.initial_collateral), config: { ...s.config, leverageByInstrument: {} } });316      await tx`delete from deriv_orders where account_id = ${id}`; await tx`delete from deriv_fills where account_id = ${id}`;317      await tx`delete from deriv_funding where account_id = ${id}`; await tx`delete from deriv_equity where account_id = ${id}`;318      l.acct = fresh; l.meta = { fundingCursor: {}, marginWarning: false, lastEquityMinute: 0 };319      // the losses that justified a halt are wiped with the account: the breaker is released, the limits themselves stay320      const hadHalt = l.policy.halt;321      l.policy = { ...l.policy, halt: null };322      l.events.push({ kind: 'reset', code: null, message: `paper account reset: positions, orders and history wiped, collateral restored to ${fresh.state.collateral}${hadHalt ? '; halt released' : ''}`, data: { haltReleased: hadHalt }, dedupe: null, ts: now });323      l.audits.push({ type: 'deriv.paper.reset', actor: 'owner', data: { account: id, collateral: fresh.state.collateral } });324      await this.snapshotEquity(tx, l, now);325    });326    return this.get(id);327  }328329  /** instrument, live ticker and live book of an order — read BEFORE the account is locked (no network inside a transaction) */330  private async live(l: { venue: VenueId }, instrumentId: string): Promise<{ inst: Instrument; ticker: Ticker; book: Book }> {331    const inst = await this.data.instrument(instrumentId);332    if (inst.venue !== l.venue) throw new UserError('instrument_mismatch', `this account is on ${l.venue}; ${inst.id} is on another venue`, 422);333    const [ticker, book] = await Promise.all([this.data.ticker(inst, LIVE), this.data.book(inst, LIVE)]);334    if (!book) throw new UserError('paper_unsupported', `${inst.venue} has no order book`, 422);335    return { inst, ticker, book };336  }337338  private async venueOf(id: string): Promise<{ venue: VenueId }> {339    const [row] = await sql<{ venue: VenueId }[]>`select venue from deriv_paper_accounts where id = ${id}`;340    if (!row) throw new UserError('unknown_account', 'unknown derivatives account', 404);341    return row;342  }343344  /** leverage of an order on its instrument, and the refusal when the requested setting is not allowed */345  private leverageFor(l: Loaded, inst: Instrument, asked: DecStr | undefined): { lev: DecStr; refusal: RiskResult | null } {346    const cfg = l.acct.state.config, max = D.from(inst.maxLeverage);347    if (asked === undefined) return { lev: cfg.leverageByInstrument[inst.id] ?? D.str(D.min(D.from(cfg.leverage), max)), refusal: null };348    const lev = D.from(asked), no = (reason: string): { lev: DecStr; refusal: RiskResult } => ({ lev: canon(asked), refusal: { ok: false, code: 'leverage_exceeded', reason } });349    if (D.gt(lev, max)) return no(`leverage ${asked} is above the venue maximum ${inst.maxLeverage} for ${inst.id}`);350    if (D.gt(lev, D.from(l.policy.maxLeverage))) return no(`leverage ${asked} is above the policy maximum ${l.policy.maxLeverage}`);351    if (inst.venue === 'hyperliquid' && (!D.isMultiple(lev, D.one) || D.lt(lev, D.one))) return no('leverage on Hyperliquid is an integer of at least 1');352    return { lev: canon(asked), refusal: null };353  }354355  /** the order as the engines take it: canonical decimals, the worst price of a market order derived when asked */356  private request(body: OrderBody, inst: Instrument, book: Book, clientId: string): { req: OrderRequest; refusal: RiskResult | null } {357    let price = body.price === null ? null : canon(body.price);358    if (price === null && body.type === 'market' && body.slippageBps !== undefined) price = worstPrice(inst, book, body.side, body.slippageBps);359    const req: OrderRequest = { instrument: inst.id, side: body.side, type: body.type, size: canon(body.size), price, triggerPrice: body.triggerPrice === null ? null : canon(body.triggerPrice),360      reduceOnly: body.reduceOnly, tif: body.tif, clientId };361    const rules = priceRules(inst);362    for (const [name, p] of [['price', req.price], ['trigger price', req.triggerPrice]] as const) {363      const why = p === null ? null : rules.check(p);364      if (why) return { req, refusal: { ok: false, code: 'price_not_on_tick', reason: `${name}: ${why}` } };365    }366    return { req, refusal: null };367  }368369  /** risk engine on the account as it stands at the live ticker */370  private async risk(tx: Tx, l: Loaded, req: OrderRequest, m: { inst: Instrument; ticker: Ticker; book: Book }, lev: DecStr, now: number): Promise<RiskResult> {371    return checkOrder(l.policy, req, {372      instrument: m.inst, account: l.acct.accountState(now), positions: l.acct.positions(), openOrders: l.acct.openOrders(), book: m.book, ticker: m.ticker, now,373      usage: await this.usage(tx, l.row.id, l.acct, now), leverage: lev, maintenanceMarginFraction: l.acct.state.config.maintenanceMarginFraction,374    });375  }376377  private estimate(l: Loaded, req: OrderRequest, m: { inst: Instrument; ticker: Ticker; book: Book }, lev: DecStr, now: number): OrderEstimate {378    const sim = PaperAccount.restore(l.acct.serialize());379    sim.state.config.leverageByInstrument[m.inst.id] = lev;380    const before = sim.signedPosition(m.inst.id), cfg = sim.state.config, levD = D.from(lev);381    const size = D.from(req.size), limit = req.price === null ? null : D.from(req.price);382    const base = { leverage: lev, price: req.price, source: 'estimate' as const };383    if (limit === null) {384      return { ...base, notional: D.str(D.mul(size, D.from(m.ticker.mark))), initialMargin: D.str(initialMargin(D.mul(size, D.from(m.ticker.mark)), levD)), fee: '0', avgFillPrice: null, slippageBps: null,385        liquidationPrice: null, freeCollateralAfter: sim.accountState(now).freeCollateral, filledSize: '0', restingSize: '0', status: 'rejected', rejectReason: 'a price is required (limit price, or worst acceptable price of a market order)' };386    }387    const res = sim.submit(req, m.book, m.ticker, now), order = res.order!;388    const mine = res.fills.filter((f) => f.clientId === req.clientId);389    const filled = mine.reduce((a, f) => D.add(a, D.from(f.size)), D.zero), cost = mine.reduce((a, f) => D.add(a, D.mul(D.from(f.size), D.from(f.price))), D.zero);390    let fee = mine.reduce((a, f) => D.add(a, D.from(f.fee)), D.zero);391    const resting = isOpen(order) ? D.sub(size, filled) : D.zero;392    const rejected = order.status === 'rejected';393    const avg = D.isPos(filled) ? D.div(cost, filled, 18, 'half_even') : null;394    const maker = D.max(D.zero, D.from(cfg.makerFeeRate));395    fee = D.add(fee, D.mul(D.mul(resting, limit), maker));396    const notional = rejected ? D.mul(size, limit) : D.add(cost, D.mul(resting, limit));397    const total = rejected ? size : D.add(filled, resting);398    const after = req.side === 'buy' ? D.add(before, total) : D.sub(before, total);399    const added = D.max(D.zero, D.sub(D.abs(after), D.abs(before)));400    const perUnit = D.isPos(total) ? D.div(notional, total, 18, 'up') : limit;401    const ref = D.from(m.ticker.mid ?? m.ticker.mark);402    const slip = avg === null ? null : D.div(D.mul(req.side === 'buy' ? D.sub(avg, ref) : D.sub(ref, avg), BPS), ref, 4, 'half_even');403    const pos = sim.positions().find((p) => p.instrument === m.inst.id);404    let free = D.from(sim.accountState(now).freeCollateral);405    if (D.isPos(resting)) {406      const cur = sim.state.positions.find((p) => p.instrument === m.inst.id);407      free = freeCollateralAfterOrder({ equity: sim.equity(), marginUsed: sim.marginUsed(), side: req.side, size: resting, price: limit, leverage: levD,408        position: cur ? { side: cur.side, size: D.from(cur.size), entryPrice: D.from(cur.entryPrice) } : null, feeRate: maker }).value;409    }410    return {411      ...base, notional: D.str(notional), initialMargin: D.str(initialMargin(D.mul(added, perUnit), levD)), fee: D.str(fee), avgFillPrice: avg === null ? null : D.str(avg),412      slippageBps: slip === null ? null : D.str(slip),413      liquidationPrice: pos ? { value: pos.liquidationPrice, source: 'estimate', method: `cross, single-position approximation at a flat maintenance fraction of ${cfg.maintenanceMarginFraction}; position after the immediate fills${D.isPos(resting) ? ' (the resting part is not included)' : ''}; no funding, no fees` } : null,414      freeCollateralAfter: D.str(free), filledSize: D.str(filled), restingSize: D.str(resting), status: order.status, rejectReason: order.rejectReason,415    };416  }417418  /** same checks and the same simulation as an order, on a copy: nothing is stored */419  async preview(id: string, body: OrderBody): Promise<{ risk: RiskResult; estimate: OrderEstimate; request: OrderRequest; ts: number }> {420    const m = await this.live(await this.venueOf(id), body.instrument), now = this.now();421    return sql.begin(async (tx) => {422      const [row] = await tx<AccountRow[]>`select * from deriv_paper_accounts where id = ${id}`;423      if (!row) throw new UserError('unknown_account', 'unknown derivatives account', 404);424      const l = this.load(row);425      l.acct.onTicker(m.ticker, now);                               // on the loaded copy only: equity at the live mark426      const { lev, refusal: levRefusal } = this.leverageFor(l, m.inst, body.leverage);427      const { req, refusal: priceRefusal } = this.request(body, m.inst, m.book, 'preview');428      const risk = levRefusal ?? priceRefusal ?? await this.risk(tx, l, req, m, lev, now);429      return { risk, estimate: this.estimate(l, req, m, lev, now), request: req, ts: now };430    }) as Promise<{ risk: RiskResult; estimate: OrderEstimate; request: OrderRequest; ts: number }>;431  }432433  private async stored(tx: Tx, id: string, clientId: string): Promise<{ order: Order; first: { order: Order; fills: Fill[] } | null } | null> {434    const [r] = await tx`select * from deriv_orders where account_id = ${id} and client_id = ${clientId}`;435    return r ? { order: orderOf(r), first: r.first_result as { order: Order; fills: Fill[] } | null } : null;436  }437438  /**439   * validate → live context → risk engine → paper engine against the live book → state and rows in one transaction → audit.440   * `clientId` is the Idempotency-Key: a key already used on the account returns the first result and does nothing.441   */442  async order(id: string, body: OrderBody, clientId: string, origin: 'order' | 'close' = 'order'): Promise<{ order: Order; fills: Fill[]; idempotent: boolean }> {443    const [prior] = await sql`select first_result from deriv_orders where account_id = ${id} and client_id = ${clientId}`;444    if (prior?.first_result) return { ...(prior.first_result as { order: Order; fills: Fill[] }), idempotent: true };445    const m = await this.live(await this.venueOf(id), body.instrument), now = this.now();446    const out = await this.mutate(id, async (l, tx): Promise<{ refusal: Extract<RiskResult, { ok: false }> } | { order: Order; fills: Fill[]; idempotent: boolean }> => {447      const again = await this.stored(tx, id, clientId);            // decided under the lock: two requests with one key cannot both place448      if (again) return { ...(again.first ?? { order: again.order, fills: [] }), idempotent: true };449      const before = new Set(l.acct.state.positions.map((p) => p.instrument));450      const intervals = new Map([[m.inst.id, m.inst.fundingIntervalSec]]);451      // the account first sees the live mark (as the worker would feed it): equity, triggers and liquidation are current452      const fed = l.acct.onTicker(m.ticker, now);453      await this.record(tx, l, fed, 'worker');454      const { lev, refusal: levRefusal } = this.leverageFor(l, m.inst, body.leverage);455      const { req, refusal: priceRefusal } = this.request(body, m.inst, m.book, clientId);456      const risk = levRefusal ?? priceRefusal ?? await this.risk(tx, l, req, m, lev, now);457      if (!risk.ok) {458        l.events.push({ kind: 'order_refused', code: risk.code, message: risk.reason, ts: now, dedupe: null,459          data: { instrument: req.instrument, side: req.side, type: req.type, size: req.size, price: req.price, reduceOnly: req.reduceOnly, origin } });460        this.syncCursors(l, before, intervals, now);461        return { refusal: risk };462      }463      l.acct.state.config.leverageByInstrument[m.inst.id] = lev;464      const res = l.acct.submit(req, m.book, m.ticker, now);465      await this.record(tx, l, res, 'owner');466      const result = { order: res.order!, fills: res.fills.filter((f) => f.clientId === clientId) };467      await tx`update deriv_orders set first_result = ${tx.json(result as never)}, origin = ${origin === 'close' ? 'close' : 'owner'} where account_id = ${id} and client_id = ${clientId}`;468      this.syncCursors(l, before, intervals, now);469      await this.snapshotEquity(tx, l, now);470      l.audits.push({ type: origin === 'close' ? 'deriv.paper.close' : 'deriv.paper.order', actor: 'owner', data: { account: id, clientId, instrument: req.instrument, side: req.side, type: req.type, size: req.size, price: req.price,471        reduceOnly: req.reduceOnly, status: result.order.status, filled: result.order.filled, avgFillPrice: result.order.avgFillPrice, fills: result.fills.length } });472      return { ...result, idempotent: false };473    });474    if ('refusal' in out) throw new UserError(out.refusal.code, out.refusal.reason, 422);475    return out;476  }477478  async cancel(id: string, clientId: string): Promise<{ order: Order }> {479    return this.mutate(id, async (l, tx) => {480      const res = l.acct.cancel(clientId, this.now());481      if (!res.order) {482        const kept = await this.stored(tx, id, clientId);           // an old closed order may have left the engine state483        if (!kept) throw new UserError('unknown_order', 'unknown order', 404);484        return { order: kept.order };485      }486      await this.record(tx, l, res, 'owner');487      if (res.orders.length) l.audits.push({ type: 'deriv.paper.cancel', actor: 'owner', data: { account: id, clientId, instrument: res.order.instrument } });488      return { order: res.order };489    });490  }491492  /** reduce-only market order on a fraction of the position; the worst price comes from the book with a stated bound */493  async close(id: string, body: z.infer<typeof CloseBodySchema>, clientId: string = `close-${randomUUID()}`) {494    const [prior] = await sql`select first_result from deriv_orders where account_id = ${id} and client_id = ${clientId}`;495    if (prior?.first_result) return { ...(prior.first_result as { order: Order; fills: Fill[] }), idempotent: true, worstPrice: (prior.first_result as { order: Order }).order.price, slippageBoundBps: null };496    const [row] = await sql<AccountRow[]>`select * from deriv_paper_accounts where id = ${id}`;497    if (!row) throw new UserError('unknown_account', 'unknown derivatives account', 404);498    const inst = await this.data.instrument(body.instrument);499    const pos = row.state.positions.find((p) => p.instrument === inst.id);500    if (!pos) throw new UserError('no_position', `no open position on ${inst.id}`, 422);501    const whole = D.eq(D.from(body.fraction), D.one);502    const size = whole ? D.from(pos.size) : D.quantize(D.mul(D.from(pos.size), D.from(body.fraction)), D.from(inst.lotSize), 'down');503    if (!D.isPos(size)) throw new UserError('below_min_size', `${body.fraction} of the position is less than one lot (${inst.lotSize})`, 422);504    const side = pos.side === 'long' ? 'sell' as const : 'buy' as const;505    const bound = body.slippageBps ?? DEFAULT_SLIPPAGE_BPS;506    const book = await this.data.book(inst, LIVE);507    if (!book) throw new UserError('paper_unsupported', `${inst.venue} has no order book`, 422);508    const price = worstPrice(inst, book, side, bound);509    const r = await this.order(id, { instrument: inst.id, side, type: 'market', size: D.str(size), price, triggerPrice: null, reduceOnly: true, tif: 'ioc' }, clientId, 'close');510    return { ...r, worstPrice: price, slippageBoundBps: bound };511  }512513  // ------------------------------------------------------------------ worker step514515  /** accounts with something for the worker to do, and the instruments they need (positions and open orders) */516  async active(): Promise<{ id: string; venue: VenueId; instruments: string[]; cursors: Record<string, number> }[]> {517    const rows = await sql<Pick<AccountRow, 'id' | 'venue' | 'state' | 'meta'>[]>`select id, venue, state, meta from deriv_paper_accounts518      where jsonb_array_length(state->'positions') > 0 or exists (select 1 from jsonb_array_elements(state->'orders') o where o->>'status' in ('pending', 'triggered', 'open', 'partially_filled'))519      order by created_at`;520    return rows.map((r) => ({ id: r.id, venue: r.venue, cursors: r.meta.fundingCursor ?? {},521      instruments: [...new Set([...r.state.positions.map((p) => p.instrument), ...r.state.orders.filter(isOpen).map((o) => o.instrument)])] }));522  }523524  /**525   * One worker step for one account, with market data read beforehand. Idempotent: a book the account has already seen does526   * nothing, a funding time is applied once (engine state, cursor and primary key agree), the equity snapshot is one row per minute.527   */528  async tick(id: string, feeds: MarketFeed[], now: number): Promise<{ fills: number; funding: number; liquidated: boolean; halted: string | null }> {529    return this.mutate(id, async (l, tx) => {530      const summary = { fills: 0, funding: 0, liquidated: false, halted: null as string | null };531      const before = new Set(l.acct.state.positions.map((p) => p.instrument));532      const intervals = new Map(feeds.map((f) => [f.inst.id, f.inst.fundingIntervalSec]));533      const take = async (res: PaperResult): Promise<void> => {534        await this.record(tx, l, res, 'worker');535        summary.fills += res.fills.length; summary.funding += res.funding.length; summary.liquidated ||= res.liquidated;536        for (const f of res.fills) if (!f.liquidation) l.audits.push({ type: 'deriv.paper.fill', actor: 'paper-engine', data: { account: id, fill: f.id, clientId: f.clientId, instrument: f.instrument, side: f.side, size: f.size, price: f.price } });537      };538539      // 1 — funding at the venue's funding times, each with the rate the venue published for it540      for (const f of feeds) {541        const iv = f.inst.fundingIntervalSec * 1000;542        let cursor = l.meta.fundingCursor[f.inst.id];543        if (cursor === undefined || !f.fundingRates) continue;544        for (let t = cursor + iv; t <= now; t += iv) {545          if (!l.acct.state.positions.some((p) => p.instrument === f.inst.id)) break;546          const rate = f.fundingRates.get(t);547          if (rate === undefined) {548            if (now - t <= FUNDING_GRACE_MS) break;                 // not published yet: the next step asks again549            l.events.push({ kind: 'funding_skipped', code: 'funding_rate_missing', message: `${f.inst.id}: the venue published no funding rate for ${new Date(t).toISOString()}; nothing was applied for that time`,550              data: { instrument: f.inst.id, fundingTime: t }, dedupe: `funding_skipped:${f.inst.id}:${t}`, ts: now });551          } else {552            l.acct.setMark(f.inst.id, D.from(f.ticker.mark));553            await take(l.acct.applyFunding(rate, t, f.inst.id));554          }555          cursor = t; l.meta.fundingCursor[f.inst.id] = t;556        }557      }558      // 2 — the live ticker (mark, triggers, liquidation), then the live book (triggered and resting orders)559      for (const f of feeds) {560        await take(l.acct.onTicker(f.ticker, now));561        if (f.book) await take(l.acct.onBook(f.book, now));562      }563      this.syncCursors(l, before, intervals, now);564565      // 3 — margin warning: recorded when the account enters the zone, cleared when it leaves it566      const equity = l.acct.equity(), maintenance = l.acct.maintenance();567      const inZone = l.acct.state.positions.length > 0 && D.gte(maintenance, D.mul(equity, D.from(MARGIN_WARNING_RATIO)));568      if (inZone && !l.meta.marginWarning) {569        l.events.push({ kind: 'margin_warning', code: 'margin_warning', message: `maintenance margin ${D.str(maintenance)} has reached ${D.str(D.mul(D.from(MARGIN_WARNING_RATIO), D.from(100n)))} % of equity ${D.str(equity)}: the account is close to a simulated liquidation`,570          data: { equity: D.str(equity), maintenanceMargin: D.str(maintenance) }, dedupe: null, ts: now });571      }572      l.meta.marginWarning = inZone;573574      // 4 — loss limits: a breach sets the circuit breaker. It stops new risk only: reduce-only orders that reduce still pass.575      if (l.policy.halt === null) {576        const u = await this.usage(tx, id, l.acct, now);577        const unrealized = D.sub(equity, D.from(l.acct.state.collateral));578        const daily = D.add(D.from(u.realizedToday), unrealized), total = D.add(D.from(u.realizedTotal), unrealized);579        const breach = D.lte(total, D.neg(D.from(l.policy.maxTotalLoss))) ? { code: 'total_loss_exceeded', text: `total loss limit reached: PnL ${D.str(total)} against a limit of −${l.policy.maxTotalLoss}` }580          : D.lte(daily, D.neg(D.from(l.policy.maxDailyLoss))) ? { code: 'daily_loss_exceeded', text: `daily loss limit reached: PnL ${D.str(daily)} against a limit of −${l.policy.maxDailyLoss}` } : null;581        if (breach) {582          l.policy = { ...l.policy, halt: `${breach.text} (${new Date(now).toISOString()})` };583          summary.halted = breach.code;584          // resting orders that could add exposure are withdrawn; reduce-only orders stay585          const cancelled: string[] = [];586          for (const o of l.acct.openOrders()) {587            if (o.reduceOnly) continue;588            const res = l.acct.cancel(o.clientId, now);589            const kept = l.acct.state.orders.find((x) => x.clientId === o.clientId);590            if (kept) kept.rejectReason = 'cancelled: trading halted by a loss limit';591            if (res.order) res.order.rejectReason = 'cancelled: trading halted by a loss limit';592            for (const x of res.orders) x.rejectReason = 'cancelled: trading halted by a loss limit';593            await this.record(tx, l, res, 'worker');594            cancelled.push(o.clientId);595          }596          l.events.push({ kind: 'loss_limit', code: breach.code, message: `${breach.text}; trading halted (closing with reduce-only orders stays possible); the halt lasts until the policy is replaced`,597            data: { daily: D.str(daily), total: D.str(total), maxDailyLoss: l.policy.maxDailyLoss, maxTotalLoss: l.policy.maxTotalLoss, cancelledOrders: cancelled }, dedupe: null, ts: now });598          l.audits.push({ type: 'deriv.paper.halt', actor: 'paper-engine', data: { account: id, code: breach.code, daily: D.str(daily), total: D.str(total) } });599        }600      }601      // 5 — equity: one row per minute, rewritten within the minute when something happened to the account602      if (Math.floor(now / 60_000) * 60_000 !== l.meta.lastEquityMinute || summary.fills > 0 || summary.funding > 0 || summary.liquidated) await this.snapshotEquity(tx, l, now);603      return summary;604    });605  }606}607608function orderOf(r: Record<string, any>): Order {609  return { instrument: r.instrument, side: r.side, type: r.type, size: canon(r.size), price: canonN(r.price), triggerPrice: canonN(r.trigger_price), reduceOnly: r.reduce_only, tif: r.tif,610    clientId: r.client_id, venueOrderId: r.venue_order_id, status: r.status, filled: canon(r.filled), avgFillPrice: canonN(r.avg_fill_price),611    createdAt: r.created_at.getTime(), updatedAt: r.updated_at.getTime(), rejectReason: r.reject_reason };612}613
Files main
Menu

Theme