Files
autofirmer-expanded/lib/contract-resolver.ts
Brandon LiandClaude Opus 4.7 e37c39a7d1 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>
2026-06-19 01:01:17 -05:00

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;
}