From 57846d2b5fe9b0d0d4bd24e6fa763f78fb9cfe79 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 8 Sep 2026 20:45:19 -0500 Subject: [PATCH] Attribute evening-session Cash History rows to the next trade date Cash History rows are stamped with the Central calendar date, but trades in the evening session (>= 5:00 PM CT) belong to the next trade date - matching currentTradeDateCT and when Tradovate resets realizedPnL. The mismatch made computeDayPlan count the same session twice (as a phantom prior day and as today's realized PnL), inflating the day target into a moving one: 40% consistency accounts showed dayTarget = base + 0.4 x today's PnL instead of the anchored day-start target. Roll those rows forward at ingestion, and make a successful Cash History report authoritative for its 28-day window so rows cached under the old calendar date are dropped rather than double-counted. An empty report never deletes anything (it can't distinguish "no trades" from "no data"), and a report still pending after polling now throws instead of masquerading as an empty one. Co-Authored-By: Claude Fable 5 --- lib/db.ts | 17 +++++++++++++++ lib/tradovate-class.ts | 47 +++++++++++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/lib/db.ts b/lib/db.ts index 3fa1bdf..c8beeda 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -234,6 +234,23 @@ export function saveDailyPnL(accountId: number, accountName: string, entries: { txn(); } +/** + * Replace all rows on/after fromDate with the given entries, keeping older rows. + * Used when a full Cash History report succeeded: it is authoritative for its window, + * so stale rows there (e.g. saved under a pre-roll calendar date) must not survive. + */ +export function replaceDailyPnLWindow(accountId: number, accountName: string, fromDate: string, entries: { date: string; pnl: number }[]): void { + const del = db.prepare('DELETE FROM daily_pnl WHERE account_id = ? AND date >= ?'); + const ins = db.prepare('INSERT OR REPLACE INTO daily_pnl (account_id, account_name, date, pnl) VALUES (?, ?, ?, ?)'); + const txn = db.transaction(() => { + del.run(accountId, fromDate); + for (const e of entries) { + ins.run(accountId, accountName, e.date, e.pnl); + } + }); + txn(); +} + export function loadDailyPnL(accountId: number): { date: string; pnl: number }[] { return (db.prepare('SELECT date, pnl FROM daily_pnl WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; pnl: number }[]); } diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 4058ff3..a7a4a34 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, replaceDailyPnLWindow, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions } from './db'; /** * Entity types Tradovate sends over the user-sync socket. Keep this as the @@ -412,6 +412,21 @@ export class TradovateClient { return parseInt(match[1], 10) < 9; } + /** + * Cash History rows are stamped with the calendar date, but trades in the evening + * session (≥ 5:00 PM CT) belong to the NEXT trade date — matching currentTradeDateCT + * and when Tradovate resets realizedPnL. Roll those rows forward so the same session + * never counts as both a prior day and today's realized PnL. + */ + private static rollEveningToNextTradeDate(date: string, timestamp: string | undefined): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return date; + const m = timestamp?.match(/\d{2}\/\d{2}\/\d{4}\s+(\d{2}):\d{2}:\d{2}/); + if (!m || parseInt(m[1], 10) < 17) return date; + const next = new Date(`${date}T00:00:00Z`); + next.setUTCDate(next.getUTCDate() + 1); + return next.toISOString().slice(0, 10); + } + /** * Filters daily PnL entries for the current cycle based on fund date and withdrawal timing. * - Deposit: include the fund date (trading can start same day) @@ -454,6 +469,10 @@ export class TradovateClient { const day = String(d.getDate()).padStart(2, '0'); return `${m}/${day}/${d.getFullYear()}`; }; + // First date the Cash History report covers — within [windowStart, ∞) a successful + // report is authoritative, so cached rows there that the report no longer produces + // (e.g. saved under the pre-roll calendar date) must be dropped, not merged. + const windowStart = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`; if (!this.lastFetchRaw) this.lastFetchRaw = {}; if (!this.lastFetchErrors) this.lastFetchErrors = {}; @@ -498,6 +517,9 @@ export class TradovateClient { )).data; pollAttempts++; } + // A report still pending is NOT an empty report — treating it as '[]' + // would let the caller mistake "no data yet" for "no trades". + if (reportData?.['p-ticket']) throw new Error(`${name} report for ${accountName} not ready after ${pollAttempts} polls`); return typeof reportData?.data === 'string' ? reportData.data : '[]'; } catch (err) { lastErr = err; @@ -568,11 +590,12 @@ export class TradovateClient { } const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, '')); if (isNaN(delta)) continue; - dailyMap[row['Date']] = (dailyMap[row['Date']] ?? 0) + delta; - // Track earliest trade timestamp per date + const tradeDate = TradovateClient.rollEveningToNextTradeDate(row['Date'], row['Timestamp']); + dailyMap[tradeDate] = (dailyMap[tradeDate] ?? 0) + delta; + // Track earliest trade timestamp per trade date const ts = row['Timestamp']; - if (ts && (!firstTradeTsMap[row['Date']] || ts < firstTradeTsMap[row['Date']])) { - firstTradeTsMap[row['Date']] = ts; + if (ts && (!firstTradeTsMap[tradeDate] || ts < firstTradeTsMap[tradeDate])) { + firstTradeTsMap[tradeDate] = ts; } } @@ -600,7 +623,14 @@ export class TradovateClient { .map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 })) .sort((a, b) => a.date.localeCompare(b.date)); - const merged = mergePnL(cached, fresh); + // A report with rows is authoritative for its window: drop cached in-window rows + // it no longer produces (e.g. saved under the pre-roll calendar date). An empty + // report deletes nothing — it can't distinguish "no trades" from "no data". + const freshDates = new Set(fresh.map((e) => e.date)); + const liveCached = rows.length > 0 + ? cached.filter((e) => e.date < windowStart || freshDates.has(e.date)) + : cached; + const merged = mergePnL(liveCached, fresh); const lastFundAmt = fundDate ? (fundMap[fundDate] ?? null) : null; const isWithdrawal = lastFundAmt !== null && lastFundAmt < 0; @@ -614,7 +644,10 @@ export class TradovateClient { this.fullDailyPnL[account.id] = merged; 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 + // Authoritative for the report window — drops stale in-window rows; older rows + // preserved. Only when the report returned actual rows: an empty report can't + // distinguish "no trades" from "no data", so it must never delete anything. + if (rows.length > 0) replaceDailyPnLWindow(account.id, account.name, windowStart, fresh); delete this.lastFetchErrors[account.name]; continue; } catch {