diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f55f2d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# Claude Instructions + +## Working Directory +Always work directly on `main`. Do **not** create worktrees or feature branches unless explicitly asked. + +The project root is `D:\Development\market-dev\autotrader-firms\autotrader`. diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts index a1cdc64..444b317 100644 --- a/app/api/debug/route.ts +++ b/app/api/debug/route.ts @@ -28,10 +28,11 @@ export async function GET() { dailyPnLEntries: (client.dailyPnL[acc.id] ?? []).length, dailyPnL: client.dailyPnL[acc.id] ?? [], })), - recentEntityEvents: client.recentEntityEvents.slice(-3).map((e) => ({ + recentEntityEvents: client.recentEntityEvents.slice(-10).map((e) => ({ ts: new Date(e.ts).toISOString(), entityType: e.entityType, eventType: e.eventType, + entity: e.entity, })), }; }); diff --git a/app/api/instruments/contracts/route.ts b/app/api/instruments/contracts/route.ts new file mode 100644 index 0000000..7aa81d5 --- /dev/null +++ b/app/api/instruments/contracts/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { getClients } from '@/lib/clients'; +import { getInstruments } from '@/lib/db'; +import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver'; + +/** + * GET /api/instruments/contracts + * Returns cached resolved contracts (fast, no external calls). + */ +export async function GET() { + return NextResponse.json(getAllCachedContracts()); +} + +/** + * POST /api/instruments/contracts + * Triggers a fresh resolve of all enabled instruments via Tradovate + yfinance. + * Returns the resolved contracts with volume data. + */ +export async function POST() { + try { + const clients = getClients(); + // Find first client with a valid token + let accessToken: string | null = null; + for (const [, c] of clients) { + const token = (c as any).accessInfo?.accessToken; + if (token) { accessToken = token; break; } + } + if (!accessToken) { + return NextResponse.json({ error: 'No authenticated client available' }, { status: 503 }); + } + + const instruments = getInstruments().filter((i) => i.enabled); + const symbols = instruments.map((i) => i.symbol); + + const resolved = await resolveContracts(symbols, accessToken); + return NextResponse.json(resolved); + } catch (err: any) { + console.error('[POST /api/instruments/contracts]', err); + return NextResponse.json({ error: err?.message ?? 'Resolve failed' }, { status: 500 }); + } +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 6eb2fa7..7df4267 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -8,12 +8,20 @@ interface Instrument { enabled: boolean; } +interface ResolvedContract { + name: string; + alternative?: string; + frontVolume?: number; + rolledVolume?: number; +} + interface AppSettings { max_concurrent_accounts: string | null; } export default function SettingsPage() { const [instruments, setInstruments] = useState([]); + const [contracts, setContracts] = useState>({}); const [maxConcurrent, setMaxConcurrent] = useState('5'); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); @@ -30,6 +38,24 @@ export default function SettingsPage() { setMaxConcurrent(s.max_concurrent_accounts); } }); + + // Load cached contracts; if cache is empty, auto-resolve + fetch('/api/instruments/contracts') + .then((r) => r.json()) + .then((data: Record) => { + if (data.error) return; + const hasData = Object.values(data).some((v) => v !== null); + if (hasData) { + setContracts(data); + } else { + // Cache empty — trigger a fresh resolve automatically + fetch('/api/instruments/contracts', { method: 'POST' }) + .then((r) => r.json()) + .then((fresh) => { if (!fresh.error) setContracts(fresh); }) + .catch(() => {}); + } + }) + .catch(() => {}); }, []); async function toggle(symbol: string, enabled: boolean) { @@ -121,29 +147,57 @@ export default function SettingsPage() { Symbol + Active Contract Enabled - {instruments.map((instr) => ( - - {instr.symbol} - - - - - ))} + > + + + + + ); + })} )} diff --git a/lib/clients.ts b/lib/clients.ts index 6aa8a9b..3424d1e 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -1,10 +1,12 @@ import { TradovateClient } from './tradovate-class'; -import { getFirms } from './db'; +import { getFirms, getInstruments } from './db'; +import { resolveContracts } from './contract-resolver'; // Use global to persist the client pool across HMR reloads in dev mode const g = global as typeof globalThis & { __tradovateClients?: Map; __tradovateClientsInitialized?: boolean; + __contractResolverTimer?: ReturnType; }; function ensureMap(): Map { @@ -51,9 +53,54 @@ export function getClients(): Map { initClient(firm.id, firm.username, firm.password, firm.name); } console.log(`[clients] Initialized ${firms.length} Tradovate client(s)`); + + // Auto-resolve contracts after clients have time to authenticate + setTimeout(() => triggerContractResolve(), 15_000); + + // Schedule daily resolve at midnight + scheduleDailyResolve(); } catch (err) { console.error('[clients] Failed to initialize clients', err); } } return map; } + +// ── Contract auto-resolve ──────────────────────────────────────────────────── + +function triggerContractResolve(): void { + const map = ensureMap(); + // Find first client with a valid access token + let accessToken: string | null = null; + for (const [, c] of map) { + const token = (c as any).accessInfo?.accessToken; + if (token) { accessToken = token; break; } + } + if (!accessToken) { + console.log('[contract-resolver] No authenticated client yet — skipping resolve'); + return; + } + + const symbols = getInstruments().filter((i) => i.enabled).map((i) => i.symbol); + 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`); + }) + .catch((err) => console.error('[contract-resolver] Resolve failed:', err)); +} + +function scheduleDailyResolve(): void { + // Clear any existing timer (HMR safety) + if (g.__contractResolverTimer) clearInterval(g.__contractResolverTimer); + + // Check every minute if it's midnight (00:00) + g.__contractResolverTimer = setInterval(() => { + const now = new Date(); + if (now.getHours() === 0 && now.getMinutes() === 0) { + console.log('[contract-resolver] Midnight resolve triggered'); + triggerContractResolve(); + } + }, 60_000); +} diff --git a/lib/contract-resolver.ts b/lib/contract-resolver.ts new file mode 100644 index 0000000..ec99817 --- /dev/null +++ b/lib/contract-resolver.ts @@ -0,0 +1,224 @@ +/** + * Contract Resolver + * + * 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) + * + * 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 contract that lost the volume comparison (if any) */ + alternative?: string; + frontVolume?: number; + rolledVolume?: number; +} + +// ── Yahoo Finance ticker mapping ───────────────────────────────────────────── + +const EXCHANGE_MAP: Record = { + 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(); +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 { + const result: Record = {}; + 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; +} + +// ── Volume lookup via Yahoo Finance REST API ──────────────────────────────── + +async function getVolume(tvName: 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' } } + ); + const meta = res.data?.chart?.result?.[0]?.meta; + return meta?.regularMarketVolume ?? 0; + } catch { + return 0; + } +} + +// ── 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 + */ +export async function resolveContracts( + symbols: string[], + accessToken: string, +): Promise> { + const headers = { Authorization: `Bearer ${accessToken}` }; + const results: Record = {}; + + // Step 1: Get front month for each symbol + const frontMonths: Record = {}; + 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 ?? []; + const match = contracts.find((c) => c.name.startsWith(sym)); + 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 + const rollTargets: Record = {}; + await Promise.all(symbols.map(async (sym) => { + const front = frontMonths[sym]; + if (!front) { rollTargets[sym] = null; return; } + try { + const res = 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, + }; + } else { + rollTargets[sym] = null; // Same contract or no roll available + } + } catch { + rollTargets[sym] = null; + } + })); + + // Step 3: Fetch volumes for all contracts that need comparison + const volumePromises: Record> = {}; + 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]; + + 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; + + if (rolledVol > frontVol) { + // Rolled contract has more volume — use it + 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 still 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})`); + } + } + + // Update cache + if (results[sym]) { + cache.set(sym, { contract: results[sym]!, resolvedAt: Date.now() }); + } + } + + return results; +} diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index ffe1cd1..832c21e 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -512,6 +512,13 @@ export class TradovateClient { async findFrontMonthContract(productName: string): Promise<{ id: number; name: string; tickSize: number } | null> { if (!this.accessInfo?.accessToken) return null; + + // Check the volume-based resolver cache first + const { getCachedContract } = require('./contract-resolver') as typeof import('./contract-resolver'); + const cached = getCachedContract(productName); + if (cached) return { id: cached.id, name: cached.name, tickSize: cached.tickSize }; + + // Fallback: use suggest API directly (first call before resolver has run) const res = await axios.get( `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(productName)}&l=20`, { headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` } }