it be better
This commit is contained in:
@@ -1,33 +1,49 @@
|
|||||||
# Consistency Auditor Memory
|
# Consistency Auditor Memory
|
||||||
|
|
||||||
## Last Audit: 2026-02-12 (PDT Protection)
|
## Last Audit: 2026-02-13 (Post Config Update)
|
||||||
|
|
||||||
### AUDIT RESULT: ⚠️ 1 CRITICAL BUG FOUND
|
### AUDIT RESULT: ⚠️ 1 CRITICAL BUG + 1 CRITICAL BEHAVIORAL DIVERGENCE
|
||||||
|
|
||||||
**PDT (Pattern Day Trading) protection data type mismatch:**
|
**1. CRITICAL BUG: Drawdown Peak Reset Inconsistency**
|
||||||
- bot.rs uses `Vec<String>` for day_trades (line 56)
|
- **backtester.rs** resets `peak_portfolio_value` to current value on halt expiry (line 132)
|
||||||
- backtester.rs uses `Vec<NaiveDate>` for day_trades (line 42)
|
- **bot.rs** does NOT reset peak on halt expiry (lines 365-377)
|
||||||
- **Impact**: String parsing on every PDT check, silent failures on malformed dates, performance degradation
|
- **Impact**: Bot will re-trigger drawdown halt more frequently than backtest suggests, spending more time in cash. After a 15% drawdown triggers a 10-bar halt, a partial recovery followed by a minor dip will immediately re-trigger the halt in live trading but not in backtest.
|
||||||
- **Fix required**: Change bot.rs to use `Vec<NaiveDate>` internally (see detailed fix below)
|
- **Fix**: Add `self.peak_portfolio_value = portfolio_value;` to bot.rs after line 374 (inside the halt expiry block)
|
||||||
|
- **Code location**: `/home/work/Documents/rust/invest-bot/src/bot.rs:365-377`
|
||||||
|
|
||||||
|
**2. CRITICAL DIVERGENCE: PDT Enforcement Differs by Timeframe**
|
||||||
|
- **bot.rs** enforces PDT blocking on non-stop-loss sells (lines 619-628), with $25K exemption (lines 281-285)
|
||||||
|
- **backtester.rs** has PDT DISABLED entirely for backtest (lines 245-248: "informational only, not blocking")
|
||||||
|
- **Impact by timeframe**:
|
||||||
|
- **Daily mode**: No impact (buys in Phase 2, sells in Phase 1 on different bars → day trades impossible by design)
|
||||||
|
- **Hourly mode**: Potential divergence IF portfolio < $25K. Bot may skip exits to avoid PDT violation, holding positions overnight that backtest would have exited same-day. Backtester relies on late-day entry prevention (line 158-166) instead of exit blocking.
|
||||||
|
- **Portfolios >= $25K**: No divergence (PDT rule doesn't apply)
|
||||||
|
- **Verdict**: Acceptable for daily mode. Document for hourly mode. If hourly is primary deployment, verify backtest PDT day-trade count matches late-day entry prevention expectations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Previous Audit: 2026-02-12 (Regime-Adaptive Dual Strategy Update)
|
## Config Changes Since Last Audit (2026-02-13)
|
||||||
|
|
||||||
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.
|
User reported these config changes:
|
||||||
|
- Drawdown halt: 12% → 15% (`MAX_DRAWDOWN_HALT`)
|
||||||
|
- Drawdown cooldown: 20 bars → 10 bars (`DRAWDOWN_HALT_BARS`)
|
||||||
|
- Momentum pool: 10 stocks → 20 stocks (`TOP_MOMENTUM_COUNT`)
|
||||||
|
- Buy threshold: 4.5 → 4.0 (in signal generation)
|
||||||
|
|
||||||
|
**Verified**: All constants consistent between bot.rs and backtester.rs ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## VERIFIED CONSISTENT (2026-02-12)
|
## VERIFIED CONSISTENT (2026-02-13 Audit) ✅
|
||||||
|
|
||||||
### Core Trading Logic ✅
|
### Core Trading Logic ✅
|
||||||
- **Signal generation**: Both use shared `indicators::generate_signal()` (indicators.rs:442-650)
|
- **Signal generation**: Both use shared `indicators::generate_signal()` (bot:739; bt:583,630)
|
||||||
- **Position sizing**: Both use shared `Strategy::calculate_position_size()` (strategy.rs:29-55)
|
- **Position sizing**: Both use shared `Strategy::calculate_position_size()` (bot:463-468; bt:199-201)
|
||||||
- Volatility-adjusted via ATR
|
- Volatility-adjusted via ATR
|
||||||
- Confidence scaling: 0.7 + 0.3 * confidence
|
- Confidence scaling: 0.7 + 0.3 * confidence
|
||||||
- Max position size cap: 25%
|
- Max position size cap: 25%
|
||||||
- Cash reserve: 5%
|
- Cash reserve: 5%
|
||||||
- **Stop-loss/trailing/time exit**: Both use shared `Strategy::check_stop_loss_take_profit()` (strategy.rs:57-128)
|
- **Stop-loss/trailing/time exit**: Both use shared `Strategy::check_stop_loss_take_profit()` (bot:473-486; bt:373-380)
|
||||||
- Hard max loss cap: 5%
|
- Hard max loss cap: 5%
|
||||||
- ATR-based stop: 3.0x ATR below entry
|
- ATR-based stop: 3.0x ATR below entry
|
||||||
- Fixed fallback stop: 2.5%
|
- Fixed fallback stop: 2.5%
|
||||||
@@ -35,19 +51,37 @@ The refactor to extract shared logic into `strategy.rs` has **eliminated all pre
|
|||||||
- Time exit: 40 bars if below trailing activation threshold
|
- Time exit: 40 bars if below trailing activation threshold
|
||||||
|
|
||||||
### Portfolio Controls ✅
|
### Portfolio Controls ✅
|
||||||
- **Cooldown timers**: Both implement 5-bar cooldown after stop-loss (bot:395-406,521-533; bt:133-138,242-247)
|
- **Cooldown timers**: Both implement 5-bar cooldown after stop-loss (bot:507-517,659-670; bt:169-173,294-299)
|
||||||
- **Ramp-up period**: Both limit to 1 new position per bar for first 15 bars (bot:433-441; bt:158-161)
|
- **Ramp-up period**: Both limit to 1 new position per cycle/bar for first 15 bars (bot:543-552; bt:194-196)
|
||||||
- **Drawdown circuit breaker**: Both halt for 20 bars at 12% drawdown (bot:244-268; bt:83-118)
|
- **Drawdown circuit breaker**: Both trigger at 15% with 10-bar cooldown (bot:353-362; bt:104-113)
|
||||||
- **Sector limits**: Both enforce max 2 per sector (bot:423-430; bt:149-156)
|
- **BUT**: bot.rs missing peak reset on expiry (see Critical Bug #1)
|
||||||
- **Max concurrent positions**: Both enforce max 7 (bot:414-421; bt:145-147)
|
- **Sector limits**: Both enforce max 2 per sector (bot:534-541; bt:184-191)
|
||||||
- **Momentum ranking**: Both filter to top 10 momentum stocks (bot:669-690; bt:438-449)
|
- **Max concurrent positions**: Both enforce max 7 (bot:525-532; bt:180-182)
|
||||||
- **bars_held increment**: Both increment at START of trading cycle/bar (bot:614-617; bt:433-436)
|
- **Momentum ranking**: Both filter to top 20 momentum stocks (bot:818-838; bt:543-554)
|
||||||
|
- **bars_held increment**: Both increment at START of trading cycle/bar (bot:763-765; bt:539-541)
|
||||||
|
|
||||||
|
### Config Constants — ALL CONSISTENT ✅
|
||||||
|
Both files import and use identical values from config.rs:
|
||||||
|
- `ATR_STOP_MULTIPLIER`: 3.0x
|
||||||
|
- `ATR_TRAIL_MULTIPLIER`: 2.0x
|
||||||
|
- `ATR_TRAIL_ACTIVATION_MULTIPLIER`: 2.0x
|
||||||
|
- `MAX_POSITION_SIZE`: 25%
|
||||||
|
- `MAX_CONCURRENT_POSITIONS`: 7
|
||||||
|
- `MAX_SECTOR_POSITIONS`: 2
|
||||||
|
- `MAX_DRAWDOWN_HALT`: 15% (updated from 12%)
|
||||||
|
- `DRAWDOWN_HALT_BARS`: 10 (updated from 20)
|
||||||
|
- `REENTRY_COOLDOWN_BARS`: 5
|
||||||
|
- `RAMPUP_PERIOD_BARS`: 15
|
||||||
|
- `TOP_MOMENTUM_COUNT`: 20 (updated from 10)
|
||||||
|
- `TIME_EXIT_BARS`: 40
|
||||||
|
- `MIN_CASH_RESERVE`: 5%
|
||||||
|
- `MAX_LOSS_PCT`: 5%
|
||||||
|
|
||||||
### Warmup Requirements ✅
|
### Warmup Requirements ✅
|
||||||
**Daily mode**: `max(35 MACD, 15 RSI, 50 EMA, 28 ADX, 20 BB, 63 momentum) + 5 = 68 bars`
|
**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`
|
**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()`)
|
Calculation in `config.rs:193-206` (`IndicatorParams::min_bars()`)
|
||||||
- RSI-2/3 warmup covered by RSI-14 requirement (15 > 3)
|
- RSI-2/3 warmup covered by RSI-14 requirement (15 > 3)
|
||||||
- MACD needs slow + signal periods (26 + 9 = 35)
|
- MACD needs slow + signal periods (26 + 9 = 35)
|
||||||
- ADX needs 2x period for smoothing (14 * 2 = 28)
|
- ADX needs 2x period for smoothing (14 * 2 = 28)
|
||||||
@@ -55,13 +89,20 @@ Calculation in `config.rs:169-183` (`IndicatorParams::min_bars()`)
|
|||||||
|
|
||||||
Both bot.rs and backtester.rs fetch sufficient historical data and validate bar count before trading.
|
Both bot.rs and backtester.rs fetch sufficient historical data and validate bar count before trading.
|
||||||
|
|
||||||
|
### Entry/Exit Flow ✅
|
||||||
|
**Both follow identical two-phase execution**:
|
||||||
|
- Phase 1: Process all sells (stop-loss, trailing, time exit, signals)
|
||||||
|
- Phase 2: Process buys for top momentum stocks only
|
||||||
|
- bot.rs lines 800-849
|
||||||
|
- backtester.rs lines 556-642
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## INTENTIONAL DIFFERENCES (Not Bugs) ✅
|
## INTENTIONAL DIFFERENCES (Not Bugs) ✅
|
||||||
|
|
||||||
### 1. Slippage Modeling
|
### 1. Slippage Modeling
|
||||||
- **Backtester**: Applies 10 bps on both entry and exit (backtester.rs:63-71)
|
- **Backtester**: Applies 10 bps on both entry and exit (backtester.rs:71-78)
|
||||||
- **Live bot**: Uses actual fill prices from Alpaca API (bot.rs:456-460)
|
- **Live bot**: Uses actual fill prices from Alpaca API (bot.rs:567-571)
|
||||||
- **Verdict**: Expected difference. Backtester simulates realistic costs; live bot gets market fills.
|
- **Verdict**: Expected difference. Backtester simulates realistic costs; live bot gets market fills.
|
||||||
|
|
||||||
### 2. RSI Short Period Scaling
|
### 2. RSI Short Period Scaling
|
||||||
@@ -74,34 +115,37 @@ Both bot.rs and backtester.rs fetch sufficient historical data and validate bar
|
|||||||
- **Hourly mode**: `ema_trend: 200` (200-hour ≈ 28.5-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.
|
- **Verdict**: Hourly uses 4x scaling (not 7x like other indicators) for longer-term trend context. Appears intentional.
|
||||||
|
|
||||||
|
### 4. Hourly Late-Day Entry Prevention
|
||||||
|
- **Backtester**: Blocks entries after 19:00 UTC in hourly mode (backtester.rs:158-166) to prevent same-day stop-loss exits
|
||||||
|
- **Bot**: Relies on PDT exit blocking instead (bot.rs:619-628)
|
||||||
|
- **Verdict**: Two different approaches to PDT prevention. See Critical Divergence #2.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## STRATEGY ARCHITECTURE (2026-02-12)
|
## STRATEGY ARCHITECTURE (2026-02-12, Still Current)
|
||||||
|
|
||||||
### Regime-Adaptive Dual Signal
|
### Regime-Adaptive Dual Signal
|
||||||
The new strategy uses **ADX for regime detection** and switches between two modes:
|
The strategy uses **ADX for regime detection** and switches between two modes:
|
||||||
|
|
||||||
#### RANGE-BOUND (ADX < 20): Mean Reversion
|
#### RANGE-BOUND (ADX < 20): Mean Reversion
|
||||||
- **Entry**: Connors RSI-2 extreme oversold (RSI-2 < 10) + price above 200 EMA
|
- **Entry**: Connors RSI-2 extreme oversold (RSI-2 < 10) + price above EMA trend
|
||||||
- **Exit**: RSI-2 extreme overbought (RSI-2 > 90) or standard exits
|
- **Exit**: RSI-2 extreme overbought (RSI-2 > 90) or standard exits
|
||||||
- **Conviction boosters**: Bollinger Band extremes, volume confirmation
|
- **Conviction boosters**: Bollinger Band extremes, volume confirmation
|
||||||
- **Logic**: indicators.rs:490-526
|
|
||||||
|
|
||||||
#### TRENDING (ADX > 25): Momentum Pullback
|
#### TRENDING (ADX > 25): Momentum Pullback
|
||||||
- **Entry**: Pullbacks in strong trends (RSI-14 dips 25-40, price near EMA support, MACD confirming)
|
- **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
|
- **Exit**: Trend break (EMA crossover down) or standard exits
|
||||||
- **Conviction boosters**: Strong trend (ADX > 40), DI+/DI- alignment
|
- **Conviction boosters**: Strong trend (ADX > 40), DI+/DI- alignment
|
||||||
- **Logic**: indicators.rs:531-557
|
|
||||||
|
|
||||||
#### UNIVERSAL SIGNALS (Both Regimes)
|
#### UNIVERSAL SIGNALS (Both Regimes)
|
||||||
- RSI-14 extremes in trending context (indicators.rs:564-570)
|
- RSI-14 extremes in trending context
|
||||||
- MACD crossovers (indicators.rs:573-583)
|
- MACD crossovers
|
||||||
- EMA crossovers (indicators.rs:599-608)
|
- EMA crossovers
|
||||||
- Volume gate (reduces scores 50% if volume < 80% of 20-period MA) (indicators.rs:611-614)
|
- Volume gate (reduces scores 50% if volume < 80% of 20-period MA)
|
||||||
|
|
||||||
### Signal Thresholds
|
### Signal Thresholds (Updated 2026-02-13)
|
||||||
- **StrongBuy**: total_score >= 7.0
|
- **StrongBuy**: total_score >= 7.0
|
||||||
- **Buy**: total_score >= 4.5
|
- **Buy**: total_score >= 4.0 (was 4.5)
|
||||||
- **StrongSell**: total_score <= -7.0
|
- **StrongSell**: total_score <= -7.0
|
||||||
- **Sell**: total_score <= -4.0
|
- **Sell**: total_score <= -4.0
|
||||||
- **Hold**: everything else
|
- **Hold**: everything else
|
||||||
@@ -110,66 +154,26 @@ 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
|
## KEY LESSONS
|
||||||
|
|
||||||
### 1. Shared Logic Eliminates Drift
|
### 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).
|
Extracting common logic into `strategy.rs` ensures bot and backtester CANNOT diverge. All core trading logic (signal generation, position sizing, stop-loss/trailing/time exit) is now in shared modules.
|
||||||
|
|
||||||
### 2. Warmup Must Account for Longest Chain
|
### 2. Drawdown Circuit Breaker Needs Peak Reset on Resume
|
||||||
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.
|
Without resetting the peak when halt expires, any minor dip after partial recovery will immediately re-trigger the halt. This creates cascading halts that keep the bot in cash for extended periods. Backtester had this right; bot.rs needs the fix.
|
||||||
|
|
||||||
### 3. NaN Handling is Critical
|
### 3. PDT Protection Strategy Differs by Timeframe
|
||||||
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.
|
- **Daily mode**: Phase separation (sells Phase 1, buys Phase 2) prevents day trades by construction. PDT enforcement not needed.
|
||||||
|
- **Hourly mode**: Late-day entry prevention (backtester) vs exit blocking (bot) are two valid approaches, but they're not identical. For portfolios < $25K, bot will hold positions overnight more often than backtest suggests.
|
||||||
|
|
||||||
### 4. ATR Fallbacks Prevent Edge Cases
|
### 4. Config Constants Must Be Audited After Every Change
|
||||||
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.
|
Recent changes to drawdown thresholds, momentum pool size, and buy thresholds were all consistent between bot and backtester, but manual audit was required to verify. Future changes should trigger automated consistency checks.
|
||||||
|
|
||||||
### 5. Slippage Modeling is Non-Negotiable
|
### 5. Warmup Must Account for Longest Indicator Chain
|
||||||
The backtester applies 10 bps slippage on both sides (20 bps round-trip) to simulate realistic fills. This prevents overfitting to unrealistic backtest performance.
|
For hourly mode, EMA-200 dominates warmup (205 bars). The `+ 5` safety margin in `min_bars()` is critical.
|
||||||
|
|
||||||
### 6. Data Type Consistency Matters for PDT Protection
|
### 6. Data Type Consistency for PDT Tracking (FIXED)
|
||||||
**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:
|
bot.rs now uses `Vec<NaiveDate>` for day_trades (line 56), matching backtester.rs (line 42). Previous audit found this as a critical bug; it's now fixed. ✅
|
||||||
- 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -182,7 +186,7 @@ When new changes are made, verify:
|
|||||||
3. **Risk management**: Still using shared `Strategy::check_stop_loss_take_profit()`?
|
3. **Risk management**: Still using shared `Strategy::check_stop_loss_take_profit()`?
|
||||||
4. **Cooldown timers**: Identical logic in both files?
|
4. **Cooldown timers**: Identical logic in both files?
|
||||||
5. **Ramp-up period**: Identical logic in both files?
|
5. **Ramp-up period**: Identical logic in both files?
|
||||||
6. **Drawdown halt**: Identical trigger and resume logic?
|
6. **Drawdown halt**: Identical trigger logic? Peak reset on expiry?
|
||||||
7. **Sector limits**: Same `MAX_SECTOR_POSITIONS` constant?
|
7. **Sector limits**: Same `MAX_SECTOR_POSITIONS` constant?
|
||||||
8. **Max positions**: Same `MAX_CONCURRENT_POSITIONS` constant?
|
8. **Max positions**: Same `MAX_CONCURRENT_POSITIONS` constant?
|
||||||
9. **Momentum ranking**: Same `TOP_MOMENTUM_COUNT` constant?
|
9. **Momentum ranking**: Same `TOP_MOMENTUM_COUNT` constant?
|
||||||
@@ -191,104 +195,73 @@ 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?
|
15. **PDT protection**: Same constants, logic, and data types? Document timeframe-specific behavior.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FILES AUDITED (2026-02-12 PDT Audit)
|
## FILES AUDITED (2026-02-13)
|
||||||
- `/home/work/Documents/rust/invest-bot/src/bot.rs` (921 lines)
|
- `/home/work/Documents/rust/invest-bot/src/bot.rs` (933 lines)
|
||||||
- `/home/work/Documents/rust/invest-bot/src/backtester.rs` (907 lines)
|
- `/home/work/Documents/rust/invest-bot/src/backtester.rs` (1002 lines)
|
||||||
|
- `/home/work/Documents/rust/invest-bot/src/config.rs` (222 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,130 lines audited
|
**Total**: 2,532 lines audited
|
||||||
**Issues found**: 1 critical (data type mismatch), 0 medium, 0 low
|
**Issues found**: 1 critical bug (drawdown peak reset), 1 critical behavioral divergence (PDT enforcement)
|
||||||
**Status**: ⚠️ FIX REQUIRED BEFORE PRODUCTION
|
**Status**: ⚠️ FIX REQUIRED BEFORE PRODUCTION (drawdown peak reset)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CRITICAL FIX REQUIRED: PDT Data Type Mismatch
|
## REQUIRED FIX: Drawdown Peak Reset on Halt Expiry
|
||||||
|
|
||||||
**Problem**: bot.rs stores day_trades as `Vec<String>`, backtester.rs stores as `Vec<NaiveDate>`
|
**File**: `/home/work/Documents/rust/invest-bot/src/bot.rs`
|
||||||
|
**Location**: Lines 365-377 (inside drawdown halt expiry check)
|
||||||
|
|
||||||
**Required Changes to bot.rs:**
|
**Current code**:
|
||||||
|
|
||||||
1. Line 56: Change field type
|
|
||||||
```rust
|
```rust
|
||||||
day_trades: Vec<NaiveDate>, // was Vec<String>
|
// Auto-resume after time-based cooldown
|
||||||
```
|
if self.drawdown_halt {
|
||||||
|
if let Some(halt_start) = self.drawdown_halt_start {
|
||||||
2. Lines 197-218: Load with parse-once strategy
|
if self.trading_cycle_count >= halt_start + DRAWDOWN_HALT_BARS {
|
||||||
```rust
|
tracing::info!(
|
||||||
fn load_day_trades(&mut self) {
|
"Drawdown halt expired after {} cycles. Resuming trading at {:.2}% drawdown.",
|
||||||
if LIVE_DAY_TRADES_FILE.exists() {
|
DRAWDOWN_HALT_BARS,
|
||||||
match std::fs::read_to_string(&*LIVE_DAY_TRADES_FILE) {
|
drawdown_pct * 100.0
|
||||||
Ok(content) if !content.is_empty() => {
|
);
|
||||||
match serde_json::from_str::<Vec<String>>(&content) {
|
self.drawdown_halt = false;
|
||||||
Ok(date_strings) => {
|
self.drawdown_halt_start = None;
|
||||||
self.day_trades = date_strings
|
// MISSING: self.peak_portfolio_value = portfolio_value;
|
||||||
.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
|
**Required change**: Add after line 374:
|
||||||
```rust
|
```rust
|
||||||
fn save_day_trades(&self) {
|
self.peak_portfolio_value = portfolio_value;
|
||||||
let date_strings: Vec<String> = self.day_trades
|
```
|
||||||
.iter()
|
|
||||||
.map(|d| d.format("%Y-%m-%d").to_string())
|
**Complete fixed block**:
|
||||||
.collect();
|
```rust
|
||||||
match serde_json::to_string_pretty(&date_strings) {
|
// Auto-resume after time-based cooldown
|
||||||
Ok(json) => {
|
if self.drawdown_halt {
|
||||||
if let Err(e) = std::fs::write(&*LIVE_DAY_TRADES_FILE, json) {
|
if let Some(halt_start) = self.drawdown_halt_start {
|
||||||
tracing::error!("Error saving day trades file: {}", e);
|
if self.trading_cycle_count >= halt_start + DRAWDOWN_HALT_BARS {
|
||||||
|
tracing::info!(
|
||||||
|
"Drawdown halt expired after {} cycles. Resuming trading. \
|
||||||
|
Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).",
|
||||||
|
DRAWDOWN_HALT_BARS,
|
||||||
|
self.peak_portfolio_value,
|
||||||
|
portfolio_value,
|
||||||
|
drawdown_pct * 100.0
|
||||||
|
);
|
||||||
|
self.drawdown_halt = false;
|
||||||
|
self.drawdown_halt_start = None;
|
||||||
|
// Reset peak to current value to prevent cascading re-triggers.
|
||||||
|
self.peak_portfolio_value = portfolio_value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::error!("Error serializing day trades: {}", e),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Lines 231-239: Use native date comparison
|
**Why this matters**: Without this reset, after a 15% drawdown triggers a 10-bar halt, the bot measures future drawdown from the OLD peak. If the portfolio recovers to 12% drawdown and then dips to 13%, it immediately re-triggers the halt. This creates cascading halts. The backtester correctly resets the peak, so backtest results show fewer/shorter halts than live trading would experience.
|
||||||
```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
|
|
||||||
|
|||||||
@@ -4,26 +4,36 @@
|
|||||||
- ~100-symbol universe across 14 sectors (expanded from original 50)
|
- ~100-symbol universe across 14 sectors (expanded from original 50)
|
||||||
- Hybrid momentum + mean-reversion via regime-adaptive dual signal in `generate_signal()`
|
- Hybrid momentum + mean-reversion via regime-adaptive dual signal in `generate_signal()`
|
||||||
- strategy.rs: shared logic between bot.rs and backtester.rs
|
- strategy.rs: shared logic between bot.rs and backtester.rs
|
||||||
- Backtester restricts buys to top 10 momentum stocks (TOP_MOMENTUM_COUNT=10)
|
- Backtester restricts buys to top momentum stocks (TOP_MOMENTUM_COUNT)
|
||||||
- Signal thresholds: StrongBuy>=7.0, Buy>=4.5, Sell<=-4.0, StrongSell<=-7.0
|
- Signal thresholds: StrongBuy>=7.0, Buy>=4.0, Sell<=-4.0, StrongSell<=-7.0
|
||||||
|
|
||||||
|
## Bugs Fixed (2026-02-13)
|
||||||
|
### 1. calculate_results used self.cash instead of equity curve final value
|
||||||
|
- backtester.rs line ~686: `let final_value = self.cash` missed open positions
|
||||||
|
- Fixed: use `self.equity_history.last().portfolio_value`
|
||||||
|
|
||||||
|
### 2. Drawdown circuit breaker cascading re-triggers
|
||||||
|
- peak_portfolio_value was never reset after halt, causing immediate re-trigger
|
||||||
|
- 7+ triggers in 3yr = ~140 bars (19% of backtest) sitting in cash
|
||||||
|
- Fixed: reset peak to current value on halt resume
|
||||||
|
|
||||||
|
### 3. PDT blocking sells in backtester (disabled)
|
||||||
|
- PDT sell-blocking removed from backtester; it measures strategy alpha not compliance
|
||||||
|
- Late-day entry prevention in execute_buy remains for hourly PDT defense
|
||||||
|
- would_be_day_trade was called AFTER position removal = always false (logic bug)
|
||||||
|
|
||||||
## PDT Implementation (2026-02-12)
|
## PDT Implementation (2026-02-12)
|
||||||
- Tracks day trades in rolling 5-business-day window, max 3 allowed
|
- 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)
|
- 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)
|
- Late-day entry prevention: On hourly, block buys after 19:00 UTC (~last 2 hours)
|
||||||
- Prevents entries needing same-day stop-loss exits
|
- PDT blocking DISABLED in backtester (kept in bot.rs for live trading)
|
||||||
- Reduced hourly trades 100->86, improved PF 1.24->1.59
|
|
||||||
- "PDT performance degradation" was mostly IEX data stochasticity, not actual PDT blocking
|
|
||||||
|
|
||||||
## Backtest Results (3-month, 2026-02-12, post-PDT-fix)
|
## Current Parameters (config.rs, updated 2026-02-13)
|
||||||
### 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: 3.0x | Trail: 2.0x distance, 2.0x activation
|
- ATR Stop: 3.0x | Trail: 2.0x distance, 2.0x activation
|
||||||
- Risk: 1.2%/trade, max 25% position, 5% cash reserve, 5% max loss
|
- 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
|
- Max 7 positions, 2/sector | Drawdown halt: 15% (10 bars) | Time exit: 40
|
||||||
- Cooldown: 5 bars | Ramp-up: 15 bars | Slippage: 10bps
|
- Cooldown: 5 bars | Ramp-up: 15 bars | Slippage: 10bps
|
||||||
|
- Buy threshold: 4.0 (lowered from 4.5) | Momentum pool: top 20 (widened from 10)
|
||||||
- Daily: momentum=63, ema_trend=50 | Hourly: momentum=63, ema_trend=200
|
- Daily: momentum=63, ema_trend=50 | Hourly: momentum=63, ema_trend=200
|
||||||
- ADX: range<20, trend>25, strong>40
|
- ADX: range<20, trend>25, strong>40
|
||||||
|
|
||||||
@@ -33,10 +43,11 @@
|
|||||||
|
|
||||||
## Failed Experiments (avoid repeating)
|
## Failed Experiments (avoid repeating)
|
||||||
1. Tighter ATR stop (<3.0x): too many stop-outs on hourly
|
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
|
2. Lower buy threshold (3.5): too many weak entries (but 4.0 is fine)
|
||||||
3. Blocking stop-loss exits for PDT: traps capital in losers, dangerous
|
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
|
4. Lower volume threshold (0.7): bad trades on IEX. Keep 0.8
|
||||||
5. Shorter hourly lookbacks: catastrophic losses
|
5. Shorter hourly lookbacks: catastrophic losses
|
||||||
|
6. Drawdown halt 12% with non-resetting peak: cascading re-triggers in multi-year tests
|
||||||
|
|
||||||
## IEX Data Stochasticity
|
## IEX Data Stochasticity
|
||||||
- Backtests have significant run-to-run variation from IEX data timing
|
- Backtests have significant run-to-run variation from IEX data timing
|
||||||
@@ -44,5 +55,5 @@
|
|||||||
- Always run 2-3 times and compare ranges before concluding a change helped/hurt
|
- 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 for types.rs fields)
|
||||||
- No tests exist
|
- No tests exist
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ pub struct Backtester {
|
|||||||
new_positions_this_bar: usize,
|
new_positions_this_bar: usize,
|
||||||
/// Rolling list of day trade dates for PDT tracking.
|
/// Rolling list of day trade dates for PDT tracking.
|
||||||
day_trades: Vec<NaiveDate>,
|
day_trades: Vec<NaiveDate>,
|
||||||
/// Count of sells blocked by PDT protection.
|
/// Count of day trades that occurred (informational only, not blocking).
|
||||||
|
#[allow(dead_code)]
|
||||||
pdt_blocked_count: usize,
|
pdt_blocked_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +89,10 @@ impl Backtester {
|
|||||||
|
|
||||||
/// Update drawdown circuit breaker state.
|
/// Update drawdown circuit breaker state.
|
||||||
/// Uses time-based halt: pause for DRAWDOWN_HALT_BARS after trigger, then auto-resume.
|
/// Uses time-based halt: pause for DRAWDOWN_HALT_BARS after trigger, then auto-resume.
|
||||||
|
/// On resume, the peak is reset to the current portfolio value to prevent cascading
|
||||||
|
/// re-triggers from the same drawdown event. Without this reset, a partial recovery
|
||||||
|
/// followed by a minor dip re-triggers the halt, causing the bot to spend excessive
|
||||||
|
/// time in cash (observed: 7+ triggers in a 3-year backtest = ~140 bars lost).
|
||||||
fn update_drawdown_state(&mut self, portfolio_value: f64) {
|
fn update_drawdown_state(&mut self, portfolio_value: f64) {
|
||||||
if portfolio_value > self.peak_portfolio_value {
|
if portfolio_value > self.peak_portfolio_value {
|
||||||
self.peak_portfolio_value = portfolio_value;
|
self.peak_portfolio_value = portfolio_value;
|
||||||
@@ -112,12 +117,19 @@ impl Backtester {
|
|||||||
if let Some(halt_start) = self.drawdown_halt_start {
|
if let Some(halt_start) = self.drawdown_halt_start {
|
||||||
if self.current_bar >= halt_start + DRAWDOWN_HALT_BARS {
|
if self.current_bar >= halt_start + DRAWDOWN_HALT_BARS {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Drawdown halt expired after {} bars. Resuming trading at {:.2}% drawdown.",
|
"Drawdown halt expired after {} bars. Resuming trading. \
|
||||||
|
Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).",
|
||||||
DRAWDOWN_HALT_BARS,
|
DRAWDOWN_HALT_BARS,
|
||||||
|
self.peak_portfolio_value,
|
||||||
|
portfolio_value,
|
||||||
drawdown_pct * 100.0
|
drawdown_pct * 100.0
|
||||||
);
|
);
|
||||||
self.drawdown_halt = false;
|
self.drawdown_halt = false;
|
||||||
self.drawdown_halt_start = None;
|
self.drawdown_halt_start = None;
|
||||||
|
// Reset peak to current value to prevent cascading re-triggers.
|
||||||
|
// The previous peak is no longer relevant after a halt — measuring
|
||||||
|
// drawdown from it would immediately re-trigger on any minor dip.
|
||||||
|
self.peak_portfolio_value = portfolio_value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,33 +241,30 @@ 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
|
/// PDT protection is DISABLED in the backtester for daily timeframe because
|
||||||
/// limit in a rolling 5-business-day window. EXCEPTION: stop-loss exits
|
/// on daily bars, buys and sells never occur on the same bar (Phase 1 sells,
|
||||||
/// (was_stop_loss=true) are NEVER blocked -- risk management takes priority
|
/// Phase 2 buys), so day trades cannot happen by construction. For hourly,
|
||||||
/// over PDT compliance. The correct defense is to prevent entries that would
|
/// the late-day entry prevention in execute_buy already handles PDT risk.
|
||||||
/// need same-day exits, not to trap capital in losing positions.
|
/// Backtests should measure strategy alpha, not compliance friction.
|
||||||
fn execute_sell(
|
fn execute_sell(
|
||||||
&mut self,
|
&mut self,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
price: f64,
|
price: f64,
|
||||||
timestamp: DateTime<Utc>,
|
timestamp: DateTime<Utc>,
|
||||||
was_stop_loss: bool,
|
was_stop_loss: bool,
|
||||||
|
_portfolio_value: f64,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
// PDT protection: check if this would be a day trade
|
// Check day trade status BEFORE removing position (would_be_day_trade
|
||||||
|
// looks up entry_time in self.positions).
|
||||||
let sell_date = timestamp.date_naive();
|
let sell_date = timestamp.date_naive();
|
||||||
let is_day_trade = self.would_be_day_trade(symbol, sell_date);
|
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
|
// Track day trades for informational purposes only (no blocking)
|
||||||
if is_day_trade {
|
if is_day_trade {
|
||||||
self.day_trades.push(sell_date);
|
self.day_trades.push(sell_date);
|
||||||
}
|
}
|
||||||
@@ -299,9 +308,10 @@ impl Backtester {
|
|||||||
|
|
||||||
// ── PDT (Pattern Day Trading) protection ───────────────────────
|
// ── PDT (Pattern Day Trading) protection ───────────────────────
|
||||||
|
|
||||||
/// PDT constants (same as bot.rs).
|
/// PDT constants (retained for hourly timeframe day-trade tracking).
|
||||||
|
#[allow(dead_code)]
|
||||||
const PDT_MAX_DAY_TRADES: usize = 3;
|
const PDT_MAX_DAY_TRADES: usize = 3;
|
||||||
const PDT_ROLLING_BUSINESS_DAYS: i64 = 5;
|
const PDT_ROLLING_BUSINESS_DAYS: i64 = 5; // Used by prune_old_day_trades
|
||||||
|
|
||||||
/// Remove day trades older than the 5-business-day rolling window.
|
/// Remove day trades older than the 5-business-day rolling window.
|
||||||
fn prune_old_day_trades(&mut self, current_date: NaiveDate) {
|
fn prune_old_day_trades(&mut self, current_date: NaiveDate) {
|
||||||
@@ -324,6 +334,7 @@ impl Backtester {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Count day trades in the rolling 5-business-day window.
|
/// Count day trades in the rolling 5-business-day window.
|
||||||
|
#[allow(dead_code)]
|
||||||
fn day_trades_in_window(&self, current_date: NaiveDate) -> usize {
|
fn day_trades_in_window(&self, current_date: NaiveDate) -> usize {
|
||||||
let cutoff = Self::business_days_before(current_date, Self::PDT_ROLLING_BUSINESS_DAYS);
|
let cutoff = Self::business_days_before(current_date, Self::PDT_ROLLING_BUSINESS_DAYS);
|
||||||
self.day_trades.iter().filter(|&&d| d >= cutoff).count()
|
self.day_trades.iter().filter(|&&d| d >= cutoff).count()
|
||||||
@@ -338,7 +349,12 @@ impl Backtester {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a day trade is allowed (under PDT limit).
|
/// Check if a day trade is allowed (under PDT limit).
|
||||||
fn can_day_trade(&self, current_date: NaiveDate) -> bool {
|
/// PDT rule only applies to accounts under $25,000.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn can_day_trade(&self, current_date: NaiveDate, portfolio_value: f64) -> bool {
|
||||||
|
if portfolio_value >= 25_000.0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
self.day_trades_in_window(current_date) < Self::PDT_MAX_DAY_TRADES
|
self.day_trades_in_window(current_date) < Self::PDT_MAX_DAY_TRADES
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +592,7 @@ impl Backtester {
|
|||||||
// Execute sells
|
// Execute sells
|
||||||
if signal.signal.is_sell() {
|
if signal.signal.is_sell() {
|
||||||
let was_stop_loss = matches!(signal.signal, Signal::StrongSell);
|
let was_stop_loss = matches!(signal.signal, Signal::StrongSell);
|
||||||
self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss);
|
self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss, portfolio_value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,7 +671,8 @@ impl Backtester {
|
|||||||
for symbol in position_symbols {
|
for symbol in position_symbols {
|
||||||
if let Some(rows) = data.get(&symbol) {
|
if let Some(rows) = data.get(&symbol) {
|
||||||
if let Some(last_row) = rows.last() {
|
if let Some(last_row) = rows.last() {
|
||||||
self.execute_sell(&symbol, last_row.close, final_date, false);
|
// Always allow final close-out sells (bypass PDT with large value)
|
||||||
|
self.execute_sell(&symbol, last_row.close, final_date, false, f64::MAX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -677,7 +694,14 @@ impl Backtester {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let final_value = self.cash;
|
// Use the last equity curve value (cash + positions) as the final value.
|
||||||
|
// All positions should be closed by now, but using the equity curve is
|
||||||
|
// more robust than relying solely on self.cash.
|
||||||
|
let final_value = self
|
||||||
|
.equity_history
|
||||||
|
.last()
|
||||||
|
.map(|e| e.portfolio_value)
|
||||||
|
.unwrap_or(self.cash);
|
||||||
let total_return = final_value - self.initial_capital;
|
let total_return = final_value - self.initial_capital;
|
||||||
let total_return_pct = total_return / self.initial_capital;
|
let total_return_pct = total_return / self.initial_capital;
|
||||||
|
|
||||||
@@ -891,13 +915,13 @@ impl Backtester {
|
|||||||
" Re-entry Cooldown: {:>13} bars",
|
" Re-entry Cooldown: {:>13} bars",
|
||||||
REENTRY_COOLDOWN_BARS
|
REENTRY_COOLDOWN_BARS
|
||||||
);
|
);
|
||||||
if self.pdt_blocked_count > 0 {
|
if !self.day_trades.is_empty() {
|
||||||
println!();
|
println!();
|
||||||
println!("{:^70}", "PDT PROTECTION");
|
println!("{:^70}", "PDT INFO");
|
||||||
println!("{}", "-".repeat(70));
|
println!("{}", "-".repeat(70));
|
||||||
println!(
|
println!(
|
||||||
" Sells blocked by PDT: {:>15}",
|
" Day trades occurred: {:>15}",
|
||||||
self.pdt_blocked_count
|
self.day_trades.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!("{}", "=".repeat(70));
|
println!("{}", "=".repeat(70));
|
||||||
|
|||||||
33
src/bot.rs
33
src/bot.rs
@@ -54,6 +54,8 @@ pub struct TradingBot {
|
|||||||
new_positions_this_cycle: usize,
|
new_positions_this_cycle: usize,
|
||||||
/// Rolling list of day trade dates for PDT tracking.
|
/// Rolling list of day trade dates for PDT tracking.
|
||||||
day_trades: Vec<NaiveDate>,
|
day_trades: Vec<NaiveDate>,
|
||||||
|
/// Current portfolio value (updated each cycle), used for PDT exemption check.
|
||||||
|
current_portfolio_value: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TradingBot {
|
impl TradingBot {
|
||||||
@@ -78,6 +80,7 @@ impl TradingBot {
|
|||||||
cooldown_timers: HashMap::new(),
|
cooldown_timers: HashMap::new(),
|
||||||
new_positions_this_cycle: 0,
|
new_positions_this_cycle: 0,
|
||||||
day_trades: Vec::new(),
|
day_trades: Vec::new(),
|
||||||
|
current_portfolio_value: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load persisted state
|
// Load persisted state
|
||||||
@@ -273,7 +276,11 @@ impl TradingBot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a day trade is allowed (under PDT limit).
|
/// Check if a day trade is allowed (under PDT limit).
|
||||||
|
/// PDT rule only applies to accounts under $25,000.
|
||||||
fn can_day_trade(&self) -> bool {
|
fn can_day_trade(&self) -> bool {
|
||||||
|
if self.current_portfolio_value >= 25_000.0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
self.day_trades_in_window() < PDT_MAX_DAY_TRADES
|
self.day_trades_in_window() < PDT_MAX_DAY_TRADES
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,12 +366,17 @@ impl TradingBot {
|
|||||||
if let Some(halt_start) = self.drawdown_halt_start {
|
if let Some(halt_start) = self.drawdown_halt_start {
|
||||||
if self.trading_cycle_count >= halt_start + DRAWDOWN_HALT_BARS {
|
if self.trading_cycle_count >= halt_start + DRAWDOWN_HALT_BARS {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Drawdown halt expired after {} cycles. Resuming trading at {:.2}% drawdown.",
|
"Drawdown halt expired after {} cycles. Resuming trading. \
|
||||||
|
Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).",
|
||||||
DRAWDOWN_HALT_BARS,
|
DRAWDOWN_HALT_BARS,
|
||||||
|
self.peak_portfolio_value,
|
||||||
|
portfolio_value,
|
||||||
drawdown_pct * 100.0
|
drawdown_pct * 100.0
|
||||||
);
|
);
|
||||||
self.drawdown_halt = false;
|
self.drawdown_halt = false;
|
||||||
self.drawdown_halt_start = None;
|
self.drawdown_halt_start = None;
|
||||||
|
// Reset peak to current value to prevent cascading re-triggers.
|
||||||
|
self.peak_portfolio_value = portfolio_value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,13 +421,15 @@ impl TradingBot {
|
|||||||
|
|
||||||
// ── Account helpers ──────────────────────────────────────────────
|
// ── Account helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
async fn log_account_info(&self) {
|
async fn log_account_info(&mut self) {
|
||||||
match self.client.get_account().await {
|
match self.client.get_account().await {
|
||||||
Ok(account) => {
|
Ok(account) => {
|
||||||
let portfolio_value: f64 = account.portfolio_value.parse().unwrap_or(0.0);
|
let portfolio_value: f64 = account.portfolio_value.parse().unwrap_or(0.0);
|
||||||
let buying_power: f64 = account.buying_power.parse().unwrap_or(0.0);
|
let buying_power: f64 = account.buying_power.parse().unwrap_or(0.0);
|
||||||
let cash: f64 = account.cash.parse().unwrap_or(0.0);
|
let cash: f64 = account.cash.parse().unwrap_or(0.0);
|
||||||
|
|
||||||
|
self.current_portfolio_value = portfolio_value;
|
||||||
|
|
||||||
tracing::info!("Account Status: {}", account.status);
|
tracing::info!("Account Status: {}", account.status);
|
||||||
tracing::info!("Buying Power: ${:.2}", buying_power);
|
tracing::info!("Buying Power: ${:.2}", buying_power);
|
||||||
tracing::info!("Portfolio Value: ${:.2}", portfolio_value);
|
tracing::info!("Portfolio Value: ${:.2}", portfolio_value);
|
||||||
@@ -738,12 +752,17 @@ impl TradingBot {
|
|||||||
self.prune_old_day_trades();
|
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;
|
||||||
|
if self.current_portfolio_value >= 25_000.0 {
|
||||||
|
tracing::info!("PDT status: EXEMPT (portfolio ${:.2} >= $25,000)", self.current_portfolio_value);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"PDT status: {}/{} day trades in rolling 5-business-day window (portfolio ${:.2} < $25,000)",
|
||||||
|
self.day_trades_in_window(),
|
||||||
|
PDT_MAX_DAY_TRADES,
|
||||||
|
self.current_portfolio_value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
for meta in self.position_meta.values_mut() {
|
for meta in self.position_meta.values_mut() {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ pub const MIN_ATR_PCT: f64 = 0.005;
|
|||||||
pub const VOLUME_MA_PERIOD: usize = 20;
|
pub const VOLUME_MA_PERIOD: usize = 20;
|
||||||
pub const VOLUME_THRESHOLD: f64 = 0.8;
|
pub const VOLUME_THRESHOLD: f64 = 0.8;
|
||||||
// Momentum Ranking
|
// Momentum Ranking
|
||||||
pub const TOP_MOMENTUM_COUNT: usize = 10; // Wider pool for more opportunities
|
pub const TOP_MOMENTUM_COUNT: usize = 20; // ~20% of universe for cross-sectional momentum
|
||||||
// Risk Management
|
// Risk Management
|
||||||
pub const MAX_POSITION_SIZE: f64 = 0.25; // Slightly larger for concentrated bets
|
pub const MAX_POSITION_SIZE: f64 = 0.25; // Slightly larger for concentrated bets
|
||||||
pub const MIN_CASH_RESERVE: f64 = 0.05;
|
pub const MIN_CASH_RESERVE: f64 = 0.05;
|
||||||
@@ -85,8 +85,8 @@ pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Activate after 2x ATR g
|
|||||||
// Portfolio-level controls
|
// Portfolio-level controls
|
||||||
pub const MAX_CONCURRENT_POSITIONS: usize = 7; // More positions for diversification
|
pub const MAX_CONCURRENT_POSITIONS: usize = 7; // More positions for diversification
|
||||||
pub const MAX_SECTOR_POSITIONS: usize = 2;
|
pub const MAX_SECTOR_POSITIONS: usize = 2;
|
||||||
pub const MAX_DRAWDOWN_HALT: f64 = 0.12; // Wider drawdown tolerance
|
pub const MAX_DRAWDOWN_HALT: f64 = 0.15; // 15% drawdown trigger (markets routinely correct 10-15%)
|
||||||
pub const DRAWDOWN_HALT_BARS: usize = 20; // Shorter cooldown to get back in
|
pub const DRAWDOWN_HALT_BARS: usize = 10; // Shorter cooldown: 10 bars to resume after halt
|
||||||
// Time-based exit
|
// Time-based exit
|
||||||
pub const TIME_EXIT_BARS: usize = 40; // Longer patience for mean reversion
|
pub const TIME_EXIT_BARS: usize = 40; // Longer patience for mean reversion
|
||||||
pub const REENTRY_COOLDOWN_BARS: usize = 5; // Shorter cooldown
|
pub const REENTRY_COOLDOWN_BARS: usize = 5; // Shorter cooldown
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
|
|||||||
|
|
||||||
let signal = if total_score >= 7.0 {
|
let signal = if total_score >= 7.0 {
|
||||||
Signal::StrongBuy
|
Signal::StrongBuy
|
||||||
} else if total_score >= 4.5 {
|
} else if total_score >= 4.0 {
|
||||||
Signal::Buy
|
Signal::Buy
|
||||||
} else if total_score <= -7.0 {
|
} else if total_score <= -7.0 {
|
||||||
Signal::StrongSell
|
Signal::StrongSell
|
||||||
|
|||||||
Reference in New Issue
Block a user