Pick active futures contract by Yahoo volume

Replaces the Yahoo continuous-contract month parse with a volume-based
probe: walk the next 6 month codes via Yahoo's specific tickers
({PROD}{MONTH}{YY}.{EXCHANGE}) and pick the one with the highest recent
volume. Tracks the trader-standard "front month" definition and rolls
correctly even when the continuous (=F) feed lags expiry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-06-19 01:01:17 -05:00
co-authored by Claude Opus 4.7
parent f6b2ee27fd
commit e37c39a7d1
+112 -77
View File
@@ -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<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',
};
/**
* 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<string, ResolvedContract | null>
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<string | 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 };
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<typeof p> => 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<string, ResolvedContract | 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;
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);