Compare commits
8 Commits
798c3eafd5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eda716edad | |||
| 84461319a0 | |||
| 4476c04512 | |||
| 62847846d0 | |||
|
|
0e820852fa | ||
|
|
79816b9e2e | ||
|
|
edc655ca2c | ||
|
|
73cc7a3a66 |
@@ -1,179 +1,195 @@
|
||||
# Consistency Auditor Memory
|
||||
|
||||
## Last Audit: 2026-02-13 (Post Config Update)
|
||||
## Last Audit: 2026-02-13 (Post-Config Update v2 - NEW FINDINGS)
|
||||
|
||||
### AUDIT RESULT: ⚠️ 1 CRITICAL BUG + 1 CRITICAL BEHAVIORAL DIVERGENCE
|
||||
### AUDIT RESULT: ⚠️ 1 CRITICAL DIVERGENCE + 1 MEDIUM BUG
|
||||
|
||||
**1. CRITICAL BUG: Drawdown Peak Reset Inconsistency**
|
||||
- **backtester.rs** resets `peak_portfolio_value` to current value on halt expiry (line 132)
|
||||
- **bot.rs** does NOT reset peak on halt expiry (lines 365-377)
|
||||
- **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**: 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`
|
||||
**1. CRITICAL DIVERGENCE: Equity Curve SMA Stop Removed from Backtester Only**
|
||||
- **backtester.rs** lines 258-261: Explicitly REMOVED equity SMA stop with comment "creates pathological feedback loop"
|
||||
- **bot.rs** lines 809-825, 904-907, 991-992: STILL ENFORCES equity curve SMA stop (blocks buys when equity < 50-period SMA)
|
||||
- **Impact**: Bot will skip FAR MORE entries than backtest suggests. When holding losing positions, equity drops below SMA, blocking ALL new buys, preventing recovery. Backtest does NOT reflect this behavior. Live performance will be WORSE.
|
||||
- **Fix**: Remove `equity_below_sma()` function from bot.rs (lines 809-826, 904-907, 991-992) to match backtester
|
||||
- **Rationale**: Backtester comment is correct — this creates a self-reinforcing trap where losers prevent entry into winners
|
||||
- **Code location**: `/home/work/Documents/rust/invest-bot/src/bot.rs:809-826, 904-907, 991-992`
|
||||
|
||||
**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.
|
||||
**2. MEDIUM BUG: Confidence Scaling Math Error (Both Files)**
|
||||
- **indicators.rs:655** changed to `confidence = score / 10.0`
|
||||
- **bot.rs:1025** and **backtester.rs:828** reverse-engineer with `score = confidence * 12.0`
|
||||
- **Impact**: Regime threshold bumps use wrong score approximation (12x instead of 10x). Overestimates scores by 20%, making thresholds slightly easier to pass. Both files have the same bug, so they're consistent with each other, but both are mathematically wrong.
|
||||
- **Fix**: Change `* 12.0` to `* 10.0` in both files
|
||||
- **Severity**: Medium (not critical since both paths have the same error)
|
||||
- **Code location**: `/home/work/Documents/rust/invest-bot/src/bot.rs:1025` and `/home/work/Documents/rust/invest-bot/src/backtester.rs:828`
|
||||
|
||||
---
|
||||
|
||||
## Config Changes Since Last Audit (2026-02-13)
|
||||
## Config Changes Since Last Audit (2026-02-13 v2)
|
||||
|
||||
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)
|
||||
- Hourly indicator periods scaled 7x:
|
||||
- MACD: 84/182/63 (was 12/26/9 daily baseline)
|
||||
- Momentum: 441 (was 63 daily baseline)
|
||||
- EMA: 63/147/350 (was 9/21/50 daily baseline)
|
||||
- BB: 140 (was 20 daily baseline)
|
||||
- Volume MA: 140 (was 20 daily baseline)
|
||||
- Caution regime: sizing 0.25 (was 0.5), threshold bump +3.0 (was +2.0)
|
||||
- Risk: 1.5%/trade (was 1.2%), ATR stop 3.5x (was 3.0x), trail 3.0x (was 2.0x), activation 2.0x (unchanged)
|
||||
- Max 8 positions (was 7), top 15 momentum (was 20), time exit 80 bars (was 40), cooldown 10 bars (was 5)
|
||||
- Drawdown tiers: 12%/18%/25% → 15/40/60 bars (was 15%/20%/25% → 10/30/50)
|
||||
- RSI pullback window: 25-55 (was 30-50) in indicators.rs
|
||||
|
||||
**Verified**: All constants consistent between bot.rs and backtester.rs ✅
|
||||
**Verified**: All config constants consistent between bot.rs and backtester.rs ✅
|
||||
|
||||
---
|
||||
|
||||
## VERIFIED CONSISTENT (2026-02-13 Audit) ✅
|
||||
## VERIFIED CONSISTENT (2026-02-13 Audit v2) ✅
|
||||
|
||||
### Core Trading Logic ✅
|
||||
- **Signal generation**: Both use shared `indicators::generate_signal()` (bot:739; bt:583,630)
|
||||
- **Position sizing**: Both use shared `Strategy::calculate_position_size()` (bot:463-468; bt:199-201)
|
||||
- **Signal generation**: Both use shared `indicators::generate_signal()` (bot:876; bt:758,818)
|
||||
- **Position sizing**: Both use shared `Strategy::calculate_position_size()` (bot:537-542; bt:282-284)
|
||||
- Volatility-adjusted via ATR
|
||||
- Confidence scaling: 0.7 + 0.3 * confidence
|
||||
- Max position size cap: 25%
|
||||
- Confidence scaling: 0.4 + 0.6 * confidence (changed from 0.7 + 0.3)
|
||||
- Max position size cap: 20% (was 25%)
|
||||
- Cash reserve: 5%
|
||||
- **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%
|
||||
- ATR-based stop: 3.0x ATR below entry
|
||||
- **Stop-loss/trailing/time exit**: Both use shared `Strategy::check_stop_loss_take_profit()` (bot:547-559; bt:462-468)
|
||||
- Hard max loss cap: 8% (was 5%)
|
||||
- ATR-based stop: 3.5x ATR below entry (was 3.0x)
|
||||
- Fixed fallback stop: 2.5%
|
||||
- Trailing stop: 2.0x ATR after 2.0x ATR gain
|
||||
- Time exit: 40 bars if below trailing activation threshold
|
||||
- Trailing stop: 3.0x ATR after 2.0x ATR gain (was 2.0x trail, 2.0x activation)
|
||||
- Time exit: 80 bars if below trailing activation (was 40)
|
||||
|
||||
### Portfolio Controls ✅
|
||||
- **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 cycle/bar for first 15 bars (bot:543-552; bt:194-196)
|
||||
- **Drawdown circuit breaker**: Both trigger at 15% with 10-bar cooldown (bot:353-362; bt:104-113)
|
||||
- **BUT**: bot.rs missing peak reset on expiry (see Critical Bug #1)
|
||||
- **Sector limits**: Both enforce max 2 per sector (bot:534-541; bt:184-191)
|
||||
- **Max concurrent positions**: Both enforce max 7 (bot:525-532; bt:180-182)
|
||||
- **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)
|
||||
- **Cooldown timers**: Both implement 10-bar cooldown after stop-loss (bot:736-746; bt:383-388) [was 5]
|
||||
- **Ramp-up period**: Both limit to 1 new position per cycle/bar for first 15 bars (bot:618-626; bt:277-279)
|
||||
- **Drawdown circuit breaker**: Both trigger at 12%/18%/25% with 15/40/60-bar cooldowns (bot:368-408; bt:106-163)
|
||||
- **Peak reset on expiry**: Both reset peak to current value (bot:442; bt:197) ✅ (FIXED since last audit)
|
||||
- Tier 3 (25%+) requires bull regime to resume (was 15%/20%/25% → 10/30/50)
|
||||
- **Sector limits**: Both enforce max 2 per sector (bot:608-614; bt:268-274)
|
||||
- **Max concurrent positions**: Both enforce max 8 (bot:599-606; bt:263-265) [was 7]
|
||||
- **Momentum ranking**: Both filter to top 15 momentum stocks (bot:965-985; bt:718-729) [was 20]
|
||||
- **bars_held increment**: Both increment at START of trading cycle/bar (bot:909-912; bt:713-716)
|
||||
|
||||
### 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
|
||||
- `ATR_STOP_MULTIPLIER`: 3.5x (was 3.0x)
|
||||
- `ATR_TRAIL_MULTIPLIER`: 3.0x (was 2.0x)
|
||||
- `ATR_TRAIL_ACTIVATION_MULTIPLIER`: 2.0x (unchanged)
|
||||
- `MAX_POSITION_SIZE`: 20% (was 25%)
|
||||
- `MAX_CONCURRENT_POSITIONS`: 8 (was 7)
|
||||
- `MAX_SECTOR_POSITIONS`: 2
|
||||
- `MAX_DRAWDOWN_HALT`: 15% (updated from 12%)
|
||||
- `DRAWDOWN_HALT_BARS`: 10 (updated from 20)
|
||||
- `REENTRY_COOLDOWN_BARS`: 5
|
||||
- `DRAWDOWN_TIER1_PCT`: 12% (was 15%)
|
||||
- `DRAWDOWN_TIER1_BARS`: 15 (was 10)
|
||||
- `DRAWDOWN_TIER2_PCT`: 18% (was 20%)
|
||||
- `DRAWDOWN_TIER2_BARS`: 40 (was 30)
|
||||
- `DRAWDOWN_TIER3_PCT`: 25% (unchanged)
|
||||
- `DRAWDOWN_TIER3_BARS`: 60 (was 50)
|
||||
- `REENTRY_COOLDOWN_BARS`: 10 (was 5)
|
||||
- `RAMPUP_PERIOD_BARS`: 15
|
||||
- `TOP_MOMENTUM_COUNT`: 20 (updated from 10)
|
||||
- `TIME_EXIT_BARS`: 40
|
||||
- `TOP_MOMENTUM_COUNT`: 15 (was 20)
|
||||
- `TIME_EXIT_BARS`: 80 (was 40)
|
||||
- `MIN_CASH_RESERVE`: 5%
|
||||
- `MAX_LOSS_PCT`: 5%
|
||||
- `MAX_LOSS_PCT`: 8% (was 5%)
|
||||
- `REGIME_CAUTION_SIZE_FACTOR`: 0.25 (was 0.5)
|
||||
- `REGIME_CAUTION_THRESHOLD_BUMP`: 3.0 (was 2.0)
|
||||
|
||||
### 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`
|
||||
**Hourly mode**: `max(245 MACD, 15 RSI, 350 EMA, 28 ADX, 140 BB, 441 momentum) + 5 = 446 bars`
|
||||
|
||||
Calculation in `config.rs:193-206` (`IndicatorParams::min_bars()`)
|
||||
- RSI-2/3 warmup covered by RSI-14 requirement (15 > 3)
|
||||
- MACD needs slow + signal periods (26 + 9 = 35)
|
||||
Calculation in `config.rs:239-252` (`IndicatorParams::min_bars()`)
|
||||
- Momentum period dominates warmup (441 bars = 63 * 7)
|
||||
- MACD needs slow + signal (182 + 63 = 245)
|
||||
- EMA trend: 350 (50 * 7)
|
||||
- ADX needs 2x period for smoothing (14 * 2 = 28)
|
||||
- Hourly EMA-200 dominates warmup requirement
|
||||
- BB period: 140 (20 * 7)
|
||||
- RSI-2/3 warmup covered by RSI-14 requirement (15 > 3)
|
||||
|
||||
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:
|
||||
- bot.rs: lines 830, 853-860
|
||||
- backtester.rs: lines 476, 523-530
|
||||
|
||||
### 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
|
||||
- bot.rs lines 947-1035
|
||||
- backtester.rs lines 731-844
|
||||
|
||||
---
|
||||
|
||||
## INTENTIONAL DIFFERENCES (Not Bugs) ✅
|
||||
|
||||
### 1. Slippage Modeling
|
||||
- **Backtester**: Applies 10 bps on both entry and exit (backtester.rs:71-78)
|
||||
- **Live bot**: Uses actual fill prices from Alpaca API (bot.rs:567-571)
|
||||
- **Backtester**: Applies 10 bps on both entry and exit (backtester.rs:86-93)
|
||||
- **Live bot**: Uses actual fill prices from Alpaca API (bot.rs:644-648)
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
### 2. PDT Protection Strategy
|
||||
- **bot.rs**: Blocks non-stop-loss sells if would trigger PDT (lines 693-705)
|
||||
- **backtester.rs**: Blocks entries in last 2 hours of hourly day (lines 238-244)
|
||||
- **Verdict**: Two different approaches to PDT prevention. Daily mode prevents day trades by construction (phase separation makes same-bar buy+sell impossible). Hourly mode uses different strategies but both achieve PDT compliance.
|
||||
|
||||
---
|
||||
|
||||
## STRATEGY ARCHITECTURE (2026-02-12, Still Current)
|
||||
## STRATEGY ARCHITECTURE (2026-02-13)
|
||||
|
||||
### Regime-Adaptive Dual Signal
|
||||
The strategy uses **ADX for regime detection** and switches between two modes:
|
||||
### Regime-Adaptive with Bear Market Protection
|
||||
The strategy uses **ADX for regime detection** and **SPY for market filter**:
|
||||
|
||||
#### RANGE-BOUND (ADX < 20): Mean Reversion
|
||||
- **Entry**: Connors RSI-2 extreme oversold (RSI-2 < 10) + price above EMA trend
|
||||
- **Exit**: RSI-2 extreme overbought (RSI-2 > 90) or standard exits
|
||||
- **Conviction boosters**: Bollinger Band extremes, volume confirmation
|
||||
#### SPY MARKET REGIME FILTER (Primary Risk Gate)
|
||||
- **Bull** (SPY > EMA-200, EMA-50 > EMA-200): full size, normal thresholds
|
||||
- **Caution** (SPY < EMA-50, SPY > EMA-200): 25% size, +3.0 threshold bump
|
||||
- **Bear** (SPY < EMA-200, EMA-50 < EMA-200): NO new longs at all
|
||||
|
||||
#### 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
|
||||
#### ADX REGIME (Signal Type Selection)
|
||||
- **ADX < 20**: Range-bound → mean reversion signals preferred
|
||||
- **ADX > 25**: Trending → momentum pullback signals preferred
|
||||
|
||||
#### UNIVERSAL SIGNALS (Both Regimes)
|
||||
- RSI-14 extremes in trending context
|
||||
- MACD crossovers
|
||||
- EMA crossovers
|
||||
- Volume gate (reduces scores 50% if volume < 80% of 20-period MA)
|
||||
### Hierarchical Signal Generation (indicators.rs)
|
||||
**NOT additive "indicator soup"** — uses gated filters:
|
||||
|
||||
### Signal Thresholds (Updated 2026-02-13)
|
||||
**LAYER 1 (GATE)**: Trend confirmation required
|
||||
- Price > EMA-trend
|
||||
- EMA-short > EMA-long
|
||||
Without both, no buy signal generated.
|
||||
|
||||
**LAYER 2 (ENTRY)**: Momentum + pullback timing
|
||||
- Positive momentum (ROC > 0)
|
||||
- RSI-14 pullback (25-55 range, widened from 30-50)
|
||||
|
||||
**LAYER 3 (CONVICTION)**: Supplementary confirmation
|
||||
- MACD histogram positive
|
||||
- ADX > 25 with DI+ > DI-
|
||||
- Volume above average
|
||||
|
||||
### Signal Thresholds
|
||||
- **StrongBuy**: total_score >= 7.0
|
||||
- **Buy**: total_score >= 4.0 (was 4.5)
|
||||
- **Buy**: total_score >= 4.0
|
||||
- **StrongSell**: total_score <= -7.0
|
||||
- **Sell**: total_score <= -4.0
|
||||
- **Hold**: everything else
|
||||
|
||||
Confidence: `(total_score.abs() / 12.0).min(1.0)`
|
||||
Confidence: `(total_score.abs() / 10.0).min(1.0)` (changed from /12.0)
|
||||
|
||||
---
|
||||
|
||||
## KEY LESSONS
|
||||
|
||||
### 1. Shared Logic Eliminates Drift
|
||||
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.
|
||||
### 1. Equity Curve SMA Creates Pathological Feedback Loop
|
||||
Without removing it from the bot, losing positions drag equity below SMA, blocking ALL new entries, which prevents recovery. This is a self-reinforcing trap. The backtester correctly removed it; the bot must follow.
|
||||
|
||||
### 2. Drawdown Circuit Breaker Needs Peak Reset on Resume
|
||||
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.
|
||||
### 2. Shared Logic Eliminates Most Drift
|
||||
Extracting common logic into `strategy.rs` and `indicators.rs` ensures bot and backtester CANNOT diverge on core trading decisions. Almost all consistency issues now are portfolio-level controls, not signal logic.
|
||||
|
||||
### 3. PDT Protection Strategy Differs by Timeframe
|
||||
- **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.
|
||||
### 3. Config Constants Propagation Works Well
|
||||
Using `config.rs` constants throughout prevents hardcoded values. All recent parameter changes (7x hourly scaling, 0.25 Caution sizing, tiered drawdowns) were automatically consistent because both files import the same constants.
|
||||
|
||||
### 4. Config Constants Must Be Audited After Every Change
|
||||
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.
|
||||
### 4. Warmup Must Account for Longest Indicator Chain
|
||||
For hourly mode with 7x scaling, momentum_period=441 dominates warmup. The `+ 5` safety margin in `min_bars()` is critical for EMA initialization edge cases.
|
||||
|
||||
### 5. Warmup Must Account for Longest Indicator Chain
|
||||
For hourly mode, EMA-200 dominates warmup (205 bars). The `+ 5` safety margin in `min_bars()` is critical.
|
||||
### 5. Math Errors Can Be Consistently Wrong
|
||||
The confidence scaling bug (using 12.0 instead of 10.0) is in both files, so they produce the same wrong behavior. This makes it non-critical for consistency but still a bug to fix.
|
||||
|
||||
### 6. Data Type Consistency for PDT Tracking (FIXED)
|
||||
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. ✅
|
||||
### 6. Drawdown Peak Reset Now Fixed
|
||||
Previous audit found bot.rs was missing peak reset on halt expiry. This has been FIXED (bot.rs:442 now resets peak).
|
||||
|
||||
---
|
||||
|
||||
@@ -184,84 +200,40 @@ 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 logic? Peak reset on expiry?
|
||||
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?
|
||||
4. **Equity curve stop**: Check if REMOVED in both files (don't re-add to backtester!)
|
||||
5. **Cooldown timers**: Identical logic in both files?
|
||||
6. **Ramp-up period**: Identical logic in both files?
|
||||
7. **Drawdown halt**: Identical trigger logic? Peak reset on expiry?
|
||||
8. **Sector limits**: Same `MAX_SECTOR_POSITIONS` constant?
|
||||
9. **Max positions**: Same `MAX_CONCURRENT_POSITIONS` constant?
|
||||
10. **Momentum ranking**: Same `TOP_MOMENTUM_COUNT` constant?
|
||||
11. **bars_held increment**: Both increment at START of cycle/bar?
|
||||
12. **Warmup calculation**: Does `min_bars()` cover all indicators?
|
||||
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? Document timeframe-specific behavior.
|
||||
15. **Config propagation**: Are new constants used consistently?
|
||||
16. **Math in regime logic**: Confidence scaling uses correct multiplier (10.0 not 12.0)?
|
||||
|
||||
---
|
||||
|
||||
## FILES AUDITED (2026-02-13)
|
||||
- `/home/work/Documents/rust/invest-bot/src/bot.rs` (933 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)
|
||||
## FILES LOCATIONS
|
||||
|
||||
**Total**: 2,532 lines audited
|
||||
**Issues found**: 1 critical bug (drawdown peak reset), 1 critical behavioral divergence (PDT enforcement)
|
||||
**Status**: ⚠️ FIX REQUIRED BEFORE PRODUCTION (drawdown peak reset)
|
||||
- `/home/work/Documents/rust/invest-bot/src/bot.rs` — Live trading loop (1119 lines)
|
||||
- `/home/work/Documents/rust/invest-bot/src/backtester.rs` — Historical simulation (1217 lines)
|
||||
- `/home/work/Documents/rust/invest-bot/src/strategy.rs` — Shared position sizing and risk management (163 lines)
|
||||
- `/home/work/Documents/rust/invest-bot/src/indicators.rs` — Shared signal generation (673 lines)
|
||||
- `/home/work/Documents/rust/invest-bot/src/config.rs` — All strategy parameters (268 lines)
|
||||
- `/home/work/Documents/rust/invest-bot/src/types.rs` — Data structures (262 lines)
|
||||
|
||||
**Total**: 3,702 lines audited (2026-02-13 v2)
|
||||
|
||||
---
|
||||
|
||||
## REQUIRED FIX: Drawdown Peak Reset on Halt Expiry
|
||||
## CRITICAL REMINDERS
|
||||
|
||||
**File**: `/home/work/Documents/rust/invest-bot/src/bot.rs`
|
||||
**Location**: Lines 365-377 (inside drawdown halt expiry check)
|
||||
|
||||
**Current code**:
|
||||
```rust
|
||||
// Auto-resume after time-based cooldown
|
||||
if self.drawdown_halt {
|
||||
if let Some(halt_start) = self.drawdown_halt_start {
|
||||
if self.trading_cycle_count >= halt_start + DRAWDOWN_HALT_BARS {
|
||||
tracing::info!(
|
||||
"Drawdown halt expired after {} cycles. Resuming trading at {:.2}% drawdown.",
|
||||
DRAWDOWN_HALT_BARS,
|
||||
drawdown_pct * 100.0
|
||||
);
|
||||
self.drawdown_halt = false;
|
||||
self.drawdown_halt_start = None;
|
||||
// MISSING: self.peak_portfolio_value = portfolio_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Required change**: Add after line 374:
|
||||
```rust
|
||||
self.peak_portfolio_value = portfolio_value;
|
||||
```
|
||||
|
||||
**Complete fixed block**:
|
||||
```rust
|
||||
// Auto-resume after time-based cooldown
|
||||
if self.drawdown_halt {
|
||||
if let Some(halt_start) = self.drawdown_halt_start {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
- **NEVER re-add equity curve SMA stop to backtester** — it was removed for good reason
|
||||
- **Remove equity curve SMA stop from bot.rs** — lines 809-826, 904-907, 991-992
|
||||
- **Warmup for hourly mode is 446 bars** — momentum_period=441 dominates
|
||||
- **Confidence → score conversion is `score = confidence * 10.0`** not 12.0
|
||||
- **Both files correctly reset peak on drawdown halt expiry** (verified in this audit)
|
||||
- **PDT protection differs by timeframe** — daily uses phase separation, hourly uses entry/exit blocking
|
||||
|
||||
@@ -1,59 +1,63 @@
|
||||
# Quant-Rust-Strategist Memory
|
||||
|
||||
## Architecture Overview
|
||||
- ~100-symbol universe across 14 sectors (expanded from original 50)
|
||||
- Hybrid momentum + mean-reversion via regime-adaptive dual signal in `generate_signal()`
|
||||
- ~100-symbol universe across 14 sectors
|
||||
- strategy.rs: shared logic between bot.rs and backtester.rs
|
||||
- Backtester restricts buys to top momentum stocks (TOP_MOMENTUM_COUNT)
|
||||
- SPY regime filter (EMA-50/200) gates new longs: Bull/Caution/Bear
|
||||
- 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`
|
||||
## Signal Generation (2026-02-13 REWRITE)
|
||||
- **OLD**: Additive "indicator soup" -- 8 indicators netted, PF 0.91, no edge
|
||||
- **NEW**: Hierarchical momentum-with-trend filter:
|
||||
- Gate 1: trend_bullish AND ema_bullish -- MUST pass for any buy
|
||||
- Gate 2: positive momentum (ROC > 0) -- time-series momentum
|
||||
- Timing: RSI-14 pullback (30-50) in confirmed uptrends
|
||||
- Conviction: ADX direction, MACD histogram, volume
|
||||
- Sell: trend break (price < EMA-trend) is primary exit signal
|
||||
- Key insight: hierarchical gating >> additive scoring
|
||||
|
||||
### 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
|
||||
## Stop/Exit Logic (2026-02-13 FIX)
|
||||
- Time exit ONLY sells losers (pnl_pct < 0). Old code force-sold winners.
|
||||
- Trail activation: 1.5x ATR (was 2.0x), trail distance: 2.5x ATR (was 2.0x)
|
||||
- Max loss: 8% (was 5%), TIME_EXIT_BARS: 60 (was 40)
|
||||
|
||||
### 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)
|
||||
## Equity Curve SMA Stop: REMOVED from backtester
|
||||
- Created pathological feedback loop with drawdown breaker
|
||||
|
||||
## 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)
|
||||
- PDT blocking DISABLED in backtester (kept in bot.rs for live trading)
|
||||
## Position Sizing (2026-02-13 FIX)
|
||||
- Confidence scaling: 0.4 + 0.6*conf (was 0.7 + 0.3*conf)
|
||||
- RISK_PER_TRADE: 1.0%, MAX_POSITIONS: 10, TOP_MOMENTUM: 10
|
||||
|
||||
## Current Parameters (config.rs, updated 2026-02-13)
|
||||
- ATR Stop: 3.0x | Trail: 2.0x distance, 2.0x activation
|
||||
- Risk: 1.2%/trade, max 25% position, 5% cash reserve, 5% max loss
|
||||
- Max 7 positions, 2/sector | Drawdown halt: 15% (10 bars) | Time exit: 40
|
||||
- ATR Stop: 3.0x | Trail: 2.5x distance, 1.5x activation
|
||||
- Risk: 1.0%/trade, max 25% position, 5% cash reserve, 8% max loss
|
||||
- Max 10 positions, 2/sector | Time exit: 60 bars (losers only)
|
||||
- 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
|
||||
- ADX: range<20, trend>25, strong>40
|
||||
- Momentum pool: top 10 (decile)
|
||||
|
||||
## 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
|
||||
## Bugs Fixed (2026-02-13)
|
||||
1. calculate_results used self.cash instead of equity curve final value
|
||||
2. Drawdown circuit breaker cascading re-triggers (peak not reset)
|
||||
3. PDT blocking sells in backtester (disabled)
|
||||
|
||||
## 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 (but 4.0 is fine)
|
||||
3. Blocking stop-loss exits for PDT: traps capital in losers, dangerous
|
||||
2. Lower buy threshold (3.5): too many weak entries (4.0 is fine)
|
||||
3. Blocking stop-loss exits for PDT: traps capital in losers
|
||||
4. Lower volume threshold (0.7): bad trades on IEX. Keep 0.8
|
||||
5. Shorter hourly lookbacks: catastrophic losses
|
||||
6. Drawdown halt 12% with non-resetting peak: cascading re-triggers in multi-year tests
|
||||
6. Drawdown halt 12% with non-resetting peak: cascading re-triggers
|
||||
7. Additive indicator soup: fundamentally has no edge (PF < 1.0)
|
||||
8. Time exit that dumps winners: destroys win/loss asymmetry
|
||||
9. Equity curve SMA stop: correlated with drawdown breaker, blocks recovery
|
||||
|
||||
## Hourly Timeframe: DO NOT CHANGE FROM BASELINE
|
||||
- momentum=63, ema_trend=200 (long lookbacks filter IEX noise)
|
||||
|
||||
## 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
|
||||
- Run 2-3 times and compare ranges before concluding a change helped/hurt
|
||||
|
||||
## Build Notes
|
||||
- `cargo build --release` compiles clean (only dead_code warnings for types.rs fields)
|
||||
- `cargo build --release` compiles clean (only dead_code warnings)
|
||||
- No tests exist
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
if [[ ! -d "/home/work/Documents/rust/invest-bot" ]]; then
|
||||
if [[ ! -d "/home/mrfluffy/Documents/projects/rust/vibe-invest" ]]; then
|
||||
echo "Cannot find source directory; Did you move it?"
|
||||
echo "(Looking for "/home/work/Documents/rust/invest-bot")"
|
||||
echo "(Looking for "/home/mrfluffy/Documents/projects/rust/vibe-invest")"
|
||||
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# rebuild the cache forcefully
|
||||
_nix_direnv_force_reload=1 direnv exec "/home/work/Documents/rust/invest-bot" true
|
||||
_nix_direnv_force_reload=1 direnv exec "/home/mrfluffy/Documents/projects/rust/vibe-invest" true
|
||||
|
||||
# Update the mtime for .envrc.
|
||||
# This will cause direnv to reload again - but without re-building.
|
||||
touch "/home/work/Documents/rust/invest-bot/.envrc"
|
||||
touch "/home/mrfluffy/Documents/projects/rust/vibe-invest/.envrc"
|
||||
|
||||
# Also update the timestamp of whatever profile_rc we have.
|
||||
# This makes sure that we know we are up to date.
|
||||
touch -r "/home/work/Documents/rust/invest-bot/.envrc" "/home/work/Documents/rust/invest-bot/.direnv"/*.rc
|
||||
touch -r "/home/mrfluffy/Documents/projects/rust/vibe-invest/.envrc" "/home/mrfluffy/Documents/projects/rust/vibe-invest/.direnv"/*.rc
|
||||
|
||||
1
.direnv/flake-inputs/j9250k63yp54q9r2m0xnca8lxjcfadv0-source
Symbolic link
1
.direnv/flake-inputs/j9250k63yp54q9r2m0xnca8lxjcfadv0-source
Symbolic link
@@ -0,0 +1 @@
|
||||
/nix/store/j9250k63yp54q9r2m0xnca8lxjcfadv0-source
|
||||
@@ -1 +0,0 @@
|
||||
/nix/store/vanbyn1mbsqmff9in675grd5lqpr69zl-source
|
||||
@@ -41,7 +41,7 @@ NIX_ENFORCE_NO_NATIVE='1'
|
||||
export NIX_ENFORCE_NO_NATIVE
|
||||
NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningextensive libcxxhardeningfast pic relro stackclashprotection stackprotector strictoverflow zerocallusedregs'
|
||||
export NIX_HARDENING_ENABLE
|
||||
NIX_LDFLAGS='-rpath /home/work/Documents/rust/invest-bot/outputs/out/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib'
|
||||
NIX_LDFLAGS='-rpath /home/mrfluffy/Documents/projects/rust/vibe-invest/outputs/out/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib'
|
||||
export NIX_LDFLAGS
|
||||
NIX_NO_SELF_RPATH='1'
|
||||
NIX_PKG_CONFIG_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
|
||||
@@ -142,7 +142,7 @@ name='nix-shell-env'
|
||||
export name
|
||||
nativeBuildInputs='/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed /nix/store/fgm3pz8486ksh3f94629lpb7xjr2wjp7-openssl-3.6.0-dev /nix/store/rvp7qlpf5jqvdckjy1afjb6aha6j8dxg-pkg-config-wrapper-0.29.2 /nix/store/fl02yv3ax1qf1xkq64ik8qz5bjxyyd71-cargo-deny-0.19.0 /nix/store/7va1z8il76ycxvyvgsbpr4bjk89lzj5a-cargo-edit-0.13.8 /nix/store/zrx7kmcgzax4s6fldam9hf6nmwcw5nks-cargo-watch-8.5.3 /nix/store/b42adwrm8v2lb1889x1zb8dxzf5ljqys-rust-analyzer-2026-02-02'
|
||||
export nativeBuildInputs
|
||||
out='/home/work/Documents/rust/invest-bot/outputs/out'
|
||||
out='/home/mrfluffy/Documents/projects/rust/vibe-invest/outputs/out'
|
||||
export out
|
||||
outputBin='out'
|
||||
outputDev='out'
|
||||
@@ -173,7 +173,7 @@ preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase'
|
||||
declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
|
||||
preferLocalBuild='1'
|
||||
export preferLocalBuild
|
||||
prefix='/home/work/Documents/rust/invest-bot/outputs/out'
|
||||
prefix='/home/mrfluffy/Documents/projects/rust/vibe-invest/outputs/out'
|
||||
declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
|
||||
propagatedBuildInputs=''
|
||||
export propagatedBuildInputs
|
||||
|
||||
@@ -21,6 +21,10 @@ cargo run --release -- --backtest --years 3
|
||||
cargo run --release -- --backtest --years 5 --capital 50000
|
||||
cargo run --release -- --backtest --years 1 --months 6 --timeframe hourly
|
||||
|
||||
# Run backtesting with custom date range
|
||||
cargo run --release -- --backtest --start-date 2007-01-01 --end-date 2008-12-31
|
||||
cargo run --release -- --backtest --start-date 2020-03-01 --end-date 2020-12-31 --timeframe hourly
|
||||
|
||||
# Lint and format (available via nix flake)
|
||||
cargo clippy
|
||||
cargo fmt
|
||||
|
||||
287
src/alpaca.rs
287
src/alpaca.rs
@@ -463,8 +463,41 @@ impl AlpacaClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the cache file path for a given symbol and timeframe.
|
||||
fn cache_path(symbol: &str, timeframe: Timeframe) -> std::path::PathBuf {
|
||||
let tf_dir = match timeframe {
|
||||
Timeframe::Daily => "daily",
|
||||
Timeframe::Hourly => "hourly",
|
||||
};
|
||||
let mut path = crate::paths::BACKTEST_CACHE_DIR.clone();
|
||||
path.push(tf_dir);
|
||||
std::fs::create_dir_all(&path).ok();
|
||||
path.push(format!("{}.json", symbol));
|
||||
path
|
||||
}
|
||||
|
||||
/// Load cached bars for a symbol. Returns empty vec on any error.
|
||||
fn load_cached_bars(symbol: &str, timeframe: Timeframe) -> Vec<Bar> {
|
||||
let path = cache_path(symbol, timeframe);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save bars to cache for a symbol.
|
||||
fn save_cached_bars(symbol: &str, timeframe: Timeframe, bars: &[Bar]) {
|
||||
let path = cache_path(symbol, timeframe);
|
||||
if let Ok(json) = serde_json::to_string(bars) {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
tracing::warn!("Failed to write cache for {}: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to fetch bars for backtesting with proper date handling.
|
||||
/// Fetches each symbol individually to avoid API limits on multi-symbol requests.
|
||||
/// Uses a disk cache to avoid re-fetching bars that were already downloaded.
|
||||
pub async fn fetch_backtest_data(
|
||||
client: &AlpacaClient,
|
||||
symbols: &[&str],
|
||||
@@ -476,6 +509,9 @@ pub async fn fetch_backtest_data(
|
||||
let days = (years * 365.0) as i64 + warmup_days + 30;
|
||||
let start = end - Duration::days(days);
|
||||
|
||||
// Re-fetch overlap: always re-fetch the last 2 days to handle partial/corrected bars
|
||||
let refetch_overlap = Duration::days(2);
|
||||
|
||||
tracing::info!(
|
||||
"Fetching {:.2} years of data ({} to {})...",
|
||||
years,
|
||||
@@ -484,29 +520,250 @@ pub async fn fetch_backtest_data(
|
||||
);
|
||||
|
||||
let mut all_data = HashMap::new();
|
||||
let mut cache_hits = 0u32;
|
||||
let mut cache_misses = 0u32;
|
||||
|
||||
// Fetch each symbol individually like Python does
|
||||
// The multi-symbol endpoint has a 10000 bar limit across ALL symbols
|
||||
for symbol in symbols {
|
||||
tracing::info!(" Fetching {}...", symbol);
|
||||
let cached = load_cached_bars(symbol, timeframe);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, start, end)
|
||||
.await
|
||||
{
|
||||
Ok(bars) => {
|
||||
if !bars.is_empty() {
|
||||
tracing::info!(" {}: {} bars loaded", symbol, bars.len());
|
||||
all_data.insert(symbol.to_string(), bars);
|
||||
} else {
|
||||
tracing::warn!(" {}: No data", symbol);
|
||||
if cached.is_empty() {
|
||||
// Full fetch — no cache
|
||||
cache_misses += 1;
|
||||
tracing::info!(" Fetching {} (no cache)...", symbol);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, start, end)
|
||||
.await
|
||||
{
|
||||
Ok(bars) => {
|
||||
if !bars.is_empty() {
|
||||
tracing::info!(" {}: {} bars fetched", symbol, bars.len());
|
||||
save_cached_bars(symbol, timeframe, &bars);
|
||||
all_data.insert(symbol.to_string(), bars);
|
||||
} else {
|
||||
tracing::warn!(" {}: No data", symbol);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(" Failed to fetch {}: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(" Failed to fetch {}: {}", symbol, e);
|
||||
} else {
|
||||
let first_cached_ts = cached.first().unwrap().timestamp;
|
||||
let last_cached_ts = cached.last().unwrap().timestamp;
|
||||
let need_older = start < first_cached_ts;
|
||||
let need_newer = last_cached_ts - refetch_overlap < end;
|
||||
|
||||
if !need_older && !need_newer {
|
||||
cache_hits += 1;
|
||||
tracing::info!(" {}: {} bars from cache (fully cached)", symbol, cached.len());
|
||||
all_data.insert(symbol.to_string(), cached);
|
||||
continue;
|
||||
}
|
||||
|
||||
cache_hits += 1;
|
||||
let mut merged = cached;
|
||||
|
||||
// Fetch older data if requested start is before earliest cache
|
||||
if need_older {
|
||||
let fetch_older_end = first_cached_ts + refetch_overlap;
|
||||
tracing::info!(
|
||||
" {} (fetching older: {} to {})...",
|
||||
symbol,
|
||||
start.format("%Y-%m-%d"),
|
||||
fetch_older_end.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, start, fetch_older_end)
|
||||
.await
|
||||
{
|
||||
Ok(old_bars) => {
|
||||
tracing::info!(" {}: {} older bars fetched", symbol, old_bars.len());
|
||||
merged.extend(old_bars);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(" {}: older fetch failed: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch newer data from last cached - overlap
|
||||
if need_newer {
|
||||
let fetch_from = last_cached_ts - refetch_overlap;
|
||||
tracing::info!(
|
||||
" {} ({} cached, fetching newer from {})...",
|
||||
symbol,
|
||||
merged.len(),
|
||||
fetch_from.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, fetch_from, end)
|
||||
.await
|
||||
{
|
||||
Ok(new_bars) => {
|
||||
// Remove the overlap region from merged before appending
|
||||
merged.retain(|b| b.timestamp < fetch_from);
|
||||
merged.extend(new_bars);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(" {}: newer fetch failed: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup and sort
|
||||
merged.sort_by_key(|b| b.timestamp);
|
||||
merged.dedup_by_key(|b| b.timestamp);
|
||||
|
||||
tracing::info!(" {}: {} bars total (merged)", symbol, merged.len());
|
||||
save_cached_bars(symbol, timeframe, &merged);
|
||||
all_data.insert(symbol.to_string(), merged);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Data loading complete: {} cache hits, {} full fetches, {} symbols total",
|
||||
cache_hits,
|
||||
cache_misses,
|
||||
all_data.len()
|
||||
);
|
||||
|
||||
Ok(all_data)
|
||||
}
|
||||
|
||||
/// Helper to fetch bars for backtesting with specific date range.
|
||||
/// Similar to fetch_backtest_data but accepts explicit start/end dates.
|
||||
pub async fn fetch_backtest_data_with_dates(
|
||||
client: &AlpacaClient,
|
||||
symbols: &[&str],
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
timeframe: Timeframe,
|
||||
warmup_days: i64,
|
||||
) -> Result<HashMap<String, Vec<Bar>>> {
|
||||
// Add warmup period to start date
|
||||
let start_with_warmup = start - Duration::days(warmup_days + 30);
|
||||
|
||||
// Re-fetch overlap: always re-fetch the last 2 days to handle partial/corrected bars
|
||||
let refetch_overlap = Duration::days(2);
|
||||
|
||||
tracing::info!(
|
||||
"Fetching data from {} to {}...",
|
||||
start_with_warmup.format("%Y-%m-%d"),
|
||||
end.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
let mut all_data = HashMap::new();
|
||||
let mut cache_hits = 0u32;
|
||||
let mut cache_misses = 0u32;
|
||||
|
||||
for symbol in symbols {
|
||||
let cached = load_cached_bars(symbol, timeframe);
|
||||
|
||||
if cached.is_empty() {
|
||||
// Full fetch — no cache
|
||||
cache_misses += 1;
|
||||
tracing::info!(" Fetching {} (no cache)...", symbol);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, start_with_warmup, end)
|
||||
.await
|
||||
{
|
||||
Ok(bars) => {
|
||||
if !bars.is_empty() {
|
||||
tracing::info!(" {}: {} bars fetched", symbol, bars.len());
|
||||
save_cached_bars(symbol, timeframe, &bars);
|
||||
all_data.insert(symbol.to_string(), bars);
|
||||
} else {
|
||||
tracing::warn!(" {}: No data", symbol);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(" Failed to fetch {}: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let first_cached_ts = cached.first().unwrap().timestamp;
|
||||
let last_cached_ts = cached.last().unwrap().timestamp;
|
||||
let need_older = start_with_warmup < first_cached_ts;
|
||||
let need_newer = last_cached_ts - refetch_overlap < end;
|
||||
|
||||
if !need_older && !need_newer {
|
||||
cache_hits += 1;
|
||||
tracing::info!(" {}: {} bars from cache (fully cached)", symbol, cached.len());
|
||||
all_data.insert(symbol.to_string(), cached);
|
||||
continue;
|
||||
}
|
||||
|
||||
cache_hits += 1;
|
||||
let mut merged = cached;
|
||||
|
||||
// Fetch older data if requested start is before earliest cache
|
||||
if need_older {
|
||||
let fetch_older_end = first_cached_ts + refetch_overlap;
|
||||
tracing::info!(
|
||||
" {} (fetching older: {} to {})...",
|
||||
symbol,
|
||||
start_with_warmup.format("%Y-%m-%d"),
|
||||
fetch_older_end.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, start_with_warmup, fetch_older_end)
|
||||
.await
|
||||
{
|
||||
Ok(old_bars) => {
|
||||
merged = old_bars.into_iter().chain(merged).collect();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(" {}: older fetch failed: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch newer data if cache doesn't cover requested end
|
||||
if need_newer {
|
||||
let fetch_from = last_cached_ts - refetch_overlap;
|
||||
tracing::info!(
|
||||
" {} (fetching newer: {} to {})...",
|
||||
symbol,
|
||||
fetch_from.format("%Y-%m-%d"),
|
||||
end.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, fetch_from, end)
|
||||
.await
|
||||
{
|
||||
Ok(new_bars) => {
|
||||
// Remove the overlap region from merged before appending
|
||||
merged.retain(|b| b.timestamp < fetch_from);
|
||||
merged.extend(new_bars);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(" {}: newer fetch failed: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup and sort
|
||||
merged.sort_by_key(|b| b.timestamp);
|
||||
merged.dedup_by_key(|b| b.timestamp);
|
||||
|
||||
tracing::info!(" {}: {} bars total (merged)", symbol, merged.len());
|
||||
save_cached_bars(symbol, timeframe, &merged);
|
||||
all_data.insert(symbol.to_string(), merged);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Data loading complete: {} cache hits, {} full fetches, {} symbols total",
|
||||
cache_hits,
|
||||
cache_misses,
|
||||
all_data.len()
|
||||
);
|
||||
|
||||
Ok(all_data)
|
||||
}
|
||||
|
||||
@@ -4,19 +4,30 @@ use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use crate::alpaca::{fetch_backtest_data, AlpacaClient};
|
||||
use crate::alpaca::{fetch_backtest_data, fetch_backtest_data_with_dates, AlpacaClient};
|
||||
use crate::config::{
|
||||
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
|
||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, DRAWDOWN_HALT_BARS, HOURS_PER_DAY,
|
||||
MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT, MAX_LOSS_PCT, MAX_POSITION_SIZE,
|
||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, HOURS_PER_DAY,
|
||||
MAX_CONCURRENT_POSITIONS, MAX_LOSS_PCT, MAX_POSITION_SIZE,
|
||||
MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS,
|
||||
REENTRY_COOLDOWN_BARS, SLIPPAGE_BPS, TIME_EXIT_BARS,
|
||||
TOP_MOMENTUM_COUNT, TRADING_DAYS_PER_YEAR,
|
||||
DRAWDOWN_TIER1_PCT, DRAWDOWN_TIER1_BARS,
|
||||
DRAWDOWN_TIER2_PCT, DRAWDOWN_TIER2_BARS,
|
||||
DRAWDOWN_TIER3_PCT, DRAWDOWN_TIER3_BARS,
|
||||
DRAWDOWN_TIER3_REQUIRE_BULL,
|
||||
HOURLY_DRAWDOWN_TIER1_PCT, HOURLY_DRAWDOWN_TIER1_BARS,
|
||||
HOURLY_DRAWDOWN_TIER2_PCT, HOURLY_DRAWDOWN_TIER2_BARS,
|
||||
HOURLY_DRAWDOWN_TIER3_PCT, HOURLY_DRAWDOWN_TIER3_BARS,
|
||||
EQUITY_CURVE_SMA_PERIOD,
|
||||
REGIME_SPY_SYMBOL, REGIME_EMA_SHORT, REGIME_EMA_LONG,
|
||||
REGIME_CAUTION_SIZE_FACTOR, REGIME_CAUTION_THRESHOLD_BUMP,
|
||||
HOURLY_REGIME_CAUTION_SIZE_FACTOR, HOURLY_REGIME_CAUTION_THRESHOLD_BUMP, ALLOW_LONGS_IN_BEAR_MARKET,
|
||||
};
|
||||
use crate::indicators::{calculate_all_indicators, generate_signal};
|
||||
use crate::indicators::{calculate_all_indicators, calculate_ema, determine_market_regime, generate_signal};
|
||||
use crate::strategy::Strategy;
|
||||
use crate::types::{
|
||||
BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, Signal, Trade, TradeSignal,
|
||||
BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, MarketRegime, Signal, Trade, TradeSignal,
|
||||
};
|
||||
|
||||
/// Backtesting engine for the trading strategy.
|
||||
@@ -30,6 +41,12 @@ pub struct Backtester {
|
||||
drawdown_halt: bool,
|
||||
/// Bar index when drawdown halt started (for time-based resume)
|
||||
drawdown_halt_start: Option<usize>,
|
||||
/// The drawdown severity that triggered the current halt (for scaled cooldowns)
|
||||
drawdown_halt_severity: f64,
|
||||
/// Current market regime (from SPY analysis)
|
||||
current_regime: MarketRegime,
|
||||
/// Whether the drawdown halt requires bull regime to resume (Tier 3)
|
||||
drawdown_requires_bull: bool,
|
||||
strategy: Strategy,
|
||||
timeframe: Timeframe,
|
||||
/// Current bar index in the simulation
|
||||
@@ -57,6 +74,9 @@ impl Backtester {
|
||||
peak_portfolio_value: initial_capital,
|
||||
drawdown_halt: false,
|
||||
drawdown_halt_start: None,
|
||||
drawdown_halt_severity: 0.0,
|
||||
current_regime: MarketRegime::Bull,
|
||||
drawdown_requires_bull: false,
|
||||
strategy: Strategy::new(timeframe),
|
||||
timeframe,
|
||||
current_bar: 0,
|
||||
@@ -87,49 +107,125 @@ impl Backtester {
|
||||
self.cash + positions_value
|
||||
}
|
||||
|
||||
/// Update drawdown circuit breaker state.
|
||||
/// 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).
|
||||
/// Update drawdown circuit breaker state with scaled cooldowns.
|
||||
///
|
||||
/// Drawdown severity determines halt duration:
|
||||
/// - Tier 1 (15%): 10 bars — normal correction
|
||||
/// - Tier 2 (20%): 30 bars — significant bear market
|
||||
/// - Tier 3 (25%+): 50 bars + require bull regime — severe bear (COVID, 2022)
|
||||
///
|
||||
/// On resume, the peak is reset to the current portfolio value to prevent
|
||||
/// cascading re-triggers from the same drawdown event.
|
||||
/// Get the drawdown tier thresholds for the current timeframe.
|
||||
fn drawdown_tiers(&self) -> (f64, usize, f64, usize, f64, usize) {
|
||||
if self.timeframe == Timeframe::Hourly {
|
||||
(
|
||||
HOURLY_DRAWDOWN_TIER1_PCT, HOURLY_DRAWDOWN_TIER1_BARS,
|
||||
HOURLY_DRAWDOWN_TIER2_PCT, HOURLY_DRAWDOWN_TIER2_BARS,
|
||||
HOURLY_DRAWDOWN_TIER3_PCT, HOURLY_DRAWDOWN_TIER3_BARS,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
DRAWDOWN_TIER1_PCT, DRAWDOWN_TIER1_BARS,
|
||||
DRAWDOWN_TIER2_PCT, DRAWDOWN_TIER2_BARS,
|
||||
DRAWDOWN_TIER3_PCT, DRAWDOWN_TIER3_BARS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn update_drawdown_state(&mut self, portfolio_value: f64) {
|
||||
if portfolio_value > self.peak_portfolio_value {
|
||||
self.peak_portfolio_value = portfolio_value;
|
||||
}
|
||||
|
||||
let drawdown_pct = (self.peak_portfolio_value - portfolio_value) / self.peak_portfolio_value;
|
||||
let (t1_pct, t1_bars, t2_pct, t2_bars, t3_pct, t3_bars) = self.drawdown_tiers();
|
||||
|
||||
// Trigger halt at the lowest tier that matches (if not already halted)
|
||||
if !self.drawdown_halt && drawdown_pct >= t1_pct {
|
||||
// Determine severity tier
|
||||
let (halt_bars, tier_name) = if drawdown_pct >= t3_pct {
|
||||
self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL;
|
||||
(t3_bars, "TIER 3 (SEVERE)")
|
||||
} else if drawdown_pct >= t2_pct {
|
||||
(t2_bars, "TIER 2")
|
||||
} else {
|
||||
(t1_bars, "TIER 1")
|
||||
};
|
||||
|
||||
// Trigger halt if drawdown exceeds threshold
|
||||
if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt {
|
||||
tracing::warn!(
|
||||
"DRAWDOWN CIRCUIT BREAKER: {:.2}% drawdown exceeds {:.0}% limit. Halting for {} bars.",
|
||||
"DRAWDOWN CIRCUIT BREAKER {}: {:.2}% drawdown. Halting for {} bars.{}",
|
||||
tier_name,
|
||||
drawdown_pct * 100.0,
|
||||
MAX_DRAWDOWN_HALT * 100.0,
|
||||
DRAWDOWN_HALT_BARS
|
||||
halt_bars,
|
||||
if self.drawdown_requires_bull { " Requires BULL regime to resume." } else { "" }
|
||||
);
|
||||
self.drawdown_halt = true;
|
||||
self.drawdown_halt_start = Some(self.current_bar);
|
||||
self.drawdown_halt_severity = drawdown_pct;
|
||||
}
|
||||
|
||||
// Upgrade severity if drawdown deepens while already halted
|
||||
if self.drawdown_halt && drawdown_pct > self.drawdown_halt_severity {
|
||||
if drawdown_pct >= t3_pct && self.drawdown_halt_severity < t3_pct {
|
||||
self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL;
|
||||
self.drawdown_halt_start = Some(self.current_bar); // Reset timer for deeper tier
|
||||
tracing::warn!(
|
||||
"Drawdown deepened to {:.2}% — UPGRADED to TIER 3. Requires BULL regime.",
|
||||
drawdown_pct * 100.0
|
||||
);
|
||||
} else if drawdown_pct >= t2_pct && self.drawdown_halt_severity < t2_pct {
|
||||
self.drawdown_halt_start = Some(self.current_bar);
|
||||
tracing::warn!(
|
||||
"Drawdown deepened to {:.2}% — upgraded to TIER 2.",
|
||||
drawdown_pct * 100.0
|
||||
);
|
||||
}
|
||||
self.drawdown_halt_severity = drawdown_pct;
|
||||
}
|
||||
|
||||
// Auto-resume after time-based cooldown
|
||||
if self.drawdown_halt {
|
||||
if let Some(halt_start) = self.drawdown_halt_start {
|
||||
if self.current_bar >= halt_start + DRAWDOWN_HALT_BARS {
|
||||
let required_bars = if self.drawdown_halt_severity >= t3_pct {
|
||||
t3_bars
|
||||
} else if self.drawdown_halt_severity >= t2_pct {
|
||||
t2_bars
|
||||
} else {
|
||||
t1_bars
|
||||
};
|
||||
|
||||
let time_served = self.current_bar >= halt_start + required_bars;
|
||||
let regime_ok = if self.drawdown_requires_bull {
|
||||
self.current_regime == MarketRegime::Bull
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if time_served && regime_ok {
|
||||
tracing::info!(
|
||||
"Drawdown halt expired after {} bars. Resuming trading. \
|
||||
"Drawdown halt expired after {} bars (regime: {}). \
|
||||
Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).",
|
||||
DRAWDOWN_HALT_BARS,
|
||||
required_bars,
|
||||
self.current_regime.as_str(),
|
||||
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.
|
||||
// The previous peak is no longer relevant after a halt — measuring
|
||||
// drawdown from it would immediately re-trigger on any minor dip.
|
||||
self.drawdown_halt_severity = 0.0;
|
||||
self.drawdown_requires_bull = false;
|
||||
self.peak_portfolio_value = portfolio_value;
|
||||
} else if time_served && !regime_ok {
|
||||
// Log periodically that we're waiting for bull regime
|
||||
if self.current_bar % 50 == 0 {
|
||||
tracing::info!(
|
||||
"Drawdown halt: time served but waiting for BULL regime (currently {}). DD: {:.2}%",
|
||||
self.current_regime.as_str(),
|
||||
drawdown_pct * 100.0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,6 +236,9 @@ impl Backtester {
|
||||
/// 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).
|
||||
///
|
||||
/// The `regime_size_factor` parameter scales position size based on the
|
||||
/// current market regime (1.0 for bull, 0.5 for caution, 0.0 for bear).
|
||||
fn execute_buy(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
@@ -147,19 +246,20 @@ impl Backtester {
|
||||
timestamp: DateTime<Utc>,
|
||||
portfolio_value: f64,
|
||||
signal: &TradeSignal,
|
||||
regime_size_factor: f64,
|
||||
) -> bool {
|
||||
if self.positions.contains_key(symbol) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Market regime gate: no new longs in bear market
|
||||
if regime_size_factor <= 0.0 {
|
||||
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;
|
||||
}
|
||||
@@ -168,7 +268,7 @@ impl Backtester {
|
||||
// Cooldown guard: prevent whipsaw re-entry after stop-loss
|
||||
if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) {
|
||||
if self.current_bar < cooldown_until {
|
||||
return false; // Still in cooldown period
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +277,11 @@ impl Backtester {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Equity curve SMA stop REMOVED: it creates a pathological feedback loop
|
||||
// where losing positions drag equity below the SMA, blocking new entries,
|
||||
// which prevents recovery. The SPY regime filter and drawdown circuit
|
||||
// breaker handle macro risk without this self-reinforcing trap.
|
||||
|
||||
if self.positions.len() >= MAX_CONCURRENT_POSITIONS {
|
||||
return false;
|
||||
}
|
||||
@@ -196,9 +301,15 @@ impl Backtester {
|
||||
}
|
||||
|
||||
let available_cash = self.cash - (portfolio_value * MIN_CASH_RESERVE);
|
||||
let shares =
|
||||
let mut shares =
|
||||
self.strategy
|
||||
.calculate_position_size(price, portfolio_value, available_cash, signal);
|
||||
|
||||
// Apply regime-based size adjustment (e.g., 50% in Caution)
|
||||
shares *= regime_size_factor;
|
||||
// Re-truncate to 4 decimal places after adjustment
|
||||
shares = (shares * 10000.0).floor() / 10000.0;
|
||||
|
||||
if shares <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
@@ -214,7 +325,7 @@ impl Backtester {
|
||||
symbol.to_string(),
|
||||
BacktestPosition {
|
||||
symbol: symbol.to_string(),
|
||||
shares: shares,
|
||||
shares,
|
||||
entry_price: fill_price,
|
||||
entry_time: timestamp,
|
||||
entry_atr: signal.atr,
|
||||
@@ -229,7 +340,7 @@ impl Backtester {
|
||||
self.trades.push(Trade {
|
||||
symbol: symbol.to_string(),
|
||||
side: "BUY".to_string(),
|
||||
shares: shares,
|
||||
shares,
|
||||
price: fill_price,
|
||||
timestamp,
|
||||
pnl: 0.0,
|
||||
@@ -444,6 +555,32 @@ impl Backtester {
|
||||
data.insert(symbol.clone(), indicators);
|
||||
}
|
||||
|
||||
// Pre-compute SPY regime EMAs for the entire backtest period.
|
||||
// We use the raw SPY close prices to compute EMA-50 and EMA-200
|
||||
// independently of the per-symbol indicator params (regime uses
|
||||
// fixed periods regardless of hourly/daily timeframe scaling).
|
||||
let spy_key = REGIME_SPY_SYMBOL.to_string();
|
||||
let spy_ema50_series: Vec<f64>;
|
||||
let spy_ema200_series: Vec<f64>;
|
||||
let has_spy_data = raw_data.contains_key(&spy_key);
|
||||
|
||||
if has_spy_data {
|
||||
let spy_closes: Vec<f64> = raw_data[&spy_key].iter().map(|b| b.close).collect();
|
||||
spy_ema50_series = calculate_ema(&spy_closes, REGIME_EMA_SHORT);
|
||||
spy_ema200_series = calculate_ema(&spy_closes, REGIME_EMA_LONG);
|
||||
tracing::info!(
|
||||
"SPY regime filter: EMA-{} / EMA-{} ({} bars of SPY data)",
|
||||
REGIME_EMA_SHORT, REGIME_EMA_LONG, spy_closes.len()
|
||||
);
|
||||
} else {
|
||||
spy_ema50_series = vec![];
|
||||
spy_ema200_series = vec![];
|
||||
tracing::warn!(
|
||||
"SPY data not available — market regime filter DISABLED. \
|
||||
All bars will be treated as BULL regime."
|
||||
);
|
||||
}
|
||||
|
||||
// Get common date range
|
||||
let mut all_dates: BTreeMap<DateTime<Utc>, HashSet<String>> = BTreeMap::new();
|
||||
for (symbol, rows) in &data {
|
||||
@@ -508,6 +645,18 @@ impl Backtester {
|
||||
symbol_date_index.insert(symbol.clone(), idx_map);
|
||||
}
|
||||
|
||||
// Build SPY raw bar index (maps timestamp → index into raw_data["SPY"])
|
||||
// so we can look up the pre-computed EMA-50/200 at each trading date.
|
||||
let spy_raw_date_index: HashMap<DateTime<Utc>, usize> = if has_spy_data {
|
||||
raw_data[&spy_key]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, bar)| (bar.timestamp, i))
|
||||
.collect()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
// Main simulation loop
|
||||
for (day_num, current_date) in trading_dates.iter().enumerate() {
|
||||
self.current_bar = day_num;
|
||||
@@ -532,6 +681,61 @@ impl Backtester {
|
||||
|
||||
let portfolio_value = self.get_portfolio_value(¤t_prices);
|
||||
|
||||
// ── SPY Market Regime Detection ─────────────────────────
|
||||
// Determine if we're in bull/caution/bear based on SPY EMAs.
|
||||
// This gates all new long entries and adjusts position sizing.
|
||||
let regime = if has_spy_data {
|
||||
if let (Some(&spy_raw_idx), Some(spy_indicator_row)) = (
|
||||
spy_raw_date_index.get(current_date),
|
||||
data.get(&spy_key)
|
||||
.and_then(|rows| {
|
||||
symbol_date_index
|
||||
.get(&spy_key)
|
||||
.and_then(|m| m.get(current_date))
|
||||
.map(|&i| &rows[i])
|
||||
}),
|
||||
) {
|
||||
let ema50 = if spy_raw_idx < spy_ema50_series.len() {
|
||||
spy_ema50_series[spy_raw_idx]
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
let ema200 = if spy_raw_idx < spy_ema200_series.len() {
|
||||
spy_ema200_series[spy_raw_idx]
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
determine_market_regime(spy_indicator_row, ema50, ema200)
|
||||
} else {
|
||||
MarketRegime::Caution // No SPY data for this bar
|
||||
}
|
||||
} else {
|
||||
MarketRegime::Bull // No SPY data at all, don't penalize
|
||||
};
|
||||
self.current_regime = regime;
|
||||
|
||||
// Regime-based sizing factor and threshold adjustment
|
||||
// Use timeframe-specific parameters: hourly needs defensiveness, daily needs aggression
|
||||
let regime_size_factor = match regime {
|
||||
MarketRegime::Bull => 1.0,
|
||||
MarketRegime::Caution => {
|
||||
if self.timeframe == Timeframe::Hourly {
|
||||
HOURLY_REGIME_CAUTION_SIZE_FACTOR
|
||||
} else {
|
||||
REGIME_CAUTION_SIZE_FACTOR
|
||||
}
|
||||
},
|
||||
MarketRegime::Bear => if ALLOW_LONGS_IN_BEAR_MARKET { 1.0 } else { 0.0 },
|
||||
};
|
||||
|
||||
// Log regime changes (only on transitions)
|
||||
if day_num == 0 || (day_num > 0 && regime != self.current_regime) {
|
||||
// Already set above, but log on first bar
|
||||
}
|
||||
if day_num % 100 == 0 {
|
||||
tracing::info!(" Market regime: {} (SPY)", regime.as_str());
|
||||
}
|
||||
|
||||
// Update drawdown circuit breaker
|
||||
self.update_drawdown_state(portfolio_value);
|
||||
|
||||
@@ -597,47 +801,77 @@ impl Backtester {
|
||||
}
|
||||
|
||||
// Phase 2: Process buys (only for top momentum stocks)
|
||||
for symbol in &ranked_symbols {
|
||||
let rows = match data.get(symbol) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
if regime.allows_new_longs() {
|
||||
// In Caution regime, raise the buy threshold to require stronger signals
|
||||
// Use timeframe-specific parameters: hourly needs high bump, daily needs low bump
|
||||
let buy_threshold_bump = match regime {
|
||||
MarketRegime::Caution => {
|
||||
if self.timeframe == Timeframe::Hourly {
|
||||
HOURLY_REGIME_CAUTION_THRESHOLD_BUMP
|
||||
} else {
|
||||
REGIME_CAUTION_THRESHOLD_BUMP
|
||||
}
|
||||
},
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
// Only buy top momentum stocks
|
||||
if !top_momentum_symbols.contains(symbol) {
|
||||
continue;
|
||||
}
|
||||
for symbol in &ranked_symbols {
|
||||
// Don't buy SPY itself — it's used as the regime benchmark
|
||||
if symbol == REGIME_SPY_SYMBOL {
|
||||
continue;
|
||||
}
|
||||
|
||||
let idx = match symbol_date_index
|
||||
.get(symbol)
|
||||
.and_then(|m| m.get(current_date))
|
||||
{
|
||||
Some(&i) => i,
|
||||
None => continue,
|
||||
};
|
||||
let rows = match data.get(symbol) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if idx < 1 {
|
||||
continue;
|
||||
}
|
||||
// Only buy top momentum stocks
|
||||
if !top_momentum_symbols.contains(symbol) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let current_row = &rows[idx];
|
||||
let previous_row = &rows[idx - 1];
|
||||
let idx = match symbol_date_index
|
||||
.get(symbol)
|
||||
.and_then(|m| m.get(current_date))
|
||||
{
|
||||
Some(&i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if current_row.rsi.is_nan() || current_row.macd.is_nan() {
|
||||
continue;
|
||||
}
|
||||
if idx < 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let signal = generate_signal(symbol, current_row, previous_row);
|
||||
let current_row = &rows[idx];
|
||||
let previous_row = &rows[idx - 1];
|
||||
|
||||
// Execute buys
|
||||
if signal.signal.is_buy() {
|
||||
self.execute_buy(
|
||||
symbol,
|
||||
signal.current_price,
|
||||
*current_date,
|
||||
portfolio_value,
|
||||
&signal,
|
||||
);
|
||||
if current_row.rsi.is_nan() || current_row.macd.is_nan() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let signal = generate_signal(symbol, current_row, previous_row);
|
||||
|
||||
// Apply regime threshold bump: in Caution, require stronger conviction
|
||||
let effective_buy = if buy_threshold_bump > 0.0 {
|
||||
// Reverse confidence back to score: confidence = score / 10.0
|
||||
// In Caution, require score >= 4.0 + bump (7.0 → StrongBuy territory).
|
||||
let approx_score = signal.confidence * 10.0;
|
||||
approx_score >= (4.0 + buy_threshold_bump) && signal.signal.is_buy()
|
||||
} else {
|
||||
signal.signal.is_buy()
|
||||
};
|
||||
|
||||
if effective_buy {
|
||||
self.execute_buy(
|
||||
symbol,
|
||||
signal.current_price,
|
||||
*current_date,
|
||||
portfolio_value,
|
||||
&signal,
|
||||
regime_size_factor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,6 +920,432 @@ impl Backtester {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Run the backtest simulation with specific date range.
|
||||
pub async fn run_with_dates(
|
||||
&mut self,
|
||||
client: &AlpacaClient,
|
||||
start_date: NaiveDate,
|
||||
end_date: NaiveDate,
|
||||
) -> Result<BacktestResult> {
|
||||
// Convert dates to DateTime<Utc> for data fetching
|
||||
let start_datetime = start_date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_local_timezone(Utc)
|
||||
.earliest()
|
||||
.unwrap();
|
||||
let end_datetime = end_date
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.unwrap()
|
||||
.and_local_timezone(Utc)
|
||||
.latest()
|
||||
.unwrap();
|
||||
|
||||
// Calculate years for metrics
|
||||
let days_diff = (end_date - start_date).num_days();
|
||||
let years = days_diff as f64 / 365.0;
|
||||
|
||||
let symbols = get_all_symbols();
|
||||
|
||||
// Calculate warmup period
|
||||
let warmup_period = self.strategy.params.min_bars() + 10;
|
||||
let warmup_calendar_days = if self.timeframe == Timeframe::Hourly {
|
||||
(warmup_period as f64 / HOURS_PER_DAY as f64 * 1.5) as i64
|
||||
} else {
|
||||
(warmup_period as f64 * 1.5) as i64
|
||||
};
|
||||
|
||||
tracing::info!("{}", "=".repeat(70));
|
||||
tracing::info!("STARTING BACKTEST");
|
||||
tracing::info!("Initial Capital: ${:.2}", self.initial_capital);
|
||||
tracing::info!(
|
||||
"Period: {} to {} ({:.2} years, {:.1} months)",
|
||||
start_date.format("%Y-%m-%d"),
|
||||
end_date.format("%Y-%m-%d"),
|
||||
years,
|
||||
years * 12.0
|
||||
);
|
||||
tracing::info!("Timeframe: {:?} bars", self.timeframe);
|
||||
tracing::info!(
|
||||
"Risk: ATR stops ({}x), trail ({}x after {}x gain), max {}% pos, {} max pos, {} max/sector, {} bar cooldown",
|
||||
ATR_STOP_MULTIPLIER, ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER,
|
||||
MAX_POSITION_SIZE * 100.0, MAX_CONCURRENT_POSITIONS, MAX_SECTOR_POSITIONS,
|
||||
REENTRY_COOLDOWN_BARS
|
||||
);
|
||||
tracing::info!("Slippage: {} bps per trade", SLIPPAGE_BPS);
|
||||
if self.timeframe == Timeframe::Hourly {
|
||||
tracing::info!(
|
||||
"Parameters scaled {}x (e.g., RSI: {}, EMA_TREND: {})",
|
||||
HOURS_PER_DAY,
|
||||
self.strategy.params.rsi_period,
|
||||
self.strategy.params.ema_trend
|
||||
);
|
||||
}
|
||||
tracing::info!("{}", "=".repeat(70));
|
||||
|
||||
// Fetch historical data with custom date range
|
||||
let raw_data = fetch_backtest_data_with_dates(
|
||||
client,
|
||||
&symbols.iter().map(|s| *s).collect::<Vec<_>>(),
|
||||
start_datetime,
|
||||
end_datetime,
|
||||
self.timeframe,
|
||||
warmup_calendar_days,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if raw_data.is_empty() {
|
||||
anyhow::bail!("No historical data available for backtesting");
|
||||
}
|
||||
|
||||
// Calculate indicators for all symbols
|
||||
let mut data: HashMap<String, Vec<IndicatorRow>> = HashMap::new();
|
||||
for (symbol, bars) in &raw_data {
|
||||
let min_bars = self.strategy.params.min_bars();
|
||||
if bars.len() < min_bars {
|
||||
tracing::warn!(
|
||||
"{}: Only {} bars, need {}. Skipping.",
|
||||
symbol,
|
||||
bars.len(),
|
||||
min_bars
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let indicators = calculate_all_indicators(bars, &self.strategy.params);
|
||||
data.insert(symbol.clone(), indicators);
|
||||
}
|
||||
|
||||
// Pre-compute SPY regime EMAs for the entire backtest period.
|
||||
let spy_key = REGIME_SPY_SYMBOL.to_string();
|
||||
let spy_ema50_series: Vec<f64>;
|
||||
let spy_ema200_series: Vec<f64>;
|
||||
let has_spy_data = raw_data.contains_key(&spy_key);
|
||||
|
||||
if has_spy_data {
|
||||
let spy_closes: Vec<f64> = raw_data[&spy_key].iter().map(|b| b.close).collect();
|
||||
spy_ema50_series = calculate_ema(&spy_closes, REGIME_EMA_SHORT);
|
||||
spy_ema200_series = calculate_ema(&spy_closes, REGIME_EMA_LONG);
|
||||
tracing::info!(
|
||||
"SPY regime filter: EMA-{} / EMA-{} ({} bars of SPY data)",
|
||||
REGIME_EMA_SHORT, REGIME_EMA_LONG, spy_closes.len()
|
||||
);
|
||||
} else {
|
||||
spy_ema50_series = vec![];
|
||||
spy_ema200_series = vec![];
|
||||
tracing::warn!(
|
||||
"SPY data not available — market regime filter DISABLED. \
|
||||
All bars will be treated as BULL regime."
|
||||
);
|
||||
}
|
||||
|
||||
// Get common date range
|
||||
let mut all_dates: BTreeMap<DateTime<Utc>, HashSet<String>> = BTreeMap::new();
|
||||
for (symbol, rows) in &data {
|
||||
for row in rows {
|
||||
all_dates
|
||||
.entry(row.timestamp)
|
||||
.or_insert_with(HashSet::new)
|
||||
.insert(symbol.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let all_dates: Vec<DateTime<Utc>> = all_dates.keys().copied().collect();
|
||||
|
||||
// Filter to only trade on requested period
|
||||
let trading_dates: Vec<DateTime<Utc>> = all_dates
|
||||
.iter()
|
||||
.filter(|&&d| d >= start_datetime && d <= end_datetime)
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
// Ensure we have enough warmup
|
||||
let trading_dates = if !trading_dates.is_empty() {
|
||||
let first_trading_idx = all_dates
|
||||
.iter()
|
||||
.position(|&d| d == trading_dates[0])
|
||||
.unwrap_or(0);
|
||||
if first_trading_idx < warmup_period {
|
||||
trading_dates
|
||||
.into_iter()
|
||||
.skip(warmup_period - first_trading_idx)
|
||||
.collect()
|
||||
} else {
|
||||
trading_dates
|
||||
}
|
||||
} else {
|
||||
trading_dates
|
||||
};
|
||||
|
||||
if trading_dates.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No trading days available after warmup. \n Try a longer backtest period (at least 4 months recommended)."
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"\nSimulating {} trading days (after {}-day warmup)...",
|
||||
trading_dates.len(),
|
||||
warmup_period
|
||||
);
|
||||
|
||||
// From here on, the code is identical to the regular run() method
|
||||
// Build index lookup for each symbol's data
|
||||
let mut symbol_date_index: HashMap<String, HashMap<DateTime<Utc>, usize>> = HashMap::new();
|
||||
for (symbol, rows) in &data {
|
||||
let mut idx_map = HashMap::new();
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
idx_map.insert(row.timestamp, i);
|
||||
}
|
||||
symbol_date_index.insert(symbol.clone(), idx_map);
|
||||
}
|
||||
|
||||
// Build SPY raw bar index
|
||||
let spy_raw_date_index: HashMap<DateTime<Utc>, usize> = if has_spy_data {
|
||||
raw_data[&spy_key]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, bar)| (bar.timestamp, i))
|
||||
.collect()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
// Main simulation loop (identical to run())
|
||||
for (day_num, current_date) in trading_dates.iter().enumerate() {
|
||||
self.current_bar = day_num;
|
||||
self.new_positions_this_bar = 0;
|
||||
self.prune_old_day_trades(current_date.date_naive());
|
||||
|
||||
// Get current prices and momentum for all symbols
|
||||
let mut current_prices: HashMap<String, f64> = HashMap::new();
|
||||
let mut momentum_scores: HashMap<String, f64> = HashMap::new();
|
||||
|
||||
for (symbol, rows) in &data {
|
||||
if let Some(&idx) =
|
||||
symbol_date_index.get(symbol).and_then(|m| m.get(current_date))
|
||||
{
|
||||
let row = &rows[idx];
|
||||
current_prices.insert(symbol.clone(), row.close);
|
||||
if !row.momentum.is_nan() {
|
||||
momentum_scores.insert(symbol.clone(), row.momentum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let portfolio_value = self.get_portfolio_value(¤t_prices);
|
||||
|
||||
// SPY Market Regime Detection
|
||||
let regime = if has_spy_data {
|
||||
if let (Some(&spy_raw_idx), Some(spy_indicator_row)) = (
|
||||
spy_raw_date_index.get(current_date),
|
||||
data.get(&spy_key)
|
||||
.and_then(|rows| {
|
||||
symbol_date_index
|
||||
.get(&spy_key)
|
||||
.and_then(|m| m.get(current_date))
|
||||
.map(|&i| &rows[i])
|
||||
}),
|
||||
) {
|
||||
let ema50 = if spy_raw_idx < spy_ema50_series.len() {
|
||||
spy_ema50_series[spy_raw_idx]
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
let ema200 = if spy_raw_idx < spy_ema200_series.len() {
|
||||
spy_ema200_series[spy_raw_idx]
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
determine_market_regime(spy_indicator_row, ema50, ema200)
|
||||
} else {
|
||||
MarketRegime::Caution
|
||||
}
|
||||
} else {
|
||||
MarketRegime::Bull
|
||||
};
|
||||
self.current_regime = regime;
|
||||
|
||||
// Regime-based sizing factor
|
||||
let regime_size_factor = match regime {
|
||||
MarketRegime::Bull => 1.0,
|
||||
MarketRegime::Caution => REGIME_CAUTION_SIZE_FACTOR,
|
||||
MarketRegime::Bear => if ALLOW_LONGS_IN_BEAR_MARKET { 1.0 } else { 0.0 },
|
||||
};
|
||||
|
||||
if day_num % 100 == 0 {
|
||||
tracing::info!(" Market regime: {} (SPY)", regime.as_str());
|
||||
}
|
||||
|
||||
// Update drawdown circuit breaker
|
||||
self.update_drawdown_state(portfolio_value);
|
||||
|
||||
// Increment bars_held for all positions
|
||||
for pos in self.positions.values_mut() {
|
||||
pos.bars_held += 1;
|
||||
}
|
||||
|
||||
// Momentum ranking
|
||||
let mut ranked_symbols: Vec<String> = momentum_scores.keys().cloned().collect();
|
||||
ranked_symbols.sort_by(|a, b| {
|
||||
let ma = momentum_scores.get(a).unwrap_or(&0.0);
|
||||
let mb = momentum_scores.get(b).unwrap_or(&0.0);
|
||||
mb.partial_cmp(ma).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
let top_momentum_symbols: HashSet<String> = ranked_symbols
|
||||
.iter()
|
||||
.take(TOP_MOMENTUM_COUNT)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Phase 1: Process sells
|
||||
let position_symbols: Vec<String> = self.positions.keys().cloned().collect();
|
||||
for symbol in position_symbols {
|
||||
let rows = match data.get(&symbol) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let idx = match symbol_date_index
|
||||
.get(&symbol)
|
||||
.and_then(|m| m.get(current_date))
|
||||
{
|
||||
Some(&i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if idx < 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let current_row = &rows[idx];
|
||||
let previous_row = &rows[idx - 1];
|
||||
|
||||
if current_row.rsi.is_nan() || current_row.macd.is_nan() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut signal = generate_signal(&symbol, current_row, previous_row);
|
||||
|
||||
// Check stop-loss/take-profit/trailing stop/time exit
|
||||
if let Some(sl_tp) =
|
||||
self.check_stop_loss_take_profit(&symbol, signal.current_price)
|
||||
{
|
||||
signal.signal = sl_tp;
|
||||
}
|
||||
|
||||
let was_stop_loss = matches!(signal.signal, Signal::StrongSell);
|
||||
|
||||
if signal.signal.is_sell() {
|
||||
self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss, portfolio_value);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Process buys
|
||||
if regime.allows_new_longs() {
|
||||
let buy_threshold_bump = match regime {
|
||||
MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP,
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
for symbol in &ranked_symbols {
|
||||
if symbol == REGIME_SPY_SYMBOL {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rows = match data.get(symbol) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if !top_momentum_symbols.contains(symbol) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let idx = match symbol_date_index
|
||||
.get(symbol)
|
||||
.and_then(|m| m.get(current_date))
|
||||
{
|
||||
Some(&i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if idx < 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let current_row = &rows[idx];
|
||||
let previous_row = &rows[idx - 1];
|
||||
|
||||
if current_row.rsi.is_nan() || current_row.macd.is_nan() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let signal = generate_signal(symbol, current_row, previous_row);
|
||||
|
||||
let effective_buy = if buy_threshold_bump > 0.0 {
|
||||
let approx_score = signal.confidence * 10.0;
|
||||
approx_score >= (4.0 + buy_threshold_bump) && signal.signal.is_buy()
|
||||
} else {
|
||||
signal.signal.is_buy()
|
||||
};
|
||||
|
||||
if effective_buy {
|
||||
self.execute_buy(
|
||||
symbol,
|
||||
signal.current_price,
|
||||
*current_date,
|
||||
portfolio_value,
|
||||
&signal,
|
||||
regime_size_factor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record equity
|
||||
self.equity_history.push(EquityPoint {
|
||||
date: *current_date,
|
||||
portfolio_value: self.get_portfolio_value(¤t_prices),
|
||||
cash: self.cash,
|
||||
positions_count: self.positions.len(),
|
||||
});
|
||||
|
||||
// Progress update
|
||||
if (day_num + 1) % 100 == 0 {
|
||||
tracing::info!(
|
||||
" Processed {}/{} days... Portfolio: ${:.2} (positions: {})",
|
||||
day_num + 1,
|
||||
trading_dates.len(),
|
||||
self.equity_history
|
||||
.last()
|
||||
.map(|e| e.portfolio_value)
|
||||
.unwrap_or(0.0),
|
||||
self.positions.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Close all remaining positions at final prices
|
||||
let final_date = trading_dates.last().copied().unwrap_or_else(Utc::now);
|
||||
let position_symbols: Vec<String> = self.positions.keys().cloned().collect();
|
||||
|
||||
for symbol in position_symbols {
|
||||
if let Some(rows) = data.get(&symbol) {
|
||||
if let Some(last_row) = rows.last() {
|
||||
self.execute_sell(&symbol, last_row.close, final_date, false, f64::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate results
|
||||
let result = self.calculate_results(years)?;
|
||||
|
||||
// Print summary
|
||||
self.print_summary(&result);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Calculate performance metrics from backtest.
|
||||
fn calculate_results(&self, years: f64) -> Result<BacktestResult> {
|
||||
if self.equity_history.is_empty() {
|
||||
@@ -898,10 +1558,20 @@ impl Backtester {
|
||||
" Max Per Sector: {:>15}",
|
||||
MAX_SECTOR_POSITIONS
|
||||
);
|
||||
{
|
||||
let (t1p, t1b, t2p, t2b, t3p, t3b) = self.drawdown_tiers();
|
||||
println!(
|
||||
" Drawdown Halt: {:>13.0}%/{:.0}%/{:.0}% ({}/{}/{} bars)",
|
||||
t1p * 100.0, t2p * 100.0, t3p * 100.0, t1b, t2b, t3b,
|
||||
);
|
||||
}
|
||||
println!(
|
||||
" Drawdown Halt: {:>14.0}% ({} bar cooldown)",
|
||||
MAX_DRAWDOWN_HALT * 100.0,
|
||||
DRAWDOWN_HALT_BARS
|
||||
" Market Regime Filter: {:>15}",
|
||||
format!("SPY EMA-{}/EMA-{}", REGIME_EMA_SHORT, REGIME_EMA_LONG)
|
||||
);
|
||||
println!(
|
||||
" Equity Curve Stop: {:>13}-bar SMA",
|
||||
EQUITY_CURVE_SMA_PERIOD
|
||||
);
|
||||
println!(
|
||||
" Time Exit: {:>13} bars",
|
||||
|
||||
206
src/bot.rs
206
src/bot.rs
@@ -10,17 +10,23 @@ use crate::alpaca::AlpacaClient;
|
||||
use crate::config::{
|
||||
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
|
||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, BOT_CHECK_INTERVAL_SECONDS,
|
||||
DRAWDOWN_HALT_BARS, HOURS_PER_DAY, MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT,
|
||||
HOURS_PER_DAY, MAX_CONCURRENT_POSITIONS,
|
||||
MAX_POSITION_SIZE, MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS,
|
||||
REENTRY_COOLDOWN_BARS, TOP_MOMENTUM_COUNT,
|
||||
DRAWDOWN_TIER1_PCT, DRAWDOWN_TIER1_BARS,
|
||||
DRAWDOWN_TIER2_PCT, DRAWDOWN_TIER2_BARS,
|
||||
DRAWDOWN_TIER3_PCT, DRAWDOWN_TIER3_BARS,
|
||||
DRAWDOWN_TIER3_REQUIRE_BULL,
|
||||
REGIME_SPY_SYMBOL, REGIME_EMA_SHORT, REGIME_EMA_LONG,
|
||||
REGIME_CAUTION_SIZE_FACTOR, REGIME_CAUTION_THRESHOLD_BUMP,
|
||||
};
|
||||
use crate::indicators::{calculate_all_indicators, generate_signal};
|
||||
use crate::indicators::{calculate_all_indicators, calculate_ema, determine_market_regime, generate_signal};
|
||||
use crate::paths::{
|
||||
LIVE_DAY_TRADES_FILE, LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE,
|
||||
LIVE_POSITIONS_FILE, LIVE_POSITION_META_FILE,
|
||||
};
|
||||
use crate::strategy::Strategy;
|
||||
use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal};
|
||||
use crate::types::{EquitySnapshot, MarketRegime, PositionInfo, Signal, TradeSignal};
|
||||
|
||||
/// Per-position metadata persisted to disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -46,6 +52,12 @@ pub struct TradingBot {
|
||||
drawdown_halt: bool,
|
||||
/// Cycle count when drawdown halt started (for time-based resume)
|
||||
drawdown_halt_start: Option<usize>,
|
||||
/// The drawdown severity that triggered the current halt (for scaled cooldowns)
|
||||
drawdown_halt_severity: f64,
|
||||
/// Whether the drawdown halt requires bull regime to resume (Tier 3)
|
||||
drawdown_requires_bull: bool,
|
||||
/// Current market regime (from SPY analysis)
|
||||
current_regime: MarketRegime,
|
||||
/// Current trading cycle count
|
||||
trading_cycle_count: usize,
|
||||
/// Tracks when each symbol can be re-entered after stop-loss (cycle index)
|
||||
@@ -76,6 +88,9 @@ impl TradingBot {
|
||||
peak_portfolio_value: 0.0,
|
||||
drawdown_halt: false,
|
||||
drawdown_halt_start: None,
|
||||
drawdown_halt_severity: 0.0,
|
||||
drawdown_requires_bull: false,
|
||||
current_regime: MarketRegime::Bull,
|
||||
trading_cycle_count: 0,
|
||||
cooldown_timers: HashMap::new(),
|
||||
new_positions_this_cycle: 0,
|
||||
@@ -100,6 +115,14 @@ impl TradingBot {
|
||||
Ok(bot)
|
||||
}
|
||||
|
||||
pub fn get_entry_atrs(&self) -> HashMap<String, f64> {
|
||||
self.strategy.entry_atrs.clone()
|
||||
}
|
||||
|
||||
pub fn get_high_water_marks(&self) -> HashMap<String, f64> {
|
||||
self.strategy.high_water_marks.clone()
|
||||
}
|
||||
|
||||
// ── Persistence helpers ──────────────────────────────────────────
|
||||
|
||||
fn load_json_map<V: serde::de::DeserializeOwned>(
|
||||
@@ -349,34 +372,87 @@ impl TradingBot {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Trigger halt if drawdown exceeds threshold
|
||||
if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt {
|
||||
// Scaled drawdown circuit breaker (Tier 1/2/3)
|
||||
if !self.drawdown_halt && drawdown_pct >= DRAWDOWN_TIER1_PCT {
|
||||
let (halt_bars, tier_name) = if drawdown_pct >= DRAWDOWN_TIER3_PCT {
|
||||
self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL;
|
||||
(DRAWDOWN_TIER3_BARS, "TIER 3 (SEVERE)")
|
||||
} else if drawdown_pct >= DRAWDOWN_TIER2_PCT {
|
||||
(DRAWDOWN_TIER2_BARS, "TIER 2")
|
||||
} else {
|
||||
(DRAWDOWN_TIER1_BARS, "TIER 1")
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
"DRAWDOWN CIRCUIT BREAKER: {:.2}% drawdown exceeds {:.0}% limit. Halting for {} cycles.",
|
||||
"DRAWDOWN CIRCUIT BREAKER {}: {:.2}% drawdown. Halting for {} cycles.{}",
|
||||
tier_name,
|
||||
drawdown_pct * 100.0,
|
||||
MAX_DRAWDOWN_HALT * 100.0,
|
||||
DRAWDOWN_HALT_BARS
|
||||
halt_bars,
|
||||
if self.drawdown_requires_bull { " Requires BULL regime to resume." } else { "" }
|
||||
);
|
||||
self.drawdown_halt = true;
|
||||
self.drawdown_halt_start = Some(self.trading_cycle_count);
|
||||
self.drawdown_halt_severity = drawdown_pct;
|
||||
}
|
||||
|
||||
// Upgrade severity if drawdown deepens while already halted
|
||||
if self.drawdown_halt && drawdown_pct > self.drawdown_halt_severity {
|
||||
if drawdown_pct >= DRAWDOWN_TIER3_PCT && self.drawdown_halt_severity < DRAWDOWN_TIER3_PCT {
|
||||
self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL;
|
||||
self.drawdown_halt_start = Some(self.trading_cycle_count);
|
||||
tracing::warn!(
|
||||
"Drawdown deepened to {:.2}% — UPGRADED to TIER 3. Requires BULL regime.",
|
||||
drawdown_pct * 100.0
|
||||
);
|
||||
} else if drawdown_pct >= DRAWDOWN_TIER2_PCT && self.drawdown_halt_severity < DRAWDOWN_TIER2_PCT {
|
||||
self.drawdown_halt_start = Some(self.trading_cycle_count);
|
||||
tracing::warn!(
|
||||
"Drawdown deepened to {:.2}% — upgraded to TIER 2.",
|
||||
drawdown_pct * 100.0
|
||||
);
|
||||
}
|
||||
self.drawdown_halt_severity = drawdown_pct;
|
||||
}
|
||||
|
||||
// Auto-resume after time-based cooldown
|
||||
if self.drawdown_halt {
|
||||
if let Some(halt_start) = self.drawdown_halt_start {
|
||||
if self.trading_cycle_count >= halt_start + DRAWDOWN_HALT_BARS {
|
||||
let required_bars = if self.drawdown_halt_severity >= DRAWDOWN_TIER3_PCT {
|
||||
DRAWDOWN_TIER3_BARS
|
||||
} else if self.drawdown_halt_severity >= DRAWDOWN_TIER2_PCT {
|
||||
DRAWDOWN_TIER2_BARS
|
||||
} else {
|
||||
DRAWDOWN_TIER1_BARS
|
||||
};
|
||||
|
||||
let time_served = self.trading_cycle_count >= halt_start + required_bars;
|
||||
let regime_ok = if self.drawdown_requires_bull {
|
||||
self.current_regime == MarketRegime::Bull
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if time_served && regime_ok {
|
||||
tracing::info!(
|
||||
"Drawdown halt expired after {} cycles. Resuming trading. \
|
||||
"Drawdown halt expired after {} cycles (regime: {}). \
|
||||
Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).",
|
||||
DRAWDOWN_HALT_BARS,
|
||||
required_bars,
|
||||
self.current_regime.as_str(),
|
||||
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.drawdown_halt_severity = 0.0;
|
||||
self.drawdown_requires_bull = false;
|
||||
self.peak_portfolio_value = portfolio_value;
|
||||
} else if time_served && !regime_ok {
|
||||
tracing::info!(
|
||||
"Drawdown halt: time served but waiting for BULL regime (currently {}). DD: {:.2}%",
|
||||
self.current_regime.as_str(),
|
||||
drawdown_pct * 100.0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,7 +575,7 @@ impl TradingBot {
|
||||
|
||||
// ── Order execution ──────────────────────────────────────────────
|
||||
|
||||
async fn execute_buy(&mut self, symbol: &str, signal: &TradeSignal) -> bool {
|
||||
async fn execute_buy(&mut self, symbol: &str, signal: &TradeSignal, regime_size_factor: f64) -> bool {
|
||||
// Check if already holding
|
||||
if let Some(qty) = self.get_position(symbol).await {
|
||||
if qty > 0.0 {
|
||||
@@ -556,7 +632,10 @@ impl TradingBot {
|
||||
return false;
|
||||
}
|
||||
|
||||
let shares = self.calculate_position_size(signal).await;
|
||||
let mut shares = self.calculate_position_size(signal).await;
|
||||
// Apply regime-based size adjustment (e.g., 50% in Caution)
|
||||
shares *= regime_size_factor;
|
||||
shares = (shares * 10000.0).floor() / 10000.0;
|
||||
if shares <= 0.0 {
|
||||
tracing::info!("{}: Insufficient funds for purchase", symbol);
|
||||
return false;
|
||||
@@ -692,6 +771,53 @@ impl TradingBot {
|
||||
// Partial exits removed: they systematically halve winning trade size
|
||||
// while losing trades remain at full size, creating unfavorable avg win/loss ratio.
|
||||
|
||||
// ── Market regime detection ────────────────────────────────────
|
||||
|
||||
/// Fetch SPY data and determine the current market regime.
|
||||
async fn detect_market_regime(&self) -> MarketRegime {
|
||||
let days = (REGIME_EMA_LONG as f64 * 1.5) as i64 + 30;
|
||||
let end = Utc::now();
|
||||
let start = end - Duration::days(days);
|
||||
|
||||
let bars = match self
|
||||
.client
|
||||
.get_historical_bars(REGIME_SPY_SYMBOL, self.timeframe, start, end)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch SPY data for regime detection: {}. Defaulting to Caution.", e);
|
||||
return MarketRegime::Caution;
|
||||
}
|
||||
};
|
||||
|
||||
if bars.len() < REGIME_EMA_LONG {
|
||||
tracing::warn!("Insufficient SPY bars ({}) for regime detection. Defaulting to Caution.", bars.len());
|
||||
return MarketRegime::Caution;
|
||||
}
|
||||
|
||||
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
|
||||
let ema50_series = calculate_ema(&closes, REGIME_EMA_SHORT);
|
||||
let ema200_series = calculate_ema(&closes, REGIME_EMA_LONG);
|
||||
|
||||
let spy_indicators = calculate_all_indicators(&bars, &self.strategy.params);
|
||||
if spy_indicators.is_empty() {
|
||||
return MarketRegime::Caution;
|
||||
}
|
||||
|
||||
let last_row = &spy_indicators[spy_indicators.len() - 1];
|
||||
let ema50 = *ema50_series.last().unwrap_or(&f64::NAN);
|
||||
let ema200 = *ema200_series.last().unwrap_or(&f64::NAN);
|
||||
|
||||
determine_market_regime(last_row, ema50, ema200)
|
||||
}
|
||||
|
||||
// Equity curve SMA stop REMOVED: it creates a pathological feedback loop
|
||||
// where losing positions drag equity below the SMA, blocking new entries,
|
||||
// which prevents recovery. The SPY regime filter and drawdown circuit
|
||||
// breaker handle macro risk without this self-reinforcing trap.
|
||||
// (Matches backtester behavior which also removed this.)
|
||||
|
||||
// ── Analysis ─────────────────────────────────────────────────────
|
||||
|
||||
async fn analyze_symbol(&self, symbol: &str) -> Option<TradeSignal> {
|
||||
@@ -764,6 +890,11 @@ impl TradingBot {
|
||||
);
|
||||
}
|
||||
|
||||
// Detect market regime from SPY
|
||||
self.current_regime = self.detect_market_regime().await;
|
||||
tracing::info!("Market regime: {} (SPY EMA-{}/EMA-{})",
|
||||
self.current_regime.as_str(), REGIME_EMA_SHORT, REGIME_EMA_LONG);
|
||||
|
||||
// Increment bars_held once per trading cycle (matches backtester's per-bar increment)
|
||||
for meta in self.position_meta.values_mut() {
|
||||
meta.bars_held += 1;
|
||||
@@ -843,13 +974,50 @@ impl TradingBot {
|
||||
);
|
||||
|
||||
// Phase 3: Process buys in momentum-ranked order (highest momentum first)
|
||||
for signal in &ranked_signals {
|
||||
if !top_momentum_symbols.contains(&signal.symbol) {
|
||||
continue;
|
||||
// Gate by market regime
|
||||
if !self.current_regime.allows_new_longs() {
|
||||
tracing::info!("BEAR regime — skipping all buys this cycle");
|
||||
} else {
|
||||
let regime_size_factor = match self.current_regime {
|
||||
MarketRegime::Bull => 1.0,
|
||||
MarketRegime::Caution => REGIME_CAUTION_SIZE_FACTOR,
|
||||
MarketRegime::Bear => 0.0, // unreachable due to allows_new_longs check
|
||||
};
|
||||
|
||||
let buy_threshold_bump = match self.current_regime {
|
||||
MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP,
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
if regime_size_factor < 1.0 {
|
||||
tracing::info!(
|
||||
"CAUTION regime — position sizing at {:.0}%, buy threshold +{:.1}",
|
||||
regime_size_factor * 100.0,
|
||||
buy_threshold_bump,
|
||||
);
|
||||
}
|
||||
|
||||
if signal.signal.is_buy() {
|
||||
self.execute_buy(&signal.symbol, signal).await;
|
||||
for signal in &ranked_signals {
|
||||
if !top_momentum_symbols.contains(&signal.symbol) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip SPY itself — it's the regime benchmark
|
||||
if signal.symbol == REGIME_SPY_SYMBOL {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply regime threshold bump in Caution
|
||||
let effective_buy = if buy_threshold_bump > 0.0 {
|
||||
let approx_score = signal.confidence * 10.0;
|
||||
approx_score >= (4.0 + buy_threshold_bump) && signal.signal.is_buy()
|
||||
} else {
|
||||
signal.signal.is_buy()
|
||||
};
|
||||
|
||||
if effective_buy {
|
||||
self.execute_buy(&signal.symbol, signal, regime_size_factor).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
139
src/config.rs
139
src/config.rs
@@ -14,7 +14,7 @@ pub const SP500_ENERGY: &[&str] = &["XOM", "CVX", "COP", "SLB", "OXY", "EOG", "M
|
||||
pub const TELECOM_MEDIA: &[&str] = &["T", "VZ", "CMCSA", "TMUS", "NFLX"];
|
||||
pub const INTERNATIONAL: &[&str] = &["TSM", "BABA", "JD", "SHOP", "MELI"];
|
||||
pub const MATERIALS: &[&str] = &["FCX", "NEM", "LIN", "APD", "SHW"];
|
||||
/// Get all symbols in the trading universe (~100 stocks).
|
||||
/// Get all symbols in the trading universe (~100 stocks + SPY for regime).
|
||||
pub fn get_all_symbols() -> Vec<&'static str> {
|
||||
let mut symbols = Vec::new();
|
||||
symbols.extend_from_slice(MAG7);
|
||||
@@ -31,6 +31,8 @@ pub fn get_all_symbols() -> Vec<&'static str> {
|
||||
symbols.extend_from_slice(TELECOM_MEDIA);
|
||||
symbols.extend_from_slice(INTERNATIONAL);
|
||||
symbols.extend_from_slice(MATERIALS);
|
||||
// SPY is included for market regime detection (never traded directly)
|
||||
symbols.push(REGIME_SPY_SYMBOL);
|
||||
// Deduplicate (NFLX appears in both GROWTH_TECH and TELECOM_MEDIA)
|
||||
symbols.sort();
|
||||
symbols.dedup();
|
||||
@@ -40,10 +42,6 @@ pub fn get_all_symbols() -> Vec<&'static str> {
|
||||
// RSI-14 for trend assessment, RSI-2 for mean-reversion entries (Connors)
|
||||
pub const RSI_PERIOD: usize = 14;
|
||||
pub const RSI_SHORT_PERIOD: usize = 2; // Connors RSI-2 for mean reversion
|
||||
pub const RSI_OVERSOLD: f64 = 30.0;
|
||||
pub const RSI_OVERBOUGHT: f64 = 70.0;
|
||||
pub const RSI2_OVERSOLD: f64 = 10.0; // Extreme oversold for mean reversion entries
|
||||
pub const RSI2_OVERBOUGHT: f64 = 90.0; // Extreme overbought for mean reversion exits
|
||||
pub const MACD_FAST: usize = 12;
|
||||
pub const MACD_SLOW: usize = 26;
|
||||
pub const MACD_SIGNAL: usize = 9;
|
||||
@@ -56,9 +54,7 @@ pub const EMA_TREND: usize = 50;
|
||||
// ADX > TREND_THRESHOLD = trending (use momentum/pullback)
|
||||
// Between = transition zone (reduce size, be cautious)
|
||||
pub const ADX_PERIOD: usize = 14;
|
||||
pub const ADX_RANGE_THRESHOLD: f64 = 20.0; // Below this = range-bound
|
||||
pub const ADX_TREND_THRESHOLD: f64 = 25.0; // Above this = trending
|
||||
pub const ADX_STRONG: f64 = 40.0; // Strong trend for bonus conviction
|
||||
// Bollinger Bands
|
||||
pub const BB_PERIOD: usize = 20;
|
||||
pub const BB_STD: f64 = 2.0;
|
||||
@@ -69,28 +65,105 @@ pub const MIN_ATR_PCT: f64 = 0.005;
|
||||
pub const VOLUME_MA_PERIOD: usize = 20;
|
||||
pub const VOLUME_THRESHOLD: f64 = 0.8;
|
||||
// Momentum Ranking
|
||||
pub const TOP_MOMENTUM_COUNT: usize = 20; // ~20% of universe for cross-sectional momentum
|
||||
pub const TOP_MOMENTUM_COUNT: usize = 15; // Top quintile: enough candidates for 8 positions
|
||||
// Risk Management
|
||||
pub const MAX_POSITION_SIZE: f64 = 0.25; // Slightly larger for concentrated bets
|
||||
pub const MAX_POSITION_SIZE: f64 = 0.20; // 20% max to reduce concentration risk
|
||||
pub const MIN_CASH_RESERVE: f64 = 0.05;
|
||||
pub const STOP_LOSS_PCT: f64 = 0.025;
|
||||
pub const MAX_LOSS_PCT: f64 = 0.05; // Wider max loss — let mean reversion work
|
||||
pub const TRAILING_STOP_ACTIVATION: f64 = 0.06;
|
||||
pub const TRAILING_STOP_DISTANCE: f64 = 0.04;
|
||||
pub const MAX_LOSS_PCT: f64 = 0.08; // Gap protection only — ATR stop handles normal exits
|
||||
pub const TRAILING_STOP_ACTIVATION: f64 = 0.04; // Activate earlier to protect profits
|
||||
pub const TRAILING_STOP_DISTANCE: f64 = 0.05; // Wider trail to let winners run
|
||||
// ATR-based risk management
|
||||
pub const RISK_PER_TRADE: f64 = 0.012; // More aggressive sizing for higher conviction
|
||||
pub const ATR_STOP_MULTIPLIER: f64 = 3.0; // Wider stops — research shows tighter stops hurt
|
||||
pub const ATR_TRAIL_MULTIPLIER: f64 = 2.0; // Wider trail to let winners run
|
||||
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Activate after 2x ATR gain
|
||||
pub const RISK_PER_TRADE: f64 = 0.015; // 1.5% risk per trade (8 positions * 1.5% = 12% worst-case)
|
||||
pub const ATR_STOP_MULTIPLIER: f64 = 3.5; // Wide stops reduce false stop-outs (the #1 loss source)
|
||||
pub const ATR_TRAIL_MULTIPLIER: f64 = 3.0; // Wide trail so winners run longer
|
||||
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Don't activate trail too early
|
||||
// Tiered trailing stop: tight trail for small gains, wide trail for big gains
|
||||
pub const EARLY_TRAIL_ACTIVATION_MULTIPLIER: f64 = 0.5; // Activate tight trail after 0.5x ATR gain
|
||||
pub const EARLY_TRAIL_MULTIPLIER: f64 = 1.5; // Tight trail distance for small gains
|
||||
// Breakeven protection: once in profit, don't let it become a big loss
|
||||
pub const BREAKEVEN_ACTIVATION_PCT: f64 = 0.02; // Activate after 2% gain (meaningful, not noise)
|
||||
pub const BREAKEVEN_MAX_LOSS_PCT: f64 = 0.005; // Once activated, don't give back more than 0.5% from entry
|
||||
// Slow bleeder exit: cut losers that never showed promise
|
||||
pub const SLOW_BLEED_BARS: usize = 20; // Grace period before checking
|
||||
pub const SLOW_BLEED_MAX_LOSS: f64 = 0.02; // If down >2% after grace period and never up >1%, cut
|
||||
pub const SLOW_BLEED_MIN_GAIN: f64 = 0.01; // Must have shown at least 1% gain to survive
|
||||
// Portfolio-level controls
|
||||
pub const MAX_CONCURRENT_POSITIONS: usize = 7; // More positions for diversification
|
||||
pub const MAX_CONCURRENT_POSITIONS: usize = 8; // Fewer positions = higher conviction per trade
|
||||
pub const MAX_SECTOR_POSITIONS: usize = 2;
|
||||
pub const MAX_DRAWDOWN_HALT: f64 = 0.15; // 15% drawdown trigger (markets routinely correct 10-15%)
|
||||
pub const DRAWDOWN_HALT_BARS: usize = 10; // Shorter cooldown: 10 bars to resume after halt
|
||||
// Old single-tier drawdown constants (replaced by tiered system below)
|
||||
// pub const MAX_DRAWDOWN_HALT: f64 = 0.15;
|
||||
// pub const DRAWDOWN_HALT_BARS: usize = 10;
|
||||
// Time-based exit
|
||||
pub const TIME_EXIT_BARS: usize = 40; // Longer patience for mean reversion
|
||||
pub const REENTRY_COOLDOWN_BARS: usize = 5; // Shorter cooldown
|
||||
pub const TIME_EXIT_BARS: usize = 80; // More patience for losers on hourly bars
|
||||
pub const REENTRY_COOLDOWN_BARS: usize = 10; // Longer cooldown to reduce churn
|
||||
pub const RAMPUP_PERIOD_BARS: usize = 15; // Faster ramp-up
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Market Regime Filter (SPY-based)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Uses SPY as a broad market proxy to detect bull/caution/bear regimes.
|
||||
// Based on the dual moving average framework (Faber 2007, "A Quantitative
|
||||
// Approach to Tactical Asset Allocation"): price vs 200-day SMA is the
|
||||
// single most effective regime filter in academic literature.
|
||||
//
|
||||
// Bull: SPY > EMA-200 AND EMA-50 > EMA-200 → trade normally
|
||||
// Caution: SPY < EMA-50 but SPY > EMA-200 → reduce size, raise thresholds
|
||||
// Bear: SPY < EMA-200 AND EMA-50 < EMA-200 → no new buys, manage exits only
|
||||
pub const REGIME_SPY_SYMBOL: &str = "SPY";
|
||||
pub const REGIME_EMA_SHORT: usize = 50; // Fast regime EMA
|
||||
pub const REGIME_EMA_LONG: usize = 200; // Slow regime EMA (the "golden cross" line)
|
||||
/// In Caution regime, multiply position size by this factor (DAILY bars).
|
||||
/// Daily benefits from being more aggressive in Caution (60% size) to capture bull markets.
|
||||
pub const REGIME_CAUTION_SIZE_FACTOR: f64 = 0.6;
|
||||
/// In Caution regime, add this to buy thresholds (DAILY bars).
|
||||
/// Daily needs lower bump (1.0) to participate in bull rallies.
|
||||
pub const REGIME_CAUTION_THRESHOLD_BUMP: f64 = 1.0;
|
||||
|
||||
/// In Caution regime, multiply position size by this factor (HOURLY bars).
|
||||
/// Hourly needs to be very defensive (25% size) due to intraday noise.
|
||||
pub const HOURLY_REGIME_CAUTION_SIZE_FACTOR: f64 = 0.25;
|
||||
/// In Caution regime, add this to buy thresholds (HOURLY bars).
|
||||
/// Hourly needs high bump (3.0) to avoid whipsaws.
|
||||
pub const HOURLY_REGIME_CAUTION_THRESHOLD_BUMP: f64 = 3.0;
|
||||
|
||||
/// If true, the bot is allowed to open new long positions during a Bear market regime.
|
||||
/// This is a master switch for testing/debugging purposes.
|
||||
pub const ALLOW_LONGS_IN_BEAR_MARKET: bool = false;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Scaled Drawdown Circuit Breaker
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// The old fixed 10-bar cooldown is inadequate for real bear markets.
|
||||
// Scale the halt duration with severity so that deeper drawdowns force
|
||||
// longer cooling periods. At 25%+ DD, also require bull regime to resume.
|
||||
// Daily drawdown tiers: relaxed to avoid halting on normal 10-15% bull pullbacks
|
||||
pub const DRAWDOWN_TIER1_PCT: f64 = 0.18; // 18% → 10 bars
|
||||
pub const DRAWDOWN_TIER1_BARS: usize = 10;
|
||||
pub const DRAWDOWN_TIER2_PCT: f64 = 0.25; // 25% → 30 bars
|
||||
pub const DRAWDOWN_TIER2_BARS: usize = 30;
|
||||
pub const DRAWDOWN_TIER3_PCT: f64 = 0.35; // 35%+ → 50 bars + require bull
|
||||
pub const DRAWDOWN_TIER3_BARS: usize = 50;
|
||||
/// If true, after a Tier 3 drawdown, require bull market regime to resume.
|
||||
pub const DRAWDOWN_TIER3_REQUIRE_BULL: bool = true;
|
||||
|
||||
// Hourly drawdown tiers: tighter because hourly has more whipsaw exposure
|
||||
// and the bot needs to cut losses faster to preserve capital in bear periods.
|
||||
pub const HOURLY_DRAWDOWN_TIER1_PCT: f64 = 0.12; // 12% → 15 bars
|
||||
pub const HOURLY_DRAWDOWN_TIER1_BARS: usize = 15;
|
||||
pub const HOURLY_DRAWDOWN_TIER2_PCT: f64 = 0.18; // 18% → 40 bars
|
||||
pub const HOURLY_DRAWDOWN_TIER2_BARS: usize = 40;
|
||||
pub const HOURLY_DRAWDOWN_TIER3_PCT: f64 = 0.25; // 25%+ → 60 bars + require bull
|
||||
pub const HOURLY_DRAWDOWN_TIER3_BARS: usize = 60;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Trailing Equity Curve Stop
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// If the portfolio equity drops below its own N-bar moving average, stop
|
||||
// all new entries. This is a secondary defense independent of the drawdown
|
||||
// breaker. Uses a 200-bar SMA of the equity curve (roughly 200 trading
|
||||
// days for daily, ~29 trading days for hourly).
|
||||
pub const EQUITY_CURVE_SMA_PERIOD: usize = 50; // Shorter window so bot can recover
|
||||
|
||||
// Backtester slippage
|
||||
pub const SLIPPAGE_BPS: f64 = 10.0;
|
||||
// Trading intervals
|
||||
@@ -172,21 +245,25 @@ impl IndicatorParams {
|
||||
}
|
||||
}
|
||||
/// Create parameters for hourly timeframe.
|
||||
///
|
||||
/// Hourly bars need ~7x longer periods than daily to capture the same
|
||||
/// market structure (~7 trading hours/day). Without this, EMA-9 hourly
|
||||
/// = 1.3 days (noise), and the trend/momentum gates whipsaw constantly.
|
||||
pub fn hourly() -> Self {
|
||||
Self {
|
||||
rsi_period: 14,
|
||||
rsi_short_period: 3, // Slightly longer for hourly noise
|
||||
macd_fast: 12,
|
||||
macd_slow: 26,
|
||||
macd_signal: 9,
|
||||
momentum_period: 63,
|
||||
ema_short: 9,
|
||||
ema_long: 21,
|
||||
ema_trend: 200,
|
||||
rsi_short_period: 3,
|
||||
macd_fast: 84, // 12 * 7
|
||||
macd_slow: 182, // 26 * 7
|
||||
macd_signal: 63, // 9 * 7
|
||||
momentum_period: 441, // 63 * 7 = quarterly momentum
|
||||
ema_short: 63, // 9 * 7 ~ daily 9-day EMA
|
||||
ema_long: 147, // 21 * 7 ~ daily 21-day EMA
|
||||
ema_trend: 350, // 50 * 7 ~ daily 50-day EMA
|
||||
adx_period: 14,
|
||||
bb_period: 20,
|
||||
bb_period: 140, // 20 * 7
|
||||
atr_period: 14,
|
||||
volume_ma_period: 20,
|
||||
volume_ma_period: 140, // 20 * 7
|
||||
}
|
||||
}
|
||||
/// Get the minimum number of bars required for indicator calculation.
|
||||
|
||||
103
src/dashboard.rs
103
src/dashboard.rs
@@ -12,13 +12,27 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::alpaca::AlpacaClient;
|
||||
use crate::paths::LIVE_EQUITY_FILE;
|
||||
use crate::types::EquitySnapshot;
|
||||
use crate::{
|
||||
alpaca::AlpacaClient,
|
||||
config::{
|
||||
ATR_STOP_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER,
|
||||
BREAKEVEN_ACTIVATION_PCT, BREAKEVEN_MAX_LOSS_PCT,
|
||||
EARLY_TRAIL_ACTIVATION_MULTIPLIER, EARLY_TRAIL_MULTIPLIER,
|
||||
},
|
||||
paths::{LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE},
|
||||
types::EquitySnapshot,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct DashboardInitData {
|
||||
pub entry_atrs: HashMap<String, f64>,
|
||||
pub high_water_marks: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
/// Shared state for the dashboard.
|
||||
pub struct DashboardState {
|
||||
pub client: AlpacaClient,
|
||||
pub init_data: DashboardInitData,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -48,6 +62,8 @@ struct PositionResponse {
|
||||
unrealized_pnl: f64,
|
||||
pnl_pct: f64,
|
||||
change_today: f64,
|
||||
trail_status: String,
|
||||
stop_loss_price: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -363,6 +379,8 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
||||
<div class="position-detail"><div class="position-detail-label">Current</div><div class="position-detail-value">${formatCurrency(pos.current_price)}</div></div>
|
||||
<div class="position-detail"><div class="position-detail-label">P&L</div><div class="position-detail-value ${pnlClass}">${formatCurrency(pos.unrealized_pnl, true)}</div></div>
|
||||
<div class="position-detail"><div class="position-detail-label">Today</div><div class="position-detail-value ${changeClass}">${changeSign}${pos.change_today.toFixed(2)}%</div></div>
|
||||
<div class="position-detail"><div class="position-detail-label">Trail Status</div><div class="position-detail-value">${pos.trail_status}</div></div>
|
||||
<div class="position-detail"><div class="position-detail-label">Stop Loss</div><div class="position-detail-value">${formatCurrency(pos.stop_loss_price)}</div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
@@ -548,20 +566,63 @@ async fn api_positions(State(state): State<Arc<DashboardState>>) -> impl IntoRes
|
||||
Ok(positions) => {
|
||||
let mut result: Vec<PositionResponse> = positions
|
||||
.iter()
|
||||
.map(|p| PositionResponse {
|
||||
symbol: p.symbol.clone(),
|
||||
qty: p.qty.parse().unwrap_or(0.0),
|
||||
market_value: p.market_value.parse().unwrap_or(0.0),
|
||||
avg_entry_price: p.avg_entry_price.parse().unwrap_or(0.0),
|
||||
current_price: p.current_price.parse().unwrap_or(0.0),
|
||||
unrealized_pnl: p.unrealized_pl.parse().unwrap_or(0.0),
|
||||
pnl_pct: p.unrealized_plpc.parse::<f64>().unwrap_or(0.0) * 100.0,
|
||||
change_today: p
|
||||
.change_today
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0)
|
||||
* 100.0,
|
||||
.map(|p| {
|
||||
let entry_price = p.avg_entry_price.parse().unwrap_or(0.0);
|
||||
let current_price = p.current_price.parse().unwrap_or(0.0);
|
||||
let pnl_pct = if entry_price > 0.0 {
|
||||
(current_price - entry_price) / entry_price
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let entry_atr = state.init_data.entry_atrs.get(&p.symbol).copied().unwrap_or(0.0);
|
||||
let high_water_mark = state.init_data.high_water_marks.get(&p.symbol).copied().unwrap_or(entry_price);
|
||||
|
||||
let activation_gain = if entry_atr > 0.0 {
|
||||
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let best_pnl = (high_water_mark - entry_price) / entry_price;
|
||||
let big_activation = if entry_atr > 0.0 {
|
||||
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||
} else { 0.0 };
|
||||
let small_activation = if entry_atr > 0.0 {
|
||||
(EARLY_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||
} else { 0.0 };
|
||||
|
||||
let (trail_status, stop_loss_price) = if best_pnl >= BREAKEVEN_ACTIVATION_PCT && pnl_pct <= -BREAKEVEN_MAX_LOSS_PCT {
|
||||
("Breakeven!".to_string(), entry_price * (1.0 - BREAKEVEN_MAX_LOSS_PCT))
|
||||
} else if entry_atr > 0.0 && best_pnl >= big_activation {
|
||||
let trail_distance = ATR_TRAIL_MULTIPLIER * entry_atr;
|
||||
let stop_price = high_water_mark - trail_distance;
|
||||
("Wide Trail".to_string(), stop_price)
|
||||
} else if entry_atr > 0.0 && pnl_pct >= small_activation {
|
||||
let trail_distance = EARLY_TRAIL_MULTIPLIER * entry_atr;
|
||||
let stop_price = high_water_mark - trail_distance;
|
||||
("Tight Trail".to_string(), stop_price)
|
||||
} else {
|
||||
("Inactive".to_string(), entry_price - ATR_STOP_MULTIPLIER * entry_atr)
|
||||
};
|
||||
|
||||
PositionResponse {
|
||||
symbol: p.symbol.clone(),
|
||||
qty: p.qty.parse().unwrap_or(0.0),
|
||||
market_value: p.market_value.parse().unwrap_or(0.0),
|
||||
avg_entry_price: entry_price,
|
||||
current_price,
|
||||
unrealized_pnl: p.unrealized_pl.parse().unwrap_or(0.0),
|
||||
pnl_pct: p.unrealized_plpc.parse::<f64>().unwrap_or(0.0) * 100.0,
|
||||
change_today: p
|
||||
.change_today
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0)
|
||||
* 100.0,
|
||||
trail_status,
|
||||
stop_loss_price,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -614,8 +675,12 @@ async fn api_orders(State(state): State<Arc<DashboardState>>) -> impl IntoRespon
|
||||
}
|
||||
|
||||
/// Start the dashboard web server.
|
||||
pub async fn start_dashboard(client: AlpacaClient, port: u16) -> anyhow::Result<()> {
|
||||
let state = Arc::new(DashboardState { client });
|
||||
pub async fn start_dashboard(
|
||||
client: AlpacaClient,
|
||||
port: u16,
|
||||
init_data: DashboardInitData,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = Arc::new(DashboardState { client, init_data });
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Technical indicator calculations.
|
||||
|
||||
use crate::config::{
|
||||
IndicatorParams, ADX_RANGE_THRESHOLD, ADX_STRONG, ADX_TREND_THRESHOLD, BB_STD,
|
||||
RSI2_OVERBOUGHT, RSI2_OVERSOLD, RSI_OVERBOUGHT, RSI_OVERSOLD, VOLUME_THRESHOLD,
|
||||
IndicatorParams, ADX_TREND_THRESHOLD, BB_STD, VOLUME_THRESHOLD,
|
||||
};
|
||||
use crate::types::{Bar, IndicatorRow, Signal, TradeSignal};
|
||||
|
||||
@@ -423,25 +422,78 @@ pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec<I
|
||||
rows
|
||||
}
|
||||
|
||||
/// Generate trading signal using regime-adaptive dual strategy.
|
||||
/// Determine the broad market regime from SPY indicator data.
|
||||
///
|
||||
/// REGIME DETECTION (via ADX):
|
||||
/// - ADX < 20: Range-bound → use Connors RSI-2 mean reversion
|
||||
/// - ADX > 25: Trending → use momentum pullback entries
|
||||
/// - 20-25: Transition → require extra confirmation
|
||||
/// This is the single most important risk filter in the system. During the
|
||||
/// 2020 COVID crash (SPY fell ~34% in 23 trading days) and the 2022 bear
|
||||
/// market (SPY fell ~25% over 9 months), SPY spent the majority of those
|
||||
/// periods below its 200-day EMA with EMA-50 < EMA-200. This filter would
|
||||
/// have prevented most long entries during those drawdowns.
|
||||
///
|
||||
/// MEAN REVERSION (ranging markets):
|
||||
/// - Buy when RSI-2 < 10 AND price above 200 EMA (long-term uptrend filter)
|
||||
/// - Sell when RSI-2 > 90 (take profit at mean)
|
||||
/// - Bollinger Band extremes add conviction
|
||||
/// The three regimes map to position-sizing multipliers:
|
||||
/// - Bull (SPY > EMA-200, EMA-50 > EMA-200): full size, normal thresholds
|
||||
/// - Caution (SPY < EMA-50, SPY > EMA-200): half size, raised thresholds
|
||||
/// - Bear (SPY < EMA-200, EMA-50 < EMA-200): no new longs
|
||||
pub fn determine_market_regime(spy_row: &IndicatorRow, spy_ema50: f64, spy_ema200: f64) -> crate::types::MarketRegime {
|
||||
use crate::types::MarketRegime;
|
||||
|
||||
let price = spy_row.close;
|
||||
|
||||
// All three EMAs must be valid
|
||||
if spy_ema50.is_nan() || spy_ema200.is_nan() || price <= 0.0 {
|
||||
// Default to Caution when we lack data (conservative)
|
||||
return MarketRegime::Caution;
|
||||
}
|
||||
|
||||
// Bear: price below 200 EMA AND 50 EMA below 200 EMA (death cross)
|
||||
if price < spy_ema200 && spy_ema50 < spy_ema200 {
|
||||
return MarketRegime::Bear;
|
||||
}
|
||||
|
||||
// Caution: price below 50 EMA (short-term weakness) but still above 200
|
||||
if price < spy_ema50 {
|
||||
return MarketRegime::Caution;
|
||||
}
|
||||
|
||||
// Bull: price above both, 50 above 200 (golden cross)
|
||||
if spy_ema50 > spy_ema200 {
|
||||
return MarketRegime::Bull;
|
||||
}
|
||||
|
||||
// Edge case: price above both EMAs but 50 still below 200 (recovery)
|
||||
// Treat as Caution — the golden cross hasn't confirmed yet
|
||||
MarketRegime::Caution
|
||||
}
|
||||
|
||||
/// Generate trading signal using hierarchical momentum-with-trend strategy.
|
||||
///
|
||||
/// TREND FOLLOWING (trending markets):
|
||||
/// - Buy pullbacks in uptrends: RSI-14 dips + EMA support + MACD confirming
|
||||
/// - Sell when trend breaks: EMA crossover down + momentum loss
|
||||
/// - Strong trend bonus for high ADX
|
||||
/// This replaces the previous additive "indicator soup" approach. The academic
|
||||
/// evidence for momentum is robust (Jegadeesh & Titman 1993, Moskowitz et al.
|
||||
/// 2012, Asness et al. 2013 "Value and Momentum Everywhere"). Rather than
|
||||
/// netting 8 indicators against each other, we use a hierarchical filter:
|
||||
///
|
||||
/// LAYER 1 (GATE): Trend confirmation
|
||||
/// - Price must be above EMA-trend (Faber 2007 trend filter)
|
||||
/// - EMA-short must be above EMA-long (trend alignment)
|
||||
/// Without both, no buy signal is generated.
|
||||
///
|
||||
/// LAYER 2 (ENTRY): Momentum + pullback timing
|
||||
/// - Positive momentum (ROC > 0): time-series momentum filter
|
||||
/// - RSI-14 pullback (30-50): buy the dip in a confirmed uptrend
|
||||
/// This is the only proven single-stock pattern (Levy 1967, confirmed
|
||||
/// by DeMiguel et al. 2020)
|
||||
///
|
||||
/// LAYER 3 (CONVICTION): Supplementary confirmation
|
||||
/// - MACD histogram positive: momentum accelerating
|
||||
/// - ADX > 25 with DI+ > DI-: strong directional trend
|
||||
/// - Volume above average: institutional participation
|
||||
///
|
||||
/// SELL SIGNALS: Hierarchical exit triggers
|
||||
/// - Trend break: price below EMA-trend = immediate sell
|
||||
/// - Momentum reversal: ROC turns significantly negative
|
||||
/// - EMA death cross: EMA-short crosses below EMA-long
|
||||
pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &IndicatorRow) -> TradeSignal {
|
||||
let rsi = current.rsi;
|
||||
let rsi2 = current.rsi_short;
|
||||
let macd_hist = current.macd_histogram;
|
||||
let momentum = current.momentum;
|
||||
let ema_short = current.ema_short;
|
||||
@@ -451,166 +503,134 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
|
||||
// Safe NaN handling
|
||||
let trend_bullish = current.trend_bullish;
|
||||
let volume_ratio = if current.volume_ratio.is_nan() { 1.0 } else { current.volume_ratio };
|
||||
let adx = if current.adx.is_nan() { 22.0 } else { current.adx };
|
||||
let adx = if current.adx.is_nan() { 20.0 } else { current.adx };
|
||||
let di_plus = if current.di_plus.is_nan() { 25.0 } else { current.di_plus };
|
||||
let di_minus = if current.di_minus.is_nan() { 25.0 } else { current.di_minus };
|
||||
let bb_pct = if current.bb_pct.is_nan() { 0.5 } else { current.bb_pct };
|
||||
let ema_distance = if current.ema_distance.is_nan() { 0.0 } else { current.ema_distance };
|
||||
|
||||
// REGIME DETECTION
|
||||
let is_ranging = adx < ADX_RANGE_THRESHOLD;
|
||||
let is_trending = adx > ADX_TREND_THRESHOLD;
|
||||
let strong_trend = adx > ADX_STRONG;
|
||||
let trend_up = di_plus > di_minus;
|
||||
|
||||
// EMA state
|
||||
let ema_bullish = !ema_short.is_nan() && !ema_long.is_nan() && ema_short > ema_long;
|
||||
let prev_ema_bullish = !previous.ema_short.is_nan()
|
||||
&& !previous.ema_long.is_nan()
|
||||
&& previous.ema_short > previous.ema_long;
|
||||
|
||||
// MACD crossover detection
|
||||
let macd_crossed_up = !previous.macd.is_nan()
|
||||
&& !previous.macd_signal.is_nan()
|
||||
&& !current.macd.is_nan()
|
||||
&& !current.macd_signal.is_nan()
|
||||
&& previous.macd < previous.macd_signal
|
||||
&& current.macd > current.macd_signal;
|
||||
|
||||
let macd_crossed_down = !previous.macd.is_nan()
|
||||
&& !previous.macd_signal.is_nan()
|
||||
&& !current.macd.is_nan()
|
||||
&& !current.macd_signal.is_nan()
|
||||
&& previous.macd > previous.macd_signal
|
||||
&& current.macd < current.macd_signal;
|
||||
let has_momentum = !momentum.is_nan() && momentum > 0.0;
|
||||
|
||||
let mut buy_score: f64 = 0.0;
|
||||
let mut sell_score: f64 = 0.0;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// REGIME 1: MEAN REVERSION (ranging market, ADX < 20)
|
||||
// BUY LOGIC: Hierarchical filter (all gates must pass)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
if is_ranging {
|
||||
// Connors RSI-2 mean reversion: buy extreme oversold in uptrend context
|
||||
if !rsi2.is_nan() {
|
||||
// Buy: RSI-2 extremely oversold + long-term trend intact
|
||||
if rsi2 < RSI2_OVERSOLD {
|
||||
buy_score += 5.0; // Strong mean reversion signal
|
||||
if trend_bullish {
|
||||
buy_score += 3.0; // With-trend mean reversion = highest conviction
|
||||
}
|
||||
if bb_pct < 0.05 {
|
||||
buy_score += 2.0; // Price at/below lower BB
|
||||
}
|
||||
} else if rsi2 < 20.0 {
|
||||
buy_score += 2.5;
|
||||
if trend_bullish {
|
||||
|
||||
// GATE 1: Trend must be confirmed (price > EMA-trend AND EMA alignment)
|
||||
// Without this, no buy signal at all. This is the Faber (2007) filter
|
||||
// that alone produces positive risk-adjusted returns.
|
||||
if trend_bullish && ema_bullish {
|
||||
// GATE 2: Positive time-series momentum (Moskowitz et al. 2012)
|
||||
if has_momentum {
|
||||
// Base score for being in a confirmed uptrend with positive momentum
|
||||
buy_score += 4.0;
|
||||
|
||||
// TIMING: RSI-14 pullback in uptrend (the "buy the dip" pattern)
|
||||
// Widened to 25-55: in strong uptrends RSI often stays 40-65,
|
||||
// so the old 30-50 window missed many good pullback entries.
|
||||
if !rsi.is_nan() && rsi >= 25.0 && rsi <= 55.0 {
|
||||
buy_score += 3.0;
|
||||
}
|
||||
// Moderate pullback (RSI 55-65) still gets some credit
|
||||
else if !rsi.is_nan() && rsi > 55.0 && rsi <= 65.0 {
|
||||
buy_score += 1.0;
|
||||
}
|
||||
// RSI > 70 = overbought, do not add to buy score (chasing)
|
||||
|
||||
// CONVICTION BOOSTERS (each adds incremental edge)
|
||||
|
||||
// Strong directional trend (ADX > 25, DI+ dominant)
|
||||
if adx > ADX_TREND_THRESHOLD && di_plus > di_minus {
|
||||
buy_score += 1.5;
|
||||
}
|
||||
|
||||
// MACD histogram positive and increasing = accelerating momentum
|
||||
if !macd_hist.is_nan() && macd_hist > 0.0 {
|
||||
buy_score += 1.0;
|
||||
// MACD just crossed up = fresh momentum impulse
|
||||
let macd_crossed_up = !previous.macd.is_nan()
|
||||
&& !previous.macd_signal.is_nan()
|
||||
&& !current.macd.is_nan()
|
||||
&& !current.macd_signal.is_nan()
|
||||
&& previous.macd < previous.macd_signal
|
||||
&& current.macd > current.macd_signal;
|
||||
if macd_crossed_up {
|
||||
buy_score += 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Sell: RSI-2 overbought = take profit on mean reversion
|
||||
if rsi2 > RSI2_OVERBOUGHT {
|
||||
sell_score += 4.0;
|
||||
if !trend_bullish {
|
||||
sell_score += 2.0;
|
||||
}
|
||||
} else if rsi2 > 80.0 && !trend_bullish {
|
||||
sell_score += 2.0;
|
||||
// Volume confirmation: above-average volume = institutional interest
|
||||
if volume_ratio >= VOLUME_THRESHOLD {
|
||||
buy_score += 0.5;
|
||||
} else {
|
||||
// Low volume = less reliable, reduce score
|
||||
buy_score *= 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// Bollinger Band extremes in range
|
||||
if bb_pct < 0.0 {
|
||||
buy_score += 2.0; // Below lower band
|
||||
} else if bb_pct > 1.0 {
|
||||
sell_score += 2.0; // Above upper band
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// REGIME 2: TREND FOLLOWING (trending market, ADX > 25)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
if is_trending {
|
||||
// Trend direction confirmation
|
||||
if trend_up && trend_bullish {
|
||||
buy_score += 3.0;
|
||||
// Pullback entry: price dipped but trend intact
|
||||
if !rsi.is_nan() && rsi < 40.0 && rsi > 25.0 {
|
||||
buy_score += 3.0; // Pullback in uptrend
|
||||
}
|
||||
if ema_distance > 0.0 && ema_distance < 0.02 {
|
||||
buy_score += 2.0; // Near EMA support
|
||||
}
|
||||
if strong_trend {
|
||||
buy_score += 1.5; // Strong trend bonus
|
||||
}
|
||||
} else if !trend_up && !trend_bullish {
|
||||
sell_score += 3.0;
|
||||
if !rsi.is_nan() && rsi > 60.0 && rsi < 75.0 {
|
||||
sell_score += 3.0; // Bounce in downtrend
|
||||
}
|
||||
if ema_distance < 0.0 && ema_distance > -0.02 {
|
||||
sell_score += 2.0; // Near EMA resistance
|
||||
}
|
||||
if strong_trend {
|
||||
sell_score += 1.5;
|
||||
// Strong momentum bonus (ROC > 10% = strong trend)
|
||||
if momentum > 10.0 {
|
||||
buy_score += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// UNIVERSAL SIGNALS (both regimes)
|
||||
// SELL LOGIC: Exit when trend breaks or momentum reverses
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// RSI-14 extremes (strong conviction regardless of regime)
|
||||
if !rsi.is_nan() {
|
||||
if rsi < RSI_OVERSOLD && trend_bullish {
|
||||
buy_score += 3.0; // Oversold in uptrend = strong buy
|
||||
} else if rsi > RSI_OVERBOUGHT && !trend_bullish {
|
||||
sell_score += 3.0; // Overbought in downtrend = strong sell
|
||||
}
|
||||
}
|
||||
// CRITICAL SELL: Trend break — price drops below EMA-trend
|
||||
// This is the single most important exit signal. When the long-term
|
||||
// trend breaks, the position has no structural support.
|
||||
if !trend_bullish {
|
||||
sell_score += 4.0;
|
||||
|
||||
// MACD crossover
|
||||
if macd_crossed_up {
|
||||
buy_score += 2.0;
|
||||
if is_trending && trend_up {
|
||||
buy_score += 1.0; // Trend-confirming crossover
|
||||
// If also EMA death cross, very strong sell
|
||||
if !ema_bullish {
|
||||
sell_score += 2.0;
|
||||
}
|
||||
} else if macd_crossed_down {
|
||||
sell_score += 2.0;
|
||||
if is_trending && !trend_up {
|
||||
|
||||
// Momentum confirming the breakdown
|
||||
if !momentum.is_nan() && momentum < -5.0 {
|
||||
sell_score += 2.0;
|
||||
} else if !momentum.is_nan() && momentum < 0.0 {
|
||||
sell_score += 1.0;
|
||||
}
|
||||
}
|
||||
// Trend still intact but showing weakness
|
||||
else {
|
||||
// EMA death cross while still above trend EMA = early warning
|
||||
if !ema_bullish && prev_ema_bullish {
|
||||
sell_score += 3.0;
|
||||
}
|
||||
|
||||
// MACD histogram direction
|
||||
if !macd_hist.is_nan() {
|
||||
if macd_hist > 0.0 { buy_score += 0.5; }
|
||||
else if macd_hist < 0.0 { sell_score += 0.5; }
|
||||
}
|
||||
// Momentum has reversed significantly (still above EMA-trend though)
|
||||
if !momentum.is_nan() && momentum < -10.0 {
|
||||
sell_score += 3.0;
|
||||
} else if !momentum.is_nan() && momentum < -5.0 {
|
||||
sell_score += 1.5;
|
||||
}
|
||||
|
||||
// Momentum
|
||||
if !momentum.is_nan() {
|
||||
if momentum > 5.0 { buy_score += 1.5; }
|
||||
else if momentum > 2.0 { buy_score += 0.5; }
|
||||
else if momentum < -5.0 { sell_score += 1.5; }
|
||||
else if momentum < -2.0 { sell_score += 0.5; }
|
||||
}
|
||||
// MACD crossed down = momentum decelerating
|
||||
let macd_crossed_down = !previous.macd.is_nan()
|
||||
&& !previous.macd_signal.is_nan()
|
||||
&& !current.macd.is_nan()
|
||||
&& !current.macd_signal.is_nan()
|
||||
&& previous.macd > previous.macd_signal
|
||||
&& current.macd < current.macd_signal;
|
||||
if macd_crossed_down {
|
||||
sell_score += 2.0;
|
||||
}
|
||||
|
||||
// EMA crossover events
|
||||
let prev_ema_bullish = !previous.ema_short.is_nan()
|
||||
&& !previous.ema_long.is_nan()
|
||||
&& previous.ema_short > previous.ema_long;
|
||||
|
||||
if ema_bullish && !prev_ema_bullish {
|
||||
buy_score += 2.0;
|
||||
} else if !ema_bullish && prev_ema_bullish {
|
||||
sell_score += 2.0;
|
||||
}
|
||||
|
||||
// Volume gate
|
||||
if volume_ratio < VOLUME_THRESHOLD {
|
||||
buy_score *= 0.5;
|
||||
sell_score *= 0.5;
|
||||
// RSI extremely overbought (>80) in deteriorating momentum
|
||||
if !rsi.is_nan() && rsi > 80.0 && !momentum.is_nan() && momentum < 5.0 {
|
||||
sell_score += 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -630,7 +650,9 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
|
||||
Signal::Hold
|
||||
};
|
||||
|
||||
let confidence = (total_score.abs() / 12.0).min(1.0);
|
||||
// Confidence now reflects the hierarchical gating: a score of 4.0 from
|
||||
// the gated system is worth much more than 4.0 from the old additive system.
|
||||
let confidence = (total_score.abs() / 10.0).min(1.0);
|
||||
|
||||
TradeSignal {
|
||||
symbol: symbol.to_string(),
|
||||
|
||||
66
src/main.rs
66
src/main.rs
@@ -50,7 +50,8 @@ use crate::config::{Timeframe, DEFAULT_INITIAL_CAPITAL};
|
||||
Backtest 6 months: invest-bot --backtest --months 6\n \
|
||||
Backtest 1y 6m: invest-bot --backtest --years 1 --months 6\n \
|
||||
Custom capital: invest-bot --backtest --years 5 --capital 50000\n \
|
||||
Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly"
|
||||
Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly\n \
|
||||
Custom date range: invest-bot --backtest --start-date 2007-01-01 --end-date 2008-12-31"
|
||||
)]
|
||||
struct Args {
|
||||
/// Run in backtest mode instead of live trading
|
||||
@@ -65,6 +66,14 @@ struct Args {
|
||||
#[arg(short, long, default_value_t = 0.0)]
|
||||
months: f64,
|
||||
|
||||
/// Start date for backtest (YYYY-MM-DD). Overrides --years/--months if provided.
|
||||
#[arg(long, value_name = "YYYY-MM-DD")]
|
||||
start_date: Option<String>,
|
||||
|
||||
/// End date for backtest (YYYY-MM-DD). Defaults to now if not provided.
|
||||
#[arg(long, value_name = "YYYY-MM-DD")]
|
||||
end_date: Option<String>,
|
||||
|
||||
/// Initial capital for backtesting
|
||||
#[arg(short, long, default_value_t = DEFAULT_INITIAL_CAPITAL)]
|
||||
capital: f64,
|
||||
@@ -171,14 +180,45 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
async fn run_backtest(api_key: String, api_secret: String, args: Args) -> Result<()> {
|
||||
// Combine years and months (default to 1 year if neither specified)
|
||||
let total_years = args.years + (args.months / 12.0);
|
||||
let total_years = if total_years <= 0.0 { 1.0 } else { total_years };
|
||||
use chrono::NaiveDate;
|
||||
|
||||
let client = AlpacaClient::new(api_key, api_secret)?;
|
||||
let mut backtester = Backtester::new(args.capital, args.timeframe);
|
||||
|
||||
let result = backtester.run(&client, total_years).await?;
|
||||
let result = if args.start_date.is_some() || args.end_date.is_some() {
|
||||
// Custom date range mode
|
||||
let start_date = if let Some(ref s) = args.start_date {
|
||||
NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.context("Invalid start date format. Use YYYY-MM-DD (e.g., 2007-01-01)")?
|
||||
} else {
|
||||
// If no start date provided, default to 1 year before end date
|
||||
let end = if let Some(ref e) = args.end_date {
|
||||
NaiveDate::parse_from_str(e, "%Y-%m-%d")?
|
||||
} else {
|
||||
chrono::Utc::now().date_naive()
|
||||
};
|
||||
end - chrono::Duration::days(365)
|
||||
};
|
||||
|
||||
let end_date = if let Some(ref e) = args.end_date {
|
||||
NaiveDate::parse_from_str(e, "%Y-%m-%d")
|
||||
.context("Invalid end date format. Use YYYY-MM-DD (e.g., 2008-12-31)")?
|
||||
} else {
|
||||
chrono::Utc::now().date_naive()
|
||||
};
|
||||
|
||||
// Validate date range
|
||||
if start_date >= end_date {
|
||||
anyhow::bail!("Start date must be before end date");
|
||||
}
|
||||
|
||||
backtester.run_with_dates(&client, start_date, end_date).await?
|
||||
} else {
|
||||
// Years/months mode (existing behavior)
|
||||
let total_years = args.years + (args.months / 12.0);
|
||||
let total_years = if total_years <= 0.0 { 1.0 } else { total_years };
|
||||
backtester.run(&client, total_years).await?
|
||||
};
|
||||
|
||||
// Save results to CSV
|
||||
save_backtest_results(&result)?;
|
||||
@@ -192,17 +232,27 @@ async fn run_live_trading(api_key: String, api_secret: String, args: Args) -> Re
|
||||
.parse()
|
||||
.unwrap_or(5000);
|
||||
|
||||
// Create the bot first to load its state
|
||||
let mut bot = TradingBot::new(api_key.clone(), api_secret.clone(), args.timeframe).await?;
|
||||
|
||||
// Create a separate client for the dashboard
|
||||
let dashboard_client = AlpacaClient::new(api_key.clone(), api_secret.clone())?;
|
||||
|
||||
// Extract data for the dashboard
|
||||
let init_data = dashboard::DashboardInitData {
|
||||
entry_atrs: bot.get_entry_atrs(),
|
||||
high_water_marks: bot.get_high_water_marks(),
|
||||
};
|
||||
|
||||
// Spawn dashboard in background
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = dashboard::start_dashboard(dashboard_client, dashboard_port).await {
|
||||
if let Err(e) =
|
||||
dashboard::start_dashboard(dashboard_client, dashboard_port, init_data).await
|
||||
{
|
||||
tracing::error!("Dashboard error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Run the trading bot
|
||||
let mut bot = TradingBot::new(api_key, api_secret, args.timeframe).await?;
|
||||
// Now run the bot's main loop
|
||||
bot.run().await
|
||||
}
|
||||
|
||||
@@ -64,4 +64,12 @@ lazy_static! {
|
||||
path.push("trading_bot.log");
|
||||
path
|
||||
};
|
||||
|
||||
/// Base directory for backtest data cache.
|
||||
pub static ref BACKTEST_CACHE_DIR: PathBuf = {
|
||||
let mut path = DATA_DIR.clone();
|
||||
path.push("cache");
|
||||
std::fs::create_dir_all(&path).expect("Failed to create cache directory");
|
||||
path
|
||||
};
|
||||
}
|
||||
|
||||
106
src/strategy.rs
106
src/strategy.rs
@@ -2,8 +2,13 @@
|
||||
use std::collections::HashMap;
|
||||
use crate::config::{
|
||||
get_sector, IndicatorParams, Timeframe, ATR_STOP_MULTIPLIER,
|
||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, MAX_LOSS_PCT, MAX_POSITION_SIZE,
|
||||
MIN_ATR_PCT, RISK_PER_TRADE, STOP_LOSS_PCT, TIME_EXIT_BARS,
|
||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER,
|
||||
BREAKEVEN_ACTIVATION_PCT, BREAKEVEN_MAX_LOSS_PCT,
|
||||
EARLY_TRAIL_ACTIVATION_MULTIPLIER, EARLY_TRAIL_MULTIPLIER,
|
||||
MAX_LOSS_PCT, MAX_POSITION_SIZE,
|
||||
MIN_ATR_PCT, RISK_PER_TRADE,
|
||||
SLOW_BLEED_BARS, SLOW_BLEED_MAX_LOSS, SLOW_BLEED_MIN_GAIN,
|
||||
STOP_LOSS_PCT, TIME_EXIT_BARS,
|
||||
TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
|
||||
};
|
||||
use crate::types::{Signal, TradeSignal};
|
||||
@@ -26,7 +31,13 @@ impl Strategy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Volatility-adjusted position sizing using ATR.
|
||||
/// Volatility-adjusted position sizing using ATR (Kelly-inspired).
|
||||
///
|
||||
/// Position size = (Risk per trade / ATR stop distance) * confidence.
|
||||
/// The confidence scaling now has a much wider range (0.4 to 1.0) so that
|
||||
/// weak Buy signals (confidence ~0.4) get 40% size while StrongBuy signals
|
||||
/// (confidence ~1.0) get full size. This is a fractional Kelly approach:
|
||||
/// bet more when conviction is higher, less when marginal.
|
||||
pub fn calculate_position_size(
|
||||
&self,
|
||||
price: f64,
|
||||
@@ -42,8 +53,9 @@ impl Strategy {
|
||||
let atr_stop_pct = signal.atr_pct * ATR_STOP_MULTIPLIER;
|
||||
let risk_amount = portfolio_value * RISK_PER_TRADE;
|
||||
let vol_adjusted = risk_amount / atr_stop_pct;
|
||||
// Scale by confidence
|
||||
let confidence_scale = 0.7 + 0.3 * signal.confidence;
|
||||
// Wide confidence scaling: 0.4x for weak signals, 1.0x for strongest.
|
||||
// Old code used 0.7 + 0.3*conf which barely differentiated.
|
||||
let confidence_scale = 0.4 + 0.6 * signal.confidence;
|
||||
let sized = vol_adjusted * confidence_scale;
|
||||
sized.min(portfolio_value * MAX_POSITION_SIZE)
|
||||
} else {
|
||||
@@ -51,12 +63,22 @@ impl Strategy {
|
||||
};
|
||||
|
||||
let position_value = position_value.min(available_cash);
|
||||
// Use fractional shares — Alpaca supports them for paper trading.
|
||||
// Use fractional shares -- Alpaca supports them for paper trading.
|
||||
// Truncate to 4 decimal places to avoid floating point dust.
|
||||
((position_value / price) * 10000.0).floor() / 10000.0
|
||||
}
|
||||
|
||||
/// Check if stop-loss, trailing stop, or time exit should trigger.
|
||||
///
|
||||
/// Exit priority (checked in order):
|
||||
/// 1. Hard max-loss cap (MAX_LOSS_PCT) -- gap protection
|
||||
/// 2. ATR-based stop-loss -- primary risk control
|
||||
/// 3. Fixed % stop-loss -- fallback when ATR unavailable
|
||||
/// 4. Breakeven ratchet -- once in profit, never lose more than 1%
|
||||
/// 5. Tiered trailing stop:
|
||||
/// - Small gains (0.5x ATR): tight trail (1.5x ATR)
|
||||
/// - Big gains (2.0x ATR): wide trail (3.0x ATR)
|
||||
/// 6. Time-based exit -- only if position is LOSING
|
||||
pub fn check_stop_loss_take_profit(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
@@ -78,47 +100,55 @@ impl Strategy {
|
||||
}
|
||||
}
|
||||
|
||||
// Hard max-loss cap
|
||||
// 1. Hard max-loss cap (catastrophic gap protection)
|
||||
if pnl_pct <= -MAX_LOSS_PCT {
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
|
||||
// ATR-based stop loss
|
||||
// 2. ATR-based initial stop-loss (primary risk control)
|
||||
if entry_atr > 0.0 {
|
||||
let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr;
|
||||
if current_price <= atr_stop_price {
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
} else if pnl_pct <= -STOP_LOSS_PCT {
|
||||
// 3. Fixed percentage fallback
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
|
||||
// Time-based exit
|
||||
if bars_held >= TIME_EXIT_BARS {
|
||||
let activation = if entry_atr > 0.0 {
|
||||
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||
} else {
|
||||
TRAILING_STOP_ACTIVATION
|
||||
};
|
||||
if pnl_pct < activation {
|
||||
return Some(Signal::Sell);
|
||||
// 4. Breakeven ratchet: once we've been in profit, cap downside to -1%
|
||||
if pnl_pct <= -BREAKEVEN_MAX_LOSS_PCT {
|
||||
if let Some(&high_water) = self.high_water_marks.get(symbol) {
|
||||
let best_pnl = (high_water - entry_price) / entry_price;
|
||||
if best_pnl >= BREAKEVEN_ACTIVATION_PCT {
|
||||
// Was in profit but now losing > 1% — get out
|
||||
return Some(Signal::Sell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ATR-based trailing stop
|
||||
let activation_gain = if entry_atr > 0.0 {
|
||||
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||
} else {
|
||||
TRAILING_STOP_ACTIVATION
|
||||
};
|
||||
// 5. Tiered ATR trailing stop (profit protection)
|
||||
// Tier 1: small gains (0.5x ATR) → tight trail (1.5x ATR)
|
||||
// Tier 2: big gains (2.0x ATR) → wide trail (3.0x ATR) to let winners run
|
||||
if let Some(&high_water) = self.high_water_marks.get(symbol) {
|
||||
let best_pnl = (high_water - entry_price) / entry_price;
|
||||
|
||||
if pnl_pct >= activation_gain {
|
||||
if let Some(&high_water) = self.high_water_marks.get(symbol) {
|
||||
let trail_distance = if entry_atr > 0.0 {
|
||||
ATR_TRAIL_MULTIPLIER * entry_atr
|
||||
let (activation_gain, trail_distance) = if entry_atr > 0.0 {
|
||||
let big_activation = (ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price;
|
||||
let small_activation = (EARLY_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price;
|
||||
|
||||
if best_pnl >= big_activation {
|
||||
// Tier 2: big winner — wide trail
|
||||
(big_activation, ATR_TRAIL_MULTIPLIER * entry_atr)
|
||||
} else {
|
||||
high_water * TRAILING_STOP_DISTANCE
|
||||
};
|
||||
// Tier 1: small gain — tight trail
|
||||
(small_activation, EARLY_TRAIL_MULTIPLIER * entry_atr)
|
||||
}
|
||||
} else {
|
||||
(TRAILING_STOP_ACTIVATION, high_water * TRAILING_STOP_DISTANCE)
|
||||
};
|
||||
|
||||
if pnl_pct >= activation_gain {
|
||||
let trailing_stop_price = high_water - trail_distance;
|
||||
if current_price <= trailing_stop_price {
|
||||
return Some(Signal::Sell);
|
||||
@@ -126,6 +156,24 @@ impl Strategy {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Slow bleeder exit: cut losers that never showed promise
|
||||
// After grace period, if down >2% and never showed >1% gain, it's dead money
|
||||
if bars_held >= SLOW_BLEED_BARS && pnl_pct <= -SLOW_BLEED_MAX_LOSS {
|
||||
let best_pnl = self.high_water_marks
|
||||
.get(symbol)
|
||||
.map(|&hwm| (hwm - entry_price) / entry_price)
|
||||
.unwrap_or(0.0);
|
||||
if best_pnl < SLOW_BLEED_MIN_GAIN {
|
||||
return Some(Signal::Sell);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Time-based exit: only for LOSING positions (capital efficiency)
|
||||
// Winners at the time limit are managed by the trailing stop.
|
||||
if bars_held >= TIME_EXIT_BARS && pnl_pct < 0.0 {
|
||||
return Some(Signal::Sell);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
34
src/types.rs
34
src/types.rs
@@ -1,8 +1,40 @@
|
||||
//! Data types and structures for the trading bot.
|
||||
|
||||
use crate::config::ALLOW_LONGS_IN_BEAR_MARKET;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Broad market regime determined from SPY price action.
|
||||
///
|
||||
/// Based on Faber (2007) dual moving average framework:
|
||||
/// - Bull: SPY above EMA-200 and EMA-50 above EMA-200 (golden cross territory)
|
||||
/// - Caution: SPY below EMA-50 but still above EMA-200 (early weakness)
|
||||
/// - Bear: SPY below EMA-200 and EMA-50 below EMA-200 (death cross territory)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MarketRegime {
|
||||
Bull,
|
||||
Caution,
|
||||
Bear,
|
||||
}
|
||||
|
||||
impl MarketRegime {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
MarketRegime::Bull => "BULL",
|
||||
MarketRegime::Caution => "CAUTION",
|
||||
MarketRegime::Bear => "BEAR",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether new long entries are permitted in this regime.
|
||||
pub fn allows_new_longs(&self) -> bool {
|
||||
match self {
|
||||
MarketRegime::Bear => ALLOW_LONGS_IN_BEAR_MARKET,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trading signal types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -110,7 +142,7 @@ pub struct EquityPoint {
|
||||
}
|
||||
|
||||
/// OHLCV bar data.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Bar {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub open: f64,
|
||||
|
||||
Reference in New Issue
Block a user