diff --git a/lib/contract-resolver.ts b/lib/contract-resolver.ts index c0bd9f7..fc853bb 100644 --- a/lib/contract-resolver.ts +++ b/lib/contract-resolver.ts @@ -1,10 +1,9 @@ /** * 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) + * 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). */ @@ -23,26 +22,30 @@ export interface ResolvedContract extends ContractInfo { alternative?: string; } -// ── Yahoo Finance ticker mapping ───────────────────────────────────────────── +// ── Month code 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', +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', }; -/** 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}`; +/** + * 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 ──────────────────────────────────────────────────────────────────── @@ -73,40 +76,32 @@ export function getAllCachedContracts(): Record return result; } -// ── Yahoo Finance price lookup ─────────────────────────────────────────────── +// ── Yahoo Finance lookup ───────────────────────────────────────────────────── -/** Fetch regularMarketPrice for a Yahoo ticker. Returns null on failure. */ -async function getYahooPrice(ticker: string): Promise { +/** 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(ticker)}`, + `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; - return meta?.regularMarketPrice ?? null; + if (!meta?.shortName) return null; + return { shortName: meta.shortName, price: meta.regularMarketPrice ?? 0 }; } 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. + * Resolve the active contract for a list of symbols using Yahoo Finance + Tradovate. * 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) + * 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[], @@ -115,142 +110,70 @@ export async function resolveContracts( const headers = { Authorization: `Bearer ${accessToken}` }; const results: Record = {}; - // Step 1: Get front month for each symbol - const frontMonths: 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(sym)}&l=5`, + `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(targetName ?? sym)}&l=10`, { 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) => { + + // 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); }); - 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, + 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, }; - // 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; + 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 { - rollTargets1[sym] = null; - rollTargets2[sym] = null; + console.log(`[contract-resolver] ${sym}: no matching contract found on Tradovate`); + results[sym] = null; } - } catch { - rollTargets1[sym] = null; - rollTargets2[sym] = null; + } catch (err) { + console.error(`[contract-resolver] ${sym}: Tradovate lookup failed`, err); + results[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; }