Compare commits

..

13 Commits

Author SHA1 Message Date
eda716edad even more profits 2026-02-26 17:15:30 +00:00
84461319a0 more profit 2026-02-26 17:05:57 +00:00
4476c04512 atr tracking 2026-02-25 20:04:58 +00:00
62847846d0 gg 2026-02-13 22:00:24 +00:00
zastian-dev
0e820852fa new best 2026-02-13 20:04:32 +00:00
zastian-dev
79816b9e2e Experiment with hourly timeframe-specific stops
- Added HOURLY_ATR_STOP_MULTIPLIER (1.8x) vs daily (3.5x)
- Added hourly-specific trail multipliers
- Strategy now uses timeframe field to select appropriate stops
- Tested multiple configurations on hourly:
  * 3.5x stops: -0.5% return, 45% max DD
  * 1.8x stops: -45% return, 53% max DD (worse)
  * Conservative regime (0.25x): -65% return, 67% max DD (terrible)
- Conclusion: Hourly doesn't work with this strategy
- Daily with relaxed regime remains best: +17.4% over 5yr, 24% max DD

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 19:20:01 +00:00
zastian-dev
edc655ca2c the holy grail. untill next time 2026-02-13 17:53:11 +00:00
zastian-dev
73cc7a3a66 just a checkpoint 2026-02-13 16:28:42 +00:00
zastian-dev
798c3eafd5 it might be better 2026-02-13 13:43:42 +00:00
zastian-dev
1ef03999b7 it be better 2026-02-13 13:12:22 +00:00
zastian-dev
80a8e7c346 PDT protection 2026-02-12 18:14:53 +00:00
zastian-dev
223051f9d8 hope it stopes rate limits 2026-02-12 16:17:03 +00:00
zastian-dev
9fb9d171d4 more symbols 2026-02-12 12:36:25 +00:00
17 changed files with 2288 additions and 509 deletions

View File

