Replace volume-based contract selection with Yahoo Finance continuous
contract price matching ({PRODUCT}=F). The old approach compared volumes
across front/roll candidates, which failed when serial months had
deceptive volume (6EJ26 > 6EM26) or Yahoo was rate-limited (all 0s).
Now fetches the continuous contract price and matches it against
candidates within 0.1% tolerance. Falls back to roll1 if Yahoo fails.
Also adds User-Agent header to avoid 429 rate limiting.
Verified: GC=F price matches GCM26, 6E=F price matches 6EM26.
Also fixes stale 'Random' comment in auto-trade.ts and cleans up
frontVolume/rolledVolume references from settings page.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
/**
|
|
* Contract Resolver
|
|
*
|
|
* Determines the best contract for each symbol by combining:
|
|
* 1. Tradovate's suggest API (front month)
|
|
* 2. Tradovate's rollcontract API (next months)
|
|
* 3. Yahoo Finance continuous contract price matching ({PRODUCT}=F)
|
|
*
|
|
* 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;
|
|
}
|
|
|
|
// ── Yahoo Finance ticker mapping ─────────────────────────────────────────────
|
|
|
|
const EXCHANGE_MAP: Record<string, string> = {
|
|
NQ: 'CME', MNQ: 'CME', ES: 'CME', MES: 'CME',
|
|
YM: 'CBT', MYM: 'CBT', RTY: 'CME', M2K: 'CME',
|
|
GC: 'CMX', MGC: 'CMX',
|
|
SI: 'CMX', SIL: 'CMX',
|
|
CL: 'NYM', MCL: 'NYM', NG: 'NYM',
|
|
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 {
|
|
const yearDigit = tvName.slice(-1);
|
|
const monthLetter = tvName.slice(-2, -1);
|
|
const product = tvName.slice(0, -2);
|
|
const year2d = '2' + yearDigit; // assumes 2020s
|
|
const exchange = EXCHANGE_MAP[product] ?? 'CME';
|
|
return `${product}${monthLetter}${year2d}.${exchange}`;
|
|
}
|
|
|
|
// ── 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 price lookup ───────────────────────────────────────────────
|
|
|
|
/** Fetch regularMarketPrice for a Yahoo ticker. Returns null on failure. */
|
|
async function getYahooPrice(ticker: string): Promise<number | null> {
|
|
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 meta = res.data?.chart?.result?.[0]?.meta;
|
|
return meta?.regularMarketPrice ?? null;
|
|
} catch {
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the best contract for a list of symbols using a Tradovate access token.
|
|
* For each symbol:
|
|
* 1. Get front month via /contract/suggest
|
|
* 2. Get roll targets via /contract/rollcontract (up to 2 forward)
|
|
* 3. Match candidates against Yahoo's continuous contract price ({PRODUCT}=F)
|
|
*/
|
|
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: Get front month for each symbol
|
|
const frontMonths: Record<string, ContractInfo | null> = {};
|
|
await Promise.all(symbols.map(async (sym) => {
|
|
try {
|
|
const res = await axios.get(
|
|
`https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(sym)}&l=5`,
|
|
{ headers }
|
|
);
|
|
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) => {
|
|
if (!c.name.startsWith(sym)) return false;
|
|
const rest = c.name.slice(sym.length);
|
|
return /^[A-Z]\d/.test(rest);
|
|
});
|
|
frontMonths[sym] = match ? {
|
|
id: match.id,
|
|
name: match.name,
|
|
tickSize: (match as any).providerTickSize ?? 0.25,
|
|
contractMaturityId: (match as any).contractMaturityId,
|
|
} : null;
|
|
} catch {
|
|
frontMonths[sym] = null;
|
|
}
|
|
}));
|
|
|
|
// Step 2: Get roll targets (up to 2 months forward to handle bi-monthly products like GC)
|
|
const rollTargets1: Record<string, ContractInfo | null> = {};
|
|
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 {
|
|
rollTargets2[sym] = null;
|
|
}
|
|
} catch {
|
|
rollTargets2[sym] = null;
|
|
}
|
|
} else {
|
|
rollTargets1[sym] = null;
|
|
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;
|
|
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 whose price matches the continuous contract (within 0.1% tolerance)
|
|
let best = candidates[0]; // default to front
|
|
if (continuousPrice !== null) {
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
const price = candidatePrices[i];
|
|
if (price !== null && Math.abs(price - continuousPrice) / continuousPrice < 0.001) {
|
|
best = candidates[i];
|
|
break; // prefer the nearest matching contract
|
|
}
|
|
}
|
|
} 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() });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|