diff --git a/.claude/agent-memory/consistency-auditor/MEMORY.md b/.claude/agent-memory/consistency-auditor/MEMORY.md index df08c82..1c14f98 100644 --- a/.claude/agent-memory/consistency-auditor/MEMORY.md +++ b/.claude/agent-memory/consistency-auditor/MEMORY.md @@ -1,8 +1,18 @@ # Consistency Auditor Memory -## Last Audit: 2026-02-12 (Regime-Adaptive Dual Strategy Update) +## Last Audit: 2026-02-12 (PDT Protection) -### AUDIT RESULT: ✅ NO CRITICAL BUGS FOUND +### AUDIT RESULT: ⚠️ 1 CRITICAL BUG FOUND + +**PDT (Pattern Day Trading) protection data type mismatch:** +- bot.rs uses `Vec` for day_trades (line 56) +- backtester.rs uses `Vec` for day_trades (line 42) +- **Impact**: String parsing on every PDT check, silent failures on malformed dates, performance degradation +- **Fix required**: Change bot.rs to use `Vec` internally (see detailed fix below) + +--- + +## Previous Audit: 2026-02-12 (Regime-Adaptive Dual Strategy Update) The refactor to extract shared logic into `strategy.rs` has **eliminated all previous consistency issues**. Bot and backtester now share identical implementations for all critical trading logic. @@ -129,6 +139,7 @@ Confidence: `(total_score.abs() / 12.0).min(1.0)` - **Drawdown halt**: 12% triggers 20-bar cooldown (was 35 bars) - **Reentry cooldown**: 5 bars after stop-loss (was 7) - **Ramp-up period**: 15 bars, 1 new position per bar (was 30 bars) +- **PDT protection**: Max 3 day trades in rolling 5-business-day window (bot:34-36; bt:279-280) ### Backtester - **Slippage**: 10 bps per trade @@ -153,6 +164,13 @@ When ATR is zero/unavailable (e.g., low volatility or warmup), code falls back t ### 5. Slippage Modeling is Non-Negotiable The backtester applies 10 bps slippage on both sides (20 bps round-trip) to simulate realistic fills. This prevents overfitting to unrealistic backtest performance. +### 6. Data Type Consistency Matters for PDT Protection +**CRITICAL**: bot.rs and backtester.rs must use the SAME data type for day_trades tracking. Using `Vec` in bot.rs vs `Vec` in backtester.rs creates: +- String parsing overhead on every PDT check +- Silent failures if malformed dates enter the system (parse errors return false) +- Inconsistent error handling between live and backtest +- **Fix**: Change bot.rs to store `Vec` internally, parse once on load, serialize to JSON as strings + --- ## AUDIT CHECKLIST (For Future Audits) @@ -173,17 +191,104 @@ When new changes are made, verify: 12. **Config propagation**: Are new constants used consistently? 13. **NaN handling**: Safe defaults for all indicator checks? 14. **ATR guards**: Checks for `> 0.0` before division? +15. **PDT protection**: Same constants, logic, and DATA TYPES in both files? --- -## FILES AUDITED (2026-02-12) -- `/home/work/Documents/rust/invest-bot/src/bot.rs` (785 lines) -- `/home/work/Documents/rust/invest-bot/src/backtester.rs` (880 lines) -- `/home/work/Documents/rust/invest-bot/src/config.rs` (199 lines) -- `/home/work/Documents/rust/invest-bot/src/indicators.rs` (651 lines) -- `/home/work/Documents/rust/invest-bot/src/strategy.rs` (141 lines) +## FILES AUDITED (2026-02-12 PDT Audit) +- `/home/work/Documents/rust/invest-bot/src/bot.rs` (921 lines) +- `/home/work/Documents/rust/invest-bot/src/backtester.rs` (907 lines) - `/home/work/Documents/rust/invest-bot/src/types.rs` (234 lines) +- `/home/work/Documents/rust/invest-bot/src/paths.rs` (68 lines) -**Total**: 2,890 lines audited -**Issues found**: 0 critical, 0 medium, 0 low -**Status**: ✅ PRODUCTION READY +**Total**: 2,130 lines audited +**Issues found**: 1 critical (data type mismatch), 0 medium, 0 low +**Status**: ⚠️ FIX REQUIRED BEFORE PRODUCTION + +--- + +## CRITICAL FIX REQUIRED: PDT Data Type Mismatch + +**Problem**: bot.rs stores day_trades as `Vec`, backtester.rs stores as `Vec` + +**Required Changes to bot.rs:** + +1. Line 56: Change field type + ```rust + day_trades: Vec, // was Vec + ``` + +2. Lines 197-218: Load with parse-once strategy + ```rust + 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::>(&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), + } + } + _ => {} + } + } + } + ``` + +3. Lines 220-229: Serialize to JSON as strings + ```rust + fn save_day_trades(&self) { + let date_strings: Vec = 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), + } + } + ``` + +4. Lines 231-239: Use native date comparison + ```rust + fn prune_old_day_trades(&mut self) { + let cutoff = self.business_days_ago(PDT_ROLLING_BUSINESS_DAYS); + self.day_trades.retain(|&d| d >= cutoff); + } + ``` + +5. Lines 256-267: Use native date comparison + ```rust + fn day_trades_in_window(&self) -> usize { + let cutoff = self.business_days_ago(PDT_ROLLING_BUSINESS_DAYS); + self.day_trades.iter().filter(|&&d| d >= cutoff).count() + } + ``` + +6. Lines 284-289: Push NaiveDate + ```rust + fn record_day_trade(&mut self) { + let today = Utc::now().date_naive(); + self.day_trades.push(today); + self.save_day_trades(); + } + ``` + +**Benefits of fix:** +- Type safety: malformed dates cannot enter the vec +- Performance: native date comparison vs string parsing on every check +- Consistency: matches backtester implementation exactly +- Reliability: no silent failures from parse errors diff --git a/.claude/agent-memory/quant-rust-strategist/MEMORY.md b/.claude/agent-memory/quant-rust-strategist/MEMORY.md index b8aa458..15312a0 100644 --- a/.claude/agent-memory/quant-rust-strategist/MEMORY.md +++ b/.claude/agent-memory/quant-rust-strategist/MEMORY.md @@ -1,42 +1,48 @@ # Quant-Rust-Strategist Memory ## Architecture Overview -- 50-symbol universe across 9 sectors -- Hybrid momentum + mean-reversion via composite signal scoring in `generate_signal()` -- Backtester restricts buys to top 8 momentum stocks (TOP_MOMENTUM_COUNT=8) -- Signal thresholds: StrongBuy>=6.0, Buy>=4.5, Sell<=-3.5, StrongSell<=-6.0 +- ~100-symbol universe across 14 sectors (expanded from original 50) +- Hybrid momentum + mean-reversion via regime-adaptive dual signal in `generate_signal()` +- strategy.rs: shared logic between bot.rs and backtester.rs +- Backtester restricts buys to top 10 momentum stocks (TOP_MOMENTUM_COUNT=10) +- Signal thresholds: StrongBuy>=7.0, Buy>=4.5, Sell<=-4.0, StrongSell<=-7.0 -## Key Finding: Daily vs Hourly Parameter Sensitivity (2026-02-11) +## PDT Implementation (2026-02-12) +- Tracks day trades in rolling 5-business-day window, max 3 allowed +- CRITICAL: Stop-loss exits must NEVER be blocked by PDT (risk mgmt > compliance) +- Late-day entry prevention: On hourly, block buys after 19:00 UTC (~last 2 hours) + - Prevents entries needing same-day stop-loss exits + - Reduced hourly trades 100->86, improved PF 1.24->1.59 +- "PDT performance degradation" was mostly IEX data stochasticity, not actual PDT blocking -### Daily Timeframe Optimization (Successful) -- Reduced momentum_period 252->63, ema_trend 200->50 in IndicatorParams::daily() -- Reduced warmup from 267 bars to ~70 bars -- Result: Sharpe 0.53->0.86 (+62%), Win rate 40%->50%, PF 1.32->1.52 - -### Hourly Timeframe: DO NOT CHANGE FROM BASELINE -- Hourly IndicatorParams: momentum=63, ema_trend=200 (long lookbacks filter IEX noise) -- Shorter periods (momentum=21, ema_trend=50): CATASTROPHIC -8% loss -- ADX threshold lowered 25->20 (shared const, helps both timeframes) - -### Failed Experiments (avoid repeating) -1. Tighter ATR stop (2.0x): too many stop-outs on hourly. Keep 2.5x -2. Lower buy threshold (3.5): too many weak entries. Keep 4.5 -3. More positions (8): spreads capital too thin. Keep 5 -4. Higher risk per trade (1.0-1.2%): compounds losses. Keep 0.8% -5. Wider trail (2.5x ATR): misses profit on hourly. Keep 1.5x -6. Lower volume threshold (0.7): bad trades on IEX. Keep 0.8 -7. Lower cash reserve (3%): marginal, not worth risk. Keep 5% +## Backtest Results (3-month, 2026-02-12, post-PDT-fix) +### Hourly: +12.00%, Sharpe 0.12, PF 1.59, 52% WR, 86 trades, MaxDD -9.36% +### Daily: +11.68%, Sharpe 2.65, PF 3.07, 61% WR, 18 trades, MaxDD -5.36% ## Current Parameters (config.rs) -- ATR Stop: 2.5x | Trail: 1.5x distance, 1.5x activation -- Risk: 0.8%/trade, max 22% position, 5% cash reserve, 4% max loss -- Max 5 positions, 2/sector | Drawdown halt: 10% (35 bars) | Time exit: 30 -- Cooldown: 7 bars | Ramp-up: 30 bars | Slippage: 10bps -- Daily params: momentum=63, ema_trend=50 -- Hourly params: momentum=63, ema_trend=200 -- ADX: threshold=20, strong=35 +- ATR Stop: 3.0x | Trail: 2.0x distance, 2.0x activation +- Risk: 1.2%/trade, max 25% position, 5% cash reserve, 5% max loss +- Max 7 positions, 2/sector | Drawdown halt: 12% (20 bars) | Time exit: 40 +- Cooldown: 5 bars | Ramp-up: 15 bars | Slippage: 10bps +- Daily: momentum=63, ema_trend=50 | Hourly: momentum=63, ema_trend=200 +- ADX: range<20, trend>25, strong>40 + +## Hourly Timeframe: DO NOT CHANGE FROM BASELINE +- Hourly IndicatorParams: momentum=63, ema_trend=200 (long lookbacks filter IEX noise) +- Shorter periods (momentum=21, ema_trend=50): CATASTROPHIC -8% loss + +## Failed Experiments (avoid repeating) +1. Tighter ATR stop (<3.0x): too many stop-outs on hourly +2. Lower buy threshold (3.5): too many weak entries. Keep 4.5 +3. Blocking stop-loss exits for PDT: traps capital in losers, dangerous +4. Lower volume threshold (0.7): bad trades on IEX. Keep 0.8 +5. Shorter hourly lookbacks: catastrophic losses + +## IEX Data Stochasticity +- Backtests have significant run-to-run variation from IEX data timing +- Do NOT panic about minor performance swings between runs +- Always run 2-3 times and compare ranges before concluding a change helped/hurt ## Build Notes - `cargo build --release` compiles clean (only dead_code warnings) - No tests exist -- Backtests have stochastic variation from IEX data timing diff --git a/src/backtester.rs b/src/backtester.rs index 7eaa888..324a175 100644 --- a/src/backtester.rs +++ b/src/backtester.rs @@ -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, /// 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, + /// 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, 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 = 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 diff --git a/src/bot.rs b/src/bot.rs index 07458b6..e20b41d 100644 --- a/src/bot.rs +++ b/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, } +/// 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, /// 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, } 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::>(&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 = 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) diff --git a/src/paths.rs b/src/paths.rs index 05913bb..5e8cff5 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -51,6 +51,13 @@ lazy_static! { path }; + /// Path to the PDT day trades tracking file. + pub static ref LIVE_DAY_TRADES_FILE: PathBuf = { + let mut path = DATA_DIR.clone(); + path.push("live_day_trades.json"); + path + }; + /// Path to the trading log file. pub static ref LOG_FILE: PathBuf = { let mut path = DATA_DIR.clone();