@@ -1,157 +1,195 @@
# Consistency Auditor Memory # Consistency Auditor Memory
## Last Audit: 2026-02-12 (Regime-Adaptive Dual Strategy Update) ## Last Audit: 2026-02-13 (Post-Config Update v2 - NEW FINDINGS)
### AUDIT RESULT: ✅ NO CRITICAL BUGS FOUND ### AUDIT RESULT: ⚠️ 1 CRITICAL DIVERGENCE + 1 MEDIUM BUG
The refactor to extract shared logic into `strategy.rs` has **eliminated all previous consistency issues**. Bot and backtester now share identical implementations for all critical trading logic. **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. 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`
--- ---
## VERIFIED CONSISTENT (2026-02-12) ## Config Changes Since Last Audit (2026-02-13 v2)
User reported these config changes:
- 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 config constants consistent between bot.rs and backtester.rs ✅
---
## VERIFIED CONSISTENT (2026-02-13 Audit v2) ✅
### Core Trading Logic ✅ ### Core Trading Logic ✅
- **Signal generation**: Both use shared `indicators::generate_signal()` (indicators.rs:442-650) - **Signal generation**: Both use shared `indicators::generate_signal()` (bot:876; bt:758,818)
- **Position sizing**: Both use shared `Strategy::calculate_position_size()` (strategy.rs:29-55) - **Position sizing**: Both use shared `Strategy::calculate_position_size()` (bot:537-542; bt:282-284)
- Volatility-adjusted via ATR - Volatility-adjusted via ATR
- Confidence scaling: 0.7 + 0.3 * confidence - Confidence scaling: 0.4 + 0.6 * confidence (changed from 0.7 + 0.3)
- Max position size cap: 25% - Max position size cap: 20% (was 25%)
- Cash reserve: 5% - Cash reserve: 5%
- **Stop-loss/trailing/time exit**: Both use shared `Strategy::check_stop_loss_take_profit()` (strategy.rs:57-128) - **Stop-loss/trailing/time exit**: Both use shared `Strategy::check_stop_loss_take_profit()` (bot:547-559; bt:462-468)
- Hard max loss cap: 5% - Hard max loss cap: 8% (was 5%)
- ATR-based stop: 3.0x ATR below entry - ATR-based stop: 3.5x ATR below entry (was 3.0x)
- Fixed fallback stop: 2.5% - Fixed fallback stop: 2.5%
- Trailing stop: 2.0x ATR after 2.0x ATR gain - Trailing stop: 3.0x ATR after 2.0x ATR gain (was 2.0x trail, 2.0x activation)
- Time exit: 40 bars if below trailing activation threshold - Time exit: 80 bars if below trailing activation (was 40)
### Portfolio Controls ✅ ### Portfolio Controls ✅
- **Cooldown timers**: Both implement 5-bar cooldown after stop-loss (bot:395-406,521-533; bt:133-138,242-247) - **Cooldown timers**: Both implement 10-bar cooldown after stop-loss (bot:736-746; bt:383-388) [was 5]
- **Ramp-up period**: Both limit to 1 new position per bar for first 15 bars (bot:433-441; bt:158-161) - **Ramp-up period**: Both limit to 1 new position per cycle/bar for first 15 bars (bot:618-626; bt:277-279)
- **Drawdown circuit breaker**: Both halt for 20 bars at 12% drawdown (bot:244-268; bt:83-118) - **Drawdown circuit breaker**: Both trigger at 12%/18%/25% with 15/40/60-bar cooldowns (bot:368-408; bt:106-163)
- **Sector limits**: Both enforce max 2 per sector (bot:423-430; bt:149-156) - **Peak reset on expiry**: Both reset peak to current value (bot:442; bt:197) ✅ (FIXED since last audit)
- **Max concurrent positions**: Both enforce max 7 (bot:414-421; bt:145-147) - Tier 3 (25%+) requires bull regime to resume (was 15%/20%/25% → 10/30/50)
- **Momentum ranking**: Both filter to top 10 momentum stocks (bot:669-690; bt:438-449) - **Sector limits**: Both enforce max 2 per sector (bot:608-614; bt:268-274)
- **bars_held increment**: Both increment at START of trading cycle/bar (bot:614-617; bt:433-436) - **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.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
- `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`: 15 (was 20)
- `TIME_EXIT_BARS`: 80 (was 40)
- `MIN_CASH_RESERVE`: 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 ✅ ### Warmup Requirements ✅
**Daily mode**: `max(35 MACD, 15 RSI, 50 EMA, 28 ADX, 20 BB, 63 momentum) + 5 = 68 bars` **Hourly mode**: `max(245 MACD, 15 RSI, 350 EMA, 28 ADX, 140 BB, 441 momentum) + 5 = 446 bars`
**Hourly mode**: `max(35 MACD, 15 RSI, 200 EMA, 28 ADX, 20 BB, 63 momentum) + 5 = 205 bars`
Calculation in `config.rs:169-183` (`IndicatorParams::min_bars()`) Calculation in `config.rs:239-252` (`IndicatorParams::min_bars()`)
- RSI-2/3 warmup covered by RSI-14 requirement (15 > 3) - Momentum period dominates warmup (441 bars = 63 * 7)
- MACD needs slow + signal periods (26 + 9 = 35) - MACD needs slow + signal (182 + 63 = 245)
- EMA trend: 350 (50 * 7)
- ADX needs 2x period for smoothing (14 * 2 = 28) - 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 947-1035
- backtester.rs lines 731-844
--- ---
## INTENTIONAL DIFFERENCES (Not Bugs) ✅ ## INTENTIONAL DIFFERENCES (Not Bugs) ✅
### 1. Slippage Modeling ### 1. Slippage Modeling
- **Backtester**: Applies 10 bps on both entry and exit (backtester.rs:63-71) - **Backtester**: Applies 10 bps on both entry and exit (backtester.rs:86-93)
- **Live bot**: Uses actual fill prices from Alpaca API (bot.rs:456-460) - **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. - **Verdict**: Expected difference. Backtester simulates realistic costs; live bot gets market fills.
### 2. RSI Short Period Scaling ### 2. PDT Protection Strategy
- **Daily mode**: `rsi_short_period: 2` (Connors RSI-2 for mean reversion) - **bot.rs**: Blocks non-stop-loss sells if would trigger PDT (lines 693-705)
- **Hourly mode**: `rsi_short_period: 3` (adjusted for intraday noise) - **backtester.rs**: Blocks entries in last 2 hours of hourly day (lines 238-244)
- **Verdict**: Intentional design choice per comment "Slightly longer for hourly noise" - **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.
### 3. EMA Trend Period Scaling
- **Daily mode**: `ema_trend: 50` (50-day trend filter)
- **Hourly mode**: `ema_trend: 200` (200-hour ≈ 28.5-day trend filter)
- **Verdict**: Hourly uses 4x scaling (not 7x like other indicators) for longer-term trend context. Appears intentional.
--- ---
## STRATEGY ARCHITECTURE (2026-02-12) ## STRATEGY ARCHITECTURE (2026-02-13)
### Regime-Adaptive Dual Signal ### Regime-Adaptive with Bear Market Protection
The new strategy uses **ADX for regime detection** and switches between two modes: The strategy uses **ADX for regime detection** and **SPY for market filter**:
#### RANGE-BOUND (ADX < 20): Mean Reversion #### SPY MARKET REGIME FILTER (Primary Risk Gate)
- **Entry**: Connors RSI-2 extreme oversold (RSI-2 < 10) + price above 200 EMA - **Bull** (SPY > EMA-200, EMA-50 > EMA-200): full size, normal thresholds
- **Exit**: RSI-2 extreme overbought (RSI-2 > 90) or standard exits - **Caution** (SPY < EMA-50, SPY > EMA-200): 25% size, +3.0 threshold bump
- **Conviction boosters**: Bollinger Band extremes, volume confirmation - **Bear** (SPY < EMA-200, EMA-50 < EMA-200): NO new longs at all
- **Logic**: indicators.rs:490-526
#### TRENDING (ADX > 25): Momentum Pullback #### ADX REGIME (Signal Type Selection)
- **Entry**: Pullbacks in strong trends (RSI-14 dips 25-40, price near EMA support, MACD confirming) - **ADX < 20**: Range-bound → mean reversion signals preferred
- **Exit**: Trend break (EMA crossover down) or standard exits - **ADX > 25**: Trending → momentum pullback signals preferred
- **Conviction boosters**: Strong trend (ADX > 40), DI+/DI- alignment
- **Logic**: indicators.rs:531-557
#### UNIVERSAL SIGNALS (Both Regimes) ### Hierarchical Signal Generation (indicators.rs)
- RSI-14 extremes in trending context (indicators.rs:564-570) **NOT additive "indicator soup"** — uses gated filters:
- MACD crossovers (indicators.rs:573-583)
- EMA crossovers (indicators.rs:599-608) **LAYER 1 (GATE)**: Trend confirmation required
- Volume gate (reduces scores 50% if volume < 80% of 20-period MA) (indicators.rs:611-614) - 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 ### Signal Thresholds
- **StrongBuy**: total_score >= 7.0 - **StrongBuy**: total_score >= 7.0
- **Buy**: total_score >= 4.5 - **Buy**: total_score >= 4.0
- **StrongSell**: total_score <= -7.0 - **StrongSell**: total_score <= -7.0
- **Sell**: total_score <= -4.0 - **Sell**: total_score <= -4.0
- **Hold**: everything else - **Hold**: everything else
Confidence: `(total_score.abs() / 12.0).min(1.0)` Confidence: `(total_score.abs() / 10.0).min(1.0)` (changed from /12.0)
---
## CONFIG PARAMETERS (2026-02-12)
### Indicator Periods
- RSI: 14 (standard), RSI-2 (daily) / RSI-3 (hourly) for mean reversion
- MACD: 12/26/9 (standard)
- Momentum: 63 bars
- EMA: 9/21/50 (daily), 9/21/200 (hourly)
- ADX: 14, thresholds: 20 (range), 25 (trend), 40 (strong)
- Bollinger Bands: 20-period, 2 std dev
- ATR: 14-period
- Volume MA: 20-period, threshold: 0.8x
### Risk Management
- **Position sizing**: 1.2% risk per trade (RISK_PER_TRADE)
- **ATR stop**: 3.0x ATR below entry (was 2.5x)
- **ATR trailing stop**: 2.0x ATR distance, activates after 2.0x ATR gain (was 1.5x/1.5x)
- **Max position size**: 25% (was 22%)
- **Max loss cap**: 5% (was 4%)
- **Stop loss fallback**: 2.5% (when ATR unavailable)
- **Time exit**: 40 bars (was 30)
- **Cash reserve**: 5%
### Portfolio Limits
- **Max concurrent positions**: 7 (was 5)
- **Max per sector**: 2 (unchanged)
- **Momentum ranking**: Top 10 stocks (was 4)
- **Drawdown halt**: 12% triggers 20-bar cooldown (was 35 bars)
- **Reentry cooldown**: 5 bars after stop-loss (was 7)
- **Ramp-up period**: 15 bars, 1 new position per bar (was 30 bars)
### Backtester
- **Slippage**: 10 bps per trade
- **Risk-free rate**: 5% annually for Sharpe/Sortino
--- ---
## KEY LESSONS ## KEY LESSONS
### 1. Shared Logic Eliminates Drift ### 1. Equity Curve SMA Creates Pathological Feedback Loop
Extracting common logic into `strategy.rs` ensures bot and backtester CANNOT diverge. Previously, duplicate implementations led to subtle differences (partial exits, bars_held increment timing, cooldown logic). 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. Warmup Must Account for Longest Chain ### 2. Shared Logic Eliminates Most Drift
For hourly mode, EMA-200 dominates warmup (205 bars). ADX also needs 2x period (28 bars) for proper smoothing. The `+ 5` safety margin is critical. 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. NaN Handling is Critical ### 3. Config Constants Propagation Works Well
Indicators can produce NaN during warmup or with insufficient data. The signal generator uses safe defaults (e.g., `if adx.is_nan() { 22.0 }`) to prevent scoring errors. 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. ATR Fallbacks Prevent Edge Cases ### 4. Warmup Must Account for Longest Indicator Chain
When ATR is zero/unavailable (e.g., low volatility or warmup), code falls back to fixed percentage stops. Without this, position sizing could explode or stops could fail. 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. Slippage Modeling is Non-Negotiable ### 5. Math Errors Can Be Consistently Wrong
The backtester applies 10 bps slippage on both sides (20 bps round-trip) to simulate realistic fills. This prevents overfitting to unrealistic backtest performance. The 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. 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).
--- ---
@@ -162,28 +200,40 @@ When new changes are made, verify:
1. **Signal generation**: Still using shared `indicators::generate_signal()`? 1. **Signal generation**: Still using shared `indicators::generate_signal()`?
2. **Position sizing**: Still using shared `Strategy::calculate_position_size()`? 2. **Position sizing**: Still using shared `Strategy::calculate_position_size()`?
3. **Risk management**: Still using shared `Strategy::check_stop_loss_take_profit()`? 3. **Risk management**: Still using shared `Strategy::check_stop_loss_take_profit()`?
4. **Cooldown timers**: Identical logic in both files? 4. **Equity curve stop**: Check if REMOVED in both files (don't re-add to backtester!)
5. **Ramp-up period**: Identical logic in both files? 5. **Cooldown timers**: Identical logic in both files?
6. **Drawdown halt**: Identical trigger and resume logic? 6. **Ramp-up period**: Identical logic in both files?
7. **Sector limits**: Same `MAX_SECTOR_POSITIONS` constant? 7. **Drawdown halt**: Identical trigger logic? Peak reset on expiry?
8. **Max positions**: Same `MAX_CONCURRENT_POSITIONS` constant? 8. **Sector limits**: Same `MAX_SECTOR_POSITIONS` constant?
9. **Momentum ranking**: Same `TOP_MOMENTUM_COUNT` constant? 9. **Max positions**: Same `MAX_CONCURRENT_POSITIONS` constant?
10. **bars_held increment**: Both increment at START of cycle/bar? 10. **Momentum ranking**: Same `TOP_MOMENTUM_COUNT` constant?
11. **Warmup calculation**: Does `min_bars()` cover all indicators? 11. **bars_held increment**: Both increment at START of cycle/bar?
12. **Config propagation**: Are new constants used consistently? 12. **Warmup calculation**: Does `min_bars()` cover all indicators?
13. **NaN handling**: Safe defaults for all indicator checks? 13. **NaN handling**: Safe defaults for all indicator checks?
14. **ATR guards**: Checks for `> 0.0` before division? 14. **ATR guards**: Checks for `> 0.0` before division?
15. **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-12) ## FILES LOCATIONS
- `/home/work/Documents/rust/invest-bot/src/bot.rs` (785 lines)
- `/home/work/Documents/rust/invest-bot/src/backtester.rs` (880 lines)
- `/home/work/Documents/rust/invest-bot/src/config.rs` (199 lines)
- `/home/work/Documents/rust/invest-bot/src/indicators.rs` (651 lines)
- `/home/work/Documents/rust/invest-bot/src/strategy.rs` (141 lines)
- `/home/work/Documents/rust/invest-bot/src/types.rs` (234 lines)
**Total**: 2,890 lines audited - `/home/work/Documents/rust/invest-bot/src/bot.rs` — Live trading loop (1119 lines)
**Issues found**: 0 critical, 0 medium, 0 low - `/home/work/Documents/rust/invest-bot/src/backtester.rs` — Historical simulation (1217 lines)
**Status**: ✅ PRODUCTION READY - `/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)
---
## CRITICAL REMINDERS
- **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

View File

