From 4025ed2f41fce0f1511e55f6bd71066c20cbc5f3 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Mon, 30 Mar 2026 18:19:51 -0500 Subject: [PATCH] Fix contract resolver: use Yahoo continuous contract price matching 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) --- app/settings/page.tsx | 11 +-- lib/auto-trade.ts | 2 +- lib/clients.ts | 4 +- lib/contract-resolver.ts | 171 ++++++++++++++++++++++----------------- 4 files changed, 102 insertions(+), 86 deletions(-) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 78a36fb..d92b271 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -11,8 +11,6 @@ interface Instrument { interface ResolvedContract { name: string; alternative?: string; - frontVolume?: number; - rolledVolume?: number; } interface AppSettings { @@ -238,7 +236,7 @@ export default function SettingsPage() { {instruments.map((instr) => { const c = contracts[instr.symbol]; - const wasRolled = c?.alternative && c.rolledVolume != null && c.frontVolume != null && c.rolledVolume > c.frontVolume; + const wasRolled = !!c?.alternative; return ( @@ -250,14 +248,9 @@ export default function SettingsPage() { {c.name} - {c.frontVolume != null && c.rolledVolume != null && ( - - vol {Math.max(c.frontVolume, c.rolledVolume).toLocaleString()} - - )} {wasRolled && ( - rolled + rolled from {c.alternative} )} diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 2576e0b..0ccd214 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -119,7 +119,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ'; console.log(`[auto-trade] random symbol resolved to: ${resolvedSymbol}`); } - // Resolve Random action once per batch so all accounts trade the same direction + // Resolve Auto action once per batch so all accounts trade the same direction const resolvedAction: 'Buy' | 'Sell' = action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action; diff --git a/lib/clients.ts b/lib/clients.ts index 80ec1a4..10387c1 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -89,8 +89,8 @@ function triggerContractResolve(): void { console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`); resolveContracts(symbols, accessToken) .then((results) => { - const rolled = Object.entries(results).filter(([, v]) => v?.alternative && v.rolledVolume && v.frontVolume && v.rolledVolume > v.frontVolume); - console.log(`[contract-resolver] Done — ${rolled.length} contract(s) rolled to higher-volume month`); + const rolled = Object.entries(results).filter(([, v]) => v?.alternative); + console.log(`[contract-resolver] Done — ${rolled.length} contract(s) rolled via price match`); }) .catch((err) => console.error('[contract-resolver] Resolve failed:', err)); } diff --git a/lib/contract-resolver.ts b/lib/contract-resolver.ts index 0e2a13d..17edfe1 100644 --- a/lib/contract-resolver.ts +++ b/lib/contract-resolver.ts @@ -3,8 +3,8 @@ * * Determines the best contract for each symbol by combining: * 1. Tradovate's suggest API (front month) - * 2. Tradovate's rollcontract API (next month) - * 3. Yahoo Finance volume data (pick whichever has more volume) + * 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). */ @@ -19,10 +19,8 @@ interface ContractInfo { } export interface ResolvedContract extends ContractInfo { - /** The other candidate contract that lost the volume comparison (if any) */ + /** The other candidate contracts that were not selected */ alternative?: string; - frontVolume?: number; - rolledVolume?: number; } // ── Yahoo Finance ticker mapping ───────────────────────────────────────────── @@ -75,30 +73,40 @@ export function getAllCachedContracts(): Record return result; } -// ── Volume lookup via Yahoo Finance REST API ──────────────────────────────── +// ── Yahoo Finance price lookup ─────────────────────────────────────────────── -async function getVolume(tvName: string): Promise { +/** Fetch regularMarketPrice for a Yahoo ticker. Returns null on failure. */ +async function getYahooPrice(ticker: string): Promise { try { - const ticker = toYahoo(tvName); const res = await axios.get( `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}`, - { params: { range: '1d', interval: '1d' } } + { params: { range: '1d', interval: '1d' }, headers: { 'User-Agent': 'Mozilla/5.0' } } ); const meta = res.data?.chart?.result?.[0]?.meta; - return meta?.regularMarketVolume ?? 0; + return meta?.regularMarketPrice ?? null; } catch { - return 0; + return null; } } +/** Get the continuous contract price for a product (e.g. "GC" → "GC=F"). */ +async function getContinuousPrice(product: string): Promise { + return getYahooPrice(`${product}=F`); +} + +/** Get the price for a specific Tradovate contract name (e.g. "GCM6" → "GCM26.CMX"). */ +async function getCandidatePrice(tvName: string): Promise { + 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 target via /contract/rollcontract - * 3. If they differ, compare Yahoo Finance volumes and pick the winner + * 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[], @@ -133,91 +141,106 @@ export async function resolveContracts( } })); - // Step 2: Get roll targets - const rollTargets: Record = {}; + // Step 2: Get roll targets (up to 2 months forward to handle bi-monthly products like GC) + const rollTargets1: Record = {}; + const rollTargets2: Record = {}; await Promise.all(symbols.map(async (sym) => { const front = frontMonths[sym]; - if (!front) { rollTargets[sym] = null; return; } + if (!front) { rollTargets1[sym] = null; rollTargets2[sym] = null; return; } try { - const res = await axios.post( + const res1 = await axios.post( 'https://demo.tradovateapi.com/v1/contract/rollcontract', { name: front.name, forward: true, ifExpired: false }, { headers } ); - const c = res.data?.contract; - if (c && c.name !== front.name) { - rollTargets[sym] = { - id: c.id, - name: c.name, - tickSize: c.providerTickSize ?? 0.25, - contractMaturityId: c.contractMaturityId, + 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 { - rollTargets[sym] = null; // Same contract or no roll available + rollTargets1[sym] = null; + rollTargets2[sym] = null; } } catch { - rollTargets[sym] = null; + rollTargets1[sym] = null; + rollTargets2[sym] = null; } })); - // Step 3: Fetch volumes for all contracts that need comparison - const volumePromises: Record> = {}; + // 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 rolled = rollTargets[sym]; - if (front && rolled) { - if (!volumePromises[front.name]) volumePromises[front.name] = getVolume(front.name); - if (!volumePromises[rolled.name]) volumePromises[rolled.name] = getVolume(rolled.name); - } - } - - // Resolve all volume lookups in parallel - const volumeEntries = Object.entries(volumePromises); - const volumeValues = await Promise.all(volumeEntries.map(([, p]) => p)); - const volumes: Record = {}; - volumeEntries.forEach(([name], i) => { volumes[name] = volumeValues[i]; }); - - if (Object.keys(volumes).length > 0) { - console.log('[contract-resolver] volumes:', volumes); - } - - // Step 4: Pick winners - for (const sym of symbols) { - const front = frontMonths[sym]; - const rolled = rollTargets[sym]; + const roll1 = rollTargets1[sym]; + const roll2 = rollTargets2[sym]; if (!front) { results[sym] = null; continue; } - if (!rolled) { - // No roll target — use front month - results[sym] = { ...front }; - } else { - const frontVol = volumes[front.name] ?? 0; - const rolledVol = volumes[rolled.name] ?? 0; + 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' }); - if (rolledVol >= frontVol) { - // Rolled contract has equal or more volume — use it - // (equal includes both-zero case: prefer the further-out month) - results[sym] = { - ...rolled, - alternative: front.name, - frontVolume: frontVol, - rolledVolume: rolledVol, - }; - console.log(`[contract-resolver] ${sym}: ${front.name} (vol=${frontVol}) → ${rolled.name} (vol=${rolledVol}) ROLLED`); - } else { - // Front month has more volume — keep it - results[sym] = { - ...front, - alternative: rolled.name, - frontVolume: frontVol, - rolledVolume: rolledVol, - }; - console.log(`[contract-resolver] ${sym}: ${front.name} (vol=${frontVol}) stays (rolled ${rolled.name} vol=${rolledVol})`); + // 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