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>
180 lines
7.7 KiB
TypeScript
180 lines
7.7 KiB
TypeScript
/**
|
|
* 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
|
|
*
|
|
* Results are cached and refreshed periodically (default: every 30 minutes).
|
|
*/
|
|
|
|
import axios from 'axios';
|
|
|
|
interface ContractInfo {
|
|
id: number;
|
|
name: string;
|
|
tickSize: number;
|
|
contractMaturityId?: number;
|
|
}
|
|
|
|
export interface ResolvedContract extends ContractInfo {
|
|
/** The other candidate contracts that were not selected */
|
|
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 };
|
|
}
|
|
|
|
// ── Cache ────────────────────────────────────────────────────────────────────
|
|
|
|
const cache = new Map<string, { contract: ResolvedContract; resolvedAt: number }>();
|
|
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
|
export function getCachedContract(symbol: string): ResolvedContract | null {
|
|
const entry = cache.get(symbol);
|
|
if (!entry) return null;
|
|
if (Date.now() - entry.resolvedAt > CACHE_TTL_MS) {
|
|
cache.delete(symbol);
|
|
return null;
|
|
}
|
|
return entry.contract;
|
|
}
|
|
|
|
export function getAllCachedContracts(): Record<string, ResolvedContract | null> {
|
|
const result: Record<string, ResolvedContract | null> = {};
|
|
for (const [symbol, entry] of cache) {
|
|
if (Date.now() - entry.resolvedAt > CACHE_TTL_MS) {
|
|
cache.delete(symbol);
|
|
result[symbol] = null;
|
|
} else {
|
|
result[symbol] = entry.contract;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── Yahoo Finance lookup ─────────────────────────────────────────────────────
|
|
|
|
/** 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(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 };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Main resolver ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the active contract for a list of symbols using Yahoo Finance + Tradovate.
|
|
* 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)
|
|
*/
|
|
export async function resolveContracts(
|
|
symbols: string[],
|
|
accessToken: string,
|
|
): Promise<Record<string, ResolvedContract | null>> {
|
|
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;
|
|
} 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`,
|
|
{ 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 fallbackMatch = contracts.find((c) => {
|
|
if (!c.name.startsWith(sym)) return false;
|
|
const rest = c.name.slice(sym.length);
|
|
return /^[A-Z]\d/.test(rest);
|
|
});
|
|
|
|
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 {
|
|
console.log(`[contract-resolver] ${sym}: no matching contract found on Tradovate`);
|
|
results[sym] = null;
|
|
}
|
|
} catch (err) {
|
|
console.error(`[contract-resolver] ${sym}: Tradovate lookup failed`, err);
|
|
results[sym] = null;
|
|
}
|
|
}));
|
|
|
|
return results;
|
|
}
|