src/markets/cache.ts
v0.1.0 · 5 KB
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;
}