Skip to content
markpaper

src/markets/cache.ts

v0.1.0 · 5 KB

Download file
import { decCmp, decSign, parseDec } from '../numbers/index.js';
import {
  decodeQfexRefdataRows,
  mergeQfexMarkets,
  QFEX_MARGIN_ASSET,
  QFEX_STATUS_ACTIVE,
  QFEX_STATUSES_OFF,
  type QfexFrozenSymbol,
  type QfexMarket,
} from './meta.js';
import { QFEX_QUOTE } from './symbols.js';

// Venue limit observed 2026-10-01: both sides combined, not a per-side limit.
export const MAX_RESTING_ORDERS_PER_SYMBOL = 20;
export type Listing = { status: 'listed' | 'unlisted' | 'unknown'; why: string };
export function listingOf(
  market: QfexMarket | undefined,
  options: { badReason?: string; frozen?: boolean } = {},
): Listing {
  if (options.frozen || options.badReason) return { status: 'unknown', why: options.badReason ?? 'Market grid frozen' };
  if (!market) return { status: 'unlisted', why: 'Symbol absent from reference data' };
  if (QFEX_STATUSES_OFF.includes(market.status)) return { status: 'unlisted', why: market.status };
  if (market.status !== QFEX_STATUS_ACTIVE)
    return { status: 'unknown', why: 'Unrecognized market status: ' + market.status };
  if (market.quote !== QFEX_QUOTE || market.marginAsset !== QFEX_MARGIN_ASSET)
    return { status: 'unlisted', why: 'Unsupported quote or margin asset' };
  return { status: 'listed', why: 'Active USD market margined in USDC' };
}

/** Grid freezes and serving stale data are caller-selected safety policies. */
export function createMarketCache(options: {
  fetchRefdata: () => Promise<{ payload: unknown; ageSec?: number | null; receivedAt?: number }>;
  ttlMs: number;
  retryBackoffMs: number;
  now?: () => number;
  watched: () => Iterable<string>;
}) {
  for (const field of ['ttlMs', 'retryBackoffMs'] as const)
    if (!Number.isFinite(options?.[field]) || options[field] <= 0)
      throw new TypeError('Required cache policy: ' + field);
  if (typeof options.fetchRefdata !== 'function' || typeof options.watched !== 'function')
    throw new TypeError('Required cache sources');
  const now = options.now ?? Date.now;
  let cache: {
    markets: Map<string, QfexMarket>;
    frozen: Map<string, QfexFrozenSymbol>;
    bad: Map<string, string>;
    at: number;
    bandAt: number;
  } | null = null;
  let inflight: Promise<Map<string, QfexMarket>> | null = null;
  let retryAt = 0;
  let lastError: string | null = null;
  async function get(): Promise<Map<string, QfexMarket>> {
    if (cache && (now() - cache.at < options.ttlMs || now() < retryAt)) return new Map(cache.markets);
    if (inflight) return inflight;
    inflight = (async () => {
      const got = await options.fetchRefdata();
      const decoded = decodeQfexRefdataRows(got.payload);
      const merged = mergeQfexMarkets(cache?.markets ?? null, decoded, options.watched(), cache?.frozen, now());
      cache = {
        ...merged,
        bad: decoded.bad,
        at: now(),
        bandAt: (got.receivedAt ?? now()) - Math.max(0, got.ageSec ?? 0) * 1000,
      };
      retryAt = 0;
      lastError = null;
      return new Map(cache.markets);
    })()
      .catch((error: unknown) => {
        lastError = String((error as Error)?.message ?? error);
        if (!cache) throw error;
        retryAt = now() + options.retryBackoffMs;
        return new Map(cache.markets);
      })
      .finally(() => {
        inflight = null;
      });
    return inflight;
  }
  return {
    get,
    peek: (symbol: string) => cache?.markets.get(symbol),
    frozen: () => [...(cache?.frozen.values() ?? [])],
    info: () => ({
      fresh: !!cache && now() - cache.at < options.ttlMs,
      at: cache?.at ?? null,
      bandAt: cache?.bandAt ?? null,
      lastError,
    }),
    listing: (symbol: string) =>
      listingOf(cache?.markets.get(symbol), { badReason: cache?.bad.get(symbol), frozen: cache?.frozen.has(symbol) }),
    acceptGridChange(symbol: string): boolean {
      const frozen = cache?.frozen.get(symbol);
      if (!cache || frozen?.reason !== 'grid_changed' || !frozen.candidate) return false;
      cache.markets.set(symbol, frozen.candidate);
      cache.frozen.delete(symbol);
      return true;
    },
  };
}

export function selectBand(options: {
  live?: { min: number; max: number; at: number } | null;
  refBand?: { min: string; max: string } | null;
  refBandAt?: number;
  freshMs: number;
  now?: number;
}): { min: string; max: string; fresh: boolean; source: 'live' | 'refdata' } | null {
  if (!Number.isFinite(options.freshMs) || options.freshMs <= 0) throw new TypeError('Required band freshness policy');
  const now = options.now ?? Date.now();
  const valid = (min: string, max: string) => {
    const a = parseDec(min),
      b = parseDec(max);
    return !!a && !!b && decSign(a) >= 0 && decSign(b) > 0 && decCmp(a, b) < 0;
  };
  if (
    options.live &&
    now >= options.live.at &&
    now - options.live.at <= options.freshMs &&
    valid(String(options.live.min), String(options.live.max))
  )
    return { min: String(options.live.min), max: String(options.live.max), fresh: true, source: 'live' };
  if (options.refBand && valid(options.refBand.min, options.refBand.max))
    return { ...options.refBand, fresh: false, source: 'refdata' };
  return null;
}
All files