/** * Contract Resolver * * Determines the active contract for each symbol by: * 1. Asking Yahoo Finance which month the continuous contract ({PRODUCT}=F) maps to * 2. Looking up that specific contract on Tradovate via /contract/suggest * * 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; } // ── Month code mapping ────────────────────────────────────────────────────── const MONTH_TO_CODE: Record = { Jan: 'F', Feb: 'G', Mar: 'H', Apr: 'J', May: 'K', Jun: 'M', Jul: 'N', Aug: 'Q', Sep: 'U', Oct: 'V', Nov: 'X', Dec: 'Z', January: 'F', February: 'G', March: 'H', April: 'J', June: 'M', July: 'N', August: 'Q', September: 'U', October: 'V', November: 'X', December: 'Z', }; /** * Parse Yahoo's shortName to extract month code and year digit. * Formats seen: "Euro FX Futures,Jun-2026", "Gold Jun 26", "Nasdaq 100 Jun 26" * Returns e.g. { monthCode: 'M', yearDigit: '6' } or null. */ function parseShortName(shortName: string): { monthCode: string; yearDigit: string } | null { // Try patterns: "Mon-YYYY", "Mon YY", "Mon YYYY", "Month-YYYY" const match = shortName.match(/\b(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)[- ](\d{2,4})\b/i); if (!match) return null; const monthCode = MONTH_TO_CODE[match[1]]; if (!monthCode) return null; const yearStr = match[2]; const yearDigit = yearStr.length === 4 ? yearStr.slice(-1) : yearStr.slice(-1); return { monthCode, yearDigit }; } // ── 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 lookup ───────────────────────────────────────────────────── /** Fetch the continuous contract meta for a product (e.g. "GC" → "GC=F"). */ async function getContinuousMeta(product: string): Promise<{ shortName: string; price: number } | null> { 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?.shortName) return null; return { shortName: meta.shortName, price: meta.regularMarketPrice ?? 0 }; } catch { return null; } } // ── Main resolver ──────────────────────────────────────────────────────────── /** * Resolve the active contract for a list of symbols using Yahoo Finance + Tradovate. * For each symbol: * 1. Fetch {PRODUCT}=F from Yahoo → parse shortName for month/year * 2. Construct the Tradovate contract name (e.g. "GCM6") * 3. Look it up on Tradovate via /contract/suggest to get the contract ID * 4. Fallback: if Yahoo fails, use Tradovate suggest directly (front month) */ export async function resolveContracts( symbols: string[], accessToken: string, ): Promise> { const headers = { Authorization: `Bearer ${accessToken}` }; const results: Record = {}; // Step 1: Ask Yahoo for the active contract month for each symbol const yahooResults: Record = {}; await Promise.all(symbols.map(async (sym) => { const meta = await getContinuousMeta(sym); if (meta) { const parsed = parseShortName(meta.shortName); console.log(`[contract-resolver] ${sym}: Yahoo says "${meta.shortName}" → ${parsed ? `${sym}${parsed.monthCode}${parsed.yearDigit}` : 'parse failed'}`); yahooResults[sym] = parsed; } else { console.log(`[contract-resolver] ${sym}: Yahoo continuous contract lookup failed`); yahooResults[sym] = null; } })); // Step 2: Look up the specific contract on Tradovate await Promise.all(symbols.map(async (sym) => { const yahoo = yahooResults[sym]; // Build the target Tradovate name from Yahoo's answer (e.g. "GCM6") const targetName = yahoo ? `${sym}${yahoo.monthCode}${yahoo.yearDigit}` : null; try { const res = await axios.get( `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(targetName ?? sym)}&l=10`, { headers } ); const contracts: ContractInfo[] = res.data ?? []; // Find exact match for the Yahoo-derived name, or first matching contract as fallback const exactMatch = targetName ? contracts.find((c) => c.name === targetName) : null; const fallbackMatch = contracts.find((c) => { if (!c.name.startsWith(sym)) return false; const rest = c.name.slice(sym.length); return /^[A-Z]\d/.test(rest); }); const match = exactMatch ?? fallbackMatch; if (match) { const contract: ResolvedContract = { id: match.id, name: match.name, tickSize: (match as any).providerTickSize ?? 0.25, contractMaturityId: (match as any).contractMaturityId, alternative: (exactMatch && fallbackMatch && exactMatch.name !== fallbackMatch.name) ? fallbackMatch.name : undefined, }; results[sym] = contract; cache.set(sym, { contract, resolvedAt: Date.now() }); if (exactMatch && fallbackMatch && exactMatch.name !== fallbackMatch.name) { console.log(`[contract-resolver] ${sym}: ${fallbackMatch.name} → ${exactMatch.name} (Yahoo) ROLLED`); } else { console.log(`[contract-resolver] ${sym}: ${match.name} resolved`); } } else { console.log(`[contract-resolver] ${sym}: no matching contract found on Tradovate`); results[sym] = null; } } catch (err) { console.error(`[contract-resolver] ${sym}: Tradovate lookup failed`, err); results[sym] = null; } })); return results; }