Adds daysHist ({daysTraded: accountCount}) to AccountTypeStats, computed
from the same combined-day view the trade path uses and covering every
live account including ones mid-trade. Feeds the master's payout
calendar; an older master simply ignores the field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
208 lines
8.4 KiB
TypeScript
208 lines
8.4 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 } 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, accountTypes: [],
|
|
};
|
|
}
|
|
|
|
let totalAccounts = 0;
|
|
let accountsTraded = 0;
|
|
let inTrade = 0;
|
|
let targetHitCount = 0;
|
|
let partialCount = 0;
|
|
let lossCount = 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.
|
|
const combined = cfg ? combineCmeSessions(
|
|
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], currentTradeDateCT(), cash.realizedPnL,
|
|
) : null;
|
|
if (combined) {
|
|
const hist = (slice.daysHist ??= {});
|
|
hist[combined.daysTraded] = (hist[combined.daysTraded] ?? 0) + 1;
|
|
}
|
|
|
|
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
|
|
if (cfg && combined) {
|
|
const equityProfit = cash.amount - cfg.accountSize;
|
|
const plan = computeDayPlan(
|
|
acc.id, combined.tradeDate, cfg.profitTarget, cfg.consistency,
|
|
combined.dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, combined.realizedPnL,
|
|
cfg.splitEnabled
|
|
);
|
|
const targetHit = !plan ? false :
|
|
((plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= cfg.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.
|
|
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, 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');
|
|
}
|