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