Repository Public
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