Add per-stage withdrawal targets with consistency and min trading days

- Add withdrawal stage system: each stage defines profit target, consistency,
  and min trading days for post-withdrawal challenge cycles
- Target Same Equity mode accounts for withdrawn amounts when computing
  effective profit target (profitTarget - remainingProfit)
- Store fund transaction timestamps for time-aware cycle filtering
  (withdrawals before 9 AM CT include that day in new cycle)
- Expose full P&L history (fullDailyPnL) for calendar/equity curve display
  across all cycles, with DB fallback for pre-restart data
- Show stage number (#1, #2, etc.) on calendar cells
- Hide consistency reference line when consistency is 0% or 100%
- Settings UI: "After First W/D" column with same-equity checkbox,
  expandable stage sub-rows with profit/consistency/days inputs
- Default target_same_equity to 1 for new and existing account configs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-03-21 05:27:13 -05:00
co-authored by Claude Opus 4.6
parent c54073e4b8
commit 70b1362d3e
11 changed files with 384 additions and 48 deletions
+78 -5
View File
@@ -31,10 +31,14 @@ export class TradovateClient {
public daysTraded: { [accountId: number]: number } = {};
public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
/** Full P&L history (all cycles) — used for calendar display */
public fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
/** Date of the last fund transaction per account — days traded are counted from this date onwards */
public lastFundDates: { [accountId: number]: string | null } = {};
/** All fund transactions (deposits/withdrawals) per account */
public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {};
/** Sum of daily P&L from before the last fund transaction — used for "Target Same Equity" mode */
public priorProfit: { [accountId: number]: number } = {};
/** Balance floor set by the prop firm — account is blown when amount <= this value (0 = not set) */
public autoLiqThresholds: { [accountId: number]: number } = {};
@@ -386,6 +390,43 @@ export class TradovateClient {
});
}
/**
* Returns true when a withdrawal happened before the trading session started,
* 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".
*/
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}/);
if (!match) return false;
return parseInt(match[1], 10) < 9;
}
/**
* 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)
*/
private static filterActivePnL(
entries: { date: string; pnl: number }[],
fundDate: string | null,
isWithdrawal: boolean,
fundTimestamp: string | 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 >)
// Deposit → always include the day (use >=)
const excludeFundDate = isWithdrawal && !TradovateClient.isWithdrawalBeforeTrading(fundTimestamp);
const active = entries.filter((d) => excludeFundDate ? d.date > fundDate : d.date >= fundDate);
const prior = entries.filter((d) => excludeFundDate ? d.date <= fundDate : d.date < fundDate);
return { active, prior };
}
public async fetchDaysTraded(): Promise<{ failedAccounts: number }> {
if (!this.accessInfo?.accessToken) return { failedAccounts: 0 };
this.fetchDaysComplete = false;
@@ -457,8 +498,16 @@ export class TradovateClient {
// Load cache first — serves as both the startup baseline and the fallback if API fails
const cached = loadDailyPnL(account.id);
const storedFundTimestamp = loadAccountMeta(account.id, 'last_fund_timestamp');
if (cached.length > 0) {
const active = storedFundDate ? cached.filter((d) => d.date >= storedFundDate) : cached;
const storedFundTxns = this.fundTransactions[account.id] ?? [];
const storedLastFundAmt = storedFundDate ? (storedFundTxns.find((f) => f.date === storedFundDate)?.amount ?? null) : null;
const storedIsWithdrawal = storedLastFundAmt !== null && storedLastFundAmt < 0;
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
cached, storedFundDate, storedIsWithdrawal, storedFundTimestamp
);
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
this.fullDailyPnL[account.id] = cached;
this.dailyPnL[account.id] = active;
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
}
@@ -471,11 +520,15 @@ export class TradovateClient {
this.lastFetchRaw[account.name] = rows.length > 0 ? JSON.stringify(rows[0]) : '(empty)';
const fundMap: { [date: string]: number } = {};
const fundTimestampMap: { [date: string]: string } = {};
const dailyMap: { [date: string]: number } = {};
for (const row of rows) {
if ((row['Cash Change Type'] ?? '').trim() === 'Fund Transaction') {
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
if (!isNaN(delta)) fundMap[row['Date']] = (fundMap[row['Date']] ?? 0) + delta;
if (!isNaN(delta)) {
fundMap[row['Date']] = (fundMap[row['Date']] ?? 0) + delta;
if (row['Timestamp']) fundTimestampMap[row['Date']] = row['Timestamp'];
}
continue;
}
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
@@ -497,6 +550,9 @@ export class TradovateClient {
if (lastFundDate) {
saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
this.lastFundDates[account.id] = lastFundDate;
if (fundTimestampMap[lastFundDate]) {
saveAccountMeta(account.id, 'last_fund_timestamp', fundTimestampMap[lastFundDate]);
}
}
const fundDate = this.lastFundDates[account.id];
@@ -505,7 +561,16 @@ export class TradovateClient {
.sort((a, b) => a.date.localeCompare(b.date));
const merged = mergePnL(cached, fresh);
const active = fundDate ? merged.filter((d) => d.date >= fundDate) : merged;
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 { active, prior: priorEntries } = TradovateClient.filterActivePnL(
merged, fundDate, isWithdrawal, lastFundTs
);
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
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
@@ -557,15 +622,23 @@ export class TradovateClient {
}
}
// Fills report has no fund transaction data — use stored fund date
// Fills report has no fund transaction data — use stored fund date and stored fund transactions
const fundDate = this.lastFundDates[account.id];
const storedFundTxns = this.fundTransactions[account.id] ?? [];
const lastFundAmt = fundDate ? (storedFundTxns.find((f) => f.date === fundDate)?.amount ?? null) : null;
const isWithdrawalFills = lastFundAmt !== null && lastFundAmt < 0;
const fresh = Object.entries(dailyMap)
.map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 }))
.sort((a, b) => a.date.localeCompare(b.date));
const merged = mergePnL(cached, fresh);
const active = fundDate ? merged.filter((d) => d.date >= fundDate) : merged;
const fillsFundTs = loadAccountMeta(account.id, 'last_fund_timestamp');
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
merged, fundDate, isWithdrawalFills, fillsFundTs
);
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
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