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
*
* 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<string, string> = {
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<string, string> = {
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<string, ResolvedContract | null>
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<number | null> {
/** 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<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 ────────────────────────────────────────────────────────────
/**
* 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<string, ResolvedContract | null> = {};
// Step 1: Get front month for each symbol
const frontMonths: Record<string, ContractInfo | null> = {};
// Step 1: Ask Yahoo for the active contract month for each symbol
const yahooResults: Record<string, { monthCode: string; yearDigit: string } | null> = {};
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<string, ContractInfo | null> = {};
const rollTargets2: Record<string, ContractInfo | null> = {};
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;
}