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:
co-authored by
Claude Opus 4.6
parent
24cfa7efcc
commit
7afc5b9437
+78
-155
@@ -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 ? {
|
|
||||||
|
const match = exactMatch ?? fallbackMatch;
|
||||||
|
if (match) {
|
||||||
|
const contract: ResolvedContract = {
|
||||||
id: match.id,
|
id: match.id,
|
||||||
name: match.name,
|
name: match.name,
|
||||||
tickSize: (match as any).providerTickSize ?? 0.25,
|
tickSize: (match as any).providerTickSize ?? 0.25,
|
||||||
contractMaturityId: (match as any).contractMaturityId,
|
contractMaturityId: (match as any).contractMaturityId,
|
||||||
} : null;
|
alternative: (exactMatch && fallbackMatch && exactMatch.name !== fallbackMatch.name)
|
||||||
} catch {
|
? fallbackMatch.name : undefined,
|
||||||
frontMonths[sym] = null;
|
};
|
||||||
}
|
results[sym] = contract;
|
||||||
}));
|
cache.set(sym, { contract, resolvedAt: Date.now() });
|
||||||
|
|
||||||
// Step 2: Get roll targets (up to 2 months forward to handle bi-monthly products like GC)
|
if (exactMatch && fallbackMatch && exactMatch.name !== fallbackMatch.name) {
|
||||||
const rollTargets1: Record<string, ContractInfo | null> = {};
|
console.log(`[contract-resolver] ${sym}: ${fallbackMatch.name} → ${exactMatch.name} (Yahoo) ROLLED`);
|
||||||
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,
|
|
||||||
};
|
|
||||||
// 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 {
|
} else {
|
||||||
rollTargets2[sym] = null;
|
console.log(`[contract-resolver] ${sym}: ${match.name} resolved`);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
rollTargets2[sym] = null;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
rollTargets1[sym] = null;
|
console.log(`[contract-resolver] ${sym}: no matching contract found on Tradovate`);
|
||||||
rollTargets2[sym] = null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
rollTargets1[sym] = null;
|
|
||||||
rollTargets2[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;
|
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() });
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[contract-resolver] ${sym}: Tradovate lookup failed`, err);
|
||||||
|
results[sym] = null;
|
||||||
}
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user