Files
vibe-invest/.claude/agent-memory/consistency-auditor/MEMORY.md
2026-02-12 18:14:53 +00:00

12 KiB

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<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.


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
  • 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<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)

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<String>, backtester.rs stores as Vec<NaiveDate>

Required Changes to bot.rs:

  1. Line 56: Change field type

    day_trades: Vec<NaiveDate>,  // was Vec<String>
    
  2. Lines 197-218: Load with parse-once strategy

    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

    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

    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

    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

    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