Instruments: each firm's list from the master, and a session-aware futures draw

- lib/instrument-specs.ts (pure, tested): the CME specs — point value,
  tick size, one-tick spread, Yahoo ticker — replacing POINT_VALUES; the
  draw's maths; the 12-hour hold cap; the micro for drain and extra-day
  legs; reading a firm's list (the master's, or the old rules against an
  older master: this machine's enabled set less bans, the symbol map).
- lib/instruments.ts: the one module the trading paths ask. Measures every
  future's daily move from Yahoo's hourly candles once per trade date, and
  shows entry cost (a tick of spread plus Tradovate's fee parameters or
  TopstepX's last measured fill) without ever choosing by it.
- Futures Auto draws one root per batch from the batch firm's list: movement
  per dollar of spread early in the session with a 10% floor each, sliding
  to the fastest movers by the last entry; nothing expected to hold 12h or
  more; micros only if nothing else is listed. Every draw logs its odds.
- The batch is chosen before its instrument, and a group that can't trade
  anything yields to the next. Copy trade matches the held contract against
  the listed roots and won't copy an instrument it can't identify. Manual
  legs, manual orders and drain/extra-day legs follow the lists.
- DXtrade firms rank their own list whatever root was drawn.
- Capabilities, the dashboard's trade bar and contract resolves follow the
  lists; the trade bar and scheduler default to Auto. The master link
  reconnects when master_dashboard_url changes, and the catalog drops
  ?since after a switch so two masters' versions can't collide.
- Reports carry each futures firm's instrumentStats for the master's Firms
  page; GET /api/instruments shows them (?simulate=N runs the draw, no
  orders); the Settings page shows the lists read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 19:47:32 -05:00
co-authored by Claude Opus 5.5
parent 302e9b4911
commit 02544fbdf5
25 changed files with 1337 additions and 277 deletions
+9
View File
@@ -1,4 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedFirm } from '@/lib/firm-catalog';
import { firmInstruments, isFuturesFirm } from '@/lib/instruments';
import { getClients } from '@/lib/clients';
export async function POST(
@@ -26,6 +28,13 @@ export async function POST(
const client = clients.get(firmId);
if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 });
// A futures firm trades only what its instrument list (master Firms
// page) names.
const firm = getCachedFirm(firmId);
if (firm && isFuturesFirm(firm) && !firmInstruments(firm).includes(symbol)) {
return NextResponse.json({ error: `${symbol} isn't on ${firm.name}'s instrument list` }, { status: 400 });
}
// 1. Find the active contract chosen by the shared resolver
const contract = await client.findFrontMonthContract(symbol);
if (!contract) {
+3 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedFirm } from '@/lib/firm-catalog';
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
import { getCredentials } from '@/lib/db';
/** One firm from the cached catalog. Read-only: firms and their account types
@@ -22,7 +23,8 @@ export async function GET(
firm: firm.name,
username: creds?.username ?? '',
hasCredentials: !!creds,
bannedSymbols: firm.bannedSymbols,
instruments: firmInstruments(firm),
instrumentsSource: instrumentsSource(firm),
accounts: firm.accountTypes,
catalogUpdatedAt: firm.updatedAt,
});
+3 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
import { getCredentials } from '@/lib/db';
import type { FirmConfig } from '@/types';
@@ -25,7 +26,8 @@ export async function GET() {
platform: firm.platform,
username: creds?.username ?? '',
hasCredentials,
bannedSymbols: firm.bannedSymbols,
instruments: firmInstruments(firm),
instrumentsSource: instrumentsSource(firm),
accounts: firm.accountTypes.map((a) => ({
id: a.id,
label: a.label,
+3 -3
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getInstruments } from '@/lib/db';
import { futuresRootsHere } from '@/lib/instruments';
import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver';
/**
@@ -15,7 +15,7 @@ export async function GET() {
* POST /api/instruments/contracts
* Triggers a fresh resolve via Tradovate + Yahoo Finance.
* Body (optional): { symbols: string[] } — when provided, resolves only those symbols.
* Otherwise resolves all enabled instruments.
* Otherwise resolves every root this machine's futures firms list.
*/
export async function POST(req: NextRequest) {
try {
@@ -35,7 +35,7 @@ export async function POST(req: NextRequest) {
const symbols = requestedSymbols?.length
? requestedSymbols
: getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
: futuresRootsHere();
const resolved = await resolveContracts(symbols, accessToken);
return NextResponse.json(resolved);
+36 -4
View File
@@ -1,6 +1,38 @@
import { NextResponse } from 'next/server';
import { getInstruments } from '@/lib/db';
import { NextRequest, NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getCachedCatalog } from '@/lib/firm-catalog';
import {
ensureMarket, firmInstruments, futuresRootsHere, instrumentStatsFor, instrumentsSource, isFuturesFirm, simulateDraws,
} from '@/lib/instruments';
import { CME_ROOTS } from '@/lib/instrument-specs';
export async function GET() {
return NextResponse.json(getInstruments());
export const dynamic = 'force-dynamic';
/** The leg the overview sizes holds for: $1,000, stopless, one contract. */
const EXAMPLE_LEG = { leg: 1000, stopDollars: Infinity, maxPositionSize: 0 };
/**
* GET /api/instruments — what each firm trades (the master's lists) and, for
* futures firms, how the Auto draw sees every instrument right now: entry
* cost, one contract's daily move, the hold of a $1,000 leg, the chance.
* ?simulate=N adds N draws from the real draw code — no orders.
*/
export async function GET(req: NextRequest) {
await ensureMarket(CME_ROOTS, 8_000);
const clients = getClients();
const n = Math.min(100_000, Math.max(0, Math.floor(Number(req.nextUrl.searchParams.get('simulate')) || 0)));
const firms = getCachedCatalog().map((firm) => {
const instruments = firmInstruments(firm);
const futures = isFuturesFirm(firm);
return {
id: firm.id,
name: firm.name,
platform: firm.platform,
source: instrumentsSource(firm),
instruments,
...(futures ? { stats: instrumentStatsFor(firm, clients.get(firm.id), { ...EXAMPLE_LEG, account: 'a $1,000 leg' }) } : {}),
...(futures && n > 0 ? { simulated: simulateDraws(instruments, EXAMPLE_LEG, n) } : {}),
};
});
return NextResponse.json({ roots: futuresRootsHere(), firms });
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { runTrade, startScheduler } from '@/lib/auto-trade';
import { errorMessage } from '@/lib/platform-client';
import { POINT_VALUES } from '@/lib/trading-logic';
import { CME_SPECS } from '@/lib/instrument-specs';
export async function POST(req: NextRequest) {
try {
@@ -12,7 +12,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Missing required fields: action, symbol' }, { status: 400 });
}
if (symbol !== 'Auto' && !POINT_VALUES[symbol]) {
if (symbol !== 'Auto' && !CME_SPECS[symbol]) {
return NextResponse.json({ error: `Unknown symbol: ${symbol}` }, { status: 400 });
}
+10 -5
View File
@@ -137,20 +137,25 @@ export default function FirmSettingsPage() {
</div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
Banned Symbols
Instruments
</h2>
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
{!firm || firm.bannedSymbols.length === 0 ? (
<span className="text-sm text-slate-400 italic">None</span>
{!firm || firm.instruments.length === 0 ? (
<span className="text-sm text-slate-400 italic">None — this firm won&apos;t trade</span>
) : (
<div className="flex flex-wrap gap-1.5">
{firm.bannedSymbols.map((sym) => (
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-red-100 text-red-700">
{firm.instruments.map((sym) => (
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">
{sym}
</span>
))}
</div>
)}
<p className="mt-2 text-xs text-slate-400">
{firm?.instrumentsSource === 'legacy'
? 'The master doesn\'t send instrument lists yet: this machine\'s enabled instruments, less the firm\'s banned symbols.'
: 'Edited on the master\'s Firms page.'}
</p>
</div>
</div>
+11 -10
View File
@@ -498,9 +498,9 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
<td className="px-4 py-3.5">
<div className="flex items-center gap-2.5">
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
{firm.bannedSymbols?.includes(tradeSymbol) && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
{tradeSymbol} BANNED
{firm.platform !== 'dxtrade' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold" title="Not on this firm's instrument list (master Firms page) — it sits this symbol out">
{tradeSymbol} not listed
</span>
)}
{firm.platform === 'topstepx' && (
@@ -629,7 +629,7 @@ export default function Home() {
const [sortDir, setSortDir] = useState<SortDir>('asc');
// Trade controls
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'Auto', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
const [enabledSymbols, setEnabledSymbols] = useState<string[]>([]);
const [tradeSymbol, setTradeSymbol] = useState('');
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy');
@@ -681,12 +681,13 @@ export default function Home() {
};
useEffect(() => {
// The roots this machine's futures firms list; 'Auto' draws among
// each batch firm's list and leads the dropdown.
fetch('/api/instruments')
.then((r) => r.json() as Promise<{ symbol: string; enabled: boolean }[]>)
.then((instruments) => {
const syms = instruments.filter((i) => i.enabled).map((i) => i.symbol);
setEnabledSymbols(syms);
setTradeSymbol((prev) => prev || syms[0] || 'NQ');
.then((r) => r.json() as Promise<{ roots: string[] }>)
.then(({ roots }) => {
setEnabledSymbols(roots ?? []);
setTradeSymbol((prev) => prev || 'Auto');
})
.catch(() => {});
@@ -839,8 +840,8 @@ export default function Home() {
onChange={(e) => setTradeSymbol(e.target.value)}
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
<option value="Auto">Auto</option>
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
<button
+98 -90
View File
@@ -3,9 +3,31 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
interface Instrument {
symbol: string;
enabled: boolean;
/** GET /api/instruments: each firm's list (from the master) and, for
* futures firms, how the Auto draw sees each instrument. */
interface InstrumentsOverview {
roots: string[];
firms: {
id: string;
name: string;
platform: string;
source: 'master' | 'legacy';
instruments: string[];
stats?: {
maxHoldHours: number;
sessionProgress: number | null;
instruments: {
symbol: string;
listed: boolean;
spreadDollars: number;
feesPerSide: number | null;
dailyMoveDollars: number | null;
holdHours: number | null;
disabled: boolean;
chance: number | null;
}[];
};
}[];
}
interface ResolvedContract {
@@ -33,7 +55,7 @@ interface BackupMeta {
}
export default function SettingsPage() {
const [instruments, setInstruments] = useState<Instrument[]>([]);
const [overview, setOverview] = useState<InstrumentsOverview | null>(null);
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
const [tradingHours, setTradingHours] = useState<string>('full_cme');
@@ -75,7 +97,8 @@ export default function SettingsPage() {
useEffect(() => {
fetch('/api/instruments')
.then((r) => r.json())
.then(setInstruments);
.then(setOverview)
.catch(() => {});
fetchCatalog();
@@ -125,33 +148,6 @@ export default function SettingsPage() {
.catch(() => {});
}, []);
async function toggle(symbol: string, enabled: boolean) {
setInstruments((prev) =>
prev.map((i) => (i.symbol === symbol ? { ...i, enabled } : i))
);
await fetch(`/api/instruments/${symbol}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
// When enabling a symbol, auto-resolve its active contract
if (enabled) {
fetch('/api/instruments/contracts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbols: [symbol] }),
})
.then((r) => r.json())
.then((fresh: Record<string, ResolvedContract | null>) => {
if (!fresh.error) {
setContracts((prev) => ({ ...prev, ...fresh }));
}
})
.catch(() => {});
}
}
async function saveSettings() {
setSaving(true);
setSaved(false);
@@ -443,68 +439,80 @@ export default function SettingsPage() {
</div>
{/* ── Instruments ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-1">
Instruments
</h2>
<p className="text-xs text-slate-400 mb-3">
What each firm trades is set on the master&apos;s{' '}
{masterUrl ? <a className="text-blue-500 hover:underline" href={`${masterUrl.replace(/\/+$/, '')}/firms`} target="_blank" rel="noreferrer">Firms page</a> : 'Firms page'}.
Futures &ldquo;Auto&rdquo; draws one per batch: cheaper per move early in the session (every instrument gets a floor),
the fastest near the last entry, never one expected to hold 12h or more. Holds below are for a $1,000 leg.
</p>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
{instruments.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
Loading…
{!overview ? (
<div className="bg-white border border-slate-200 rounded-xl px-4 py-8 text-center text-sm text-slate-400 italic shadow-sm">Loading…</div>
) : overview.firms.map((firm) => (
<div key={firm.id} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm mb-3">
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 border-b border-slate-200">
<span className="font-semibold text-sm text-slate-800">{firm.name}</span>
<span className="text-xs text-slate-400">{firm.platform}</span>
{firm.source === 'legacy' && (
<span className="text-xs bg-amber-50 text-amber-700 border border-amber-200 rounded px-1.5 py-0.5" title="The master doesn't send instrument lists yet: this machine's enabled instruments, less the firm's banned symbols">
from this machine (older master)
</span>
)}
{firm.instruments.length === 0 && <span className="text-xs text-amber-700">no instruments — won&apos;t trade</span>}
</div>
) : (
<table className="w-full text-sm border-collapse">
<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) => {
const c = contracts[instr.symbol];
const wasRolled = !!c?.alternative;
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">
{instr.enabled && 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>
{wasRolled && (
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium">
rolled from {c.alternative}
</span>
)}
</div>
) : null}
</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'
}`}
/>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{firm.stats ? (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-100 text-xs uppercase tracking-wider text-slate-400">
<th className="px-4 py-2 text-left font-semibold">Symbol</th>
<th className="px-4 py-2 text-left font-semibold">Contract</th>
<th className="px-4 py-2 text-right font-semibold">Entry</th>
<th className="px-4 py-2 text-right font-semibold">Daily move</th>
<th className="px-4 py-2 text-right font-semibold">Hold</th>
<th className="px-4 py-2 text-right font-semibold">Chance now</th>
</tr>
</thead>
<tbody>
{firm.instruments.map((sym) => {
const x = firm.stats!.instruments.find((i) => i.symbol === sym);
const c = contracts[sym];
return (
<tr key={sym} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2 font-mono font-semibold text-slate-800">{sym}</td>
<td className="px-4 py-2 font-mono text-slate-600">
{c?.name ?? '—'}
{c?.alternative && <span className="ml-2 text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-sans">rolled from {c.alternative}</span>}
</td>
<td className="px-4 py-2 text-right tabular-nums text-slate-600" title="Per contract: one tick of spread, plus the firm's fees per side when known">
{x ? `$${x.spreadDollars.toFixed(2)}${x.feesPerSide !== null ? ` + $${x.feesPerSide.toFixed(2)}` : ''}` : '—'}
</td>
<td className="px-4 py-2 text-right tabular-nums text-slate-600">
{x?.dailyMoveDollars != null ? `$${x.dailyMoveDollars.toLocaleString()}` : '—'}
</td>
<td className={`px-4 py-2 text-right tabular-nums ${x?.disabled ? 'text-amber-600' : 'text-slate-600'}`}>
{x?.holdHours != null ? (x.holdHours < 48 ? `${x.holdHours.toFixed(1)}h` : `${(x.holdHours / 24).toFixed(1)}d`) : '—'}
{x?.disabled && ' · disabled'}
</td>
<td className="px-4 py-2 text-right tabular-nums font-semibold text-slate-700">
{x?.chance != null ? `${Math.round(x.chance * 100)}%` : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="flex flex-wrap gap-1.5 px-4 py-3">
{firm.instruments.map((sym) => (
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">{sym}</span>
))}
</div>
)}
</div>
))}
</div>
</div>
);