Skip to content
markpaper

src/markets/meta.ts

v0.1.0 · 10.7 KB

Download file
import {
  type Dec,
  decCmp,
  decDecimals,
  decEq,
  decFromNumber,
  decSign,
  decToNumber,
  decToString,
  isOnStep,
  parseDec,
  snapToStep,
} from '../numbers/index.js';
import { QFEX_QUOTE } from './symbols.js';
export interface QfexQuant {
  readonly qfex: true;
  readonly tick: Dec;
  readonly lotDec: Dec;
  readonly minQtyDec: Dec;
  readonly maxQtyDec: Dec | null;
  readonly minSz: number;
  readonly lot: number;
  readonly decimals: number;
  pxToStr(px: number): string;
  floorSz(sz: number): number;
  ceilSz(sz: number): number;
  pxToStrDir(px: number, dir: 'down' | 'up'): string;
  szToStr(sz: number): string;
  pxStrOnTick(pxStr: string): boolean;
  exceedsMaxQty(sz: number): boolean;
}
export function isQfexQuant(q: QfexQuant | undefined): q is QfexQuant {
  return !!q && (q as Partial<QfexQuant>).qfex === true;
}
function positiveDec(s: string, what: string): Dec {
  const d = parseDec(s);
  if (!d || decSign(d) <= 0 || !Number.isFinite(decToNumber(d)) || decToNumber(d) <= 0)
    throw new Error(`qfexQuant: ${what} must be a positive decimal: ${String(s).slice(0, 40)}`);
  return d;
}
function posDecOf(x: number): Dec | null {
  const d = decFromNumber(x);
  return d && decSign(d) > 0 ? d : null;
}
export function qfexQuant(m: Pick<QfexMarket, 'tick' | 'lot' | 'minQty' | 'maxQty'>): QfexQuant {
  const tick = positiveDec(m.tick, 'tick_size');
  const lotDec = positiveDec(m.lot, 'lot_size');
  const minQtyDec = positiveDec(m.minQty, 'min_quantity');
  const maxQtyDec = m.maxQty === '' ? null : positiveDec(m.maxQty, 'max_quantity');
  const minOnGrid = snapToStep(minQtyDec, lotDec, 'ceil');
  if (!Number.isFinite(decToNumber(minOnGrid)))
    throw new RangeError('Quantized minimum quantity overflows a JavaScript number');
  const onTick = (px: number, mode: 'nearest' | 'floor' | 'ceil'): string => {
    const d = posDecOf(px);
    if (!d) return '0';
    const s = snapToStep(d, tick, mode, mode === 'nearest' ? 'exact' : 'float');
    return decSign(s) > 0 ? decToString(s) : '0';
  };
  const onLot = (sz: number, mode: 'floor' | 'ceil'): Dec | null => {
    const d = posDecOf(sz);
    return d ? snapToStep(d, lotDec, mode, 'float') : null;
  };
  return {
    qfex: true,
    tick,
    lotDec,
    minQtyDec,
    maxQtyDec,
    lot: decToNumber(lotDec),
    decimals: decDecimals(lotDec),
    minSz: decToNumber(minOnGrid),
    pxToStr: (px: number) => onTick(px, 'nearest'),
    pxToStrDir: (px: number, dir: 'down' | 'up') => onTick(px, dir === 'down' ? 'floor' : 'ceil'),
    floorSz(sz: number): number {
      const d = onLot(sz, 'floor');
      return d ? decToNumber(d) : 0;
    },
    ceilSz(sz: number): number {
      const d = onLot(sz, 'ceil');
      return d ? decToNumber(d) : 0;
    },
    szToStr(sz: number): string {
      const d = onLot(sz, 'floor');
      return d && decSign(d) > 0 ? decToString(d) : '0';
    },
    pxStrOnTick(pxStr: string): boolean {
      const d = parseDec(pxStr);
      return !!d && decSign(d) > 0 && isOnStep(d, tick);
    },
    exceedsMaxQty(sz: number): boolean {
      if (!maxQtyDec) return false;
      const d = decFromNumber(sz);
      return !d || decCmp(d, maxQtyDec) > 0;
    },
  };
}
export const QFEX_STATUS_ACTIVE = 'ACTIVE';
export const QFEX_STATUSES_OFF: readonly string[] = ['INACTIVE', 'DELISTED'];
export const QFEX_MARGIN_ASSET = 'USDC';
export interface QfexMarket {
  symbol: string;

