From 5696f2b0366a2b14b787de8b8575326bb3beb52f Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sun, 27 Sep 2026 18:50:56 -0500 Subject: [PATCH] Report each firm's trading mix: open positions and today's entries by instrument The master knew how many accounts were in a trade, not in what. Each firm's report now carries its open positions by instrument (accounts long and short, contracts held) and today's entries the same way. A position's contract is named by its instrument: DXtrade's carries the symbol; a futures one is looked up among the contracts this machine has entered (recorded at the fill and kept), then the resolver's cache and TopstepX's own. Entries are counted as they fill, per trade date, in a setting, so a restart doesn't lose the day. Co-Authored-By: Claude Opus 5.5 --- lib/auto-trade.ts | 12 +++++ lib/master-protocol.ts | 17 +++++++ lib/reporter.ts | 2 + lib/topstepx-class.ts | 6 +++ lib/trade-mix.ts | 101 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 lib/trade-mix.ts diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 368aa02..52d11db 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -25,6 +25,7 @@ import { getSetting, setSetting } from './db'; import type { LaneTick, TickReason } from './master-protocol'; import { POINT_VALUES, housekeepingRoot, MAX_HOLD_HOURS } from './instrument-specs'; import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments'; +import { recordEntry } from './trade-mix'; import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client'; // ── process-wide state ─────────────────────────────────────────────────────── @@ -1082,6 +1083,7 @@ async function runTradeLocked( 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'); + recordEntry(item.firmName, microRoot, resolvedAction, 1, mnqContract.id); const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market'); @@ -1318,6 +1320,16 @@ async function fireLeg( const { sizing, obfuscation } = plan; const fill = await sendEntry(tag, client, acc, legContract.name, contracts, action, tradeDate, legAmount); + // For the master's trade mix: a futures leg is its root (the drain + // micro on a drain day), a DXtrade one its symbol. + const dx = (legContract.stepNotional ?? 0) > 0; + recordEntry( + item.firmName, + dx ? String(legContract.name).split('|')[0] : (item.drain ? housekeepingFor(item.firm) : symbolLabel), + action, + dx ? contracts * (Number(String(legContract.name).split('|')[1]) || 1) : contracts, + (legContract as { id?: unknown }).id, + ); const exitAction: 'Buy' | 'Sell' = action === 'Buy' ? 'Sell' : 'Buy'; // The entry fee. Percent firms: straight off the fill. Otherwise wait diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 998f2ae..75f84a3 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -119,6 +119,23 @@ export interface FirmStats { /** Futures firms: each CME root's entry cost, movement, expected hold and * chance in the Auto draw right now. Optional. */ instrumentStats?: InstrumentStats; + /** What the firm is trading, by instrument: its open positions and + * today's entries (lib/trade-mix.ts). Optional. */ + mix?: TradeMix; +} + +/** One instrument's share of a firm's trading: accounts (open) or entries + * (today) on each side, and the contracts in them — units on DXtrade. */ +export interface InstrumentCount { + instrument: string; + long: number; + short: number; + contracts: number; +} + +export interface TradeMix { + open: InstrumentCount[]; + today: InstrumentCount[]; } /** A futures firm's instruments as the Auto draw sees them. */ diff --git a/lib/reporter.ts b/lib/reporter.ts index e03e0fc..7263df6 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -14,6 +14,7 @@ import { getSetting, loadEndedCycleStart } from './db'; import { pushReport } from './master-link'; import { getBuildVersion } from './version'; +import { tradeMixFor } from './trade-mix'; import { updaterStatus } from './updater-status'; import { getClients } from './clients'; import { getTradableFirms } from './firm-catalog'; @@ -392,6 +393,7 @@ function collectFirmStats() { accountTypes, ...symbolStatsFor(f, client, rawToday), ...instrumentStatsBlock(f, client, rawToday), + mix: tradeMixFor(f, client), }; }); } diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index db7ed84..ea28766 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -1758,6 +1758,12 @@ export class TopstepXClient implements PlatformClient { // ── lifecycle ──────────────────────────────────────────────────────────── + /** Contract id → the root it was resolved for, for the reporter's trade + * mix (lib/trade-mix.ts). */ + public contractRoots(): Map { + return new Map([...this.contractByProduct].map(([root, { contract }]) => [String(contract.id), root])); + } + /** The session token, for the live quote feed's market hub * (lib/live-quotes.ts); null while logged out. */ public quoteToken(): string | null { diff --git a/lib/trade-mix.ts b/lib/trade-mix.ts new file mode 100644 index 0000000..71e17ff --- /dev/null +++ b/lib/trade-mix.ts @@ -0,0 +1,101 @@ +/** + * What each firm is trading, by instrument — its open positions now and its + * entries today — for the master's fleet-wide mix. + * + * Open positions come off the client's position map, each contract named by + * the instrument it is: a DXtrade position carries its symbol; a futures one + * is looked up among the contracts this machine has entered (recorded at the + * fill, kept across restarts), then the resolver's cache and TopstepX's own. + * Entries are counted as they fill and kept in a setting per trade date, so + * a restart doesn't lose the day. + */ + +import { getSetting, setSetting } from './db'; +import { currentTradeDateCT } from './trading-logic'; +import { getAllCachedContracts } from './contract-resolver'; +import { errorMessage } from './platform-client'; +import type { InstrumentCount, TradeMix } from './master-protocol'; + +const TODAY_KEY = 'trade_mix_today'; +const NAMES_KEY = 'trade_mix_contracts'; +/** Contract ids remembered at most — the front months of a few roots. */ +const MAX_NAMES = 200; + +type Tally = Record; +interface Today { + date: string; + byFirm: Record; +} + +function readJson(key: string): T | null { + try { + return JSON.parse(getSetting(key) ?? 'null') as T | null; + } catch { + return null; + } +} + +function today(date: string): Today { + const saved = readJson(TODAY_KEY); + return saved?.date === date && saved.byFirm && typeof saved.byFirm === 'object' ? saved : { date, byFirm: {} }; +} + +/** + * Count one filled entry toward the firm's mix for today, and remember which + * instrument its contract is. Bookkeeping: it never throws into a trade. + */ +export function recordEntry(firm: string, instrument: string, side: 'Buy' | 'Sell', contracts: number, contractId?: unknown): void { + try { + const t = today(currentTradeDateCT()); + const tally = (t.byFirm[firm] ??= {}); + const entry = (tally[instrument] ??= { long: 0, short: 0, contracts: 0 }); + if (side === 'Buy') entry.long++; + else entry.short++; + entry.contracts += Number.isFinite(contracts) ? contracts : 0; + setSetting(TODAY_KEY, JSON.stringify(t)); + + if (contractId !== undefined && contractId !== null && contractId !== '') { + const names = readJson>(NAMES_KEY) ?? {}; + names[String(contractId)] = instrument; + const kept = Object.entries(names).slice(-MAX_NAMES); + setSetting(NAMES_KEY, JSON.stringify(Object.fromEntries(kept))); + } + } catch (err) { + console.warn(`[trade-mix] could not record an entry: ${errorMessage(err)}`); + } +} + +/** The firm's open positions and today's entries, by instrument. */ +export function tradeMixFor( + firm: { name: string; platform: string }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, +): TradeMix { + const names = new Map(); + for (const [root, contract] of Object.entries(getAllCachedContracts())) { + if (contract) names.set(String(contract.id), root); + } + const own = typeof client.contractRoots === 'function' ? (client.contractRoots() as Map) : null; + for (const [id, root] of own ?? []) names.set(id, root); + for (const [id, instrument] of Object.entries(readJson>(NAMES_KEY) ?? {})) names.set(id, instrument); + const instrumentOf = (contractId: unknown): string => (firm.platform === 'dxtrade' + ? String(contractId).split('|')[0] + : names.get(String(contractId)) ?? `#${String(contractId)}`); + + const open = new Map(); + for (const acc of (client.accountList ?? []) as { id: number }[]) { + const pos = client.positions?.[acc.id] as { contractId: unknown; netPos: number } | undefined; + if (!pos || !pos.netPos) continue; + const instrument = instrumentOf(pos.contractId); + const entry = open.get(instrument) ?? { instrument, long: 0, short: 0, contracts: 0 }; + if (pos.netPos > 0) entry.long++; + else entry.short++; + entry.contracts += Math.abs(pos.netPos); + open.set(instrument, entry); + } + const tally = today(currentTradeDateCT()).byFirm[firm.name] ?? {}; + return { + open: [...open.values()], + today: Object.entries(tally).map(([instrument, t]) => ({ instrument, long: t.long, short: t.short, contracts: t.contracts })), + }; +}