it be better

This commit is contained in:
zastian-dev
2026-02-13 13:12:22 +00:00
parent 80a8e7c346
commit 1ef03999b7
6 changed files with 250 additions and 223 deletions

View File

@@ -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));