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:
co-authored by
Claude Sonnet 4.6
parent
881c210366
commit
d945e0038f
+48
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` } }
|
||||
|
||||
Reference in New Issue
Block a user