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:
co-authored by
Claude Opus 4.7
parent
f6b2ee27fd
commit
e37c39a7d1
+112
-77
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user