Skip to content
markpaper

src/markets/cache.ts

v0.1.0 · 3.2 KB

Download file
import { decodeMarkets, type PhoenixMarket, PhoenixMetaError } from './parse.js';
export function assertMarketsSane(
  previous: Map<string, PhoenixMarket> | null,
  next: Map<string, PhoenixMarket>,
  interest: Iterable<string>,
): void {
  if (!previous) return;
  for (const sym of interest) {
    const before = previous.get(sym);
    if (!before) continue;
    const after = next.get(sym);
    if (!after) throw new PhoenixMetaError('Market in use disappeared: ' + sym);
    if (
      after.assetId !== before.assetId ||
      after.tickSize !== before.tickSize ||
      after.baseLotsDecimals !== before.baseLotsDecimals
    )
      throw new PhoenixMetaError('Market identity or quantization changed: ' + sym);
  }
}
export interface MarketCacheOptions {
  fetchMarkets: () => Promise<unknown>;
  ttlMs: number;
  freshForMs: number;
  failedBackoffMs: number;
  now?: () => number;
  onError?: (error: unknown) => void;
}
export function createMarketCache(opts: MarketCacheOptions) {
  if (!(opts.ttlMs > 0) || !(opts.freshForMs > 0) || !(opts.failedBackoffMs >= 0))
    throw new TypeError('Explicit cache timings required');
  const now = opts.now ?? Date.now;
  const interest = new Set<string>();
  let cache: { ts: number; map: Map<string, PhoenixMarket> } | null = null;
  let inflight: Promise<Map<string, PhoenixMarket>> | null = null;
  let retryAfter = 0;
  return {
    noteInterest(symbol: string) {
      interest.add(symbol);
    },
    fresh(at = now()) {
      return cache !== null && at - cache.ts < opts.freshForMs;
    },
    get(): Promise<Map<string, PhoenixMarket>> {
      const at = now();
      if (cache && (at - cache.ts < opts.ttlMs || at < retryAfter)) return Promise.resolve(new Map(cache.map));
      if (inflight) return inflight;
      inflight = opts
        .fetchMarkets()
        .then((raw) => {
          const map = decodeMarkets(raw);
          assertMarketsSane(cache?.map ?? null, map, interest);
          cache = { ts: now(), map };
          retryAfter = 0;
          return new Map(map);
        })
        .catch((err) => {
          if (!cache) throw err;
          retryAfter = now() + opts.failedBackoffMs;
          try {
            opts.onError?.(err);
          } catch {}
          return new Map(cache.map);
        })
        .finally(() => {
          inflight = null;
        });
      return inflight;
    },
  };
}
export function tradability(m: PhoenixMarket): 'tradable' | 'unknown' | 'not_tradable' {
  if (m.status === 'active' || m.status === 'postOnly') return 'tradable';
  if (['closed', 'tombstoned', 'uninitialized'].includes(m.status)) return 'not_tradable';
  return 'unknown';
}
/** @experimental Band-edge inclusivity and clamped IoC fills are unverified. Caller must prove cache freshness. */
export function afterHoursBand(m: PhoenixMarket, now: number, fresh: boolean): { min: number; max: number } | null {
  if (!fresh || m.afterHours !== true || !m.executionBand) return null;
  if (m.nextTransitionAt !== null && now >= m.nextTransitionAt) return null;
  if (m.bandIndexExpiresAt !== null && now >= m.bandIndexExpiresAt) return null;
  if (m.nextTransitionAt === null && m.bandIndexExpiresAt === null) return null;
  return { ...m.executionBand };
}
All files