# Consistency Auditor Memory ## Last Audit: 2026-02-12 (PDT Protection) ### 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. --- ## VERIFIED CONSISTENT (2026-02-12) ### Core Trading Logic ✅ - **Signal generation**: Both use shared `indicators::generate_signal()` (indicators.rs:442-650) - **Position sizing**: Both use shared `Strategy::calculate_position_size()` (strategy.rs:29-55) - Volatility-adjusted via ATR - Confidence scaling: 0.7 + 0.3 * confidence - Max position size cap: 25% - Cash reserve: 5% - **Stop-loss/trailing/time exit**: Both use shared `Strategy::check_stop_loss_take_profit()` (strategy.rs:57-128) - Hard max loss cap: 5% - ATR-based stop: 3.0x ATR below entry - Fixed fallback stop: 2.5% - Trailing stop: 2.0x ATR after 2.0x ATR gain - Time exit: 40 bars if below trailing activation threshold ### Portfolio Controls ✅ - **Cooldown timers**: Both implement 5-bar cooldown after stop-loss (bot:395-406,521-533; bt:133-138,242-247) - **Ramp-up period**: Both limit to 1 new position per bar for first 15 bars (bot:433-441; bt:158-161) - **Drawdown circuit breaker**: Both halt for 20 bars at 12% drawdown (bot:244-268; bt:83-118) - **Sector limits**: Both enforce max 2 per sector (bot:423-430; bt:149-156) - **Max concurrent positions**: Both enforce max 7 (bot:414-421; bt:145-147) - **Momentum ranking**: Both filter to top 10 momentum stocks (bot:669-690; bt:438-449) - **bars_held increment**: Both increment at START of trading cycle/bar (bot:614-617; bt:433-436) ### Warmup Requirements ✅ **Daily mode**: `max(35 MACD, 15 RSI, 50 EMA, 28 ADX, 20 BB, 63 momentum) + 5 = 68 bars` **Hourly mode**: `max(35 MACD, 15 RSI, 200 EMA, 28 ADX, 20 BB, 63 momentum) + 5 = 205 bars` Calculation in `config.rs:169-183` (`IndicatorParams::min_bars()`) - RSI-2/3 warmup covered by RSI-14 requirement (15 > 3) - MACD needs slow + signal periods (26 + 9 = 35) - ADX needs 2x period for smoothing (14 * 2 = 28) - Hourly EMA-200 dominates warmup requirement Both bot.rs and backtester.rs fetch sufficient historical data and validate bar count before trading. --- ## INTENTIONAL DIFFERENCES (Not Bugs) ✅ ### 1. Slippage Modeling - **Backtester**: Applies 10 bps on both entry and exit (backtester.rs:63-71) - **Live bot**: Uses actual fill prices from Alpaca API (bot.rs:456-460) - **Verdict**: Expected difference. Backtester simulates realistic costs; live bot gets market fills. ### 2. RSI Short Period Scaling - **Daily mode**: `rsi_short_period: 2` (Connors RSI-2 for mean reversion) - **Hourly mode**: `rsi_short_period: 3` (adjusted for intraday noise) - **Verdict**: Intentional design choice per comment "Slightly longer for hourly noise" ### 3. EMA Trend Period Scaling - **Daily mode**: `ema_trend: 50` (50-day trend filter) - **Hourly mode**: `ema_trend: 200` (200-hour ≈ 28.5-day trend filter) - **Verdict**: Hourly uses 4x scaling (not 7x like other indicators) for longer-term trend context. Appears intentional. --- ## STRATEGY ARCHITECTURE (2026-02-12) ### Regime-Adaptive Dual Signal The new strategy uses **ADX for regime detection** and switches between two modes: #### RANGE-BOUND (ADX < 20): Mean Reversion - **Entry**: Connors RSI-2 extreme oversold (RSI-2 < 10) + price above 200 EMA - **Exit**: RSI-2 extreme overbought (RSI-2 > 90) or standard exits - **Conviction boosters**: Bollinger Band extremes, volume confirmation - **Logic**: indicators.rs:490-526 #### TRENDING (ADX > 25): Momentum Pullback - **Entry**: Pullbacks in strong trends (RSI-14 dips 25-40, price near EMA support, MACD confirming) - **Exit**: Trend break (EMA crossover down) or standard exits - **Conviction boosters**: Strong trend (ADX > 40), DI+/DI- alignment - **Logic**: indicators.rs:531-557 #### UNIVERSAL SIGNALS (Both Regimes) - RSI-14 extremes in trending context (indicators.rs:564-570) - MACD crossovers (indicators.rs:573-583) - EMA crossovers (indicators.rs:599-608) - Volume gate (reduces scores 50% if volume < 80% of 20-period MA) (indicators.rs:611-614) ### Signal Thresholds - **StrongBuy**: total_score >= 7.0 - **Buy**: total_score >= 4.5 - **StrongSell**: total_score <= -7.0 - **Sell**: total_score <= -4.0 - **Hold**: everything else Confidence: `(total_score.abs() / 12.0).min(1.0)` --- ## CONFIG PARAMETERS (2026-02-12) ### Indicator Periods - RSI: 14 (standard), RSI-2 (daily) / RSI-3 (hourly) for mean reversion - MACD: 12/26/9 (standard) - Momentum: 63 bars - EMA: 9/21/50 (daily), 9/21/200 (hourly) - ADX: 14, thresholds: 20 (range), 25 (trend), 40 (strong) - Bollinger Bands: 20-period, 2 std dev - ATR: 14-period - Volume MA: 20-period, threshold: 0.8x ### Risk Management - **Position sizing**: 1.2% risk per trade (RISK_PER_TRADE) - **ATR stop**: 3.0x ATR below entry (was 2.5x) - **ATR trailing stop**: 2.0x ATR distance, activates after 2.0x ATR gain (was 1.5x/1.5x) - **Max position size**: 25% (was 22%) - **Max loss cap**: 5% (was 4%) - **Stop loss fallback**: 2.5% (when ATR unavailable) - **Time exit**: 40 bars (was 30) - **Cash reserve**: 5% ### Portfolio Limits - **Max concurrent positions**: 7 (was 5) - **Max per sector**: 2 (unchanged) - **Momentum ranking**: Top 10 stocks (was 4) - **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 - **Risk-free rate**: 5% annually for Sharpe/Sortino --- ## KEY LESSONS ### 1. Shared Logic Eliminates Drift Extracting common logic into `strategy.rs` ensures bot and backtester CANNOT diverge. Previously, duplicate implementations led to subtle differences (partial exits, bars_held increment timing, cooldown logic). ### 2. Warmup Must Account for Longest Chain For hourly mode, EMA-200 dominates warmup (205 bars). ADX also needs 2x period (28 bars) for proper smoothing. The `+ 5` safety margin is critical. ### 3. NaN Handling is Critical Indicators can produce NaN during warmup or with insufficient data. The signal generator uses safe defaults (e.g., `if adx.is_nan() { 22.0 }`) to prevent scoring errors. ### 4. ATR Fallbacks Prevent Edge Cases When ATR is zero/unavailable (e.g., low volatility or warmup), code falls back to fixed percentage stops. Without this, position sizing could explode or stops could fail. ### 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) When new changes are made, verify: 1. **Signal generation**: Still using shared `indicators::generate_signal()`? 2. **Position sizing**: Still using shared `Strategy::calculate_position_size()`? 3. **Risk management**: Still using shared `Strategy::check_stop_loss_take_profit()`? 4. **Cooldown timers**: Identical logic in both files? 5. **Ramp-up period**: Identical logic in both files? 6. **Drawdown halt**: Identical trigger and resume logic? 7. **Sector limits**: Same `MAX_SECTOR_POSITIONS` constant? 8. **Max positions**: Same `MAX_CONCURRENT_POSITIONS` constant? 9. **Momentum ranking**: Same `TOP_MOMENTUM_COUNT` constant? 10. **bars_held increment**: Both increment at START of cycle/bar? 11. **Warmup calculation**: Does `min_bars()` cover all indicators? 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 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,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