PDT protection

This commit is contained in:
zastian-dev
2026-02-12 18:14:53 +00:00
parent 223051f9d8
commit 80a8e7c346
5 changed files with 396 additions and 46 deletions

View File

@@ -1,7 +1,7 @@
//! Backtesting engine for the trading strategy.
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc};
use std::collections::{BTreeMap, HashMap, HashSet};
use crate::alpaca::{fetch_backtest_data, AlpacaClient};
@@ -38,6 +38,10 @@ pub struct Backtester {
cooldown_timers: HashMap<String, usize>,
/// Tracks new positions opened in current bar (for gradual ramp-up)
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.
pdt_blocked_count: usize,
}
impl Backtester {
@@ -57,6 +61,8 @@ impl Backtester {
current_bar: 0,
cooldown_timers: HashMap::new(),
new_positions_this_bar: 0,
day_trades: Vec::new(),
pdt_blocked_count: 0,
}
}
@@ -118,6 +124,10 @@ impl Backtester {
}
/// Execute a simulated buy order with slippage.
///
/// For hourly timeframe, entries are blocked in the last 2 hours of the
/// trading day to avoid creating positions that might need same-day
/// stop-loss exits (PDT prevention at entry rather than blocking exits).
fn execute_buy(
&mut self,
symbol: &str,
@@ -130,6 +140,19 @@ impl Backtester {
return false;
}
// PDT-safe entry: on hourly, avoid buying in the last 2 hours of the day.
// This prevents positions that might need a same-day stop-loss exit.
// Market hours are roughly 9:30-16:00 ET; avoid entries after 14:00 ET.
if self.timeframe == Timeframe::Hourly {
let hour = timestamp.hour();
// IEX timestamps are in UTC; ET = UTC-5 in winter, UTC-4 in summer.
// 14:00 ET = 19:00 UTC (winter) or 18:00 UTC (summer).
// Conservative: block entries after 19:00 UTC (covers both).
if hour >= 19 {
return false;
}
}
// Cooldown guard: prevent whipsaw re-entry after stop-loss
if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) {
if self.current_bar < cooldown_until {
@@ -205,6 +228,12 @@ 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.
fn execute_sell(
&mut self,
symbol: &str,
@@ -212,11 +241,25 @@ impl Backtester {
timestamp: DateTime<Utc>,
was_stop_loss: bool,
) -> bool {
// PDT protection: check if this would be a day trade
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
if is_day_trade {
self.day_trades.push(sell_date);
}
let fill_price = Self::apply_slippage(price, "sell");
let proceeds = position.shares * fill_price;
self.cash += proceeds;
@@ -254,6 +297,51 @@ impl Backtester {
// avg_win < avg_loss profile. The trailing stop alone provides adequate
// profit protection without splitting winners into smaller fragments.
// ── PDT (Pattern Day Trading) protection ───────────────────────
/// PDT constants (same as bot.rs).
const PDT_MAX_DAY_TRADES: usize = 3;
const PDT_ROLLING_BUSINESS_DAYS: i64 = 5;
/// Remove day trades older than the 5-business-day rolling window.
fn prune_old_day_trades(&mut self, current_date: NaiveDate) {
let cutoff = Self::business_days_before(current_date, Self::PDT_ROLLING_BUSINESS_DAYS);
self.day_trades.retain(|&d| d >= cutoff);
}
/// Get the date N business days before the given date.
fn business_days_before(from: NaiveDate, n: i64) -> NaiveDate {
let mut count = 0i64;
let mut date = from;
while count < n {
date -= Duration::days(1);
let wd = date.weekday();
if wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun {
count += 1;
}
}
date
}
/// Count day trades in the rolling 5-business-day window.
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()
}
/// Check if selling this symbol on the given date would be a day trade.
fn would_be_day_trade(&self, symbol: &str, sell_date: NaiveDate) -> bool {
self.positions
.get(symbol)
.map(|p| p.entry_time.date_naive() == sell_date)
.unwrap_or(false)
}
/// Check if a day trade is allowed (under PDT limit).
fn can_day_trade(&self, current_date: NaiveDate) -> bool {
self.day_trades_in_window(current_date) < Self::PDT_MAX_DAY_TRADES
}
/// Check if stop-loss, trailing stop, or time exit should trigger.
///
/// Exit priority (checked in order):
@@ -408,6 +496,7 @@ impl Backtester {
for (day_num, current_date) in trading_dates.iter().enumerate() {
self.current_bar = day_num;
self.new_positions_this_bar = 0; // Reset counter for each bar
self.prune_old_day_trades(current_date.date_naive()); // PDT window cleanup
// Get current prices and momentum for all symbols
let mut current_prices: HashMap<String, f64> = HashMap::new();
@@ -802,6 +891,15 @@ impl Backtester {
" Re-entry Cooldown: {:>13} bars",
REENTRY_COOLDOWN_BARS
);
if self.pdt_blocked_count > 0 {
println!();
println!("{:^70}", "PDT PROTECTION");
println!("{}", "-".repeat(70));
println!(
" Sells blocked by PDT: {:>15}",
self.pdt_blocked_count
);
}
println!("{}", "=".repeat(70));
// Show recent trades