PDT protection
This commit is contained in:
140
src/bot.rs
140
src/bot.rs
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user