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>
215 lines
8.2 KiB
TypeScript
215 lines
8.2 KiB
TypeScript
/**
|
|
* Contract Resolver
|
|
*
|
|
* Determines the active contract for each symbol by:
|
|
* 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).
|
|
*/
|
|
|
|
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;
|
|
}
|
|
|
|
const MONTH_CODES_ORDERED = ['F', 'G', 'H', 'J', 'K', 'M', 'N', 'Q', 'U', 'V', 'X', 'Z'];
|
|
const PROBE_WINDOW = 6;
|
|
|
|
// ── 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 lookups ────────────────────────────────────────────────────
|
|
|
|
/** 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) 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.
|
|
* For each symbol:
|
|
* 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[],
|
|
accessToken: string,
|
|
): Promise<Record<string, ResolvedContract | null>> {
|
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
|
const results: Record<string, ResolvedContract | null> = {};
|
|
|
|
await Promise.all(symbols.map(async (sym) => {
|
|
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`);
|
|
}
|
|
|
|
try {
|
|
const res = await axios.get(
|
|
`https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(chosenName ?? sym)}&l=10`,
|
|
{ headers }
|
|
);
|
|
const contracts: ContractInfo[] = res.data ?? [];
|
|
|
|
const exactMatch = chosenName ? contracts.find((c) => c.name === chosenName) : null;
|
|
const fallbackMatch = contracts.find((c) => {
|
|
if (!c.name.startsWith(sym)) return false;
|
|
return /^[A-Z]\d/.test(c.name.slice(sym.length));
|
|
});
|
|
const match = exactMatch ?? fallbackMatch;
|
|
|
|
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);
|
|
results[sym] = null;
|
|
}
|
|
}));
|
|
|
|
return results;
|
|
}
|