PDT protection
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user