Compare commits

...
3 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5.5 ed62788480 Drain and extra-day legs always trade MNQ
They traded the micro of whatever the firm listed: MNQ with NQ, but MGC
for a firm listing only GC and CL. Now every futures firm's tiny legs go on
MNQ, the one micro the bot has always drained on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:54:41 -05:00
Brandon LiandClaude Opus 5.5 5696f2b036 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>
2026-09-27 18:50:56 -05:00
Brandon LiandClaude Opus 5.5 ab3132182a Report each tick's outcome and the updater's state to the master
The master saw a scheduler's last run time and nothing of what it did. Each
tick now records, per lane, its outcome, a line on what happened (what
fired, or the spread, contract or hold cap that stopped it) and why the
lane's accounts sat it out, counted by reason with a few names each. It
rides the scheduler status as lastTicks.

Each report also carries the auto-updater's state from the files it keeps
and from git: its last check, a failed update waiting to retry, how far the
checkout is behind origin, and tracked files edited by hand, which stop the
fast-forward pull.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:31:21 -05:00
12 changed files with 405 additions and 40 deletions
+1 -2
View File
@@ -122,8 +122,7 @@ the batch firm's list, at random but weighted:
the type's max contracts); an instrument whose expected hold for the 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, batch's leg is 12 h or more is out of the draw (e.g. 6E at a $1,000 leg,
about a week). about a week).
- Micros stay out of the draw; drain and extra-day legs trade the micro of a - Micros stay out of the draw; drain and extra-day legs always trade MNQ.
listed product (MNQ when NQ is listed).
Every draw logs its odds. `GET /api/instruments` shows each firm's list with Every draw logs its odds. `GET /api/instruments` shows each firm's list with
spread, daily move, hold and chance (`?simulate=10000` runs the real draw spread, daily move, hold and chance (`?simulate=10000` runs the real draw
+119 -18
View File
@@ -18,11 +18,14 @@ import {
isNoTradeTime, isPastSessionClose, isNoTradeTime, isPastSessionClose,
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
CME_EVENING_OPEN_MIN, foldReason,
type DayPlan, type DayPlan,
} from './trading-logic'; } from './trading-logic';
import { getSetting, setSetting } from './db'; import { getSetting, setSetting } from './db';
import { POINT_VALUES, housekeepingRoot } from './instrument-specs'; import type { LaneTick, TickReason } from './master-protocol';
import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS } from './instrument-specs';
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments'; 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'; import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
// ── process-wide state ─────────────────────────────────────────────────────── // ── process-wide state ───────────────────────────────────────────────────────
@@ -69,6 +72,9 @@ interface TradeRuntime {
lastTickOutcome: TickOutcome; lastTickOutcome: TickOutcome;
/** The last tick's outcome per lane. */ /** The last tick's outcome per lane. */
laneOutcomes?: Map<string, TickOutcome>; laneOutcomes?: Map<string, TickOutcome>;
/** The last tick per lane as the master sees it: what happened and why
* accounts sat it out (getSchedulerStatus). */
laneTicks?: Map<string, LaneTick>;
lastFireAt: number; lastFireAt: number;
holding: string[]; holding: string[];
quarantined: string[]; quarantined: string[];
@@ -428,7 +434,7 @@ function releaseTradeMutex(lease: number, lane: string): void {
* housekeepingRoot); a DXtrade firm its current pick. */ * housekeepingRoot); a DXtrade firm its current pick. */
function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> }): string { function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> }): string {
if (firm.platform === 'dxtrade') return '*'; if (firm.platform === 'dxtrade') return '*';
return housekeepingRoot(firmInstruments(firm)) ?? 'MNQ'; return housekeepingRoot(firmInstruments(firm)) ?? HOUSEKEEPING_ROOT;
} }
/** The instrument a leg actually trades: drain legs go on the firm's /** The instrument a leg actually trades: drain legs go on the firm's
@@ -780,14 +786,50 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string):
return results; return results;
} }
/** What a lane's batch decided: its outcome, what it traded, and for the
* master a line on what happened and why accounts sat it out. */
type BatchOutcome = { outcome: TradeOutcome; results: FirmTradeResults[]; detail?: string; reasons?: TickReason[] };
/** Accounts that sat a batch out, by reason. Dollar amounts are folded so
* like reasons group ("day done ($12 left of $300)" and "($40 left of $500)"). */
type ReasonTally = Map<string, { count: number; accounts: string[] }>;
function tally(reasons: ReasonTally, reason: string, who: string): void {
const key = foldReason(reason);
const entry = reasons.get(key) ?? { count: 0, accounts: [] };
entry.count++;
if (entry.accounts.length < 3) entry.accounts.push(who);
reasons.set(key, entry);
}
function reasonList(reasons: ReasonTally): TickReason[] {
return [...reasons.entries()]
.map(([reason, { count, accounts }]) => ({ reason, count, accounts }))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
}
const fmtCentralMinutes = (m: number): string => {
const h = Math.floor(m / 60) % 24;
return `${h % 12 || 12}:${String(m % 60).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'} CT`;
};
/** Why a lane is closed, for the master: when today's session opens. */
function closedDetail(lane: string): string {
if (lane !== FUTURES_LANE) return 'standing down around the daily roll';
const delay = todaysSessionOpenDelay();
const open = getSetting('trading_hours') === 'equity_hours' ? 8 * 60 + 35 + delay : CME_EVENING_OPEN_MIN + delay;
return `outside trading hours — today's open is ${fmtCentralMinutes(open)}`;
}
/** One lane's batch plus what it decided, which the tick records for getSchedulerHealth. */ /** One lane's batch plus what it decided, which the tick records for getSchedulerHealth. */
async function runTradeBatch( async function runTradeBatch(
action: 'Buy' | 'Sell' | 'Auto', action: 'Buy' | 'Sell' | 'Auto',
symbol: string, symbol: string,
lane: string, lane: string,
): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> { ): Promise<BatchOutcome> {
const lease = acquireTradeMutex('[auto-trade]', lane); const lease = acquireTradeMutex('[auto-trade]', lane);
if (lease === null) return { outcome: 'skipped-in-flight', results: [] }; if (lease === null) return { outcome: 'skipped-in-flight', results: [], detail: 'another batch is still in flight' };
try { try {
return await runTradeLocked(action, symbol, lease, lane); return await runTradeLocked(action, symbol, lease, lane);
} finally { } finally {
@@ -822,11 +864,11 @@ async function runTradeLocked(
symbol: string, symbol: string,
lease: number, lease: number,
lane: string, lane: string,
): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> { ): Promise<BatchOutcome> {
const laneFirms = getTradableFirms().filter((f) => laneOf(f) === lane); const laneFirms = getTradableFirms().filter((f) => laneOf(f) === lane);
if (laneFirms.every((f) => isInNoTradeWindow(f))) { if (laneFirms.every((f) => isInNoTradeWindow(f))) {
console.log(`[auto-trade] ${laneName(lane)}: market closed — skipping`); console.log(`[auto-trade] ${laneName(lane)}: market closed — skipping`);
return { outcome: 'closed', results: [] }; return { outcome: 'closed', results: [], detail: closedDetail(lane) };
} }
// Block if any account in the lane still has an open position (same gate // Block if any account in the lane still has an open position (same gate
@@ -836,7 +878,7 @@ async function runTradeLocked(
const openPositions = countOpenPositions(laneReady); const openPositions = countOpenPositions(laneReady);
if (openPositions > 0) { if (openPositions > 0) {
console.log(`[auto-trade] ${laneName(lane)}: ${openPositions} position(s) still open — skipping`); console.log(`[auto-trade] ${laneName(lane)}: ${openPositions} position(s) still open — skipping`);
return { outcome: 'positions-open', results: [] }; return { outcome: 'positions-open', results: [], detail: `${openPositions} position(s) still open` };
} }
const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm)); const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm));
@@ -858,16 +900,22 @@ async function runTradeLocked(
type Candidate = SizingItem & { daysTraded: number; isExtraDay: boolean }; type Candidate = SizingItem & { daysTraded: number; isExtraDay: boolean };
type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean }; type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean };
const allEligible: Candidate[] = []; const allEligible: Candidate[] = [];
// Why accounts sat this batch out (accounts with no type aren't ours),
// and what stopped the batch itself — for the master.
const reasons: ReasonTally = new Map();
const notes = new Set<string>();
for (const { firm, client } of open) { for (const { firm, client } of open) {
if (client.accountList.length === 0) continue; if (client.accountList.length === 0) continue;
const listed = firmInstruments(firm); const listed = firmInstruments(firm);
if (listed.length === 0) { if (listed.length === 0) {
console.log(`[auto-trade] ${firm.name}: no instruments listed — skipping firm`); console.log(`[auto-trade] ${firm.name}: no instruments listed — skipping firm`);
tally(reasons, 'its firm lists no instruments', firm.name);
continue; continue;
} }
if (explicit && !listed.includes(explicit)) { if (explicit && !listed.includes(explicit)) {
console.log(`[auto-trade] ${firm.name}: ${explicit} isn't on its instrument list — skipping firm`); console.log(`[auto-trade] ${firm.name}: ${explicit} isn't on its instrument list — skipping firm`);
tally(reasons, `${explicit} isn't on its firm's list`, firm.name);
continue; continue;
} }
// One firm's trouble (a malformed snapshot) skips that firm, never // One firm's trouble (a malformed snapshot) skips that firm, never
@@ -875,7 +923,11 @@ async function runTradeLocked(
try { try {
for (const acc of client.accountList) { for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate); const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev || !ev.eligible || !ev.plan) continue; if (!ev) continue;
if (!ev.eligible || !ev.plan) {
tally(reasons, ev.reason || 'no day plan', acc.name);
continue;
}
allEligible.push({ allEligible.push({
firmName: firm.name, firm, client, acc, firmName: firm.name, firm, client, acc,
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded, cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
@@ -886,12 +938,13 @@ async function runTradeLocked(
} }
} catch (err) { } catch (err) {
console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`); console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
tally(reasons, `firm skipped — ${errorMessage(err)}`, firm.name);
} }
} }
if (allEligible.length === 0) { if (allEligible.length === 0) {
console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`); console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`);
return { outcome: 'no-eligible', results: [] }; return { outcome: 'no-eligible', results: [], detail: 'no eligible accounts', reasons: reasonList(reasons) };
} }
// Take only the first batch — all fired simultaneously, no rolling pool. // Take only the first batch — all fired simultaneously, no rolling pool.
@@ -932,7 +985,10 @@ async function runTradeLocked(
const tried: string[] = []; const tried: string[] = [];
for (let attempt = 0; attempt < 3 && batch.length === 0; attempt++) { for (let attempt = 0; attempt < 3 && batch.length === 0; attempt++) {
const root = explicit ?? await drawFuturesInstrument(allowed.filter((r) => !tried.includes(r)), shape); const root = explicit ?? await drawFuturesInstrument(allowed.filter((r) => !tried.includes(r)), shape);
if (!root) break; if (!root) {
notes.add(`nothing on ${prefix || 'its'} list fits right now (spread or ${MAX_HOLD_HOURS}h hold cap)`);
break;
}
tried.push(root); tried.push(root);
// Paying more than the master's limit to cross the book: // Paying more than the master's limit to cross the book:
// not this tick (the draw left it out already; an explicit // not this tick (the draw left it out already; an explicit
@@ -940,6 +996,7 @@ async function runTradeLocked(
const wide = spreadBlock(root); const wide = spreadBlock(root);
if (wide) { if (wide) {
console.log(`[auto-trade] ${wide} — not trading it this tick`); console.log(`[auto-trade] ${wide} — not trading it this tick`);
notes.add(wide);
if (explicit) break; if (explicit) break;
continue; continue;
} }
@@ -950,11 +1007,13 @@ async function runTradeLocked(
const pv = contract?.pointValue ?? POINT_VALUES[root]; const pv = contract?.pointValue ?? POINT_VALUES[root];
if (!contract || !pv) { if (!contract || !pv) {
console.log(`[auto-trade] ${firm.name}: no ${root} contract — leaving its accounts out`); console.log(`[auto-trade] ${firm.name}: no ${root} contract — leaving its accounts out`);
notes.add(`${firm.name}: no ${root} contract`);
continue; continue;
} }
resolved.push(...pool.filter((it) => it.firm.id === firm.id).map((it) => ({ ...it, contract, pv }))); resolved.push(...pool.filter((it) => it.firm.id === firm.id).map((it) => ({ ...it, contract, pv })));
} catch (err) { } catch (err) {
console.error(`[auto-trade] ${firm.name}: ${root} contract lookup failed — ${errorMessage(err)}`); console.error(`[auto-trade] ${firm.name}: ${root} contract lookup failed — ${errorMessage(err)}`);
notes.add(`${firm.name}: ${root} contract lookup failed`);
} }
} }
if (resolved.length > 0) { if (resolved.length > 0) {
@@ -974,9 +1033,11 @@ async function runTradeLocked(
resolvedSymbol = String(contract.id); resolvedSymbol = String(contract.id);
} else { } else {
console.log(`[auto-trade] ${firm.name}: nothing on its list can trade right now`); console.log(`[auto-trade] ${firm.name}: nothing on its list can trade right now`);
notes.add(`${firm.name}: nothing on its list can trade right now`);
} }
} catch (err) { } catch (err) {
console.error(`[auto-trade] ${firm.name}: instrument pick failed — ${errorMessage(err)}`); console.error(`[auto-trade] ${firm.name}: instrument pick failed — ${errorMessage(err)}`);
notes.add(`${firm.name}: instrument pick failed — ${errorMessage(err)}`);
} }
} }
if (batch.length > 0) { if (batch.length > 0) {
@@ -987,7 +1048,11 @@ async function runTradeLocked(
if (batch.length === 0) { if (batch.length === 0) {
console.log(`[auto-trade] ${laneName(lane)}: nothing tradable for any eligible group`); console.log(`[auto-trade] ${laneName(lane)}: nothing tradable for any eligible group`);
return { outcome: 'no-eligible', results: [] }; return {
outcome: 'no-eligible', results: [],
detail: `${allEligible.length} eligible, nothing tradable${notes.size ? ` — ${[...notes].join('; ')}` : ''}`,
reasons: reasonList(reasons),
};
} }
// Choosing took so long that another batch presumed this one wedged and // Choosing took so long that another batch presumed this one wedged and
@@ -995,7 +1060,7 @@ async function runTradeLocked(
// firing now could trade an account twice. // firing now could trade an account twice.
if (!holdsTradeMutex(lease, lane)) { if (!holdsTradeMutex(lease, lane)) {
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch'); console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
return { outcome: 'skipped-in-flight', results: [] }; return { outcome: 'skipped-in-flight', results: [], detail: 'superseded by another batch while choosing' };
} }
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}`); 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}`);
@@ -1018,6 +1083,7 @@ async function runTradeLocked(
if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`); 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 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 exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market'); const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market');
@@ -1057,9 +1123,19 @@ async function runTradeLocked(
); );
} }
const all = [...firmResultsMap.values()].flat();
const filledCount = all.filter((r) => r.status === 'filled').length;
const failed = all.filter((r) => r.status === 'error');
// Accounts left for a later batch aren't benched: the next tick fires them.
for (const item of allEligible) {
if (!batch.some((b) => b.acc.id === item.acc.id)) tally(reasons, 'eligible — waits for a later batch', item.acc.name);
}
return { return {
outcome: 'fired', outcome: 'fired',
results: Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results })), results: Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results })),
detail: `fired ${batch.length} × ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}: ${filledCount} filled`
+ (failed.length ? `, ${failed.length} failed (${String(failed[0].reason ?? '').slice(0, 120)})` : ''),
reasons: reasonList(reasons),
}; };
} }
@@ -1244,6 +1320,16 @@ async function fireLeg(
const { sizing, obfuscation } = plan; const { sizing, obfuscation } = plan;
const fill = await sendEntry(tag, client, acc, legContract.name, contracts, action, tradeDate, legAmount); 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'; const exitAction: 'Buy' | 'Sell' = action === 'Buy' ? 'Sell' : 'Buy';
// The entry fee. Percent firms: straight off the fill. Otherwise wait // The entry fee. Percent firms: straight off the fill. Otherwise wait
@@ -1770,13 +1856,25 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
} }
const outcomes = new Map<string, TickOutcome>(); const outcomes = new Map<string, TickOutcome>();
const ticks = new Map<string, LaneTick>();
const record = (lane: string, outcome: TickOutcome, detail: string, reasons?: TickReason[]) => {
outcomes.set(lane, outcome);
ticks.set(lane, {
lane: laneName(lane),
at: new Date(now).toISOString(),
outcome,
detail: detail.slice(0, 300),
...(reasons && reasons.length > 0 ? { reasons } : {}),
});
};
let filled = 0; let filled = 0;
for (const lane of tradableLanes()) { for (const lane of tradableLanes()) {
try { try {
const laneHolding = holding.filter((f) => laneOf(f) === lane); const laneHolding = holding.filter((f) => laneOf(f) === lane);
if (laneHolding.length > 0) { if (laneHolding.length > 0) {
outcomes.set(lane, 'holding-for-sync'); const names = laneHolding.map((f) => f.name).join(', ');
console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${laneHolding.map((f) => f.name).join(', ')}`); record(lane, 'holding-for-sync', `waiting for sync: ${names}`);
console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${names}`);
continue; continue;
} }
// Skip this lane if any of its accounts still has an open // Skip this lane if any of its accounts still has an open
@@ -1784,19 +1882,20 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
// when every (ready) account in the lane is flat. // when every (ready) account in the lane is flat.
const openPositions = countOpenPositions(inLane(ready, lane)); const openPositions = countOpenPositions(inLane(ready, lane));
if (openPositions > 0) { if (openPositions > 0) {
outcomes.set(lane, 'positions-open'); record(lane, 'positions-open', `${openPositions} position(s) still open`);
console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`); console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`);
continue; continue;
} }
const { outcome, results } = await runTradeBatch(state.action, state.symbol, lane); const { outcome, results, detail, reasons } = await runTradeBatch(state.action, state.symbol, lane);
outcomes.set(lane, outcome); record(lane, outcome, detail ?? outcome, reasons);
filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length; filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length;
} catch (err) { } catch (err) {
outcomes.set(lane, `error: ${errorMessage(err)}`); record(lane, `error: ${errorMessage(err)}`, errorMessage(err));
console.error(`[scheduler] ${laneName(lane)}: tick error: ${errorMessage(err)}`); console.error(`[scheduler] ${laneName(lane)}: tick error: ${errorMessage(err)}`);
} }
} }
rt.laneOutcomes = outcomes; rt.laneOutcomes = outcomes;
rt.laneTicks = ticks;
rt.lastTickOutcome = overallOutcome([...outcomes.values()]); rt.lastTickOutcome = overallOutcome([...outcomes.values()]);
if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`); if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`);
@@ -1880,6 +1979,8 @@ export function getSchedulerStatus() {
intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10), intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10),
stopAfterAll: state.stopAfterAll, stopAfterAll: state.stopAfterAll,
sessionOpenDelayMin: todaysSessionOpenDelay(), sessionOpenDelayMin: todaysSessionOpenDelay(),
// A stopped scheduler keeps its last ticks, each with its time.
lastTicks: [...(runtime().laneTicks?.values() ?? [])],
}; };
} }
+7 -12
View File
@@ -227,21 +227,16 @@ export function pickByChance(chances: { symbol: string; chance: number }[], rand
return chances.length > 0 ? chances[chances.length - 1].symbol : null; return chances.length > 0 ? chances[chances.length - 1].symbol : null;
} }
/** What every futures firm's drain and extra-day legs trade. */
export const HOUSEKEEPING_ROOT = 'MNQ';
/** /**
* What a firm's drain and extra-day legs trade — tiny, so a micro: MNQ when * What a firm's drain and extra-day legs trade: always MNQ — tiny targets
* NQ or MNQ is listed (as always), else the micro of a listed full-size * belong on a micro, whatever the firm lists for its full-size legs. null
* contract, else a listed micro, else the listed root with the smallest tick * for an empty list: that firm trades nothing.
* value. null for an empty list.
*/ */
export function housekeepingRoot(listed: readonly string[]): string | null { export function housekeepingRoot(listed: readonly string[]): string | null {
const roots = listed.filter((r) => CME_SPECS[r]); return listed.some((r) => CME_SPECS[r]) ? HOUSEKEEPING_ROOT : null;
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}$/; const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/;
+2
View File
@@ -23,6 +23,7 @@ import { futuresRootsHere } from './instruments';
import { queueRun } from './queue-run'; import { queueRun } from './queue-run';
import { pushBackup } from './backup-sync'; import { pushBackup } from './backup-sync';
import { getBuildVersion } from './version'; import { getBuildVersion } from './version';
import { updaterStatus } from './updater-status';
import type { import type {
Command, Command,
InstanceCapabilities, InstanceCapabilities,
@@ -185,6 +186,7 @@ export function pushReport(firms: unknown[], scheduler: unknown): boolean {
scheduler: scheduler as never, scheduler: scheduler as never,
capabilities: collectCapabilities(), capabilities: collectCapabilities(),
version: getBuildVersion() ?? undefined, version: getBuildVersion() ?? undefined,
updater: updaterStatus(),
}); });
} }
+65 -3
View File
@@ -119,6 +119,23 @@ export interface FirmStats {
/** Futures firms: each CME root's entry cost, movement, expected hold and /** Futures firms: each CME root's entry cost, movement, expected hold and
* chance in the Auto draw right now. Optional. */ * chance in the Auto draw right now. Optional. */
instrumentStats?: InstrumentStats; 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. */ /** A futures firm's instruments as the Auto draw sees them. */
@@ -191,9 +208,54 @@ export interface SchedulerState {
lastRun: string | null; lastRun: string | null;
intervalSeconds: number; intervalSeconds: number;
stopAfterAll?: boolean; stopAfterAll?: boolean;
/** Today's random session-open delay in minutes (1–20), drawn once per /** Today's random session-open delay in minutes (0–180, after 5:45 PM
* Central-time day. Optional so an older master simply ignores it. */ * CT), drawn once per Central-time day. Optional so an older master
* simply ignores it. */
sessionOpenDelayMin?: number; sessionOpenDelayMin?: number;
/** What the last tick did in each lane, and why accounts sat it out.
* Optional so an older master simply ignores it. */
lastTicks?: LaneTick[];
}
/** Accounts that sat a tick out for one reason (dollar amounts folded, so
* like reasons group), with a few of their names. */
export interface TickReason {
reason: string;
count: number;
accounts: string[];
}
/** What the scheduler's last tick did in one lane. */
export interface LaneTick {
/** "futures", or the every-day firm's name. */
lane: string;
/** ISO time of the tick. */
at: string;
/** fired, no-eligible, positions-open, closed, holding-for-sync,
* skipped-in-flight, or "error: …". */
outcome: string;
/** One line on what happened: what fired, or what stopped it. */
detail: string;
/** Why the lane's accounts weren't in the batch, most common first. */
reasons?: TickReason[];
}
/** The auto-updater on this machine (scripts/update-check.mjs), read from
* the files it keeps and from git. */
export interface UpdaterStatus {
/** Its last check (it runs every minute under PM2); null = never ran here. */
lastCheckAt: string | null;
/** What that check found, e.g. "up to date at 1a2b3c4d" or "ERROR: …". */
lastCheck: string | null;
/** An update that failed and waits to retry: at which step, how often. */
failed: { target: string; step: string; at: string; attempts: number } | null;
/** The commit the running build was made from. */
builtCommit: string | null;
/** Commits on origin this checkout doesn't have, as of the updater's
* last fetch; null when git can't say. */
behind: number | null;
/** Tracked files edited on this machine — they can block the pull. */
localChanges: string[];
} }
/** The build this instance is running — its checkout's HEAD commit. The /** The build this instance is running — its checkout's HEAD commit. The
@@ -381,5 +443,5 @@ export type MasterMessage =
export type InstanceMessage = export type InstanceMessage =
| { type: 'hello'; instance: string; id?: string; capabilities: InstanceCapabilities; version?: BuildVersion } | { type: 'hello'; instance: string; id?: string; capabilities: InstanceCapabilities; version?: BuildVersion }
| { type: 'report'; firms: FirmStats[]; scheduler: SchedulerState; capabilities?: InstanceCapabilities; version?: BuildVersion } | { type: 'report'; firms: FirmStats[]; scheduler: SchedulerState; capabilities?: InstanceCapabilities; version?: BuildVersion; updater?: UpdaterStatus }
| { type: 'ack'; id: string; ok: boolean; result?: unknown; error?: string }; | { type: 'ack'; id: string; ok: boolean; result?: unknown; error?: string };
+4
View File
@@ -14,6 +14,8 @@
import { getSetting, loadEndedCycleStart } from './db'; import { getSetting, loadEndedCycleStart } from './db';
import { pushReport } from './master-link'; import { pushReport } from './master-link';
import { getBuildVersion } from './version'; import { getBuildVersion } from './version';
import { tradeMixFor } from './trade-mix';
import { updaterStatus } from './updater-status';
import { getClients } from './clients'; import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog'; import { getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver'; import { getAccountConfig } from './account-type-resolver';
@@ -391,6 +393,7 @@ function collectFirmStats() {
accountTypes, accountTypes,
...symbolStatsFor(f, client, rawToday), ...symbolStatsFor(f, client, rawToday),
...instrumentStatsBlock(f, client, rawToday), ...instrumentStatsBlock(f, client, rawToday),
mix: tradeMixFor(f, client),
}; };
}); });
} }
@@ -505,6 +508,7 @@ export async function sendReport(): Promise<void> {
firms, firms,
scheduler, scheduler,
version: getBuildVersion() ?? undefined, version: getBuildVersion() ?? undefined,
updater: updaterStatus(),
}), }),
}); });
} catch { } catch {
+6
View File
@@ -1758,6 +1758,12 @@ export class TopstepXClient implements PlatformClient {
// ── lifecycle ──────────────────────────────────────────────────────────── // ── 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 /** The session token, for the live quote feed's market hub
* (lib/live-quotes.ts); null while logged out. */ * (lib/live-quotes.ts); null while logged out. */
public quoteToken(): string | null { 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 })),
};
}
+7
View File
@@ -1563,6 +1563,13 @@ function centralClock(now: Date): { day: string; minutes: number; resetMinutes:
* waiting for them to go flat, and skips the open's widest spreads. * waiting for them to go flat, and skips the open's widest spreads.
*/ */
export const CME_EVENING_OPEN_MIN = 17 * 60 + 45; export const CME_EVENING_OPEN_MIN = 17 * 60 + 45;
/** An account's reason for sitting a tick out, with its dollar amounts and
* "=number" values folded, so like reasons count together on the master:
* "day done ($12 left of $300)" and "day done ($40 left of $500)" are one. */
export function foldReason(reason: string): string {
return reason.replace(/\$-?[\d,]+(?:\.\d+)?/g, '$…').replace(/=-?\d+(?:\.\d+)?/g, '=…');
}
/** Every-day firms' roll guard ends here (plus the day's delay): they have /** Every-day firms' roll guard ends here (plus the day's delay): they have
* no Tradovate roll to wait for. */ * no Tradovate roll to wait for. */
const EVERY_DAY_OPEN_MIN = 17 * 60 + 5; const EVERY_DAY_OPEN_MIN = 17 * 60 + 5;
+78
View File
@@ -0,0 +1,78 @@
/**
* The auto-updater's state on this machine (scripts/update-check.mjs), for
* the master: its last check, an update that failed and waits to retry, how
* far the checkout is behind origin, and tracked files edited by hand — which
* stop the fast-forward pull. Read from the files the updater keeps and from
* git, and cached: the reporter asks every 30 seconds.
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import type { UpdaterStatus } from './master-protocol';
const CACHE_MS = 2 * 60_000;
const BRANCH = process.env.AUTOFIRMER_BRANCH ?? 'master';
// Per process: instrumentation.ts and the route handlers each load this module.
const g = globalThis as typeof globalThis & { __updaterStatus?: { at: number; status: UpdaterStatus } };
function readScript(file: string): string | null {
try {
return readFileSync(path.join(process.cwd(), 'scripts', file), 'utf8');
} catch {
return null;
}
}
function git(args: string[]): string | null {
try {
return execFileSync('git', args, {
cwd: process.cwd(),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5_000,
}).trimEnd();
} catch {
return null;
}
}
function readFailure(): UpdaterStatus['failed'] {
try {
const f = JSON.parse(readScript('.update-failed') ?? 'null') as Record<string, unknown> | null;
if (!f || typeof f.target !== 'string') return null;
const at = Number(f.at);
return {
target: f.target.slice(0, 12),
step: String(f.step ?? '').slice(0, 40),
at: Number.isFinite(at) ? new Date(at).toISOString() : '',
attempts: Number(f.count) || 1,
};
} catch {
return null;
}
}
export function updaterStatus(now = Date.now()): UpdaterStatus {
const cached = g.__updaterStatus;
if (cached && now - cached.at < CACHE_MS) return cached.status;
// "<ISO time> <status>", rewritten on every check.
const last = /^(\S+)\s+(.*)$/.exec(readScript('.last-check')?.trim() ?? '');
const behind = git(['rev-list', '--count', `HEAD..origin/${BRANCH}`]);
const changed = git(['status', '--porcelain', '--untracked-files=no']);
const status: UpdaterStatus = {
lastCheckAt: last ? last[1] : null,
lastCheck: last ? last[2].slice(0, 200) : null,
failed: readFailure(),
builtCommit: readScript('.built-commit')?.trim().slice(0, 12) || null,
behind: behind !== null && /^\d+$/.test(behind) ? Number(behind) : null,
// "XY path" per line; the status column can start with a space.
localChanges: changed
? changed.split('\n').map((line) => line.slice(3).trim()).filter(Boolean).slice(0, 10)
: [],
};
g.__updaterStatus = { at: now, status };
return status;
}
+5 -4
View File
@@ -140,12 +140,13 @@ test('10k seeded draws land on the chances', () => {
assert.equal(pickByChance([], 0.5), null); assert.equal(pickByChance([], 0.5), null);
}); });
test('drain and extra-day legs: a micro of what is listed', () => { test('drain and extra-day legs: always MNQ', () => {
// Always MNQ, whatever the firm lists; nothing for a firm that lists nothing.
assert.equal(housekeepingRoot(['NQ', 'GC', 'CL', '6E']), 'MNQ'); assert.equal(housekeepingRoot(['NQ', 'GC', 'CL', '6E']), 'MNQ');
assert.equal(housekeepingRoot(['MNQ']), 'MNQ'); assert.equal(housekeepingRoot(['MNQ']), 'MNQ');
assert.equal(housekeepingRoot(['GC', 'CL']), 'MGC'); assert.equal(housekeepingRoot(['GC', 'CL']), 'MNQ');
assert.equal(housekeepingRoot(['6E', 'ZN']), '6E'); // no micro: the smallest tick value assert.equal(housekeepingRoot(['6E', 'ZN']), 'MNQ');
assert.equal(housekeepingRoot(['MCL', 'ZB']), 'MCL'); assert.equal(housekeepingRoot(['MCL', 'ZB']), 'MNQ');
assert.equal(housekeepingRoot([]), null); assert.equal(housekeepingRoot([]), null);
}); });
+10 -1
View File
@@ -13,7 +13,7 @@ import {
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS, botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
currentTradeDateCT, currentTradeDateCT, foldReason,
} from '../lib/trading-logic.ts'; } from '../lib/trading-logic.ts';
// Deterministic PRNG so a failure reproduces. // Deterministic PRNG so a failure reproduces.
@@ -729,3 +729,12 @@ test('l2Book best bid/ask', () => {
assert.equal(empty.bid, undefined); assert.equal(empty.bid, undefined);
assert.equal(empty.ask, undefined); assert.equal(empty.ask, undefined);
}); });
test('tick reasons fold their amounts, so like reasons count together', () => {
assert.equal(foldReason('day done ($12.5 left of $300)'), 'day done ($… left of $…)');
assert.equal(foldReason('day done ($40 left of $1,500)'), 'day done ($… left of $…)');
assert.equal(foldReason('already traded today (realizedPnL=-123.45)'), 'already traded today (realizedPnL=…)');
assert.equal(foldReason('daily loss limit: $294.7 of $3000 left — day booked'), 'daily loss limit: $… of $… left — day booked');
assert.equal(foldReason("waiting for the broker to roll the account's day"), "waiting for the broker to roll the account's day");
assert.equal(foldReason('leg cap reached (5 today)'), 'leg cap reached (5 today)');
});