@@ -1,42 +1,63 @@
# Quant-Rust-Strategist Memory # Quant-Rust-Strategist Memory
## Architecture Overview ## Architecture Overview
- 50-symbol universe across 9 sectors - ~100-symbol universe across 14 sectors
- Hybrid momentum + mean-reversion via composite signal scoring in `generate_signal()` - strategy.rs: shared logic between bot.rs and backtester.rs
- Backtester restricts buys to top 8 momentum stocks (TOP_MOMENTUM_COUNT=8) - Backtester restricts buys to top momentum stocks (TOP_MOMENTUM_COUNT)
- Signal thresholds: StrongBuy>=6.0, Buy>=4.5, Sell<=-3.5, StrongSell<=-6.0 - 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
## Key Finding: Daily vs Hourly Parameter Sensitivity (2026-02-11) ## 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
### Daily Timeframe Optimization (Successful) ## Stop/Exit Logic (2026-02-13 FIX)
- Reduced momentum_period 252->63, ema_trend 200->50 in IndicatorParams::daily() - Time exit ONLY sells losers (pnl_pct < 0). Old code force-sold winners.
- Reduced warmup from 267 bars to ~70 bars - Trail activation: 1.5x ATR (was 2.0x), trail distance: 2.5x ATR (was 2.0x)
- Result: Sharpe 0.53->0.86 (+62%), Win rate 40%->50%, PF 1.32->1.52 - Max loss: 8% (was 5%), TIME_EXIT_BARS: 60 (was 40)
### Hourly Timeframe: DO NOT CHANGE FROM BASELINE ## Equity Curve SMA Stop: REMOVED from backtester
- Hourly IndicatorParams: momentum=63, ema_trend=200 (long lookbacks filter IEX noise) - Created pathological feedback loop with drawdown breaker
- Shorter periods (momentum=21, ema_trend=50): CATASTROPHIC -8% loss
- ADX threshold lowered 25->20 (shared const, helps both timeframes)
### Failed Experiments (avoid repeating) ## Position Sizing (2026-02-13 FIX)
1. Tighter ATR stop (2.0x): too many stop-outs on hourly. Keep 2.5x - Confidence scaling: 0.4 + 0.6*conf (was 0.7 + 0.3*conf)
2. Lower buy threshold (3.5): too many weak entries. Keep 4.5 - RISK_PER_TRADE: 1.0%, MAX_POSITIONS: 10, TOP_MOMENTUM: 10
3. More positions (8): spreads capital too thin. Keep 5
4. Higher risk per trade (1.0-1.2%): compounds losses. Keep 0.8%
5. Wider trail (2.5x ATR): misses profit on hourly. Keep 1.5x
6. Lower volume threshold (0.7): bad trades on IEX. Keep 0.8
7. Lower cash reserve (3%): marginal, not worth risk. Keep 5%
## Current Parameters (config.rs) ## Current Parameters (config.rs, updated 2026-02-13)
- ATR Stop: 2.5x | Trail: 1.5x distance, 1.5x activation - ATR Stop: 3.0x | Trail: 2.5x distance, 1.5x activation
- Risk: 0.8%/trade, max 22% position, 5% cash reserve, 4% max loss - Risk: 1.0%/trade, max 25% position, 5% cash reserve, 8% max loss
- Max 5 positions, 2/sector | Drawdown halt: 10% (35 bars) | Time exit: 30 - Max 10 positions, 2/sector | Time exit: 60 bars (losers only)
- Cooldown: 7 bars | Ramp-up: 30 bars | Slippage: 10bps - Cooldown: 5 bars | Ramp-up: 15 bars | Slippage: 10bps
- Daily params: momentum=63, ema_trend=50 - Momentum pool: top 10 (decile)
- Hourly params: momentum=63, ema_trend=200
- ADX: threshold=20, strong=35 ## 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 (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
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
- Run 2-3 times and compare ranges before concluding a change helped/hurt
## Build Notes ## Build Notes
- `cargo build --release` compiles clean (only dead_code warnings) - `cargo build --release` compiles clean (only dead_code warnings)
- No tests exist - No tests exist
- Backtests have stochastic variation from IEX data timing

View File

@@ -1,19 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e 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 "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' echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
exit 1 exit 1
fi fi
# rebuild the cache forcefully # 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. # Update the mtime for .envrc.
# This will cause direnv to reload again - but without re-building. # 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. # Also update the timestamp of whatever profile_rc we have.
# This makes sure that we know we are up to date. # 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

View File

@@ -0,0 +1 @@
/nix/store/j9250k63yp54q9r2m0xnca8lxjcfadv0-source

View File

@@ -1 +0,0 @@
/nix/store/vanbyn1mbsqmff9in675grd5lqpr69zl-source

View File

@@ -41,7 +41,7 @@ NIX_ENFORCE_NO_NATIVE='1'
export NIX_ENFORCE_NO_NATIVE export NIX_ENFORCE_NO_NATIVE
NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningextensive libcxxhardeningfast pic relro stackclashprotection stackprotector strictoverflow zerocallusedregs' NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningextensive libcxxhardeningfast pic relro stackclashprotection stackprotector strictoverflow zerocallusedregs'
export NIX_HARDENING_ENABLE 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 export NIX_LDFLAGS
NIX_NO_SELF_RPATH='1' NIX_NO_SELF_RPATH='1'
NIX_PKG_CONFIG_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1' NIX_PKG_CONFIG_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
@@ -142,7 +142,7 @@ name='nix-shell-env'
export name 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' 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 export nativeBuildInputs
out='/home/work/Documents/rust/invest-bot/outputs/out' out='/home/mrfluffy/Documents/projects/rust/vibe-invest/outputs/out'
export out export out
outputBin='out' outputBin='out'
outputDev='out' outputDev='out'
@@ -173,7 +173,7 @@ preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase'
declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' ) declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
preferLocalBuild='1' preferLocalBuild='1'
export preferLocalBuild 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' ) declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
propagatedBuildInputs='' propagatedBuildInputs=''
export propagatedBuildInputs export propagatedBuildInputs

View File

@@ -21,6 +21,10 @@ cargo run --release -- --backtest --years 3
cargo run --release -- --backtest --years 5 --capital 50000 cargo run --release -- --backtest --years 5 --capital 50000
cargo run --release -- --backtest --years 1 --months 6 --timeframe hourly 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) # Lint and format (available via nix flake)
cargo clippy cargo clippy
cargo fmt cargo fmt

View File