  base: string;
  quote: string;
  status: string;
  tick: string;
  lot: string;
  minQty: string;
  maxQty: string;
  refBand: {
    min: string;
    max: string;
  } | null;
  maxLeverage: number;
  marginAsset: string;
  marketHours: unknown;
  category: string | null;
}
export interface QfexRefdataDecoded {
  markets: Map<string, QfexMarket>;
  bad: Map<string, string>;
  unnamed: number;
}
const SYMBOL_RE = /^[A-Za-z0-9._]{1,32}-[A-Za-z0-9]{2,8}$/;
const QUOTE_RE = /^[A-Za-z0-9]{2,8}$/;
const BASE_ANY_RE = /^[A-Za-z0-9._]{1,32}$/;
const MAX_LEVERAGE = 1000;
const MAX_ROWS = 20000;
function intLike(v: unknown): number | null {
  if (typeof v === 'number') return Number.isSafeInteger(v) ? v : null;
  if (typeof v === 'string' && /^\d{1,6}$/.test(v)) return Number(v);
  return null;
}
function decStr(v: unknown): string | null {
  const d = typeof v === 'string' ? parseDec(v) : typeof v === 'number' ? decFromNumber(v) : null;
  return d ? decToString(d) : null;
}
function bandOf(
  minRaw: unknown,
  maxRaw: unknown,
): {
  min: string;
  max: string;
} | null {
  const min = decStr(minRaw);
  const max = decStr(maxRaw);
  if (min === null || max === null) return null;
  const a = parseDec(min)!;
  const b = parseDec(max)!;
  return decSign(a) >= 0 && decSign(b) > 0 && decCmp(a, b) < 0 ? { min, max } : null;
}
function decodeRow(m: Record<string, unknown>, symbol: string): QfexMarket | string {
  const base = m.base_asset;
  const quote = m.quote_asset;
  if (typeof base !== 'string' || !BASE_ANY_RE.test(base))
    return `Invalid base_asset: ${JSON.stringify(base)?.slice(0, 40)}`;
  if (typeof quote !== 'string' || !QUOTE_RE.test(quote))
    return `Invalid quote_asset: ${JSON.stringify(quote)?.slice(0, 40)}`;
  if (symbol !== `${base}-${quote}`) return `Symbol does not match base and quote: expected ${base}-${quote}`;
  const status = typeof m.status === 'string' ? m.status.trim() : '';
  if (!status) return 'Missing market status';
  const tick = decStr(m.tick_size);
  const lot = decStr(m.lot_size);
  const minQty = decStr(m.min_quantity);
  const maxQty = decStr(m.max_quantity);
  if (tick === null || lot === null || minQty === null || maxQty === null)
    return 'Invalid tick, lot, minimum or maximum quantity';
  for (const [name, s] of [
    ['tick_size', tick],
    ['lot_size', lot],
    ['min_quantity', minQty],
    ['max_quantity', maxQty],
  ] as const) {
    if (decSign(parseDec(s)!) <= 0) return `${name} ${s} ≤ 0`;
  }
  if (decCmp(parseDec(maxQty)!, parseDec(minQty)!) < 0) return `max_quantity ${maxQty} < min_quantity ${minQty}`;
  const lev = intLike(m.default_max_leverage);
  if (lev === null || lev < 1 || lev > MAX_LEVERAGE)
    return `default_max_leverage ${String(m.default_max_leverage).slice(0, 20)} must be an integer from 1 to ${MAX_LEVERAGE}`;
  const marginAsset = typeof m.margin_asset === 'string' ? m.margin_asset : '';
  if (!marginAsset) return 'Missing margin_asset';
  const mh = m.market_hours;
  return {
    symbol,
    base,
    quote,
    status,
    tick,
    lot,
    minQty,
    maxQty,
    refBand: bandOf(m.min_price, m.max_price),
    maxLeverage: lev,
    marginAsset,
    marketHours: mh && typeof mh === 'object' && !Array.isArray(mh) ? mh : null,
    category: typeof m.product_category === 'string' ? m.product_category : null,
  };
}
export function decodeQfexRefdataRows(payload: unknown): QfexRefdataDecoded {
  const arr = Array.isArray(payload)
    ? payload
    : (
        payload as {
          data?: unknown;
        } | null
      )?.data;
  if (!Array.isArray(arr)) throw new Error('Refdata must contain an array of markets');
  if (arr.length > MAX_ROWS) throw new Error(`Refdata has ${arr.length} rows, exceeding the decoder safety limit`);
  const markets = new Map<string, QfexMarket>();
  const bad = new Map<string, string>();
  let unnamed = 0;
  for (const raw of arr as unknown[]) {
    if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
      unnamed++;
      continue;
    }
    const m = raw as Record<string, unknown>;
    const symbol = typeof m.symbol === 'string' ? m.symbol : '';
    if (!SYMBOL_RE.test(symbol)) {
      unnamed++;
      continue;
    }
    if (markets.has(symbol) || bad.has(symbol)) {
      markets.delete(symbol);
      bad.set(symbol, 'Duplicate symbol in refdata');
      continue;
    }
    const r = decodeRow(m, symbol);
    if (typeof r === 'string') bad.set(symbol, r);
    else markets.set(symbol, r);
  }
  const total = arr.length;
  if (markets.size === 0) throw new Error(`Refdata contains no valid markets among ${total} rows`);
  if (markets.size * 2 <= total) {
    const sample = [...bad]
      .slice(0, 3)
      .map(([s, why]) => `${s}: ${why}`)
      .join('; ');
    throw new Error(`Refdata is mostly invalid: ${markets.size} valid of ${total}; ${sample}`);
  }
  return { markets, bad, unnamed };
}
export function decodeQfexRefdata(payload: unknown): Map<string, QfexMarket> {
  return decodeQfexRefdataRows(payload).markets;
}
export type QfexFreezeReason = 'lost' | 'bad_record' | 'grid_changed';
export interface QfexFrozenSymbol {
  symbol: string;
  reason: QfexFreezeReason;
  detail: string;
  since: number;
  candidate: QfexMarket | null;
}
function gridDiff(before: QfexMarket, after: QfexMarket): string | null {
  const parts: string[] = [];
  const eq = (a: string, b: string): boolean => {
    const x = parseDec(a);
    const y = parseDec(b);
    return !!x && !!y && decEq(x, y);
  };
  if (!eq(before.tick, after.tick)) parts.push(`tick ${before.tick} → ${after.tick}`);
  if (!eq(before.lot, after.lot)) parts.push(`lot ${before.lot} → ${after.lot}`);
  if (!eq(before.minQty, after.minQty)) parts.push(`min_quantity ${before.minQty} → ${after.minQty}`);
  return parts.length ? parts.join(', ') : null;
}
export function mergeQfexMarkets(
  prev: Map<string, QfexMarket> | null,
  next: QfexRefdataDecoded,
  interest: Iterable<string>,
  frozenBefore: ReadonlyMap<string, QfexFrozenSymbol> = new Map(),
  now: number = Date.now(),
): {
  markets: Map<string, QfexMarket>;
  frozen: Map<string, QfexFrozenSymbol>;
} {
  const markets = new Map(next.markets);
  const frozen = new Map<string, QfexFrozenSymbol>();
  if (!prev) return { markets, frozen };
  for (const sym of new Set(interest)) {
    const before = prev.get(sym);
    if (!before) continue;
    const after = next.markets.get(sym);
    let reason: QfexFreezeReason | null = null;
    let detail = '';
    if (!after) {
      const why = next.bad.get(sym);
      reason = why ? 'bad_record' : 'lost';
      detail = why ? `New refdata record is invalid: ${why}` : 'Symbol is absent from the new refdata';
    } else {
      const diff = gridDiff(before, after);
      if (diff) {
        reason = 'grid_changed';
        detail = diff;
      }
    }
    if (!reason) continue;
    markets.set(sym, before);
    const was = frozenBefore.get(sym);
    frozen.set(sym, {
      symbol: sym,
      reason,
      detail,
      since: was && was.reason === reason && was.detail === detail ? was.since : now,
      candidate: after ?? null,
    });
  }
  return { markets, frozen };
}
export function assertQfexMarketsSane(
  prev: Map<string, QfexMarket> | null,
  next: Map<string, QfexMarket>,
  interest: Iterable<string>,
): void {
  const { frozen } = mergeQfexMarkets(prev, { markets: next, bad: new Map(), unnamed: 0 }, interest);
  if (frozen.size) {
    throw new Error(
      `Market metadata requires explicit review: ${[...frozen.values()].map((f) => `${f.symbol} (${f.detail})`).join('; ')}`,
    );
  }
}
All files