/** * Contract Resolver * * Determines the best contract for each symbol by combining: * 1. Tradovate's suggest API (front month) * 2. Tradovate's rollcontract API (next months) * 3. Yahoo Finance continuous contract price matching ({PRODUCT}=F) * * 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; } // ── 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; } // ── Yahoo Finance price lookup ─────────────────────────────────────────────── /** Fetch regularMarketPrice for a Yahoo ticker. Returns null on failure. */ async function getYahooPrice(ticker: string): Promise { 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 meta = res.data?.chart?.result?.[0]?.meta; return meta?.regularMarketPrice ?? null; } catch { return null; } } /** Get the continuous contract price for a product (e.g. "GC" → "GC=F"). */ async function getContinuousPrice(product: string): Promise { return getYahooPrice(`${product}=F`); } /** Get the price for a specific Tradovate contract name (e.g. "GCM6" → "GCM26.CMX"). */ async function getCandidatePrice(tvName: string): Promise { return getYahooPrice(toYahoo(tvName)); } // ── 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 targets via /contract/rollcontract (up to 2 forward) * 3. Match candidates against Yahoo's continuous contract price ({PRODUCT}=F) */ 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 ?? []; // Match symbol exactly: after the symbol prefix, next char must be a month code (A-Z) then a digit const match = contracts.find((c) => { if (!c.name.startsWith(sym)) return false; const rest = c.name.slice(sym.length); return /^[A-Z]\d/.test(rest); }); 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 (up to 2 months forward to handle bi-monthly products like GC) const rollTargets1: Record = {}; const rollTargets2: Record = {}; await Promise.all(symbols.map(async (sym) => { const front = frontMonths[sym]; if (!front) { rollTargets1[sym] = null; rollTargets2[sym] = null; return; } try { const res1 = await axios.post( 'https://demo.tradovateapi.com/v1/contract/rollcontract', { name: front.name, forward: true, ifExpired: false }, { headers } ); const c1 = res1.data?.contract; if (c1 && c1.name !== front.name) { rollTargets1[sym] = { id: c1.id, name: c1.name, tickSize: c1.providerTickSize ?? 0.25, contractMaturityId: c1.contractMaturityId, }; // Roll a second time from the first rolled contract try { const res2 = await axios.post( 'https://demo.tradovateapi.com/v1/contract/rollcontract', { name: c1.name, forward: true, ifExpired: false }, { headers } ); const c2 = res2.data?.contract; if (c2 && c2.name !== c1.name) { rollTargets2[sym] = { id: c2.id, name: c2.name, tickSize: c2.providerTickSize ?? 0.25, contractMaturityId: c2.contractMaturityId, }; } else { rollTargets2[sym] = null; } } catch { rollTargets2[sym] = null; } } else { rollTargets1[sym] = null; rollTargets2[sym] = null; } } catch { rollTargets1[sym] = null; rollTargets2[sym] = null; } })); // Step 3: Match candidates against Yahoo's continuous contract price ({PRODUCT}=F) // This is more reliable than volume comparison — Yahoo knows the active contract. for (const sym of symbols) { const front = frontMonths[sym]; const roll1 = rollTargets1[sym]; const roll2 = rollTargets2[sym]; if (!front) { results[sym] = null; continue; } const candidates: { contract: ContractInfo; label: string }[] = [ { contract: front, label: 'front' }, ]; if (roll1) candidates.push({ contract: roll1, label: 'roll1' }); if (roll2) candidates.push({ contract: roll2, label: 'roll2' }); // Fetch continuous price and all candidate prices in parallel const [continuousPrice, ...candidatePrices] = await Promise.all([ getContinuousPrice(sym), ...candidates.map(c => getCandidatePrice(c.contract.name)), ]); console.log(`[contract-resolver] ${sym}: continuous=${continuousPrice}, candidates=[${candidates.map((c, i) => `${c.contract.name}=$${candidatePrices[i]}`).join(', ')}]`); // Find the candidate with the closest price to the continuous contract let best = candidates[0]; // default to front if (continuousPrice !== null) { let bestDiff = Infinity; for (let i = 0; i < candidates.length; i++) { const price = candidatePrices[i]; if (price === null) continue; const diff = Math.abs(price - continuousPrice); if (diff < bestDiff) { bestDiff = diff; best = candidates[i]; } } } else { // Yahoo failed entirely — fall back to roll1 (nearest non-expired) if available if (roll1) best = candidates[1]; } const alternatives = candidates.filter(c => c !== best).map(c => c.contract.name).join(', '); results[sym] = { ...best.contract, alternative: alternatives || undefined, }; if (best.label !== 'front') { console.log(`[contract-resolver] ${sym}: ${front.name} → ${best.contract.name} (price match) ROLLED`); } else { console.log(`[contract-resolver] ${sym}: ${front.name} stays (price match)`); } // Update cache if (results[sym]) { cache.set(sym, { contract: results[sym]!, resolvedAt: Date.now() }); } } return results; }