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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-27 18:50:56 -05:00
co-authored by Claude Opus 5.5
parent ab3132182a
commit 5696f2b036
5 changed files with 138 additions and 0 deletions
+12
View File
@@ -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
+17
View File
@@ -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. */
+2
View File
@@ -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),
};
});
}
+6
View File
@@ -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<string, string> {
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 {
+101
View File
@@ -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<string, { long: number; short: number; contracts: number }>;
interface Today {
date: string;
byFirm: Record<string, Tally>;
}
function readJson<T>(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>(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<Record<string, string>>(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<string, string>();
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<string, string>) : null;
for (const [id, root] of own ?? []) names.set(id, root);
for (const [id, instrument] of Object.entries(readJson<Record<string, string>>(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<string, InstrumentCount>();
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 })),
};
}