From 02544fbdf5929d4ad49b6e97851a1a6b836a4841 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sat, 26 Sep 2026 19:47:32 -0500 Subject: [PATCH] Instruments: each firm's list from the master, and a session-aware futures draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 48 +++- app/api/firms/[id]/orders/route.ts | 9 + app/api/firms/[id]/route.ts | 4 +- app/api/firms/route.ts | 4 +- app/api/instruments/contracts/route.ts | 6 +- app/api/instruments/route.ts | 40 +++- app/api/trade/route.ts | 4 +- app/firms/[id]/settings/page.tsx | 15 +- app/page.tsx | 21 +- app/settings/page.tsx | 188 ++++++++-------- lib/auto-trade.ts | 298 +++++++++++++++++-------- lib/clients.ts | 26 ++- lib/dxtrade-class.ts | 31 +-- lib/firm-catalog.ts | 12 +- lib/instrument-specs.ts | 267 ++++++++++++++++++++++ lib/instruments.ts | 280 +++++++++++++++++++++++ lib/leg-estimate.ts | 67 ++++-- lib/master-link.ts | 31 ++- lib/master-protocol.ts | 40 +++- lib/reporter.ts | 41 +++- lib/trading-logic.ts | 9 - lib/tradovate-class.ts | 3 +- package.json | 2 +- scripts/check-instruments.mjs | 161 +++++++++++++ types.ts | 7 +- 25 files changed, 1337 insertions(+), 277 deletions(-) create mode 100644 lib/instrument-specs.ts create mode 100644 lib/instruments.ts create mode 100644 scripts/check-instruments.mjs diff --git a/README.md b/README.md index 2159ada..5ae87ec 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,44 @@ stores the platform login for each firm. --- +## Instruments: what each firm trades + +Each firm's **Instruments** list on the master's Firms page is the one place +that says what it trades — there are no per-machine toggles or banned symbols +any more (the instance's Settings page shows the lists read-only). + +- **Futures firms** (Tradovate, TopstepX) list CME roots — the same names on + both platforms; each client turns a root into its own front-month contract. +- **DXtrade firms** list their own symbols (`ETH/USD`), whatever root a batch + drew. + +With the scheduler on **Auto**, each futures batch draws one instrument from +the batch firm's list, at random but weighted: + +- **Efficiency** — one contract's typical daily move ÷ one tick of spread + (from Yahoo's hourly candles over the past week, measured once a day). + Commissions don't count; they're shown, never used to choose. +- **Session timing** — at the open every instrument gets a 10% floor and the + weights follow efficiency; toward the last entry (14:55 CT) the floor fades + and the weight moves sharply to the fastest movers. +- **12-hour cap** — sizing is unchanged (a contract per $1,000 of leg, up to + the type's max contracts); an instrument whose expected hold for the + batch's leg is 12 h or more is out of the draw (e.g. 6E at a $1,000 leg, + about a week). +- Micros stay out of the draw; drain and extra-day legs trade the micro of a + listed product (MNQ when NQ is listed). + +Every draw logs its odds. `GET /api/instruments` shows each firm's list with +entry cost, daily move, hold and chance (`?simulate=10000` runs the real draw +with no orders), and the master's Firms page shows the same next to each +instrument. + +Against a master from before the lists, an instance falls back to the old +rules: this machine's enabled instruments less each firm's banned symbols, +and a DXtrade firm's symbol map. + +--- + ## DXtrade firms (Tradeify247) A DXtrade firm is set up on the master's **Firms** page like any other, plus: @@ -113,11 +151,9 @@ per-contract fees and the CME calendar. in its own scheduler *lane*: its own trading hours (closed only around the daily roll), its own wait-until-flat gate and its own batches, so a multi-hour crypto position never holds up the futures open. -- **Symbol map** — which DXtrade symbols each CME root may trade as. A `*` row - covers every root (drain and extra-day legs trade `MNQ`, so map it or use - `*`). When a root lists several, the cheapest tradable one right now wins: - spread plus both commissions, divided by its volatility (hourly closes over - the past week, measured once a day). +- **Instruments** — the firm's own symbols (see above). The cheapest + tradable one right now wins: spread plus both commissions, per unit of its + volatility (hourly closes over the past week, measured once a day). - **Target hold hours** — each leg's notional is sized from that volatility so its expected hold is about this long (blank = 12 h). The position never exceeds what the account's available funds can margin at the broker's @@ -148,7 +184,7 @@ It is read-only unless `DX_CONFIRM=yes DX_ACCOUNT=` are set too, which adds one minimum-size live round trip. `npm run check` runs the pure-logic checks (fees, stops, sizing, trading -hours, the instrument selector). +hours, the DXtrade selector, the futures draw and instrument lists). --- diff --git a/app/api/firms/[id]/orders/route.ts b/app/api/firms/[id]/orders/route.ts index c38eae2..3d05785 100644 --- a/app/api/firms/[id]/orders/route.ts +++ b/app/api/firms/[id]/orders/route.ts @@ -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) { diff --git a/app/api/firms/[id]/route.ts b/app/api/firms/[id]/route.ts index d4a4648..61e9f56 100644 --- a/app/api/firms/[id]/route.ts +++ b/app/api/firms/[id]/route.ts @@ -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, }); diff --git a/app/api/firms/route.ts b/app/api/firms/route.ts index 674da69..8d87271 100644 --- a/app/api/firms/route.ts +++ b/app/api/firms/route.ts @@ -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, diff --git a/app/api/instruments/contracts/route.ts b/app/api/instruments/contracts/route.ts index 59ce180..9d8284e 100644 --- a/app/api/instruments/contracts/route.ts +++ b/app/api/instruments/contracts/route.ts @@ -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); diff --git a/app/api/instruments/route.ts b/app/api/instruments/route.ts index 68936b1..3f3caf7 100644 --- a/app/api/instruments/route.ts +++ b/app/api/instruments/route.ts @@ -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 }); } diff --git a/app/api/trade/route.ts b/app/api/trade/route.ts index f5e90cc..5112bf8 100644 --- a/app/api/trade/route.ts +++ b/app/api/trade/route.ts @@ -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 }); } diff --git a/app/firms/[id]/settings/page.tsx b/app/firms/[id]/settings/page.tsx index ce4f594..36d059a 100644 --- a/app/firms/[id]/settings/page.tsx +++ b/app/firms/[id]/settings/page.tsx @@ -137,20 +137,25 @@ export default function FirmSettingsPage() {

- Banned Symbols + Instruments

