|
@@ -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
|