From a7e01b01384d8be971241955f991d25372c765bd Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Wed, 23 Sep 2026 13:08:16 -0500 Subject: [PATCH] Reporter: catch up completed cycles on reset, report each firm's account list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a fund transaction starts a new cycle (the days revert to 0), the cycle it ended is checked against the completion rule for a week after and named if it stood complete — its final day may never have been reported. Only for a cycle this instance saw begin: the clients now note where the ended cycle started as they move last_fund_date forward. Completions carry the cycle they belong to, and each firm reports its whole account list so the master can tell erased accounts and spot new funded ones. Co-Authored-By: Claude Opus 5.5 --- lib/db.ts | 25 ++++++ lib/master-protocol.ts | 20 ++++- lib/reporter.ts | 82 +++++++++++++++----- lib/topstepx-class.ts | 15 +++- lib/trading-logic.ts | 170 ++++++++++++++++++++++++++++++++++------- lib/tradovate-class.ts | 16 +++- 6 files changed, 279 insertions(+), 49 deletions(-) diff --git a/lib/db.ts b/lib/db.ts index 9a87c73..fb5f265 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -348,6 +348,31 @@ export function loadAccountMeta(accountId: number, key: string): string | null { return row?.value ?? null; } +/** Where the cycle the latest fund transaction ended began, saved by the + * platform client as it moves last_fund_date forward — and only from a cycle + * start this instance saw, so the reporter's catch-up never judges a cycle + * whose older rows it lacks. fundDate = that cycle's fund date ('' for the + * account's first cycle); includesDay = whether that date's own trades were + * the cycle's (a deposit's are; a Tradovate withdrawal's only when it came + * before the day's first trade, which the reporter can't work out again). */ +export function saveEndedCycleStart(accountId: number, start: { fundDate: string; includesDay: boolean }): void { + saveAccountMeta(accountId, 'ended_cycle_start', JSON.stringify(start)); +} + +/** null when this instance never saw the ended cycle start: a new instance + * or a wipe (which clears it with the rest of the meta), or TopstepX's + * first-check lump. */ +export function loadEndedCycleStart(accountId: number): { fundDate: string; includesDay: boolean } | null { + const raw = loadAccountMeta(accountId, 'ended_cycle_start'); + if (raw === null) return null; + try { + const { fundDate, includesDay } = JSON.parse(raw) as { fundDate?: unknown; includesDay?: unknown }; + return typeof fundDate === 'string' && typeof includesDay === 'boolean' ? { fundDate, includesDay } : null; + } catch { + return null; + } +} + /** Deletes every cached row for an account — daily P&L, fund transactions and * meta — so the next report fetch rebuilds it from scratch. Rows older than * the report's 28-day window are gone for good; that is the point (stale diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 7048b75..bbdb4b4 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -33,9 +33,14 @@ export interface AccountTypeStats { * 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' }[]; + * cycle ready to withdraw, 'drain' = a drain stage's days booked; + * cycle = the date of the fund transaction that started the cycle ('' for + * the account's first cycle) — firm + name + cycle identify one + * completed cycle. The list also carries caught-up entries: for up to 7 + * days after a fund transaction starts a new cycle, it includes the cycle + * that just ended if that cycle had met the rule. Caught-up entries are + * not counted in `completed`. Optional: older instances omit it. */ + completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string }[]; /** 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 @@ -89,6 +94,15 @@ export interface FirmStats { personalDll?: number; /** Of targetHit: accounts that finished their cycle today. Optional. */ completed?: number; + /** Every account in the firm's broker list right now, dead ones included, + * so the master can tell a saved account the firm erased from one still + * listed, and spot a newly arrived funded account. size = the account + * type's size (0 without one); payoutable = the type pays out — its + * payoutable flag or withdrawal stages, the test that reports its + * completions as 'payout' — null while the account has no type. Absent + * when the firm isn't synced (unknown, not empty). Optional: older + * instances omit it. */ + accounts?: { name: string; size: number; payoutable: boolean | null }[]; /** 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/reporter.ts b/lib/reporter.ts index 091a80e..b9e3e6c 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -11,15 +11,15 @@ * POST keeps those working unchanged. */ -import { getSetting } from './db'; +import { getSetting, loadEndedCycleStart } 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, cycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic'; -import type { AccountTypeStats } from './master-protocol'; +import { combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic'; +import type { AccountTypeStats, FirmStats } from './master-protocol'; import { historyFresh } from './platform-client'; function collectFirmStats() { @@ -31,13 +31,23 @@ function collectFirmStats() { // 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(); + // projectPerfectPath's `loops` test: a type whose completed cycle keeps + // going pays out (a funded account), one that stops just passes. + const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean => + cfg.payoutable === true || cfg.withdrawalStages.length > 0; + const completionKind = (cfg: Parameters[0], drain: boolean): 'pass' | 'payout' | 'drain' => + (drain ? 'drain' : loops(cfg) ? 'payout' : 'pass'); 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, completed: 0, accountTypes: [], + targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0, + // Empty only once synced — before that, a firm whose accounts + // haven't arrived would read as every saved account erased. + ...(client?.syncComplete ? { accounts: [] } : {}), + accountTypes: [], }; } @@ -49,6 +59,10 @@ function collectFirmStats() { let lossCount = 0; let personalDllCount = 0; let completedCount = 0; + // The firm's whole broker list, dead accounts included — the master + // tells an account the firm erased from one still listed, and spots + // a newly arrived funded account. + const accounts: NonNullable = []; // Per account type, so the master can say what the accounts still to // trade actually are. Keyed by account type id; unassigned accounts @@ -78,11 +92,12 @@ function collectFirmStats() { for (const acc of client.accountList) { const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; + const cfg = getAccountConfig(acc.id).accountType; + accounts.push({ name: acc.name, size: cfg?.accountSize ?? 0, payoutable: cfg ? loops(cfg) : null }); // 0 = no floor; negative floors are reconstructed TopstepX MLLs. const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold; if (isDead) continue; - const cfg = getAccountConfig(acc.id).accountType; const slice = bucket(cfg); totalAccounts++; @@ -205,6 +220,41 @@ function collectFirmStats() { }); } + // 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. + const historyLoaded = client.dailyPnL[acc.id] !== undefined + && client.fundTransactions?.[acc.id] !== undefined; + + // Catch-up for the cycle a fund transaction just ended: if it stood + // complete, name it, since its final day may never have been + // reported (a guard held it back, or this instance was offline). + // Named only — it isn't today's outcome, so no counter moves. Ahead + // of the in-trade check: the new cycle trading doesn't hide it. + if (cfg && effective && historyLoaded && historyFresh(client, acc)) { + const ended = endedCycleCompletion({ + accountId: acc.id, + rawTradeDate: rawToday, + fullDailyPnL: client.fullDailyPnL[acc.id] ?? [], + cycleDailyPnL: client.dailyPnL[acc.id], + fundTransactions: fundTxns, + lastFundDate: client.lastFundDates?.[acc.id] ?? null, + endedCycleStart: loadEndedCycleStart(acc.id), + rawRealizedPnL: cash.realizedPnL, + equityProfit: cash.amount - cfg.accountSize, + cfg, + }); + if (ended) { + (slice.completedAccounts ??= []).push({ + name: acc.name, + date: ended.date, + stage: ended.stage, + kind: completionKind(cfg, ended.drain), + cycle: ended.cycle, + }); + } + } + if (client.positions[acc.id]) { inTrade++; accountsTraded++; @@ -215,12 +265,8 @@ function collectFirmStats() { // 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; + // canTrade=false), and a pass is no loss. A 'today' only on + // freshly fetched rows, since the master keeps it. if (cfg && combined && effective && historyLoaded) { const completion = cycleCompletion({ accountId: acc.id, @@ -243,15 +289,14 @@ function collectFirmStats() { 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, + kind: completionKind(cfg, effective.drain), + // The fund transaction that started this cycle — the + // catch-up names the same cycle by the same key. + cycle: client.lastFundDates?.[acc.id] ?? '', }); continue; } @@ -322,7 +367,10 @@ function collectFirmStats() { return { firm: f.name, totalAccounts, accountsTraded, inTrade, targetHit: targetHitCount, partial: partialCount, loss: lossCount, - personalDll: personalDllCount, completed: completedCount, accountTypes, + personalDll: personalDllCount, completed: completedCount, + // Unknown until the firm has synced, not empty. + ...(client.syncComplete ? { accounts } : {}), + accountTypes, }; }); } diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index be0444d..72f3d12 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -25,7 +25,7 @@ import * as signalR from '@microsoft/signalr'; import { currentTradeDateCT } from './trading-logic'; import { getCachedContract } from './contract-resolver'; import { signalrWebSocket } from './outbound-proxy'; -import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions } from './db'; +import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart } from './db'; import { getAccountConfig } from './account-type-resolver'; import type { PlatformClient, PlatformAccount, PlatformContract } from './platform-client'; @@ -563,9 +563,22 @@ export class TopstepXClient implements PlatformClient { const txns = this.fundTransactions[account.id] ?? []; const funds = txns.reduce((s, f) => s + f.amount, 0); const delta = Math.round((cash.amount - startBal - tradePnL - funds) * 100) / 100; + // The first check on this instance (or since a wipe) only has the + // fetch window to explain the balance with: a lump it books is unseen + // P&L and past payouts, not the end of a cycle this instance watched. + const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null; + if (firstCheck) saveAccountMeta(account.id, 'funds_reconciled', today); if (Math.abs(delta) < 200) return; console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((tradePnL + funds) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`); + // A new cycle starts today: note where the one it ends began, by + // applyCycleSlice's rule for that date's own row, for the reporter's + // catch-up. A second event today doesn't start another cycle. + const prevFundDate = this.lastFundDates[account.id] ?? null; + if (!firstCheck && prevFundDate !== today) { + const prevAmt = prevFundDate ? (txns.find((f) => f.date === prevFundDate)?.amount ?? null) : null; + saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) }); + } // Same-day events merge into one row (the table keys on account+date), // so add today's delta onto whatever is already booked for today. const existingToday = txns.find((f) => f.date === today)?.amount ?? 0; diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index a42f96c..bf70877 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -637,6 +637,48 @@ export type CycleCompletion = 'none' | 'today' | 'before'; * number — already booked under its own date — would count as a second day. */ const ROLL_SETTLE_MS = 5 * 60 * 1000; +/** How long after a fund transaction starts a new cycle the cycle it ended is + * still checked for a missed completion. */ +const ENDED_CYCLE_WINDOW_DAYS = 7; + +/** 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. */ +function cycleOwesNothing( + accountId: number, + eff: EffectiveConfig, + rows: { date: string; pnl: number }[], + date: string, + equityProfit: number, + realized: number, + splitEnabled: boolean, +): boolean { + const plan = computeDayPlan( + accountId, date, eff.profitTarget, eff.consistency, rows, eff.minDayPnL, + eff.minTradingDays, equityProfit, realized, 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; +} + +/** Is the live realizedPnL too doubtful to count as today's session? A + * weekend (it 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. */ +function sessionDoubtful(rawTradeDate: string, cycleDailyPnL: { date: string; pnl: number }[], now: Date): boolean { + const day = new Date(`${rawTradeDate}T00:00:00Z`); + const dow = day.getUTCDay(); + if (dow === 0 || dow === 6) return true; + 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 (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true; + } + // Settle window: a few minutes ago it was still an earlier trade date. + return currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate; +} + /** * Did today's trading finish the account's current cycle — profit target, * consistency and minimum days all met? projectPerfectPath's "already @@ -666,40 +708,116 @@ export function cycleCompletion(opts: { /** Defaults to the current time. */ now?: Date; }): CycleCompletion { - const { accountId, rawTradeDate, effective: eff } = opts; + const { accountId, rawTradeDate, effective: eff, equityProfit, splitEnabled } = 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'; + if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before'; + if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return 'none'; const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }]; - if (view.realizedPnL !== 0 && owesNothing(withToday, nextTradingDate(view.tradeDate), 0)) return 'today'; + const probe = nextTradingDate(view.tradeDate); + if (view.realizedPnL !== 0 && cycleOwesNothing(accountId, eff, withToday, probe, equityProfit, 0, splitEnabled)) return 'today'; return 'none'; } +/** + * Catch-up for a missed final day: did the cycle the latest fund transaction + * just ended stand complete — profit target, consistency and minimum days all + * met? cycleCompletion reads 'today' on the final day only, so a completion + * its guards held back (or that the instance was offline for) would vanish + * once the payout, erasure or reset starts a new cycle and the days revert + * to 0. Checked for a week after that fund transaction. + * + * The ended cycle is judged on its own rows — from the previous fund date + * (its own row too when the client counted it in, as it does for a deposit), + * up to the latest one, less any the current cycle holds — against the + * equity it ended on: live equity less the current cycle's P&L and the fund + * transaction itself. A failed account's reset (a positive adjustment after + * a cycle that fell short) correctly reads null: the ended cycle never met + * the rule. Null on a doubtful session too — on a weekend the live + * realizedPnL is stale and would come off equity twice — and when this + * instance never saw the ended cycle start (a new instance or a wipe, whose + * fetch window cuts it short; TopstepX's first-check lump, which isn't a + * cycle at all). + * + * `cycle` is the fund date that STARTED the ended cycle ('' for the + * account's first), which is what client.lastFundDates held while it was + * current — the same key the final-day path reported for this cycle, so the + * master dedupes the two. + */ +export function endedCycleCompletion(opts: { + accountId: number; + /** currentTradeDateCT(). */ + rawTradeDate: string; + /** Every cycle's daily P&L, raw (client.fullDailyPnL). */ + fullDailyPnL: { date: string; pnl: number }[]; + /** Current-cycle daily P&L, raw (client.dailyPnL). */ + cycleDailyPnL: { date: string; pnl: number }[]; + fundTransactions: { date: string; amount: number }[]; + /** client.lastFundDates — the fund transaction that started the current cycle. */ + lastFundDate: string | null; + /** loadEndedCycleStart() — where the client saw the ended cycle begin, + * null when it never did. */ + endedCycleStart: { fundDate: string; includesDay: boolean } | null; + rawRealizedPnL: number; + /** amount − accountSize — today's P&L included. */ + equityProfit: number; + cfg: { + profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number; + targetSameEquity: boolean; withdrawalStages: WithdrawalStage[]; + combineCmeHolidays: boolean; splitEnabled: boolean; + }; + /** Defaults to the current time. */ + now?: Date; +}): { date: string; stage: number; drain: boolean; cycle: string } | null { + const { accountId, rawTradeDate, lastFundDate, endedCycleStart: start, fundTransactions, cfg } = opts; + if (lastFundDate === null || start === null) return null; + const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000); + if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date + if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return null; + + const latest = (a: string, b: string) => (b > a ? b : a); + const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate); + const prevFundDate = earlierFunds.length > 0 ? earlierFunds.map((f) => f.date).reduce(latest) : null; + // Saved for another transition — two fund dates since the client last + // looked — so the start it saw isn't this cycle's. + if (start.fundDate !== (prevFundDate ?? '')) return null; + const current = new Set(opts.cycleDailyPnL.map((d) => d.date)); + const endedRaw = opts.fullDailyPnL.filter((d) => d.date <= lastFundDate + && (prevFundDate === null || d.date > prevFundDate || (start.includesDay && d.date === prevFundDate)) + && !current.has(d.date)); + if (!endedRaw.some((d) => d.pnl !== 0)) return null; + + // Equity the ended cycle finished on, worked back from live equity: less + // everything the current cycle booked (today's live P&L included) and + // less the fund transaction between the two. + const cur = combineCmeSessions(cfg.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL); + const curTotal = cur.dailyPnL.filter((d) => d.date !== cur.tradeDate).reduce((s, d) => s + d.pnl, 0) + + cur.realizedPnL; + const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0); + const equityBefore = round2(opts.equityProfit - curTotal - fundAmt); + + // The stage the ended cycle traded on: only the withdrawals before it. + const eff = resolveEffectiveConfig( + cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, + cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL, + ); + const rows = combineCmeSessions(cfg.combineCmeHolidays, endedRaw, lastFundDate, 0).dailyPnL; + // A combining firm's folded day can net to 0 — nothing traded, as the firm counts it. + const traded = rows.filter((d) => d.pnl !== 0); + if (traded.length === 0) return null; + const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest)); + if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null; + + return { + date: traded.map((d) => d.date).reduce(latest), + stage: 1 + earlierFunds.filter((f) => f.amount < 0).length, + drain: eff.drain, + cycle: prevFundDate ?? '', + }; +} + /** 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 45db33f..e2a7531 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -5,7 +5,7 @@ import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpe import { computeSec, randomUUIDV4 } from './tradovate-helpers'; import { POINT_VALUES } from './trading-logic'; import { getCachedContract, resolveContracts } from './contract-resolver'; -import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions } from './db'; +import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db'; import { brokerWebSocket } from './outbound-proxy'; import { FILLS_FALLBACK_NOTE } from './platform-client'; @@ -267,7 +267,6 @@ export class TradovateClient { console.error('[requestSync] Received null/undefined response — auth may have failed'); return; } - // liquidateOnly flag lives in accountRiskStatuses const riskStatusById: { [accountId: number]: { liquidateOnly?: string } } = ( response.accountRiskStatuses || [] @@ -595,6 +594,19 @@ export class TradovateClient { // Use the most recent fund transaction as the reset point — persist it so it survives beyond the 28-day window const lastFundDate = [...Object.keys(fundMap)].sort().pop() ?? null; if (lastFundDate) { + // A new cycle starts: note where the one it ends began — + // with filterActivePnL's call on that date's own row — for + // the reporter's catch-up. Only from a stored start: the + // first load after a wipe or on a new instance never saw + // the cycle before it begin. + if (storedFundDate && lastFundDate > storedFundDate) { + const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null; + saveEndedCycleStart(account.id, { + fundDate: storedFundDate, + includesDay: !(prevAmt !== null && prevAmt < 0) || TradovateClient.isWithdrawalBeforeFirstTrade( + fundTimestampMap[storedFundDate] ?? storedFundTimestamp, firstTradeTsMap[storedFundDate] ?? null), + }); + } saveAccountMeta(account.id, 'last_fund_date', lastFundDate); this.lastFundDates[account.id] = lastFundDate; if (fundTimestampMap[lastFundDate]) {