Add volume-based contract auto-resolver and CLAUDE.md

- New lib/contract-resolver.ts: picks the best contract month for each
  symbol by comparing Yahoo Finance volume between the front month
  (Tradovate suggest API) and the roll target (rollcontract API)
- lib/clients.ts: auto-resolves all enabled instruments 15s after startup
  and again daily at midnight via a setInterval check
- lib/tradovate-class.ts: findFrontMonthContract checks resolver cache
  first before falling back to the suggest API
- app/api/instruments/contracts/route.ts: GET returns cached contracts,
  POST triggers a fresh resolve
- app/settings/page.tsx: shows active contract + rolled badge per symbol;
  auto-resolves on load if cache is empty; removed manual Resolve button
- app/api/debug/route.ts: include entity data in recentEntityEvents
- CLAUDE.md: instructs Claude to always work on main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Senofy
2026-03-12 03:17:09 -05:00
co-authored by Claude Sonnet 4.6
parent 881c210366
commit d945e0038f
7 changed files with 400 additions and 20 deletions
+6
View File
@@ -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`.
+2 -1
View File
@@ -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,
})),
};
});
+41
View File
@@ -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 });
}
}
+57 -3
View File
@@ -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<Instrument[]>([]);
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
const [maxConcurrent, setMaxConcurrent] = useState<string>('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<string, ResolvedContract | null>) => {
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,13 +147,40 @@ export default function SettingsPage() {
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Active Contract</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</th>
</tr>
</thead>
<tbody>
{instruments.map((instr) => (
{instruments.map((instr) => {
const c = contracts[instr.symbol];
const wasRolled = c?.alternative && c.rolledVolume != null && c.frontVolume != null && c.rolledVolume > c.frontVolume;
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">{instr.symbol}</td>
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">
{instr.symbol}
</td>
<td className="px-4 py-2.5">
{c ? (
<div className="flex items-center gap-2">
<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
</span>
)}
</div>
) : (
<span className="text-xs text-slate-300 italic"></span>
)}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => toggle(instr.symbol, !instr.enabled)}
@@ -143,7 +196,8 @@ export default function SettingsPage() {
</button>
</td>
</tr>
))}
);
})}
</tbody>
</table>
)}
+48 -1
View File
@@ -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<number, TradovateClient>;
__tradovateClientsInitialized?: boolean;
__contractResolverTimer?: ReturnType<typeof setInterval>;
};
function ensureMap(): Map<number, TradovateClient> {
@@ -51,9 +53,54 @@ export function getClients(): Map<number, TradovateClient> {
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);
}
+224
View File
@@ -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<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;
}
// ── Volume lookup via Yahoo Finance REST API ────────────────────────────────
async function getVolume(tvName: string): Promise<number> {
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<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 ?? [];
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<string, ContractInfo | null> = {};
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<string, Promise<number>> = {};
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];
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;
}
+7
View File
@@ -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}` } }