Skip to content
markpaper

src/markets/parse.ts

v0.2.1 · 7.1 KB

Download file
// Fail-closed decoding of `orderBookDetails` and `orderBooks` (knowledge base: instances-and-api.md §2.1-2.2).
//
// A duplicate symbol or market_id, a record without decimals, a non-integer market_id: the whole payload
// is rejected, not "the other markets are fine". A truncated or corrupt meta that decodes partially is
// indistinguishable from a delisting, and a delisting drives cancels and closes.

import { maxLeverageFromImf } from '../numbers/margin.js';
import { MAX_DECIMALS } from '../numbers/quantize.js';
import { isUsdgDuplicate } from './symbols.js';
import { type LighterMarket, LighterMetaError, type LighterOrderBookRow } from './types.js';

export interface ParseOrderBookDetailsOptions {
  /** Market types to keep. Default `['perp']`; spot markets are not covered by this kit. */
  marketTypes?: readonly string[];
}

const num = (v: unknown): number => {
  if (typeof v === 'number') return v;
  if (typeof v === 'string' && v.trim() !== '') return Number(v);
  return Number.NaN;
};
const str = (v: unknown): string => (typeof v === 'string' ? v : v === undefined || v === null ? '' : String(v));

function isRecord(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

function preview(v: unknown): string {
  try {
    return JSON.stringify(v).slice(0, 160);
  } catch {
    return String(v);
  }
}

function decodeMarket(raw: Record<string, unknown>): LighterMarket {
  const symbol = typeof raw.symbol === 'string' ? raw.symbol.trim() : '';
  const marketId = num(raw.market_id);
  if (!symbol || !Number.isInteger(marketId) || marketId < 0) {
    throw new LighterMetaError(`orderBookDetails: broken record ${preview(raw)}`);
  }
  const sizeDecimals = num(raw.supported_size_decimals);
  const priceDecimals = num(raw.supported_price_decimals);
  const validDecimals = (d: number) => Number.isInteger(d) && d >= 0 && d <= MAX_DECIMALS;
  if (!validDecimals(sizeDecimals) || !validDecimals(priceDecimals)) {
    throw new LighterMetaError(`orderBookDetails: no size/price decimals for ${symbol}`);
  }
  const minBase = num(raw.min_base_amount);
  const minQuote = num(raw.min_quote_amount);
  const mark = num(raw.mark_price);
  const last = num(raw.last_trade_price);
  const minImf = num(raw.min_initial_margin_fraction);
  const defImf = num(raw.default_initial_margin_fraction);
  return {
    symbol,
    marketId,
    marketType: str(raw.market_type),
    status: str(raw.status),
    sizeDecimals,
    priceDecimals,
    minBaseAmount: Number.isFinite(minBase) && minBase > 0 ? minBase : 0,
    minBaseAmountRaw: str(raw.min_base_amount),
    minQuoteUsd: Number.isFinite(minQuote) && minQuote > 0 ? minQuote : 0,
    minQuoteUsdRaw: str(raw.min_quote_amount),
    markPrice: Number.isFinite(mark) && mark > 0 ? mark : 0,
    markPriceRaw: str(raw.mark_price),
    lastTradePrice: Number.isFinite(last) && last > 0 ? last : 0,
    minInitialMarginFraction: Number.isFinite(minImf) ? minImf : 0,
    defaultInitialMarginFraction: Number.isFinite(defImf) ? defImf : 0,
    maxLeverage: maxLeverageFromImf(Number.isFinite(minImf) ? minImf : 0),
    isUsdgDuplicate: isUsdgDuplicate(symbol),
  };
}

/**
 * Decodes `GET /api/v1/orderBookDetails` (`{ order_book_details: [...] }`) into a map keyed by symbol.
 *
 * Fail-closed: not an array, a broken record, missing decimals, a duplicate symbol or market_id, or no
 * market at all -> {@link LighterMetaError}. Markets whose `market_type` is not in `marketTypes` are
 * skipped silently (they are not corruption, they are other products).
 *
 * Observed 2026-09-16 (robinhoodchain, public read): `order_book_details` carried perps only; spot
 * markets (`*\/USDG`) live in a separate `spot_order_book_details` array that this function does not
 * read (@experimental: their shape was not consumed by the knowledge base).
 */
export function parseOrderBookDetails(
  payload: unknown,
  opts: ParseOrderBookDetailsOptions = {},
): Map<string, LighterMarket> {
  const types = opts.marketTypes ?? ['perp'];
  const books = isRecord(payload) ? payload.order_book_details : undefined;
  if (!Array.isArray(books)) throw new LighterMetaError('orderBookDetails: no order_book_details array');
  const out = new Map<string, LighterMarket>();
  const seenIds = new Set<number>();
  for (const raw of books) {
    if (!isRecord(raw)) throw new LighterMetaError(`orderBookDetails: non-object record ${preview(raw)}`);
    if (!types.includes(str(raw.market_type))) continue;
    const m = decodeMarket(raw);
    if (out.has(m.symbol)) throw new LighterMetaError(`orderBookDetails: duplicate symbol ${m.symbol}`);
    if (seenIds.has(m.marketId)) throw new LighterMetaError(`orderBookDetails: duplicate market_id ${m.marketId}`);
    seenIds.add(m.marketId);
    out.set(m.symbol, m);
  }
  if (out.size === 0) throw new LighterMetaError('orderBookDetails: no markets of the requested type');
  return out;
}

/**
 * Decodes `GET /api/v1/orderBooks` (`{ order_books: [...] }`), the flat market list used to check a
 * listing. Only `symbol` and `market_id` are required; the rest of each row is kept in `raw`.
 * Duplicate symbol / market_id -> {@link LighterMetaError}.
 *
 * @experimental The full row shape (fees, decimals, minimums) is documented by Lighter but the
 * knowledge base only ever consumed `symbol`; everything beyond the two required fields is passed
 * through untouched and unvalidated.
 */
export function parseOrderBooks(payload: unknown): LighterOrderBookRow[] {
  const rows = isRecord(payload) ? payload.order_books : undefined;
  if (!Array.isArray(rows)) throw new LighterMetaError('orderBooks: no order_books array');
  const out: LighterOrderBookRow[] = [];
  const seenSymbols = new Set<string>();
  const seenIds = new Set<number>();
  for (const raw of rows) {
    if (!isRecord(raw)) throw new LighterMetaError(`orderBooks: non-object row ${preview(raw)}`);
    const symbol = typeof raw.symbol === 'string' ? raw.symbol.trim() : '';
    const marketId = num(raw.market_id);
    if (!symbol || !Number.isInteger(marketId) || marketId < 0)
      throw new LighterMetaError(`orderBooks: broken row ${preview(raw)}`);
    if (seenSymbols.has(symbol)) throw new LighterMetaError(`orderBooks: duplicate symbol ${symbol}`);
    if (seenIds.has(marketId)) throw new LighterMetaError(`orderBooks: duplicate market_id ${marketId}`);
    seenSymbols.add(symbol);
    seenIds.add(marketId);
    out.push({
      symbol,
      marketId,
      status: str(raw.status),
      marketType: str(raw.market_type),
      isUsdgDuplicate: isUsdgDuplicate(symbol),
      raw,
    });
  }
  return out;
}

/** `market_id` -> symbol map for decoding `market_index` in orders and positions. */
export function symbolByMarketId(markets: Iterable<LighterMarket>): Map<number, string> {
  const out = new Map<number, string>();
  for (const m of markets) out.set(m.marketId, m.symbol);
  return out;
}

/** `mark_price` by symbol (`orderBookDetails` is the only mid source the kit uses). */
export function markPrices(markets: Iterable<LighterMarket>): Map<string, number> {
  const out = new Map<string, number>();
  for (const m of markets) if (m.markPrice > 0) out.set(m.symbol, m.markPrice);
  return out;
}
All files