SPB Git

Repository Public

data.ts 24.1 KB 387 lines typescript

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
Files main
Menu

Theme