/** * Contract Resolver * * Determines the active contract for each symbol by: * 1. Asking Yahoo Finance's continuous contract ({PRODUCT}=F) for the exchange suffix * 2. Probing Yahoo for several near-month tickers and ranking them by volume * 3. Looking up the highest-volume contract on Tradovate via /contract/suggest * * Volume is the trader-standard definition of "front month": liquidity has * migrated to whichever contract is most actively traded on any given day. * * Results are cached and refreshed periodically (default: every 30 minutes). */ import axios from 'axios'; interface ContractInfo { id: number; name: string; tickSize: number; contractMaturityId?: number; } export interface ResolvedContract extends ContractInfo { /** The other candidate contracts that were not selected */ alternative?: string; } const MONTH_CODES_ORDERED = ['F', 'G', 'H', 'J', 'K', 'M', 'N', 'Q', 'U', 'V', 'X', 'Z']; const PROBE_WINDOW = 6; // ── Cache ──────────────────────────────────────────────────────────────────── const cache = new Map(); const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes export function getCachedContract(symbol: string): ResolvedContract | null { const entry = cache.get(symbol); if (!entry) return null; if (Date.now() - entry.resolvedAt > CACHE_TTL_MS) { cache.delete(symbol); return null; } return entry.contract; } export function getAllCachedContracts(): Record { const result: Record = {}; for (const [symbol, entry] of cache) { if (Date.now() - entry.resolvedAt > CACHE_TTL_MS) { cache.delete(symbol); result[symbol] = null; } else { result[symbol] = entry.contract; } } return result; } // ── Yahoo Finance lookups ──────────────────────────────────────────────────── /** Fetch the continuous contract's exchange suffix (e.g. "NQ=F" → "CME"). */ async function getContinuousExchange(product: string): Promise { try { const res = await axios.get( `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(product + '=F')}`, { params: { range: '1d', interval: '1d' }, headers: { 'User-Agent': 'Mozilla/5.0' } } ); const meta = res.data?.chart?.result?.[0]?.meta; if (!meta) return null; return meta.exchangeName ?? 'CME'; } catch { return null; } } /** * Probe Yahoo for the next PROBE_WINDOW month codes starting from the current * calendar month. Returns the candidate with the highest recent volume, plus * the earliest other candidate with non-zero volume (used as the "rolled from" * label). Quarterly products naturally skip their unused months — Yahoo has * no data for them. */ async function findHighestVolumeContract( product: string, exchange: string, ): Promise<{ chosen: string; runnerUp: string | null } | null> { const now = new Date(); const startMonthIdx = now.getUTCMonth(); const startYear = now.getUTCFullYear(); const probes = await Promise.all( Array.from({ length: PROBE_WINDOW }, (_, offset) => offset).map(async (offset) => { const flat = startMonthIdx + offset; const monthCode = MONTH_CODES_ORDERED[flat % 12]; const year = startYear + Math.floor(flat / 12); const yy = (year % 100).toString().padStart(2, '0'); const ticker = `${product}${monthCode}${yy}.${exchange}`; try { const res = await axios.get( `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}`, { params: { range: '1d', interval: '1d' }, headers: { 'User-Agent': 'Mozilla/5.0' } } ); const result = res.data?.chart?.result?.[0]; const meta = result?.meta; if (!meta?.shortName) return null; const volume: number = meta.regularMarketVolume ?? result?.indicators?.quote?.[0]?.volume?.at(-1) ?? 0; return { name: `${product}${monthCode}${(year % 10).toString()}`, offset, volume, }; } catch { return null; } }) ); const valid = probes.filter((p): p is NonNullable => p !== null); if (valid.length === 0) return null; const byVolume = [...valid].sort((a, b) => b.volume - a.volume); const chosen = byVolume[0]; console.log( `[contract-resolver] ${product}: volume ranking — ${byVolume.map((c) => `${c.name}=${c.volume}`).join(', ')}` ); const earlierWithVolume = valid .filter((p) => p.offset < chosen.offset && p.volume > 0) .sort((a, b) => a.offset - b.offset)[0]; return { chosen: chosen.name, runnerUp: earlierWithVolume?.name ?? null, }; } // ── Main resolver ──────────────────────────────────────────────────────────── /** * Resolve the active contract for a list of symbols. * For each symbol: * 1. Fetch {PRODUCT}=F from Yahoo to learn the exchange suffix * 2. Probe near-month Yahoo tickers and pick the highest-volume contract * 3. Look up the chosen name on Tradovate to get the contract ID */ export async function resolveContracts( symbols: string[], accessToken: string, ): Promise> { const headers = { Authorization: `Bearer ${accessToken}` }; const results: Record = {}; await Promise.all(symbols.map(async (sym) => { let chosenName: string | null = null; let rolledFrom: string | null = null; const exchange = await getContinuousExchange(sym); if (exchange) { const winner = await findHighestVolumeContract(sym, exchange); if (winner) { chosenName = winner.chosen; rolledFrom = winner.runnerUp; } } else { console.log(`[contract-resolver] ${sym}: Yahoo continuous contract lookup failed`); } try { const res = await axios.get( `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(chosenName ?? sym)}&l=10`, { headers } ); const contracts: ContractInfo[] = res.data ?? []; const exactMatch = chosenName ? contracts.find((c) => c.name === chosenName) : null; const fallbackMatch = contracts.find((c) => { if (!c.name.startsWith(sym)) return false; return /^[A-Z]\d/.test(c.name.slice(sym.length)); }); const match = exactMatch ?? fallbackMatch; if (!match) { console.log(`[contract-resolver] ${sym}: no matching contract found on Tradovate`); results[sym] = null; return; } const contract: ResolvedContract = { id: match.id, name: match.name, tickSize: (match as any).providerTickSize ?? 0.25, contractMaturityId: (match as any).contractMaturityId, alternative: rolledFrom ?? undefined, }; results[sym] = contract; cache.set(sym, { contract, resolvedAt: Date.now() }); if (rolledFrom) { console.log(`[contract-resolver] ${sym}: ${rolledFrom} → ${match.name} ROLLED (by volume)`); } else { console.log(`[contract-resolver] ${sym}: ${match.name} resolved`); } } catch (err) { console.error(`[contract-resolver] ${sym}: Tradovate lookup failed`, err); results[sym] = null; } })); return results; }