PDT protection
This commit is contained in:
@@ -1,8 +1,18 @@
|
|||||||
# Consistency Auditor Memory
|
# 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<String>` for day_trades (line 56)
|
||||||
|
- backtester.rs uses `Vec<NaiveDate>` 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<NaiveDate>` 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.
|
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)
|
- **Drawdown halt**: 12% triggers 20-bar cooldown (was 35 bars)
|
||||||
- **Reentry cooldown**: 5 bars after stop-loss (was 7)
|
- **Reentry cooldown**: 5 bars after stop-loss (was 7)
|
||||||
- **Ramp-up period**: 15 bars, 1 new position per bar (was 30 bars)
|
- **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
|
### Backtester
|
||||||
- **Slippage**: 10 bps per trade
|
- **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
|
### 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.
|
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<String>` in bot.rs vs `Vec<NaiveDate>` 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<NaiveDate>` internally, parse once on load, serialize to JSON as strings
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AUDIT CHECKLIST (For Future Audits)
|
## AUDIT CHECKLIST (For Future Audits)
|
||||||
@@ -173,17 +191,104 @@ When new changes are made, verify:
|
|||||||
12. **Config propagation**: Are new constants used consistently?
|
12. **Config propagation**: Are new constants used consistently?
|
||||||
13. **NaN handling**: Safe defaults for all indicator checks?
|
13. **NaN handling**: Safe defaults for all indicator checks?
|
||||||
14. **ATR guards**: Checks for `> 0.0` before division?
|
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)
|
## FILES AUDITED (2026-02-12 PDT Audit)
|
||||||
- `/home/work/Documents/rust/invest-bot/src/bot.rs` (785 lines)
|
- `/home/work/Documents/rust/invest-bot/src/bot.rs` (921 lines)
|
||||||
- `/home/work/Documents/rust/invest-bot/src/backtester.rs` (880 lines)
|
- `/home/work/Documents/rust/invest-bot/src/backtester.rs` (907 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)
|
|
||||||
- `/home/work/Documents/rust/invest-bot/src/types.rs` (234 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
|
**Total**: 2,130 lines audited
|
||||||
**Issues found**: 0 critical, 0 medium, 0 low
|
**Issues found**: 1 critical (data type mismatch), 0 medium, 0 low
|
||||||
**Status**: ✅ PRODUCTION READY
|
**Status**: ⚠️ FIX REQUIRED BEFORE PRODUCTION
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL FIX REQUIRED: PDT Data Type Mismatch
|
||||||
|
|
||||||
|
**Problem**: bot.rs stores day_trades as `Vec<String>`, backtester.rs stores as `Vec<NaiveDate>`
|
||||||
|
|
||||||
|
**Required Changes to bot.rs:**
|
||||||
|
|
||||||
|
1. Line 56: Change field type
|
||||||
|
```rust
|
||||||
|
day_trades: Vec<NaiveDate>, // was Vec<String>
|
||||||
|
```
|
||||||
|
|
||||||
|
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::<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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Lines 220-229: Serialize to JSON as strings
|
||||||
|
```rust
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,42 +1,48 @@
|
|||||||
# Quant-Rust-Strategist Memory
|
# Quant-Rust-Strategist Memory
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
- 50-symbol universe across 9 sectors
|
- ~100-symbol universe across 14 sectors (expanded from original 50)
|
||||||
- Hybrid momentum + mean-reversion via composite signal scoring in `generate_signal()`
|
- Hybrid momentum + mean-reversion via regime-adaptive dual signal in `generate_signal()`
|
||||||
- Backtester restricts buys to top 8 momentum stocks (TOP_MOMENTUM_COUNT=8)
|
- strategy.rs: shared logic between bot.rs and backtester.rs
|
||||||
- Signal thresholds: StrongBuy>=6.0, Buy>=4.5, Sell<=-3.5, StrongSell<=-6.0
|
- 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)
|
## Backtest Results (3-month, 2026-02-12, post-PDT-fix)
|
||||||
- Reduced momentum_period 252->63, ema_trend 200->50 in IndicatorParams::daily()
|
### Hourly: +12.00%, Sharpe 0.12, PF 1.59, 52% WR, 86 trades, MaxDD -9.36%
|
||||||
- Reduced warmup from 267 bars to ~70 bars
|
### Daily: +11.68%, Sharpe 2.65, PF 3.07, 61% WR, 18 trades, MaxDD -5.36%
|
||||||
- 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%
|
|
||||||
|
|
||||||
## Current Parameters (config.rs)
|
## Current Parameters (config.rs)
|
||||||
- ATR Stop: 2.5x | Trail: 1.5x distance, 1.5x activation
|
- ATR Stop: 3.0x | Trail: 2.0x distance, 2.0x activation
|
||||||
- Risk: 0.8%/trade, max 22% position, 5% cash reserve, 4% max loss
|
- Risk: 1.2%/trade, max 25% position, 5% cash reserve, 5% max loss
|
||||||
- Max 5 positions, 2/sector | Drawdown halt: 10% (35 bars) | Time exit: 30
|
- Max 7 positions, 2/sector | Drawdown halt: 12% (20 bars) | Time exit: 40
|
||||||
- Cooldown: 7 bars | Ramp-up: 30 bars | Slippage: 10bps
|
- Cooldown: 5 bars | Ramp-up: 15 bars | Slippage: 10bps
|
||||||
- Daily params: momentum=63, ema_trend=50
|
- Daily: momentum=63, ema_trend=50 | Hourly: momentum=63, ema_trend=200
|
||||||
- Hourly params: momentum=63, ema_trend=200
|
- ADX: range<20, trend>25, strong>40
|
||||||
- ADX: threshold=20, strong=35
|
|
||||||
|
## 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
|
## Build Notes
|
||||||
- `cargo build --release` compiles clean (only dead_code warnings)
|
- `cargo build --release` compiles clean (only dead_code warnings)
|
||||||
- No tests exist
|
- No tests exist
|
||||||
- Backtests have stochastic variation from IEX data timing
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Backtesting engine for the trading strategy.
|
//! Backtesting engine for the trading strategy.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc};
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
|
||||||
use crate::alpaca::{fetch_backtest_data, AlpacaClient};
|
use crate::alpaca::{fetch_backtest_data, AlpacaClient};
|
||||||
@@ -38,6 +38,10 @@ pub struct Backtester {
|
|||||||
cooldown_timers: HashMap<String, usize>,
|
cooldown_timers: HashMap<String, usize>,
|
||||||
/// Tracks new positions opened in current bar (for gradual ramp-up)
|
/// Tracks new positions opened in current bar (for gradual ramp-up)
|
||||||
new_positions_this_bar: usize,
|
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 {
|
impl Backtester {
|
||||||
@@ -57,6 +61,8 @@ impl Backtester {
|
|||||||
current_bar: 0,
|
current_bar: 0,
|
||||||
cooldown_timers: HashMap::new(),
|
cooldown_timers: HashMap::new(),
|
||||||
new_positions_this_bar: 0,
|
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.
|
/// 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(
|
fn execute_buy(
|
||||||
&mut self,
|
&mut self,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
@@ -130,6 +140,19 @@ impl Backtester {
|
|||||||
return false;
|
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
|
// Cooldown guard: prevent whipsaw re-entry after stop-loss
|
||||||
if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) {
|
if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) {
|
||||||
if self.current_bar < cooldown_until {
|
if self.current_bar < cooldown_until {
|
||||||
@@ -205,6 +228,12 @@ impl Backtester {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a simulated full sell order with slippage.
|
/// 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(
|
fn execute_sell(
|
||||||
&mut self,
|
&mut self,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
@@ -212,11 +241,25 @@ impl Backtester {
|
|||||||
timestamp: DateTime<Utc>,
|
timestamp: DateTime<Utc>,
|
||||||
was_stop_loss: bool,
|
was_stop_loss: bool,
|
||||||
) -> 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) {
|
let position = match self.positions.remove(symbol) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => return false,
|
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 fill_price = Self::apply_slippage(price, "sell");
|
||||||
let proceeds = position.shares * fill_price;
|
let proceeds = position.shares * fill_price;
|
||||||
self.cash += proceeds;
|
self.cash += proceeds;
|
||||||
@@ -254,6 +297,51 @@ impl Backtester {
|
|||||||
// avg_win < avg_loss profile. The trailing stop alone provides adequate
|
// avg_win < avg_loss profile. The trailing stop alone provides adequate
|
||||||
// profit protection without splitting winners into smaller fragments.
|
// 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.
|
/// Check if stop-loss, trailing stop, or time exit should trigger.
|
||||||
///
|
///
|
||||||
/// Exit priority (checked in order):
|
/// Exit priority (checked in order):
|
||||||
@@ -408,6 +496,7 @@ impl Backtester {
|
|||||||
for (day_num, current_date) in trading_dates.iter().enumerate() {
|
for (day_num, current_date) in trading_dates.iter().enumerate() {
|
||||||
self.current_bar = day_num;
|
self.current_bar = day_num;
|
||||||
self.new_positions_this_bar = 0; // Reset counter for each bar
|
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
|
// Get current prices and momentum for all symbols
|
||||||
let mut current_prices: HashMap<String, f64> = HashMap::new();
|
let mut current_prices: HashMap<String, f64> = HashMap::new();
|
||||||
@@ -802,6 +891,15 @@ impl Backtester {
|
|||||||
" Re-entry Cooldown: {:>13} bars",
|
" Re-entry Cooldown: {:>13} bars",
|
||||||
REENTRY_COOLDOWN_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));
|
println!("{}", "=".repeat(70));
|
||||||
|
|
||||||
// Show recent trades
|
// Show recent trades
|
||||||
|
|||||||
140
src/bot.rs
140
src/bot.rs
@@ -1,7 +1,7 @@
|
|||||||
//! Live trading bot using Alpaca API.
|
//! Live trading bot using Alpaca API.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Datelike, Duration, NaiveDate, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio::time::{sleep, Duration as TokioDuration};
|
use tokio::time::{sleep, Duration as TokioDuration};
|
||||||
@@ -16,8 +16,8 @@ use crate::config::{
|
|||||||
};
|
};
|
||||||
use crate::indicators::{calculate_all_indicators, generate_signal};
|
use crate::indicators::{calculate_all_indicators, generate_signal};
|
||||||
use crate::paths::{
|
use crate::paths::{
|
||||||
LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE, LIVE_POSITIONS_FILE,
|
LIVE_DAY_TRADES_FILE, LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE,
|
||||||
LIVE_POSITION_META_FILE,
|
LIVE_POSITIONS_FILE, LIVE_POSITION_META_FILE,
|
||||||
};
|
};
|
||||||
use crate::strategy::Strategy;
|
use crate::strategy::Strategy;
|
||||||
use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal};
|
use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal};
|
||||||
@@ -26,8 +26,15 @@ use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal};
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct PositionMeta {
|
struct PositionMeta {
|
||||||
bars_held: usize,
|
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.
|
/// Live trading bot for paper trading.
|
||||||
pub struct TradingBot {
|
pub struct TradingBot {
|
||||||
client: AlpacaClient,
|
client: AlpacaClient,
|
||||||
@@ -45,6 +52,8 @@ pub struct TradingBot {
|
|||||||
cooldown_timers: HashMap<String, usize>,
|
cooldown_timers: HashMap<String, usize>,
|
||||||
/// Tracks new positions opened in current cycle (for gradual ramp-up)
|
/// Tracks new positions opened in current cycle (for gradual ramp-up)
|
||||||
new_positions_this_cycle: usize,
|
new_positions_this_cycle: usize,
|
||||||
|
/// Rolling list of day trade dates for PDT tracking.
|
||||||
|
day_trades: Vec<NaiveDate>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TradingBot {
|
impl TradingBot {
|
||||||
@@ -68,6 +77,7 @@ impl TradingBot {
|
|||||||
trading_cycle_count: 0,
|
trading_cycle_count: 0,
|
||||||
cooldown_timers: HashMap::new(),
|
cooldown_timers: HashMap::new(),
|
||||||
new_positions_this_cycle: 0,
|
new_positions_this_cycle: 0,
|
||||||
|
day_trades: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load persisted state
|
// Load persisted state
|
||||||
@@ -76,6 +86,7 @@ impl TradingBot {
|
|||||||
bot.load_entry_atrs();
|
bot.load_entry_atrs();
|
||||||
bot.load_position_meta();
|
bot.load_position_meta();
|
||||||
bot.load_cooldown_timers();
|
bot.load_cooldown_timers();
|
||||||
|
bot.load_day_trades();
|
||||||
bot.load_equity_history();
|
bot.load_equity_history();
|
||||||
|
|
||||||
// Log account info
|
// 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) {
|
fn load_equity_history(&mut self) {
|
||||||
if LIVE_EQUITY_FILE.exists() {
|
if LIVE_EQUITY_FILE.exists() {
|
||||||
match std::fs::read_to_string(&*LIVE_EQUITY_FILE) {
|
match std::fs::read_to_string(&*LIVE_EQUITY_FILE) {
|
||||||
@@ -466,6 +568,7 @@ impl TradingBot {
|
|||||||
symbol.to_string(),
|
symbol.to_string(),
|
||||||
PositionMeta {
|
PositionMeta {
|
||||||
bars_held: 0,
|
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
|
match self
|
||||||
.client
|
.client
|
||||||
.submit_market_order(symbol, current_position, "sell")
|
.submit_market_order(symbol, current_position, "sell")
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_order) => {
|
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) {
|
if let Some(entry) = self.strategy.entry_prices.remove(symbol) {
|
||||||
let pnl_pct = (signal.current_price - entry) / entry;
|
let pnl_pct = (signal.current_price - entry) / entry;
|
||||||
tracing::info!("{}: Realized P&L: {:.2}%", symbol, pnl_pct * 100.0);
|
tracing::info!("{}: Realized P&L: {:.2}%", symbol, pnl_pct * 100.0);
|
||||||
@@ -607,8 +735,14 @@ impl TradingBot {
|
|||||||
async fn run_trading_cycle(&mut self) {
|
async fn run_trading_cycle(&mut self) {
|
||||||
self.trading_cycle_count += 1;
|
self.trading_cycle_count += 1;
|
||||||
self.new_positions_this_cycle = 0; // Reset counter for each cycle
|
self.new_positions_this_cycle = 0; // Reset counter for each cycle
|
||||||
|
self.prune_old_day_trades();
|
||||||
tracing::info!("{}", "=".repeat(60));
|
tracing::info!("{}", "=".repeat(60));
|
||||||
tracing::info!("Starting trading cycle #{}...", self.trading_cycle_count);
|
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;
|
self.log_account_info().await;
|
||||||
|
|
||||||
// Increment bars_held once per trading cycle (matches backtester's per-bar increment)
|
// Increment bars_held once per trading cycle (matches backtester's per-bar increment)
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ lazy_static! {
|
|||||||
path
|
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.
|
/// Path to the trading log file.
|
||||||
pub static ref LOG_FILE: PathBuf = {
|
pub static ref LOG_FILE: PathBuf = {
|
||||||
let mut path = DATA_DIR.clone();
|
let mut path = DATA_DIR.clone();
|
||||||
|
|||||||
Reference in New Issue
Block a user