Skip to content
markpaper

src/markets/parse.ts

v0.1.0 · 5.7 KB

Download file
export class PhoenixMetaError extends Error {
  override name = 'PhoenixMetaError';
}
export const MARKET_STATUSES = ['uninitialized', 'active', 'postOnly', 'paused', 'closed', 'tombstoned'] as const;
export interface PhoenixMarket {
  symbol: string;
  assetId: number;
  status: string;
  tickSize: bigint;
  baseLotsDecimals: number;
  isolatedOnly: boolean;
  maxLeverage: number;
  afterHours: boolean | null;
  executionBand: {
    min: number;
    max: number;
  } | null;
  bandIndexExpiresAt: number | null;
  nextTransitionAt: number | null;
}
const SYMBOL_RE = /^[A-Za-z0-9._-]{1,32}$/;
function intLike(v: unknown): number | null {
  if (typeof v === 'number') return Number.isSafeInteger(v) ? v : null;
  if (typeof v === 'string' && /^-?\d{1,15}$/.test(v)) return Number(v);
  return null;
}
function tokenAmountUsd(v: unknown): number | null {
  if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
  const t = v as {
    value?: unknown;
    decimals?: unknown;
    ui?: unknown;
  };
  const val = intLike(t.value);
  const dec = intLike(t.decimals);
  const n =
    val !== null && dec !== null && dec >= 0 && dec <= 18
      ? val / 10 ** dec
      : typeof t.ui === 'string'
        ? Number(t.ui)
        : NaN;
  return Number.isFinite(n) && n >= 0 ? n : null;
}
function decodeExecutionBand(b: unknown): {
  min: number;
  max: number;
} | null {
  if (!b || typeof b !== 'object' || Array.isArray(b)) return null;
  const r = b as {
    min?: unknown;
    max?: unknown;
    lower?: unknown;
    upper?: unknown;
  };
  const min = tokenAmountUsd(r.min ?? r.lower);
  const max = tokenAmountUsd(r.max ?? r.upper);
  return min !== null && max !== null && max > 0 && min < max ? { min, max } : null;
}
function epochMs(v: unknown): number | null {
  const n = intLike(v);
  if (n === null) return null;
  if (n >= 1e9 && n < 1e11) return n * 1000;
  if (n >= 1e12 && n < 1e14) return n;
  return null;
}
function decodeNextTransition(meta: unknown): number | null {
  const cal =
    meta && typeof meta === 'object'
      ? (
          meta as {
            calendar?: unknown;
          }
        ).calendar
      : null;
  const s =
    cal && typeof cal === 'object'
      ? (
          cal as {
            nextMarketTransitionUtc?: unknown;
          }
        ).nextMarketTransitionUtc
      : null;
  if (typeof s !== 'string' || s.length > 40) return null;
  const t = Date.parse(s);
  return Number.isFinite(t) && t > 0 ? t : null;
}
export function decodeMarkets(payload: unknown): Map<string, PhoenixMarket> {
  const arr = Array.isArray(payload)
    ? payload
    : (
        payload as {
          markets?: unknown;
        } | null
      )?.markets;
  if (!Array.isArray(arr)) throw new PhoenixMetaError('Market list must be a nonempty array');
  const out = new Map<string, PhoenixMarket>();
  const seenIds = new Set<number>();
  for (const raw of arr as unknown[]) {
    if (!raw || typeof raw !== 'object' || Array.isArray(raw))
      throw new PhoenixMetaError('Market record must be an object');
    const m = raw as Record<string, unknown>;
    const symbol = typeof m.symbol === 'string' ? m.symbol : '';
    if (!SYMBOL_RE.test(symbol)) throw new PhoenixMetaError('Invalid market symbol');
    const assetId = intLike(m.assetId);
    if (assetId === null || assetId < 0) throw new PhoenixMetaError('assetId must be a nonnegative safe integer');
    const status = typeof m.marketStatus === 'string' ? m.marketStatus : '';
    if (!status) throw new PhoenixMetaError('marketStatus missing');
    const tick = intLike(m.tickSize);
    if (tick === null || tick <= 0) throw new PhoenixMetaError('tickSize must be a positive safe integer');
    const bld = intLike(m.baseLotsDecimals);
    if (bld === null || bld < -12 || bld > 18)
      throw new PhoenixMetaError('baseLotsDecimals must be an integer in -12..18');
    if (typeof m.isolatedOnly !== 'boolean') throw new PhoenixMetaError('isolatedOnly must be boolean');
    const tiers = m.leverageTiers;
    const lev0 =
      Array.isArray(tiers) && tiers.length > 0
        ? Number(
            (
              tiers[0] as {
                maxLeverage?: unknown;
              }
            )?.maxLeverage,
          )
        : NaN;
    if (!(Number.isFinite(lev0) && lev0 > 0))
      throw new PhoenixMetaError('First leverage tier needs a positive maxLeverage');
    const cm = m.commodityMetadata;
    const afterHours =
      cm &&
      typeof cm === 'object' &&
      typeof (
        cm as {
          isAfterHours?: unknown;
        }
      ).isAfterHours === 'boolean'
        ? (
            cm as {
              isAfterHours: boolean;
            }
          ).isAfterHours
        : null;
    const executionBand =
      cm && typeof cm === 'object'
        ? decodeExecutionBand(
            (
              cm as {
                executionPriceBand?: unknown;
              }
            ).executionPriceBand,
          )
        : null;
    const bandIndexExpiresAt =
      cm && typeof cm === 'object'
        ? epochMs(
            (
              cm as {
                lastIndexExpiryTimestamp?: unknown;
              }
            ).lastIndexExpiryTimestamp,
          )
        : null;
    const nextTransitionAt = decodeNextTransition(m.metadata);
    if (out.has(symbol)) throw new PhoenixMetaError('Duplicate market symbol');
    if (seenIds.has(assetId)) throw new PhoenixMetaError('Duplicate market assetId');
    seenIds.add(assetId);
    out.set(symbol, {
      symbol,
      assetId,
      status,
      tickSize: BigInt(tick),
      baseLotsDecimals: bld,
      isolatedOnly: m.isolatedOnly,
      maxLeverage: Math.max(1, Math.floor(lev0)),
      afterHours,
      executionBand,
      bandIndexExpiresAt,
      nextTransitionAt,
    });
  }
  if (out.size === 0) throw new PhoenixMetaError('No markets returned');
  return out;
}
All files