The version is the checkout's HEAD commit (short hash + commit time), read once at startup — updates arrive as `git pull`, so there is no release number to bump. It rides on the socket hello, socket reports, and the HTTP report fallback; the field is optional in the protocol so older masters ignore it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
316 lines
15 KiB
TypeScript
316 lines
15 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 { getBuildVersion } from './version';
|
||
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, projectPerfectPath, 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);
|
||
|
||
// The account's future payout chain on the perfect path —
|
||
// dates, dynamic amounts (withdrawal % of balance, capped,
|
||
// × profit split) and cumulative fair-walk reach probability.
|
||
// The master renders these directly instead of running its
|
||
// own Monte-Carlo for this account.
|
||
let futurePayouts: NonNullable<AccountTypeStats['payoutStates']>[number]['futurePayouts'];
|
||
try {
|
||
futurePayouts = projectPerfectPath({
|
||
accountId: acc.id,
|
||
cfg,
|
||
cycleDailyPnL: client.dailyPnL[acc.id] ?? [],
|
||
fundTransactions: fundTxns,
|
||
balance: cash.amount,
|
||
realizedPnL: cash.realizedPnL,
|
||
payout: {
|
||
withdrawalPct: cfg.withdrawalPct,
|
||
payoutCap: cfg.payoutCap,
|
||
profitSplit: cfg.profitSplit,
|
||
// Same default chain the master's calendar always
|
||
// used: configured trailing drawdown, else the
|
||
// max-loss size, else $2,000.
|
||
drawdown: (cfg.trailingDrawdown ?? 0) > 0
|
||
? cfg.trailingDrawdown!
|
||
: Math.abs(cfg.maxLoss) > 0 ? Math.abs(cfg.maxLoss) : 2000,
|
||
startHigh: highWater,
|
||
// Tradovate reports the account's REAL liquidation
|
||
// floor — anchor the trail there for exact room.
|
||
startThreshold: autoLiqThreshold > 0
|
||
? r2(autoLiqThreshold - cfg.accountSize)
|
||
: undefined,
|
||
},
|
||
})
|
||
.filter((p) => p.withdrawal !== undefined || p.payoutReady)
|
||
.slice(0, 6)
|
||
.map((p) => ({
|
||
date: p.date,
|
||
gross: p.gross ?? 0,
|
||
net: p.net ?? 0,
|
||
prob: Math.round(p.prob * 10000) / 10000,
|
||
stage: p.stage,
|
||
...(p.drain ? { drain: true } : {}),
|
||
}));
|
||
if (futurePayouts.length === 0) futurePayouts = undefined;
|
||
} catch (err) {
|
||
console.error(`[reporter] payout projection failed for account ${acc.id}`, err);
|
||
}
|
||
|
||
(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,
|
||
...(futurePayouts ? { futurePayouts } : {}),
|
||
});
|
||
}
|
||
|
||
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,
|
||
version: getBuildVersion() ?? undefined,
|
||
}),
|
||
});
|
||
} 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');
|
||
}
|