SPB Git

Repository Public

mock.ts 56.5 KB 658 lines typescript

1/**2 * In-browser stand-in for the derivatives API — DEVELOPMENT BUILDS ONLY (`main.tsx` imports this file behind3 * `import.meta.env.DEV`, so a production build neither contains it nor can enable it). Turned on by `?mock=1`.4 *5 * It answers the routes of the "API contract (wave 2)" table with the shapes of the real backend (docs/v4/API-WAVE2-NOTES.md,6 * services/api/src/derivatives: same defaults, same refusal order, same error codes), by patching `fetch` and7 * `EventSource` for `/api/v1/…` — the real client code runs unchanged. Market data starts from the recorded venue fixtures8 * (`packages/derivatives/test/fixtures/`: Hyperliquid universe / book / trades / candles, dYdX markets / book / trades / candles,9 * GMX tickers / markets / config / candles) and then moves with a small random walk so that the stream is alive. Paper accounts10 * are the repository's real `PaperAccount` engine and real `checkOrder` risk engine, so refusals carry the real risk codes.11 *12 * The routes of controlled LIVE trading (`/derivatives/live/*`) are simulated by `mockLive.ts`: venue accounts, authorization13 * with a simulated passkey, proposals, approvals, venue refusals, `unknown` → reconciled, halt, divergence.14 *15 * Test hooks: `window.__spwMock` — `setRate(perSecond)`, `stale()`, `reconnect()`, `resume()`, `perf()`, and `live.*` (mockLive.ts).16 * Nothing here talks to a venue; nothing here is evidence that the real backend behaves the same way.17 */18import dydxBookRaw from '../../../../packages/derivatives/test/fixtures/dydx/orderbook-BTC-USD.json?raw';19import dydxCandlesRaw from '../../../../packages/derivatives/test/fixtures/dydx/candles-BTC-USD-1HOUR.json?raw';20import dydxMarketsRaw from '../../../../packages/derivatives/test/fixtures/dydx/perpetualMarkets.json?raw';21import gmxCandlesRaw from '../../../../packages/derivatives/test/fixtures/gmx/v1-ohlcv-BTC-1h.json?raw';22import gmxConfigRaw from '../../../../packages/derivatives/test/fixtures/gmx/v1-markets-config.json?raw';23import gmxMarketsRaw from '../../../../packages/derivatives/test/fixtures/gmx/v1-markets.json?raw';24import gmxTickersRaw from '../../../../packages/derivatives/test/fixtures/gmx/v1-markets-tickers.json?raw';25import hlBookRaw from '../../../../packages/derivatives/test/fixtures/hyperliquid/mainnet-l2Book-BTC.json?raw';26import hlCandlesRaw from '../../../../packages/derivatives/test/fixtures/hyperliquid/mainnet-candleSnapshot-BTC-1h.json?raw';27import hlStateRaw from '../../../../packages/derivatives/test/fixtures/hyperliquid/mainnet-clearinghouseState.json?raw';28import hlCtxRaw from '../../../../packages/derivatives/test/fixtures/hyperliquid/mainnet-metaAndAssetCtxs.json?raw';29import hlTradesRaw from '../../../../packages/derivatives/test/fixtures/hyperliquid/mainnet-recentTrades-BTC.json?raw';30import { D, type Dec } from '../../../../packages/derivatives/src/decimal.ts';31import { freeCollateralAfterOrder, initialMargin } from '../../../../packages/derivatives/src/margin.ts';32import { PaperAccount } from '../../../../packages/derivatives/src/paper.ts';33import { checkOrder } from '../../../../packages/derivatives/src/risk.ts';34import { stepAt, worstPrice as worstPriceOf } from './dec';35import { createLiveMock, type LiveHooks } from './mockLive';36import { createRouterMock } from './mockRouter';   // router (simulation): POST /derivatives/route, GET /derivatives/route/assets37import { createPerpMock, type PerpHooks } from './mockPerpLive';   // v7 perp live: the perp-agent routes38import { aggregateBars, type Bar } from './reducers';39import type { Book, Candle, CompareRow, Instrument, Interval, Liquidity, OrderRequest, Position, PublicTrade, RiskPolicy, Ticker, VenueId, VenueInfo } from './types';40import { INTERVAL_MS } from './types';4142// ---------------------------------------------------------------- helpers43const E30 = D.from(10n ** 30n);44const f30 = (raw: string, scale = 12): string => D.str(D.div(D.from(BigInt(raw)), E30, scale, 'half_even'));45const pow10neg = (n: number): string => (n <= 0 ? '1' : `0.${'0'.repeat(n - 1)}1`);46const n2s = (n: number, dp: number): string => { const s = Math.max(0, n).toFixed(Math.min(dp, 12)); return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') || '0' : s; };47const intDigits = (px: string): number => { const i = px.split('.')[0]!.replace(/^0+/, ''); return i.length; };48function rng(seed: string): () => number {49  let h = 2166136261;50  for (const ch of seed) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619); }51  return () => { h |= 0; h = (h + 0x6d2b79f5) | 0; let t = Math.imul(h ^ (h >>> 15), 1 | h); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };52}53const gauss = (r: () => number) => Math.sqrt(-2 * Math.log(Math.max(1e-9, r()))) * Math.cos(2 * Math.PI * r());54const body = (raw: string) => { const j = JSON.parse(raw); return 'text' in j ? JSON.parse(j.text) : j.body; };5556// ---------------------------------------------------------------- fixtures → instruments and starting tickers57const HL_SETTLEMENT = 'Linear perpetual margined and settled in USDC; the oracle is USDT-denominated for most assets (quanto: USDT PnL paid in USDC, no conversion); funding every hour, peer to peer';58const DYDX_SETTLEMENT = 'USDC-margined linear perpetual on dYdX Chain: PnL and funding settle in USDC (Noble USDC over IBC) in the subaccount; no expiry, no delivery';59const GMX_SETTLEMENT = 'isolated position against a GM liquidity pool; PnL is computed in USD and paid in the pool token of the side (long token for longs, short token for shorts) on top of the returned collateral; settlement happens on chain when a keeper executes the order';60const GMX_DECIMALS: Record<string, number> = { BTC: 8, ETH: 18, SOL: 9, LINK: 18, DOGE: 8, LTC: 8 };6162interface Seed { inst: Instrument; ticker: Omit<Ticker, 'ts'>; prev: number; bookStep: string; liquidity?: Liquidity }63function seeds(): Seed[] {64  const out: Seed[] = [];65  // Hyperliquid: the 24 most traded listed assets of the recorded universe66  const [meta, ctxs] = body(hlCtxRaw) as [{ universe: { name: string; szDecimals: number; maxLeverage: number; isDelisted?: boolean; onlyIsolated?: boolean }[] }, any[]];67  meta.universe.map((u, i) => ({ u, c: ctxs[i] })).filter((x) => !x.u.isDelisted && x.c?.markPx && x.c?.midPx && Number(x.c.dayNtlVlm) > 0)68    .sort((a, b) => Number(b.c.dayNtlVlm) - Number(a.c.dayNtlVlm)).slice(0, 24).forEach(({ u, c }) => {69      const id = `hyperliquid:${u.name}`, priceDp = Math.max(0, 6 - u.szDecimals);70      const prev = Number(c.prevDayPx), mark = Number(c.markPx);71      out.push({72        inst: { venue: 'hyperliquid', symbol: u.name, id, base: u.name, quote: 'USD', kind: 'perpetual', collateral: 'USDC', settlement: HL_SETTLEMENT,73          tickSize: pow10neg(priceDp), lotSize: pow10neg(u.szDecimals), minOrderSize: null, minNotional: '10', maxLeverage: String(u.maxLeverage),74          marginModes: u.onlyIsolated ? ['isolated'] : ['cross', 'isolated'], fundingIntervalSec: 3600,75          markPriceMethod: 'Median of (1) oracle + 150 s EMA of (Hyperliquid mid − oracle), (2) median of best bid, best ask, last trade, (3) weighted median of CEX perp mids; updated about every 3 s', status: 'active' },76        ticker: { instrument: id, mark: c.markPx, index: c.oraclePx, mid: c.midPx, funding: c.funding, openInterest: c.openInterest, volume24h: c.dayNtlVlm, change24h: prev > 0 ? n2s0((mark - prev) / prev) : null },77        prev, bookStep: pow10neg(Math.min(priceDp, Math.max(0, 5 - intDigits(c.markPx)))),78      });79    });80  // dYdX v4: market data only81  const dm = (body(dydxMarketsRaw) as { markets: Record<string, any> }).markets;82  Object.values(dm).filter((m) => m.status === 'ACTIVE' && Number(m.volume24H) > 0 && Number(m.oraclePrice) > 0).sort((a, b) => Number(b.volume24H) - Number(a.volume24H)).slice(0, 10).forEach((m) => {83    const id = `dydx:${m.ticker}`, px = Number(m.oraclePrice), prev = px - Number(m.priceChange24H);84    out.push({85      inst: { venue: 'dydx', symbol: m.ticker, id, base: String(m.ticker).split('-')[0]!, quote: 'USD', kind: 'perpetual', collateral: 'USDC', settlement: DYDX_SETTLEMENT,86        tickSize: m.tickSize, lotSize: m.stepSize, minOrderSize: m.stepSize, minNotional: null, maxLeverage: D.str(D.div(D.one, D.from(m.initialMarginFraction), 4, 'down')),87        marginModes: ['cross'], fundingIntervalSec: 3600, markPriceMethod: 'no separate mark price: the protocol oracle price (validator-aggregated index of exchange prices, updated per block) values positions, margin and liquidations', status: 'active' },88      ticker: { instrument: id, mark: m.oraclePrice, index: m.oraclePrice, mid: null, funding: m.nextFundingRate, openInterest: m.openInterest, volume24h: m.volume24H, change24h: prev > 0 ? n2s0((px - prev) / prev) : null },89      prev, bookStep: m.tickSize,90    });91  });92  // GMX v2 (Arbitrum): one instrument per pool, no order book93  const gm = new Map((body(gmxMarketsRaw) as any[]).map((m) => [m.marketTokenAddress, m]));94  const gc = new Map((body(gmxConfigRaw) as any[]).map((m) => [m.marketTokenAddress, m]));95  for (const t of body(gmxTickersRaw) as any[]) {96    const m = gm.get(t.marketTokenAddress), g = gc.get(t.marketTokenAddress);97    const mm = /^([A-Z]+)\/USD \[([A-Z.]+)-([A-Z.]+)\]$/.exec(t.symbol);98    if (!m || m.isSpotOnly || !mm || !g) continue;99    const base = mm[1]!, dec = GMX_DECIMALS[base] ?? 18, id = `gmx:${t.symbol}`, mark = f30(t.markPrice, 4);100    const oiTokens = D.div(D.from(BigInt(t.longInterestInTokens) + BigInt(t.shortInterestInTokens)), D.from(10n ** BigInt(dec)), 8, 'down');101    const fac = (x: unknown) => (typeof x === 'string' && /^-?\d+$/.test(x) ? f30(x, 14) : undefined);102    out.push({103      inst: { venue: 'gmx', symbol: t.symbol, id, base, quote: 'USD', kind: 'perpetual', collateral: mm[2] === mm[3] ? mm[2]! : `${mm[2]} or ${mm[3]}`, settlement: GMX_SETTLEMENT,104        tickSize: pow10neg(30 - dec), lotSize: pow10neg(dec), minOrderSize: null, minNotional: f30(m.minPositionSizeUsd, 2), maxLeverage: D.str(D.div(D.from(BigInt(m.leverageTiers[0].maxLeverage)), D.from(10000n), 2, 'down')),105        marginModes: ['isolated'], fundingIntervalSec: 3600, markPriceMethod: 'signed oracle min/max (Chainlink Data Streams): longs open at max and close at min; displayed mark = (min + max) / 2', status: m.isListed ? 'active' : 'halted' },106      ticker: { instrument: id, mark, index: mark, mid: null, funding: f30(String(-BigInt(t.fundingRateLong)), 14), openInterest: D.str(oiTokens), volume24h: null, change24h: n2s0(Number(t.priceChangePercent24hBps) / 10000) },107      prev: Number(mark) / (1 + Number(t.priceChangePercent24hBps) / 10000), bookStep: '0.01',108      liquidity: { openInterestLongUsd: f30(t.longInterestUsd, 2), openInterestShortUsd: f30(t.shortInterestUsd, 2), maxOpenInterestLongUsd: f30(g.maxOpenInterestLong, 2), maxOpenInterestShortUsd: f30(g.maxOpenInterestShort, 2),109        availableLiquidityLongUsd: f30(t.availableLiquidityLong, 2), availableLiquidityShortUsd: f30(t.availableLiquidityShort, 2), capacityLongUsd: f30(t.capacityLong.availableLiquidity, 2), capacityShortUsd: f30(t.capacityShort.availableLiquidity, 2),110        fundingRateLongPerHour: f30(t.fundingRateLong, 14), fundingRateShortPerHour: f30(t.fundingRateShort, 14), borrowingRateLongPerHour: f30(t.borrowingRateLong, 14), borrowingRateShortPerHour: f30(t.borrowingRateShort, 14),111        netRateLongPerHour: f30(t.netRateLong, 14), netRateShortPerHour: f30(t.netRateShort, 14),112        priceImpact: { factorPositive: fac(g.positionImpactFactorPositive), factorNegative: fac(g.positionImpactFactorNegative), exponentPositive: fac(g.positionImpactExponentFactorPositive), exponentNegative: fac(g.positionImpactExponentFactorNegative),113          maxFactorPositive: fac(g.maxPositionImpactFactorPositive), maxFactorNegative: fac(g.maxPositionImpactFactorNegative) },114        positionFeeFactor: { balanceImproved: fac(g.positionFeeFactorForBalanceWasImproved), balanceNotImproved: fac(g.positionFeeFactorForBalanceWasNotImproved) },115        oracleMin: f30(t.minPrice, 4), oracleMax: f30(t.maxPrice, 4) },116    });117  }118  return out;119}120function n2s0(n: number): string { const s = n.toFixed(6).replace(/0+$/, '').replace(/\.$/, ''); return s === '-0' || s === '' ? '0' : s; }121122// ---------------------------------------------------------------- live simulation of one instrument123interface Profile { gap: number; usd: number }124function profiles(): Record<'hyperliquid' | 'dydx', { bids: Profile[]; asks: Profile[]; spread: number; tradeUsd: number[] }> {125  const hl = body(hlBookRaw) as { levels: [{ px: string; sz: string }[], { px: string; sz: string }[]] };126  const dy = body(dydxBookRaw) as { bids: { price: string; size: string }[]; asks: { price: string; size: string }[] };127  const side = (ls: { p: number; s: number }[]): Profile[] => ls.slice(0, 20).map((l) => ({ gap: Math.round(Math.abs(l.p - ls[0]!.p)), usd: l.p * l.s }));128  const hb = hl.levels[0].map((l) => ({ p: Number(l.px), s: Number(l.sz) })), ha = hl.levels[1].map((l) => ({ p: Number(l.px), s: Number(l.sz) }));129  const db = dy.bids.map((l) => ({ p: Number(l.price), s: Number(l.size) })), da = dy.asks.map((l) => ({ p: Number(l.price), s: Number(l.size) }));130  const tr = (body(hlTradesRaw) as { px: string; sz: string }[]).map((t) => Number(t.px) * Number(t.sz));131  return { hyperliquid: { bids: side(hb), asks: side(ha), spread: Math.max(1, Math.round(ha[0]!.p - hb[0]!.p)), tradeUsd: tr },132    dydx: { bids: side(db), asks: side(da), spread: Math.max(1, Math.round(da[0]!.p - db[0]!.p)), tradeUsd: tr.map((x) => x / 20) } };133}134135class Sim {136  readonly inst: Instrument;137  readonly seed: Seed;138  readonly step: Dec;139  readonly lot: Dec;140  readonly r: () => number;141  k: number;                 // best bid, in book steps142  drift = 0;143  ticker: Ticker;144  book: Book | null = null;145  trades: PublicTrade[] = [];146  fresh: PublicTrade[] = [];147  seq = 0;148  lastStep = 0;149  frozen = false;150  constructor(seed: Seed) {151    this.seed = seed; this.inst = seed.inst; this.step = D.from(seed.bookStep); this.lot = D.from(seed.inst.lotSize);152    this.r = rng(seed.inst.id);153    this.k = Math.max(2, Math.round(Number(seed.ticker.mid ?? seed.ticker.mark) / Number(seed.bookStep)));154    this.ticker = { ...seed.ticker, ts: Date.now() };155    this.advance(true);156    for (let i = 0; i < 24; i++) this.trade(Date.now() - (24 - i) * 1400);157    this.fresh = [];158  }159  price(k: number): string { return D.str(D.mul(this.step, D.from(BigInt(Math.max(1, k))))); }160  size(usd: number, px: number): string { const s = D.quantize(D.from(n2s(usd / px, 10)), this.lot, 'down'); return D.str(D.isPos(s) ? s : this.lot); }161  trade(ts: number): void {162    if (this.inst.venue === 'gmx') return;163    const p = PROFILES[this.inst.venue], buy = this.r() < 0.5, k = buy ? this.k + p.spread : this.k, px = Number(this.price(k));164    const usd = p.tradeUsd[Math.floor(this.r() * p.tradeUsd.length)] ?? 500;165    const t: PublicTrade = { instrument: this.inst.id, price: this.price(k), size: this.size(Math.max(12, usd * (px / 73700 > 0.2 ? 1 : 0.2)), px), side: buy ? 'buy' : 'sell', ts, id: `m-${this.inst.symbol}-${++this.seq}` };166    this.trades = [t, ...this.trades].slice(0, 60); this.fresh.push(t);167  }168  /** one step of the random walk: new mark, new book, sometimes a trade */169  advance(force = false): void {170    const now = Date.now();171    if (this.frozen && !force) return;172    if (!force) {173      this.drift = this.drift * 0.92 + (this.r() - 0.5) * 0.9;174      const move = Math.round(this.drift + (this.r() - 0.5) * 2.2);175      this.k = Math.max(2, this.k + move);176      if (this.r() < 0.45) this.trade(now);177    }178    this.lastStep = now;179    const stepN = Number(this.seed.bookStep);180    if (this.inst.venue === 'gmx') {181      const mark = this.price(this.k);182      this.ticker = { ...this.ticker, mark, index: mark, change24h: n2s0((Number(mark) - this.seed.prev) / this.seed.prev), ts: now };183      return;184    }185    const p = PROFILES[this.inst.venue];186    const bids: [string, string][] = [], asks: [string, string][] = [];187    for (const l of p.bids) { const k = this.k - l.gap; if (k < 1) break; const px = k * stepN; bids.push([this.price(k), this.size(l.usd * (0.55 + this.r() * 0.9) * this.scaleUsd(), px)]); }188    for (const l of p.asks) { const k = this.k + p.spread + l.gap, px = k * stepN; asks.push([this.price(k), this.size(l.usd * (0.55 + this.r() * 0.9) * this.scaleUsd(), px)]); }189    this.book = { instrument: this.inst.id, bids, asks, ts: Math.max(now, (this.book?.ts ?? 0) + 1) };190    const mid = D.div(D.add(D.from(bids[0]![0]), D.from(asks[0]![0])), D.from(2n), 12, 'half_even'), midN = D.toNumber(mid);191    const ratio = this.seed.ticker.index && Number(this.seed.ticker.mark) > 0 ? Number(this.seed.ticker.index) / Number(this.seed.ticker.mark) : 1;192    const tick = D.from(this.inst.tickSize);193    this.ticker = { ...this.ticker, mark: D.str(D.quantize(mid, tick, 'down')), mid: D.str(mid), index: D.str(D.quantize(D.from(n2s(midN * ratio, 10)), tick, 'down')),194      change24h: this.seed.prev > 0 ? n2s0((midN - this.seed.prev) / this.seed.prev) : null, ts: now };195  }196  /** smaller markets get a thinner book: scale the recorded BTC depth by relative 24 h volume (bounded) */197  scaleUsd(): number { const v = Number(this.seed.ticker.volume24h ?? 0), ref = this.inst.venue === 'dydx' ? 5_000_000 : 3_500_000_000; return Math.max(0.02, Math.min(1, v / ref)); }198}199200let PROFILES: ReturnType<typeof profiles>;201const SIMS = new Map<string, Sim>();202let SEEDS: Seed[] = [];203const sim = (id: string): Sim | null => { let s = SIMS.get(id); if (!s) { const sd = SEEDS.find((x) => x.inst.id === id); if (!sd) return null; s = new Sim(sd); SIMS.set(id, s); } return s; };204const fresh = (s: Sim): Sim => { if (!s.frozen && Date.now() - s.lastStep > 400) s.advance(); return s; };205206// ---------------------------------------------------------------- candles207const HOURLY = new Map<string, Bar[]>();208function pattern(venue: VenueId): Bar[] {209  if (venue === 'hyperliquid') return (body(hlCandlesRaw) as any[]).map((c) => ({ ts: c.t, o: +c.o, h: +c.h, l: +c.l, c: +c.c, v: +c.v }));210  if (venue === 'dydx') return ((body(dydxCandlesRaw) as { candles: any[] }).candles).map((c) => ({ ts: Date.parse(c.startedAt), o: +c.open, h: +c.high, l: +c.low, c: +c.close, v: +c.baseTokenVolume })).sort((a, b) => a.ts - b.ts);211  return (body(gmxCandlesRaw) as any[]).map((c) => ({ ts: c.timestamp, o: +c.open, h: +c.high, l: +c.low, c: +c.close, v: 0 })).sort((a, b) => a.ts - b.ts);212}213/** hourly history: the recorded day at the end, a seeded walk with the same volatility before it; prices scaled to the instrument */214function hourly(s: Sim, hours: number): Bar[] {215  const H = 3_600_000, end = Math.floor(Date.now() / H) * H, key = `${s.inst.id}:${end}:${hours}`;216  const hit = HOURLY.get(key); if (hit) return hit;217  const pat = pattern(s.inst.venue), r = rng(`${s.inst.id}:h`), k = Number(s.ticker.mark) / pat[pat.length - 1]!.c;218  const rets = pat.map((b) => Math.log(b.c / b.o)), sd = Math.sqrt(rets.reduce((a, x) => a + x * x, 0) / rets.length) || 0.003;219  const volScale = s.inst.venue === 'gmx' ? 0 : (73700 / Number(s.ticker.mark)) * s.scaleUsd();220  const bars: Bar[] = pat.map((b) => ({ ts: 0, o: b.o * k, h: b.h * k, l: b.l * k, c: b.c * k, v: b.v * volScale }));221  while (bars.length < hours) {222    const next = bars[0]!, c = next.o, o = c / Math.exp(gauss(r) * sd), w = Math.abs(gauss(r)) * sd * 0.6, w2 = Math.abs(gauss(r)) * sd * 0.6;223    bars.unshift({ ts: 0, o, c, h: Math.max(o, c) * (1 + w), l: Math.min(o, c) * (1 - w2), v: (pat[Math.floor(r() * pat.length)]!.v || 0) * volScale * (0.6 + r() * 0.8) });224  }225  bars.forEach((b, i) => { b.ts = end - (bars.length - 1 - i) * H; });226  HOURLY.set(key, bars);227  return bars;228}229function candles(s: Sim, interval: Interval, limit: number): Candle[] {230  const ms = INTERVAL_MS[interval], H = 3_600_000, dp = Math.min(8, D.from(s.inst.tickSize).s);231  let bars: Bar[];232  if (ms >= H) bars = aggregateBars(hourly(s, Math.ceil((limit * ms) / H) + 24), ms);233  else {234    const n = H / ms, hs = hourly(s, Math.ceil(limit / n) + 2), r = rng(`${s.inst.id}:${interval}`);235    bars = [];236    for (const hb of hs) {237      // a bridge from the open to the close that stays inside [low, high] and touches both238      const pts: number[] = [hb.o];239      for (let i = 1; i < n; i++) { const t = i / n, base = hb.o + (hb.c - hb.o) * t; pts.push(Math.min(hb.h, Math.max(hb.l, base + gauss(r) * (hb.h - hb.l) * 0.28 * Math.sqrt(t * (1 - t)) * 2))); }240      pts.push(hb.c);241      const iH = Math.floor(r() * n), iL = (iH + 1 + Math.floor(r() * (n - 1))) % n;242      const w = Array.from({ length: n }, () => 0.4 + r()), wSum = w.reduce((a, x) => a + x, 0);243      for (let i = 0; i < n; i++) {244        const o = pts[i]!, c = pts[i + 1]!, span = (hb.h - hb.l) / n;245        bars.push({ ts: hb.ts + i * ms, o, c, h: i === iH ? hb.h : Math.min(hb.h, Math.max(o, c) + r() * span * 0.5), l: i === iL ? hb.l : Math.max(hb.l, Math.min(o, c) - r() * span * 0.5), v: (hb.v * w[i]!) / wSum });246      }247    }248    bars = bars.filter((b) => b.ts <= Date.now());249  }250  bars = bars.slice(-limit).map((b) => ({ ...b }));251  const last = bars[bars.length - 1], mark = Number(s.ticker.mark);252  if (last) { last.c = mark; last.h = Math.max(last.h, mark); last.l = Math.min(last.l, mark); }253  return bars.map((b) => ({ ts: b.ts, open: n2s(b.o, dp), high: n2s(Math.max(b.h, b.o, b.c), dp), low: n2s(Math.min(b.l, b.o, b.c), dp), close: n2s(b.c, dp), volume: n2s(b.v, 4) }));254}255256// ---------------------------------------------------------------- venues, comparison (shapes of services/api/src/derivatives)257const DYDX_RESTRICTION = 'dYdX is served for market data only: its terms of use exclude Canada (and the United States and the United Kingdom) and its indexer geoblocks account reads from the owner\'s network. No account view and no paper account are offered on this venue, so that it cannot be mistaken for a venue the owner may trade on.';258const VENUES: VenueInfo[] = [259  { id: 'hyperliquid', name: 'Hyperliquid', network: 'mainnet', status: 'ok', lastOkAt: Date.now(), lastError: null, restriction: null, paper: true, orderBook: true,260    capabilities: { marketData: true, account: true, orders: true, orderTypes: ['limit', 'market', 'stop_market', 'stop_limit', 'take_profit_market', 'take_profit_limit'], tif: ['gtc', 'ioc', 'alo'], modify: true, leverage: true, marginModes: ['cross', 'isolated'], subaccounts: true, testnet: true, stream: true, notes: [] } },261  { id: 'gmx', name: 'GMX v2 (Arbitrum)', network: 'mainnet', status: 'ok', lastOkAt: Date.now(), lastError: null, restriction: null, paper: false, orderBook: false,262    capabilities: { marketData: true, account: true, orders: false, orderTypes: [], tif: [], modify: false, leverage: false, marginModes: ['isolated'], subaccounts: false, testnet: false, stream: false,263      notes: ['NO ORDER BOOK: book() throws "unsupported". Use liquidity(symbol): available liquidity and open interest per side, price-impact parameters, funding and borrowing rates.'] } },264  { id: 'dydx', name: 'dYdX v4', network: 'mainnet', status: 'ok', lastOkAt: Date.now(), lastError: null, restriction: DYDX_RESTRICTION, paper: false, orderBook: true,265    capabilities: { marketData: true, account: false, orders: false, orderTypes: [], tif: [], modify: false, leverage: false, marginModes: ['cross'], subaccounts: true, testnet: true, stream: true, notes: [DYDX_RESTRICTION] } },266];267function compare(base: string) {268  const rows: (CompareRow & { ts: number; source: 'venue' })[] = [];269  for (const sd of SEEDS.filter((x) => x.inst.base === base.toUpperCase())) {270    const s = fresh(sim(sd.inst.id)!), b = s.book;271    let spreadBps: string | null = null, bid: string | null = null, ask: string | null = null;272    if (b && b.bids[0] && b.asks[0]) {273      const bb = D.from(b.bids[0][0]), ba = D.from(b.asks[0][0]), mid = D.div(D.add(bb, ba), D.from(2n), 12, 'half_even');274      spreadBps = D.str(D.div(D.mul(D.sub(ba, bb), D.from(10000n)), mid, 2, 'half_even'));275      const lo = D.mul(mid, D.from('0.99')), hi = D.mul(mid, D.from('1.01'));276      let sb = D.zero, sa = D.zero;277      for (const [p, q] of b.bids) if (D.gte(D.from(p), lo)) sb = D.add(sb, D.mul(D.from(p), D.from(q)));278      for (const [p, q] of b.asks) if (D.lte(D.from(p), hi)) sa = D.add(sa, D.mul(D.from(p), D.from(q)));279      bid = D.str(D.div(sb, D.one, 2, 'down')); ask = D.str(D.div(sa, D.one, 2, 'down'));280    }281    const v = s.inst.venue;282    rows.push({ venue: v, instrument: s.inst.id, mark: s.ticker.mark, index: s.ticker.index, funding: s.ticker.funding, fundingVenue: { rate: s.ticker.funding, intervalSec: s.inst.fundingIntervalSec },283      spreadBps, depthUsd1pct: bid !== null && ask !== null ? D.str(D.add(D.from(bid), D.from(ask))) : null, depthBidUsd1pct: bid, depthAskUsd1pct: ask,284      takerFeeBps: v === 'hyperliquid' ? '4.5' : v === 'dydx' ? '5' : null, maxLeverage: s.inst.maxLeverage, collateral: s.inst.collateral, settlement: s.inst.settlement,285      restriction: VENUES.find((x) => x.id === v)?.restriction ?? null,286      notes: v === 'hyperliquid' ? ['depth is a lower bound: the book is read 20 levels deep per side', 'mark = median of oracle-anchored EMA, own book and CEX perp mids; the oracle is USDT-denominated']287        : v === 'dydx' ? ['oracle price only (no separate mark)', 'market data only on this venue']288        : ['no order book: oracle price ± price impact, executed later by a keeper', 'position fee 0.04 % if the trade improves the long/short balance, 0.06 % otherwise; plus price impact and an execution fee', 'borrowing fee on the larger side'],289      ts: s.ticker.ts, source: 'venue' });290  }291  return { base: base.toUpperCase(), rows, errors: [], ts: Date.now() };292}293294// ---------------------------------------------------------------- paper accounts (the repository's real engines, the backend's rules)295interface Ev { id: number; ts: number; kind: string; code: string | null; message: string; data: Record<string, unknown> }296interface Acct { id: string; venue: VenueId; label: string; policy: RiskPolicy; paper: PaperAccount; initial: string; events: Ev[]; equity: { ts: number; equity: string; collateral: string; marginUsed: string; unrealizedPnl: string; source: 'paper' }[]; cursor: number; createdAt: number; updatedAt: number }297const ACCTS = new Map<string, Acct>();298let EVSEQ = 0;299const MAINTENANCE = '0.02';300/** services/api/src/derivatives/paper.ts `defaultPolicy` */301function defaultPolicy(venue: VenueId, collateral: string): RiskPolicy {302  const c = D.from(collateral), times = (k: string) => D.str(D.mul(c, D.from(k)));303  return { mode: 'PAPER', allowedInstruments: [`${venue}:BTC`, `${venue}:ETH`], halt: null, maxDataAgeMs: 10_000, maxDeviationBps: '200', maxMarkIndexBps: '100', maxLeverage: '3',304    maxOrderNotional: times('1'), maxGrossExposure: times('3'), maxPositionNotional: times('2'), minFreeCollateral: times('0.1'), minLiquidationDistanceBps: '1000',305    maxDailyLoss: times('0.05'), maxTotalLoss: times('0.2'), maxOrdersPerMinute: 10, maxOrdersPerDay: 200 };306}307const event = (a: Acct, kind: string, code: string | null, message: string, data: Record<string, unknown> = {}) => { a.events.push({ id: ++EVSEQ, ts: Date.now(), kind, code, message, data }); };308function snapshot(a: Acct, force = false): void {309  const now = Date.now(), minute = Math.floor(now / 60_000) * 60_000, st = a.paper.accountState(now), last = a.equity[a.equity.length - 1];310  const row = { ts: minute, equity: st.equity, collateral: st.collateral, marginUsed: st.marginUsed, unrealizedPnl: D.str(D.sub(D.from(st.equity), D.from(st.collateral))), source: 'paper' as const };311  if (last && last.ts === minute) { if (force || last.equity !== row.equity) a.equity[a.equity.length - 1] = row; } else a.equity.push(row);312  if (a.equity.length > 1500) a.equity.shift();313}314function createAcct(venue: VenueId, label: string, collateral: string, policy?: Partial<RiskPolicy>): Acct {315  const id = crypto.randomUUID(), now = Date.now(), c = D.str(D.from(collateral));316  const pol: RiskPolicy = { ...defaultPolicy(venue, c), ...(policy ?? {}) };317  const a: Acct = { id, venue, label, policy: pol, initial: c, events: [], equity: [], cursor: Math.floor(now / 3_600_000), createdAt: now, updatedAt: now,318    paper: PaperAccount.create({ venue, account: `paper:${id}`, collateral: c, config: { takerFeeRate: '0.00045', makerFeeRate: '0.00015', liquidationFeeRate: '0', feeAsset: 'USDC', leverage: pol.maxLeverage, leverageByInstrument: {}, maintenanceMarginFraction: MAINTENANCE } }) };319  ACCTS.set(id, a); snapshot(a, true);320  return a;321}322function view(a: Acct) {323  const s = a.paper.state;324  return { id: a.id, venue: a.venue, network: 'mainnet', kind: 'paper' as const, mode: a.policy.mode, label: a.label, policy: a.policy, state: a.paper.accountState(a.updatedAt), positions: a.paper.positions(), openOrders: a.paper.openOrders(),325    initialCollateral: a.initial, settings: { leverage: s.config.leverage, leverageByInstrument: s.config.leverageByInstrument, takerFeeRate: s.config.takerFeeRate, makerFeeRate: s.config.makerFeeRate, maintenanceMarginFraction: s.config.maintenanceMarginFraction,326      liquidationPrice: 'estimate (cross, single-position approximation)' },327    totals: { realizedPnl: s.realizedPnl, feesPaid: s.feesPaid, fundingNet: s.fundingNet, liquidations: s.liquidations }, createdAt: a.createdAt, updatedAt: a.updatedAt };328}329const detail = (a: Acct) => ({ ...view(a), orders: [...a.paper.state.orders].reverse().slice(0, 100), fills: [...a.paper.state.fills].reverse().slice(0, 100), funding: [...a.paper.state.funding].reverse().slice(0, 100),330  riskEvents: [...a.events].reverse().slice(0, 100), equity: a.equity });331function usage(a: Acct) {332  const now = Date.now(), st = a.paper.state, day = Math.floor(now / 86_400_000) * 86_400_000;333  const total = D.add(D.sub(D.from(st.realizedPnl), D.from(st.feesPaid)), D.from(st.fundingNet));334  return { realizedToday: D.str(total), realizedTotal: D.str(total), ordersLastMinute: st.orders.filter((o) => o.createdAt > now - 60_000).length, ordersToday: st.orders.filter((o) => o.createdAt >= day).length, clientIds: st.orders.map((o) => o.clientId) };335}336function worker(): void {337  const now = Date.now();338  for (const a of ACCTS.values()) {339    const ids = new Set([...a.paper.state.positions.map((p) => p.instrument), ...a.paper.openOrders().map((o) => o.instrument)]);340    let touched = false;341    for (const id of ids) {342      const s = sim(id); if (!s || !s.book || s.frozen) continue;343      fresh(s); touched = true;344      const r1 = a.paper.onTicker(s.ticker, now), r2 = a.paper.onBook(s.book, now);345      if (r1.liquidated || r2.liquidated) event(a, 'liquidation', null, 'equity fell below the maintenance margin: every order cancelled, every position closed at the mark (simulated liquidation)', { instrument: id });346      const hour = Math.floor(now / 3_600_000);347      if (hour !== a.cursor && s.ticker.funding) a.paper.applyFunding(s.ticker.funding, hour * 3_600_000, id);348    }349    a.cursor = Math.floor(now / 3_600_000);350    if (touched) { a.updatedAt = now; snapshot(a); }351    const lost = D.sub(D.from(a.initial), D.from(a.paper.accountState(now).equity));352    if (a.policy.halt === null && D.isPos(D.from(a.policy.maxTotalLoss)) && D.gte(lost, D.from(a.policy.maxTotalLoss))) {353      a.policy = { ...a.policy, halt: 'total loss limit reached' };354      event(a, 'loss_limit', 'total_loss_exceeded', `equity is ${D.str(lost)} below the starting collateral (limit ${a.policy.maxTotalLoss}): trading halted`);355    }356  }357}358const HL_SIG = (venue: string, tick: Dec, p: string): string | null => {359  const d = D.from(p);360  return D.isMultiple(d, stepAt(venue, tick, d)) ? null : `${p} is not on the venue's price grid (at most 5 significant figures)`;361};362type Outcome = { status: number; json: any };363/** the order as the engines take it + the first refusal, exactly in the backend's order: leverage, price grid, risk engine */364function prepare(a: Acct, b: any, clientId: string): Outcome | { a: Acct; s: Sim; req: OrderRequest; lev: string; risk: ReturnType<typeof checkOrder> } {365  const s = sim(String(b.instrument));366  if (!s) return { status: 404, json: { error: 'unknown_instrument', message: `unknown instrument ${b.instrument}` } };367  if (s.inst.venue !== a.venue || !s.book) return { status: 422, json: { error: 'instrument_mismatch', message: `${b.instrument} is not an instrument of the account's venue` } };368  if (s.frozen || Date.now() - s.ticker.ts > 15_000) return { status: 503, json: { error: 'stale_data', message: 'market data is too old' } };369  fresh(s);370  if ('clientId' in b || !D.isValid(b.size) || !D.isPos(D.from(b.size))) return { status: 400, json: { error: 'bad_request', message: 'invalid order body' } };371  const tick = D.from(s.inst.tickSize), cfg = a.paper.state.config, max = D.from(s.inst.maxLeverage);372  let price: string | null = b.price ?? null;373  if (price === null && b.type === 'market' && typeof b.slippageBps === 'number') {374    const best = b.side === 'buy' ? s.book.asks[0] : s.book.bids[0];375    if (!best) return { status: 503, json: { error: 'no_liquidity', message: 'the book has no level on that side' } };376    price = D.str(worstPriceOf(D.from(best[0]), b.side, b.slippageBps, tick, s.inst.venue));377  }378  const req: OrderRequest = { instrument: s.inst.id, side: b.side, type: b.type, size: D.str(D.from(b.size)), price: price === null ? null : D.str(D.from(price)), triggerPrice: b.triggerPrice ? D.str(D.from(b.triggerPrice)) : null,379    reduceOnly: b.reduceOnly ?? false, tif: b.tif ?? 'gtc', clientId };380  let lev = cfg.leverageByInstrument[s.inst.id] ?? D.str(D.min(D.from(cfg.leverage), max));381  const no = (code: string, reason: string) => ({ a, s, req, lev, risk: { ok: false as const, code: code as never, reason } });382  if (typeof b.leverage === 'string') {383    if (!D.isValid(b.leverage) || !D.isPos(D.from(b.leverage))) return { status: 400, json: { error: 'bad_request', message: 'invalid leverage' } };384    lev = D.str(D.from(b.leverage));385    if (D.gt(D.from(lev), max)) return no('leverage_exceeded', `leverage ${lev} is above the venue maximum ${s.inst.maxLeverage} for ${s.inst.id}`);386    if (D.gt(D.from(lev), D.from(a.policy.maxLeverage))) return no('leverage_exceeded', `leverage ${lev} is above the policy maximum ${a.policy.maxLeverage}`);387    if (!D.isMultiple(D.from(lev), D.one)) return no('leverage_exceeded', 'leverage on Hyperliquid is an integer of at least 1');388  }389  for (const [name, p] of [['price', req.price], ['trigger price', req.triggerPrice]] as const) { const why = p === null ? null : HL_SIG(s.inst.venue, tick, p); if (why) return no('price_not_on_tick', `${name}: ${why}`); }390  const risk = checkOrder(a.policy, req, { instrument: s.inst, account: a.paper.accountState(Date.now()), positions: a.paper.positions(), openOrders: a.paper.openOrders(), book: s.book, ticker: s.ticker, now: Date.now(), usage: usage(a), leverage: lev, maintenanceMarginFraction: cfg.maintenanceMarginFraction });391  return { a, s, req, lev, risk };392}393/** same simulation as an order, on a copy (services/api/src/derivatives/paper.ts `estimate`) */394function estimate(a: Acct, s: Sim, req: OrderRequest, lev: string) {395  const now = Date.now(), simAcct = PaperAccount.restore(a.paper.serialize());396  simAcct.state.config.leverageByInstrument[s.inst.id] = lev;397  const before = simAcct.signedPosition(s.inst.id), cfg = simAcct.state.config, levD = D.from(lev), size = D.from(req.size), limit = req.price === null ? null : D.from(req.price);398  const base = { leverage: lev, price: req.price, source: 'estimate' as const };399  if (limit === null) return { ...base, notional: D.str(D.mul(size, D.from(s.ticker.mark))), initialMargin: D.str(initialMargin(D.mul(size, D.from(s.ticker.mark)), levD)), fee: '0', avgFillPrice: null, slippageBps: null, liquidationPrice: null,400    freeCollateralAfter: simAcct.accountState(now).freeCollateral, filledSize: '0', restingSize: '0', status: 'rejected', rejectReason: 'a price is required (limit price, or worst acceptable price of a market order)' };401  const res = simAcct.submit(req, s.book!, s.ticker, now), order = res.order!;402  const mine = res.fills.filter((f) => f.clientId === req.clientId);403  const filled = mine.reduce((x, f) => D.add(x, D.from(f.size)), D.zero), cost = mine.reduce((x, f) => D.add(x, D.mul(D.from(f.size), D.from(f.price))), D.zero);404  let fee = mine.reduce((x, f) => D.add(x, D.from(f.fee)), D.zero);405  const open = ['pending', 'triggered', 'open', 'partially_filled'].includes(order.status), resting = open ? D.sub(size, filled) : D.zero, rejected = order.status === 'rejected';406  const avg = D.isPos(filled) ? D.div(cost, filled, 18, 'half_even') : null, maker = D.max(D.zero, D.from(cfg.makerFeeRate));407  fee = D.add(fee, D.mul(D.mul(resting, limit), maker));408  const notional = rejected ? D.mul(size, limit) : D.add(cost, D.mul(resting, limit)), total = rejected ? size : D.add(filled, resting);409  const after = req.side === 'buy' ? D.add(before, total) : D.sub(before, total), added = D.max(D.zero, D.sub(D.abs(after), D.abs(before)));410  const perUnit = D.isPos(total) ? D.div(notional, total, 18, 'up') : limit, ref = D.from(s.ticker.mid ?? s.ticker.mark);411  const slip = avg === null ? null : D.div(D.mul(req.side === 'buy' ? D.sub(avg, ref) : D.sub(ref, avg), D.from(10000n)), ref, 4, 'half_even');412  const pos = simAcct.positions().find((p) => p.instrument === s.inst.id);413  let free = D.from(simAcct.accountState(now).freeCollateral);414  if (D.isPos(resting)) {415    const cur = simAcct.state.positions.find((p) => p.instrument === s.inst.id);416    free = freeCollateralAfterOrder({ equity: simAcct.equity(), marginUsed: simAcct.marginUsed(), side: req.side, size: resting, price: limit, leverage: levD, position: cur ? { side: cur.side, size: D.from(cur.size), entryPrice: D.from(cur.entryPrice) } : null, feeRate: maker }).value;417  }418  return { ...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), slippageBps: slip === null ? null : D.str(slip),419    liquidationPrice: pos ? { value: pos.liquidationPrice, source: 'estimate' as const, 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,420    freeCollateralAfter: D.str(free), filledSize: D.str(filled), restingSize: D.str(resting), status: order.status, rejectReason: order.rejectReason };421}422function preview(a: Acct, b: any): Outcome {423  const p = prepare(a, b, `preview-${crypto.randomUUID()}`);424  if ('status' in p) return p;425  return { status: 200, json: { risk: p.risk, estimate: estimate(a, p.s, p.req, p.lev), request: p.req, ts: Date.now() } };426}427function place(a: Acct, b: any, key: string | null): Outcome {428  if (!key || !/^[A-Za-z0-9._:-]{8,100}$/.test(key)) return { status: 400, json: { error: 'idempotency', message: 'an Idempotency-Key header (8 to 100 characters: letters, digits, . _ : -) is required' } };429  const existing = a.paper.state.orders.find((o) => o.clientId === key);430  if (existing) return { status: 200, json: { order: existing, fills: a.paper.state.fills.filter((f) => f.clientId === key), idempotent: true } };431  const p = prepare(a, b, key);432  if ('status' in p) return p;433  if (!p.risk.ok) { event(a, 'order_refused', p.risk.code, p.risk.reason, { request: p.req }); return { status: 422, json: { error: p.risk.code, message: p.risk.reason } }; }434  a.paper.state.config.leverageByInstrument[p.s.inst.id] = p.lev;435  const res = a.paper.submit(p.req, p.s.book!, p.s.ticker, Date.now());436  a.updatedAt = Date.now(); snapshot(a);437  return { status: 200, json: { order: res.order, fills: res.fills, idempotent: false } };   // a paper-engine rejection is a 200 with order.status 'rejected'438}439440// ---------------------------------------------------------------- HTTP and stream441let RATE = 4;442const STREAMS = new Set<MockStream>();443class MockStream extends EventTarget {444  static readonly CONNECTING = 0; static readonly OPEN = 1; static readonly CLOSED = 2;445  readonly CONNECTING = 0; readonly OPEN = 1; readonly CLOSED = 2;446  readonly url: string; readonly withCredentials = false;447  readyState = 0;448  onopen: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; onerror: ((e: Event) => void) | null = null;449  private timer: ReturnType<typeof setTimeout> | null = null;450  private s: Sim | null;451  paused = false;452  constructor(url: string) {453    super();454    this.url = url;455    this.s = sim(new URL(url, location.origin).searchParams.get('instrument') ?? '');456    STREAMS.add(this);457    setTimeout(() => this.up(), 120);458  }459  send(type: string, data: unknown): void { if (this.readyState !== 2) this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) })); }460  up(): void {461    if (this.readyState === 2) return;462    this.readyState = 1; this.paused = false;463    this.dispatchEvent(new Event('open'));464    this.send('status', { status: 'reconnecting', ts: Date.now() });        // the backend's first event, before its upstream subscription is up465    setTimeout(() => { if (this.readyState !== 1 || this.paused) return; this.send('status', { status: 'connected', ts: Date.now() }); this.loop(); }, 150);466  }467  loop = (): void => {468    if (this.readyState !== 1 || this.paused) return;469    const s = this.s;470    if (s && !s.frozen) {471      s.advance();472      this.send('ticker', { ...s.ticker, source: 'venue' });473      if (s.book) this.send('book', { ...s.book, source: 'venue' });474      if (s.fresh.length) { this.send('trades', { instrument: s.inst.id, trades: s.fresh, source: 'venue' }); s.fresh = []; }475    }476    this.timer = setTimeout(this.loop, s?.inst.venue === 'gmx' ? 3000 : 1000 / RATE);   // GMX is polled every 3 s by the backend477  };478  stale(): void { this.paused = true; if (this.timer) clearTimeout(this.timer); this.send('status', { status: 'stale', ts: Date.now() }); }479  drop(): void { this.paused = true; if (this.timer) clearTimeout(this.timer); this.readyState = 0; this.dispatchEvent(new Event('error')); }480  close(): void { this.readyState = 2; if (this.timer) clearTimeout(this.timer); STREAMS.delete(this); }481}482483/** controlled live trading (mockLive.ts) on top of the same simulated markets */484const LIVE = createLiveMock({ sim: (id) => sim(id), fresh: (s) => fresh(s as Sim), priceProblem: (venue, tick, p) => HL_SIG(venue, D.from(tick), p),485  worstPrice: (ref, side, bps, tick, venue) => D.str(worstPriceOf(D.from(ref), side, bps, D.from(tick), venue)) });486// >>> v7 perp live: ONE scripted perp agent whose real mode runs on the venue accounts above (mockPerpLive.ts)487const PERP = createPerpMock(LIVE.agentSide, { mark: (id) => { const s = sim(id); return s ? fresh(s).ticker.mark : null; }, lot: (id) => sim(id)?.inst.lotSize ?? '0.00001', fund: (n, usdc) => LIVE.hooks.fund(n, usdc) });488// <<< v7 perp live489/** the stream of one venue account: `status` and `account` first, then every event of the account */490class LiveMockStream extends EventTarget {491  static readonly CONNECTING = 0; static readonly OPEN = 1; static readonly CLOSED = 2;492  readonly CONNECTING = 0; readonly OPEN = 1; readonly CLOSED = 2;493  readonly url: string; readonly withCredentials = false;494  readyState = 0;495  onopen: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; onerror: ((e: Event) => void) | null = null;496  private off: (() => void) | null = null;497  constructor(url: string) {498    super();499    this.url = url;500    setTimeout(() => { if (this.readyState === 2) return; this.readyState = 1; this.dispatchEvent(new Event('open'));501      this.off = LIVE.subscribe(url, (event, data) => { if (this.readyState === 1) this.dispatchEvent(new MessageEvent(event, { data: JSON.stringify(data) })); }); }, 80);502  }503  close(): void { this.readyState = 2; this.off?.(); }504}505506// --- router (simulation) ---507const ROUTER = createRouterMock({508  instruments: () => SEEDS.map((s) => s.inst),509  markets: (base) => SEEDS.filter((s) => s.inst.base.toUpperCase() === base).map((sd) => { const s = fresh(sim(sd.inst.id)!); return { inst: s.inst, ticker: s.ticker, book: s.book }; }),510  restriction: (venue) => VENUES.find((v) => v.id === venue)?.restriction ?? null,511  gmxDecimals: (base) => GMX_DECIMALS[base] ?? 18,512  account: (id) => { const a = ACCTS.get(id); if (!a) return null; const v = view(a); return { id: a.id, venue: a.venue, label: a.label, mode: a.policy.mode, equity: v.state.equity, freeCollateral: v.state.freeCollateral, preview: (body) => preview(a, body) }; },513});514// --- /router ---515const json = (status: number, j: unknown): Response => new Response(JSON.stringify(j), { status, headers: { 'content-type': 'application/json' } });516const out = (o: Outcome) => json(o.status, o.json);517async function route(method: string, path: string, q: URLSearchParams, b: any, headers: Headers): Promise<Response> {518  const venue = (q.get('venue') || undefined) as VenueId | undefined, now = Date.now();519  if (path === '/auth/state') return json(200, { setupRequired: false, authenticated: true });520  if (path === '/auth/logout') return json(200, {});521  if (path === '/settings') return json(200, { locale: 'fr', currency: 'CAD', hideBalances: false, prices: true });522  if (PERP.handles(path)) return out(PERP.route(method, path, b, q));   // v7 perp live523  if (!path.startsWith('/derivatives')) return json(503, { error: 'mock', message: 'mock mode: only the Futures terminal is simulated' });524  if (path.startsWith('/derivatives/live/')) return out(LIVE.route(method, path, b, headers, q));525  const p = path.slice('/derivatives'.length);526  const unknown = () => json(404, { error: 'unknown_instrument', message: 'unknown instrument' });527  if (venue && !VENUES.some((v) => v.id === venue)) return json(404, { error: 'unknown_venue', message: `unknown venue ${venue}` });528  if (p === '/venues') return json(200, { venues: VENUES.map((v) => ({ ...v, lastOkAt: now })), bounds: {}, ts: now });529  if (p === '/instruments') return json(200, { instruments: SEEDS.filter((s) => !venue || s.inst.venue === venue).map((s) => s.inst), ts: now, errors: [] });530  if (p === '/tickers') return json(200, { tickers: SEEDS.filter((s) => !venue || s.inst.venue === venue).map((s) => { const live = SIMS.get(s.inst.id); return live ? fresh(live).ticker : { ...s.ticker, ts: now }; }), ts: now, source: 'venue', errors: [] });531  if (p === '/market') { const s = sim(q.get('instrument') ?? ''); if (!s) return unknown(); fresh(s);532    return json(200, { instrument: s.inst, ticker: { ...s.ticker, source: 'venue' }, book: s.book ? { ...s.book, source: 'venue' } : null, liquidity: s.seed.liquidity ? { ...s.seed.liquidity, instrument: s.inst.id, symbol: s.inst.symbol, ts: now, source: 'venue' } : null,533      trades: s.trades.slice(0, 50), errors: {}, ts: now, source: 'venue' }); }534  if (p === '/candles') { const s = sim(q.get('instrument') ?? ''); if (!s) return unknown(); const interval = (q.get('interval') ?? '1h') as Interval;535    return json(200, { instrument: s.inst.id, interval, candles: candles(fresh(s), interval, Math.min(1000, Number(q.get('limit')) || 300)), ts: now, source: 'venue' }); }536  if (p === '/funding') { const s = sim(q.get('instrument') ?? ''); if (!s) return unknown();537    const hours = Math.min(480, Number(q.get('hours')) || 24), r = rng(`${s.inst.id}:f`), base = Number(s.ticker.funding ?? 0), H = 3_600_000, end = Math.floor(now / H) * H;538    return json(200, { instrument: s.inst.id, history: Array.from({ length: hours }, (_, i) => ({ ts: end - (hours - 1 - i) * H, rate: n2sSigned(base * (0.6 + r() * 0.8)) })), intervalSec: s.inst.fundingIntervalSec, ts: now, source: 'venue' }); }539  if (p === '/compare') return json(200, compare(q.get('base') ?? 'BTC'));540  // --- router (simulation): the real router on the mock's markets (mockRouter.ts) ---541  if (p === '/route/assets' && method === 'GET') return out(ROUTER.assets());542  if (p === '/route' && method === 'POST') return out(ROUTER.route(b));543  // --- /router ---544  if (p === '/watch') {545    const acct = q.get('account') ?? '';546    if (venue === 'dydx') return json(403, { error: 'venue_restricted', message: DYDX_RESTRICTION });547    if (!/^0x[0-9a-fA-F]{40}$/.test(acct)) return json(400, { error: 'bad_account', message: 'an account is a 20-byte hex address' });548    return json(200, watch(acct, venue ?? 'hyperliquid'));549  }550  if (p === '/accounts' && method === 'GET') return json(200, { accounts: [...ACCTS.values()].map(view), ts: now });551  if (p === '/accounts' && method === 'POST') {552    const v = VENUES.find((x) => x.id === b?.venue);553    if (!v) return json(404, { error: 'unknown_venue', message: 'unknown venue' });554    if (v.restriction) return json(403, { error: 'venue_restricted', message: v.restriction });555    if (!v.paper) return json(422, { error: 'paper_unsupported', message: `${v.name} has no order book: the paper engine fills against a live book, so no paper account is offered on this venue` });556    if (typeof b.label !== 'string' || !b.label.trim() || !D.isValid(b.collateral) || D.lt(D.from(b.collateral), D.from(10n)) || D.gt(D.from(b.collateral), D.from(1_000_000_000n))) return json(400, { error: 'bad_request', message: 'collateral must be between 10 and 1000000000' });557    if (ACCTS.size >= 20) return json(422, { error: 'too_many_accounts', message: 'at most 20 paper accounts' });558    return json(200, detail(createAcct(v.id, b.label.trim().slice(0, 60), b.collateral, b.policy)));559  }560  const m = /^\/accounts\/([^/]+)(\/.*)?$/.exec(p);561  const a = m ? ACCTS.get(decodeURIComponent(m[1]!)) : undefined;562  if (!m || !a) return json(404, { error: 'unknown_account', message: 'unknown derivatives account' });563  const rest = m[2] ?? '', key = headers.get('idempotency-key');564  if (rest === '' && method === 'GET') return json(200, detail(a));565  if (rest === '/policy') {566    if (b.mode !== 'PAPER' && b.mode !== 'READ_ONLY') return json(422, { error: 'live_not_available', message: 'a paper account can only be PAPER or READ_ONLY: live trading does not exist in this delivery' });567    a.policy = b as RiskPolicy; a.updatedAt = now; return json(200, detail(a));568  }569  if (rest === '/orders/preview') return out(preview(a, b));570  if (rest === '/orders') return out(place(a, b, key));571  const c = /^\/orders\/([^/]+)\/cancel$/.exec(rest);572  if (c) { const r = a.paper.cancel(decodeURIComponent(c[1]!), now); a.updatedAt = now; return r.order ? json(200, { order: r.order }) : json(404, { error: 'unknown_order', message: 'unknown order' }); }573  if (rest === '/close') {574    const s = sim(String(b.instrument)), pos = a.paper.positions().find((x) => x.instrument === b.instrument);575    if (!s || !pos || !s.book) return json(422, { error: 'no_position', message: `no position on ${b.instrument}` });576    fresh(s);577    const lot = D.from(s.inst.lotSize), fr = D.from(String(b.fraction)), bps = typeof b.slippageBps === 'number' ? b.slippageBps : 50;578    const size = D.gte(fr, D.one) ? D.from(pos.size) : D.quantize(D.mul(D.from(pos.size), fr), lot, 'down');579    if (!D.isPos(size)) return json(422, { error: 'below_min_size', message: 'the fraction is smaller than one lot' });580    const side = pos.side === 'long' ? 'sell' as const : 'buy' as const, best = side === 'buy' ? s.book.asks[0] : s.book.bids[0];581    if (!best) return json(503, { error: 'no_liquidity', message: 'the book has no level on that side' });582    const worst = D.str(worstPriceOf(D.from(best[0]), side, bps, D.from(s.inst.tickSize), s.inst.venue));583    const r = place(a, { instrument: b.instrument, side, type: 'market', size: D.str(size), price: worst, triggerPrice: null, reduceOnly: true, tif: 'ioc' }, key ?? crypto.randomUUID());584    return json(r.status, r.status === 200 ? { ...r.json, worstPrice: worst, slippageBoundBps: bps } : r.json);585  }586  if (rest === '/reset') {587    const hadHalt = a.policy.halt;588    a.paper = PaperAccount.create({ venue: a.venue, account: a.paper.state.account, collateral: a.initial, config: { ...a.paper.state.config, leverageByInstrument: {} } });589    a.equity = []; a.policy = { ...a.policy, halt: null }; a.updatedAt = now;590    event(a, 'reset', null, `paper account reset: positions, orders and history wiped, collateral restored to ${a.initial}${hadHalt ? '; halt released' : ''}`, { haltReleased: hadHalt });591    snapshot(a, true);592    return json(200, detail(a));593  }594  return json(404, { error: 'not_found', message: 'unknown route' });595}596function n2sSigned(n: number): string { const s = n.toFixed(10).replace(/0+$/, '').replace(/\.$/, ''); return s === '-0' || s === '' ? '0' : s; }597/** a recorded Hyperliquid account (clearinghouseState fixture), mapped to the normalised models */598function watch(account: string, venue: VenueId) {599  const now = Date.now();600  if (venue !== 'hyperliquid') return { venue, account, state: { venue, account, equity: '0', collateral: '0', freeCollateral: '0', marginUsed: '0', maintenanceMargin: null, withdrawable: '0', ts: now, source: 'venue' }, positions: [], openOrders: [], ts: now, source: 'venue' };601  const st = body(hlStateRaw) as any;602  const positions: Position[] = (st.assetPositions as any[]).map(({ position: p }) => {603    const szi = D.from(String(p.szi)), size = D.abs(szi), mark = D.isPos(size) ? D.div(D.from(p.positionValue), size, 6, 'half_even') : D.from(p.entryPx);604    return { instrument: `hyperliquid:${p.coin}`, side: D.isNeg(szi) ? 'short' : 'long', size: D.str(size), entryPrice: D.str(D.from(p.entryPx)), markPrice: D.str(mark), liquidationPrice: p.liquidationPx ? D.str(D.div(D.from(p.liquidationPx), D.one, 4, 'half_even')) : null,605      leverage: String(p.leverage.value), marginMode: p.leverage.type === 'isolated' ? 'isolated' : 'cross', margin: p.marginUsed, unrealizedPnl: p.unrealizedPnl, realizedPnl: null, fundingPaid: p.cumFunding?.sinceOpen ?? null, source: 'venue' };606  });607  const eq = D.from(st.marginSummary.accountValue), used = D.from(st.marginSummary.totalMarginUsed);608  return { venue, account, state: { venue, account, equity: D.str(eq), collateral: st.marginSummary.totalRawUsd, freeCollateral: D.str(D.sub(eq, used)), marginUsed: D.str(used), maintenanceMargin: st.crossMaintenanceMarginUsed ?? null, withdrawable: st.withdrawable ?? null, ts: now, source: 'venue' },609    positions, openOrders: [], ts: now, source: 'venue' };610}611612declare global { interface Window { __spwMock?: { setRate: (n: number) => void; stale: () => void; reconnect: () => void; resume: () => void; accounts: () => number; live: LiveHooks; /** v7 perp live */ perp: PerpHooks } } }613614export function installMock(): void {615  PROFILES = profiles();616  SEEDS = seeds();617  const q = new URLSearchParams(location.search);618  if (Number(q.get('rate')) > 0) RATE = Math.min(60, Number(q.get('rate')));619  if (sessionStorage.getItem('spw-mock') !== 'empty') createAcct('hyperliquid', 'Simulation Hyperliquid', '10000');620  const realFetch = window.fetch.bind(window);621  window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {622    const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, location.origin);623    if (url.origin !== location.origin || !url.pathname.startsWith('/api/v1/')) return realFetch(input, init);624    await new Promise((r) => setTimeout(r, 40 + Math.random() * 60));625    let b: any = {};626    try { b = init?.body ? JSON.parse(String(init.body)) : {}; } catch { b = {}; }627    try { return await route((init?.method ?? 'GET').toUpperCase(), url.pathname.slice('/api/v1'.length), url.searchParams, b, new Headers(init?.headers)); }628    catch (e) { console.error('[mock]', e); return json(500, { error: 'mock_error', message: e instanceof Error ? e.message : String(e) }); }629  };630  const RealES = window.EventSource;631  window.EventSource = new Proxy(RealES, { construct(target, args: [string, EventSourceInit?]) {632    const url = String(args[0]);633    if (LIVE.isStream(url)) return new LiveMockStream(url) as unknown as EventSource;634    return url.includes('/api/v1/derivatives/stream') ? (new MockStream(url) as unknown as EventSource) : new target(...args);635  } });636  setInterval(worker, 1000);637  setInterval(LIVE.step, 1000);638  setInterval(PERP.step, 1000);   // v7 perp live: a stopping agent closes its positions639  LIVE.installPasskey();640  // >>> v7 perp live: `&perp=<scenario>` starts the scripted perp agent in a scenario (kept for the tab, like `mock=1`; `&perp=` forgets it)641  if (q.has('perp')) { if (q.get('perp')) sessionStorage.setItem('spw-mock-perp', q.get('perp')!); else sessionStorage.removeItem('spw-mock-perp'); }642  const scenario = sessionStorage.getItem('spw-mock-perp');643  if (scenario) PERP.hooks.scenario(scenario as Parameters<PerpHooks['scenario']>[0]);644  // <<< v7 perp live645  if (q.get('mainnet') === '1') LIVE.hooks.mainnet(true);   // `&mainnet=1`: the simulated server setting SPW_DERIV_LIVE_MAINNET starts on646  const freeze = (v: boolean) => { for (const s of SIMS.values()) s.frozen = v; };647  window.__spwMock = {648    setRate: (n) => { RATE = Math.max(1, Math.min(60, n)); },649    stale: () => { freeze(true); for (const s of STREAMS) s.stale(); },650    reconnect: () => { freeze(true); for (const s of STREAMS) s.drop(); setTimeout(() => { freeze(false); for (const s of STREAMS) s.up(); }, 2500); },651    resume: () => { freeze(false); for (const s of STREAMS) s.up(); },652    accounts: () => ACCTS.size,653    live: LIVE.hooks,654    perp: PERP.hooks,   // v7 perp live655  };656  console.info('[SP Wallet] mock mode: derivatives API simulated in the browser from recorded fixtures — no backend, no venue.');657}658
Files main
Menu

Theme