@@ -14,7 +14,7 @@ use crate::types::Bar;
const DATA_BASE_URL: &str = "https://data.alpaca.markets/v2"; const DATA_BASE_URL: &str = "https://data.alpaca.markets/v2";
const TRADING_BASE_URL: &str = "https://paper-api.alpaca.markets/v2"; const TRADING_BASE_URL: &str = "https://paper-api.alpaca.markets/v2";
const RATE_LIMIT_REQUESTS_PER_MINUTE: u32 = 200; const RATE_LIMIT_REQUESTS_PER_MINUTE: u32 = 190;
/// Alpaca API client. /// Alpaca API client.
pub struct AlpacaClient { pub struct AlpacaClient {
@@ -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. /// Helper to fetch bars for backtesting with proper date handling.
/// Fetches each symbol individually to avoid API limits on multi-symbol requests. /// 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( pub async fn fetch_backtest_data(
client: &AlpacaClient, client: &AlpacaClient,
symbols: &[&str], symbols: &[&str],
@@ -476,6 +509,9 @@ pub async fn fetch_backtest_data(
let days = (years * 365.0) as i64 + warmup_days + 30; let days = (years * 365.0) as i64 + warmup_days + 30;
let start = end - Duration::days(days); 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!( tracing::info!(
"Fetching {:.2} years of data ({} to {})...", "Fetching {:.2} years of data ({} to {})...",
years, years,
@@ -484,29 +520,250 @@ pub async fn fetch_backtest_data(
); );
let mut all_data = HashMap::new(); 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 { for symbol in symbols {
tracing::info!(" Fetching {}...", symbol); let cached = load_cached_bars(symbol, timeframe);
match client if cached.is_empty() {
.get_historical_bars(symbol, timeframe, start, end) // Full fetch — no cache
.await cache_misses += 1;
{ tracing::info!(" Fetching {} (no cache)...", symbol);
Ok(bars) => {
if !bars.is_empty() { match client
tracing::info!(" {}: {} bars loaded", symbol, bars.len()); .get_historical_bars(symbol, timeframe, start, end)
all_data.insert(symbol.to_string(), bars); .await
} else { {
tracing::warn!(" {}: No data", symbol); 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) => { } else {
tracing::error!(" Failed to fetch {}: {}", symbol, e); 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) Ok(all_data)
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
//! Live trading bot using Alpaca API. //! Live trading bot using Alpaca API.
use anyhow::Result; use anyhow::Result;
use chrono::{Duration, Utc}; use chrono::{Datelike, Duration, NaiveDate, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use tokio::time::{sleep, Duration as TokioDuration}; use tokio::time::{sleep, Duration as TokioDuration};
@@ -10,24 +10,37 @@ use crate::alpaca::AlpacaClient;
use crate::config::{ use crate::config::{
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER, get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, BOT_CHECK_INTERVAL_SECONDS, 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, MAX_POSITION_SIZE, MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS,
REENTRY_COOLDOWN_BARS, TOP_MOMENTUM_COUNT, 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::{ use crate::paths::{
LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE, LIVE_POSITIONS_FILE, LIVE_DAY_TRADES_FILE, LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE,
LIVE_POSITION_META_FILE, LIVE_POSITIONS_FILE, LIVE_POSITION_META_FILE,
}; };
use crate::strategy::Strategy; use crate::strategy::Strategy;
use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal}; use crate::types::{EquitySnapshot, MarketRegime, PositionInfo, Signal, TradeSignal};
/// Per-position metadata persisted to disk. /// Per-position metadata persisted to disk.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct PositionMeta { struct PositionMeta {
bars_held: usize, bars_held: usize,
/// Date (YYYY-MM-DD) when this position was opened, for PDT tracking.
#[serde(default)]
entry_date: Option<String>,
} }
/// PDT (Pattern Day Trading) constants.
const PDT_MAX_DAY_TRADES: usize = 3;
const PDT_ROLLING_BUSINESS_DAYS: i64 = 5;
/// Live trading bot for paper trading. /// Live trading bot for paper trading.
pub struct TradingBot { pub struct TradingBot {
client: AlpacaClient, client: AlpacaClient,
@@ -39,12 +52,22 @@ pub struct TradingBot {
drawdown_halt: bool, drawdown_halt: bool,
/// Cycle count when drawdown halt started (for time-based resume) /// Cycle count when drawdown halt started (for time-based resume)
drawdown_halt_start: Option<usize>, 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 /// Current trading cycle count
trading_cycle_count: usize, trading_cycle_count: usize,
/// Tracks when each symbol can be re-entered after stop-loss (cycle index) /// Tracks when each symbol can be re-entered after stop-loss (cycle index)
cooldown_timers: HashMap<String, usize>, cooldown_timers: HashMap<String, usize>,
/// Tracks new positions opened in current cycle (for gradual ramp-up) /// Tracks new positions opened in current cycle (for gradual ramp-up)
new_positions_this_cycle: usize, new_positions_this_cycle: usize,
/// Rolling list of day trade dates for PDT tracking.
day_trades: Vec<NaiveDate>,
/// Current portfolio value (updated each cycle), used for PDT exemption check.
current_portfolio_value: f64,
} }
impl TradingBot { impl TradingBot {
@@ -65,9 +88,14 @@ impl TradingBot {
peak_portfolio_value: 0.0, peak_portfolio_value: 0.0,
drawdown_halt: false, drawdown_halt: false,
drawdown_halt_start: None, drawdown_halt_start: None,
drawdown_halt_severity: 0.0,
drawdown_requires_bull: false,
current_regime: MarketRegime::Bull,
trading_cycle_count: 0, trading_cycle_count: 0,
cooldown_timers: HashMap::new(), cooldown_timers: HashMap::new(),
new_positions_this_cycle: 0, new_positions_this_cycle: 0,
day_trades: Vec::new(),
current_portfolio_value: 0.0,
}; };
// Load persisted state // Load persisted state
@@ -76,6 +104,7 @@ impl TradingBot {
bot.load_entry_atrs(); bot.load_entry_atrs();
bot.load_position_meta(); bot.load_position_meta();
bot.load_cooldown_timers(); bot.load_cooldown_timers();
bot.load_day_trades();
bot.load_equity_history(); bot.load_equity_history();
// Log account info // Log account info
@@ -86,6 +115,14 @@ impl TradingBot {
Ok(bot) 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 ────────────────────────────────────────── // ── Persistence helpers ──────────────────────────────────────────
fn load_json_map<V: serde::de::DeserializeOwned>( fn load_json_map<V: serde::de::DeserializeOwned>(
@@ -181,6 +218,101 @@ impl TradingBot {
} }
} }
// ── PDT (Pattern Day Trading) protection ───────────────────────
fn load_day_trades(&mut self) {
if LIVE_DAY_TRADES_FILE.exists() {
match std::fs::read_to_string(&*LIVE_DAY_TRADES_FILE) {
Ok(content) if !content.is_empty() => {
match serde_json::from_str::<Vec<String>>(&content) {
Ok(date_strings) => {
self.day_trades = date_strings
.iter()
.filter_map(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
.collect();
self.prune_old_day_trades();
if !self.day_trades.is_empty() {
tracing::info!(
"Loaded {} day trades in rolling window.",
self.day_trades.len()
);
}
}
Err(e) => tracing::error!("Error parsing day trades file: {}", e),
}
}
_ => {}
}
}
}
fn save_day_trades(&self) {
let date_strings: Vec<String> = self
.day_trades
.iter()
.map(|d| d.format("%Y-%m-%d").to_string())
.collect();
match serde_json::to_string_pretty(&date_strings) {
Ok(json) => {
if let Err(e) = std::fs::write(&*LIVE_DAY_TRADES_FILE, json) {
tracing::error!("Error saving day trades file: {}", e);
}
}
Err(e) => tracing::error!("Error serializing day trades: {}", e),
}
}
/// Remove day trades older than the 5-business-day rolling window.
fn prune_old_day_trades(&mut self) {
let cutoff = Self::business_days_before(Utc::now().date_naive(), PDT_ROLLING_BUSINESS_DAYS);
self.day_trades.retain(|&d| d >= cutoff);
}
/// Get the date N business days before the given date.
fn business_days_before(from: NaiveDate, n: i64) -> NaiveDate {
let mut count = 0i64;
let mut date = from;
while count < n {
date -= Duration::days(1);
let wd = date.weekday();
if wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun {
count += 1;
}
}
date
}
/// Count how many day trades have occurred in the rolling 5-business-day window.
fn day_trades_in_window(&self) -> usize {
let cutoff = Self::business_days_before(Utc::now().date_naive(), PDT_ROLLING_BUSINESS_DAYS);
self.day_trades.iter().filter(|&&d| d >= cutoff).count()
}
/// Check if selling this symbol today would be a day trade (bought today).
fn would_be_day_trade(&self, symbol: &str) -> bool {
let today = Utc::now().date_naive().format("%Y-%m-%d").to_string();
self.position_meta
.get(symbol)
.and_then(|m| m.entry_date.as_ref())
.map(|d| d == &today)
.unwrap_or(false)
}
/// Check if a day trade is allowed (under PDT limit).
/// PDT rule only applies to accounts under $25,000.
fn can_day_trade(&self) -> bool {
if self.current_portfolio_value >= 25_000.0 {
return true;
}
self.day_trades_in_window() < PDT_MAX_DAY_TRADES
}
/// Record a day trade.
fn record_day_trade(&mut self) {
self.day_trades.push(Utc::now().date_naive());
self.save_day_trades();
}
fn load_equity_history(&mut self) { fn load_equity_history(&mut self) {
if LIVE_EQUITY_FILE.exists() { if LIVE_EQUITY_FILE.exists() {
match std::fs::read_to_string(&*LIVE_EQUITY_FILE) { match std::fs::read_to_string(&*LIVE_EQUITY_FILE) {
@@ -240,29 +372,87 @@ impl TradingBot {
0.0 0.0
}; };
// Trigger halt if drawdown exceeds threshold // Scaled drawdown circuit breaker (Tier 1/2/3)
if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt { 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!( 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, drawdown_pct * 100.0,
MAX_DRAWDOWN_HALT * 100.0, halt_bars,
DRAWDOWN_HALT_BARS if self.drawdown_requires_bull { " Requires BULL regime to resume." } else { "" }
); );
self.drawdown_halt = true; self.drawdown_halt = true;
self.drawdown_halt_start = Some(self.trading_cycle_count); 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 // Auto-resume after time-based cooldown
if self.drawdown_halt { if self.drawdown_halt {
if let Some(halt_start) = self.drawdown_halt_start { if let Some(halt_start) = self.drawdown_halt_start {
if self.trading_cycle_count >= halt_start + DRAWDOWN_HALT_BARS { 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!( tracing::info!(
"Drawdown halt expired after {} cycles. Resuming trading at {:.2}% drawdown.", "Drawdown halt expired after {} cycles (regime: {}). \
DRAWDOWN_HALT_BARS, Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).",
required_bars,
self.current_regime.as_str(),
self.peak_portfolio_value,
portfolio_value,
drawdown_pct * 100.0 drawdown_pct * 100.0
); );
self.drawdown_halt = false; self.drawdown_halt = false;
self.drawdown_halt_start = None; self.drawdown_halt_start = None;
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
);
} }
} }
} }
@@ -307,13 +497,15 @@ impl TradingBot {
// ── Account helpers ────────────────────────────────────────────── // ── Account helpers ──────────────────────────────────────────────
async fn log_account_info(&self) { async fn log_account_info(&mut self) {
match self.client.get_account().await { match self.client.get_account().await {
Ok(account) => { Ok(account) => {
let portfolio_value: f64 = account.portfolio_value.parse().unwrap_or(0.0); let portfolio_value: f64 = account.portfolio_value.parse().unwrap_or(0.0);
let buying_power: f64 = account.buying_power.parse().unwrap_or(0.0); let buying_power: f64 = account.buying_power.parse().unwrap_or(0.0);
let cash: f64 = account.cash.parse().unwrap_or(0.0); let cash: f64 = account.cash.parse().unwrap_or(0.0);
self.current_portfolio_value = portfolio_value;
tracing::info!("Account Status: {}", account.status); tracing::info!("Account Status: {}", account.status);
tracing::info!("Buying Power: ${:.2}", buying_power); tracing::info!("Buying Power: ${:.2}", buying_power);
tracing::info!("Portfolio Value: ${:.2}", portfolio_value); tracing::info!("Portfolio Value: ${:.2}", portfolio_value);
@@ -336,12 +528,12 @@ impl TradingBot {
// ── Volatility-adjusted position sizing ────────────────────────── // ── Volatility-adjusted position sizing ──────────────────────────
async fn calculate_position_size(&self, signal: &TradeSignal) -> u64 { async fn calculate_position_size(&self, signal: &TradeSignal) -> f64 {
let account = match self.client.get_account().await { let account = match self.client.get_account().await {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {
tracing::error!("Failed to get account: {}", e); tracing::error!("Failed to get account: {}", e);
return 0; return 0.0;
} }
}; };
@@ -383,7 +575,7 @@ impl TradingBot {
// ── Order execution ────────────────────────────────────────────── // ── 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 // Check if already holding
if let Some(qty) = self.get_position(symbol).await { if let Some(qty) = self.get_position(symbol).await {
if qty > 0.0 { if qty > 0.0 {
@@ -440,15 +632,18 @@ impl TradingBot {
return false; return false;
} }
let shares = self.calculate_position_size(signal).await; let mut shares = self.calculate_position_size(signal).await;
if shares == 0 { // 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); tracing::info!("{}: Insufficient funds for purchase", symbol);
return false; return false;
} }
match self match self
.client .client
.submit_market_order(symbol, shares as f64, "buy") .submit_market_order(symbol, shares, "buy")
.await .await
{ {
Ok(order) => { Ok(order) => {
@@ -466,6 +661,7 @@ impl TradingBot {
symbol.to_string(), symbol.to_string(),
PositionMeta { PositionMeta {
bars_held: 0, bars_held: 0,
entry_date: Some(Utc::now().format("%Y-%m-%d").to_string()),
}, },
); );
@@ -500,12 +696,37 @@ impl TradingBot {
} }
}; };
// PDT protection: if selling today would create a day trade, check the limit.
// EXCEPTION: stop-loss exits are NEVER blocked -- risk management takes priority
// over PDT compliance. The correct defense against PDT violations is to prevent
// entries that would need same-day exits, not to trap capital in losing positions.
let is_day_trade = self.would_be_day_trade(symbol);
if is_day_trade && !was_stop_loss && !self.can_day_trade() {
let count = self.day_trades_in_window();
tracing::warn!(
"{}: SKIPPING SELL — would trigger PDT violation ({}/{} day trades in rolling 5-day window). \
Position opened today, will sell tomorrow.",
symbol, count, PDT_MAX_DAY_TRADES
);
return false;
}
match self match self
.client .client
.submit_market_order(symbol, current_position, "sell") .submit_market_order(symbol, current_position, "sell")
.await .await
{ {
Ok(_order) => { Ok(_order) => {
// Record the day trade if applicable
if is_day_trade {
self.record_day_trade();
tracing::info!(
"{}: Day trade recorded ({}/{} in rolling window)",
symbol,
self.day_trades_in_window(),
PDT_MAX_DAY_TRADES
);
}
if let Some(entry) = self.strategy.entry_prices.remove(symbol) { if let Some(entry) = self.strategy.entry_prices.remove(symbol) {
let pnl_pct = (signal.current_price - entry) / entry; let pnl_pct = (signal.current_price - entry) / entry;
tracing::info!("{}: Realized P&L: {:.2}%", symbol, pnl_pct * 100.0); tracing::info!("{}: Realized P&L: {:.2}%", symbol, pnl_pct * 100.0);
@@ -550,6 +771,53 @@ impl TradingBot {
// Partial exits removed: they systematically halve winning trade size // Partial exits removed: they systematically halve winning trade size
// while losing trades remain at full size, creating unfavorable avg win/loss ratio. // 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 ───────────────────────────────────────────────────── // ── Analysis ─────────────────────────────────────────────────────
async fn analyze_symbol(&self, symbol: &str) -> Option<TradeSignal> { async fn analyze_symbol(&self, symbol: &str) -> Option<TradeSignal> {
@@ -607,9 +875,25 @@ impl TradingBot {
async fn run_trading_cycle(&mut self) { async fn run_trading_cycle(&mut self) {
self.trading_cycle_count += 1; self.trading_cycle_count += 1;
self.new_positions_this_cycle = 0; // Reset counter for each cycle self.new_positions_this_cycle = 0; // Reset counter for each cycle
self.prune_old_day_trades();
tracing::info!("{}", "=".repeat(60)); tracing::info!("{}", "=".repeat(60));
tracing::info!("Starting trading cycle #{}...", self.trading_cycle_count); tracing::info!("Starting trading cycle #{}...", self.trading_cycle_count);
self.log_account_info().await; self.log_account_info().await;
if self.current_portfolio_value >= 25_000.0 {
tracing::info!("PDT status: EXEMPT (portfolio ${:.2} >= $25,000)", self.current_portfolio_value);
} else {
tracing::info!(
"PDT status: {}/{} day trades in rolling 5-business-day window (portfolio ${:.2} < $25,000)",
self.day_trades_in_window(),
PDT_MAX_DAY_TRADES,
self.current_portfolio_value,
);
}
// 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) // Increment bars_held once per trading cycle (matches backtester's per-bar increment)
for meta in self.position_meta.values_mut() { for meta in self.position_meta.values_mut() {
@@ -690,13 +974,50 @@ impl TradingBot {
); );
// Phase 3: Process buys in momentum-ranked order (highest momentum first) // Phase 3: Process buys in momentum-ranked order (highest momentum first)
for signal in &ranked_signals { // Gate by market regime
if !top_momentum_symbols.contains(&signal.symbol) { if !self.current_regime.allows_new_longs() {
continue; 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() { for signal in &ranked_signals {
self.execute_buy(&signal.symbol, signal).await; 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;
}
} }
} }

View File

@@ -1,36 +1,47 @@
//! Configuration constants for the trading bot. //! Configuration constants for the trading bot.
// Stock Universe // Stock Universe (~100 symbols across 14 sectors)
pub const MAG7: &[&str] = &["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "TSLA"]; pub const MAG7: &[&str] = &["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "TSLA"];
pub const SEMIS: &[&str] = &["AVGO", "AMD", "ASML", "QCOM", "MU"]; pub const SEMIS: &[&str] = &["AVGO", "AMD", "ASML", "QCOM", "MU", "MRVL", "LRCX", "KLAC", "AMAT"];
pub const GROWTH_TECH: &[&str] = &["NFLX", "CRM", "NOW", "UBER", "SNOW"]; pub const GROWTH_TECH: &[&str] = &["NFLX", "CRM", "NOW", "UBER", "SNOW", "DDOG", "CRWD", "ZS", "WDAY"];
pub const HEALTHCARE: &[&str] = &["LLY", "UNH", "ISRG", "VRTX", "ABBV", "MRK", "PFE"]; pub const SOFTWARE: &[&str] = &["ADBE", "INTU", "PANW", "FTNT", "TEAM", "HUBS", "MNDY"];
pub const FINTECH_VOLATILE: &[&str] = &["V", "MA", "COIN", "PLTR", "MSTR"]; pub const HEALTHCARE: &[&str] = &["LLY", "UNH", "ISRG", "VRTX", "ABBV", "MRK", "PFE", "TMO", "ABT", "DHR"];
pub const SP500_FINANCIALS: &[&str] = &["JPM", "GS", "MS", "BLK", "AXP", "C"]; pub const BIOTECH: &[&str] = &["GILD", "AMGN", "REGN", "BIIB", "MRNA"];
pub const SP500_INDUSTRIALS: &[&str] = &["CAT", "GE", "HON", "BA", "RTX", "LMT", "DE"]; pub const FINTECH_VOLATILE: &[&str] = &["V", "MA", "COIN", "PLTR", "MSTR", "SQ", "PYPL"];
pub const SP500_CONSUMER: &[&str] = &["COST", "WMT", "HD", "NKE", "SBUX", "MCD", "DIS"]; pub const SP500_FINANCIALS: &[&str] = &["JPM", "GS", "MS", "BLK", "AXP", "C", "SCHW", "ICE"];
pub const SP500_ENERGY: &[&str] = &["XOM", "CVX", "COP", "SLB", "OXY"]; pub const SP500_INDUSTRIALS: &[&str] = &["CAT", "GE", "HON", "BA", "RTX", "LMT", "DE", "UNP", "UPS"];
/// Get all symbols in the trading universe (50 stocks). pub const SP500_CONSUMER: &[&str] = &["COST", "WMT", "HD", "NKE", "SBUX", "MCD", "DIS", "TGT", "LOW", "ABNB", "BKNG"];
pub const SP500_ENERGY: &[&str] = &["XOM", "CVX", "COP", "SLB", "OXY", "EOG", "MPC"];
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 + SPY for regime).
pub fn get_all_symbols() -> Vec<&'static str> { pub fn get_all_symbols() -> Vec<&'static str> {
let mut symbols = Vec::new(); let mut symbols = Vec::new();
symbols.extend_from_slice(MAG7); symbols.extend_from_slice(MAG7);
symbols.extend_from_slice(SEMIS); symbols.extend_from_slice(SEMIS);
symbols.extend_from_slice(GROWTH_TECH); symbols.extend_from_slice(GROWTH_TECH);
symbols.extend_from_slice(SOFTWARE);
symbols.extend_from_slice(HEALTHCARE); symbols.extend_from_slice(HEALTHCARE);
symbols.extend_from_slice(BIOTECH);
symbols.extend_from_slice(FINTECH_VOLATILE); symbols.extend_from_slice(FINTECH_VOLATILE);
symbols.extend_from_slice(SP500_FINANCIALS); symbols.extend_from_slice(SP500_FINANCIALS);
symbols.extend_from_slice(SP500_INDUSTRIALS); symbols.extend_from_slice(SP500_INDUSTRIALS);
symbols.extend_from_slice(SP500_CONSUMER); symbols.extend_from_slice(SP500_CONSUMER);
symbols.extend_from_slice(SP500_ENERGY); symbols.extend_from_slice(SP500_ENERGY);
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();
symbols symbols
} }
// Strategy Parameters — Regime-Adaptive Dual Signal // Strategy Parameters — Regime-Adaptive Dual Signal
// RSI-14 for trend assessment, RSI-2 for mean-reversion entries (Connors) // RSI-14 for trend assessment, RSI-2 for mean-reversion entries (Connors)
pub const RSI_PERIOD: usize = 14; pub const RSI_PERIOD: usize = 14;
pub const RSI_SHORT_PERIOD: usize = 2; // Connors RSI-2 for mean reversion 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_FAST: usize = 12;
pub const MACD_SLOW: usize = 26; pub const MACD_SLOW: usize = 26;
pub const MACD_SIGNAL: usize = 9; pub const MACD_SIGNAL: usize = 9;
@@ -43,9 +54,7 @@ pub const EMA_TREND: usize = 50;
// ADX > TREND_THRESHOLD = trending (use momentum/pullback) // ADX > TREND_THRESHOLD = trending (use momentum/pullback)
// Between = transition zone (reduce size, be cautious) // Between = transition zone (reduce size, be cautious)
pub const ADX_PERIOD: usize = 14; 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_TREND_THRESHOLD: f64 = 25.0; // Above this = trending
pub const ADX_STRONG: f64 = 40.0; // Strong trend for bonus conviction
// Bollinger Bands // Bollinger Bands
pub const BB_PERIOD: usize = 20; pub const BB_PERIOD: usize = 20;
pub const BB_STD: f64 = 2.0; pub const BB_STD: f64 = 2.0;
@@ -56,28 +65,105 @@ pub const MIN_ATR_PCT: f64 = 0.005;
pub const VOLUME_MA_PERIOD: usize = 20; pub const VOLUME_MA_PERIOD: usize = 20;
pub const VOLUME_THRESHOLD: f64 = 0.8; pub const VOLUME_THRESHOLD: f64 = 0.8;
// Momentum Ranking // Momentum Ranking
pub const TOP_MOMENTUM_COUNT: usize = 10; // Wider pool for more opportunities pub const TOP_MOMENTUM_COUNT: usize = 15; // Top quintile: enough candidates for 8 positions
// Risk Management // 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 MIN_CASH_RESERVE: f64 = 0.05;
pub const STOP_LOSS_PCT: f64 = 0.025; 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 MAX_LOSS_PCT: f64 = 0.08; // Gap protection only — ATR stop handles normal exits
pub const TRAILING_STOP_ACTIVATION: f64 = 0.06; pub const TRAILING_STOP_ACTIVATION: f64 = 0.04; // Activate earlier to protect profits
pub const TRAILING_STOP_DISTANCE: f64 = 0.04; pub const TRAILING_STOP_DISTANCE: f64 = 0.05; // Wider trail to let winners run
// ATR-based risk management // ATR-based risk management
pub const RISK_PER_TRADE: f64 = 0.012; // More aggressive sizing for higher conviction 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.0; // Wider stops — research shows tighter stops hurt pub const ATR_STOP_MULTIPLIER: f64 = 3.5; // Wide stops reduce false stop-outs (the #1 loss source)
pub const ATR_TRAIL_MULTIPLIER: f64 = 2.0; // Wider trail to let winners run pub const ATR_TRAIL_MULTIPLIER: f64 = 3.0; // Wide trail so winners run longer
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Activate after 2x ATR gain 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 // 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_SECTOR_POSITIONS: usize = 2;
pub const MAX_DRAWDOWN_HALT: f64 = 0.12; // Wider drawdown tolerance // Old single-tier drawdown constants (replaced by tiered system below)
pub const DRAWDOWN_HALT_BARS: usize = 20; // Shorter cooldown to get back in // pub const MAX_DRAWDOWN_HALT: f64 = 0.15;
// pub const DRAWDOWN_HALT_BARS: usize = 10;
// Time-based exit // Time-based exit
pub const TIME_EXIT_BARS: usize = 40; // Longer patience for mean reversion pub const TIME_EXIT_BARS: usize = 80; // More patience for losers on hourly bars
pub const REENTRY_COOLDOWN_BARS: usize = 5; // Shorter cooldown pub const REENTRY_COOLDOWN_BARS: usize = 10; // Longer cooldown to reduce churn
pub const RAMPUP_PERIOD_BARS: usize = 15; // Faster ramp-up 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 // Backtester slippage
pub const SLIPPAGE_BPS: f64 = 10.0; pub const SLIPPAGE_BPS: f64 = 10.0;
// Trading intervals // Trading intervals
@@ -96,8 +182,12 @@ pub fn get_sector(symbol: &str) -> &'static str {
"semis" "semis"
} else if GROWTH_TECH.contains(&symbol) { } else if GROWTH_TECH.contains(&symbol) {
"growth_tech" "growth_tech"
} else if SOFTWARE.contains(&symbol) {
"software"
} else if HEALTHCARE.contains(&symbol) { } else if HEALTHCARE.contains(&symbol) {
"healthcare" "healthcare"
} else if BIOTECH.contains(&symbol) {
"biotech"
} else if FINTECH_VOLATILE.contains(&symbol) { } else if FINTECH_VOLATILE.contains(&symbol) {
"fintech_volatile" "fintech_volatile"
} else if SP500_FINANCIALS.contains(&symbol) { } else if SP500_FINANCIALS.contains(&symbol) {
@@ -108,6 +198,12 @@ pub fn get_sector(symbol: &str) -> &'static str {
"consumer" "consumer"
} else if SP500_ENERGY.contains(&symbol) { } else if SP500_ENERGY.contains(&symbol) {
"energy" "energy"
} else if TELECOM_MEDIA.contains(&symbol) {
"telecom_media"
} else if INTERNATIONAL.contains(&symbol) {
"international"
} else if MATERIALS.contains(&symbol) {
"materials"
} else { } else {
"unknown" "unknown"
} }
@@ -149,21 +245,25 @@ impl IndicatorParams {
} }
} }
/// Create parameters for hourly timeframe. /// 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 { pub fn hourly() -> Self {
Self { Self {
rsi_period: 14, rsi_period: 14,
rsi_short_period: 3, // Slightly longer for hourly noise rsi_short_period: 3,
macd_fast: 12, macd_fast: 84, // 12 * 7
macd_slow: 26, macd_slow: 182, // 26 * 7
macd_signal: 9, macd_signal: 63, // 9 * 7
momentum_period: 63, momentum_period: 441, // 63 * 7 = quarterly momentum
ema_short: 9, ema_short: 63, // 9 * 7 ~ daily 9-day EMA
ema_long: 21, ema_long: 147, // 21 * 7 ~ daily 21-day EMA
ema_trend: 200, ema_trend: 350, // 50 * 7 ~ daily 50-day EMA
adx_period: 14, adx_period: 14,
bb_period: 20, bb_period: 140, // 20 * 7
atr_period: 14, atr_period: 14,
volume_ma_period: 20, volume_ma_period: 140, // 20 * 7
} }
} }
/// Get the minimum number of bars required for indicator calculation. /// Get the minimum number of bars required for indicator calculation.

View File

@@ -12,13 +12,27 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use crate::alpaca::AlpacaClient; use crate::{
use crate::paths::LIVE_EQUITY_FILE; alpaca::AlpacaClient,
use crate::types::EquitySnapshot; 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. /// Shared state for the dashboard.
pub struct DashboardState { pub struct DashboardState {
pub client: AlpacaClient, pub client: AlpacaClient,
pub init_data: DashboardInitData,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -48,6 +62,8 @@ struct PositionResponse {
unrealized_pnl: f64, unrealized_pnl: f64,
pnl_pct: f64, pnl_pct: f64,
change_today: f64, change_today: f64,
trail_status: String,
stop_loss_price: f64,
} }
#[derive(Serialize)] #[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">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">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">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>
</div>`; </div>`;
}).join(''); }).join('');
@@ -548,20 +566,63 @@ async fn api_positions(State(state): State<Arc<DashboardState>>) -> impl IntoRes
Ok(positions) => { Ok(positions) => {
let mut result: Vec<PositionResponse> = positions let mut result: Vec<PositionResponse> = positions
.iter() .iter()
.map(|p| PositionResponse { .map(|p| {
symbol: p.symbol.clone(), let entry_price = p.avg_entry_price.parse().unwrap_or(0.0);
qty: p.qty.parse().unwrap_or(0.0), let current_price = p.current_price.parse().unwrap_or(0.0);
market_value: p.market_value.parse().unwrap_or(0.0), let pnl_pct = if entry_price > 0.0 {
avg_entry_price: p.avg_entry_price.parse().unwrap_or(0.0), (current_price - entry_price) / entry_price
current_price: p.current_price.parse().unwrap_or(0.0), } else {
unrealized_pnl: p.unrealized_pl.parse().unwrap_or(0.0), 0.0
pnl_pct: p.unrealized_plpc.parse::<f64>().unwrap_or(0.0) * 100.0, };
change_today: p
.change_today let entry_atr = state.init_data.entry_atrs.get(&p.symbol).copied().unwrap_or(0.0);
.as_ref() let high_water_mark = state.init_data.high_water_marks.get(&p.symbol).copied().unwrap_or(entry_price);
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0) let activation_gain = if entry_atr > 0.0 {
* 100.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(); .collect();
@@ -614,8 +675,12 @@ async fn api_orders(State(state): State<Arc<DashboardState>>) -> impl IntoRespon
} }
/// Start the dashboard web server. /// Start the dashboard web server.
pub async fn start_dashboard(client: AlpacaClient, port: u16) -> anyhow::Result<()> { pub async fn start_dashboard(
let state = Arc::new(DashboardState { client }); client: AlpacaClient,
port: u16,
init_data: DashboardInitData,
) -> anyhow::Result<()> {
let state = Arc::new(DashboardState { client, init_data });
let app = Router::new() let app = Router::new()
.route("/", get(index)) .route("/", get(index))

View File

@@ -1,8 +1,7 @@
//! Technical indicator calculations. //! Technical indicator calculations.
use crate::config::{ use crate::config::{
IndicatorParams, ADX_RANGE_THRESHOLD, ADX_STRONG, ADX_TREND_THRESHOLD, BB_STD, IndicatorParams, ADX_TREND_THRESHOLD, BB_STD, VOLUME_THRESHOLD,
RSI2_OVERBOUGHT, RSI2_OVERSOLD, RSI_OVERBOUGHT, RSI_OVERSOLD, VOLUME_THRESHOLD,
}; };
use crate::types::{Bar, IndicatorRow, Signal, TradeSignal}; use crate::types::{Bar, IndicatorRow, Signal, TradeSignal};
@@ -423,25 +422,78 @@ pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec<I
rows rows
} }
/// Generate trading signal using regime-adaptive dual strategy. /// Determine the broad market regime from SPY indicator data.
/// ///
/// REGIME DETECTION (via ADX): /// This is the single most important risk filter in the system. During the
/// - ADX < 20: Range-bound → use Connors RSI-2 mean reversion /// 2020 COVID crash (SPY fell ~34% in 23 trading days) and the 2022 bear
/// - ADX > 25: Trending → use momentum pullback entries /// market (SPY fell ~25% over 9 months), SPY spent the majority of those
/// - 20-25: Transition → require extra confirmation /// 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): /// The three regimes map to position-sizing multipliers:
/// - Buy when RSI-2 < 10 AND price above 200 EMA (long-term uptrend filter) /// - Bull (SPY > EMA-200, EMA-50 > EMA-200): full size, normal thresholds
/// - Sell when RSI-2 > 90 (take profit at mean) /// - Caution (SPY < EMA-50, SPY > EMA-200): half size, raised thresholds
/// - Bollinger Band extremes add conviction /// - 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): /// This replaces the previous additive "indicator soup" approach. The academic
/// - Buy pullbacks in uptrends: RSI-14 dips + EMA support + MACD confirming /// evidence for momentum is robust (Jegadeesh & Titman 1993, Moskowitz et al.
/// - Sell when trend breaks: EMA crossover down + momentum loss /// 2012, Asness et al. 2013 "Value and Momentum Everywhere"). Rather than
/// - Strong trend bonus for high ADX /// 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 { pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &IndicatorRow) -> TradeSignal {
let rsi = current.rsi; let rsi = current.rsi;
let rsi2 = current.rsi_short;
let macd_hist = current.macd_histogram; let macd_hist = current.macd_histogram;
let momentum = current.momentum; let momentum = current.momentum;
let ema_short = current.ema_short; let ema_short = current.ema_short;
@@ -451,166 +503,134 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
// Safe NaN handling // Safe NaN handling
let trend_bullish = current.trend_bullish; let trend_bullish = current.trend_bullish;
let volume_ratio = if current.volume_ratio.is_nan() { 1.0 } else { current.volume_ratio }; 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_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 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 // EMA state
let ema_bullish = !ema_short.is_nan() && !ema_long.is_nan() && ema_short > ema_long; 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 has_momentum = !momentum.is_nan() && momentum > 0.0;
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 mut buy_score: f64 = 0.0; let mut buy_score: f64 = 0.0;
let mut sell_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 // GATE 1: Trend must be confirmed (price > EMA-trend AND EMA alignment)
if !rsi2.is_nan() { // Without this, no buy signal at all. This is the Faber (2007) filter
// Buy: RSI-2 extremely oversold + long-term trend intact // that alone produces positive risk-adjusted returns.
if rsi2 < RSI2_OVERSOLD { if trend_bullish && ema_bullish {
buy_score += 5.0; // Strong mean reversion signal // GATE 2: Positive time-series momentum (Moskowitz et al. 2012)
if trend_bullish { if has_momentum {
buy_score += 3.0; // With-trend mean reversion = highest conviction // Base score for being in a confirmed uptrend with positive momentum
} buy_score += 4.0;
if bb_pct < 0.05 {
buy_score += 2.0; // Price at/below lower BB // TIMING: RSI-14 pullback in uptrend (the "buy the dip" pattern)
} // Widened to 25-55: in strong uptrends RSI often stays 40-65,
} else if rsi2 < 20.0 { // so the old 30-50 window missed many good pullback entries.
buy_score += 2.5; if !rsi.is_nan() && rsi >= 25.0 && rsi <= 55.0 {
if trend_bullish { 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; buy_score += 1.5;
} }
} }
// Sell: RSI-2 overbought = take profit on mean reversion // Volume confirmation: above-average volume = institutional interest
if rsi2 > RSI2_OVERBOUGHT { if volume_ratio >= VOLUME_THRESHOLD {
sell_score += 4.0; buy_score += 0.5;
if !trend_bullish { } else {
sell_score += 2.0; // Low volume = less reliable, reduce score
} buy_score *= 0.7;
} else if rsi2 > 80.0 && !trend_bullish {
sell_score += 2.0;
} }
}
// Bollinger Band extremes in range // Strong momentum bonus (ROC > 10% = strong trend)
if bb_pct < 0.0 { if momentum > 10.0 {
buy_score += 2.0; // Below lower band buy_score += 1.0;
} 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;
} }
} }
} }
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// UNIVERSAL SIGNALS (both regimes) // SELL LOGIC: Exit when trend breaks or momentum reverses
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// RSI-14 extremes (strong conviction regardless of regime) // CRITICAL SELL: Trend break — price drops below EMA-trend
if !rsi.is_nan() { // This is the single most important exit signal. When the long-term
if rsi < RSI_OVERSOLD && trend_bullish { // trend breaks, the position has no structural support.
buy_score += 3.0; // Oversold in uptrend = strong buy if !trend_bullish {
} else if rsi > RSI_OVERBOUGHT && !trend_bullish { sell_score += 4.0;
sell_score += 3.0; // Overbought in downtrend = strong sell
}
}
// MACD crossover // If also EMA death cross, very strong sell
if macd_crossed_up { if !ema_bullish {
buy_score += 2.0; sell_score += 2.0;
if is_trending && trend_up {
buy_score += 1.0; // Trend-confirming crossover
} }
} else if macd_crossed_down {
sell_score += 2.0; // Momentum confirming the breakdown
if is_trending && !trend_up { if !momentum.is_nan() && momentum < -5.0 {
sell_score += 2.0;
} else if !momentum.is_nan() && momentum < 0.0 {
sell_score += 1.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 // Momentum has reversed significantly (still above EMA-trend though)
if !macd_hist.is_nan() { if !momentum.is_nan() && momentum < -10.0 {
if macd_hist > 0.0 { buy_score += 0.5; } sell_score += 3.0;
else if macd_hist < 0.0 { sell_score += 0.5; } } else if !momentum.is_nan() && momentum < -5.0 {
} sell_score += 1.5;
}
// Momentum // MACD crossed down = momentum decelerating
if !momentum.is_nan() { let macd_crossed_down = !previous.macd.is_nan()
if momentum > 5.0 { buy_score += 1.5; } && !previous.macd_signal.is_nan()
else if momentum > 2.0 { buy_score += 0.5; } && !current.macd.is_nan()
else if momentum < -5.0 { sell_score += 1.5; } && !current.macd_signal.is_nan()
else if momentum < -2.0 { sell_score += 0.5; } && previous.macd > previous.macd_signal
} && current.macd < current.macd_signal;
if macd_crossed_down {
sell_score += 2.0;
}
// EMA crossover events // RSI extremely overbought (>80) in deteriorating momentum
let prev_ema_bullish = !previous.ema_short.is_nan() if !rsi.is_nan() && rsi > 80.0 && !momentum.is_nan() && momentum < 5.0 {
&& !previous.ema_long.is_nan() sell_score += 1.5;
&& 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;
} }
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -620,7 +640,7 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
let signal = if total_score >= 7.0 { let signal = if total_score >= 7.0 {
Signal::StrongBuy Signal::StrongBuy
} else if total_score >= 4.5 { } else if total_score >= 4.0 {
Signal::Buy Signal::Buy
} else if total_score <= -7.0 { } else if total_score <= -7.0 {
Signal::StrongSell Signal::StrongSell
@@ -630,7 +650,9 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
Signal::Hold 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 { TradeSignal {
symbol: symbol.to_string(), symbol: symbol.to_string(),

View File

@@ -50,7 +50,8 @@ use crate::config::{Timeframe, DEFAULT_INITIAL_CAPITAL};
Backtest 6 months: invest-bot --backtest --months 6\n \ Backtest 6 months: invest-bot --backtest --months 6\n \
Backtest 1y 6m: invest-bot --backtest --years 1 --months 6\n \ Backtest 1y 6m: invest-bot --backtest --years 1 --months 6\n \
Custom capital: invest-bot --backtest --years 5 --capital 50000\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 { struct Args {
/// Run in backtest mode instead of live trading /// Run in backtest mode instead of live trading
@@ -65,6 +66,14 @@ struct Args {
#[arg(short, long, default_value_t = 0.0)] #[arg(short, long, default_value_t = 0.0)]
months: f64, 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 /// Initial capital for backtesting
#[arg(short, long, default_value_t = DEFAULT_INITIAL_CAPITAL)] #[arg(short, long, default_value_t = DEFAULT_INITIAL_CAPITAL)]
capital: f64, capital: f64,
@@ -171,14 +180,45 @@ async fn main() -> Result<()> {
} }
async fn run_backtest(api_key: String, api_secret: String, args: Args) -> 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) use chrono::NaiveDate;
let total_years = args.years + (args.months / 12.0);
let total_years = if total_years <= 0.0 { 1.0 } else { total_years };
let client = AlpacaClient::new(api_key, api_secret)?; let client = AlpacaClient::new(api_key, api_secret)?;
let mut backtester = Backtester::new(args.capital, args.timeframe); 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 results to CSV
save_backtest_results(&result)?; save_backtest_results(&result)?;
@@ -192,17 +232,27 @@ async fn run_live_trading(api_key: String, api_secret: String, args: Args) -> Re
.parse() .parse()
.unwrap_or(5000); .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 // Create a separate client for the dashboard
let dashboard_client = AlpacaClient::new(api_key.clone(), api_secret.clone())?; 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 // Spawn dashboard in background
tokio::spawn(async move { 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); tracing::error!("Dashboard error: {}", e);
} }
}); });
// Run the trading bot // Now run the bot's main loop
let mut bot = TradingBot::new(api_key, api_secret, args.timeframe).await?;
bot.run().await bot.run().await
} }

View File

@@ -51,10 +51,25 @@ lazy_static! {
path path
}; };
/// Path to the PDT day trades tracking file.
pub static ref LIVE_DAY_TRADES_FILE: PathBuf = {
let mut path = DATA_DIR.clone();
path.push("live_day_trades.json");
path
};
/// Path to the trading log file. /// Path to the trading log file.
pub static ref LOG_FILE: PathBuf = { pub static ref LOG_FILE: PathBuf = {
let mut path = DATA_DIR.clone(); let mut path = DATA_DIR.clone();
path.push("trading_bot.log"); path.push("trading_bot.log");
path 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
};
} }

View File

@@ -2,8 +2,13 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::config::{ use crate::config::{
get_sector, IndicatorParams, Timeframe, ATR_STOP_MULTIPLIER, get_sector, IndicatorParams, Timeframe, ATR_STOP_MULTIPLIER,
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, MAX_LOSS_PCT, MAX_POSITION_SIZE, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER,
MIN_ATR_PCT, RISK_PER_TRADE, STOP_LOSS_PCT, TIME_EXIT_BARS, 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, TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
}; };
use crate::types::{Signal, TradeSignal}; use crate::types::{Signal, TradeSignal};
@@ -26,24 +31,31 @@ 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( pub fn calculate_position_size(
&self, &self,
price: f64, price: f64,
portfolio_value: f64, portfolio_value: f64,
available_cash: f64, available_cash: f64,
signal: &TradeSignal, signal: &TradeSignal,
) -> u64 { ) -> f64 {
if available_cash <= 0.0 { if available_cash <= 0.0 {
return 0; return 0.0;
} }
let position_value = if signal.atr_pct > MIN_ATR_PCT { let position_value = if signal.atr_pct > MIN_ATR_PCT {
let atr_stop_pct = signal.atr_pct * ATR_STOP_MULTIPLIER; let atr_stop_pct = signal.atr_pct * ATR_STOP_MULTIPLIER;
let risk_amount = portfolio_value * RISK_PER_TRADE; let risk_amount = portfolio_value * RISK_PER_TRADE;
let vol_adjusted = risk_amount / atr_stop_pct; let vol_adjusted = risk_amount / atr_stop_pct;
// Scale by confidence // Wide confidence scaling: 0.4x for weak signals, 1.0x for strongest.
let confidence_scale = 0.7 + 0.3 * signal.confidence; // 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; let sized = vol_adjusted * confidence_scale;
sized.min(portfolio_value * MAX_POSITION_SIZE) sized.min(portfolio_value * MAX_POSITION_SIZE)
} else { } else {
@@ -51,10 +63,22 @@ impl Strategy {
}; };
let position_value = position_value.min(available_cash); let position_value = position_value.min(available_cash);
(position_value / price).floor() as u64 // 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. /// 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( pub fn check_stop_loss_take_profit(
&mut self, &mut self,
symbol: &str, symbol: &str,
@@ -76,47 +100,55 @@ impl Strategy {
} }
} }
// Hard max-loss cap // 1. Hard max-loss cap (catastrophic gap protection)
if pnl_pct <= -MAX_LOSS_PCT { if pnl_pct <= -MAX_LOSS_PCT {
return Some(Signal::StrongSell); return Some(Signal::StrongSell);
} }
// ATR-based stop loss // 2. ATR-based initial stop-loss (primary risk control)
if entry_atr > 0.0 { if entry_atr > 0.0 {
let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr; let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr;
if current_price <= atr_stop_price { if current_price <= atr_stop_price {
return Some(Signal::StrongSell); return Some(Signal::StrongSell);
} }
} else if pnl_pct <= -STOP_LOSS_PCT { } else if pnl_pct <= -STOP_LOSS_PCT {
// 3. Fixed percentage fallback
return Some(Signal::StrongSell); return Some(Signal::StrongSell);
} }
// Time-based exit // 4. Breakeven ratchet: once we've been in profit, cap downside to -1%
if bars_held >= TIME_EXIT_BARS { if pnl_pct <= -BREAKEVEN_MAX_LOSS_PCT {
let activation = if entry_atr > 0.0 { if let Some(&high_water) = self.high_water_marks.get(symbol) {
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price let best_pnl = (high_water - entry_price) / entry_price;
} else { if best_pnl >= BREAKEVEN_ACTIVATION_PCT {
TRAILING_STOP_ACTIVATION // Was in profit but now losing > 1% — get out
}; return Some(Signal::Sell);
if pnl_pct < activation { }
return Some(Signal::Sell);
} }
} }
// ATR-based trailing stop // 5. Tiered ATR trailing stop (profit protection)
let activation_gain = if entry_atr > 0.0 { // Tier 1: small gains (0.5x ATR) → tight trail (1.5x ATR)
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price // Tier 2: big gains (2.0x ATR) → wide trail (3.0x ATR) to let winners run
} else { if let Some(&high_water) = self.high_water_marks.get(symbol) {
TRAILING_STOP_ACTIVATION let best_pnl = (high_water - entry_price) / entry_price;
};
if pnl_pct >= activation_gain { let (activation_gain, trail_distance) = if entry_atr > 0.0 {
if let Some(&high_water) = self.high_water_marks.get(symbol) { let big_activation = (ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price;
let trail_distance = if entry_atr > 0.0 { let small_activation = (EARLY_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price;
ATR_TRAIL_MULTIPLIER * entry_atr
if best_pnl >= big_activation {
// Tier 2: big winner — wide trail
(big_activation, ATR_TRAIL_MULTIPLIER * entry_atr)
} else { } 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; let trailing_stop_price = high_water - trail_distance;
if current_price <= trailing_stop_price { if current_price <= trailing_stop_price {
return Some(Signal::Sell); return Some(Signal::Sell);
@@ -124,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 None
} }

View File

@@ -1,8 +1,40 @@
//! Data types and structures for the trading bot. //! Data types and structures for the trading bot.
use crate::config::ALLOW_LONGS_IN_BEAR_MARKET;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; 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. /// Trading signal types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -110,7 +142,7 @@ pub struct EquityPoint {
} }
/// OHLCV bar data. /// OHLCV bar data.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bar { pub struct Bar {
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
pub open: f64, pub open: f64,