/** * Contract Resolver * * Determines the best contract for each symbol by combining: * 1. Tradovate's suggest API (front month) * 2. Tradovate's rollcontract API (next month) * 3. Yahoo Finance volume data (pick whichever has more volume) * * 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 contract that lost the volume comparison (if any) */ alternative?: string; frontVolume?: number; rolledVolume?: number; } // ── Yahoo Finance ticker mapping ───────────────────────────────────────────── const EXCHANGE_MAP: Record = { NQ: 'CME', MNQ: 'CME', ES: 'CME', MES: 'CME', YM: 'CBT', MYM: 'CBT', RTY: 'CME', M2K: 'CME', GC: 'CMX', MGC: 'CMX', SI: 'CMX', SIL: 'CMX', CL: 'NYM', MCL: 'NYM', NG: 'NYM', ZB: 'CBT', ZN: 'CBT', ZF: 'CBT', '6E': 'CME', '6J': 'CME', '6B': 'CME', }; /** Convert Tradovate name to Yahoo ticker, e.g. "GCH6" → "GCH26.CMX" */ function toYahoo(tvName: string): string { const yearDigit = tvName.slice(-1); const monthLetter = tvName.slice(-2, -1); const product = tvName.slice(0, -2); const year2d = '2' + yearDigit; // assumes 2020s const exchange = EXCHANGE_MAP[product] ?? 'CME'; return `${product}${monthLetter}${year2d}.${exchange}`; } // ── 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; } // ── Volume lookup via Yahoo Finance REST API ──────────────────────────────── async function getVolume(tvName: string): Promise { try { const ticker = toYahoo(tvName); const res = await axios.get( `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}`, { params: { range: '1d', interval: '1d' } } ); const meta = res.data?.chart?.result?.[0]?.meta; return meta?.regularMarketVolume ?? 0; } catch { return 0; } } // ── Main resolver ──────────────────────────────────────────────────────────── /** * Resolve the best contract for a list of symbols using a Tradovate access token. * For each symbol: * 1. Get front month via /contract/suggest * 2. Get roll target via /contract/rollcontract * 3. If they differ, compare Yahoo Finance volumes and pick the winner */ export async function resolveContracts( symbols: string[], accessToken: string, ): Promise> { const headers = { Authorization: `Bearer ${accessToken}` }; const results: Record = {}; // Step 1: Get front month for each symbol const frontMonths: Record = {}; await Promise.all(symbols.map(async (sym) => { try { const res = await axios.get( `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(sym)}&l=5`, { headers } ); const contracts: ContractInfo[] = res.data ?? []; const match = contracts.find((c) => c.name.startsWith(sym)); frontMonths[sym] = match ? { id: match.id, name: match.name, tickSize: (match as any).providerTickSize ?? 0.25, contractMaturityId: (match as any).contractMaturityId, } : null; } catch { frontMonths[sym] = null; } })); // Step 2: Get roll targets const rollTargets: Record = {}; await Promise.all(symbols.map(async (sym) => { const front = frontMonths[sym]; if (!front) { rollTargets[sym] = null; return; } try { const res = await axios.post( 'https://demo.tradovateapi.com/v1/contract/rollcontract', { name: front.name, forward: true, ifExpired: false }, { headers } ); const c = res.data?.contract; if (c && c.name !== front.name) { rollTargets[sym] = { id: c.id, name: c.name, tickSize: c.providerTickSize ?? 0.25, contractMaturityId: c.contractMaturityId, }; } else { rollTargets[sym] = null; // Same contract or no roll available } } catch { rollTargets[sym] = null; } })); // Step 3: Fetch volumes for all contracts that need comparison const volumePromises: Record> = {}; for (const sym of symbols) { const front = frontMonths[sym]; const rolled = rollTargets[sym]; if (front && rolled) { if (!volumePromises[front.name]) volumePromises[front.name] = getVolume(front.name); if (!volumePromises[rolled.name]) volumePromises[rolled.name] = getVolume(rolled.name); } } // Resolve all volume lookups in parallel const volumeEntries = Object.entries(volumePromises); const volumeValues = await Promise.all(volumeEntries.map(([, p]) => p)); const volumes: Record = {}; volumeEntries.forEach(([name], i) => { volumes[name] = volumeValues[i]; }); if (Object.keys(volumes).length > 0) { console.log('[contract-resolver] volumes:', volumes); } // Step 4: Pick winners for (const sym of symbols) { const front = frontMonths[sym]; const rolled = rollTargets[sym]; if (!front) { results[sym] = null; continue; } if (!rolled) { // No roll target — use front month results[sym] = { ...front }; } else { const frontVol = volumes[front.name] ?? 0; const rolledVol = volumes[rolled.name] ?? 0; if (rolledVol > frontVol) { // Rolled contract has more volume — use it results[sym] = { ...rolled, alternative: front.name, frontVolume: frontVol, rolledVolume: rolledVol, }; console.log(`[contract-resolver] ${sym}: ${front.name} (vol=${frontVol}) → ${rolled.name} (vol=${rolledVol}) ROLLED`); } else { // Front month still has more volume — keep it results[sym] = { ...front, alternative: rolled.name, frontVolume: frontVol, rolledVolume: rolledVol, }; console.log(`[contract-resolver] ${sym}: ${front.name} (vol=${frontVol}) stays (rolled ${rolled.name} vol=${rolledVol})`); } } // Update cache if (results[sym]) { cache.set(sym, { contract: results[sym]!, resolvedAt: Date.now() }); } } return results; }