src/markets/parse.ts
v0.2.1 · 7.1 KB
// 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;
}