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:
co-authored by
Claude Fable 5
parent
77ae4971f8
commit
57846d2b5f
+40
-7
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user