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
@@ -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,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+72
-18
@@ -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,29 +147,57 @@ 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) => (
|
||||
<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 text-right">
|
||||
<button
|
||||
onClick={() => toggle(instr.symbol, !instr.enabled)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
||||
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
instr.enabled ? 'translate-x-4' : 'translate-x-1'
|
||||
{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">
|
||||
{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)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
||||
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
instr.enabled ? 'translate-x-4' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user