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 @@
//! Live trading bot using Alpaca API.
use anyhow::Result;
use chrono::{Duration, Utc};
use chrono::{Datelike, Duration, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::time::{sleep, Duration as TokioDuration};
@@ -16,8 +16,8 @@ use crate::config::{
};
use crate::indicators::{calculate_all_indicators, generate_signal};
use crate::paths::{
LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE, LIVE_POSITIONS_FILE,
LIVE_POSITION_META_FILE,
LIVE_DAY_TRADES_FILE, LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE,
LIVE_POSITIONS_FILE, LIVE_POSITION_META_FILE,
};
use crate::strategy::Strategy;
use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal};
@@ -26,8 +26,15 @@ use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PositionMeta {
bars_held: usize,
/// Date (YYYY-MM-DD) when this position was opened, for PDT tracking.
#[serde(default)]
entry_date: Option<String>,
}
/// PDT (Pattern Day Trading) constants.
const PDT_MAX_DAY_TRADES: usize = 3;
const PDT_ROLLING_BUSINESS_DAYS: i64 = 5;
/// Live trading bot for paper trading.
pub struct TradingBot {
client: AlpacaClient,
@@ -45,6 +52,8 @@ pub struct TradingBot {
cooldown_timers: HashMap<String, usize>,
/// Tracks new positions opened in current cycle (for gradual ramp-up)
new_positions_this_cycle: usize,
/// Rolling list of day trade dates for PDT tracking.
day_trades: Vec<NaiveDate>,
}
impl TradingBot {
@@ -68,6 +77,7 @@ impl TradingBot {
trading_cycle_count: 0,
cooldown_timers: HashMap::new(),
new_positions_this_cycle: 0,
day_trades: Vec::new(),
};
// Load persisted state
@@ -76,6 +86,7 @@ impl TradingBot {
bot.load_entry_atrs();
bot.load_position_meta();
bot.load_cooldown_timers();
bot.load_day_trades();
bot.load_equity_history();
// Log account info
@@ -181,6 +192,97 @@ impl TradingBot {
}
}
// ── PDT (Pattern Day Trading) protection ───────────────────────
fn load_day_trades(&mut self) {
if LIVE_DAY_TRADES_FILE.exists() {
match std::fs::read_to_string(&*LIVE_DAY_TRADES_FILE) {
Ok(content) if !content.is_empty() => {
match serde_json::from_str::<Vec<String>>(&content) {
Ok(date_strings) => {
self.day_trades = date_strings
.iter()
.filter_map(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
.collect();
self.prune_old_day_trades();
if !self.day_trades.is_empty() {
tracing::info!(
"Loaded {} day trades in rolling window.",
self.day_trades.len()
);
}
}
Err(e) => tracing::error!("Error parsing day trades file: {}", e),
}
}
_ => {}
}
}
}
fn save_day_trades(&self) {
let date_strings: Vec<String> = self
.day_trades
.iter()
.map(|d| d.format("%Y-%m-%d").to_string())
.collect();
match serde_json::to_string_pretty(&date_strings) {
Ok(json) => {
if let Err(e) = std::fs::write(&*LIVE_DAY_TRADES_FILE, json) {
tracing::error!("Error saving day trades file: {}", e);
}
}
Err(e) => tracing::error!("Error serializing day trades: {}", e),
}
}
/// Remove day trades older than the 5-business-day rolling window.
fn prune_old_day_trades(&mut self) {
let cutoff = Self::business_days_before(Utc::now().date_naive(), 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 how many day trades have occurred in the rolling 5-business-day window.
fn day_trades_in_window(&self) -> usize {
let cutoff = Self::business_days_before(Utc::now().date_naive(), PDT_ROLLING_BUSINESS_DAYS);
self.day_trades.iter().filter(|&&d| d >= cutoff).count()
}
/// Check if selling this symbol today would be a day trade (bought today).
fn would_be_day_trade(&self, symbol: &str) -> bool {
let today = Utc::now().date_naive().format("%Y-%m-%d").to_string();
self.position_meta
.get(symbol)
.and_then(|m| m.entry_date.as_ref())
.map(|d| d == &today)
.unwrap_or(false)
}
/// Check if a day trade is allowed (under PDT limit).
fn can_day_trade(&self) -> bool {
self.day_trades_in_window() < PDT_MAX_DAY_TRADES
}
/// Record a day trade.
fn record_day_trade(&mut self) {
self.day_trades.push(Utc::now().date_naive());
self.save_day_trades();
}
fn load_equity_history(&mut self) {
if LIVE_EQUITY_FILE.exists() {
match std::fs::read_to_string(&*LIVE_EQUITY_FILE) {
@@ -466,6 +568,7 @@ impl TradingBot {
symbol.to_string(),
PositionMeta {
bars_held: 0,
entry_date: Some(Utc::now().format("%Y-%m-%d").to_string()),
},
);
@@ -500,12 +603,37 @@ impl TradingBot {
}
};
// PDT protection: if selling today would create a day trade, check the limit.
// EXCEPTION: stop-loss exits are NEVER blocked -- risk management takes priority
// over PDT compliance. The correct defense against PDT violations is to prevent
// entries that would need same-day exits, not to trap capital in losing positions.
let is_day_trade = self.would_be_day_trade(symbol);
if is_day_trade && !was_stop_loss && !self.can_day_trade() {
let count = self.day_trades_in_window();
tracing::warn!(
"{}: SKIPPING SELL — would trigger PDT violation ({}/{} day trades in rolling 5-day window). \
Position opened today, will sell tomorrow.",
symbol, count, PDT_MAX_DAY_TRADES
);
return false;
}
match self
.client
.submit_market_order(symbol, current_position, "sell")
.await
{
Ok(_order) => {
// Record the day trade if applicable
if is_day_trade {
self.record_day_trade();
tracing::info!(
"{}: Day trade recorded ({}/{} in rolling window)",
symbol,
self.day_trades_in_window(),
PDT_MAX_DAY_TRADES
);
}
if let Some(entry) = self.strategy.entry_prices.remove(symbol) {
let pnl_pct = (signal.current_price - entry) / entry;
tracing::info!("{}: Realized P&L: {:.2}%", symbol, pnl_pct * 100.0);
@@ -607,8 +735,14 @@ impl TradingBot {
async fn run_trading_cycle(&mut self) {
self.trading_cycle_count += 1;
self.new_positions_this_cycle = 0; // Reset counter for each cycle
self.prune_old_day_trades();
tracing::info!("{}", "=".repeat(60));
tracing::info!("Starting trading cycle #{}...", self.trading_cycle_count);
tracing::info!(
"PDT status: {}/{} day trades in rolling 5-business-day window",
self.day_trades_in_window(),
PDT_MAX_DAY_TRADES
);
self.log_account_info().await;
// Increment bars_held once per trading cycle (matches backtester's per-bar increment)