Repository Public
1// Perpetuals market data on top of the venue adapters (sp-market-data, perps): short caches, explicit ages, one shared2// upstream stream per instrument, and the venue comparison table.3//4// Freshness rule: every answer carries the time it was read (`ts`). A cached value is served while it is younger than its5// `ttlMs`; when the venue cannot be reached an older value is served only while it is younger than `maxAgeMs`, with its real6// `ts`; beyond that the request fails with 503 — old data is never presented as current.7import { D, VenueError, type Book, type Candle, type DecStr, type GmxLiquidity, type Instrument, type PublicTrade, type Ticker, type VenueId } from '@spw/derivatives';8import { UserError } from '../config.ts';9import type { ReadAdapter, VenueRegistry } from './venues.ts';1011export type CandleInterval = '1m' | '5m' | '15m' | '1h' | '4h' | '1d';12export const CANDLE_INTERVALS = ['1m', '5m', '15m', '1h', '4h', '1d'] as const;1314/** cache ages, in milliseconds: `ttl` = how long a value is reused, `maxAge` = oldest value that may still be served when the venue fails */15export const DATA_BOUNDS = {16 instruments: { ttl: 600_000, maxAge: 3_600_000 },17 tickers: { ttl: 3_000, maxAge: 15_000 },18 book: { ttl: 1_000, maxAge: 10_000 },19 trades: { ttl: 2_000, maxAge: 15_000 },20 liquidity: { ttl: 5_000, maxAge: 30_000 },21 candles: { ttl: 10_000, maxAge: 120_000 },22 funding: { ttl: 60_000, maxAge: 900_000 },23 watch: { ttl: 5_000, maxAge: 5_000 },24} as const;25/** a venue failure is remembered this long: a venue that is down is not hammered by every request */26export const FAILURE_BACKOFF_MS = 1_000;27/** a book whose own (venue) timestamp is older than this is refused, whatever the cache says */28export const BOOK_MAX_VENUE_AGE_MS = 15_000;29/** levels per side sent to stream listeners and returned by /market */30export const BOOK_DEPTH = 20;31/** Hyperliquid's fundingHistory answers at most 500 rows from the start time: more hours than that would lose the newest rows */32export const FUNDING_MAX_HOURS = 480;33const STREAM_POLL_MS = 3_000;34const BPS = D.from(10000n);3536export interface Stamped<T> { value: T; ts: number; stale: boolean }37export type StreamEvent =38 | { event: 'status'; data: { status: 'connected' | 'reconnecting' | 'stale'; ts: number } }39 | { event: 'ticker'; data: Ticker & { source: 'venue' } }40 | { event: 'book'; data: Book & { source: 'venue' } }41 | { event: 'trades'; data: { instrument: string; trades: PublicTrade[]; source: 'venue' } }42 | { event: 'error'; data: { message: string; ts: number } };43export type StreamListener = (ev: StreamEvent) => void;4445export interface CompareRow {46 venue: VenueId; instrument: string; mark: DecStr; index: DecStr | null;47 /** fraction of notional that longs pay per HOUR (negative: longs receive); null when the venue publishes none */48 funding: DecStr | null;49 /** the venue's own figure and interval, before normalisation */50 fundingVenue: { rate: DecStr | null; intervalSec: number };51 spreadBps: DecStr | null; depthUsd1pct: DecStr | null; depthBidUsd1pct: DecStr | null; depthAskUsd1pct: DecStr | null;52 takerFeeBps: DecStr | null; maxLeverage: DecStr; collateral: string; settlement: string;53 restriction: string | null; notes: string[]; ts: number; source: 'venue';54}5556/** VenueError → what the API answers. A venue that cannot be read is a 503, a request the venue refuses is a 422. */57export function venueFailure(e: unknown): unknown {58 if (!(e instanceof VenueError)) return e;59 const msg = e.message.slice(0, 240);60 if (e.code === 'rate_limited') return new UserError('venue_rate_limited', msg, 503);61 if (e.code === 'rejected' || e.code === 'unsupported') return new UserError('venue_rejected', msg, 422);62 return new UserError('venue_unavailable', msg, 503);63}6465export function splitInstrumentId(id: string): { venue: string; symbol: string } {66 const k = id.indexOf(':');67 if (k < 1 || k === id.length - 1) throw new UserError('bad_instrument', 'instrument must be "<venue>:<symbol>"', 400);68 return { venue: id.slice(0, k), symbol: id.slice(k + 1) };69}7071interface Slot { value: unknown; at: number }72interface Hub {73 inst: Instrument; listeners: Set<StreamListener>; closers: (() => void)[]; status: 'connected' | 'reconnecting' | 'stale' | null;74 everConnected: boolean; ticker: Ticker | null; book: Book | null; trades: PublicTrade[]; closed: boolean; timer: ReturnType<typeof setInterval> | null;75}7677export class DerivData {78 readonly registry: VenueRegistry;79 readonly now: () => number;80 private readonly slots = new Map<string, Slot>();81 private readonly inflight = new Map<string, Promise<Slot>>();82 private readonly failed = new Map<string, { until: number; error: unknown }>();83 private readonly hubs = new Map<string, Hub>();84 /** upstream stream subscriptions opened since start (tests and diagnostics) */85 readonly streamStats = { opened: 0, closed: 0 };8687 constructor(registry: VenueRegistry, o: { now?: () => number } = {}) { this.registry = registry; this.now = o.now ?? Date.now; }8889 // ------------------------------------------------------------------ cache9091 /**92 * `within`: the caller needs a value read at most that many ms ago (order flow, snapshot after a gap). Such a caller is never93 * given an older value when the venue fails — it gets the failure.94 */95 private async cached<T>(key: string, venue: VenueId, bounds: { ttl: number; maxAge: number }, fn: (a: ReadAdapter) => Promise<T>, o: { within?: number } = {}): Promise<Stamped<T>> {96 const slot = this.slots.get(key), now = this.now(), strict = o.within !== undefined;97 if (slot && now - slot.at < (o.within ?? bounds.ttl)) return { value: slot.value as T, ts: slot.at, stale: false };98 const serveOld = (err: unknown): Stamped<T> => {99 // an older value may stand in for a venue that cannot be reached, with its real time — never beyond its bound100 const s = this.slots.get(key);101 if (s && !strict && this.now() - s.at <= bounds.maxAge) return { value: s.value as T, ts: s.at, stale: true };102 throw venueFailure(err);103 };104 const f = this.failed.get(key);105 if (f && now < f.until) return serveOld(f.error);106 let p = this.inflight.get(key);107 if (!p) {108 p = this.registry.call(venue, fn).then((value) => {109 const s = { value, at: this.now() };110 this.slots.set(key, s); this.failed.delete(key);111 return s;112 }, (err) => {113 if (err instanceof VenueError && err.code !== 'rejected' && err.code !== 'unsupported') this.failed.set(key, { until: this.now() + FAILURE_BACKOFF_MS, error: err });114 throw err;115 }).finally(() => { this.inflight.delete(key); });116 this.inflight.set(key, p);117 }118 try { const s = await p; return { value: s.value as T, ts: s.at, stale: false }; } catch (err) { return serveOld(err); }119 }120121 // ------------------------------------------------------------------ reads122123 async instruments(venue: VenueId): Promise<Stamped<Instrument[]>> {124 this.registry.get(venue);125 return this.cached(`instruments:${venue}`, venue, DATA_BOUNDS.instruments, async (a) => {126 const rank = (i: Instrument): number => (i.status === 'active' ? 0 : i.status === 'reduce_only' ? 1 : 2);127 return (await a.instruments()).map((i, k) => ({ i, k })).sort((x, y) => rank(x.i) - rank(y.i) || x.k - y.k).map((x) => x.i);128 });129 }130131 async instrument(id: string): Promise<Instrument> {132 const { venue } = splitInstrumentId(id);133 const list = await this.instruments(this.registry.get(venue).id);134 const inst = list.value.find((i) => i.id === id);135 if (!inst) throw new UserError('unknown_instrument', `unknown instrument '${id.slice(0, 80)}'`, 404);136 return inst;137 }138139 async tickers(venue: VenueId, o: { within?: number } = {}): Promise<Stamped<Ticker[]>> {140 this.registry.get(venue);141 return this.cached(`tickers:${venue}`, venue, DATA_BOUNDS.tickers, (a) => a.tickers(), o);142 }143144 async ticker(inst: Instrument, o: { within?: number } = {}): Promise<Ticker> {145 const all = await this.tickers(inst.venue, o);146 const t = all.value.find((x) => x.instrument === inst.id);147 if (!t) throw new UserError('venue_unavailable', `${inst.venue} publishes no ticker for ${inst.symbol}`, 503);148 return t;149 }150151 hasBook(inst: Instrument): boolean { return this.registry.get(inst.venue).facts.hasOrderBook; }152153 /** the order book, or null on a venue that has none (GMX). Refused when the venue's own timestamp is too old. */154 async book(inst: Instrument, o: { within?: number } = {}): Promise<Book | null> {155 if (!this.hasBook(inst)) return null;156 const b = await this.cached(`book:${inst.id}`, inst.venue, DATA_BOUNDS.book, (a) => a.book(inst.symbol), o);157 const age = this.now() - b.value.ts;158 if (age > BOOK_MAX_VENUE_AGE_MS) throw new UserError('stale_data', `the ${inst.venue} order book of ${inst.symbol} is ${Math.round(age / 1000)} s old (limit ${BOOK_MAX_VENUE_AGE_MS / 1000} s)`, 503);159 return b.value;160 }161162 async trades(inst: Instrument, limit = 50): Promise<PublicTrade[]> {163 const t = await this.cached(`trades:${inst.id}`, inst.venue, DATA_BOUNDS.trades, (a) => a.trades(inst.symbol, 100));164 return [...t.value].sort((x, y) => y.ts - x.ts).slice(0, limit);165 }166167 /** what GMX has instead of a book; null on order-book venues */168 async liquidity(inst: Instrument): Promise<GmxLiquidity | null> {169 if (!this.registry.get(inst.venue).adapter.liquidity) return null;170 // one read for every market of the venue, shared by all its instruments171 const all = await this.cached(`liquidity:${inst.venue}`, inst.venue, DATA_BOUNDS.liquidity, (a) => a.liquidity!());172 return all.value.find((x) => x.instrument === inst.id) ?? null;173 }174175 async candles(inst: Instrument, interval: CandleInterval, limit: number): Promise<Stamped<Candle[]>> {176 return this.cached(`candles:${inst.id}:${interval}:${limit}`, inst.venue, DATA_BOUNDS.candles, async (a) => (await a.candles(inst.symbol, interval, { limit })).slice(-limit)); // a venue may answer more bars than asked177 }178179 async funding(inst: Instrument, hours: number): Promise<Stamped<{ ts: number; rate: DecStr }[]>> {180 return this.cached(`funding:${inst.id}:${hours}`, inst.venue, DATA_BOUNDS.funding, async (a) =>181 (await a.fundingHistory(inst.symbol, { start: this.now() - hours * 3_600_000 })).map((x) => ({ ts: x.ts, rate: x.rate })));182 }183184 /** uncached funding rates applied by the venue since `start` (paper worker: the rate of each funding time) */185 async fundingSince(inst: Instrument, start: number): Promise<{ ts: number; rate: DecStr }[]> {186 try { return (await this.registry.call(inst.venue, (a) => a.fundingHistory(inst.symbol, { start }))).map((x) => ({ ts: x.ts, rate: x.rate })); }187 catch (e) { throw venueFailure(e); }188 }189190 async market(id: string): Promise<{ instrument: Instrument; ticker: Ticker & { source: 'venue' }; book: (Book & { source: 'venue' }) | null; liquidity: (GmxLiquidity & { source: 'venue' }) | null;191 trades: PublicTrade[]; errors: Record<string, string>; ts: number; source: 'venue' }> {192 const inst = await this.instrument(id);193 const errors: Record<string, string> = {};194 const soft = async <T>(what: string, p: Promise<T>, dflt: T): Promise<T> => p.catch((e: unknown) => { errors[what] = e instanceof Error ? e.message.slice(0, 200) : 'error'; return dflt; });195 // the ticker is the minimum: without it there is nothing honest to show. Book, liquidity and trades fail separately.196 const [ticker, book, liquidity, trades] = await Promise.all([197 this.ticker(inst), soft('book', this.book(inst), null), soft('liquidity', this.liquidity(inst), null), soft('trades', this.trades(inst), [] as PublicTrade[]),198 ]);199 return { instrument: inst, ticker: { ...ticker, source: 'venue' }, book: book ? { ...capBook(book), source: 'venue' } : null,200 liquidity: liquidity ? { ...liquidity, source: 'venue' } : null, trades, errors, ts: this.now(), source: 'venue' };201 }202203 /** read-only view of a public address on a venue that allows account reads */204 async watch(venue: VenueId, account: string) {205 this.registry.assertAccountAllowed(venue);206 const r = await this.cached(`watch:${venue}:${account.toLowerCase()}`, venue, DATA_BOUNDS.watch, async (a) => {207 const [state, positions, openOrders] = await Promise.all([a.account(account), a.positions(account), a.openOrders(account)]);208 return { state, positions, openOrders };209 });210 return { venue, account, ...r.value, ts: r.ts, source: 'venue' as const };211 }212213 // ------------------------------------------------------------------ comparison214215 /** One row per instrument on `base` across the venues. A table of facts and live figures — never a recommendation. */216 async compare(base: string): Promise<{ base: string; rows: CompareRow[]; errors: { venue: VenueId; message: string }[]; ts: number }> {217 const want = base.toUpperCase(), rows: CompareRow[] = [], errors: { venue: VenueId; message: string }[] = [];218 await Promise.all(this.registry.list().map(async (v) => {219 try {220 const insts = (await this.instruments(v.id)).value.filter((i) => i.base.toUpperCase() === want && i.status !== 'halted');221 if (insts.length === 0) return;222 const tickers = await this.tickers(v.id);223 for (const inst of insts) {224 const t = tickers.value.find((x) => x.instrument === inst.id);225 if (!t) continue;226 const notes = [...v.facts.notes];227 let spreadBps: DecStr | null = null, bid: DecStr | null = null, ask: DecStr | null = null, takerFeeBps = v.facts.takerFeeBps;228 if (v.facts.hasOrderBook) {229 const book = await this.book(inst).catch((e: unknown) => { notes.push(`order book unavailable: ${e instanceof Error ? e.message.slice(0, 120) : 'error'}`); return null; });230 if (book) {231 const m = bookMetrics(book);232 spreadBps = m.spreadBps; bid = m.depthBidUsd1pct; ask = m.depthAskUsd1pct;233 if (m.partial) notes.push(`The book snapshot (${book.bids.length} bids, ${book.asks.length} asks) does not reach 1 % from the mid on at least one side: the depth within 1 % is a lower bound.`);234 }235 } else {236 const l = await this.liquidity(inst).catch((e: unknown) => { notes.push(`liquidity figures unavailable: ${e instanceof Error ? e.message.slice(0, 120) : 'error'}`); return null; });237 if (l) {238 takerFeeBps = D.str(D.mul(D.from(l.positionFeeFactor.balanceNotImproved), BPS));239 notes.push(`No spread and no book depth on this venue. Capacity still open: long ${usd0(l.capacityLongUsd)} USD (limited by ${l.capacityLimitedBy.long}), short ${usd0(l.capacityShortUsd)} USD (limited by ${l.capacityLimitedBy.short}).`);240 notes.push(`Open interest: long ${usd0(l.openInterestLongUsd)} USD, short ${usd0(l.openInterestShortUsd)} USD. Oracle range ${l.oracleMin} – ${l.oracleMax}.`);241 notes.push(`Price impact: factor ${l.priceImpact.factorNegative} (negative) / ${l.priceImpact.factorPositive} (positive) × imbalance^${l.priceImpact.exponentNegative}, capped at ${pct(l.priceImpact.maxFactorNegative)} % against and ${pct(l.priceImpact.maxFactorPositive)} % in favour of the trade.`);242 notes.push(`Position fee ${pct(l.positionFeeFactor.balanceImproved)} % when the trade improves the long/short balance, ${pct(l.positionFeeFactor.balanceNotImproved)} % otherwise. Borrowing rate per hour as the venue publishes it: long ${l.borrowingRateLongPerHour}, short ${l.borrowingRateShortPerHour} (a cost carried by the larger open-interest side, on top of funding).`);243 }244 }245 rows.push({246 venue: v.id, instrument: inst.id, mark: t.mark, index: t.index,247 funding: t.funding === null ? null : D.str(D.div(D.mul(D.from(t.funding), D.from(3600n)), D.from(BigInt(inst.fundingIntervalSec)), 12, 'half_even')),248 fundingVenue: { rate: t.funding, intervalSec: inst.fundingIntervalSec },249 spreadBps, depthUsd1pct: bid !== null && ask !== null ? D.str(D.add(D.from(bid), D.from(ask))) : null, depthBidUsd1pct: bid, depthAskUsd1pct: ask,250 takerFeeBps, maxLeverage: inst.maxLeverage, collateral: inst.collateral, settlement: inst.settlement,251 restriction: v.restriction, notes, ts: t.ts, source: 'venue',252 });253 }254 } catch (e) { errors.push({ venue: v.id, message: e instanceof Error ? e.message.slice(0, 200) : 'error' }); }255 }));256 const order = this.registry.list().map((v) => v.id);257 rows.sort((a, b) => order.indexOf(a.venue) - order.indexOf(b.venue) || a.instrument.localeCompare(b.instrument));258 return { base: want, rows, errors, ts: this.now() };259 }260261 // ------------------------------------------------------------------ shared stream262263 /**264 * Live data of one instrument for a listener. The first listener opens the upstream (the adapter's stream when it has one,265 * a poll of the cached reads otherwise); the following ones share it; the upstream is closed when the last one leaves.266 * A newcomer is told the current status and, unless the upstream is in a gap, the last ticker, book and trades.267 */268 subscribe(inst: Instrument, listener: StreamListener): () => void {269 let hub = this.hubs.get(inst.id);270 if (!hub) { hub = this.openHub(inst); this.hubs.set(inst.id, hub); }271 const h = hub;272 h.listeners.add(listener);273 listener({ event: 'status', data: { status: h.status ?? 'reconnecting', ts: this.now() } });274 // not during a gap (the hub was connected and is not any more): what it holds then may be wrong, the snapshot after the gap follows275 if (h.status === 'connected' || !h.everConnected) {276 if (h.ticker) listener({ event: 'ticker', data: { ...h.ticker, source: 'venue' } });277 if (h.book) listener({ event: 'book', data: { ...h.book, source: 'venue' } });278 if (h.trades.length) listener({ event: 'trades', data: { instrument: inst.id, trades: h.trades, source: 'venue' } });279 }280 let done = false;281 return () => {282 if (done) return;283 done = true; h.listeners.delete(listener);284 if (h.listeners.size === 0) this.closeHub(h);285 };286 }287288 listeners(instrumentId: string): number { return this.hubs.get(instrumentId)?.listeners.size ?? 0; }289 /** closes every upstream (shutdown) */290 close(): void { for (const h of [...this.hubs.values()]) this.closeHub(h); }291292 private emit(h: Hub, ev: StreamEvent): void { for (const l of [...h.listeners]) { try { l(ev); } catch { /* a listener must not break the others */ } } }293294 private closeHub(h: Hub): void {295 if (h.closed) return;296 h.closed = true; this.hubs.delete(h.inst.id);297 if (h.timer) clearInterval(h.timer);298 for (const c of h.closers) { try { c(); } catch { /* already closed */ } this.streamStats.closed += 1; }299 h.closers = []; h.listeners.clear();300 }301302 private setStatus(h: Hub, s: 'connected' | 'reconnecting' | 'stale'): void {303 if (h.closed || h.status === s) return;304 const again = s === 'connected' && h.everConnected;305 h.status = s;306 if (s === 'connected') h.everConnected = true;307 this.emit(h, { event: 'status', data: { status: s, ts: this.now() } });308 // back from a gap: what the listeners hold may be wrong — a fresh snapshot follows the status309 if (again) void this.snapshot(h);310 }311312 private take(h: Hub, ev: { type?: unknown; ticker?: Ticker; book?: Book; trades?: PublicTrade[]; reason?: unknown; message?: unknown }): void {313 if (h.closed) return;314 if (ev.type === 'ticker' && ev.ticker) { h.ticker = ev.ticker; this.emit(h, { event: 'ticker', data: { ...ev.ticker, source: 'venue' } }); }315 else if (ev.type === 'book' && ev.book) { h.book = capBook(ev.book); this.emit(h, { event: 'book', data: { ...h.book, source: 'venue' } }); }316 else if (ev.type === 'trades' && ev.trades && ev.trades.length) {317 const trades = [...ev.trades].sort((a, b) => b.ts - a.ts);318 const seen = new Set(trades.map((t) => t.id));319 h.trades = [...trades, ...h.trades.filter((t) => !seen.has(t.id))].slice(0, 50);320 this.emit(h, { event: 'trades', data: { instrument: h.inst.id, trades, source: 'venue' } });321 } else if (ev.type === 'gap') void this.snapshot(h);322 else if (ev.type === 'invalid' || ev.type === 'error') this.emit(h, { event: 'error', data: { message: String(ev.reason ?? ev.message ?? 'stream error').slice(0, 200), ts: this.now() } });323 }324325 /** fresh REST reads pushed to every listener (first data of a hub, and after any gap) */326 private async snapshot(h: Hub): Promise<void> {327 const part = async (fn: () => Promise<void>): Promise<void> => {328 try { await fn(); } catch (e) { if (!h.closed) this.emit(h, { event: 'error', data: { message: e instanceof Error ? e.message.slice(0, 200) : 'snapshot failed', ts: this.now() } }); }329 };330 await Promise.all([331 part(async () => { const t = await this.ticker(h.inst, { within: 0 }); this.take(h, { type: 'ticker', ticker: t }); }),332 part(async () => { const b = await this.book(h.inst, { within: 0 }); if (b) this.take(h, { type: 'book', book: b }); }),333 part(async () => { const t = await this.trades(h.inst, 50); if (t.length) { h.trades = []; this.take(h, { type: 'trades', trades: t }); } }),334 ]);335 }336337 private openHub(inst: Instrument): Hub {338 const h: Hub = { inst, listeners: new Set(), closers: [], status: null, everConnected: false, ticker: null, book: null, trades: [], closed: false, timer: null };339 const a = this.registry.get(inst.venue).adapter;340 if (a.stream && a.capabilities.stream) {341 const types = this.hasBook(inst) ? ['ticker', 'book', 'trades'] as const : ['ticker', 'trades'] as const;342 for (const type of types) {343 try {344 h.closers.push(a.stream({ type, symbol: inst.symbol }, (ev) => this.take(h, ev as Parameters<DerivData['take']>[1]), (s) => this.setStatus(h, s)));345 this.streamStats.opened += 1;346 } catch (e) { queueMicrotask(() => this.emit(h, { event: 'error', data: { message: e instanceof Error ? e.message.slice(0, 200) : 'stream unavailable', ts: this.now() } })); }347 }348 void this.snapshot(h);349 } else {350 // no venue stream (GMX): the hub polls the cached reads and reports a failed poll as `stale`351 const poll = async (): Promise<void> => {352 try {353 const t = await this.ticker(inst);354 if (h.closed) return;355 this.setStatus(h, 'connected');356 if (!h.ticker || h.ticker.ts !== t.ts) this.take(h, { type: 'ticker', ticker: t });357 } catch { if (!h.closed) this.setStatus(h, 'stale'); }358 };359 h.timer = setInterval(() => { void poll(); }, STREAM_POLL_MS);360 h.timer.unref?.();361 this.streamStats.opened += 1;362 h.closers.push(() => {});363 void poll();364 }365 return h;366 }367}368369const capBook = (b: Book): Book => ({ instrument: b.instrument, bids: b.bids.slice(0, BOOK_DEPTH), asks: b.asks.slice(0, BOOK_DEPTH), ts: b.ts });370const usd0 = (x: DecStr): string => D.str(D.quantize(D.from(x), D.one, 'down'));371const pct = (fraction: DecStr): string => D.str(D.mul(D.from(fraction), D.from(100n)));372373/** spread and depth within 1 % of the mid, from the real book. `partial`: a side ends before 1 % (the depth is a lower bound). */374export function bookMetrics(book: Book): { mid: DecStr | null; spreadBps: DecStr | null; depthBidUsd1pct: DecStr | null; depthAskUsd1pct: DecStr | null; partial: boolean } {375 const bb = book.bids[0], ba = book.asks[0];376 if (!bb || !ba) return { mid: null, spreadBps: null, depthBidUsd1pct: null, depthAskUsd1pct: null, partial: true };377 const bid = D.from(bb[0]), ask = D.from(ba[0]), mid = D.div(D.add(bid, ask), D.from(2n), 18, 'half_even');378 const spreadBps = D.div(D.mul(D.sub(ask, bid), BPS), mid, 4, 'half_even');379 const lo = D.mul(mid, D.from('0.99')), hi = D.mul(mid, D.from('1.01'));380 let dBid = D.zero, dAsk = D.zero;381 for (const [p, s] of book.bids) { const px = D.from(p); if (D.lt(px, lo)) break; dBid = D.add(dBid, D.mul(px, D.from(s))); }382 for (const [p, s] of book.asks) { const px = D.from(p); if (D.gt(px, hi)) break; dAsk = D.add(dAsk, D.mul(px, D.from(s))); }383 const lastBid = D.from(book.bids[book.bids.length - 1]![0]), lastAsk = D.from(book.asks[book.asks.length - 1]![0]);384 return { mid: D.str(mid), spreadBps: D.str(spreadBps), depthBidUsd1pct: D.str(D.quantize(dBid, D.from('0.01'), 'down')), depthAskUsd1pct: D.str(D.quantize(dAsk, D.from('0.01'), 'down')),385 partial: D.gte(lastBid, lo) || D.lte(lastAsk, hi) };386}387