diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 93b2554..0a8add8 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -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, diff --git a/app/page.tsx b/app/page.tsx index b9d0612..cf204cb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 }: {
{dead ? Dead - : !account.active - ? Hit DLL - : account.positionDirection - ? {account.positionDirection === 'long' ? 'Long' : 'Short'} - : account.personalDll - ? Personal DLL - : account.targetHit - ? Target Hit - : Flat} + : account.completed + ? Completed + : !account.active + ? Hit DLL + : account.positionDirection + ? {account.positionDirection === 'long' ? 'Long' : 'Short'} + : account.personalDll + ? Personal DLL + : account.targetHit + ? Target Hit + : Flat} ; /** 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; + * 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; /** 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[]; diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 2364770..48bc40e 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -82,3 +82,20 @@ export interface PlatformClient { disconnect(): void; onFill(orderId: number, callback: (fill: Record) => 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; +} diff --git a/lib/reporter.ts b/lib/reporter.ts index a98b636..091a80e 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -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, }; }); } diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 9b29189..a42f96c 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -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, diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index d03f352..45db33f 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -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); diff --git a/types.ts b/types.ts index 5b84029..3a354a9 100644 --- a/types.ts +++ b/types.ts @@ -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). */