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