Skip to content
markpaper

src/markets/parse.ts

v0.2.0 · 8.7 KB

Download file
// 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 };
}
All files