Use actual first-trade timestamp for same-day withdrawal filtering

Instead of the 9 AM CT heuristic, compare the withdrawal timestamp
against the day's earliest trade timestamp. If trades happened AFTER
the withdrawal, those trades count toward the new cycle.

Falls back to the 9 AM heuristic when first-trade timestamp is
unavailable (e.g., cache-only fallback path).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-04-20 20:30:41 -05:00
co-authored by Claude Opus 4.6
parent 91a448750c
commit fa2aad38cb
+28 -12
View File
@@ -391,14 +391,22 @@ export class TradovateClient {
}
/**
* Returns true when a withdrawal happened before the trading session started,
* Returns true when a withdrawal happened before the first trade of the day,
* meaning that day's trades belong to the NEW cycle.
* Timestamp format from Tradovate: "MM/DD/YYYY HH:MM:SS" in Central Time.
* Cutoff: before 9:00 AM CT → "before trading".
* Falls back to 9:00 AM CT cutoff when firstTradeTimestamp is not available.
*/
private static isWithdrawalBeforeTrading(timestamp: string | null): boolean {
if (!timestamp) return false;
const match = timestamp.match(/\d{2}\/\d{2}\/\d{4}\s+(\d{2}):\d{2}:\d{2}/);
private static isWithdrawalBeforeFirstTrade(
fundTimestamp: string | null,
firstTradeTimestamp: string | null
): boolean {
if (!fundTimestamp) return false;
if (firstTradeTimestamp) {
// Direct comparison — withdrawal before the day's first trade
return fundTimestamp < firstTradeTimestamp;
}
// Fallback heuristic: before 9:00 AM CT
const match = fundTimestamp.match(/\d{2}\/\d{2}\/\d{4}\s+(\d{2}):\d{2}:\d{2}/);
if (!match) return false;
return parseInt(match[1], 10) < 9;
}
@@ -406,21 +414,22 @@ export class TradovateClient {
/**
* 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)
* - Withdrawal before trading session: include the fund date (day's trades are new cycle)
* - Withdrawal during/after trading: exclude the fund date (day's trades are old cycle)
* - Withdrawal before first trade: include the fund date (day's trades are new cycle)
* - Withdrawal during/after first trade: exclude the fund date (day's trades are old cycle)
*/
private static filterActivePnL(
entries: { date: string; pnl: number }[],
fundDate: string | null,
isWithdrawal: boolean,
fundTimestamp: string | null
fundTimestamp: string | null,
firstTradeTimestamp: string | null = null
): { active: { date: string; pnl: number }[]; prior: { date: string; pnl: number }[] } {
if (!fundDate) return { active: entries, prior: [] };
// Withdrawal before trading → day belongs to NEW cycle (use >=)
// Withdrawal during/after trading → day belongs to OLD cycle (use >)
// Withdrawal before first trade → day belongs to NEW cycle (use >=)
// Withdrawal during/after first trade → day belongs to OLD cycle (use >)
// Deposit → always include the day (use >=)
const excludeFundDate = isWithdrawal && !TradovateClient.isWithdrawalBeforeTrading(fundTimestamp);
const excludeFundDate = isWithdrawal && !TradovateClient.isWithdrawalBeforeFirstTrade(fundTimestamp, firstTradeTimestamp);
const active = entries.filter((d) => excludeFundDate ? d.date > fundDate : d.date >= fundDate);
const prior = entries.filter((d) => excludeFundDate ? d.date <= fundDate : d.date < fundDate);
@@ -522,6 +531,7 @@ export class TradovateClient {
const fundMap: { [date: string]: number } = {};
const fundTimestampMap: { [date: string]: string } = {};
const dailyMap: { [date: string]: number } = {};
const firstTradeTsMap: { [date: string]: string } = {};
for (const row of rows) {
const changeType = (row['Cash Change Type'] ?? '').trim();
if (changeType === 'Fund Transaction' || changeType === 'Manual Adjustment') {
@@ -535,6 +545,11 @@ 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 ts = row['Timestamp'];
if (ts && (!firstTradeTsMap[row['Date']] || ts < firstTradeTsMap[row['Date']])) {
firstTradeTsMap[row['Date']] = ts;
}
}
// Persist fund transactions so they survive beyond the 28-day window
@@ -566,8 +581,9 @@ export class TradovateClient {
const lastFundAmt = fundDate ? (fundMap[fundDate] ?? null) : null;
const isWithdrawal = lastFundAmt !== null && lastFundAmt < 0;
const lastFundTs = fundDate ? (fundTimestampMap[fundDate] ?? loadAccountMeta(account.id, 'last_fund_timestamp')) : null;
const firstTradeTs = fundDate ? (firstTradeTsMap[fundDate] ?? null) : null;
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
merged, fundDate, isWithdrawal, lastFundTs
merged, fundDate, isWithdrawal, lastFundTs, firstTradeTs
);
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;