diff --git a/lib/contract-resolver.ts b/lib/contract-resolver.ts index fc853bb..3071f34 100644 --- a/lib/contract-resolver.ts +++ b/lib/contract-resolver.ts @@ -2,8 +2,12 @@ * 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 + * 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). */ @@ -22,31 +26,8 @@ export interface ResolvedContract extends ContractInfo { 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 }; -} +const MONTH_CODES_ORDERED = ['F', 'G', 'H', 'J', 'K', 'M', 'N', 'Q', 'U', 'V', 'X', 'Z']; +const PROBE_WINDOW = 6; // ── Cache ──────────────────────────────────────────────────────────────────── @@ -76,32 +57,95 @@ export function getAllCachedContracts(): Record return result; } -// ── Yahoo Finance lookup ───────────────────────────────────────────────────── +// ── Yahoo Finance lookups ──────────────────────────────────────────────────── -/** Fetch the continuous contract meta for a product (e.g. "GC" → "GC=F"). */ -async function getContinuousMeta(product: string): Promise<{ shortName: string; price: number } | null> { +/** 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?.shortName) return null; - return { shortName: meta.shortName, price: meta.regularMarketPrice ?? 0 }; + 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 using Yahoo Finance + Tradovate. + * Resolve the active contract for a list of symbols. * 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) + * 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[], @@ -110,64 +154,55 @@ export async function resolveContracts( 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; + 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`); - 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`, + `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(chosenName ?? 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 exactMatch = chosenName ? contracts.find((c) => c.name === chosenName) : 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); + return /^[A-Z]\d/.test(c.name.slice(sym.length)); }); - 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 { + 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);