Files
autofirmer-expanded/lib/reporter.ts
T
Brandon LiandClaude Fable 5 50a5eb4713 Add TopstepX platform support, drain withdrawal stages, and perfect-path calendar projection
- TopstepXClient (ProjectX Gateway): API-key auth, SignalR user hub,
  Trade/search daily P&L, emulated OCO exits, books-invariant
  withdrawal detection for $0-start accounts
- Platform-aware client pool, credentials (api_key column), and
  credential form; $0-size account types match by prefix alone
- Drain stages: min-day ($5-$15 randomized) MNQ legs for the
  configured days, then wait for the 50% withdrawal; stage pills
  show 'Stage N – Drain'
- Account calendar projects the perfect path: future days book their
  planned targets, simulated 50% withdrawals, payout marker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 16:25:52 -05:00

265 lines
12 KiB
TypeScript

/**
* Reporter module — pushes aggregated state to the master dashboard.
*
* Every 30 seconds, collects firm-level stats + scheduler state and sends them
* to the configured master_dashboard_url. Silently skips if not configured.
*
* Two transports, in that order of preference: the control WebSocket in
* `master-link.ts` when it is up, and the original POST /api/report when it is
* not. The socket already exists for commands, so reusing it saves a request
* per tick — but a master on an older build has no socket to offer, and the
* POST keeps those working unchanged.
*/
import { getSetting } from './db';
import { pushReport } from './master-link';
import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import { getSchedulerStatus } from './auto-trade';
import { combineCmeSessions, computeDayPlan, currentTradeDateCT, LEG_COMPLETION_EPSILON, resolveEffectiveConfig } from './trading-logic';
import type { AccountTypeStats } from './master-protocol';
function collectFirmStats() {
// Only firms this machine has credentials for. A firm it cannot log into
// has nothing to say about, and reporting it as zero accounts would fill
// the master's table with rows for firms it does not run.
const firms = getTradableFirms();
const clients = getClients();
return firms.map((f) => {
const client = clients.get(f.id);
if (!client || client.accountList.length === 0) {
return {
firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
targetHit: 0, partial: 0, loss: 0, personalDll: 0, accountTypes: [],
};
}
let totalAccounts = 0;
let accountsTraded = 0;
let inTrade = 0;
let targetHitCount = 0;
let partialCount = 0;
let lossCount = 0;
let personalDllCount = 0;
// Per account type, so the master can say what the accounts still to
// trade actually are. Keyed by account type id; unassigned accounts
// share the null bucket — they inflate "left to trade" indefinitely,
// which is precisely the thing worth being able to see.
const byType = new Map<string | null, AccountTypeStats>();
const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => {
const key = cfg?.id ?? null;
let entry = byType.get(key);
if (!entry) {
entry = {
accountTypeId: key,
label: cfg?.label ?? 'Unassigned',
accountSize: cfg?.accountSize ?? 0,
totalAccounts: 0,
accountsTraded: 0,
inTrade: 0,
targetHit: 0,
partial: 0,
loss: 0,
};
byType.set(key, entry);
}
return entry;
};
for (const acc of client.accountList) {
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold;
if (isDead) continue;
const cfg = getAccountConfig(acc.id).accountType;
const slice = bucket(cfg);
totalAccounts++;
slice.totalAccounts++;
// Same combined-day view as the trade path: for a combining firm,
// "today" spans the holiday stretch. Computed for every live
// account — including ones mid-trade — so the days-traded
// histogram below covers the whole slice. Accounts in the red or
// DLL-locked stay OUT of the histogram: the payout calendar
// projects from "on the winning path", which they are not.
// (Dead accounts never reach here — the auto-liq check above.)
const combined = cfg ? combineCmeSessions(
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], currentTradeDateCT(), cash.realizedPnL,
) : null;
// Stage-resolved config, shared by the payout state and the
// day-outcome check below — a drain stage swaps in its own
// min-day and drops the profit target.
const fundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = cfg ? resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays,
cfg.targetSameEquity, cfg.withdrawalStages,
client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL,
) : null;
if (cfg && combined && acc.active && cash.amount - cfg.accountSize >= 0) {
const hist = (slice.daysHist ??= {});
hist[combined.daysTraded] = (hist[combined.daysTraded] ?? 0) + 1;
}
// Exact payout state for every live account — negative ones
// included, since the master sims their real required profit.
// client.dailyPnL is already the active-cycle slice, so a
// withdrawal resets days/maxDay/sum here automatically.
if (cfg && combined && effective) {
const r2 = (n: number) => Math.round(n * 100) / 100;
const equityProfit = r2(cash.amount - cfg.accountSize);
const qualifying = effective.minDayPnL === 0
? combined.dailyPnL
: combined.dailyPnL.filter((d) => d.pnl >= effective.minDayPnL);
const sum = r2(combined.dailyPnL.reduce((s, d) => s + d.pnl, 0));
// End-of-day equity-profit high-water mark for this cycle,
// floored at the $0 start: walk the P&L from the cycle base.
let run = r2(equityProfit - sum);
let highWater = Math.max(0, run);
for (const d of combined.dailyPnL) {
run = r2(run + d.pnl);
if (run > highWater) highWater = run;
}
const traded = combined.dailyPnL.filter((d) => d.pnl !== 0);
(slice.payoutStates ??= []).push({
days: qualifying.length,
profit: equityProfit,
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
sum,
high: highWater,
withdrawals: fundTxns.filter((f) => f.amount < 0).length,
target: effective.profitTarget,
consistency: effective.consistency,
minDays: effective.minTradingDays,
lastDay: traded.length > 0 ? traded.reduce((a, b) => (a.date > b.date ? a : b)).date : undefined,
});
}
if (client.positions[acc.id]) {
inTrade++;
accountsTraded++;
slice.inTrade++;
slice.accountsTraded++;
continue;
}
// DLL lockout: Tradovate flips the account to liquidate-only, so
// its day is over at a loss even when the booked realizedPnL
// hasn't caught up yet (or the account has no matching config).
if (!acc.active) {
accountsTraded++;
slice.accountsTraded++;
lossCount++;
slice.loss = (slice.loss ?? 0) + 1;
continue;
}
// Check if target was hit today — on the stage-resolved config, so
// a later stage's (or drain's) day counts the way it was traded.
if (cfg && combined && effective) {
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled, effective.drain
);
const targetHit = !plan ? false :
((plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= effective.minTradingDays) ||
(combined.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON));
if (targetHit || combined.realizedPnL !== 0) {
accountsTraded++;
slice.accountsTraded++;
// A grind-day fill (dayTarget 0) can flag targetHit on a
// negative day — a loss is still a loss, so red wins.
// A red day on a stop-enabled account is its own personal
// DLL, booked on purpose — not a genuine loss. (A firm DLL
// lockout took the !acc.active branch above and stays a
// loss.)
if ((cfg.firstDayStop || cfg.minDayStop) && combined.realizedPnL <= -25) {
personalDllCount++;
slice.personalDll = (slice.personalDll ?? 0) + 1;
} else if (combined.realizedPnL < 0) {
lossCount++;
slice.loss = (slice.loss ?? 0) + 1;
} else if (targetHit) {
targetHitCount++;
slice.targetHit = (slice.targetHit ?? 0) + 1;
} else {
partialCount++;
slice.partial = (slice.partial ?? 0) + 1;
}
}
}
}
// Biggest shortfall first — the account types with the most left to do
// are the ones worth reading, and Unassigned sinks to the bottom only
// when it is genuinely small.
const accountTypes = [...byType.values()].sort((a, b) =>
(b.totalAccounts - b.accountsTraded) - (a.totalAccounts - a.accountsTraded)
|| a.label.localeCompare(b.label));
return {
firm: f.name, totalAccounts, accountsTraded, inTrade,
targetHit: targetHitCount, partial: partialCount, loss: lossCount,
personalDll: personalDllCount, accountTypes,
};
});
}
// HMR-safe global to avoid duplicate intervals
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
/**
* Collect and send one report. Exported so the control link can fire an
* off-cycle one the moment a command has changed something — waiting up to 30s
* to see whether your own button worked is the wrong feedback loop.
*/
export async function sendReport(): Promise<void> {
const url = getSetting('master_dashboard_url');
const instanceName = getSetting('instance_name');
if (!url || !instanceName) return;
try {
const firms = collectFirmStats();
const scheduler = getSchedulerStatus();
if (pushReport(firms, scheduler)) return;
await fetch(new URL('/api/report', url), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instance: instanceName,
// Same identity the socket hello carries, so a rename resolves
// to the same person whichever transport the report takes.
id: getSetting('instance_id') || undefined,
firms,
scheduler,
}),
});
} catch {
// Master might be down — silently continue
}
}
export function startReporter() {
if (_g.__reporterInterval) {
clearInterval(_g.__reporterInterval);
}
_g.__reporterInterval = setInterval(sendReport, 30_000);
// Fire first report after a short delay to let clients sync
setTimeout(sendReport, 10_000);
console.log('[reporter] started — reporting every 30s');
}