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[];