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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 19:47:32 -05:00
co-authored by Claude Opus 5.5
parent 302e9b4911
commit 02544fbdf5
25 changed files with 1337 additions and 277 deletions
+201 -97
View File
@@ -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, { symbol: string }[]> }): 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<string | null> {
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<string, EligibleItem[]>();
// 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<string, Candidate[]>();
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<typeof sizeLeg>;
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<unknown[]>
}
}
}
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<unknown[]>
}
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<unknown[]>
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}`);