Simplify contract resolver: use Yahoo's shortName directly

Instead of generating candidates from Tradovate and matching by price,
now parses Yahoo's continuous contract shortName (e.g. "Gold Jun 26")
to determine the exact month/year, constructs the Tradovate name
(e.g. "GCM6"), and looks it up directly. Falls back to Tradovate
suggest if Yahoo is unavailable.

This eliminates all price comparison, volume comparison, and the
rollcontract API calls entirely — Yahoo already knows the active month.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-03-30 18:24:41 -05:00
co-authored by Claude Opus 4.6
parent 24cfa7efcc
commit 7afc5b9437
+82 -159
View File
@@ -1,10 +1,9 @@
/** /**
* Contract Resolver * Contract Resolver
* *
* Determines the best contract for each symbol by combining: * Determines the active contract for each symbol by:
* 1. Tradovate's suggest API (front month) * 1. Asking Yahoo Finance which month the continuous contract ({PRODUCT}=F) maps to
* 2. Tradovate's rollcontract API (next months) * 2. Looking up that specific contract on Tradovate via /contract/suggest
* 3. Yahoo Finance continuous contract price matching ({PRODUCT}=F)
* *
* Results are cached and refreshed periodically (default: every 30 minutes). * Results are cached and refreshed periodically (default: every 30 minutes).
*/ */
@@ -23,26 +22,30 @@ export interface ResolvedContract extends ContractInfo {
alternative?: string; alternative?: string;
} }
// ── Yahoo Finance ticker mapping ───────────────────────────────────────────── // ── Month code mapping ──────────────────────────────────────────────────────
const EXCHANGE_MAP: Record<string, string> = { const MONTH_TO_CODE: Record<string, string> = {
NQ: 'CME', MNQ: 'CME', ES: 'CME', MES: 'CME', Jan: 'F', Feb: 'G', Mar: 'H', Apr: 'J', May: 'K', Jun: 'M',
YM: 'CBT', MYM: 'CBT', RTY: 'CME', M2K: 'CME', Jul: 'N', Aug: 'Q', Sep: 'U', Oct: 'V', Nov: 'X', Dec: 'Z',
GC: 'CMX', MGC: 'CMX', January: 'F', February: 'G', March: 'H', April: 'J',
SI: 'CMX', SIL: 'CMX', June: 'M', July: 'N', August: 'Q', September: 'U',
CL: 'NYM', MCL: 'NYM', NG: 'NYM', October: 'V', November: 'X', December: 'Z',
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 { * Parse Yahoo's shortName to extract month code and year digit.
const yearDigit = tvName.slice(-1); * Formats seen: "Euro FX Futures,Jun-2026", "Gold Jun 26", "Nasdaq 100 Jun 26"
const monthLetter = tvName.slice(-2, -1); * Returns e.g. { monthCode: 'M', yearDigit: '6' } or null.
const product = tvName.slice(0, -2); */
const year2d = '2' + yearDigit; // assumes 2020s function parseShortName(shortName: string): { monthCode: string; yearDigit: string } | null {
const exchange = EXCHANGE_MAP[product] ?? 'CME'; // Try patterns: "Mon-YYYY", "Mon YY", "Mon YYYY", "Month-YYYY"
return `${product}${monthLetter}${year2d}.${exchange}`; 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 ──────────────────────────────────────────────────────────────────── // ── Cache ────────────────────────────────────────────────────────────────────
@@ -73,40 +76,32 @@ export function getAllCachedContracts(): Record<string, ResolvedContract | null>
return result; return result;
} }
// ── Yahoo Finance price lookup ─────────────────────────────────────────────── // ── Yahoo Finance lookup ─────────────────────────────────────────────────────
/** Fetch regularMarketPrice for a Yahoo ticker. Returns null on failure. */ /** Fetch the continuous contract meta for a product (e.g. "GC" → "GC=F"). */
async function getYahooPrice(ticker: string): Promise<number | null> { async function getContinuousMeta(product: string): Promise<{ shortName: string; price: number } | null> {
try { try {
const res = await axios.get( 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' } } { params: { range: '1d', interval: '1d' }, headers: { 'User-Agent': 'Mozilla/5.0' } }
); );
const meta = res.data?.chart?.result?.[0]?.meta; 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 { } catch {
return null; return null;
} }
} }
/** Get the continuous contract price for a product (e.g. "GC" → "GC=F"). */
async function getContinuousPrice(product: string): Promise<number | null> {
return getYahooPrice(`${product}=F`);
}
/** Get the price for a specific Tradovate contract name (e.g. "GCM6" → "GCM26.CMX"). */
async function getCandidatePrice(tvName: string): Promise<number | null> {
return getYahooPrice(toYahoo(tvName));
}
// ── Main resolver ──────────────────────────────────────────────────────────── // ── 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: * For each symbol:
* 1. Get front month via /contract/suggest * 1. Fetch {PRODUCT}=F from Yahoo → parse shortName for month/year
* 2. Get roll targets via /contract/rollcontract (up to 2 forward) * 2. Construct the Tradovate contract name (e.g. "GCM6")
* 3. Match candidates against Yahoo's continuous contract price ({PRODUCT}=F) * 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( export async function resolveContracts(
symbols: string[], symbols: string[],
@@ -115,142 +110,70 @@ export async function resolveContracts(
const headers = { Authorization: `Bearer ${accessToken}` }; const headers = { Authorization: `Bearer ${accessToken}` };
const results: Record<string, ResolvedContract | null> = {}; const results: Record<string, ResolvedContract | null> = {};
// Step 1: Get front month for each symbol // Step 1: Ask Yahoo for the active contract month for each symbol
const frontMonths: Record<string, ContractInfo | null> = {}; const yahooResults: Record<string, { monthCode: string; yearDigit: string } | null> = {};
await Promise.all(symbols.map(async (sym) => { 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 { try {
const res = await axios.get( 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 } { headers }
); );
const contracts: ContractInfo[] = res.data ?? []; 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; if (!c.name.startsWith(sym)) return false;
const rest = c.name.slice(sym.length); const rest = c.name.slice(sym.length);
return /^[A-Z]\d/.test(rest); 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 match = exactMatch ?? fallbackMatch;
const rollTargets1: Record<string, ContractInfo | null> = {}; if (match) {
const rollTargets2: Record<string, ContractInfo | null> = {}; const contract: ResolvedContract = {
await Promise.all(symbols.map(async (sym) => { id: match.id,
const front = frontMonths[sym]; name: match.name,
if (!front) { rollTargets1[sym] = null; rollTargets2[sym] = null; return; } tickSize: (match as any).providerTickSize ?? 0.25,
try { contractMaturityId: (match as any).contractMaturityId,
const res1 = await axios.post( alternative: (exactMatch && fallbackMatch && exactMatch.name !== fallbackMatch.name)
'https://demo.tradovateapi.com/v1/contract/rollcontract', ? fallbackMatch.name : undefined,
{ 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 results[sym] = contract;
try { cache.set(sym, { contract, resolvedAt: Date.now() });
const res2 = await axios.post(
'https://demo.tradovateapi.com/v1/contract/rollcontract', if (exactMatch && fallbackMatch && exactMatch.name !== fallbackMatch.name) {
{ name: c1.name, forward: true, ifExpired: false }, console.log(`[contract-resolver] ${sym}: ${fallbackMatch.name}${exactMatch.name} (Yahoo) ROLLED`);
{ headers } } else {
); console.log(`[contract-resolver] ${sym}: ${match.name} resolved`);
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 { } else {
rollTargets1[sym] = null; console.log(`[contract-resolver] ${sym}: no matching contract found on Tradovate`);
rollTargets2[sym] = null; results[sym] = null;
} }
} catch { } catch (err) {
rollTargets1[sym] = null; console.error(`[contract-resolver] ${sym}: Tradovate lookup failed`, err);
rollTargets2[sym] = null; 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; return results;
} }