Reporter: name the accounts whose final day completed their cycle
cycleCompletion() says whether today's P&L finished the current cycle (profit target, consistency and minimum days all met) — projectPerfectPath's completion probe run without and with today. The reporter counts those as `completed` (a subset of targetHit) and sends their names, so the master can keep an account a firm erases after it passes. Checked ahead of the lockout branch: a firm locking a passed account is no DLL loss. The local dashboard shows them with a purple Completed pill. Because the master keeps a completion for good, doubtful reads stay 'none': weekends, a Monday with a weekend-dated row, the first minutes after the 5 PM roll, and history that hasn't freshly loaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a47a86a59c
commit
acb8879575
+21
-1
@@ -3,8 +3,9 @@ import { loadDailyPnL } from '@/lib/db';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
||||
import { historyFresh } from '@/lib/platform-client';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, currentTradeDateCT,
|
||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
||||
BIG_DAY_THRESHOLD, FIRST_DAY_STOP, LEG_COMPLETION_EPSILON, MIN_DAY_STOP_MULT,
|
||||
} from '@/lib/trading-logic';
|
||||
|
||||
@@ -174,6 +175,24 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
// Today finished the cycle — the reporter's check and guards,
|
||||
// so this pill and the master's purple count agree.
|
||||
let completed = false;
|
||||
const historyLoaded = client.dailyPnL[acc.id] !== undefined
|
||||
&& client.fundTransactions?.[acc.id] !== undefined;
|
||||
if (cfg && effective && historyLoaded && historyFresh(client, acc) && !client.positions[acc.id]) {
|
||||
completed = cycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: tradeDate,
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
effective,
|
||||
equityProfit: cash.amount - cfg.accountSize,
|
||||
splitEnabled: cfg.splitEnabled,
|
||||
}) === 'today';
|
||||
}
|
||||
|
||||
return {
|
||||
id: acc.id,
|
||||
name: acc.name,
|
||||
@@ -195,6 +214,7 @@ export async function GET() {
|
||||
autoLiqThreshold,
|
||||
totalProfit,
|
||||
targetHit,
|
||||
completed,
|
||||
stage,
|
||||
// Current stage is a drain stage — dashboards label it "Stage N – Drain".
|
||||
stageDrain: effective?.drain ?? false,
|
||||
|
||||
+13
-10
@@ -10,9 +10,10 @@ type SortDir = 'asc' | 'desc';
|
||||
|
||||
function statusRank(account: AccountState): number {
|
||||
if (isAccountDead(account)) return 0;
|
||||
if (account.completed) return 5; // Completed — finished its cycle today
|
||||
if (!account.active) return 1;
|
||||
if (!account.positionDirection) {
|
||||
if (account.targetHit) return 4; // Target Hit — most accomplished
|
||||
if (account.targetHit) return 4; // Target Hit — next to Completed
|
||||
return 2; // Flat
|
||||
}
|
||||
return 3; // In Trade
|
||||
@@ -384,15 +385,17 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
<div className="flex items-center justify-between">
|
||||
{dead
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-800 text-white">Dead</span>
|
||||
: !account.active
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Hit DLL</span>
|
||||
: account.positionDirection
|
||||
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
|
||||
: account.personalDll
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop — make-back rides next session">Personal DLL</span>
|
||||
: account.targetHit
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
|
||||
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
|
||||
: account.completed
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-700" title="Finished its cycle today — target, consistency and min days met">Completed</span>
|
||||
: !account.active
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Hit DLL</span>
|
||||
: account.positionDirection
|
||||
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
|
||||
: account.personalDll
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop — make-back rides next session">Personal DLL</span>
|
||||
: account.targetHit
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
|
||||
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
|
||||
<Link
|
||||
href={`/accounts/${account.id}`}
|
||||
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
|
||||
|
||||
+17
-3
@@ -25,6 +25,17 @@ export interface AccountTypeStats {
|
||||
/** Of the finished accounts, how many booked the day via their own
|
||||
* obfuscation stop (personal DLL) rather than a genuine loss. */
|
||||
personalDll?: number;
|
||||
/** Of targetHit: accounts whose day finished the current cycle — profit
|
||||
* target, consistency and minimum days all met once today's P&L booked
|
||||
* (the final day). Optional: older instances omit it. */
|
||||
completed?: number;
|
||||
/** The accounts behind `completed`, by name, so the master can keep them
|
||||
* after a firm erases a passed account. date = the trade date it
|
||||
* completed on; stage = 1 + withdrawals taken (its meaning depends on the
|
||||
* account type); kind: 'pass' = an eval passing, 'payout' = a funded
|
||||
* cycle ready to withdraw, 'drain' = a drain stage's days booked.
|
||||
* Optional: older instances omit it. */
|
||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain' }[];
|
||||
/** Live accounts by trading days completed toward the current target:
|
||||
* {daysTraded: accountCount}. Feeds the master's payout calendar, so
|
||||
* accounts that are dead, DLL-locked, or in the red are left out — the
|
||||
@@ -33,9 +44,10 @@ export interface AccountTypeStats {
|
||||
daysHist?: Record<number, number>;
|
||||
/** Live accounts by the trading day they are on today (1 = first day of
|
||||
* the current cycle, counting traded days before today's trade date),
|
||||
* with how many of them hit today's target. Lets the master show
|
||||
* "Day 1 3/5 · Day 2 1/4". Optional: older instances omit it. */
|
||||
dayStats?: Record<number, { total: number; targetHit: number }>;
|
||||
* with how many of them hit today's target; completed = how many of those
|
||||
* hits finished the cycle. Lets the master show "Day 1 3/5 · Day 2 1/4".
|
||||
* Optional: older instances omit it. */
|
||||
dayStats?: Record<number, { total: number; targetHit: number; completed?: number }>;
|
||||
/** One entry per live account (negative ones included, dead ones not):
|
||||
* its actual progress toward the current target, so the master can
|
||||
* simulate the real required profit instead of assuming the winning
|
||||
@@ -75,6 +87,8 @@ export interface FirmStats {
|
||||
partial?: number;
|
||||
loss?: number;
|
||||
personalDll?: number;
|
||||
/** Of targetHit: accounts that finished their cycle today. Optional. */
|
||||
completed?: number;
|
||||
/** Per account type, summing to the firm's own totals. Optional so a master
|
||||
* on an older build simply ignores it. */
|
||||
accountTypes?: AccountTypeStats[];
|
||||
|
||||
@@ -82,3 +82,20 @@ export interface PlatformClient {
|
||||
disconnect(): void;
|
||||
onFill(orderId: number, callback: (fill: Record<string, unknown>) => void): void;
|
||||
}
|
||||
|
||||
/** lastFetchErrors note for a Tradovate account whose Cash History is
|
||||
* unavailable (passed accounts) and whose rows came from the Fills report —
|
||||
* a fresh fetch, not a failure. */
|
||||
export const FILLS_FALLBACK_NOTE = 'cash history unavailable (using fills fallback)';
|
||||
|
||||
/**
|
||||
* Can this account's daily P&L be trusted for a verdict the master keeps
|
||||
* for good (cycleCompletion 'today')? Not while a fetch run is in flight —
|
||||
* at startup Tradovate serves the DB cache before each account's fetch —
|
||||
* and not when the account's last fetch failed and left cached rows behind.
|
||||
*/
|
||||
export function historyFresh(client: PlatformClient, account: PlatformAccount): boolean {
|
||||
if (!client.fetchDaysComplete) return false;
|
||||
const error = client.lastFetchErrors?.[account.name];
|
||||
return !error || error === FILLS_FALLBACK_NOTE;
|
||||
}
|
||||
|
||||
+57
-4
@@ -18,8 +18,9 @@ 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 { combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic';
|
||||
import type { AccountTypeStats } from './master-protocol';
|
||||
import { historyFresh } from './platform-client';
|
||||
|
||||
function collectFirmStats() {
|
||||
// Only firms this machine has credentials for. A firm it cannot log into
|
||||
@@ -27,13 +28,16 @@ function collectFirmStats() {
|
||||
// the master's table with rows for firms it does not run.
|
||||
const firms = getTradableFirms();
|
||||
const clients = getClients();
|
||||
// One raw trade date for the whole report: the combined views below roll
|
||||
// it forward, the completion check needs it as is.
|
||||
const rawToday = currentTradeDateCT();
|
||||
|
||||
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: [],
|
||||
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0, accountTypes: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +48,7 @@ function collectFirmStats() {
|
||||
let partialCount = 0;
|
||||
let lossCount = 0;
|
||||
let personalDllCount = 0;
|
||||
let completedCount = 0;
|
||||
|
||||
// Per account type, so the master can say what the accounts still to
|
||||
// trade actually are. Keyed by account type id; unassigned accounts
|
||||
@@ -91,7 +96,7 @@ function collectFirmStats() {
|
||||
// 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,
|
||||
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], rawToday, cash.realizedPnL,
|
||||
) : null;
|
||||
|
||||
// Stage-resolved config, shared by the payout state and the
|
||||
@@ -208,6 +213,54 @@ function collectFirmStats() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Did today finish the cycle? Checked ahead of the lockout branch:
|
||||
// firms lock a passed account (Tradovate liquidate-only, TopstepX
|
||||
// canTrade=false), and a pass is no loss. Only once the account's
|
||||
// history has loaded — right after a restart the missing rows and
|
||||
// withdrawals would read as a fresh stage-1 cycle — and a 'today'
|
||||
// only on freshly fetched rows, since the master keeps it.
|
||||
const historyLoaded = client.dailyPnL[acc.id] !== undefined
|
||||
&& client.fundTransactions?.[acc.id] !== undefined;
|
||||
if (cfg && combined && effective && historyLoaded) {
|
||||
const completion = cycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: rawToday,
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
effective,
|
||||
equityProfit: cash.amount - cfg.accountSize,
|
||||
splitEnabled: cfg.splitEnabled,
|
||||
});
|
||||
if (completion === 'today' && historyFresh(client, acc)) {
|
||||
accountsTraded++;
|
||||
slice.accountsTraded++;
|
||||
targetHitCount++;
|
||||
slice.targetHit = (slice.targetHit ?? 0) + 1;
|
||||
completedCount++;
|
||||
slice.completed = (slice.completed ?? 0) + 1;
|
||||
if (dayEntry) {
|
||||
dayEntry.targetHit++;
|
||||
dayEntry.completed = (dayEntry.completed ?? 0) + 1;
|
||||
}
|
||||
// Same test as projectPerfectPath's `loops`: a cycle that
|
||||
// keeps going pays out, one that stops just passes.
|
||||
const kind = effective.drain ? 'drain'
|
||||
: (cfg.payoutable === true || cfg.withdrawalStages.length > 0) ? 'payout' : 'pass';
|
||||
(slice.completedAccounts ??= []).push({
|
||||
name: acc.name,
|
||||
date: combined.tradeDate,
|
||||
stage: 1 + fundTxns.filter((f) => f.amount < 0).length,
|
||||
kind,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Passed on an earlier day and locked since: nothing traded
|
||||
// today, so it reads as untraded — like a finished account
|
||||
// that's still active — not as a DLL loss every day after.
|
||||
if (completion === 'before' && !acc.active && combined.realizedPnL === 0) 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).
|
||||
@@ -269,7 +322,7 @@ function collectFirmStats() {
|
||||
return {
|
||||
firm: f.name, totalAccounts, accountsTraded, inTrade,
|
||||
targetHit: targetHitCount, partial: partialCount, loss: lossCount,
|
||||
personalDll: personalDllCount, accountTypes,
|
||||
personalDll: personalDllCount, completed: completedCount, accountTypes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -626,6 +626,80 @@ export function projectPerfectPath(opts: {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Where the current cycle stands against today's P&L: 'today' = today's
|
||||
* trading finished it (the final day), 'before' = it already stood complete
|
||||
* without today, 'none' = profit or days still owed. */
|
||||
export type CycleCompletion = 'none' | 'today' | 'before';
|
||||
|
||||
/** How long a new trade date waits before it can read 'today': the platforms
|
||||
* reset realizedPnL on their own clock (a Tradovate cashBalance event, the
|
||||
* TopstepX 60s sync), not at 5 PM sharp, and until then the last session's
|
||||
* number — already booked under its own date — would count as a second day. */
|
||||
const ROLL_SETTLE_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Did today's trading finish the account's current cycle — profit target,
|
||||
* consistency and minimum days all met? projectPerfectPath's "already
|
||||
* complete" probe, run once without today's P&L and once with it, so only
|
||||
* the final day reads 'today'.
|
||||
*
|
||||
* The master keeps a 'today' permanently, so every doubtful case reads
|
||||
* 'none' instead: a weekend (realizedPnL is the last session's stale number,
|
||||
* already booked under its own date), a Monday with a weekend-dated row — the
|
||||
* live session filed under a weekend date, which would count it twice — and
|
||||
* the first minutes after the 5 PM roll. 'before' skips those guards: it only
|
||||
* keeps a locked, untraded pass out of the reporter's losses.
|
||||
*/
|
||||
export function cycleCompletion(opts: {
|
||||
accountId: number;
|
||||
/** currentTradeDateCT() — NOT the combined date, which rolls a weekend
|
||||
* forward to Monday. */
|
||||
rawTradeDate: string;
|
||||
/** Current-cycle daily P&L as the client holds it (client.dailyPnL). */
|
||||
cycleDailyPnL: { date: string; pnl: number }[];
|
||||
rawRealizedPnL: number;
|
||||
combineCmeHolidays: boolean;
|
||||
effective: EffectiveConfig;
|
||||
/** amount − accountSize — today's P&L included. */
|
||||
equityProfit: number;
|
||||
splitEnabled: boolean;
|
||||
/** Defaults to the current time. */
|
||||
now?: Date;
|
||||
}): CycleCompletion {
|
||||
const { accountId, rawTradeDate, effective: eff } = opts;
|
||||
const view = combineCmeSessions(opts.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
|
||||
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||||
// Nothing left to trade AND the days booked: with no min-day amount the
|
||||
// plan coasts at 0 while days are still owed, so count them too.
|
||||
const owesNothing = (rows: { date: string; pnl: number }[], date: string, realized: number): boolean => {
|
||||
const plan = computeDayPlan(
|
||||
accountId, date, eff.profitTarget, eff.consistency, rows, eff.minDayPnL,
|
||||
eff.minTradingDays, opts.equityProfit, realized, opts.splitEnabled, eff.drain,
|
||||
);
|
||||
const qualifying = rows.filter((d) => d.date !== date
|
||||
&& (eff.minDayPnL > 0 ? d.pnl >= eff.minDayPnL : d.pnl !== 0)).length;
|
||||
return plan !== null && plan.dayTarget <= 0 && qualifying >= eff.minTradingDays;
|
||||
};
|
||||
|
||||
if (owesNothing(prior, view.tradeDate, view.realizedPnL)) return 'before';
|
||||
|
||||
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||||
const dow = day.getUTCDay();
|
||||
if (dow === 0 || dow === 6) return 'none';
|
||||
if (dow === 1) {
|
||||
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
|
||||
const saturday = fmtUTC(new Date(day.getTime() - 2 * 24 * 60 * 60 * 1000));
|
||||
if (opts.cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return 'none';
|
||||
}
|
||||
// Settle window: a few minutes ago it was still an earlier trade date.
|
||||
const now = opts.now ?? new Date();
|
||||
if (currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate) return 'none';
|
||||
|
||||
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
||||
if (view.realizedPnL !== 0 && owesNothing(withToday, nextTradingDate(view.tradeDate), 0)) return 'today';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** Dollar-per-point value for common futures products. */
|
||||
export const POINT_VALUES: { [symbol: string]: number } = {
|
||||
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { POINT_VALUES } from './trading-logic';
|
||||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions } from './db';
|
||||
import { brokerWebSocket } from './outbound-proxy';
|
||||
import { FILLS_FALLBACK_NOTE } from './platform-client';
|
||||
|
||||
/**
|
||||
* Entity types Tradovate sends over the user-sync socket. Keep this as the
|
||||
@@ -689,7 +690,7 @@ export class TradovateClient {
|
||||
this.dailyPnL[account.id] = active;
|
||||
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
||||
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
||||
this.lastFetchErrors[account.name] = 'cash history unavailable (using fills fallback)';
|
||||
this.lastFetchErrors[account.name] = FILLS_FALLBACK_NOTE;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[fetchDaysTraded] both reports failed for ${account.name}:`, msg);
|
||||
|
||||
@@ -66,6 +66,10 @@ export interface AccountState {
|
||||
/** True when a stop-enabled account booked its day red via its own stop —
|
||||
* the personal DLL. It stands down until the next session's make-back. */
|
||||
personalDll: boolean;
|
||||
/** True when today's trading finished the current cycle — profit target,
|
||||
* consistency and minimum days all met (cycleCompletion 'today'). Only
|
||||
* the final day; the firm may lock or remove the account afterwards. */
|
||||
completed: boolean;
|
||||
/** Current stage = 1 + number of withdrawals (negative fund transactions). Stage 1 = fresh account. */
|
||||
stage: number;
|
||||
/** True when the current stage is a drain stage (spam-withdraw mode). */
|
||||
|
||||
Reference in New Issue
Block a user