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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9a24692c1a
commit
4025ed2f41
@@ -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() {
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">
|
||||
@@ -250,14 +248,9 @@ export default function SettingsPage() {
|
||||
<span className={`font-mono text-sm ${wasRolled ? 'text-amber-600 font-semibold' : 'text-slate-600'}`}>
|
||||
{c.name}
|
||||
</span>
|
||||
{c.frontVolume != null && c.rolledVolume != null && (
|
||||
<span className="text-xs text-slate-400">
|
||||
vol {Math.max(c.frontVolume, c.rolledVolume).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{wasRolled && (
|
||||
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium">
|
||||
rolled
|
||||
rolled from {c.alternative}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+2
-2
@@ -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));
|
||||
}
|
||||
|
||||
+97
-74
@@ -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<string, ResolvedContract | null>
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Volume lookup via Yahoo Finance REST API ────────────────────────────────
|
||||
// ── Yahoo Finance price lookup ───────────────────────────────────────────────
|
||||
|
||||
async function getVolume(tvName: string): Promise<number> {
|
||||
/** Fetch regularMarketPrice for a Yahoo ticker. Returns null on failure. */
|
||||
async function getYahooPrice(ticker: string): Promise<number | null> {
|
||||
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<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 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<string, ContractInfo | 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) { 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<string, Promise<number>> = {};
|
||||
// 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<string, number> = {};
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user