src/markets/parse.ts
v0.2.0 · 8.7 KB
// Fail-closed decoder of the `symbols` query.
//
// One malformed product invalidates the WHOLE response: orders are signed against `product_id`, so a
// shifted or partial payload must never remap a coin to another id. An empty perp map is also an error,
// not "no markets".
import { x18ToBigInt, x18ToNumber } from '../numbers/x18.js';
import { maxLeverageFromWeights } from './leverage.js';
import type { PerpProduct, PerpUniverse } from './types.js';
/** Thrown by {@link parseSymbols} when the payload cannot be trusted. */
export class SymbolsParseError extends Error {
override readonly name = 'SymbolsParseError';
/** Symbol (or key) of the entry that failed, when known. */
readonly symbol: string | undefined;
constructor(message: string, symbol?: string) {
super(message);
this.symbol = symbol;
}
}
const PERP_SUFFIX = '-PERP';
function hasControlChars(s: string): boolean {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 0x20 || c === 0x7f) return true;
}
return false;
}
function cleanSymbol(value: unknown, key: string): string {
if (typeof value !== 'string' || !value || value !== value.trim() || value.length > 64 || hasControlChars(value)) {
throw new SymbolsParseError(`symbols: entry ${key} has no usable symbol`, key);
}
return value;
}
function optionalX18(value: unknown, label: string): bigint | null {
if (value === undefined || value === null) return null;
return x18ToBigInt(value, label);
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/** Coin name of a venue symbol: `'BTC-PERP'` -> `'BTC'`. Symbols without the suffix pass through. */
export function coinOfSymbol(symbol: string): string {
return symbol.endsWith(PERP_SUFFIX) ? symbol.slice(0, -PERP_SUFFIX.length) : symbol;
}
/**
* Decodes the `data` of `{type:'symbols', product_type:'perp'}` (or without `product_type`; spot entries
* are skipped). Every field that orders depend on is validated: `product_id` a positive safe integer and
* unique, key equal to `symbol`, tick and lot positive x18, `min_size` >= 0, `long_weight_initial` in
* (0, 1), `trading_status` a non-empty string, coin unique.
*
* @throws SymbolsParseError on any malformed entry or an empty perp universe.
*/
export function parseSymbols(payload: unknown): PerpUniverse {
if (!isRecord(payload)) throw new SymbolsParseError('symbols: data is not an object');
const symbols = payload.symbols;
if (!isRecord(symbols)) throw new SymbolsParseError('symbols: missing symbols map');
const byCoin = new Map<string, PerpProduct>();
const byProductId = new Map<number, PerpProduct>();
const skippedSpot: string[] = [];
for (const [key, value] of Object.entries(symbols)) {
if (!isRecord(value)) throw new SymbolsParseError(`symbols: entry ${key} is not an object`, key);
const type = value.type;
if (type !== 'perp' && type !== 'spot') {
throw new SymbolsParseError(`symbols: entry ${key} has unknown type ${String(type)}`, key);
}
if (type === 'spot') {
skippedSpot.push(key);
continue;
}
const symbol = cleanSymbol(value.symbol, key);
if (symbol !== key) throw new SymbolsParseError(`symbols: key/symbol mismatch (${key} vs ${symbol})`, key);
const rawId = value.product_id;
if (typeof rawId !== 'number' || !Number.isSafeInteger(rawId) || rawId <= 0) {
throw new SymbolsParseError(`symbols: ${symbol} has invalid product_id ${String(rawId)}`, symbol);
}
const productId = rawId;
const tickX18 = x18ToBigInt(value.price_increment_x18, `${symbol} price_increment_x18`);
const lotX18 = x18ToBigInt(value.size_increment, `${symbol} size_increment`);
if (tickX18 <= 0n || lotX18 <= 0n)
throw new SymbolsParseError(`symbols: ${symbol} has non-positive tick/lot`, symbol);
const minSizeX18 = x18ToBigInt(value.min_size, `${symbol} min_size`);
if (minSizeX18 < 0n) throw new SymbolsParseError(`symbols: ${symbol} has negative min_size`, symbol);
const longWeightInitialX18 = x18ToBigInt(value.long_weight_initial_x18, `${symbol} long_weight_initial_x18`);
const longWeightInitial = x18ToNumber(longWeightInitialX18);
if (!(longWeightInitial > 0 && longWeightInitial < 1)) {
throw new SymbolsParseError(`symbols: ${symbol} has invalid long_weight_initial (${longWeightInitial})`, symbol);
}
const tradingStatus = value.trading_status;
if (typeof tradingStatus !== 'string' || tradingStatus.length === 0) {
throw new SymbolsParseError(`symbols: ${symbol} has no trading_status`, symbol);
}
const isolatedOnly = value.isolated_only;
if (isolatedOnly !== undefined && typeof isolatedOnly !== 'boolean') {
throw new SymbolsParseError(`symbols: ${symbol} has invalid isolated_only`, symbol);
}
const coin = coinOfSymbol(symbol);
if (byCoin.has(coin)) throw new SymbolsParseError(`symbols: duplicate coin '${coin}'`, symbol);
if (byProductId.has(productId)) throw new SymbolsParseError(`symbols: duplicate product_id ${productId}`, symbol);
const product: PerpProduct = {
coin,
symbol,
productId,
tickX18,
lotX18,
minSizeX18,
makerFeeRateX18: optionalX18(value.maker_fee_rate_x18, `${symbol} maker_fee_rate_x18`),
takerFeeRateX18: optionalX18(value.taker_fee_rate_x18, `${symbol} taker_fee_rate_x18`),
longWeightInitialX18,
longWeightMaintenanceX18: optionalX18(value.long_weight_maintenance_x18, `${symbol} long_weight_maintenance_x18`),
maxOpenInterestX18: optionalX18(value.max_open_interest_x18, `${symbol} max_open_interest_x18`),
tradingStatus,
isolatedOnly: isolatedOnly ?? false,
tick: x18ToNumber(tickX18),
lot: x18ToNumber(lotX18),
minNotionalUsd: x18ToNumber(minSizeX18),
longWeightInitial,
maxLeverage: maxLeverageFromWeights(longWeightInitial),
};
byCoin.set(coin, product);
byProductId.set(productId, product);
}
if (byCoin.size === 0) throw new SymbolsParseError('symbols: no perp products decoded');
return { byCoin, byProductId, skippedSpot };
}
/** Result of {@link checkProductsStable}. */
export type StabilityCheck =
| { readonly ok: true; readonly dropped: readonly string[]; readonly added: readonly string[] }
| {
readonly ok: false;
readonly reason: 'dropped' | 'product_id' | 'grid';
readonly coin: string;
readonly message: string;
};
/** Options of {@link checkProductsStable}. */
export interface StabilityOptions {
/**
* Coins that must survive a refresh: those with positions, resting orders or active trading.
* When given, a dropped coin OUTSIDE this set is reported in `dropped` but does not fail the check.
* When omitted, any dropped coin fails (strict mode).
*
* The strict rule freezes a cache after a public market rename (CIRCLE -> CRCL, 2026-08-10): every
* refresh "drops a previously verified coin", the comparison base is the frozen cache itself, and new
* listings stay invisible until a restart.
*/
pinnedCoins?: Iterable<string>;
}
/**
* A refresh may add products, but must never MOVE a known coin (changed `product_id`) or change its
* lot / tick under resting orders: that is indistinguishable from a partial or foreign payload, and
* signing against it could target another instrument. `trading_status` changes are always accepted
* (a market opening `post_only -> live` must pass a refresh).
*/
export function checkProductsStable(
previous: PerpUniverse | null | undefined,
candidate: PerpUniverse,
options: StabilityOptions = {},
): StabilityCheck {
const pinned = options.pinnedCoins === undefined ? null : new Set(options.pinnedCoins);
const dropped: string[] = [];
if (previous) {
for (const [coin, old] of previous.byCoin) {
const next = candidate.byCoin.get(coin);
if (!next) {
if (pinned === null || pinned.has(coin)) {
return {
ok: false,
reason: 'dropped',
coin,
message: `symbols refresh dropped previously verified coin '${coin}'`,
};
}
dropped.push(coin);
continue;
}
if (next.productId !== old.productId) {
return {
ok: false,
reason: 'product_id',
coin,
message: `symbols refresh changed product_id for '${coin}' (${old.productId} -> ${next.productId})`,
};
}
if (next.lotX18 !== old.lotX18 || next.tickX18 !== old.tickX18) {
return { ok: false, reason: 'grid', coin, message: `symbols refresh changed tick/lot for '${coin}'` };
}
}
}
const added: string[] = [];
for (const coin of candidate.byCoin.keys()) if (!previous?.byCoin.has(coin)) added.push(coin);
return { ok: true, dropped, added };
}