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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-08 20:45:19 -05:00
co-authored by Claude Fable 5
parent 77ae4971f8
commit 57846d2b5f
2 changed files with 57 additions and 7 deletions
+17
View File
@@ -234,6 +234,23 @@ export function saveDailyPnL(accountId: number, accountName: string, entries: {
txn(); 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 }[] { 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 }[]); return (db.prepare('SELECT date, pnl FROM daily_pnl WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; pnl: number }[]);
} }
+40 -7
View File
@@ -5,7 +5,7 @@ import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpe
import { computeSec, randomUUIDV4 } from './tradovate-helpers'; import { computeSec, randomUUIDV4 } from './tradovate-helpers';
import { POINT_VALUES } from './trading-logic'; import { POINT_VALUES } from './trading-logic';
import { getCachedContract, resolveContracts } from './contract-resolver'; 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 * 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; 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. * 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) * - 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'); const day = String(d.getDate()).padStart(2, '0');
return `${m}/${day}/${d.getFullYear()}`; 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.lastFetchRaw) this.lastFetchRaw = {};
if (!this.lastFetchErrors) this.lastFetchErrors = {}; if (!this.lastFetchErrors) this.lastFetchErrors = {};
@@ -498,6 +517,9 @@ export class TradovateClient {
)).data; )).data;
pollAttempts++; 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 : '[]'; return typeof reportData?.data === 'string' ? reportData.data : '[]';
} catch (err) { } catch (err) {
lastErr = err; lastErr = err;
@@ -568,11 +590,12 @@ export class TradovateClient {
} }
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, '')); const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
if (isNaN(delta)) continue; if (isNaN(delta)) continue;
dailyMap[row['Date']] = (dailyMap[row['Date']] ?? 0) + delta; const tradeDate = TradovateClient.rollEveningToNextTradeDate(row['Date'], row['Timestamp']);
// Track earliest trade timestamp per date dailyMap[tradeDate] = (dailyMap[tradeDate] ?? 0) + delta;
// Track earliest trade timestamp per trade date
const ts = row['Timestamp']; const ts = row['Timestamp'];
if (ts && (!firstTradeTsMap[row['Date']] || ts < firstTradeTsMap[row['Date']])) { if (ts && (!firstTradeTsMap[tradeDate] || ts < firstTradeTsMap[tradeDate])) {
firstTradeTsMap[row['Date']] = ts; firstTradeTsMap[tradeDate] = ts;
} }
} }
@@ -600,7 +623,14 @@ export class TradovateClient {
.map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 })) .map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 }))
.sort((a, b) => a.date.localeCompare(b.date)); .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 lastFundAmt = fundDate ? (fundMap[fundDate] ?? null) : null;
const isWithdrawal = lastFundAmt !== null && lastFundAmt < 0; const isWithdrawal = lastFundAmt !== null && lastFundAmt < 0;
@@ -614,7 +644,10 @@ export class TradovateClient {
this.fullDailyPnL[account.id] = merged; this.fullDailyPnL[account.id] = merged;
this.dailyPnL[account.id] = active; this.dailyPnL[account.id] = active;
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length; 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]; delete this.lastFetchErrors[account.name];
continue; continue;
} catch { } catch {