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:
co-authored by
Claude Opus 4.6
parent
c54073e4b8
commit
70b1362d3e
+20
-7
@@ -9,7 +9,7 @@
|
||||
|
||||
import { getFirms, isSymbolBanned, getInstruments } from './db';
|
||||
import { getClients } from './clients';
|
||||
import { computeDailyTarget, POINT_VALUES } from './trading-logic';
|
||||
import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic';
|
||||
import { getSetting } from './db';
|
||||
import type { FirmConfig, AccountConfig } from '@/types';
|
||||
import type { FirmWithAccounts } from './db';
|
||||
@@ -42,6 +42,8 @@ function mapFirmConfig(firm: FirmWithAccounts): FirmConfig {
|
||||
accountSize: a.account_size,
|
||||
maxLoss: a.max_loss,
|
||||
maxPositionSize: a.max_position_size,
|
||||
targetSameEquity: a.target_same_equity === 1,
|
||||
withdrawalStages: (() => { try { return JSON.parse(a.withdrawal_stages ?? '[]') as { profit: number; consistency: number; minTradingDays: number }[]; } catch { return []; } })(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -176,13 +178,19 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string
|
||||
// Only trade accounts that haven't traded yet today
|
||||
if (cash.realizedPnL !== 0) continue;
|
||||
|
||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||
const effective = resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns
|
||||
);
|
||||
|
||||
// Use the same target formula as the dashboard — skip if $0 (challenge complete)
|
||||
const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, totalProfit, dailyPnL, cfg.minDayPnL, cfg.minTradingDays);
|
||||
const target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.minDayPnL, effective.minTradingDays);
|
||||
|
||||
// Allow through if it's an MNQ extra-day trade: no min day P&L, profit done, days still needed
|
||||
const isMnqExtraDay = cfg.minDayPnL <= 0
|
||||
&& cfg.minTradingDays > daysTraded
|
||||
&& totalProfit >= cfg.profitTarget;
|
||||
&& effective.minTradingDays > daysTraded
|
||||
&& totalProfit >= effective.profitTarget;
|
||||
|
||||
if (target.amount <= 0 && !isMnqExtraDay) continue;
|
||||
|
||||
@@ -205,12 +213,17 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string
|
||||
const { client, acc, contract, firmConfig, dailyPnL, daysTraded } = item;
|
||||
const cfg = getAccountConfig(acc.name, firmConfig)!;
|
||||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||
const effective = resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns
|
||||
);
|
||||
|
||||
// Extra-day mode: profit target already met, no min day P&L, days still needed.
|
||||
// Just trade 1 MNQ in and out at market immediately — P&L doesn't matter.
|
||||
const isExtraDay = cfg.minDayPnL <= 0
|
||||
&& cfg.minTradingDays > daysTraded
|
||||
&& totalProfit >= cfg.profitTarget;
|
||||
&& effective.minTradingDays > daysTraded
|
||||
&& totalProfit >= effective.profitTarget;
|
||||
|
||||
if (isExtraDay) {
|
||||
const mnqContract = await client.findFrontMonthContract('MNQ');
|
||||
@@ -237,7 +250,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string
|
||||
};
|
||||
}
|
||||
|
||||
const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, totalProfit, dailyPnL, cfg.minDayPnL, cfg.minTradingDays);
|
||||
const target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.minDayPnL, effective.minTradingDays);
|
||||
|
||||
const rawContracts = Math.max(1, Math.ceil(target.amount / 1000));
|
||||
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
||||
|
||||
@@ -50,6 +50,20 @@ try {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: add target_same_equity flag
|
||||
try {
|
||||
db.exec('ALTER TABLE account_configs ADD COLUMN target_same_equity INTEGER NOT NULL DEFAULT 1');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: add withdrawal_stages JSON array
|
||||
try {
|
||||
db.exec("ALTER TABLE account_configs ADD COLUMN withdrawal_stages TEXT NOT NULL DEFAULT '[]'");
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Interfaces ─────────────────────────────────────────────────────────────
|
||||
@@ -65,6 +79,8 @@ export interface AccountConfigRow {
|
||||
account_size: number;
|
||||
max_loss: number;
|
||||
max_position_size: number;
|
||||
target_same_equity: number; // 0 | 1
|
||||
withdrawal_stages: string; // JSON { profit: number; consistency: number; minTradingDays: number }[]
|
||||
}
|
||||
|
||||
export interface FirmRow {
|
||||
@@ -118,11 +134,13 @@ export function createAccountConfig(firmId: number, data: {
|
||||
accountSize: number;
|
||||
maxLoss: number;
|
||||
maxPositionSize: number;
|
||||
targetSameEquity?: boolean;
|
||||
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
||||
}): AccountConfigRow {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size, max_loss, max_position_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size, max_loss, max_position_size, target_same_equity, withdrawal_stages) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(firmId, data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize);
|
||||
const result = stmt.run(firmId, data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []));
|
||||
return db.prepare('SELECT * FROM account_configs WHERE id = ?').get(result.lastInsertRowid) as AccountConfigRow;
|
||||
}
|
||||
|
||||
@@ -140,12 +158,14 @@ export function updateAccountConfig(id: number, data: {
|
||||
accountSize: number;
|
||||
maxLoss: number;
|
||||
maxPositionSize: number;
|
||||
targetSameEquity?: boolean;
|
||||
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
||||
}): boolean {
|
||||
const result = db.prepare(`
|
||||
UPDATE account_configs
|
||||
SET prefix = ?, profit_target = ?, consistency = ?, min_day_pnl = ?, min_trading_days = ?, account_size = ?, max_loss = ?, max_position_size = ?
|
||||
SET prefix = ?, profit_target = ?, consistency = ?, min_day_pnl = ?, min_trading_days = ?, account_size = ?, max_loss = ?, max_position_size = ?, target_same_equity = ?, withdrawal_stages = ?
|
||||
WHERE id = ?
|
||||
`).run(data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, id);
|
||||
`).run(data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []), id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,54 @@
|
||||
/**
|
||||
* Resolve the effective profit target and consistency for an account based on its withdrawal strategy.
|
||||
*
|
||||
* Mode A (targetSameEquity=true): the account must reach the same cumulative equity level.
|
||||
* remainingProfit = priorProfit + totalWithdrawals (profit still in the account after payouts)
|
||||
* effectiveProfitTarget = profitTarget − remainingProfit
|
||||
*
|
||||
* Mode B (withdrawalStages non-empty): Stage 1 (no withdrawals yet) uses base profitTarget/consistency.
|
||||
* After the Nth withdrawal, use withdrawalStages[N-1]; last stage repeats.
|
||||
* Fallback: returns base profitTarget and consistency unchanged.
|
||||
*/
|
||||
export function resolveEffectiveConfig(
|
||||
profitTarget: number,
|
||||
consistency: number,
|
||||
minTradingDays: number,
|
||||
targetSameEquity: boolean,
|
||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
|
||||
priorProfit: number,
|
||||
fundTransactions: { date: string; amount: number }[]
|
||||
): { profitTarget: number; consistency: number; minTradingDays: number } {
|
||||
if (targetSameEquity) {
|
||||
// Withdrawals reduce the profit remaining in the account
|
||||
const totalWithdrawals = fundTransactions
|
||||
.filter((f) => f.amount < 0)
|
||||
.reduce((s, f) => s + f.amount, 0); // negative sum
|
||||
const remainingProfit = priorProfit + totalWithdrawals;
|
||||
return { profitTarget: Math.max(0, profitTarget - remainingProfit), consistency, minTradingDays };
|
||||
}
|
||||
if (withdrawalStages.length > 0) {
|
||||
const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length;
|
||||
if (withdrawalCount === 0) {
|
||||
return { profitTarget, consistency, minTradingDays }; // Stage 1 = base values
|
||||
}
|
||||
const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
|
||||
const stage = withdrawalStages[idx];
|
||||
return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays };
|
||||
}
|
||||
return { profitTarget, consistency, minTradingDays };
|
||||
}
|
||||
|
||||
/** @deprecated Use resolveEffectiveConfig instead */
|
||||
export function resolveEffectiveProfitTarget(
|
||||
profitTarget: number,
|
||||
targetSameEquity: boolean,
|
||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
|
||||
priorProfit: number,
|
||||
fundTransactions: { date: string; amount: number }[]
|
||||
): number {
|
||||
return resolveEffectiveConfig(profitTarget, 0, 0, targetSameEquity, withdrawalStages, priorProfit, fundTransactions).profitTarget;
|
||||
}
|
||||
|
||||
/** Dollar-per-point value for common futures products. */
|
||||
export const POINT_VALUES: { [symbol: string]: number } = {
|
||||
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
||||
|
||||
+78
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user