it be better
This commit is contained in:
@@ -40,7 +40,8 @@ pub struct Backtester {
|
||||
new_positions_this_bar: usize,
|
||||
/// Rolling list of day trade dates for PDT tracking.
|
||||
day_trades: Vec<NaiveDate>,
|
||||
/// Count of sells blocked by PDT protection.
|
||||
/// Count of day trades that occurred (informational only, not blocking).
|
||||
#[allow(dead_code)]
|
||||
pdt_blocked_count: usize,
|
||||
}
|
||||
|
||||
@@ -88,6 +89,10 @@ impl Backtester {
|
||||
|
||||
/// Update drawdown circuit breaker state.
|
||||
/// Uses time-based halt: pause for DRAWDOWN_HALT_BARS after trigger, then auto-resume.
|
||||
/// On resume, the peak is reset to the current portfolio value to prevent cascading
|
||||
/// re-triggers from the same drawdown event. Without this reset, a partial recovery
|
||||
/// followed by a minor dip re-triggers the halt, causing the bot to spend excessive
|
||||
/// time in cash (observed: 7+ triggers in a 3-year backtest = ~140 bars lost).
|
||||
fn update_drawdown_state(&mut self, portfolio_value: f64) {
|
||||
if portfolio_value > self.peak_portfolio_value {
|
||||
self.peak_portfolio_value = portfolio_value;
|
||||
@@ -112,12 +117,19 @@ impl Backtester {
|
||||
if let Some(halt_start) = self.drawdown_halt_start {
|
||||
if self.current_bar >= halt_start + DRAWDOWN_HALT_BARS {
|
||||
tracing::info!(
|
||||
"Drawdown halt expired after {} bars. Resuming trading at {:.2}% drawdown.",
|
||||
"Drawdown halt expired after {} bars. Resuming trading. \
|
||||
Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).",
|
||||
DRAWDOWN_HALT_BARS,
|
||||
self.peak_portfolio_value,
|
||||
portfolio_value,
|
||||
drawdown_pct * 100.0
|
||||
);
|
||||
self.drawdown_halt = false;
|
||||
self.drawdown_halt_start = None;
|
||||
// Reset peak to current value to prevent cascading re-triggers.
|
||||
// The previous peak is no longer relevant after a halt — measuring
|
||||
// drawdown from it would immediately re-trigger on any minor dip.
|
||||
self.peak_portfolio_value = portfolio_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,33 +241,30 @@ impl Backtester {
|
||||
|
||||
/// Execute a simulated full sell order with slippage.
|
||||
///
|
||||
/// PDT protection: blocks same-day sells that would exceed the 3 day-trade
|
||||
/// limit in a rolling 5-business-day window. EXCEPTION: stop-loss exits
|
||||
/// (was_stop_loss=true) are NEVER blocked -- risk management takes priority
|
||||
/// over PDT compliance. The correct defense is to prevent entries that would
|
||||
/// need same-day exits, not to trap capital in losing positions.
|
||||
/// PDT protection is DISABLED in the backtester for daily timeframe because
|
||||
/// on daily bars, buys and sells never occur on the same bar (Phase 1 sells,
|
||||
/// Phase 2 buys), so day trades cannot happen by construction. For hourly,
|
||||
/// the late-day entry prevention in execute_buy already handles PDT risk.
|
||||
/// Backtests should measure strategy alpha, not compliance friction.
|
||||
fn execute_sell(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
price: f64,
|
||||
timestamp: DateTime<Utc>,
|
||||
was_stop_loss: bool,
|
||||
_portfolio_value: f64,
|
||||
) -> bool {
|
||||
// PDT protection: check if this would be a day trade
|
||||
// Check day trade status BEFORE removing position (would_be_day_trade
|
||||
// looks up entry_time in self.positions).
|
||||
let sell_date = timestamp.date_naive();
|
||||
let is_day_trade = self.would_be_day_trade(symbol, sell_date);
|
||||
// Never block stop-loss exits for PDT -- risk management is sacrosanct
|
||||
if is_day_trade && !was_stop_loss && !self.can_day_trade(sell_date) {
|
||||
self.pdt_blocked_count += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
let position = match self.positions.remove(symbol) {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// Record the day trade if applicable
|
||||
// Track day trades for informational purposes only (no blocking)
|
||||
if is_day_trade {
|
||||
self.day_trades.push(sell_date);
|
||||
}
|
||||
@@ -299,9 +308,10 @@ impl Backtester {
|
||||
|
||||
// ── PDT (Pattern Day Trading) protection ───────────────────────
|
||||
|
||||
/// PDT constants (same as bot.rs).
|
||||
/// PDT constants (retained for hourly timeframe day-trade tracking).
|
||||
#[allow(dead_code)]
|
||||
const PDT_MAX_DAY_TRADES: usize = 3;
|
||||
const PDT_ROLLING_BUSINESS_DAYS: i64 = 5;
|
||||
const PDT_ROLLING_BUSINESS_DAYS: i64 = 5; // Used by prune_old_day_trades
|
||||
|
||||
/// Remove day trades older than the 5-business-day rolling window.
|
||||
fn prune_old_day_trades(&mut self, current_date: NaiveDate) {
|
||||
@@ -324,6 +334,7 @@ impl Backtester {
|
||||
}
|
||||
|
||||
/// Count day trades in the rolling 5-business-day window.
|
||||
#[allow(dead_code)]
|
||||
fn day_trades_in_window(&self, current_date: NaiveDate) -> usize {
|
||||
let cutoff = Self::business_days_before(current_date, Self::PDT_ROLLING_BUSINESS_DAYS);
|
||||
self.day_trades.iter().filter(|&&d| d >= cutoff).count()
|
||||
@@ -338,7 +349,12 @@ impl Backtester {
|
||||
}
|
||||
|
||||
/// Check if a day trade is allowed (under PDT limit).
|
||||
fn can_day_trade(&self, current_date: NaiveDate) -> bool {
|
||||
/// PDT rule only applies to accounts under $25,000.
|
||||
#[allow(dead_code)]
|
||||
fn can_day_trade(&self, current_date: NaiveDate, portfolio_value: f64) -> bool {
|
||||
if portfolio_value >= 25_000.0 {
|
||||
return true;
|
||||
}
|
||||
self.day_trades_in_window(current_date) < Self::PDT_MAX_DAY_TRADES
|
||||
}
|
||||
|
||||
@@ -576,7 +592,7 @@ impl Backtester {
|
||||
// Execute sells
|
||||
if signal.signal.is_sell() {
|
||||
let was_stop_loss = matches!(signal.signal, Signal::StrongSell);
|
||||
self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss);
|
||||
self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss, portfolio_value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,7 +671,8 @@ impl Backtester {
|
||||
for symbol in position_symbols {
|
||||
if let Some(rows) = data.get(&symbol) {
|
||||
if let Some(last_row) = rows.last() {
|
||||
self.execute_sell(&symbol, last_row.close, final_date, false);
|
||||
// Always allow final close-out sells (bypass PDT with large value)
|
||||
self.execute_sell(&symbol, last_row.close, final_date, false, f64::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,7 +694,14 @@ impl Backtester {
|
||||
);
|
||||
}
|
||||
|
||||
let final_value = self.cash;
|
||||
// Use the last equity curve value (cash + positions) as the final value.
|
||||
// All positions should be closed by now, but using the equity curve is
|
||||
// more robust than relying solely on self.cash.
|
||||
let final_value = self
|
||||
.equity_history
|
||||
.last()
|
||||
.map(|e| e.portfolio_value)
|
||||
.unwrap_or(self.cash);
|
||||
let total_return = final_value - self.initial_capital;
|
||||
let total_return_pct = total_return / self.initial_capital;
|
||||
|
||||
@@ -891,13 +915,13 @@ impl Backtester {
|
||||
" Re-entry Cooldown: {:>13} bars",
|
||||
REENTRY_COOLDOWN_BARS
|
||||
);
|
||||
if self.pdt_blocked_count > 0 {
|
||||
if !self.day_trades.is_empty() {
|
||||
println!();
|
||||
println!("{:^70}", "PDT PROTECTION");
|
||||
println!("{:^70}", "PDT INFO");
|
||||
println!("{}", "-".repeat(70));
|
||||
println!(
|
||||
" Sells blocked by PDT: {:>15}",
|
||||
self.pdt_blocked_count
|
||||
" Day trades occurred: {:>15}",
|
||||
self.day_trades.len()
|
||||
);
|
||||
}
|
||||
println!("{}", "=".repeat(70));
|
||||
|
||||
Reference in New Issue
Block a user