- {!firm || firm.bannedSymbols.length === 0 ? ( - None + {!firm || firm.instruments.length === 0 ? ( + None — this firm won't trade ) : (
- {firm.bannedSymbols.map((sym) => ( - + {firm.instruments.map((sym) => ( + {sym} ))}
)} +

+ {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.'} +

diff --git a/app/page.tsx b/app/page.tsx index 4b969d2..b3cd058 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -498,9 +498,9 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
{firm.firm} - {firm.bannedSymbols?.includes(tradeSymbol) && ( - - {tradeSymbol} BANNED + {firm.platform !== 'dxtrade' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && ( + + {tradeSymbol} not listed )} {firm.platform === 'topstepx' && ( @@ -629,7 +629,7 @@ export default function Home() { const [sortDir, setSortDir] = useState('asc'); // Trade controls - const [scheduler, setScheduler] = useState({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false }); + const [scheduler, setScheduler] = useState({ running: false, action: 'Buy', symbol: 'Auto', lastRun: null, intervalSeconds: 60, stopAfterAll: false }); const [enabledSymbols, setEnabledSymbols] = useState([]); 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) => )} + {enabledSymbols.map((s) => )}
{/* ── Instruments ── */} -

+

Instruments

+

+ What each firm trades is set on the master's{' '} + {masterUrl ? Firms page : 'Firms page'}. + Futures “Auto” 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. +

-
- {instruments.length === 0 ? ( -
- Loading… + {!overview ? ( +
Loading…
+ ) : overview.firms.map((firm) => ( +
+
+ {firm.name} + {firm.platform} + {firm.source === 'legacy' && ( + + from this machine (older master) + + )} + {firm.instruments.length === 0 && no instruments — won't trade}
- ) : ( - - - - - - - - - - {instruments.map((instr) => { - const c = contracts[instr.symbol]; - const wasRolled = !!c?.alternative; - return ( - - - - - - ); - })} - -
SymbolActive ContractEnabled
- {instr.symbol} - - {instr.enabled && c ? ( -
- - {c.name} - - {wasRolled && ( - - rolled from {c.alternative} - - )} -
- ) : null} -
- -
- )} -
+ {firm.stats ? ( + + + + + + + + + + + + + {firm.instruments.map((sym) => { + const x = firm.stats!.instruments.find((i) => i.symbol === sym); + const c = contracts[sym]; + return ( + + + + + + + + + ); + })} + +
SymbolContractEntryDaily moveHoldChance now
{sym} + {c?.name ?? '—'} + {c?.alternative && rolled from {c.alternative}} + + {x ? `$${x.spreadDollars.toFixed(2)}${x.feesPerSide !== null ? ` + $${x.feesPerSide.toFixed(2)}` : ''}` : '—'} + + {x?.dailyMoveDollars != null ? `$${x.dailyMoveDollars.toLocaleString()}` : '—'} + + {x?.holdHours != null ? (x.holdHours < 48 ? `${x.holdHours.toFixed(1)}h` : `${(x.holdHours / 24).toFixed(1)}d`) : '—'} + {x?.disabled && ' · disabled'} + + {x?.chance != null ? `${Math.round(x.chance * 100)}%` : '—'} +
+ ) : ( +
+ {firm.instruments.map((sym) => ( + {sym} + ))} +
+ )} +
+ ))}
); diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index f87319e..9cd2865 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -7,7 +7,6 @@ * signal but have since exited and are now eligible. */ -import { getInstruments } from './db'; import { getClients } from './clients'; import { getCachedFirm, getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; @@ -17,11 +16,13 @@ import { expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, DEFAULT_TARGET_HOLD_HOURS, isNoTradeTime, isPastSessionClose, - BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES, + BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, type DayPlan, } from './trading-logic'; import { getSetting, setSetting } from './db'; +import { POINT_VALUES, housekeepingRoot } from './instrument-specs'; +import { firmInstruments, drawFuturesInstrument, recordMeasuredFee, type LegShape } from './instruments'; import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client'; // ── process-wide state ─────────────────────────────────────────────────────── @@ -422,20 +423,64 @@ function releaseTradeMutex(lease: number, lane: string): void { if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane); } -/** The instrument a leg actually trades: drain legs go on MNQ (whatever the - * platform maps MNQ to), hardcoded like the extra-day path — tiny targets - * belong on the micro regardless of the batch symbol. */ +/** What a firm's tiny legs (drain, extra-day) trade: a futures firm the + * micro of what it lists (MNQ when NQ is listed, as always — see + * housekeepingRoot); a DXtrade firm its current pick. */ +function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record }): string { + if (firm.platform === 'dxtrade') return '*'; + return housekeepingRoot(firmInstruments(firm)) ?? 'MNQ'; +} + +/** The instrument a leg actually trades: drain legs go on the firm's + * housekeeping micro — tiny targets belong on a micro whatever the batch + * symbol. */ async function legInstrument( // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, + firm: TradableFirm, drain: boolean, batchContract: LegContract, batchPointValue: number, ): Promise<{ contract: LegContract; pointValue: number }> { if (!drain) return { contract: batchContract, pointValue: batchPointValue }; - const mnq: LegContract | null = await client.findFrontMonthContract('MNQ'); - if (!mnq) throw new Error('MNQ contract not found for drain trade'); - return { contract: mnq, pointValue: mnq.pointValue ?? POINT_VALUES['MNQ'] }; + const root = housekeepingFor(firm); + const micro: LegContract | null = await client.findFrontMonthContract(root); + if (!micro) throw new Error(`${root} contract not found for drain trade`); + const pointValue = micro.pointValue ?? POINT_VALUES[root]; + if (!pointValue) throw new Error(`no point value for ${root}`); + return { contract: micro, pointValue }; +} + +/** Which listed root a held contract is — each candidate looked up on the + * client holding it. null when none matches. */ +async function heldRoot( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + contractId: number | string, + roots: readonly string[], +): Promise { + for (const root of roots) { + try { + const contract = await client.findFrontMonthContract(root); + if (contract && String(contract.id) === String(contractId)) return root; + } catch (err) { + console.error(`[auto-trade] ${root}: contract lookup failed — ${errorMessage(err)}`); + } + } + return null; +} + +/** A leg's shape for the draw's hold estimate: its dollars, its stop and + * the account type's max contracts. */ +function legShapeFor(item: SizingItem, tradeDate: string): LegShape { + const cfg = getAccountConfig(item.acc.id).accountType!; + const leg = legAmountFor(item, tradeDate); + const obf = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, leg); + const dll = cfg.dailyLossLimit ?? 0; + const hard = Number.isFinite(item.dllRoom) && dll > 0 + ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 }) + : Infinity; + return { leg, stopDollars: Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard), maxPositionSize: cfg.maxPositionSize }; } /** What the fire path needs of a contract — PlatformContract minus the id. */ @@ -783,49 +828,43 @@ async function runTradeLocked( } const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm)); - // Resolve 'Auto' symbol once per batch so all accounts trade the same symbol - let resolvedSymbol = symbol; - if (symbol === 'Auto') { - const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol); - resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ'; - console.log(`[auto-trade] ${laneName(lane)}: random symbol resolved to: ${resolvedSymbol}`); - } - // Resolve Auto action once per batch so all accounts trade the same direction + // One direction per batch: every account trades the same side. const resolvedAction: 'Buy' | 'Sell' = action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action; - // The CME product's point value. A platform that maps the root to its own - // instrument (DXTrade) supplies the contract's point value instead. - const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol]; + const futuresLane = lane === FUTURES_LANE; + // An explicit symbol (the scheduler's, or a manual Trade) is the + // operator's pick; 'Auto' is drawn per batch from the batch firm's list. + const explicit = futuresLane && symbol !== 'Auto' ? symbol : null; const tradeDate = currentTradeDateCT(); const maxConcurrent = maxConcurrentAccounts(); - // ── Phase 1: collect ALL eligible accounts across the lane's firms in parallel ── + // ── Phase 1: the eligible accounts across the lane's firms ── + // No instrument yet: it's chosen for the batch's firm, from that firm's + // list, once the batch is known. + type Candidate = SizingItem & { daysTraded: number; isExtraDay: boolean }; type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean }; + const allEligible: Candidate[] = []; - const allEligible: EligibleItem[] = []; - - await Promise.all(open.map(async ({ firm, client }) => { - if (client.accountList.length === 0) return; - - // Skip this firm entirely if the symbol is banned for it - if (firm.bannedSymbols.includes(resolvedSymbol)) { - console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`); - return; + for (const { firm, client } of open) { + if (client.accountList.length === 0) continue; + const listed = firmInstruments(firm); + if (listed.length === 0) { + console.log(`[auto-trade] ${firm.name}: no instruments listed — skipping firm`); + continue; } - - // One firm's trouble (a contract lookup that throws, a malformed - // snapshot) skips that firm, never the batch — a rejection here would - // fail the Promise.all and every other firm would sit the tick out. + if (explicit && !listed.includes(explicit)) { + console.log(`[auto-trade] ${firm.name}: ${explicit} isn't on its instrument list — skipping firm`); + continue; + } + // One firm's trouble (a malformed snapshot) skips that firm, never + // the batch. try { - // The accounts first: a platform that chooses between - // instruments ranks them by how the batch's leg would size. - const evaluated: (SizingItem & { daysTraded: number; isExtraDay: boolean })[] = []; for (const acc of client.accountList) { const ev = evaluateAccountForTrade(client, acc, tradeDate); if (!ev || !ev.eligible || !ev.plan) continue; - evaluated.push({ + allEligible.push({ firmName: firm.name, firm, client, acc, cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded, plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain, @@ -833,53 +872,112 @@ async function runTradeLocked( dllRoom: ev.dllRoom, }); } - if (evaluated.length === 0) return; - - const contract = await client.findFrontMonthContract( - resolvedSymbol, pickContextFor(evaluated.filter((it) => !it.isExtraDay && !it.drain), tradeDate)); - if (!contract) return; - const pv = contract.pointValue ?? basePointValue; - if (!pv) { - console.log(`[auto-trade] ${firm.name}: no point value for ${resolvedSymbol} — skipping firm`); - return; - } - const found: EligibleItem[] = evaluated.map((it) => ({ ...it, contract, pv })); - allEligible.push(...found); } catch (err) { console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`); } - })); + } if (allEligible.length === 0) { console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`); return { outcome: 'no-eligible', results: [] }; } - // Collecting took so long that another batch presumed this one wedged and - // took the mutex. That batch traded on fresher numbers than these, so - // firing now could trade an account twice. - if (!holdsTradeMutex(lease, lane)) { - console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch'); - return { outcome: 'skipped-in-flight', results: [] }; - } - // Take only the first batch — all fired simultaneously, no rolling pool. // Remaining accounts wait for the next tick (which only fires once all positions are flat). // Like accounts trade together: a batch is drawn from a single prefix group // (largest group first), never mixed across prefixes — a group's remainder // fires as a smaller batch on a later tick instead of being padded from // another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as - // batches of 5, 5, 4, 2. - const groups = new Map(); + // batches of 5, 5, 4, 2. A group that can't trade anything right now + // (nothing listed fits, no contract resolves) yields to the next. + const groups = new Map(); for (const item of allEligible) { const group = groups.get(item.prefix); if (group) group.push(item); else groups.set(item.prefix, [item]); } - const [batchPrefix, batchGroup] = [...groups.entries()] - .sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))[0]; - const batch = batchGroup.slice(0, maxConcurrent); - console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'}`); + const ordered = [...groups.entries()].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])); + + let batch: EligibleItem[] = []; + let batchPrefix = ''; + let resolvedSymbol = ''; + for (const [prefix, members] of ordered) { + const candidates = members.slice(0, maxConcurrent); + const lead = candidates.find((it) => !it.isExtraDay && !it.drain); + const firms = [...new Map(candidates.map((it) => [it.firm.id, it])).values()]; + + if (futuresLane) { + // The group's firms must all list the root: their lists' overlap, + // or — if that's empty — the lead firm's accounts alone. + let pool = candidates; + let allowed = firms.map((it) => firmInstruments(it.firm)).reduce((a, b) => a.filter((r) => b.includes(r))); + if (allowed.length === 0) { + const leadFirm = (lead ?? candidates[0]).firm.id; + pool = candidates.filter((it) => it.firm.id === leadFirm); + allowed = firmInstruments(pool[0].firm); + } + const shape = lead ? legShapeFor(lead, tradeDate) : null; + const tried: string[] = []; + for (let attempt = 0; attempt < 3 && batch.length === 0; attempt++) { + const root = explicit ?? await drawFuturesInstrument(allowed.filter((r) => !tried.includes(r)), shape); + if (!root) break; + tried.push(root); + const resolved: EligibleItem[] = []; + for (const { firm, client } of [...new Map(pool.map((it) => [it.firm.id, it])).values()]) { + try { + const contract = await client.findFrontMonthContract(root); + const pv = contract?.pointValue ?? POINT_VALUES[root]; + if (!contract || !pv) { + console.log(`[auto-trade] ${firm.name}: no ${root} contract — leaving its accounts out`); + continue; + } + resolved.push(...pool.filter((it) => it.firm.id === firm.id).map((it) => ({ ...it, contract, pv }))); + } catch (err) { + console.error(`[auto-trade] ${firm.name}: ${root} contract lookup failed — ${errorMessage(err)}`); + } + } + if (resolved.length > 0) { + batch = resolved; + resolvedSymbol = root; + } + if (explicit) break; + } + } else { + // A DXtrade lane: one firm, its list ranked for the group's leg. + const { client, firm } = candidates[0]; + try { + const contract = await client.findFrontMonthContract('*', pickContextFor(candidates.filter((it) => !it.isExtraDay && !it.drain), tradeDate)); + const pv = contract?.pointValue; + if (contract && pv) { + batch = candidates.map((it) => ({ ...it, contract, pv })); + resolvedSymbol = String(contract.id); + } else { + console.log(`[auto-trade] ${firm.name}: nothing on its list can trade right now`); + } + } catch (err) { + console.error(`[auto-trade] ${firm.name}: instrument pick failed — ${errorMessage(err)}`); + } + } + if (batch.length > 0) { + batchPrefix = prefix; + break; + } + } + + if (batch.length === 0) { + console.log(`[auto-trade] ${laneName(lane)}: nothing tradable for any eligible group`); + return { outcome: 'no-eligible', results: [] }; + } + + // Choosing took so long that another batch presumed this one wedged and + // took the mutex. That batch traded on fresher numbers than these, so + // firing now could trade an account twice. + if (!holdsTradeMutex(lease, lane)) { + console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch'); + return { outcome: 'skipped-in-flight', results: [] }; + } + + console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}`); runtime().lastFireAt = Date.now(); // ── Phase 2: fire the batch simultaneously ── @@ -889,10 +987,12 @@ async function runTradeLocked( const { client, acc } = item; // Extra-day mode: profit target already met, no min day P&L, days still needed. - // Just trade 1 MNQ in and out at market immediately — P&L doesn't matter. + // Just trade 1 micro (MNQ for a firm listing NQ) in and out at market + // immediately — P&L doesn't matter. if (item.isExtraDay) { - const mnqContract = await client.findFrontMonthContract('MNQ'); - if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade'); + const microRoot = housekeepingFor(item.firm); + const mnqContract = await client.findFrontMonthContract(microRoot); + if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`); const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market'); const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; @@ -1082,7 +1182,7 @@ async function fireLeg( let contracts: number; let plan: ReturnType; try { - const inst = await legInstrument(client, item.drain, item.contract, item.pv); + const inst = await legInstrument(client, item.firm, item.drain, item.contract, item.pv); legContract = inst.contract; pv = inst.pointValue; plan = sizeLeg(item, legContract, legAmount, tradeDate); @@ -1130,6 +1230,7 @@ async function fireLeg( await new Promise(r => setTimeout(r, 1000)); const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; measured = entryCommissionMeasured(item.cash.realizedPnL, updatedCash.realizedPnL, contracts); + if (measured !== null && !item.drain) recordMeasuredFee(item.firm.id, symbolLabel, measured / contracts); entryFee = measured ?? FALLBACK_COMMISSION_PER_CONTRACT * contracts; } // The stop. On a measured-fee firm whose fee didn't show, the daily-loss @@ -1297,6 +1398,7 @@ async function copyTradeLocked(lease: number, lane: string): Promise } } } + const futuresLane = lane === FUTURES_LANE; if (!resolvedAction || positionedCount === 0) { console.log('[copy-trade] no open positions to copy from'); @@ -1304,33 +1406,23 @@ async function copyTradeLocked(lease: number, lane: string): Promise } console.log(`[copy-trade] found ${positionedCount} open position(s), action=${resolvedAction}, contractId=${positionContractId}`); - // Resolve symbol from the positioned contract's contractId — only check the client that - // actually has the position. Iterating every firm × every symbol can take 30+ seconds. + // Which instrument is held — looked up only on the client that holds it + // (iterating every firm × every symbol can take 30+ seconds), among the + // roots the lane's firms list plus their drain micros. A DXtrade client + // answers '*' with the symbol it holds. Unidentified means no copy: a + // guess could put the direction on a different instrument. let resolvedSymbol: string | null = null; - const instruments = getInstruments(); - const enabledSymbols = instruments.filter(i => i.enabled).map(i => i.symbol); - - if (positionedClient) { - for (const sym of enabledSymbols) { - // A lookup that throws rules out that one symbol, not the copy. - try { - const contract = await positionedClient.findFrontMonthContract(sym); - if (contract && contract.id === positionContractId) { - resolvedSymbol = sym; - break; - } - } catch (err) { - console.error(`[copy-trade] ${sym}: contract lookup failed — ${errorMessage(err)}`); - } - } + if (positionedClient && positionContractId !== null) { + const roots = futuresLane + ? [...new Set(ready.flatMap(({ firm }) => [...firmInstruments(firm), housekeepingFor(firm)]))] + : ['*']; + resolvedSymbol = await heldRoot(positionedClient, positionContractId, roots); } - console.log(`[copy-trade] resolved symbol: ${resolvedSymbol ?? '(none, using fallback)'}`); - - // Fall back to scheduler symbol or first enabled if (!resolvedSymbol) { - const state = getState(); - resolvedSymbol = state.symbol !== 'Auto' ? state.symbol : (enabledSymbols[0] ?? 'NQ'); + console.log(`[copy-trade] can't tell which instrument contract ${positionContractId} is — not copying`); + return []; } + console.log(`[copy-trade] resolved symbol: ${resolvedSymbol}`); const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol]; const slotsAvailable = maxConcurrent - positionedCount; @@ -1349,8 +1441,8 @@ async function copyTradeLocked(lease: number, lane: string): Promise console.log(`[copy-trade] ${firm.name}: empty account list — skip firm`); return; } - if (firm.bannedSymbols.includes(resolvedSymbol!)) { - console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`); + if (futuresLane && !firmInstruments(firm).includes(resolvedSymbol!)) { + console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} isn't on its instrument list — skip firm`); return; } @@ -1478,14 +1570,26 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | if (heldSide && action !== 'Auto' && action !== heldSide) { throw new Error(`the ${laneName(lane)} lane is ${heldSide === 'Buy' ? 'long' : 'short'} — a ${action} would hedge it`); } - const state = getState(); - const symbol = state.symbol !== 'Auto' ? state.symbol : 'NQ'; const resolvedAction: 'Buy' | 'Sell' = heldSide ?? (action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action); const sizingItem: SizingItem = { firmName: firm.name, firm, client, acc, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain, prefix: cfg.prefix, dllRoom: room, }; + // The instrument: a DXtrade firm ranks its list; a futures firm joins the + // held root, else trades an explicit scheduler symbol it lists, else + // draws from its list. + let symbol = '*'; + if (firm.platform !== 'dxtrade') { + const listed = firmInstruments(firm); + const state = getState(); + const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null; + const drawn = found ?? (state.symbol !== 'Auto' && listed.includes(state.symbol) + ? state.symbol + : await drawFuturesInstrument(listed, legShapeFor(sizingItem, tradeDate))); + if (!drawn) throw new Error(held.length > 0 ? `can't tell which listed instrument the lane holds` : 'nothing on the firm\'s list fits right now'); + symbol = drawn; + } const contract = await client.findFrontMonthContract(symbol, ev.drain ? undefined : pickContextFor([sizingItem], tradeDate)); if (!contract) throw new Error(`no instrument for ${symbol}`); if (held.length > 0 && String(contract.id) !== [...heldContracts][0]) { @@ -1574,7 +1678,7 @@ const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerStat function getState(): SchedulerState { if (!_global.__autoTrader) { - _global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false, stopAfterAll: false }; + _global.__autoTrader = { action: 'Buy', symbol: 'Auto', intervalId: null, lastRun: null, running: false, stopAfterAll: false }; } return _global.__autoTrader; } @@ -1723,7 +1827,7 @@ export function resumeSchedulerIfPersisted(): void { if (getSetting('scheduler_running') !== '1') return; const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto'; - const symbol = getSetting('scheduler_symbol') ?? 'NQ'; + const symbol = getSetting('scheduler_symbol') ?? 'Auto'; const stopAfterAll = getSetting('scheduler_stop_after_all') === '1'; console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`); diff --git a/lib/clients.ts b/lib/clients.ts index 7f79821..5040f79 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -3,8 +3,9 @@ import { TopstepXClient } from './topstepx-class'; import { DXTradeClient } from './dxtrade-class'; import type { FirmPlatform } from './master-protocol'; import { errorMessage, isClientReady, type PlatformClient } from './platform-client'; -import { getInstruments } from './db'; import { getTradableFirms, startCatalogSync } from './firm-catalog'; +import { firmInstruments, isFuturesFirm, prewarmMarket } from './instruments'; +import { housekeepingRoot } from './instrument-specs'; import { resolveFirmAssignments } from './account-type-resolver'; import { resolveContracts } from './contract-resolver'; import { startReporter, sendReport } from './reporter'; @@ -27,6 +28,7 @@ const g = global as typeof globalThis & { __contractResolverTimer?: ReturnType; __contractResolveRetryTimer?: ReturnType; __contractResolveRetries?: number; + __contractResolvedRoots?: Set; __clientSupervisorTimer?: ReturnType; __clientSupervision?: Map; }; @@ -191,6 +193,8 @@ export function getClients(): Map { // sitting Unassigned without waiting for a Tradovate sync. startCatalogSync(() => { reconcileClients(); + // A newly listed root needs its front-month contract. + if (resolveRootsHere().some((r) => !g.__contractResolvedRoots?.has(r))) triggerContractResolve(); for (const [firmId, client] of ensureMap()) { try { resolveFirmAssignments(firmId, client); @@ -203,8 +207,10 @@ export function getClients(): Map { reconcileClients(); console.log(`[clients] Initialized ${map.size} platform client(s)`); - // Auto-resolve contracts after clients have time to authenticate + // Auto-resolve contracts after clients have time to authenticate, + // and measure every future's movement for the Auto draw. setTimeout(() => triggerContractResolve(), 15_000); + prewarmMarket(); // Schedule daily resolve at midnight scheduleDailyResolve(); @@ -242,6 +248,18 @@ export function getClients(): Map { const CONTRACT_RESOLVE_RETRY_MS = 60_000; const CONTRACT_RESOLVE_MAX_RETRIES = 30; +/** Every root a futures firm here lists, plus each one's drain micro. */ +function resolveRootsHere(): string[] { + const roots = new Set(); + for (const firm of getTradableFirms().filter(isFuturesFirm)) { + const listed = firmInstruments(firm); + for (const r of listed) roots.add(r); + const micro = housekeepingRoot(listed); + if (micro) roots.add(micro); + } + return [...roots]; +} + function triggerContractResolve(): void { // A midnight run supersedes a pending retry; either way only one chain. if (g.__contractResolveRetryTimer) clearTimeout(g.__contractResolveRetryTimer); @@ -284,7 +302,8 @@ function triggerContractResolve(): void { // Runs from timers: a throw here (the instruments read) would escape as // an uncaught exception. try { - const symbols = getInstruments().filter((i) => i.enabled).map((i) => i.symbol); + const symbols = resolveRootsHere(); + g.__contractResolvedRoots = new Set(symbols); console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`); resolveContracts(symbols, accessToken) .then((results) => { @@ -309,6 +328,7 @@ function scheduleDailyResolve(): void { // Its own half hour of retries, not what's left of a chain it cuts off. g.__contractResolveRetries = 0; triggerContractResolve(); + prewarmMarket(); } }, 60_000); } diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index aac6264..621c924 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -14,9 +14,9 @@ * Differences from the futures platforms that shape this file: * - Account codes are strings ("clearing:account"); ids are a stable hash of * them (dxAccountId) so every numeric account table keeps working. - * - There are no CME products. The firm's symbol map (master Firms page) says - * which DXtrade symbols a CME root may trade as, and findFrontMonthContract - * picks the cheapest per unit of volatility right now (scoreCandidate). + * - There are no CME products. The firm's instrument list (master Firms page) + * says which DXtrade symbols it trades, whatever root a batch drew, and + * findFrontMonthContract picks among them (rankForTarget / scoreCandidate). * One engine "contract" is one quantity step of the symbol, and the step * rides in the contract name, so the engine's target/stop math is unchanged. * - Quantities are units, and positions are closed by positionCode @@ -38,6 +38,7 @@ import { } from './db'; import { getAccountConfig } from './account-type-resolver'; import { getCachedFirm } from './firm-catalog'; +import { firmInstruments } from './instruments'; import type { SymbolMapCandidate } from './master-protocol'; import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client'; import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client'; @@ -1162,24 +1163,26 @@ export class DXTradeClient implements PlatformClient { } /** - * The contract a CME root trades as on this firm right now, from the - * symbol map. With a pick context (the batch's leg), each candidate is + * The contract this firm trades right now, from its instrument list — + * the root is ignored. With a pick context (the batch's leg), each candidate is * sized as the leg would be — at its own leverage — and the cheapest one * that reaches the target hold wins (else the quickest); without one, the * cheapest per unit of volatility (see scoreCandidate). Sticky while an * account holds one of the candidates; a batch's pick is reused briefly * so its other lookups (drain legs) agree. */ - public async findFrontMonthContract(root: string, ctx?: PickContext): Promise { + public async findFrontMonthContract(_root: string, ctx?: PickContext): Promise { if (!this.token) return null; + // The firm's own list: whatever root the batch drew, a DXtrade firm + // chooses among its instruments. One cache key for every root. + const root = '*'; const firm = getCachedFirm(this.cfg.firmId); - const map = firm?.symbolMap ?? {}; - const candidates = map[root.toUpperCase()] ?? map['*'] ?? []; + const candidates: SymbolMapCandidate[] = firm ? firmInstruments(firm).map((symbol) => ({ symbol })) : []; const now = Date.now(); if (candidates.length === 0) { if (now - (this.unmappedLoggedAt.get(root) ?? 0) > 10 * 60_000) { this.unmappedLoggedAt.set(root, now); - console.log(`${this.tag} no symbol mapped for ${root} (nor '*') — skipping; set the symbol map on the master Firms page`); + console.log(`${this.tag} no instruments listed — skipping; add them on the master Firms page`); } return null; } @@ -1194,14 +1197,12 @@ export class DXTradeClient implements PlatformClient { return pick; } - /** Every mapped symbol, looked at afresh whatever the accounts hold: the + /** Every listed symbol, looked at afresh whatever the accounts hold: the * day's volatility, and the candidates the dashboard ranks. */ private async surveyCandidates(): Promise { - const all = new Map(); - for (const list of Object.values(getCachedFirm(this.cfg.firmId)?.symbolMap ?? {})) { - for (const c of list) if (!all.has(c.symbol)) all.set(c.symbol, c); - } - if (all.size > 0) await this.pickCandidate('survey', [...all.values()], false); + const firm = getCachedFirm(this.cfg.firmId); + const list = firm ? firmInstruments(firm) : []; + if (list.length > 0) await this.pickCandidate('survey', list.map((symbol) => ({ symbol })), false); } private async pickCandidate(root: string, candidates: SymbolMapCandidate[], sticky: boolean, ctx?: PickContext): Promise { diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index 7f68e77..ce79c6e 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -18,6 +18,7 @@ */ import { isFirmPlatform } from './master-protocol'; +import { sanitizeInstruments } from './instrument-specs'; import type { CatalogAccountType, CatalogFirm, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate, } from './master-protocol'; @@ -241,6 +242,9 @@ function validatePayload(raw: unknown): FirmCatalogPayload { id: firm.id, name: firm.name, platform, + // The master's list when it sends one; absent from an older + // master, whose bannedSymbols / symbolMap then apply. + ...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}), bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [], accountTypes, serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '', @@ -302,8 +306,12 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve // Only the URL is required — deliberately not instance_name, unlike the // reporter. A freshly imaged machine has to be able to pull its catalog // before anyone has got round to naming it. + // `since` only against the master the cache came from: two masters' + // version numbers can collide, and an 'unchanged' from the wrong one + // would keep the other's catalog. const hasCache = getCachedCatalog().length > 0; - if (hasCache) url.searchParams.set('since', String(version)); + const source = url.origin; + if (hasCache && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version)); try { const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); @@ -311,6 +319,7 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve const body = await res.json(); if (body && typeof body === 'object' && (body as Record).unchanged === true) { + setSetting('catalog_source_url', source); setSetting('catalog_last_sync_at', new Date().toISOString()); setSetting('catalog_last_error', ''); return { ok: true, changed: false, version }; @@ -342,6 +351,7 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve setSetting('catalog_payload', JSON.stringify(payload)); setSetting('catalog_version', String(payload.version)); + setSetting('catalog_source_url', source); setSetting('catalog_updated_at', payload.updatedAt); setSetting('catalog_last_sync_at', new Date().toISOString()); setSetting('catalog_last_error', suspicious.length > 0 diff --git a/lib/instrument-specs.ts b/lib/instrument-specs.ts new file mode 100644 index 0000000..710ef84 --- /dev/null +++ b/lib/instrument-specs.ts @@ -0,0 +1,267 @@ +/** + * Contract specs for the CME futures the bot trades, and the pure maths of + * choosing between them — the session-aware Auto draw, the 12-hour hold cap, + * the legs that always trade a micro — plus reading a firm's instrument list + * off the catalog. + * + * Specs live here, not on the master: point value and tick size size real + * orders, so they aren't editable remotely. The master only knows the names. + * + * No imports: scripts/check-instruments.mjs loads this file directly. + */ + +export interface CmeSpec { + name: string; + /** Dollars per 1.0 of price, per contract. */ + pointValue: number; + tickSize: number; + /** Typical bid/ask spread in ticks. There are no live futures quotes, and + * a liquid front month is one tick wide. */ + spreadTicks: number; + /** Yahoo's continuous-contract ticker, for hourly candles. */ + yahoo: string; + micro?: boolean; +} + +const spec = (name: string, pointValue: number, tickSize: number, yahoo: string, micro = false): CmeSpec => + ({ name, pointValue, tickSize, spreadTicks: 1, yahoo, ...(micro ? { micro } : {}) }); + +export const CME_SPECS: Readonly> = { + NQ: spec('Nasdaq-100', 20, 0.25, 'NQ=F'), + MNQ: spec('Micro Nasdaq-100', 2, 0.25, 'MNQ=F', true), + ES: spec('S&P 500', 50, 0.25, 'ES=F'), + MES: spec('Micro S&P 500', 5, 0.25, 'MES=F', true), + YM: spec('Dow', 5, 1, 'YM=F'), + MYM: spec('Micro Dow', 0.5, 1, 'MYM=F', true), + RTY: spec('Russell 2000', 50, 0.1, 'RTY=F'), + M2K: spec('Micro Russell 2000', 5, 0.1, 'M2K=F', true), + GC: spec('Gold', 100, 0.1, 'GC=F'), + MGC: spec('Micro Gold', 10, 0.1, 'MGC=F', true), + SI: spec('Silver', 5000, 0.005, 'SI=F'), + CL: spec('Crude Oil', 1000, 0.01, 'CL=F'), + MCL: spec('Micro Crude Oil', 100, 0.01, 'MCL=F', true), + NG: spec('Natural Gas', 10000, 0.001, 'NG=F'), + ZB: spec('30-Year T-Bond', 1000, 1 / 32, 'ZB=F'), + ZN: spec('10-Year T-Note', 1000, 1 / 64, 'ZN=F'), + ZF: spec('5-Year T-Note', 1000, 1 / 128, 'ZF=F'), + '6E': spec('Euro FX', 125000, 0.00005, '6E=F'), + '6J': spec('Japanese Yen', 12500000, 0.0000005, '6J=F'), + '6B': spec('British Pound', 62500, 0.0001, '6B=F'), +}; + +export const CME_ROOTS: readonly string[] = Object.keys(CME_SPECS); + +/** $ per point, per contract — the table the engine has always used. */ +export const POINT_VALUES: Readonly> = Object.fromEntries( + CME_ROOTS.map((root) => [root, CME_SPECS[root].pointValue]), +); + +/** Each full-size contract's micro. */ +export const MICRO_OF: Readonly> = { NQ: 'MNQ', ES: 'MES', YM: 'MYM', RTY: 'M2K', GC: 'MGC', CL: 'MCL' }; + +export function tickValue(root: string): number { + const s = CME_SPECS[root]; + return s ? s.tickSize * s.pointValue : NaN; +} + +/** One contract crossing the typical spread, in dollars. */ +export function spreadDollars(root: string): number { + const s = CME_SPECS[root]; + return s ? s.spreadTicks * s.tickSize * s.pointValue : NaN; +} + +/** Hold cap: an instrument whose next leg is expected to take this long or + * longer is out of the draw. */ +export const MAX_HOLD_HOURS = 12; +/** Every drawable instrument's minimum share at the session's open; it fades + * to nothing by the last entry. */ +export const DRAW_FLOOR = 0.10; +/** How hard the end of the session leans on the fastest instruments. */ +export const LATE_SHARPNESS = 8; +/** Hourly bars in a CME trading day (23 of 24 hours trade). */ +export const BARS_PER_DAY = 23; +const MIN_CLOSES = 25; + +/** + * One contract's typical daily move (1σ), in dollars, from hourly closes + * oldest first: σ of the hourly log returns × √(bars per day) × the last + * price × the point value. null on too few closes. + */ +export function dailyMoveDollars(closes: number[], pointValue: number, barsPerDay = BARS_PER_DAY): number | null { + const clean = closes.filter((c) => Number.isFinite(c) && c > 0); + if (clean.length < MIN_CLOSES || !(pointValue > 0)) return null; + const r: number[] = []; + for (let i = 1; i < clean.length; i++) r.push(Math.log(clean[i] / clean[i - 1])); + const mean = r.reduce((s, x) => s + x, 0) / r.length; + const sd = Math.sqrt(r.reduce((s, x) => s + (x - mean) ** 2, 0) / (r.length - 1)); + return sd * Math.sqrt(barsPerDay) * clean[clean.length - 1] * pointValue; +} + +/** Contracts a futures leg trades: one per $1,000, capped by the account + * type's max contracts (0 = no cap) — the rule fireLeg sizes by. */ +export function futuresContracts(leg: number, maxPositionSize: number): number { + const raw = Math.max(1, Math.ceil(leg / 1000)); + return maxPositionSize > 0 ? Math.min(raw, maxPositionSize) : raw; +} + +/** + * Expected hours for a leg to reach its take-profit or stop, as a fair walk: + * (A / move)(B / move) days, with the move scaled by the contracts. A + * stopless leg is taken as B = A. null without a move. + */ +export function expectedHoldHours(o: { leg: number; stopDollars: number; contracts: number; dailyMove: number | null }): number | null { + if (o.dailyMove === null || !(o.dailyMove > 0) || !(o.leg > 0)) return null; + const move = o.dailyMove * Math.max(1, o.contracts); + const b = Number.isFinite(o.stopDollars) && o.stopDollars > 0 ? o.stopDollars : o.leg; + return (o.leg / move) * (b / move) * 24; +} + +function centralMinutes(now: Date): { day: string; minutes: number } { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Chicago', weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', + }).formatToParts(now); + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''; + return { day: get('weekday'), minutes: (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10) }; +} + +/** + * How far through the trading session `now` is: 0 at the open, 1 at the last + * entry. full_cme runs 17:05 → 14:55 CT the next day; equity_hours 8:35 → + * 14:55. null outside the session. + */ +export function sessionProgress(now: Date, mode: string): number | null { + const { day, minutes } = centralMinutes(now); + if (mode === 'equity_hours') { + if (day === 'Sat' || day === 'Sun' || minutes < 515 || minutes > 895) return null; + return (minutes - 515) / (895 - 515); + } + const open = 17 * 60 + 5; + const length = 24 * 60 - open + 14 * 60 + 55; // 21h50m + const since = (minutes - open + 1440) % 1440; + if (since > length) return null; + if (day === 'Sat' || (day === 'Fri' && minutes > 895) || (day === 'Sun' && minutes < open)) return null; + return since / length; +} + +export interface DrawCandidate { + symbol: string; + spreadDollars: number; + /** One contract's daily move in dollars; null = not measured. */ + dailyMove: number | null; + /** The next leg's expected hold on it; null = unknown. */ + holdHours: number | null; + micro?: boolean; +} + +/** + * Each instrument's share of the Auto draw at session progress `p`. + * + * Efficiency = daily move ÷ spread: movement per dollar paid to cross the + * spread (commissions don't count). Speed ∝ move², since a fair walk's hold + * time goes with 1/move². With q = p², the weight slides from efficiency + * early to a sharp preference for the fastest late: + * weight = efficiency^(1−q) × move^(sharpness·q) + * and every drawable instrument gets a floor of DRAW_FLOOR × (1−q). + * + * Out of the draw: anything whose expected hold is MAX_HOLD_HOURS or more + * (`disabled`), and micros unless nothing else is left. An unmeasured + * instrument gets the median weight; if none are measured, the draw is even. + */ +export function drawChances( + candidates: DrawCandidate[], + p: number | null, + opts: { floor?: number; sharpness?: number; maxHoldHours?: number } = {}, +): { chances: { symbol: string; chance: number }[]; disabled: string[] } { + const floorBase = opts.floor ?? DRAW_FLOOR; + const sharpness = opts.sharpness ?? LATE_SHARPNESS; + const maxHold = opts.maxHoldHours ?? MAX_HOLD_HOURS; + const progress = p === null ? 0 : Math.min(1, Math.max(0, p)); + const q = progress * progress; + + const disabled = candidates.filter((c) => c.holdHours !== null && c.holdHours >= maxHold).map((c) => c.symbol); + let pool = candidates.filter((c) => !disabled.includes(c.symbol)); + if (pool.some((c) => !c.micro)) pool = pool.filter((c) => !c.micro); + if (pool.length === 0) return { chances: [], disabled }; + + const logWeight = (c: DrawCandidate): number | null => (c.dailyMove !== null && c.dailyMove > 0 && c.spreadDollars > 0 + ? (1 - q) * Math.log(c.dailyMove / c.spreadDollars) + sharpness * q * Math.log(c.dailyMove) + : null); + const measured = pool.map(logWeight).filter((w): w is number => w !== null).sort((a, b) => a - b); + const median = measured.length === 0 ? 0 : measured.length % 2 + ? measured[(measured.length - 1) / 2] + : (measured[measured.length / 2 - 1] + measured[measured.length / 2]) / 2; + const logs = pool.map((c) => logWeight(c) ?? median); + const top = Math.max(...logs); + const weights = logs.map((l) => Math.exp(l - top)); + const total = weights.reduce((s, w) => s + w, 0); + const n = pool.length; + const floor = Math.min(floorBase * (1 - q), 1 / n); + return { + chances: pool.map((c, i) => ({ symbol: c.symbol, chance: floor + (1 - n * floor) * (weights[i] / total) })), + disabled, + }; +} + +/** The instrument a uniform random number in [0, 1) lands on. */ +export function pickByChance(chances: { symbol: string; chance: number }[], rand: number): string | null { + let acc = 0; + for (const c of chances) { + acc += c.chance; + if (rand < acc) return c.symbol; + } + return chances.length > 0 ? chances[chances.length - 1].symbol : null; +} + +/** + * What a firm's drain and extra-day legs trade — tiny, so a micro: MNQ when + * NQ or MNQ is listed (as always), else the micro of a listed full-size + * contract, else a listed micro, else the listed root with the smallest tick + * value. null for an empty list. + */ +export function housekeepingRoot(listed: readonly string[]): string | null { + const roots = listed.filter((r) => CME_SPECS[r]); + if (roots.length === 0) return null; + if (roots.includes('NQ') || roots.includes('MNQ')) return 'MNQ'; + const twin = roots.find((r) => MICRO_OF[r]); + if (twin) return MICRO_OF[twin]; + const micro = roots.find((r) => CME_SPECS[r].micro); + if (micro) return micro; + return [...roots].sort((a, b) => tickValue(a) - tickValue(b))[0]; +} + +const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/; + +/** A catalog list, cleaned for the firm's family: CME roots this build has + * specs for, or valid DXtrade symbols. Deduplicated, in order. */ +export function sanitizeInstruments(platform: string, raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const futures = platform !== 'dxtrade'; + const out: string[] = []; + for (const value of raw) { + if (typeof value !== 'string') continue; + const symbol = futures ? value.trim().toUpperCase() : value.trim(); + const ok = futures ? Boolean(CME_SPECS[symbol]) : DXTRADE_SYMBOL.test(symbol); + if (ok && !out.includes(symbol)) out.push(symbol); + } + return out; +} + +/** + * The instruments a firm trades. The master's list when the catalog carries + * one; otherwise (a master from before the lists) the old rules: a futures + * firm trades this machine's enabled instruments less its banned symbols; a + * DXtrade firm every symbol in its map, the '*' row first. + */ +export function resolveAllowlist( + firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record }, + localEnabled: readonly string[], +): { roots: string[]; source: 'master' | 'legacy' } { + if (Array.isArray(firm.instruments)) return { roots: sanitizeInstruments(firm.platform, firm.instruments), source: 'master' }; + if (firm.platform === 'dxtrade') { + const map = firm.symbolMap ?? {}; + const keys = Object.keys(map).sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b))); + return { roots: sanitizeInstruments('dxtrade', keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' }; + } + const banned = new Set(firm.bannedSymbols ?? []); + return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' }; +} diff --git a/lib/instruments.ts b/lib/instruments.ts new file mode 100644 index 0000000..9df6ca6 --- /dev/null +++ b/lib/instruments.ts @@ -0,0 +1,280 @@ +/** + * The instruments each firm trades, and the futures Auto draw — the one + * module every trading path asks. + * + * A firm's list comes from the master's catalog (`instruments`); against a + * master from before the lists, from the old rules (this machine's enabled + * instruments less the firm's bans; a DXtrade firm's symbol map). Futures + * "Auto" draws one listed root per batch, weighted by lib/instrument-specs' + * drawChances: cheap-per-move with a floor early in the session, the fastest + * near the last entry, nothing expected to hold 12 hours or more. + * + * Movement comes from Yahoo's hourly candles (7 days, measured once per trade + * date); the spread is one tick. Fees are shown, never used to choose: + * Tradovate's fee parameters, or TopstepX's last measured fill. + */ + +import axios from 'axios'; +import { getInstruments, getSetting } from './db'; +import { getTradableFirms } from './firm-catalog'; +import { getCachedContract } from './contract-resolver'; +import { currentTradeDateCT } from './trading-logic'; +import { errorMessage } from './platform-client'; +import type { InstrumentStats } from './master-protocol'; +import { + CME_SPECS, CME_ROOTS, spreadDollars, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress, + drawChances, pickByChance, resolveAllowlist, MAX_HOLD_HOURS, type DrawCandidate, +} from './instrument-specs'; + +export interface FirmLike { + id?: string; + platform: string; + instruments?: string[]; + bannedSymbols?: string[]; + symbolMap?: Record; +} + +function localEnabled(): string[] { + return getInstruments().filter((i) => i.enabled).map((i) => i.symbol); +} + +/** What the firm trades (see resolveAllowlist). */ +export function firmInstruments(firm: FirmLike): string[] { + return resolveAllowlist(firm, localEnabled()).roots; +} + +/** Whether the list came from the master or the pre-list fallback. */ +export function instrumentsSource(firm: FirmLike): 'master' | 'legacy' { + return resolveAllowlist(firm, localEnabled()).source; +} + +export function isFuturesFirm(firm: { platform: string }): boolean { + return firm.platform !== 'dxtrade'; +} + +/** Every root a futures firm on this machine lists, in CME order — what the + * master's trade bar may send here. */ +export function futuresRootsHere(): string[] { + const listed = new Set(getTradableFirms().filter(isFuturesFirm).flatMap((f) => firmInstruments(f))); + return CME_ROOTS.filter((root) => listed.has(root)); +} + +// ── market data (Yahoo hourly candles) ───────────────────────────────────── + +interface Market { + tradeDate: string; + price: number | null; + dailyMove: number | null; + at: number; + error: string | null; +} + +const RETRY_MS = 15 * 60_000; +const FETCH_TIMEOUT_MS = 10_000; +const CONCURRENCY = 4; + +const g = globalThis as typeof globalThis & { + __futuresMarket?: Map; + __futuresMarketInflight?: Map>; + __futuresFees?: Map; at: number }>; + __futuresMeasuredFees?: Map; +}; +const market = (g.__futuresMarket ??= new Map()); +const inflight = (g.__futuresMarketInflight ??= new Map()); + +function isFresh(m: Market | undefined): boolean { + if (!m || m.tradeDate !== currentTradeDateCT()) return false; + return m.error === null || Date.now() - m.at < RETRY_MS; +} + +async function fetchMarket(root: string): Promise { + const spec = CME_SPECS[root]; + const tradeDate = currentTradeDateCT(); + try { + const res = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(spec.yahoo)}`, { + params: { interval: '60m', range: '7d' }, + headers: { 'User-Agent': 'Mozilla/5.0' }, + timeout: FETCH_TIMEOUT_MS, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS + 5_000), + }); + const raw: unknown[] = res.data?.chart?.result?.[0]?.indicators?.quote?.[0]?.close ?? []; + const closes = raw.filter((c): c is number => typeof c === 'number' && Number.isFinite(c) && c > 0); + const dailyMove = dailyMoveDollars(closes, spec.pointValue); + market.set(root, { + tradeDate, price: closes.at(-1) ?? null, dailyMove, at: Date.now(), + error: dailyMove === null ? `only ${closes.length} hourly closes` : null, + }); + } catch (err) { + market.set(root, { tradeDate, price: null, dailyMove: null, at: Date.now(), error: errorMessage(err) }); + } +} + +/** Refresh whichever roots aren't measured for today, waiting at most + * `deadlineMs` — a draw never stalls on Yahoo. */ +export async function ensureMarket(roots: readonly string[], deadlineMs = 4_000): Promise { + const stale = [...new Set(roots)].filter((r) => CME_SPECS[r] && !isFresh(market.get(r))); + if (stale.length === 0) return; + const run = (async () => { + for (let i = 0; i < stale.length; i += CONCURRENCY) { + await Promise.all(stale.slice(i, i + CONCURRENCY).map((root) => { + let p = inflight.get(root); + if (!p) { + p = fetchMarket(root).finally(() => inflight.delete(root)); + inflight.set(root, p); + } + return p; + })); + } + })(); + await Promise.race([run, new Promise((r) => setTimeout(r, deadlineMs))]); +} + +/** Measure every root in the background — at boot and at the midnight + * contract resolve — so the stats cover unlisted instruments too. */ +export function prewarmMarket(): void { + void ensureMarket(CME_ROOTS, 120_000).then(() => { + const measured = CME_ROOTS.filter((r) => market.get(r)?.dailyMove != null).length; + console.log(`[instruments] measured ${measured}/${CME_ROOTS.length} futures from hourly closes`); + }); +} + +export function marketOf(root: string): { price: number | null; dailyMove: number | null; error: string | null } | null { + const m = market.get(root); + return m ? { price: m.price, dailyMove: m.dailyMove, error: m.error } : null; +} + +// ── fees (shown, never used to choose) ───────────────────────────────────── + +const feeCache = (g.__futuresFees ??= new Map()); +const measuredFees = (g.__futuresMeasuredFees ??= new Map()); + +/** A fill's commission per contract per side, for platforms with no fee + * lookup (TopstepX). */ +export function recordMeasuredFee(firmId: string, root: string, perContract: number): void { + if (Number.isFinite(perContract) && perContract >= 0) measuredFees.set(`${firmId}|${root}`, Math.round(perContract * 100) / 100); +} + +/** Tradovate's fee parameters, fetched once per trade date per firm. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function refreshFees(firm: { id: string; platform: string }, client: any): void { + if (firm.platform !== 'tradovate' || typeof client?.fetchInstrumentFees !== 'function') return; + const cached = feeCache.get(firm.id); + if (cached && cached.tradeDate === currentTradeDateCT() && (Object.keys(cached.fees).length > 0 || Date.now() - cached.at < RETRY_MS)) return; + feeCache.set(firm.id, { tradeDate: currentTradeDateCT(), fees: cached?.fees ?? {}, at: Date.now() }); + void Promise.resolve(client.fetchInstrumentFees([...CME_ROOTS])) + .then((fees: Record) => feeCache.set(firm.id, { tradeDate: currentTradeDateCT(), fees: fees ?? {}, at: Date.now() })) + .catch((err: unknown) => console.warn(`[instruments] fee lookup failed: ${errorMessage(err)}`)); +} + +export function feesPerSide(firmId: string, root: string): number | null { + return feeCache.get(firmId)?.fees[root] ?? measuredFees.get(`${firmId}|${root}`) ?? null; +} + +// ── the draw ──────────────────────────────────────────────────────────────── + +/** The batch's leg, for the hold estimate: its dollars, its stop (Infinity = + * none) and the account type's max contracts. */ +export interface LegShape { + leg: number; + stopDollars: number; + maxPositionSize: number; +} + +function progressNow(now: Date): number | null { + return sessionProgress(now, getSetting('trading_hours') ?? 'full_cme'); +} + +/** Every root sized for the leg, and the draw's chances. */ +export function futuresBoard(roots: readonly string[], leg: LegShape | null, now = new Date()): { + progress: number | null; + candidates: DrawCandidate[]; + chances: { symbol: string; chance: number }[]; + disabled: string[]; +} { + const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize) : 1; + const candidates = roots.filter((r) => CME_SPECS[r]).map((root): DrawCandidate => { + const dailyMove = market.get(root)?.dailyMove ?? null; + return { + symbol: root, + spreadDollars: spreadDollars(root), + dailyMove, + holdHours: leg ? expectedHoldHours({ leg: leg.leg, stopDollars: leg.stopDollars, contracts, dailyMove }) : null, + micro: CME_SPECS[root].micro === true, + }; + }); + const progress = progressNow(now); + const { chances, disabled } = drawChances(candidates, progress); + return { progress, candidates, chances, disabled }; +} + +/** + * Draw one of `roots` for a batch whose leg is `leg`. null when nothing + * listed fits (all over the hold cap) — the batch sits this tick out. + */ +export async function drawFuturesInstrument(roots: readonly string[], leg: LegShape | null, rand: () => number = Math.random): Promise { + await ensureMarket(roots); + const board = futuresBoard(roots, leg); + const root = pickByChance(board.chances, rand()); + const hold = (s: string) => board.candidates.find((c) => c.symbol === s)?.holdHours; + const parts = board.chances.map((c) => `${c.symbol} ${Math.round(c.chance * 100)}%`).join(' · '); + const off = board.disabled.map((s) => `${s} (~${Math.round(hold(s) ?? 0)}h)`).join(', '); + console.log(`[instruments] futures: ${root ? `drew ${root}` : 'nothing fits'} — ${parts || 'none drawable'}` + + `${off ? `; over ${MAX_HOLD_HOURS}h: ${off}` : ''}` + + `${board.progress !== null ? ` (session ${Math.round(board.progress * 100)}%)` : ''}`); + return root; +} + +/** The draw's odds over `n` simulated batches — the real draw code, no + * orders — for GET /api/instruments?simulate=. */ +export function simulateDraws(roots: readonly string[], leg: LegShape | null, n: number): Record { + const board = futuresBoard(roots, leg); + const counts: Record = {}; + for (let i = 0; i < n; i++) { + const s = pickByChance(board.chances, Math.random()); + if (s) counts[s] = (counts[s] ?? 0) + 1; + } + return counts; +} + +// ── stats for the master and the Settings page ───────────────────────────── + +/** + * A futures firm's instruments as the draw sees them right now: every CME + * root with its entry cost, movement and contract, and — sized for `lead`'s + * next leg — the listed ones' holds, the ones over the cap, and each one's + * chance. + */ +export function instrumentStatsFor( + firm: FirmLike & { id: string }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + lead: (LegShape & { account: string }) | null, +): InstrumentStats { + refreshFees({ id: firm.id, platform: firm.platform }, client); + const listed = firmInstruments(firm); + const board = futuresBoard(listed, lead); + const disabled = new Set(board.disabled); + const leg = lead ? { leg: lead.leg, stopDollars: lead.stopDollars, contracts: futuresContracts(lead.leg, lead.maxPositionSize) } : null; + return { + at: new Date().toISOString(), + account: lead?.account ?? null, + leg: lead?.leg ?? null, + sessionProgress: board.progress, + maxHoldHours: MAX_HOLD_HOURS, + instruments: CME_ROOTS.map((root) => { + const dailyMove = market.get(root)?.dailyMove ?? null; + const holdHours = leg ? expectedHoldHours({ ...leg, dailyMove }) : null; + return { + symbol: root, + listed: listed.includes(root), + contract: getCachedContract(root)?.name ?? null, + spreadDollars: spreadDollars(root), + feesPerSide: feesPerSide(firm.id, root), + dailyMoveDollars: dailyMove === null ? null : Math.round(dailyMove), + holdHours: holdHours === null ? null : Math.round(holdHours * 10) / 10, + disabled: listed.includes(root) ? disabled.has(root) : holdHours !== null && holdHours >= MAX_HOLD_HOURS, + chance: board.chances.find((c) => c.symbol === root)?.chance ?? null, + }; + }), + }; +} diff --git a/lib/leg-estimate.ts b/lib/leg-estimate.ts index 3ce2291..8686a8d 100644 --- a/lib/leg-estimate.ts +++ b/lib/leg-estimate.ts @@ -11,7 +11,8 @@ import type { PlatformContract } from './platform-client'; import type { SymbolStats } from './master-protocol'; import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom, - planLegSize, rankForTarget, maxLossRoom, lastStand, DEFAULT_TARGET_HOLD_HOURS, + planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, DEFAULT_TARGET_HOLD_HOURS, + FALLBACK_COMMISSION_PER_CONTRACT, } from './trading-logic'; export type CandidateLeg = SymbolStats['symbols'][number] & { @@ -86,18 +87,14 @@ export function rankNextLeg(o: { return rankForTarget(sized, target).map((i) => sized[i]); } -/** - * The same for an account worked out from scratch — its day plan, daily-loss - * room and stops as the dashboard computes them. null when it has no - * account type, no plan, or nothing left to trade today. - */ -export function accountNextLeg( +/** An account's day plan and daily-loss room, worked out from scratch the + * way the dashboard does. null without an account type or a plan. */ +function accountPlan( // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, acc: { id: number }, - firm: FirmSizing, tradeDate: string, -): { leg: number; lastStand: boolean; candidates: CandidateLeg[] } | null { +) { const cfg = getAccountConfig(acc.id).accountType; if (!cfg) return null; const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; @@ -111,7 +108,7 @@ export function accountNextLeg( combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, cash.amount - cfg.accountSize, combined.realizedPnL, cfg.splitEnabled, effective.drain, ); - if (!plan || !(plan.remainingToday > 0)) return null; + if (!plan) return null; const dll = cfg.dailyLossLimit ?? 0; let room: number | null = null; if (dll > 0) { @@ -121,17 +118,55 @@ export function accountNextLeg( realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL), }); } + const obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday); + return { cfg, cash, plan, dll, room, obfuscation }; +} + +/** + * The same for an account worked out from scratch — its day plan, daily-loss + * room and stops as the dashboard computes them. null when it has no + * account type, no plan, or nothing left to trade today. + */ +export function accountNextLeg( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + acc: { id: number }, + firm: FirmSizing, + tradeDate: string, +): { leg: number; lastStand: boolean; candidates: CandidateLeg[] } | null { + const p = accountPlan(client, acc, tradeDate); + if (!p || !(p.plan.remainingToday > 0)) return null; const floorRoom = maxLossRoom({ - balance: cash.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0, + balance: p.cash.amount, accountSize: p.cfg.accountSize, maxLoss: p.cfg.maxLoss ?? 0, autoLiqThreshold: client.autoLiqThresholds?.[acc.id], }); return { - leg: plan.remainingToday, - lastStand: room !== null && lastStand({ dllRoom: room, dailyLossLimit: dll, maxLossRoom: floorRoom }), + leg: p.plan.remainingToday, + lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }), candidates: rankNextLeg({ - client, accountId: acc.id, cfg, firm, leg: plan.remainingToday, dllRoom: room, - obfuscationDollars: obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday), - cash, + client, accountId: acc.id, cfg: p.cfg, firm, leg: p.plan.remainingToday, dllRoom: p.room, + obfuscationDollars: p.obfuscation, cash: p.cash, }), }; } + +/** + * A futures account's next leg as the Auto draw sizes its hold: the day's + * remaining target, its stop (the tighter of the obfuscation and daily-loss + * stops; Infinity = none) and the account type's max contracts. null with + * nothing left to trade today. + */ +export function accountLegShape( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + acc: { id: number }, + tradeDate: string, +): { leg: number; stopDollars: number; maxPositionSize: number } | null { + const p = accountPlan(client, acc, tradeDate); + if (!p || !(p.plan.remainingToday > 0)) return null; + const obf = Number.isFinite(p.obfuscation) && p.obfuscation > 0 ? p.obfuscation : Infinity; + const hard = p.room !== null && p.dll > 0 + ? dllStopDollars({ room: p.room, dailyLossLimit: p.dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 }) + : Infinity; + return { leg: p.plan.remainingToday, stopDollars: Math.min(obf, hard), maxPositionSize: p.cfg.maxPositionSize }; +} diff --git a/lib/master-link.ts b/lib/master-link.ts index 7f379f1..3044c04 100644 --- a/lib/master-link.ts +++ b/lib/master-link.ts @@ -15,10 +15,11 @@ * master you control, and prefer https so it cannot be redirected in transit. */ -import { getSetting, setSetting, getInstruments, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db'; +import { getSetting, setSetting, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db'; import { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade'; import { FIRMS, automationKey } from './automations'; -import { POINT_VALUES } from './trading-logic'; +import { CME_ROOTS, CME_SPECS } from './instrument-specs'; +import { futuresRootsHere } from './instruments'; import { queueRun } from './queue-run'; import { pushBackup } from './backup-sync'; import { getBuildVersion } from './version'; @@ -35,6 +36,8 @@ const RECONNECT_MAX_MS = 60_000; interface LinkState { socket: WebSocket | null; + /** The master_dashboard_url the socket was opened for. */ + url: string | null; /** Only true once the master has answered the hello. */ ready: boolean; attempt: number; @@ -47,7 +50,7 @@ const _g = globalThis as typeof globalThis & { __masterLink?: LinkState }; function state(): LinkState { if (!_g.__masterLink) { - _g.__masterLink = { socket: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false }; + _g.__masterLink = { socket: null, url: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false }; } return _g.__masterLink; } @@ -56,16 +59,16 @@ function state(): LinkState { /** What this instance can be asked to do, as of right now. */ export function collectCapabilities(): InstanceCapabilities { - // Only symbols the operator has enabled here. An empty instruments table - // means nothing has been configured yet, so offer everything we can price. - const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol); + // The roots this machine's futures firms list (the master's per-firm + // instrument lists); none listed yet means offer everything we can price. + const listed = futuresRootsHere(); // Same test the AutoBuyer page uses: `at` is epoch ms from the last ping. const heartbeat = getRunnerHeartbeat(); const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS; return { - symbols: enabled.length > 0 ? enabled : Object.keys(POINT_VALUES), + symbols: listed.length > 0 ? listed : [...CME_ROOTS], automations: FIRMS.flatMap((firm) => firm.automations .filter((a) => a.steps.length > 0) @@ -96,7 +99,7 @@ export function collectCapabilities(): InstanceCapabilities { async function execute(command: Command): Promise { switch (command.kind) { case 'trade': { - if (command.symbol !== 'Auto' && !POINT_VALUES[command.symbol]) { + if (command.symbol !== 'Auto' && !CME_SPECS[command.symbol]) { throw new Error(`Unknown symbol: ${command.symbol}`); } // Same order as POST /api/trade: (re)start the scheduler first, @@ -163,8 +166,19 @@ function send(message: InstanceMessage): boolean { } } +/** A changed master_dashboard_url drops the socket, and the reconnect dials + * the new master — otherwise reports and commands would stay with the old + * one until a restart, while the catalog followed the setting. */ +function followMasterUrl(): void { + const s = state(); + if (!s.socket || s.url === getSetting('master_dashboard_url')) return; + console.log('[master-link] master_dashboard_url changed — reconnecting'); + try { s.socket.close(1000, 'master changed'); } catch { /* the close handler reconnects */ } +} + /** True when the report went up the socket, so the caller can skip the POST. */ export function pushReport(firms: unknown[], scheduler: unknown): boolean { + followMasterUrl(); return send({ type: 'report', firms: firms as never, @@ -224,6 +238,7 @@ function connect() { return; } s.socket = socket; + s.url = masterUrl; s.ready = false; socket.addEventListener('open', () => { diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 8d8fffe..552fa4d 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -116,6 +116,37 @@ export interface FirmStats { * candidate, best first — what the master's Firms page shows beside each * symbol. Optional. */ symbolStats?: SymbolStats; + /** Futures firms: each CME root's entry cost, movement, expected hold and + * chance in the Auto draw right now. Optional. */ + instrumentStats?: InstrumentStats; +} + +/** A futures firm's instruments as the Auto draw sees them. */ +export interface InstrumentStats { + /** ISO time computed. */ + at: string; + /** Whose next leg the holds are sized for; null when no account has one. */ + account: string | null; + leg: number | null; + /** 0 at the session's open, 1 at its last entry; null outside it. */ + sessionProgress: number | null; + /** Holds at or over this are disabled. */ + maxHoldHours: number; + instruments: { + symbol: string; + listed: boolean; + contract: string | null; + /** One tick of spread on one contract, in dollars. */ + spreadDollars: number; + /** The firm's fees per contract per side; null when unknown. */ + feesPerSide: number | null; + /** One contract's typical daily move (1σ), in dollars. */ + dailyMoveDollars: number | null; + holdHours: number | null; + disabled: boolean; + /** Share of Auto draws right now (0–1); null when not in the draw. */ + chance: number | null; + }[]; } /** How one account's next leg sizes on each candidate (see planLegSize). */ @@ -278,6 +309,12 @@ export interface CatalogFirm { * field — treated as 'tradovate'; a value this build doesn't know makes * the sync skip the firm. */ platform: FirmPlatform; + /** What the firm trades, in order: CME roots for futures firms, the firm's + * own symbols for DXtrade (see lib/instruments.ts). Absent from a master + * from before the lists — then bannedSymbols and symbolMap apply. */ + instruments?: string[]; + /** Older masters' way to exclude roots; newer ones derive it from + * `instruments`. */ bannedSymbols: string[]; accountTypes: CatalogAccountType[]; /** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */ @@ -289,7 +326,8 @@ export interface CatalogFirm { commissionPct?: number; /** Trades every calendar day, weekends and CME holidays included. */ tradesEveryDay?: boolean; - /** DXTrade: which symbols each CME root may trade as. */ + /** DXTrade, older masters: which symbols each CME root may trade as. + * Newer ones derive it from `instruments`. */ symbolMap?: SymbolMap; /** DXTrade sizing: aim each leg's expected hold at this many hours * (0 = DEFAULT_TARGET_HOLD_HOURS, 12). */ diff --git a/lib/reporter.ts b/lib/reporter.ts index bed926b..1ef970e 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -19,8 +19,9 @@ import { getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic'; -import type { AccountTypeStats, FirmStats, SymbolStats } from './master-protocol'; -import { accountNextLeg } from './leg-estimate'; +import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol'; +import { accountNextLeg, accountLegShape } from './leg-estimate'; +import { instrumentStatsFor, isFuturesFirm } from './instruments'; import { historyFresh } from './platform-client'; function collectFirmStats() { @@ -389,10 +390,46 @@ function collectFirmStats() { ...(client.syncComplete ? { accounts } : {}), accountTypes, ...symbolStatsFor(f, client, rawToday), + ...instrumentStatsBlock(f, client, rawToday), }; }); } +/** The live accounts of a firm's largest prefix group first — the group a + * batch fires — each with an account type. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function leadCandidates(client: any): { id: number; name: string }[] { + const groups = new Map(); + for (const acc of client.accountList as { id: number; name: string; active: boolean }[]) { + const cfg = getAccountConfig(acc.id).accountType; + const floor = client.autoLiqThresholds?.[acc.id] ?? 0; + const cash = client.accountCashBalances?.[acc.id]; + if (!cfg || !acc.active || !cash || (floor !== 0 && cash.amount <= floor)) continue; + groups.set(cfg.prefix, [...(groups.get(cfg.prefix) ?? []), acc]); + } + return [...groups.values()].sort((a, b) => b.length - a.length).flat(); +} + +/** + * A futures firm's instruments as the Auto draw sees them, for the master's + * Firms page: entry cost, movement and chance for each, with holds sized for + * the next leg of the first live account (largest prefix group) that has one. + */ +function instrumentStatsBlock( + f: { id: string; name: string; platform: string; instruments?: string[]; bannedSymbols?: string[] }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + tradeDate: string, +): { instrumentStats?: InstrumentStats } { + if (!isFuturesFirm(f)) return {}; + let lead: { account: string; leg: number; stopDollars: number; maxPositionSize: number } | null = null; + for (const acc of leadCandidates(client)) { + const shape = accountLegShape(client, acc, tradeDate); + if (shape) { lead = { ...shape, account: acc.name }; break; } + } + return { instrumentStats: instrumentStatsFor(f, client, lead) }; +} + /** * A DXtrade firm's next leg on each symbol-map candidate, for the master's * Firms page: one live account from the firm's largest prefix group — the diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 24a0534..d74a9fe 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -847,15 +847,6 @@ export function endedCycleCompletion(opts: { }; } -/** Dollar-per-point value for common futures products. */ -export const POINT_VALUES: { [symbol: string]: number } = { - NQ: 20, MNQ: 2, ES: 50, MES: 5, - YM: 5, MYM: 0.5, RTY: 50, M2K: 5, - GC: 100, MGC: 10, SI: 5000, CL: 1000, - MCL: 100, NG: 10000, ZB: 1000, ZN: 1000, - ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500, -}; - /** * Compute the next trading day's profit target for an account. * diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 0f1c74b..9bb2ecd 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -3,7 +3,8 @@ import axios from 'axios'; import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers'; import { computeSec, randomUUIDV4 } from './tradovate-helpers'; -import { POINT_VALUES, currentTradeDateCT } from './trading-logic'; +import { currentTradeDateCT } from './trading-logic'; +import { POINT_VALUES } from './instrument-specs'; import { getCachedContract, resolveContracts } from './contract-resolver'; import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db'; import { brokerWebSocket } from './outbound-proxy'; diff --git a/package.json b/package.json index cb99ee1..b08ab3e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs" + "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs" }, "dependencies": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-instruments.mjs b/scripts/check-instruments.mjs new file mode 100644 index 0000000..b85a3ad --- /dev/null +++ b/scripts/check-instruments.mjs @@ -0,0 +1,161 @@ +// Checks for the futures instrument specs and the Auto draw +// (lib/instrument-specs.ts). +// +// npm run check +// +// lib/instrument-specs.ts has no imports, so Node strips its types and loads +// it directly; .mjs keeps this file out of Next's type check. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + CME_SPECS, CME_ROOTS, POINT_VALUES, MICRO_OF, tickValue, spreadDollars, dailyMoveDollars, futuresContracts, + expectedHoldHours, sessionProgress, drawChances, pickByChance, housekeepingRoot, sanitizeInstruments, + resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, +} from '../lib/instrument-specs.ts'; + +// Deterministic PRNG so a failure reproduces. +function rng(seed) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +test('specs: the old point-value table exactly, and the tick values', () => { + // The table lib/trading-logic.ts carried until the specs replaced it. + const old = { + NQ: 20, MNQ: 2, ES: 50, MES: 5, YM: 5, MYM: 0.5, RTY: 50, M2K: 5, GC: 100, MGC: 10, SI: 5000, CL: 1000, + MCL: 100, NG: 10000, ZB: 1000, ZN: 1000, ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500, + }; + assert.deepEqual({ ...POINT_VALUES }, old); + assert.equal(CME_ROOTS.length, 20); + const ticks = { NQ: 5, MNQ: 0.5, ES: 12.5, GC: 10, MGC: 1, CL: 10, SI: 25, NG: 10, ZB: 31.25, ZN: 15.625, ZF: 7.8125, '6E': 6.25, '6J': 6.25, '6B': 6.25, YM: 5, RTY: 5 }; + for (const [root, value] of Object.entries(ticks)) assert.ok(Math.abs(tickValue(root) - value) < 1e-9, `${root} ${tickValue(root)}`); + assert.equal(spreadDollars('NQ'), 5); + for (const [full, micro] of Object.entries(MICRO_OF)) { + assert.ok(CME_SPECS[micro].micro && !CME_SPECS[full].micro); + assert.equal(CME_SPECS[full].pointValue / CME_SPECS[micro].pointValue, 10); + } +}); + +test('daily move from hourly closes; contracts; expected hold', () => { + // A constant 0.2% hourly swing: σ = 0.002, × √23 × price × pv. + const closes = Array.from({ length: 60 }, (_, i) => (i % 2 ? 20000 * 1.002 : 20000)); + const move = dailyMoveDollars(closes, 20); + const r = Math.log(1.002); + const sd = Math.sqrt((r * r * 59) / 58); // mean ≈ 0 over alternating ±r + assert.ok(Math.abs(move - sd * Math.sqrt(23) * closes[59] * 20) / move < 0.01, `move ${move}`); + assert.equal(dailyMoveDollars(closes.slice(0, 10), 20), null); + assert.equal(futuresContracts(1000, 0), 1); + assert.equal(futuresContracts(2400, 0), 3); + assert.equal(futuresContracts(2400, 2), 2); + // $1,000 leg, stopless, 1 contract: (1000/5227)² days ≈ 0.88 h on NQ; ≈ 8 days on 6E. + assert.ok(Math.abs(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: 5227 }) - 0.878) < 0.01); + assert.ok(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: 351 }) > 190); + // More contracts, bigger move, shorter hold; a stop shortens it too. + assert.ok(Math.abs(expectedHoldHours({ leg: 2000, stopDollars: Infinity, contracts: 2, dailyMove: 5227 }) - 0.878) < 0.01); + assert.ok(expectedHoldHours({ leg: 1000, stopDollars: 500, contracts: 1, dailyMove: 5227 }) < 0.5); + assert.equal(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: null }), null); +}); + +test('session progress: full CME 17:05 → 14:55 CT, equity hours 8:35 → 14:55', () => { + // 2026-09-28 is a Monday; CDT is UTC−5. + const ct = (iso) => new Date(`${iso}-05:00`); + assert.equal(sessionProgress(ct('2026-09-27T17:05'), 'full_cme'), 0); + assert.ok(Math.abs(sessionProgress(ct('2026-09-28T04:00'), 'full_cme') - 655 / 1310) < 1e-9); + assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'full_cme'), 1); + assert.equal(sessionProgress(ct('2026-09-28T15:30'), 'full_cme'), null); // maintenance + assert.equal(sessionProgress(ct('2026-09-26T12:00'), 'full_cme'), null); // Saturday + assert.equal(sessionProgress(ct('2026-09-27T12:00'), 'full_cme'), null); // Sunday before the open + assert.equal(sessionProgress(ct('2026-10-02T15:10'), 'full_cme'), null); // Friday after the close + assert.equal(sessionProgress(ct('2026-09-28T08:35'), 'equity_hours'), 0); + assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'equity_hours'), 1); + assert.equal(sessionProgress(ct('2026-09-28T07:00'), 'equity_hours'), null); +}); + +// Measured 2026-09-26 from Yahoo hourly closes. +const MEASURED = [ + { symbol: 'NQ', spreadDollars: 5, dailyMove: 5227, holdHours: null }, + { symbol: 'GC', spreadDollars: 10, dailyMove: 4843, holdHours: null }, + { symbol: 'CL', spreadDollars: 10, dailyMove: 3550, holdHours: null }, + { symbol: '6E', spreadDollars: 6.25, dailyMove: 351, holdHours: null }, +]; +const pct = (chances) => Object.fromEntries(chances.map((c) => [c.symbol, Math.round(c.chance * 1000) / 10])); + +test('the draw reproduces the plan\'s table: a floor early, the fastest late', () => { + const rows = [ + [0, { NQ: 42.3, GC: 25.0, CL: 21.0, '6E': 11.7 }], + [0.5, { NQ: 49.0, GC: 27.5, CL: 16.0, '6E': 7.5 }], + [0.9, { NQ: 59.9, GC: 32.5, CL: 5.7, '6E': 1.9 }], + [1, { NQ: 63.0, GC: 34.2, CL: 2.9, '6E': 0.0 }], + ]; + for (const [p, expected] of rows) { + const { chances } = drawChances(MEASURED, p); + const got = pct(chances); + for (const [sym, v] of Object.entries(expected)) assert.ok(Math.abs(got[sym] - v) <= 0.15, `p=${p} ${sym} ${got[sym]} vs ${v}`); + assert.ok(Math.abs(chances.reduce((s, c) => s + c.chance, 0) - 1) < 1e-9); + } + // At the open every instrument has at least the floor. + for (const c of drawChances(MEASURED, 0).chances) assert.ok(c.chance >= DRAW_FLOOR - 1e-12); +}); + +test('the 12-hour cap disables slow instruments; nothing fitting means no draw', () => { + const withHolds = MEASURED.map((c) => ({ ...c, holdHours: c.symbol === '6E' ? 195 : 1 })); + const { chances, disabled } = drawChances(withHolds, 0); + assert.deepEqual(disabled, ['6E']); + assert.deepEqual(chances.map((c) => c.symbol), ['NQ', 'GC', 'CL']); + const got = pct(chances); + assert.ok(Math.abs(got.NQ - 48.9) < 0.2 && Math.abs(got.GC - 28.0) < 0.2 && Math.abs(got.CL - 23.2) < 0.2, JSON.stringify(got)); + assert.equal(drawChances([{ ...MEASURED[0], holdHours: MAX_HOLD_HOURS }], 0).chances.length, 0); + assert.equal(drawChances([{ ...MEASURED[0], holdHours: MAX_HOLD_HOURS - 0.01 }], 0).chances.length, 1); +}); + +test('fallbacks: median weight when unmeasured, even when nothing is; micros only as a last resort', () => { + const one = drawChances([...MEASURED.slice(0, 3), { symbol: 'ES', spreadDollars: 12.5, dailyMove: null, holdHours: null }], 0); + const es = one.chances.find((c) => c.symbol === 'ES').chance; + const sorted = one.chances.filter((c) => c.symbol !== 'ES').map((c) => c.chance).sort(); + assert.ok(es >= sorted[0] && es <= sorted[2], `ES ${es}`); + const none = drawChances(MEASURED.map((c) => ({ ...c, dailyMove: null })), 0.7); + for (const c of none.chances) assert.ok(Math.abs(c.chance - 0.25) < 1e-9); + const micros = [{ symbol: 'MNQ', spreadDollars: 0.5, dailyMove: 522, holdHours: 5, micro: true }]; + assert.deepEqual(drawChances([...micros, MEASURED[0]], 0).chances.map((c) => c.symbol), ['NQ']); + assert.deepEqual(drawChances(micros, 0).chances.map((c) => c.symbol), ['MNQ']); +}); + +test('10k seeded draws land on the chances', () => { + const { chances } = drawChances(MEASURED, 0.5); + const r = rng(7); + const counts = {}; + for (let i = 0; i < 10000; i++) { + const s = pickByChance(chances, r()); + counts[s] = (counts[s] ?? 0) + 1; + } + for (const c of chances) assert.ok(Math.abs(counts[c.symbol] / 10000 - c.chance) < 0.02, `${c.symbol} ${counts[c.symbol]} vs ${c.chance}`); + assert.equal(pickByChance([], 0.5), null); +}); + +test('drain and extra-day legs: a micro of what is listed', () => { + assert.equal(housekeepingRoot(['NQ', 'GC', 'CL', '6E']), 'MNQ'); + assert.equal(housekeepingRoot(['MNQ']), 'MNQ'); + assert.equal(housekeepingRoot(['GC', 'CL']), 'MGC'); + assert.equal(housekeepingRoot(['6E', 'ZN']), '6E'); // no micro: the smallest tick value + assert.equal(housekeepingRoot(['MCL', 'ZB']), 'MCL'); + assert.equal(housekeepingRoot([]), null); +}); + +test('reading lists: sanitize per family; master list first, the old rules otherwise', () => { + assert.deepEqual(sanitizeInstruments('tradovate', ['nq', 'GC', 'XX', 'NQ', 5]), ['NQ', 'GC']); + assert.deepEqual(sanitizeInstruments('dxtrade', ['ETH/USD', 'BAD|X', 'ETH/USD', 'btc/usd']), ['ETH/USD', 'btc/usd']); + const local = ['CL', 'GC', 'NQ', '6E']; + assert.deepEqual(resolveAllowlist({ platform: 'tradovate', instruments: ['NQ', 'ES'], bannedSymbols: ['NQ'] }, local), { roots: ['NQ', 'ES'], source: 'master' }); + assert.deepEqual(resolveAllowlist({ platform: 'tradovate', bannedSymbols: ['GC'] }, local), { roots: ['CL', 'NQ', '6E'], source: 'legacy' }); + assert.deepEqual(resolveAllowlist({ platform: 'topstepx', instruments: [] }, local), { roots: [], source: 'master' }); + assert.deepEqual( + resolveAllowlist({ platform: 'dxtrade', symbolMap: { NQ: [{ symbol: 'XYZ100/USD' }], '*': [{ symbol: 'ETH/USD' }, { symbol: 'XYZ100/USD' }] } }, local), + { roots: ['ETH/USD', 'XYZ100/USD'], source: 'legacy' }, + ); +}); diff --git a/types.ts b/types.ts index 8c8cde1..97fba4e 100644 --- a/types.ts +++ b/types.ts @@ -49,7 +49,12 @@ export interface FirmConfig { * password (Tradovate, DXTrade) or a TopstepX API key. Firms without them * are shown greyed out with an inline credential form. */ hasCredentials: boolean; - bannedSymbols: string[]; + /** What the firm trades (lib/instruments.ts): CME roots for futures + * firms, its own symbols for DXtrade. */ + instruments: string[]; + /** 'master' = the catalog's list; 'legacy' = a master from before the + * lists, read the old way (this machine's enabled set less bans). */ + instrumentsSource: 'master' | 'legacy'; /** The firm's account types, straight from the catalog. Doubles as the * option list for a manual account-type override. */ accounts: AccountConfig[];