Compare commits
16 Commits
c53fb1f7b5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eda716edad | |||
| 84461319a0 | |||
| 4476c04512 | |||
| 62847846d0 | |||
|
|
0e820852fa | ||
|
|
79816b9e2e | ||
|
|
edc655ca2c | ||
|
|
73cc7a3a66 | ||
|
|
798c3eafd5 | ||
|
|
1ef03999b7 | ||
|
|
80a8e7c346 | ||
|
|
223051f9d8 | ||
|
|
9fb9d171d4 | ||
|
|
7c94b0f422 | ||
|
|
9cca8b3db8 | ||
|
|
189694cc09 |
239
.claude/agent-memory/consistency-auditor/MEMORY.md
Normal file
239
.claude/agent-memory/consistency-auditor/MEMORY.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Consistency Auditor Memory
|
||||
|
||||
## Last Audit: 2026-02-13 (Post-Config Update v2 - NEW FINDINGS)
|
||||
|
||||
### AUDIT RESULT: ⚠️ 1 CRITICAL DIVERGENCE + 1 MEDIUM BUG
|
||||
|
||||
**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`
|
||||
|
||||
---
|
||||
|
||||
## 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 ✅
|
||||
- **Signal generation**: Both use shared `indicators::generate_signal()` (bot:876; bt:758,818)
|
||||
- **Position sizing**: Both use shared `Strategy::calculate_position_size()` (bot:537-542; bt:282-284)
|
||||
- Volatility-adjusted via ATR
|
||||
- Confidence scaling: 0.4 + 0.6 * confidence (changed from 0.7 + 0.3)
|
||||
- Max position size cap: 20% (was 25%)
|
||||
- Cash reserve: 5%
|
||||
- **Stop-loss/trailing/time exit**: Both use shared `Strategy::check_stop_loss_take_profit()` (bot:547-559; bt:462-468)
|
||||
- Hard max loss cap: 8% (was 5%)
|
||||
- ATR-based stop: 3.5x ATR below entry (was 3.0x)
|
||||
- Fixed fallback stop: 2.5%
|
||||
- Trailing stop: 3.0x ATR after 2.0x ATR gain (was 2.0x trail, 2.0x activation)
|
||||
- Time exit: 80 bars if below trailing activation (was 40)
|
||||
|
||||
### Portfolio Controls ✅
|
||||
- **Cooldown timers**: Both implement 10-bar cooldown after stop-loss (bot:736-746; bt:383-388) [was 5]
|
||||
- **Ramp-up period**: Both limit to 1 new position per cycle/bar for first 15 bars (bot:618-626; bt:277-279)
|
||||
- **Drawdown circuit breaker**: Both trigger at 12%/18%/25% with 15/40/60-bar cooldowns (bot:368-408; bt:106-163)
|
||||
- **Peak reset on expiry**: Both reset peak to current value (bot:442; bt:197) ✅ (FIXED since last audit)
|
||||
- Tier 3 (25%+) requires bull regime to resume (was 15%/20%/25% → 10/30/50)
|
||||
- **Sector limits**: Both enforce max 2 per sector (bot:608-614; bt:268-274)
|
||||
- **Max concurrent positions**: Both enforce max 8 (bot:599-606; bt:263-265) [was 7]
|
||||
- **Momentum ranking**: Both filter to top 15 momentum stocks (bot:965-985; bt:718-729) [was 20]
|
||||
- **bars_held increment**: Both increment at START of trading cycle/bar (bot:909-912; bt:713-716)
|
||||
|
||||
### Config Constants — ALL CONSISTENT ✅
|
||||
Both files import and use identical values from config.rs:
|
||||
- `ATR_STOP_MULTIPLIER`: 3.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 ✅
|
||||
**Hourly mode**: `max(245 MACD, 15 RSI, 350 EMA, 28 ADX, 140 BB, 441 momentum) + 5 = 446 bars`
|
||||
|
||||
Calculation in `config.rs:239-252` (`IndicatorParams::min_bars()`)
|
||||
- Momentum period dominates warmup (441 bars = 63 * 7)
|
||||
- MACD needs slow + signal (182 + 63 = 245)
|
||||
- EMA trend: 350 (50 * 7)
|
||||
- ADX needs 2x period for smoothing (14 * 2 = 28)
|
||||
- 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:
|
||||
- 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) ✅
|
||||
|
||||
### 1. Slippage Modeling
|
||||
- **Backtester**: Applies 10 bps on both entry and exit (backtester.rs:86-93)
|
||||
- **Live bot**: Uses actual fill prices from Alpaca API (bot.rs:644-648)
|
||||
- **Verdict**: Expected difference. Backtester simulates realistic costs; live bot gets market fills.
|
||||
|
||||
### 2. PDT Protection Strategy
|
||||
- **bot.rs**: Blocks non-stop-loss sells if would trigger PDT (lines 693-705)
|
||||
- **backtester.rs**: Blocks entries in last 2 hours of hourly day (lines 238-244)
|
||||
- **Verdict**: Two different approaches to PDT prevention. Daily mode prevents day trades by construction (phase separation makes same-bar buy+sell impossible). Hourly mode uses different strategies but both achieve PDT compliance.
|
||||
|
||||
---
|
||||
|
||||
## STRATEGY ARCHITECTURE (2026-02-13)
|
||||
|
||||
### Regime-Adaptive with Bear Market Protection
|
||||
The strategy uses **ADX for regime detection** and **SPY for market filter**:
|
||||
|
||||
#### SPY MARKET REGIME FILTER (Primary Risk Gate)
|
||||
- **Bull** (SPY > EMA-200, EMA-50 > EMA-200): full size, normal thresholds
|
||||
- **Caution** (SPY < EMA-50, SPY > EMA-200): 25% size, +3.0 threshold bump
|
||||
- **Bear** (SPY < EMA-200, EMA-50 < EMA-200): NO new longs at all
|
||||
|
||||
#### ADX REGIME (Signal Type Selection)
|
||||
- **ADX < 20**: Range-bound → mean reversion signals preferred
|
||||
- **ADX > 25**: Trending → momentum pullback signals preferred
|
||||
|
||||
### Hierarchical Signal Generation (indicators.rs)
|
||||
**NOT additive "indicator soup"** — uses gated filters:
|
||||
|
||||
**LAYER 1 (GATE)**: Trend confirmation required
|
||||
- Price > EMA-trend
|
||||
- EMA-short > EMA-long
|
||||
Without both, no buy signal generated.
|
||||
|
||||
**LAYER 2 (ENTRY)**: Momentum + pullback timing
|
||||
- Positive momentum (ROC > 0)
|
||||
- RSI-14 pullback (25-55 range, widened from 30-50)
|
||||
|
||||
**LAYER 3 (CONVICTION)**: Supplementary confirmation
|
||||
- MACD histogram positive
|
||||
- ADX > 25 with DI+ > DI-
|
||||
- Volume above average
|
||||
|
||||
### Signal Thresholds
|
||||
- **StrongBuy**: total_score >= 7.0
|
||||
- **Buy**: total_score >= 4.0
|
||||
- **StrongSell**: total_score <= -7.0
|
||||
- **Sell**: total_score <= -4.0
|
||||
- **Hold**: everything else
|
||||
|
||||
Confidence: `(total_score.abs() / 10.0).min(1.0)` (changed from /12.0)
|
||||
|
||||
---
|
||||
|
||||
## KEY LESSONS
|
||||
|
||||
### 1. Equity Curve SMA Creates Pathological Feedback Loop
|
||||
Without removing it from the bot, losing positions drag equity below SMA, blocking ALL new entries, which prevents recovery. This is a self-reinforcing trap. The backtester correctly removed it; the bot must follow.
|
||||
|
||||
### 2. Shared Logic Eliminates Most Drift
|
||||
Extracting common logic into `strategy.rs` and `indicators.rs` ensures bot and backtester CANNOT diverge on core trading decisions. Almost all consistency issues now are portfolio-level controls, not signal logic.
|
||||
|
||||
### 3. Config Constants Propagation Works Well
|
||||
Using `config.rs` constants throughout prevents hardcoded values. All recent parameter changes (7x hourly scaling, 0.25 Caution sizing, tiered drawdowns) were automatically consistent because both files import the same constants.
|
||||
|
||||
### 4. Warmup Must Account for Longest Indicator Chain
|
||||
For hourly mode with 7x scaling, momentum_period=441 dominates warmup. The `+ 5` safety margin in `min_bars()` is critical for EMA initialization edge cases.
|
||||
|
||||
### 5. Math Errors Can Be Consistently Wrong
|
||||
The confidence scaling bug (using 12.0 instead of 10.0) is in both files, so they produce the same wrong behavior. This makes it non-critical for consistency but still a bug to fix.
|
||||
|
||||
### 6. 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).
|
||||
|
||||
---
|
||||
|
||||
## AUDIT CHECKLIST (For Future Audits)
|
||||
|
||||
When new changes are made, verify:
|
||||
|
||||
1. **Signal generation**: Still using shared `indicators::generate_signal()`?
|
||||
2. **Position sizing**: Still using shared `Strategy::calculate_position_size()`?
|
||||
3. **Risk management**: Still using shared `Strategy::check_stop_loss_take_profit()`?
|
||||
4. **Equity curve stop**: Check if REMOVED in both files (don't re-add to backtester!)
|
||||
5. **Cooldown timers**: Identical logic in both files?
|
||||
6. **Ramp-up period**: Identical logic in both files?
|
||||
7. **Drawdown halt**: Identical trigger logic? Peak reset on expiry?
|
||||
8. **Sector limits**: Same `MAX_SECTOR_POSITIONS` constant?
|
||||
9. **Max positions**: Same `MAX_CONCURRENT_POSITIONS` constant?
|
||||
10. **Momentum ranking**: Same `TOP_MOMENTUM_COUNT` constant?
|
||||
11. **bars_held increment**: Both increment at START of cycle/bar?
|
||||
12. **Warmup calculation**: Does `min_bars()` cover all indicators?
|
||||
13. **NaN handling**: Safe defaults for all indicator checks?
|
||||
14. **ATR guards**: Checks for `> 0.0` before division?
|
||||
15. **Config propagation**: Are new constants used consistently?
|
||||
16. **Math in regime logic**: Confidence scaling uses correct multiplier (10.0 not 12.0)?
|
||||
|
||||
---
|
||||
|
||||
## FILES LOCATIONS
|
||||
|
||||
- `/home/work/Documents/rust/invest-bot/src/bot.rs` — Live trading loop (1119 lines)
|
||||
- `/home/work/Documents/rust/invest-bot/src/backtester.rs` — Historical simulation (1217 lines)
|
||||
- `/home/work/Documents/rust/invest-bot/src/strategy.rs` — Shared position sizing and risk management (163 lines)
|
||||
- `/home/work/Documents/rust/invest-bot/src/indicators.rs` — Shared signal generation (673 lines)
|
||||
- `/home/work/Documents/rust/invest-bot/src/config.rs` — All strategy parameters (268 lines)
|
||||
- `/home/work/Documents/rust/invest-bot/src/types.rs` — Data structures (262 lines)
|
||||
|
||||
**Total**: 3,702 lines audited (2026-02-13 v2)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
63
.claude/agent-memory/quant-rust-strategist/MEMORY.md
Normal file
63
.claude/agent-memory/quant-rust-strategist/MEMORY.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Quant-Rust-Strategist Memory
|
||||
|
||||
## Architecture Overview
|
||||
- ~100-symbol universe across 14 sectors
|
||||
- strategy.rs: shared logic between bot.rs and backtester.rs
|
||||
- Backtester restricts buys to top momentum stocks (TOP_MOMENTUM_COUNT)
|
||||
- SPY regime filter (EMA-50/200) gates new longs: Bull/Caution/Bear
|
||||
- Signal thresholds: StrongBuy>=7.0, Buy>=4.0, Sell<=-4.0, StrongSell<=-7.0
|
||||
|
||||
## 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
|
||||
|
||||
## Stop/Exit Logic (2026-02-13 FIX)
|
||||
- Time exit ONLY sells losers (pnl_pct < 0). Old code force-sold winners.
|
||||
- Trail activation: 1.5x ATR (was 2.0x), trail distance: 2.5x ATR (was 2.0x)
|
||||
- Max loss: 8% (was 5%), TIME_EXIT_BARS: 60 (was 40)
|
||||
|
||||
## Equity Curve SMA Stop: REMOVED from backtester
|
||||
- Created pathological feedback loop with drawdown breaker
|
||||
|
||||
## Position Sizing (2026-02-13 FIX)
|
||||
- Confidence scaling: 0.4 + 0.6*conf (was 0.7 + 0.3*conf)
|
||||
- RISK_PER_TRADE: 1.0%, MAX_POSITIONS: 10, TOP_MOMENTUM: 10
|
||||
|
||||
## Current Parameters (config.rs, updated 2026-02-13)
|
||||
- ATR Stop: 3.0x | Trail: 2.5x distance, 1.5x activation
|
||||
- Risk: 1.0%/trade, max 25% position, 5% cash reserve, 8% max loss
|
||||
- Max 10 positions, 2/sector | Time exit: 60 bars (losers only)
|
||||
- Cooldown: 5 bars | Ramp-up: 15 bars | Slippage: 10bps
|
||||
- Momentum pool: top 10 (decile)
|
||||
|
||||
## 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
|
||||
- `cargo build --release` compiles clean (only dead_code warnings)
|
||||
- No tests exist
|
||||
@@ -0,0 +1,51 @@
|
||||
# 1-Month Hourly Backtest Analysis - 2026-02-11
|
||||
|
||||
## Summary Results
|
||||
- Return: +0.61% ($611.10 on $100k) | CAGR: 7.58%
|
||||
- Sharpe: -0.15 (reported), annualized from hourly: 0.26
|
||||
- Max Drawdown: -7.52% | Sortino: negative
|
||||
- 75 trades: 33W/42L (44% win rate)
|
||||
- Avg win: $548.85 vs Avg loss: -$416.69 (1.32:1 ratio -- good)
|
||||
- Profit factor: 1.03
|
||||
|
||||
## Problem #1: Whipsaw/Churning (Biggest PnL Drain)
|
||||
No cooldown mechanism exists. Phase 1 sells, Phase 2 rebuys same bar.
|
||||
- 12 identified whipsaw events: -$7,128
|
||||
- 16 same-day roundtrips: 0% win rate, -$7,966
|
||||
- 62 sell-then-rebuy events; 84% rebought within 1% of sell price
|
||||
- 55 same-day sell-rebuy events
|
||||
|
||||
### Worst offenders:
|
||||
- MU: 17 round-trips, 10 losses, 7 wins. Net +$2,062 vs +14% simple hold
|
||||
- ASML: 9 trades, 6 losses. Net +$271 vs +7.9% simple hold
|
||||
- LLY: 7 trades, 5 losses. Net -$1,450 (LLY was in downtrend)
|
||||
|
||||
## Problem #2: ATR Stop Too Tight (1.5x)
|
||||
23 trades exited >2% loss = $12,882 total loss. Many immediately re-entered.
|
||||
For hourly bars, 1.5x ATR is roughly 1-sigma noise. Getting stopped on noise.
|
||||
|
||||
## Problem #3: Excessive Turnover
|
||||
- Total capital deployed: $1.21M in 22 trading days = 12.1x monthly turnover
|
||||
- Annualized: 145x. Slippage: $2,420 (2.4% of capital).
|
||||
- Even at 10 bps, this is destructive. Real-world slippage may be higher.
|
||||
|
||||
## Problem #4: Overnight Gap Exposure
|
||||
- Two gap-downs >1%: total -$4,360
|
||||
- No overnight risk management (positions held through weekend gaps)
|
||||
|
||||
## Holding Period vs Win Rate
|
||||
- 0-2h: 6 trades, 0% win rate, -$3,318
|
||||
- 2-7h: 10 trades, 0% win rate, -$4,648
|
||||
- 14-35h: 19 trades, 32% win rate, -$2,762
|
||||
- 35-70h: 11 trades, 46% win rate, -$585
|
||||
- 70h+: 29 trades, 76% win rate, +$11,923
|
||||
|
||||
## Sector Concentration
|
||||
- Healthcare: 22 trades, 41% WR, -$2,985 (LLY and ISRG biggest losers)
|
||||
- Semis: 26 trades, 39% WR, +$2,333 (MU volume, but churning drag)
|
||||
- Industrials: 10 trades, 60% WR, +$984 (CAT 100% WR)
|
||||
|
||||
## Position Count vs Returns
|
||||
- 5 positions: -4.89% total (worst)
|
||||
- 7-8 positions: +2.4% and +1.8% (better)
|
||||
- Being invested more fully correlated with better returns in uptrending market
|
||||
@@ -0,0 +1,115 @@
|
||||
# Hourly Backtest Catastrophic Failure Analysis (2026-02-11)
|
||||
|
||||
## Results Summary
|
||||
- Period: 3 months hourly (Nov 12, 2025 - Feb 11, 2026)
|
||||
- Total Return: -11.44% ($88,563 from $100k)
|
||||
- CAGR: -38.48%, Sharpe: -2.53, Sortino: -0.38
|
||||
- Win Rate: 10% (2/20), Profit Factor: 0.08
|
||||
- All 20 trades occurred in first 8 trading days, then system went to 100% cash permanently
|
||||
|
||||
## Trade-by-Trade Analysis
|
||||
All trades between Nov 12-21 (8 trading days, ~56 hourly bars):
|
||||
|
||||
### Buys
|
||||
1. Nov 12 17:00 - MU @$245.29 (89 shares = $21.8k)
|
||||
2. Nov 12 17:00 - AMD @$255.39 (86 shares = $21.9k)
|
||||
3. Nov 12 20:00 - GOOGL @$287.08 (76 shares = $21.8k)
|
||||
4. Nov 12 20:00 - CAT @$573.69 (38 shares = $21.8k)
|
||||
5. Nov 12 20:00 - SNOW @$269.80 (28 shares = $7.6k) -- total 5 positions, 95% invested
|
||||
|
||||
### Sell Cascade (Nov 13-21)
|
||||
Nov 13: GOOGL -$636 (-2.9%), MU -$694 (-3.2%), SNOW -$226 (-3.0%), CAT -$744 (-3.4%)
|
||||
Nov 13: Rebuy ASML, AAPL
|
||||
Nov 14: ASML -$705 (-3.4%), AMD -$1,352 (-6.2%!!) -- exceeded 4% max loss cap
|
||||
Nov 14: Rebuy MU, GOOGL, CAT, SNOW
|
||||
Nov 17: AAPL -$440 (-2.1%), MU -$699 (-3.3%)
|
||||
Nov 17: Rebuy AMD, GOOGL, ASML
|
||||
Nov 18: AMD -$1,582 (-7.6%!!), CAT -$493 (-2.4%), GOOGL -$426 (-2.0%)
|
||||
Nov 18: Rebuy AAPL, MU
|
||||
Nov 19: MU -$941 (-4.6%), SNOW -$39 (-0.6%)
|
||||
Nov 19: Rebuy AMD, ISRG, SNOW
|
||||
Nov 20: AMD -$1,170 (-5.8%!), ISRG -$385 (-1.9%), SNOW -$164 (-2.6%), ASML -$442 (-2.2%)
|
||||
Nov 20: Rebuy MU
|
||||
Nov 21: MU -$1,274 (-6.6%!!), AAPL +$275 (+1.4%) -- ONLY WIN
|
||||
|
||||
### Key Observations
|
||||
1. AMD losses: -$1,352, -$1,582, -$1,170 = -$4,104 total (36% of all losses). ALL exceeded the 4% max loss cap.
|
||||
2. MU losses: -$694, -$699, -$941, -$1,274 = -$3,608 (32% of all losses).
|
||||
3. After Nov 21: drawdown hit 11.65%, exceeding 10% halt. System went to 100% cash PERMANENTLY.
|
||||
4. Equity curve shows 0 positions from Nov 21 through Feb 11 (461 bars of nothing).
|
||||
5. Only 2 wins out of 20: GOOGL +$701, AAPL +$275. Total wins = $976.
|
||||
|
||||
## Root Cause #1: Absurd Indicator Period Scaling (FUNDAMENTAL)
|
||||
|
||||
The 7x multiplier creates these hourly indicator periods:
|
||||
- RSI: 14 * 7 = 98 bars (14 trading days)
|
||||
- MACD fast: 12 * 7 = 84 bars
|
||||
- MACD slow: 26 * 7 = 182 bars (26 trading days)
|
||||
- MACD signal: 9 * 7 = 63 bars
|
||||
- EMA short: 9 * 7 = 63 bars
|
||||
- EMA long: 21 * 7 = 147 bars
|
||||
- EMA trend: 50 * 7 = 350 bars (50 trading days)
|
||||
- ADX: 14 * 7 = 98 bars
|
||||
- Bollinger: 20 * 7 = 140 bars
|
||||
- Volume MA: 20 * 7 = 140 bars
|
||||
- Momentum (ROC): 63 * 7 = 441 bars (63 trading days)
|
||||
|
||||
min_bars() = max(182+63, 98+1, 350, 98*2, 140, 441) + 5 = 446 bars
|
||||
|
||||
This means the system needs ~64 trading days (446/7) of WARMUP before it can even produce a valid signal. For a 3-month backtest, that eats most of the data. The indicators that DO produce values are extremely slow-moving and unresponsive to hourly price action.
|
||||
|
||||
### Why 7x is Wrong
|
||||
The daily parameters (RSI-14, MACD 12/26/9) are designed for daily price action noise. On hourly bars, there are 6.5-7 bars per day, so naively you'd think 7x preserves the "look-back in days." But this ignores that:
|
||||
1. Hourly bars have fundamentally different noise characteristics (mean-reverting intraday patterns)
|
||||
2. A 98-period RSI on hourly bars is insanely slow -- it would take a massive multi-week move to push RSI to oversold/overbought
|
||||
3. MACD with 182-period slow EMA cannot detect hourly momentum shifts
|
||||
4. The strategy's edge is supposed to be momentum + pullback detection. A 441-bar momentum period on hourly data measures ~3-month trends, not tactical momentum.
|
||||
|
||||
## Root Cause #2: Drawdown Halt is Terminal
|
||||
|
||||
MAX_DRAWDOWN_HALT = 10%, MAX_DRAWDOWN_RESUME = 5%.
|
||||
|
||||
Once drawdown exceeds 10%, the system stops buying. It only resumes when drawdown recovers to 5%. But with 0 positions, there's no way to recover! The portfolio sits in cash forever.
|
||||
|
||||
This is a logical impossibility: you can't recover from drawdown without taking new positions, but the system won't take new positions until drawdown recovers.
|
||||
|
||||
## Root Cause #3: Max Loss Cap Not Working
|
||||
|
||||
AMD lost -6.2%, -7.6%, -5.8% in three trades despite MAX_LOSS_PCT = 4%.
|
||||
MU lost -6.6% in one trade.
|
||||
|
||||
The check_stop_loss_take_profit function checks `pnl_pct <= -MAX_LOSS_PCT` but this only triggers on the NEXT bar after the loss occurs. If a stock gaps down or moves 6% in one hourly bar, the 4% cap is breached before the check runs. On hourly timeframe, overnight gaps can easily exceed 4%.
|
||||
|
||||
## Root Cause #4: All Indicators Fire Simultaneously at Warmup Edge
|
||||
|
||||
When indicators first become valid (right after the warmup period), all the EMA crossovers, MACD crossovers, etc. fire at once. This creates a burst of buy signals, putting 95% of capital at risk immediately. The system went from 0 to 5 positions in 3 hours on Nov 12.
|
||||
|
||||
## Proposed Fixes
|
||||
|
||||
### Fix 1: Proper Hourly Indicator Periods (NOT 7x scaling)
|
||||
Use empirically appropriate periods for hourly timeframe:
|
||||
- RSI: 14 (same as daily -- 14 hourly bars captures 2 trading days of momentum)
|
||||
- MACD: 12/26/9 (same as daily -- these already work on any timeframe)
|
||||
- EMA short: 20, EMA long: 50, EMA trend: 100 (~14 trading days)
|
||||
- ADX: 14
|
||||
- Bollinger: 20
|
||||
- Volume MA: 20
|
||||
- ATR: 14
|
||||
- Momentum ROC: 63 (same as daily -- 63 hourly bars = ~9 trading days)
|
||||
|
||||
Rationale: Most technical indicators were designed for BARS, not calendar time. RSI-14 means "14 bars of price action" regardless of timeframe. The 7x scaling was conceptually wrong -- it assumed indicators need calendar-day equivalence, but they need bar-count equivalence.
|
||||
|
||||
### Fix 2: Remove or Redesign Drawdown Halt for Backtesting
|
||||
Options:
|
||||
a) Remove drawdown halt entirely in backtesting (it's more appropriate for live trading where you want manual review)
|
||||
b) Make drawdown halt time-based: halt for N bars, then auto-resume
|
||||
c) Change to a gradual reduction: reduce position size by 50% instead of going to 0
|
||||
|
||||
### Fix 3: Limit Initial Deployment Speed
|
||||
Don't go from 0 to 5 positions in one bar. Add a "ramp-up" period where max positions increases gradually (e.g., 1 per day for first week).
|
||||
|
||||
### Fix 4: Tighter Max Loss Cap with Intra-Bar Checks
|
||||
For hourly mode, use tighter max loss (3%) or check at open price vs entry to catch gap losses earlier.
|
||||
|
||||
### Fix 5: Use SIP Feed Instead of IEX
|
||||
Change `feed=iex` to `feed=sip` for consolidated market data. IEX-only volume is unreliable for volume-based signals.
|
||||
99
.claude/agents/consistency-auditor.md
Normal file
99
.claude/agents/consistency-auditor.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: consistency-auditor
|
||||
description: "Use this agent when you want to verify that the live trading (bot.rs) and backtesting (backtester.rs) code paths are consistent and produce equivalent behavior. Also use it to find subtle bugs in warmup periods, indicator initialization, off-by-one errors, and edge cases in signal generation and risk management logic.\\n\\nExamples:\\n\\n- user: \"I just changed the trailing stop logic in bot.rs, can you check if backtester matches?\"\\n assistant: \"Let me use the consistency-auditor agent to compare the trailing stop implementations across bot.rs and backtester.rs.\"\\n\\n- user: \"I'm getting different results between live and backtest for the same period\"\\n assistant: \"I'll launch the consistency-auditor agent to find discrepancies between the live and backtest code paths that could explain the divergence.\"\\n\\n- user: \"I updated the indicator warmup in indicators.rs\"\\n assistant: \"Let me use the consistency-auditor agent to verify the warmup logic is correct and consistently applied across all indicator calculations and both execution modes.\"\\n\\n- user: \"Can you review the changes I just made to the signal scoring?\"\\n assistant: \"I'll use the consistency-auditor agent to audit the signal scoring changes for consistency between live and backtest, and check for any edge cases in the new logic.\""
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are a senior systems developer with 20+ years of experience in algorithmic trading systems, specializing in finding subtle inconsistencies between parallel code paths. You have an obsessive eye for detail — the kind of developer who finds the off-by-one error everyone else missed, the warmup period that's 1 bar short, or the condition that's >= in one place and > in another.
|
||||
|
||||
## Your Primary Mission
|
||||
|
||||
Audit the Rust algorithmic trading bot codebase, focusing on:
|
||||
|
||||
1. **Live/Backtest Consistency**: Compare `bot.rs` and `backtester.rs` line-by-line for any behavioral differences. These two files MUST implement identical trading logic — same signal thresholds, same position sizing, same stop-loss/take-profit/trailing-stop behavior, same partial exit logic, same sector limits, same drawdown circuit breaker. Any divergence is a bug unless explicitly justified.
|
||||
|
||||
2. **Warmup & Indicator Initialization**: Examine `indicators.rs` and how both bot.rs and backtester.rs handle the warmup period. Look for:
|
||||
- Indicators being calculated before sufficient data exists (e.g., EMA needs N bars, RSI needs N+1, MACD needs slow_period + signal_period bars)
|
||||
- Off-by-one errors in warmup length calculations
|
||||
- Hourly mode scaling (7x) being applied inconsistently to warmup periods
|
||||
- generate_signal() being called too early or with insufficient data
|
||||
- ATR calculations during warmup producing zero/NaN/infinity values that propagate
|
||||
|
||||
3. **Subtle Logic Bugs**: Hunt for:
|
||||
- Floating point comparison issues (== on f64)
|
||||
- Integer overflow or truncation in period calculations
|
||||
- Edge cases when positions list is empty
|
||||
- Race conditions or state inconsistencies in the live trading loop
|
||||
- Config constants used in one path but hardcoded in another
|
||||
- Slippage applied in backtester but not accounted for in live expectations
|
||||
- Bar indexing differences (0-indexed vs 1-indexed)
|
||||
- Boundary conditions at market open/close
|
||||
- Vec indexing that could panic on insufficient data
|
||||
|
||||
## Methodology
|
||||
|
||||
1. **Read both files thoroughly** before making any claims. Use file reading tools to examine the actual code — never guess or assume.
|
||||
2. **Create a mental diff** of every trading logic block between bot.rs and backtester.rs. Compare:
|
||||
- Entry conditions and signal thresholds
|
||||
- Position sizing formulas
|
||||
- Stop-loss calculation (ATR-based and fixed fallback)
|
||||
- Trailing stop activation and distance
|
||||
- Take-profit levels
|
||||
- Partial exit logic
|
||||
- Time-based exit (stale position) thresholds
|
||||
- Sector limit enforcement
|
||||
- Max position count enforcement
|
||||
- Drawdown circuit breaker thresholds
|
||||
- Cash reserve handling
|
||||
3. **Trace data flow** from raw bars through indicator calculation to signal generation. Count exact warmup requirements for each indicator chain.
|
||||
4. **Check config.rs usage** — verify both files reference the same constants, and that hourly scaling is applied identically.
|
||||
5. **Report findings with precision**: file name, line numbers or function names, exact code snippets showing the inconsistency, and the potential impact.
|
||||
|
||||
## Output Format
|
||||
|
||||
For each finding, report:
|
||||
- **Category**: Consistency | Warmup | Logic Bug | Edge Case
|
||||
- **Severity**: Critical (wrong trades) | Medium (suboptimal behavior) | Low (cosmetic/theoretical)
|
||||
- **Location**: Exact file(s) and function(s)
|
||||
- **Description**: What the issue is, with code references
|
||||
- **Impact**: What could go wrong in practice
|
||||
- **Suggested Fix**: Concrete code change
|
||||
|
||||
Start with critical issues, then medium, then low. If you find NO issues in a category, explicitly state that — don't skip silently.
|
||||
|
||||
## Important Rules
|
||||
|
||||
- Never assume code is correct just because it compiles. Clippy-clean code can still have logic bugs.
|
||||
- When comparing bot.rs and backtester.rs, even tiny differences matter: a `>=` vs `>`, a `+ 1` present in one but not the other, a config constant vs a hardcoded value.
|
||||
- For warmup issues, calculate the EXACT number of bars needed for the longest indicator chain (e.g., MACD with signal smoothing on top of EMA). Compare this to what the code actually checks.
|
||||
- Remember that hourly mode multiplies periods by 7, which means warmup requirements also scale by 7x. Verify this is handled.
|
||||
- The backtester applies 10 bps slippage — check this is done correctly on both entry and exit.
|
||||
- ATR values of 0 or very small numbers can cause division-by-zero or absurdly large position sizes — check for guards.
|
||||
|
||||
**Update your agent memory** as you discover inconsistencies, confirmed-consistent sections, warmup requirements, and bug patterns. This builds institutional knowledge across audits. Write concise notes about what you found and where.
|
||||
|
||||
Examples of what to record:
|
||||
- Confirmed consistent: trailing stop logic matches between bot.rs:L200 and backtester.rs:L180
|
||||
- Found inconsistency: partial exit threshold differs (bot uses 1.5x ATR, backtester uses 12% fixed)
|
||||
- Warmup requirement: MACD needs slow_period(26) + signal_period(9) = 35 bars minimum (245 in hourly mode)
|
||||
- Bug pattern: ATR fallback to 0.0 when insufficient bars, no guard before division
|
||||
|
||||
# Persistent Agent Memory
|
||||
|
||||
You have a persistent Persistent Agent Memory directory at `/home/work/Documents/rust/invest-bot/.claude/agent-memory/consistency-auditor/`. Its contents persist across conversations.
|
||||
|
||||
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
|
||||
|
||||
Guidelines:
|
||||
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
|
||||
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
|
||||
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
|
||||
- Update or remove memories that turn out to be wrong or outdated
|
||||
- Organize memory semantically by topic, not chronologically
|
||||
- Use the Write and Edit tools to update your memory files
|
||||
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
|
||||
|
||||
## MEMORY.md
|
||||
|
||||
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.
|
||||
97
.claude/agents/quant-rust-strategist.md
Normal file
97
.claude/agents/quant-rust-strategist.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: quant-rust-strategist
|
||||
description: "Use this agent when the user needs to design, implement, refine, or review algorithmic trading strategies, risk management logic, technical indicators, or any Rust code related to the investment bot. This includes optimizing signal generation, backtesting parameters, position sizing, stop-loss/take-profit logic, and portfolio construction. Also use when the user wants to discuss or evaluate trading methodologies grounded in empirical evidence.\\n\\nExamples:\\n\\n- User: \"The current strategy isn't performing well in sideways markets, can you improve it?\"\\n Assistant: \"Let me use the quant-rust-strategist agent to analyze the current strategy and propose evidence-based improvements for range-bound market conditions.\"\\n\\n- User: \"Add a volatility filter to avoid trading during low-volume periods\"\\n Assistant: \"I'll launch the quant-rust-strategist agent to implement a volatility filter using proven methods like ATR-based regime detection.\"\\n\\n- User: \"Review the signal scoring in generate_signal()\"\\n Assistant: \"Let me use the quant-rust-strategist agent to review the signal scoring logic for correctness, edge cases, and alignment with quantitative best practices.\"\\n\\n- User: \"I want to add a new indicator to the strategy\"\\n Assistant: \"I'll use the quant-rust-strategist agent to evaluate which indicator would add orthogonal signal value and implement it in idiomatic Rust.\"\\n\\n- User: \"Optimize the backtester performance, it's too slow\"\\n Assistant: \"Let me launch the quant-rust-strategist agent to profile and optimize the backtester with high-performance Rust patterns.\""
|
||||
model: opus
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are an elite quantitative developer and senior Rust engineer with 20+ years of experience in both systematic trading and Rust systems programming. You have worked at top quantitative hedge funds (Renaissance Technologies, Two Sigma, DE Shaw caliber) and have deep expertise in building production trading systems. Your background spans statistical arbitrage, momentum strategies, mean-reversion, risk management, and portfolio optimization.
|
||||
|
||||
## Core Principles
|
||||
|
||||
**Evidence-Based Only**: You NEVER recommend speculative or unproven strategies. Every method you suggest must be backed by peer-reviewed research, extensive backtesting evidence, or established quantitative finance theory. When proposing changes, cite the underlying principle (e.g., Jegadeesh & Titman momentum studies, Fama-French factors, mean-reversion literature).
|
||||
|
||||
**Profit Maximization with Risk Control**: You treat risk management as non-negotiable. You think in terms of risk-adjusted returns (Sharpe ratio, Sortino ratio, maximum drawdown) rather than raw returns. A strategy that returns 15% with a Sharpe of 1.8 is superior to one returning 25% with a Sharpe of 0.7.
|
||||
|
||||
**Rust Excellence**: You write idiomatic, zero-cost-abstraction Rust. You leverage the type system for correctness, use proper error handling (no unwrap in production paths), minimize allocations in hot loops, and write code that is both performant and readable.
|
||||
|
||||
## Project Context
|
||||
|
||||
You are working on Vibe Invest, a Rust algorithmic trading bot using the Alpaca paper trading API. Key architecture:
|
||||
- `indicators.rs`: Technical indicators (EMA, SMA, RSI, MACD, ADX, ATR, Bollinger Bands, ROC) and composite signal scoring via `generate_signal()`
|
||||
- `bot.rs`: Live trading loop with position tracking
|
||||
- `backtester.rs`: Historical simulation with metrics (CAGR, Sharpe, Sortino, drawdown)
|
||||
- `config.rs`: Strategy parameters, 50-symbol universe, risk limits
|
||||
- Hourly mode scales indicator periods by 7x
|
||||
- Risk: max 22% position size, 2.5% stop-loss, 40% take-profit, trailing stop at 7% after 12% gain
|
||||
- Signal thresholds: StrongBuy ≥ 6.0, Buy ≥ 3.5, Sell ≤ -3.5, StrongSell ≤ -6.0
|
||||
|
||||
## Decision-Making Framework
|
||||
|
||||
When designing or modifying strategies:
|
||||
1. **Identify the edge**: What market inefficiency or behavioral bias does this exploit? If you can't articulate the edge, don't implement it.
|
||||
2. **Check for overfitting**: Fewer parameters is better. Out-of-sample validation is mandatory. Be suspicious of strategies that only work on specific time periods.
|
||||
3. **Assess transaction costs**: Account for slippage, spread, and commission impact. A strategy with high turnover must have proportionally higher gross alpha.
|
||||
4. **Correlation analysis**: New signals should be orthogonal to existing ones. Adding correlated indicators adds complexity without improving the information ratio.
|
||||
5. **Regime awareness**: Consider how the strategy behaves across bull, bear, and sideways markets. Robustness across regimes beats optimization for one.
|
||||
|
||||
## Proven Methods You Draw From
|
||||
|
||||
- **Momentum**: Cross-sectional momentum (Jegadeesh-Titman), time-series momentum (Moskowitz-Ooi-Pedersen), dual momentum (Antonacci)
|
||||
- **Mean Reversion**: Bollinger Band mean reversion, RSI oversold/overbought with confirmation, Ornstein-Uhlenbeck models
|
||||
- **Risk Management**: Kelly criterion (fractional), ATR-based position sizing, correlation-adjusted portfolio limits, maximum drawdown controls
|
||||
- **Volatility**: GARCH-family models, realized vs implied vol, vol-targeting for position sizing
|
||||
- **Regime Detection**: ADX for trend strength, VIX-based regime switching, moving average crossover for macro regime
|
||||
- **Portfolio Construction**: Mean-variance optimization (with shrinkage estimators), risk parity, equal risk contribution
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
- Use `cargo clippy` with zero warnings
|
||||
- Proper error handling with `Result` types and `thiserror`/`anyhow`
|
||||
- Document public functions with `///` doc comments explaining the financial logic, not just the code
|
||||
- Use `f64` for all price/indicator calculations; be mindful of floating-point edge cases
|
||||
- Prefer iterators over manual loops for indicator calculations
|
||||
- Keep indicator functions pure (no side effects) for testability
|
||||
- When modifying `config.rs` parameters, always explain the quantitative rationale
|
||||
|
||||
## Self-Verification Checklist
|
||||
|
||||
Before finalizing any strategy change:
|
||||
- [ ] Does this have a sound theoretical basis?
|
||||
- [ ] Have I considered the impact on existing signal correlations?
|
||||
- [ ] Are the parameters reasonable and not overfit to historical data?
|
||||
- [ ] Does the risk management still hold under stress scenarios (2x normal volatility)?
|
||||
- [ ] Is the Rust implementation correct, performant, and idiomatic?
|
||||
- [ ] Have I considered edge cases (gaps, halts, zero volume, NaN propagation)?
|
||||
|
||||
## Communication Style
|
||||
|
||||
Explain your reasoning like a senior quant presenting to a portfolio manager: precise, data-driven, and honest about limitations. When there are tradeoffs, present them clearly. Never oversell a strategy's expected performance. If something is uncertain, say so and suggest how to validate it through backtesting.
|
||||
|
||||
**Update your agent memory** as you discover strategy performance characteristics, indicator effectiveness patterns, parameter sensitivities, codebase architectural decisions, and backtesting results. This builds institutional knowledge across conversations. Write concise notes about what you found and where.
|
||||
|
||||
Examples of what to record:
|
||||
- Which indicator combinations produce orthogonal signals in this codebase
|
||||
- Parameter values that were tested and their backtested results
|
||||
- Code patterns and architectural decisions in the trading system
|
||||
- Risk management thresholds and their rationale
|
||||
- Performance bottlenecks discovered in the backtester or live trading loop
|
||||
|
||||
# Persistent Agent Memory
|
||||
|
||||
You have a persistent Persistent Agent Memory directory at `/home/work/Documents/rust/invest-bot/.claude/agent-memory/quant-rust-strategist/`. Its contents persist across conversations.
|
||||
|
||||
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
|
||||
|
||||
Guidelines:
|
||||
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
|
||||
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
|
||||
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
|
||||
- Update or remove memories that turn out to be wrong or outdated
|
||||
- Organize memory semantically by topic, not chronologically
|
||||
- Use the Write and Edit tools to update your memory files
|
||||
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
|
||||
|
||||
## MEMORY.md
|
||||
|
||||
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.
|
||||
@@ -1,19 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
if [[ ! -d "/home/work/Documents/rust/invest-bot" ]]; then
|
||||
if [[ ! -d "/home/mrfluffy/Documents/projects/rust/vibe-invest" ]]; then
|
||||
echo "Cannot find source directory; Did you move it?"
|
||||
echo "(Looking for "/home/work/Documents/rust/invest-bot")"
|
||||
echo "(Looking for "/home/mrfluffy/Documents/projects/rust/vibe-invest")"
|
||||
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# rebuild the cache forcefully
|
||||
_nix_direnv_force_reload=1 direnv exec "/home/work/Documents/rust/invest-bot" true
|
||||
_nix_direnv_force_reload=1 direnv exec "/home/mrfluffy/Documents/projects/rust/vibe-invest" true
|
||||
|
||||
# Update the mtime for .envrc.
|
||||
# This will cause direnv to reload again - but without re-building.
|
||||
touch "/home/work/Documents/rust/invest-bot/.envrc"
|
||||
touch "/home/mrfluffy/Documents/projects/rust/vibe-invest/.envrc"
|
||||
|
||||
# Also update the timestamp of whatever profile_rc we have.
|
||||
# This makes sure that we know we are up to date.
|
||||
touch -r "/home/work/Documents/rust/invest-bot/.envrc" "/home/work/Documents/rust/invest-bot/.direnv"/*.rc
|
||||
touch -r "/home/mrfluffy/Documents/projects/rust/vibe-invest/.envrc" "/home/mrfluffy/Documents/projects/rust/vibe-invest/.direnv"/*.rc
|
||||
|
||||
1
.direnv/flake-inputs/j9250k63yp54q9r2m0xnca8lxjcfadv0-source
Symbolic link
1
.direnv/flake-inputs/j9250k63yp54q9r2m0xnca8lxjcfadv0-source
Symbolic link
@@ -0,0 +1 @@
|
||||
/nix/store/j9250k63yp54q9r2m0xnca8lxjcfadv0-source
|
||||
@@ -1 +0,0 @@
|
||||
/nix/store/vanbyn1mbsqmff9in675grd5lqpr69zl-source
|
||||
@@ -41,7 +41,7 @@ NIX_ENFORCE_NO_NATIVE='1'
|
||||
export NIX_ENFORCE_NO_NATIVE
|
||||
NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningextensive libcxxhardeningfast pic relro stackclashprotection stackprotector strictoverflow zerocallusedregs'
|
||||
export NIX_HARDENING_ENABLE
|
||||
NIX_LDFLAGS='-rpath /home/work/Documents/rust/invest-bot/outputs/out/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib'
|
||||
NIX_LDFLAGS='-rpath /home/mrfluffy/Documents/projects/rust/vibe-invest/outputs/out/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib'
|
||||
export NIX_LDFLAGS
|
||||
NIX_NO_SELF_RPATH='1'
|
||||
NIX_PKG_CONFIG_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
|
||||
@@ -142,7 +142,7 @@ name='nix-shell-env'
|
||||
export name
|
||||
nativeBuildInputs='/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed /nix/store/fgm3pz8486ksh3f94629lpb7xjr2wjp7-openssl-3.6.0-dev /nix/store/rvp7qlpf5jqvdckjy1afjb6aha6j8dxg-pkg-config-wrapper-0.29.2 /nix/store/fl02yv3ax1qf1xkq64ik8qz5bjxyyd71-cargo-deny-0.19.0 /nix/store/7va1z8il76ycxvyvgsbpr4bjk89lzj5a-cargo-edit-0.13.8 /nix/store/zrx7kmcgzax4s6fldam9hf6nmwcw5nks-cargo-watch-8.5.3 /nix/store/b42adwrm8v2lb1889x1zb8dxzf5ljqys-rust-analyzer-2026-02-02'
|
||||
export nativeBuildInputs
|
||||
out='/home/work/Documents/rust/invest-bot/outputs/out'
|
||||
out='/home/mrfluffy/Documents/projects/rust/vibe-invest/outputs/out'
|
||||
export out
|
||||
outputBin='out'
|
||||
outputDev='out'
|
||||
@@ -173,7 +173,7 @@ preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase'
|
||||
declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
|
||||
preferLocalBuild='1'
|
||||
export preferLocalBuild
|
||||
prefix='/home/work/Documents/rust/invest-bot/outputs/out'
|
||||
prefix='/home/mrfluffy/Documents/projects/rust/vibe-invest/outputs/out'
|
||||
declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
|
||||
propagatedBuildInputs=''
|
||||
export propagatedBuildInputs
|
||||
|
||||
@@ -21,6 +21,10 @@ cargo run --release -- --backtest --years 3
|
||||
cargo run --release -- --backtest --years 5 --capital 50000
|
||||
cargo run --release -- --backtest --years 1 --months 6 --timeframe hourly
|
||||
|
||||
# Run backtesting with custom date range
|
||||
cargo run --release -- --backtest --start-date 2007-01-01 --end-date 2008-12-31
|
||||
cargo run --release -- --backtest --start-date 2020-03-01 --end-date 2020-12-31 --timeframe hourly
|
||||
|
||||
# Lint and format (available via nix flake)
|
||||
cargo clippy
|
||||
cargo fmt
|
||||
|
||||
289
src/alpaca.rs
289
src/alpaca.rs
@@ -14,7 +14,7 @@ use crate::types::Bar;
|
||||
|
||||
const DATA_BASE_URL: &str = "https://data.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.
|
||||
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.
|
||||
/// Fetches each symbol individually to avoid API limits on multi-symbol requests.
|
||||
/// Uses a disk cache to avoid re-fetching bars that were already downloaded.
|
||||
pub async fn fetch_backtest_data(
|
||||
client: &AlpacaClient,
|
||||
symbols: &[&str],
|
||||
@@ -476,6 +509,9 @@ pub async fn fetch_backtest_data(
|
||||
let days = (years * 365.0) as i64 + warmup_days + 30;
|
||||
let start = end - Duration::days(days);
|
||||
|
||||
// Re-fetch overlap: always re-fetch the last 2 days to handle partial/corrected bars
|
||||
let refetch_overlap = Duration::days(2);
|
||||
|
||||
tracing::info!(
|
||||
"Fetching {:.2} years of data ({} to {})...",
|
||||
years,
|
||||
@@ -484,29 +520,250 @@ pub async fn fetch_backtest_data(
|
||||
);
|
||||
|
||||
let mut all_data = HashMap::new();
|
||||
let mut cache_hits = 0u32;
|
||||
let mut cache_misses = 0u32;
|
||||
|
||||
// Fetch each symbol individually like Python does
|
||||
// The multi-symbol endpoint has a 10000 bar limit across ALL symbols
|
||||
for symbol in symbols {
|
||||
tracing::info!(" Fetching {}...", symbol);
|
||||
let cached = load_cached_bars(symbol, timeframe);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, start, end)
|
||||
.await
|
||||
{
|
||||
Ok(bars) => {
|
||||
if !bars.is_empty() {
|
||||
tracing::info!(" {}: {} bars loaded", symbol, bars.len());
|
||||
all_data.insert(symbol.to_string(), bars);
|
||||
} else {
|
||||
tracing::warn!(" {}: No data", symbol);
|
||||
if cached.is_empty() {
|
||||
// Full fetch — no cache
|
||||
cache_misses += 1;
|
||||
tracing::info!(" Fetching {} (no cache)...", symbol);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, start, end)
|
||||
.await
|
||||
{
|
||||
Ok(bars) => {
|
||||
if !bars.is_empty() {
|
||||
tracing::info!(" {}: {} bars fetched", symbol, bars.len());
|
||||
save_cached_bars(symbol, timeframe, &bars);
|
||||
all_data.insert(symbol.to_string(), bars);
|
||||
} else {
|
||||
tracing::warn!(" {}: No data", symbol);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(" Failed to fetch {}: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(" Failed to fetch {}: {}", symbol, e);
|
||||
} else {
|
||||
let first_cached_ts = cached.first().unwrap().timestamp;
|
||||
let last_cached_ts = cached.last().unwrap().timestamp;
|
||||
let need_older = start < first_cached_ts;
|
||||
let need_newer = last_cached_ts - refetch_overlap < end;
|
||||
|
||||
if !need_older && !need_newer {
|
||||
cache_hits += 1;
|
||||
tracing::info!(" {}: {} bars from cache (fully cached)", symbol, cached.len());
|
||||
all_data.insert(symbol.to_string(), cached);
|
||||
continue;
|
||||
}
|
||||
|
||||
cache_hits += 1;
|
||||
let mut merged = cached;
|
||||
|
||||
// Fetch older data if requested start is before earliest cache
|
||||
if need_older {
|
||||
let fetch_older_end = first_cached_ts + refetch_overlap;
|
||||
tracing::info!(
|
||||
" {} (fetching older: {} to {})...",
|
||||
symbol,
|
||||
start.format("%Y-%m-%d"),
|
||||
fetch_older_end.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, start, fetch_older_end)
|
||||
.await
|
||||
{
|
||||
Ok(old_bars) => {
|
||||
tracing::info!(" {}: {} older bars fetched", symbol, old_bars.len());
|
||||
merged.extend(old_bars);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(" {}: older fetch failed: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch newer data from last cached - overlap
|
||||
if need_newer {
|
||||
let fetch_from = last_cached_ts - refetch_overlap;
|
||||
tracing::info!(
|
||||
" {} ({} cached, fetching newer from {})...",
|
||||
symbol,
|
||||
merged.len(),
|
||||
fetch_from.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, fetch_from, end)
|
||||
.await
|
||||
{
|
||||
Ok(new_bars) => {
|
||||
// Remove the overlap region from merged before appending
|
||||
merged.retain(|b| b.timestamp < fetch_from);
|
||||
merged.extend(new_bars);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(" {}: newer fetch failed: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup and sort
|
||||
merged.sort_by_key(|b| b.timestamp);
|
||||
merged.dedup_by_key(|b| b.timestamp);
|
||||
|
||||
tracing::info!(" {}: {} bars total (merged)", symbol, merged.len());
|
||||
save_cached_bars(symbol, timeframe, &merged);
|
||||
all_data.insert(symbol.to_string(), merged);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Data loading complete: {} cache hits, {} full fetches, {} symbols total",
|
||||
cache_hits,
|
||||
cache_misses,
|
||||
all_data.len()
|
||||
);
|
||||
|
||||
Ok(all_data)
|
||||
}
|
||||
|
||||
/// Helper to fetch bars for backtesting with specific date range.
|
||||
/// Similar to fetch_backtest_data but accepts explicit start/end dates.
|
||||
pub async fn fetch_backtest_data_with_dates(
|
||||
client: &AlpacaClient,
|
||||
symbols: &[&str],
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
timeframe: Timeframe,
|
||||
warmup_days: i64,
|
||||
) -> Result<HashMap<String, Vec<Bar>>> {
|
||||
// Add warmup period to start date
|
||||
let start_with_warmup = start - Duration::days(warmup_days + 30);
|
||||
|
||||
// Re-fetch overlap: always re-fetch the last 2 days to handle partial/corrected bars
|
||||
let refetch_overlap = Duration::days(2);
|
||||
|
||||
tracing::info!(
|
||||
"Fetching data from {} to {}...",
|
||||
start_with_warmup.format("%Y-%m-%d"),
|
||||
end.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
let mut all_data = HashMap::new();
|
||||
let mut cache_hits = 0u32;
|
||||
let mut cache_misses = 0u32;
|
||||
|
||||
for symbol in symbols {
|
||||
let cached = load_cached_bars(symbol, timeframe);
|
||||
|
||||
if cached.is_empty() {
|
||||
// Full fetch — no cache
|
||||
cache_misses += 1;
|
||||
tracing::info!(" Fetching {} (no cache)...", symbol);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, start_with_warmup, end)
|
||||
.await
|
||||
{
|
||||
Ok(bars) => {
|
||||
if !bars.is_empty() {
|
||||
tracing::info!(" {}: {} bars fetched", symbol, bars.len());
|
||||
save_cached_bars(symbol, timeframe, &bars);
|
||||
all_data.insert(symbol.to_string(), bars);
|
||||
} else {
|
||||
tracing::warn!(" {}: No data", symbol);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(" Failed to fetch {}: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let first_cached_ts = cached.first().unwrap().timestamp;
|
||||
let last_cached_ts = cached.last().unwrap().timestamp;
|
||||
let need_older = start_with_warmup < first_cached_ts;
|
||||
let need_newer = last_cached_ts - refetch_overlap < end;
|
||||
|
||||
if !need_older && !need_newer {
|
||||
cache_hits += 1;
|
||||
tracing::info!(" {}: {} bars from cache (fully cached)", symbol, cached.len());
|
||||
all_data.insert(symbol.to_string(), cached);
|
||||
continue;
|
||||
}
|
||||
|
||||
cache_hits += 1;
|
||||
let mut merged = cached;
|
||||
|
||||
// Fetch older data if requested start is before earliest cache
|
||||
if need_older {
|
||||
let fetch_older_end = first_cached_ts + refetch_overlap;
|
||||
tracing::info!(
|
||||
" {} (fetching older: {} to {})...",
|
||||
symbol,
|
||||
start_with_warmup.format("%Y-%m-%d"),
|
||||
fetch_older_end.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, start_with_warmup, fetch_older_end)
|
||||
.await
|
||||
{
|
||||
Ok(old_bars) => {
|
||||
merged = old_bars.into_iter().chain(merged).collect();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(" {}: older fetch failed: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch newer data if cache doesn't cover requested end
|
||||
if need_newer {
|
||||
let fetch_from = last_cached_ts - refetch_overlap;
|
||||
tracing::info!(
|
||||
" {} (fetching newer: {} to {})...",
|
||||
symbol,
|
||||
fetch_from.format("%Y-%m-%d"),
|
||||
end.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
match client
|
||||
.get_historical_bars(symbol, timeframe, fetch_from, end)
|
||||
.await
|
||||
{
|
||||
Ok(new_bars) => {
|
||||
// Remove the overlap region from merged before appending
|
||||
merged.retain(|b| b.timestamp < fetch_from);
|
||||
merged.extend(new_bars);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(" {}: newer fetch failed: {}", symbol, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup and sort
|
||||
merged.sort_by_key(|b| b.timestamp);
|
||||
merged.dedup_by_key(|b| b.timestamp);
|
||||
|
||||
tracing::info!(" {}: {} bars total (merged)", symbol, merged.len());
|
||||
save_cached_bars(symbol, timeframe, &merged);
|
||||
all_data.insert(symbol.to_string(), merged);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Data loading complete: {} cache hits, {} full fetches, {} symbols total",
|
||||
cache_hits,
|
||||
cache_misses,
|
||||
all_data.len()
|
||||
);
|
||||
|
||||
Ok(all_data)
|
||||
}
|
||||
|
||||
1211
src/backtester.rs
1211
src/backtester.rs
File diff suppressed because it is too large
Load Diff
859
src/bot.rs
859
src/bot.rs
File diff suppressed because it is too large
Load Diff
283
src/config.rs
283
src/config.rs
@@ -1,92 +1,218 @@
|
||||
//! 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 SEMIS: &[&str] = &["AVGO", "AMD", "ASML", "QCOM", "MU"];
|
||||
pub const GROWTH_TECH: &[&str] = &["NFLX", "CRM", "NOW", "UBER", "SNOW"];
|
||||
pub const HEALTHCARE: &[&str] = &["LLY", "UNH", "ISRG", "VRTX", "ABBV", "MRK", "PFE"];
|
||||
pub const FINTECH_VOLATILE: &[&str] = &["V", "MA", "COIN", "PLTR", "MSTR"];
|
||||
pub const SP500_FINANCIALS: &[&str] = &["JPM", "GS", "MS", "BLK", "AXP", "C"];
|
||||
pub const SP500_INDUSTRIALS: &[&str] = &["CAT", "GE", "HON", "BA", "RTX", "LMT", "DE"];
|
||||
pub const SP500_CONSUMER: &[&str] = &["COST", "WMT", "HD", "NKE", "SBUX", "MCD", "DIS"];
|
||||
pub const SP500_ENERGY: &[&str] = &["XOM", "CVX", "COP", "SLB", "OXY"];
|
||||
|
||||
/// Get all symbols in the trading universe (50 stocks).
|
||||
pub const SEMIS: &[&str] = &["AVGO", "AMD", "ASML", "QCOM", "MU", "MRVL", "LRCX", "KLAC", "AMAT"];
|
||||
pub const GROWTH_TECH: &[&str] = &["NFLX", "CRM", "NOW", "UBER", "SNOW", "DDOG", "CRWD", "ZS", "WDAY"];
|
||||
pub const SOFTWARE: &[&str] = &["ADBE", "INTU", "PANW", "FTNT", "TEAM", "HUBS", "MNDY"];
|
||||
pub const HEALTHCARE: &[&str] = &["LLY", "UNH", "ISRG", "VRTX", "ABBV", "MRK", "PFE", "TMO", "ABT", "DHR"];
|
||||
pub const BIOTECH: &[&str] = &["GILD", "AMGN", "REGN", "BIIB", "MRNA"];
|
||||
pub const FINTECH_VOLATILE: &[&str] = &["V", "MA", "COIN", "PLTR", "MSTR", "SQ", "PYPL"];
|
||||
pub const SP500_FINANCIALS: &[&str] = &["JPM", "GS", "MS", "BLK", "AXP", "C", "SCHW", "ICE"];
|
||||
pub const SP500_INDUSTRIALS: &[&str] = &["CAT", "GE", "HON", "BA", "RTX", "LMT", "DE", "UNP", "UPS"];
|
||||
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> {
|
||||
let mut symbols = Vec::new();
|
||||
symbols.extend_from_slice(MAG7);
|
||||
symbols.extend_from_slice(SEMIS);
|
||||
symbols.extend_from_slice(GROWTH_TECH);
|
||||
symbols.extend_from_slice(SOFTWARE);
|
||||
symbols.extend_from_slice(HEALTHCARE);
|
||||
symbols.extend_from_slice(BIOTECH);
|
||||
symbols.extend_from_slice(FINTECH_VOLATILE);
|
||||
symbols.extend_from_slice(SP500_FINANCIALS);
|
||||
symbols.extend_from_slice(SP500_INDUSTRIALS);
|
||||
symbols.extend_from_slice(SP500_CONSUMER);
|
||||
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
|
||||
}
|
||||
|
||||
// Strategy Parameters
|
||||
// Strategy Parameters — Regime-Adaptive Dual Signal
|
||||
// RSI-14 for trend assessment, RSI-2 for mean-reversion entries (Connors)
|
||||
pub const RSI_PERIOD: usize = 14;
|
||||
pub const RSI_OVERSOLD: f64 = 30.0;
|
||||
pub const RSI_OVERBOUGHT: f64 = 70.0;
|
||||
pub const RSI_PULLBACK_LOW: f64 = 35.0;
|
||||
pub const RSI_PULLBACK_HIGH: f64 = 60.0;
|
||||
|
||||
pub const RSI_SHORT_PERIOD: usize = 2; // Connors RSI-2 for mean reversion
|
||||
pub const MACD_FAST: usize = 12;
|
||||
pub const MACD_SLOW: usize = 26;
|
||||
pub const MACD_SIGNAL: usize = 9;
|
||||
|
||||
pub const MOMENTUM_PERIOD: usize = 5;
|
||||
|
||||
pub const MOMENTUM_PERIOD: usize = 63;
|
||||
pub const EMA_SHORT: usize = 9;
|
||||
pub const EMA_LONG: usize = 21;
|
||||
pub const EMA_TREND: usize = 50;
|
||||
|
||||
// ADX - Trend Strength
|
||||
// ADX — Regime Detection
|
||||
// ADX < RANGE_THRESHOLD = ranging (use mean reversion)
|
||||
// ADX > TREND_THRESHOLD = trending (use momentum/pullback)
|
||||
// Between = transition zone (reduce size, be cautious)
|
||||
pub const ADX_PERIOD: usize = 14;
|
||||
pub const ADX_THRESHOLD: f64 = 25.0;
|
||||
pub const ADX_STRONG: f64 = 35.0;
|
||||
|
||||
pub const ADX_TREND_THRESHOLD: f64 = 25.0; // Above this = trending
|
||||
// Bollinger Bands
|
||||
pub const BB_PERIOD: usize = 20;
|
||||
pub const BB_STD: f64 = 2.0;
|
||||
|
||||
// ATR for volatility-based stops
|
||||
// ATR
|
||||
pub const ATR_PERIOD: usize = 14;
|
||||
pub const ATR_MULTIPLIER_STOP: f64 = 1.5;
|
||||
pub const ATR_MULTIPLIER_TRAIL: f64 = 2.5;
|
||||
|
||||
pub const MIN_ATR_PCT: f64 = 0.005;
|
||||
// Volume filter
|
||||
pub const VOLUME_MA_PERIOD: usize = 20;
|
||||
pub const VOLUME_THRESHOLD: f64 = 0.8;
|
||||
|
||||
// Momentum Ranking
|
||||
pub const TOP_MOMENTUM_COUNT: usize = 8;
|
||||
|
||||
pub const TOP_MOMENTUM_COUNT: usize = 15; // Top quintile: enough candidates for 8 positions
|
||||
// Risk Management
|
||||
pub const MAX_POSITION_SIZE: f64 = 0.22;
|
||||
pub const MIN_CASH_RESERVE: f64 = 0.01;
|
||||
pub const MAX_POSITION_SIZE: f64 = 0.20; // 20% max to reduce concentration risk
|
||||
pub const MIN_CASH_RESERVE: f64 = 0.05;
|
||||
pub const STOP_LOSS_PCT: f64 = 0.025;
|
||||
pub const TAKE_PROFIT_PCT: f64 = 0.40;
|
||||
pub const TRAILING_STOP_ACTIVATION: f64 = 0.12;
|
||||
pub const TRAILING_STOP_DISTANCE: f64 = 0.07;
|
||||
pub const MAX_LOSS_PCT: f64 = 0.08; // Gap protection only — ATR stop handles normal exits
|
||||
pub const TRAILING_STOP_ACTIVATION: f64 = 0.04; // Activate earlier to protect profits
|
||||
pub const TRAILING_STOP_DISTANCE: f64 = 0.05; // Wider trail to let winners run
|
||||
// ATR-based risk management
|
||||
pub const RISK_PER_TRADE: f64 = 0.015; // 1.5% risk per trade (8 positions * 1.5% = 12% worst-case)
|
||||
pub const ATR_STOP_MULTIPLIER: f64 = 3.5; // Wide stops reduce false stop-outs (the #1 loss source)
|
||||
pub const ATR_TRAIL_MULTIPLIER: f64 = 3.0; // Wide trail so winners run longer
|
||||
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Don't activate trail too early
|
||||
// Tiered trailing stop: tight trail for small gains, wide trail for big gains
|
||||
pub const EARLY_TRAIL_ACTIVATION_MULTIPLIER: f64 = 0.5; // Activate tight trail after 0.5x ATR gain
|
||||
pub const EARLY_TRAIL_MULTIPLIER: f64 = 1.5; // Tight trail distance for small gains
|
||||
// Breakeven protection: once in profit, don't let it become a big loss
|
||||
pub const BREAKEVEN_ACTIVATION_PCT: f64 = 0.02; // Activate after 2% gain (meaningful, not noise)
|
||||
pub const BREAKEVEN_MAX_LOSS_PCT: f64 = 0.005; // Once activated, don't give back more than 0.5% from entry
|
||||
// Slow bleeder exit: cut losers that never showed promise
|
||||
pub const SLOW_BLEED_BARS: usize = 20; // Grace period before checking
|
||||
pub const SLOW_BLEED_MAX_LOSS: f64 = 0.02; // If down >2% after grace period and never up >1%, cut
|
||||
pub const SLOW_BLEED_MIN_GAIN: f64 = 0.01; // Must have shown at least 1% gain to survive
|
||||
// Portfolio-level controls
|
||||
pub const MAX_CONCURRENT_POSITIONS: usize = 8; // Fewer positions = higher conviction per trade
|
||||
pub const MAX_SECTOR_POSITIONS: usize = 2;
|
||||
// Old single-tier drawdown constants (replaced by tiered system below)
|
||||
// pub const MAX_DRAWDOWN_HALT: f64 = 0.15;
|
||||
// pub const DRAWDOWN_HALT_BARS: usize = 10;
|
||||
// Time-based exit
|
||||
pub const TIME_EXIT_BARS: usize = 80; // More patience for losers on hourly bars
|
||||
pub const REENTRY_COOLDOWN_BARS: usize = 10; // Longer cooldown to reduce churn
|
||||
pub const RAMPUP_PERIOD_BARS: usize = 15; // Faster ramp-up
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Market Regime Filter (SPY-based)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Uses SPY as a broad market proxy to detect bull/caution/bear regimes.
|
||||
// Based on the dual moving average framework (Faber 2007, "A Quantitative
|
||||
// Approach to Tactical Asset Allocation"): price vs 200-day SMA is the
|
||||
// single most effective regime filter in academic literature.
|
||||
//
|
||||
// Bull: SPY > EMA-200 AND EMA-50 > EMA-200 → trade normally
|
||||
// Caution: SPY < EMA-50 but SPY > EMA-200 → reduce size, raise thresholds
|
||||
// Bear: SPY < EMA-200 AND EMA-50 < EMA-200 → no new buys, manage exits only
|
||||
pub const REGIME_SPY_SYMBOL: &str = "SPY";
|
||||
pub const REGIME_EMA_SHORT: usize = 50; // Fast regime EMA
|
||||
pub const REGIME_EMA_LONG: usize = 200; // Slow regime EMA (the "golden cross" line)
|
||||
/// In Caution regime, multiply position size by this factor (DAILY bars).
|
||||
/// Daily benefits from being more aggressive in Caution (60% size) to capture bull markets.
|
||||
pub const REGIME_CAUTION_SIZE_FACTOR: f64 = 0.6;
|
||||
/// In Caution regime, add this to buy thresholds (DAILY bars).
|
||||
/// Daily needs lower bump (1.0) to participate in bull rallies.
|
||||
pub const REGIME_CAUTION_THRESHOLD_BUMP: f64 = 1.0;
|
||||
|
||||
/// In Caution regime, multiply position size by this factor (HOURLY bars).
|
||||
/// Hourly needs to be very defensive (25% size) due to intraday noise.
|
||||
pub const HOURLY_REGIME_CAUTION_SIZE_FACTOR: f64 = 0.25;
|
||||
/// In Caution regime, add this to buy thresholds (HOURLY bars).
|
||||
/// Hourly needs high bump (3.0) to avoid whipsaws.
|
||||
pub const HOURLY_REGIME_CAUTION_THRESHOLD_BUMP: f64 = 3.0;
|
||||
|
||||
/// If true, the bot is allowed to open new long positions during a Bear market regime.
|
||||
/// This is a master switch for testing/debugging purposes.
|
||||
pub const ALLOW_LONGS_IN_BEAR_MARKET: bool = false;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Scaled Drawdown Circuit Breaker
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// The old fixed 10-bar cooldown is inadequate for real bear markets.
|
||||
// Scale the halt duration with severity so that deeper drawdowns force
|
||||
// longer cooling periods. At 25%+ DD, also require bull regime to resume.
|
||||
// Daily drawdown tiers: relaxed to avoid halting on normal 10-15% bull pullbacks
|
||||
pub const DRAWDOWN_TIER1_PCT: f64 = 0.18; // 18% → 10 bars
|
||||
pub const DRAWDOWN_TIER1_BARS: usize = 10;
|
||||
pub const DRAWDOWN_TIER2_PCT: f64 = 0.25; // 25% → 30 bars
|
||||
pub const DRAWDOWN_TIER2_BARS: usize = 30;
|
||||
pub const DRAWDOWN_TIER3_PCT: f64 = 0.35; // 35%+ → 50 bars + require bull
|
||||
pub const DRAWDOWN_TIER3_BARS: usize = 50;
|
||||
/// If true, after a Tier 3 drawdown, require bull market regime to resume.
|
||||
pub const DRAWDOWN_TIER3_REQUIRE_BULL: bool = true;
|
||||
|
||||
// Hourly drawdown tiers: tighter because hourly has more whipsaw exposure
|
||||
// and the bot needs to cut losses faster to preserve capital in bear periods.
|
||||
pub const HOURLY_DRAWDOWN_TIER1_PCT: f64 = 0.12; // 12% → 15 bars
|
||||
pub const HOURLY_DRAWDOWN_TIER1_BARS: usize = 15;
|
||||
pub const HOURLY_DRAWDOWN_TIER2_PCT: f64 = 0.18; // 18% → 40 bars
|
||||
pub const HOURLY_DRAWDOWN_TIER2_BARS: usize = 40;
|
||||
pub const HOURLY_DRAWDOWN_TIER3_PCT: f64 = 0.25; // 25%+ → 60 bars + require bull
|
||||
pub const HOURLY_DRAWDOWN_TIER3_BARS: usize = 60;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Trailing Equity Curve Stop
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// If the portfolio equity drops below its own N-bar moving average, stop
|
||||
// all new entries. This is a secondary defense independent of the drawdown
|
||||
// breaker. Uses a 200-bar SMA of the equity curve (roughly 200 trading
|
||||
// days for daily, ~29 trading days for hourly).
|
||||
pub const EQUITY_CURVE_SMA_PERIOD: usize = 50; // Shorter window so bot can recover
|
||||
|
||||
// Backtester slippage
|
||||
pub const SLIPPAGE_BPS: f64 = 10.0;
|
||||
// Trading intervals
|
||||
pub const BOT_CHECK_INTERVAL_SECONDS: u64 = 15;
|
||||
pub const BARS_LOOKBACK: usize = 100;
|
||||
|
||||
// Backtest defaults
|
||||
pub const DEFAULT_INITIAL_CAPITAL: f64 = 100_000.0;
|
||||
pub const TRADING_DAYS_PER_YEAR: usize = 252;
|
||||
|
||||
// Hours per trading day (for scaling parameters)
|
||||
// Hours per trading day
|
||||
pub const HOURS_PER_DAY: usize = 7;
|
||||
|
||||
/// Get the sector for a given symbol.
|
||||
pub fn get_sector(symbol: &str) -> &'static str {
|
||||
if MAG7.contains(&symbol) {
|
||||
"mag7"
|
||||
} else if SEMIS.contains(&symbol) {
|
||||
"semis"
|
||||
} else if GROWTH_TECH.contains(&symbol) {
|
||||
"growth_tech"
|
||||
} else if SOFTWARE.contains(&symbol) {
|
||||
"software"
|
||||
} else if HEALTHCARE.contains(&symbol) {
|
||||
"healthcare"
|
||||
} else if BIOTECH.contains(&symbol) {
|
||||
"biotech"
|
||||
} else if FINTECH_VOLATILE.contains(&symbol) {
|
||||
"fintech_volatile"
|
||||
} else if SP500_FINANCIALS.contains(&symbol) {
|
||||
"financials"
|
||||
} else if SP500_INDUSTRIALS.contains(&symbol) {
|
||||
"industrials"
|
||||
} else if SP500_CONSUMER.contains(&symbol) {
|
||||
"consumer"
|
||||
} else if SP500_ENERGY.contains(&symbol) {
|
||||
"energy"
|
||||
} else if TELECOM_MEDIA.contains(&symbol) {
|
||||
"telecom_media"
|
||||
} else if INTERNATIONAL.contains(&symbol) {
|
||||
"international"
|
||||
} else if MATERIALS.contains(&symbol) {
|
||||
"materials"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
/// Indicator parameters that can be scaled for different timeframes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndicatorParams {
|
||||
pub rsi_period: usize,
|
||||
pub rsi_short_period: usize, // RSI-2 for mean reversion
|
||||
pub macd_fast: usize,
|
||||
pub macd_slow: usize,
|
||||
pub macd_signal: usize,
|
||||
@@ -99,53 +225,56 @@ pub struct IndicatorParams {
|
||||
pub atr_period: usize,
|
||||
pub volume_ma_period: usize,
|
||||
}
|
||||
|
||||
impl IndicatorParams {
|
||||
/// Create parameters for daily timeframe.
|
||||
pub fn daily() -> Self {
|
||||
Self {
|
||||
rsi_period: RSI_PERIOD,
|
||||
macd_fast: MACD_FAST,
|
||||
macd_slow: MACD_SLOW,
|
||||
macd_signal: MACD_SIGNAL,
|
||||
momentum_period: MOMENTUM_PERIOD,
|
||||
ema_short: EMA_SHORT,
|
||||
ema_long: EMA_LONG,
|
||||
ema_trend: EMA_TREND,
|
||||
adx_period: ADX_PERIOD,
|
||||
bb_period: BB_PERIOD,
|
||||
atr_period: ATR_PERIOD,
|
||||
volume_ma_period: VOLUME_MA_PERIOD,
|
||||
rsi_period: 14,
|
||||
rsi_short_period: 2, // Connors RSI-2
|
||||
macd_fast: 12,
|
||||
macd_slow: 26,
|
||||
macd_signal: 9,
|
||||
momentum_period: 63,
|
||||
ema_short: 9,
|
||||
ema_long: 21,
|
||||
ema_trend: 50,
|
||||
adx_period: 14,
|
||||
bb_period: 20,
|
||||
atr_period: 14,
|
||||
volume_ma_period: 20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create parameters for hourly timeframe (scaled by HOURS_PER_DAY).
|
||||
/// 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 {
|
||||
let scale = HOURS_PER_DAY;
|
||||
Self {
|
||||
rsi_period: RSI_PERIOD * scale,
|
||||
macd_fast: MACD_FAST * scale,
|
||||
macd_slow: MACD_SLOW * scale,
|
||||
macd_signal: MACD_SIGNAL * scale,
|
||||
momentum_period: MOMENTUM_PERIOD * scale,
|
||||
ema_short: EMA_SHORT * scale,
|
||||
ema_long: EMA_LONG * scale,
|
||||
ema_trend: EMA_TREND * scale,
|
||||
adx_period: ADX_PERIOD * scale,
|
||||
bb_period: BB_PERIOD * scale,
|
||||
atr_period: ATR_PERIOD * scale,
|
||||
volume_ma_period: VOLUME_MA_PERIOD * scale,
|
||||
rsi_period: 14,
|
||||
rsi_short_period: 3,
|
||||
macd_fast: 84, // 12 * 7
|
||||
macd_slow: 182, // 26 * 7
|
||||
macd_signal: 63, // 9 * 7
|
||||
momentum_period: 441, // 63 * 7 = quarterly momentum
|
||||
ema_short: 63, // 9 * 7 ~ daily 9-day EMA
|
||||
ema_long: 147, // 21 * 7 ~ daily 21-day EMA
|
||||
ema_trend: 350, // 50 * 7 ~ daily 50-day EMA
|
||||
adx_period: 14,
|
||||
bb_period: 140, // 20 * 7
|
||||
atr_period: 14,
|
||||
volume_ma_period: 140, // 20 * 7
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the minimum number of bars required for indicator calculation.
|
||||
pub fn min_bars(&self) -> usize {
|
||||
*[
|
||||
self.macd_slow,
|
||||
self.rsi_period,
|
||||
self.macd_slow + self.macd_signal,
|
||||
self.rsi_period + 1,
|
||||
self.ema_trend,
|
||||
self.adx_period,
|
||||
self.adx_period * 2,
|
||||
self.bb_period,
|
||||
self.momentum_period,
|
||||
]
|
||||
.iter()
|
||||
.max()
|
||||
@@ -153,14 +282,12 @@ impl IndicatorParams {
|
||||
+ 5
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeframe for trading data.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum Timeframe {
|
||||
Daily,
|
||||
Hourly,
|
||||
}
|
||||
|
||||
impl Timeframe {
|
||||
pub fn params(&self) -> IndicatorParams {
|
||||
match self {
|
||||
|
||||
103
src/dashboard.rs
103
src/dashboard.rs
@@ -12,13 +12,27 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::alpaca::AlpacaClient;
|
||||
use crate::paths::LIVE_EQUITY_FILE;
|
||||
use crate::types::EquitySnapshot;
|
||||
use crate::{
|
||||
alpaca::AlpacaClient,
|
||||
config::{
|
||||
ATR_STOP_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER,
|
||||
BREAKEVEN_ACTIVATION_PCT, BREAKEVEN_MAX_LOSS_PCT,
|
||||
EARLY_TRAIL_ACTIVATION_MULTIPLIER, EARLY_TRAIL_MULTIPLIER,
|
||||
},
|
||||
paths::{LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE},
|
||||
types::EquitySnapshot,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct DashboardInitData {
|
||||
pub entry_atrs: HashMap<String, f64>,
|
||||
pub high_water_marks: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
/// Shared state for the dashboard.
|
||||
pub struct DashboardState {
|
||||
pub client: AlpacaClient,
|
||||
pub init_data: DashboardInitData,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -48,6 +62,8 @@ struct PositionResponse {
|
||||
unrealized_pnl: f64,
|
||||
pnl_pct: f64,
|
||||
change_today: f64,
|
||||
trail_status: String,
|
||||
stop_loss_price: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -363,6 +379,8 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
||||
<div class="position-detail"><div class="position-detail-label">Current</div><div class="position-detail-value">${formatCurrency(pos.current_price)}</div></div>
|
||||
<div class="position-detail"><div class="position-detail-label">P&L</div><div class="position-detail-value ${pnlClass}">${formatCurrency(pos.unrealized_pnl, true)}</div></div>
|
||||
<div class="position-detail"><div class="position-detail-label">Today</div><div class="position-detail-value ${changeClass}">${changeSign}${pos.change_today.toFixed(2)}%</div></div>
|
||||
<div class="position-detail"><div class="position-detail-label">Trail Status</div><div class="position-detail-value">${pos.trail_status}</div></div>
|
||||
<div class="position-detail"><div class="position-detail-label">Stop Loss</div><div class="position-detail-value">${formatCurrency(pos.stop_loss_price)}</div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
@@ -548,20 +566,63 @@ async fn api_positions(State(state): State<Arc<DashboardState>>) -> impl IntoRes
|
||||
Ok(positions) => {
|
||||
let mut result: Vec<PositionResponse> = positions
|
||||
.iter()
|
||||
.map(|p| PositionResponse {
|
||||
symbol: p.symbol.clone(),
|
||||
qty: p.qty.parse().unwrap_or(0.0),
|
||||
market_value: p.market_value.parse().unwrap_or(0.0),
|
||||
avg_entry_price: p.avg_entry_price.parse().unwrap_or(0.0),
|
||||
current_price: p.current_price.parse().unwrap_or(0.0),
|
||||
unrealized_pnl: p.unrealized_pl.parse().unwrap_or(0.0),
|
||||
pnl_pct: p.unrealized_plpc.parse::<f64>().unwrap_or(0.0) * 100.0,
|
||||
change_today: p
|
||||
.change_today
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0)
|
||||
* 100.0,
|
||||
.map(|p| {
|
||||
let entry_price = p.avg_entry_price.parse().unwrap_or(0.0);
|
||||
let current_price = p.current_price.parse().unwrap_or(0.0);
|
||||
let pnl_pct = if entry_price > 0.0 {
|
||||
(current_price - entry_price) / entry_price
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let entry_atr = state.init_data.entry_atrs.get(&p.symbol).copied().unwrap_or(0.0);
|
||||
let high_water_mark = state.init_data.high_water_marks.get(&p.symbol).copied().unwrap_or(entry_price);
|
||||
|
||||
let activation_gain = if entry_atr > 0.0 {
|
||||
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let best_pnl = (high_water_mark - entry_price) / entry_price;
|
||||
let big_activation = if entry_atr > 0.0 {
|
||||
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||
} else { 0.0 };
|
||||
let small_activation = if entry_atr > 0.0 {
|
||||
(EARLY_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||
} else { 0.0 };
|
||||
|
||||
let (trail_status, stop_loss_price) = if best_pnl >= BREAKEVEN_ACTIVATION_PCT && pnl_pct <= -BREAKEVEN_MAX_LOSS_PCT {
|
||||
("Breakeven!".to_string(), entry_price * (1.0 - BREAKEVEN_MAX_LOSS_PCT))
|
||||
} else if entry_atr > 0.0 && best_pnl >= big_activation {
|
||||
let trail_distance = ATR_TRAIL_MULTIPLIER * entry_atr;
|
||||
let stop_price = high_water_mark - trail_distance;
|
||||
("Wide Trail".to_string(), stop_price)
|
||||
} else if entry_atr > 0.0 && pnl_pct >= small_activation {
|
||||
let trail_distance = EARLY_TRAIL_MULTIPLIER * entry_atr;
|
||||
let stop_price = high_water_mark - trail_distance;
|
||||
("Tight Trail".to_string(), stop_price)
|
||||
} else {
|
||||
("Inactive".to_string(), entry_price - ATR_STOP_MULTIPLIER * entry_atr)
|
||||
};
|
||||
|
||||
PositionResponse {
|
||||
symbol: p.symbol.clone(),
|
||||
qty: p.qty.parse().unwrap_or(0.0),
|
||||
market_value: p.market_value.parse().unwrap_or(0.0),
|
||||
avg_entry_price: entry_price,
|
||||
current_price,
|
||||
unrealized_pnl: p.unrealized_pl.parse().unwrap_or(0.0),
|
||||
pnl_pct: p.unrealized_plpc.parse::<f64>().unwrap_or(0.0) * 100.0,
|
||||
change_today: p
|
||||
.change_today
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0)
|
||||
* 100.0,
|
||||
trail_status,
|
||||
stop_loss_price,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -614,8 +675,12 @@ async fn api_orders(State(state): State<Arc<DashboardState>>) -> impl IntoRespon
|
||||
}
|
||||
|
||||
/// Start the dashboard web server.
|
||||
pub async fn start_dashboard(client: AlpacaClient, port: u16) -> anyhow::Result<()> {
|
||||
let state = Arc::new(DashboardState { client });
|
||||
pub async fn start_dashboard(
|
||||
client: AlpacaClient,
|
||||
port: u16,
|
||||
init_data: DashboardInitData,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = Arc::new(DashboardState { client, init_data });
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Technical indicator calculations.
|
||||
|
||||
use crate::config::{
|
||||
IndicatorParams, ADX_STRONG, ADX_THRESHOLD, BB_STD, RSI_OVERBOUGHT, RSI_OVERSOLD,
|
||||
RSI_PULLBACK_HIGH, RSI_PULLBACK_LOW, VOLUME_THRESHOLD,
|
||||
IndicatorParams, ADX_TREND_THRESHOLD, BB_STD, VOLUME_THRESHOLD,
|
||||
};
|
||||
use crate::types::{Bar, IndicatorRow, Signal, TradeSignal};
|
||||
|
||||
@@ -348,6 +347,7 @@ pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec<I
|
||||
|
||||
// Calculate all indicators
|
||||
let rsi = calculate_rsi(&closes, params.rsi_period);
|
||||
let rsi_short = calculate_rsi(&closes, params.rsi_short_period);
|
||||
let (macd, macd_signal, macd_histogram) =
|
||||
calculate_macd(&closes, params.macd_fast, params.macd_slow, params.macd_signal);
|
||||
let momentum = calculate_roc(&closes, params.momentum_period);
|
||||
@@ -392,6 +392,7 @@ pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec<I
|
||||
close: bar.close,
|
||||
volume: bar.volume,
|
||||
rsi: rsi[i],
|
||||
rsi_short: rsi_short[i],
|
||||
macd: macd[i],
|
||||
macd_signal: macd_signal[i],
|
||||
macd_histogram: macd_histogram[i],
|
||||
@@ -421,201 +422,251 @@ pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec<I
|
||||
rows
|
||||
}
|
||||
|
||||
/// Generate trading signal from current and previous indicator rows.
|
||||
/// Determine the broad market regime from SPY indicator data.
|
||||
///
|
||||
/// This is the single most important risk filter in the system. During the
|
||||
/// 2020 COVID crash (SPY fell ~34% in 23 trading days) and the 2022 bear
|
||||
/// market (SPY fell ~25% over 9 months), SPY spent the majority of those
|
||||
/// periods below its 200-day EMA with EMA-50 < EMA-200. This filter would
|
||||
/// have prevented most long entries during those drawdowns.
|
||||
///
|
||||
/// The three regimes map to position-sizing multipliers:
|
||||
/// - Bull (SPY > EMA-200, EMA-50 > EMA-200): full size, normal thresholds
|
||||
/// - Caution (SPY < EMA-50, SPY > EMA-200): half size, raised thresholds
|
||||
/// - Bear (SPY < EMA-200, EMA-50 < EMA-200): no new longs
|
||||
pub fn determine_market_regime(spy_row: &IndicatorRow, spy_ema50: f64, spy_ema200: f64) -> crate::types::MarketRegime {
|
||||
use crate::types::MarketRegime;
|
||||
|
||||
let price = spy_row.close;
|
||||
|
||||
// All three EMAs must be valid
|
||||
if spy_ema50.is_nan() || spy_ema200.is_nan() || price <= 0.0 {
|
||||
// Default to Caution when we lack data (conservative)
|
||||
return MarketRegime::Caution;
|
||||
}
|
||||
|
||||
// Bear: price below 200 EMA AND 50 EMA below 200 EMA (death cross)
|
||||
if price < spy_ema200 && spy_ema50 < spy_ema200 {
|
||||
return MarketRegime::Bear;
|
||||
}
|
||||
|
||||
// Caution: price below 50 EMA (short-term weakness) but still above 200
|
||||
if price < spy_ema50 {
|
||||
return MarketRegime::Caution;
|
||||
}
|
||||
|
||||
// Bull: price above both, 50 above 200 (golden cross)
|
||||
if spy_ema50 > spy_ema200 {
|
||||
return MarketRegime::Bull;
|
||||
}
|
||||
|
||||
// Edge case: price above both EMAs but 50 still below 200 (recovery)
|
||||
// Treat as Caution — the golden cross hasn't confirmed yet
|
||||
MarketRegime::Caution
|
||||
}
|
||||
|
||||
/// Generate trading signal using hierarchical momentum-with-trend strategy.
|
||||
///
|
||||
/// This replaces the previous additive "indicator soup" approach. The academic
|
||||
/// evidence for momentum is robust (Jegadeesh & Titman 1993, Moskowitz et al.
|
||||
/// 2012, Asness et al. 2013 "Value and Momentum Everywhere"). Rather than
|
||||
/// netting 8 indicators against each other, we use a hierarchical filter:
|
||||
///
|
||||
/// LAYER 1 (GATE): Trend confirmation
|
||||
/// - Price must be above EMA-trend (Faber 2007 trend filter)
|
||||
/// - EMA-short must be above EMA-long (trend alignment)
|
||||
/// Without both, no buy signal is generated.
|
||||
///
|
||||
/// LAYER 2 (ENTRY): Momentum + pullback timing
|
||||
/// - Positive momentum (ROC > 0): time-series momentum filter
|
||||
/// - RSI-14 pullback (30-50): buy the dip in a confirmed uptrend
|
||||
/// This is the only proven single-stock pattern (Levy 1967, confirmed
|
||||
/// by DeMiguel et al. 2020)
|
||||
///
|
||||
/// LAYER 3 (CONVICTION): Supplementary confirmation
|
||||
/// - MACD histogram positive: momentum accelerating
|
||||
/// - ADX > 25 with DI+ > DI-: strong directional trend
|
||||
/// - Volume above average: institutional participation
|
||||
///
|
||||
/// SELL SIGNALS: Hierarchical exit triggers
|
||||
/// - Trend break: price below EMA-trend = immediate sell
|
||||
/// - Momentum reversal: ROC turns significantly negative
|
||||
/// - EMA death cross: EMA-short crosses below EMA-long
|
||||
pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &IndicatorRow) -> TradeSignal {
|
||||
let rsi = current.rsi;
|
||||
let macd = current.macd;
|
||||
let macd_signal_val = current.macd_signal;
|
||||
let macd_hist = current.macd_histogram;
|
||||
let momentum = current.momentum;
|
||||
let ema_short = current.ema_short;
|
||||
let ema_long = current.ema_long;
|
||||
let current_price = current.close;
|
||||
|
||||
// Advanced indicators
|
||||
// Safe NaN handling
|
||||
let trend_bullish = current.trend_bullish;
|
||||
let volume_ratio = if current.volume_ratio.is_nan() {
|
||||
1.0
|
||||
} else {
|
||||
current.volume_ratio
|
||||
};
|
||||
let adx = if current.adx.is_nan() { 25.0 } else { current.adx };
|
||||
let di_plus = if current.di_plus.is_nan() {
|
||||
25.0
|
||||
} else {
|
||||
current.di_plus
|
||||
};
|
||||
let di_minus = if current.di_minus.is_nan() {
|
||||
25.0
|
||||
} else {
|
||||
current.di_minus
|
||||
};
|
||||
let bb_pct = if current.bb_pct.is_nan() {
|
||||
0.5
|
||||
} else {
|
||||
current.bb_pct
|
||||
};
|
||||
let ema_distance = if current.ema_distance.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
current.ema_distance
|
||||
};
|
||||
let volume_ratio = if current.volume_ratio.is_nan() { 1.0 } else { current.volume_ratio };
|
||||
let adx = if current.adx.is_nan() { 20.0 } else { current.adx };
|
||||
let di_plus = if current.di_plus.is_nan() { 25.0 } else { current.di_plus };
|
||||
let di_minus = if current.di_minus.is_nan() { 25.0 } else { current.di_minus };
|
||||
|
||||
// MACD crossover detection
|
||||
let macd_crossed_up = !previous.macd.is_nan()
|
||||
&& !previous.macd_signal.is_nan()
|
||||
&& !macd.is_nan()
|
||||
&& !macd_signal_val.is_nan()
|
||||
&& previous.macd < previous.macd_signal
|
||||
&& macd > macd_signal_val;
|
||||
|
||||
let macd_crossed_down = !previous.macd.is_nan()
|
||||
&& !previous.macd_signal.is_nan()
|
||||
&& !macd.is_nan()
|
||||
&& !macd_signal_val.is_nan()
|
||||
&& previous.macd > previous.macd_signal
|
||||
&& macd < macd_signal_val;
|
||||
|
||||
// EMA trend
|
||||
// EMA state
|
||||
let ema_bullish = !ema_short.is_nan() && !ema_long.is_nan() && ema_short > ema_long;
|
||||
|
||||
// ADX trend strength
|
||||
let is_trending = adx > ADX_THRESHOLD;
|
||||
let strong_trend = adx > ADX_STRONG;
|
||||
let trend_up = di_plus > di_minus;
|
||||
|
||||
// Calculate scores
|
||||
let mut buy_score: f64 = 0.0;
|
||||
let mut sell_score: f64 = 0.0;
|
||||
|
||||
// TREND STRENGTH FILTER
|
||||
if is_trending {
|
||||
if trend_up && trend_bullish {
|
||||
buy_score += 3.0;
|
||||
} else if !trend_up && !trend_bullish {
|
||||
sell_score += 3.0;
|
||||
}
|
||||
} else {
|
||||
// Ranging market - use mean reversion
|
||||
if bb_pct < 0.1 {
|
||||
buy_score += 2.0;
|
||||
} else if bb_pct > 0.9 {
|
||||
sell_score += 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
// PULLBACK ENTRY
|
||||
if trend_bullish && ema_bullish {
|
||||
if !rsi.is_nan() && rsi > RSI_PULLBACK_LOW && rsi < RSI_PULLBACK_HIGH {
|
||||
buy_score += 3.0;
|
||||
}
|
||||
if ema_distance > 0.0 && ema_distance < 0.03 {
|
||||
buy_score += 1.5;
|
||||
}
|
||||
if bb_pct < 0.3 {
|
||||
buy_score += 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
// OVERSOLD/OVERBOUGHT
|
||||
if !rsi.is_nan() {
|
||||
if rsi < RSI_OVERSOLD {
|
||||
if trend_bullish {
|
||||
buy_score += 4.0;
|
||||
} else {
|
||||
buy_score += 2.0;
|
||||
}
|
||||
} else if rsi > RSI_OVERBOUGHT {
|
||||
sell_score += 3.0;
|
||||
}
|
||||
}
|
||||
|
||||
// MACD MOMENTUM
|
||||
if macd_crossed_up {
|
||||
buy_score += 2.5;
|
||||
if strong_trend && trend_up {
|
||||
buy_score += 1.0;
|
||||
}
|
||||
} else if macd_crossed_down {
|
||||
sell_score += 2.5;
|
||||
} else if !macd_hist.is_nan() {
|
||||
if macd_hist > 0.0 {
|
||||
buy_score += 0.5;
|
||||
} else if macd_hist < 0.0 {
|
||||
sell_score += 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// MOMENTUM
|
||||
if !momentum.is_nan() {
|
||||
if momentum > 5.0 {
|
||||
buy_score += 2.0;
|
||||
} else if momentum > 2.0 {
|
||||
buy_score += 1.0;
|
||||
} else if momentum < -5.0 {
|
||||
sell_score += 2.0;
|
||||
} else if momentum < -2.0 {
|
||||
sell_score += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// EMA CROSSOVER
|
||||
let prev_ema_bullish = !previous.ema_short.is_nan()
|
||||
&& !previous.ema_long.is_nan()
|
||||
&& previous.ema_short > previous.ema_long;
|
||||
|
||||
if ema_bullish && !prev_ema_bullish {
|
||||
buy_score += 2.0;
|
||||
} else if !ema_bullish && prev_ema_bullish {
|
||||
sell_score += 2.0;
|
||||
} else if ema_bullish {
|
||||
buy_score += 0.5;
|
||||
} else {
|
||||
sell_score += 0.5;
|
||||
}
|
||||
let has_momentum = !momentum.is_nan() && momentum > 0.0;
|
||||
|
||||
// VOLUME CONFIRMATION
|
||||
let has_volume = volume_ratio >= VOLUME_THRESHOLD;
|
||||
if has_volume && volume_ratio > 1.5 {
|
||||
if buy_score > sell_score {
|
||||
buy_score += 1.0;
|
||||
} else if sell_score > buy_score {
|
||||
sell_score += 1.0;
|
||||
let mut buy_score: f64 = 0.0;
|
||||
let mut sell_score: f64 = 0.0;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// BUY LOGIC: Hierarchical filter (all gates must pass)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// GATE 1: Trend must be confirmed (price > EMA-trend AND EMA alignment)
|
||||
// Without this, no buy signal at all. This is the Faber (2007) filter
|
||||
// that alone produces positive risk-adjusted returns.
|
||||
if trend_bullish && ema_bullish {
|
||||
// GATE 2: Positive time-series momentum (Moskowitz et al. 2012)
|
||||
if has_momentum {
|
||||
// Base score for being in a confirmed uptrend with positive momentum
|
||||
buy_score += 4.0;
|
||||
|
||||
// TIMING: RSI-14 pullback in uptrend (the "buy the dip" pattern)
|
||||
// Widened to 25-55: in strong uptrends RSI often stays 40-65,
|
||||
// so the old 30-50 window missed many good pullback entries.
|
||||
if !rsi.is_nan() && rsi >= 25.0 && rsi <= 55.0 {
|
||||
buy_score += 3.0;
|
||||
}
|
||||
// Moderate pullback (RSI 55-65) still gets some credit
|
||||
else if !rsi.is_nan() && rsi > 55.0 && rsi <= 65.0 {
|
||||
buy_score += 1.0;
|
||||
}
|
||||
// RSI > 70 = overbought, do not add to buy score (chasing)
|
||||
|
||||
// CONVICTION BOOSTERS (each adds incremental edge)
|
||||
|
||||
// Strong directional trend (ADX > 25, DI+ dominant)
|
||||
if adx > ADX_TREND_THRESHOLD && di_plus > di_minus {
|
||||
buy_score += 1.5;
|
||||
}
|
||||
|
||||
// MACD histogram positive and increasing = accelerating momentum
|
||||
if !macd_hist.is_nan() && macd_hist > 0.0 {
|
||||
buy_score += 1.0;
|
||||
// MACD just crossed up = fresh momentum impulse
|
||||
let macd_crossed_up = !previous.macd.is_nan()
|
||||
&& !previous.macd_signal.is_nan()
|
||||
&& !current.macd.is_nan()
|
||||
&& !current.macd_signal.is_nan()
|
||||
&& previous.macd < previous.macd_signal
|
||||
&& current.macd > current.macd_signal;
|
||||
if macd_crossed_up {
|
||||
buy_score += 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Volume confirmation: above-average volume = institutional interest
|
||||
if volume_ratio >= VOLUME_THRESHOLD {
|
||||
buy_score += 0.5;
|
||||
} else {
|
||||
// Low volume = less reliable, reduce score
|
||||
buy_score *= 0.7;
|
||||
}
|
||||
|
||||
// Strong momentum bonus (ROC > 10% = strong trend)
|
||||
if momentum > 10.0 {
|
||||
buy_score += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DETERMINE SIGNAL
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SELL LOGIC: Exit when trend breaks or momentum reverses
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// CRITICAL SELL: Trend break — price drops below EMA-trend
|
||||
// This is the single most important exit signal. When the long-term
|
||||
// trend breaks, the position has no structural support.
|
||||
if !trend_bullish {
|
||||
sell_score += 4.0;
|
||||
|
||||
// If also EMA death cross, very strong sell
|
||||
if !ema_bullish {
|
||||
sell_score += 2.0;
|
||||
}
|
||||
|
||||
// Momentum confirming the breakdown
|
||||
if !momentum.is_nan() && momentum < -5.0 {
|
||||
sell_score += 2.0;
|
||||
} else if !momentum.is_nan() && momentum < 0.0 {
|
||||
sell_score += 1.0;
|
||||
}
|
||||
}
|
||||
// Trend still intact but showing weakness
|
||||
else {
|
||||
// EMA death cross while still above trend EMA = early warning
|
||||
if !ema_bullish && prev_ema_bullish {
|
||||
sell_score += 3.0;
|
||||
}
|
||||
|
||||
// Momentum has reversed significantly (still above EMA-trend though)
|
||||
if !momentum.is_nan() && momentum < -10.0 {
|
||||
sell_score += 3.0;
|
||||
} else if !momentum.is_nan() && momentum < -5.0 {
|
||||
sell_score += 1.5;
|
||||
}
|
||||
|
||||
// MACD crossed down = momentum decelerating
|
||||
let macd_crossed_down = !previous.macd.is_nan()
|
||||
&& !previous.macd_signal.is_nan()
|
||||
&& !current.macd.is_nan()
|
||||
&& !current.macd_signal.is_nan()
|
||||
&& previous.macd > previous.macd_signal
|
||||
&& current.macd < current.macd_signal;
|
||||
if macd_crossed_down {
|
||||
sell_score += 2.0;
|
||||
}
|
||||
|
||||
// RSI extremely overbought (>80) in deteriorating momentum
|
||||
if !rsi.is_nan() && rsi > 80.0 && !momentum.is_nan() && momentum < 5.0 {
|
||||
sell_score += 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SIGNAL DETERMINATION
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
let total_score = buy_score - sell_score;
|
||||
|
||||
let signal = if total_score >= 6.0 {
|
||||
let signal = if total_score >= 7.0 {
|
||||
Signal::StrongBuy
|
||||
} else if total_score >= 3.5 {
|
||||
} else if total_score >= 4.0 {
|
||||
Signal::Buy
|
||||
} else if total_score <= -6.0 {
|
||||
} else if total_score <= -7.0 {
|
||||
Signal::StrongSell
|
||||
} else if total_score <= -3.5 {
|
||||
} else if total_score <= -4.0 {
|
||||
Signal::Sell
|
||||
} else {
|
||||
Signal::Hold
|
||||
};
|
||||
|
||||
// Confidence now reflects the hierarchical gating: a score of 4.0 from
|
||||
// the gated system is worth much more than 4.0 from the old additive system.
|
||||
let confidence = (total_score.abs() / 10.0).min(1.0);
|
||||
|
||||
TradeSignal {
|
||||
symbol: symbol.to_string(),
|
||||
signal,
|
||||
rsi: if rsi.is_nan() { 0.0 } else { rsi },
|
||||
macd: if macd.is_nan() { 0.0 } else { macd },
|
||||
macd_signal: if macd_signal_val.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
macd_signal_val
|
||||
},
|
||||
macd: if current.macd.is_nan() { 0.0 } else { current.macd },
|
||||
macd_signal: if current.macd_signal.is_nan() { 0.0 } else { current.macd_signal },
|
||||
macd_histogram: if macd_hist.is_nan() { 0.0 } else { macd_hist },
|
||||
momentum: if momentum.is_nan() { 0.0 } else { momentum },
|
||||
ema_short: if ema_short.is_nan() { 0.0 } else { ema_short },
|
||||
ema_long: if ema_long.is_nan() { 0.0 } else { ema_long },
|
||||
current_price,
|
||||
confidence,
|
||||
atr: if current.atr.is_nan() { 0.0 } else { current.atr },
|
||||
atr_pct: if current.atr_pct.is_nan() { 0.0 } else { current.atr_pct },
|
||||
}
|
||||
}
|
||||
|
||||
67
src/main.rs
67
src/main.rs
@@ -21,6 +21,7 @@ mod config;
|
||||
mod dashboard;
|
||||
mod indicators;
|
||||
mod paths;
|
||||
mod strategy;
|
||||
mod types;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -49,7 +50,8 @@ use crate::config::{Timeframe, DEFAULT_INITIAL_CAPITAL};
|
||||
Backtest 6 months: invest-bot --backtest --months 6\n \
|
||||
Backtest 1y 6m: invest-bot --backtest --years 1 --months 6\n \
|
||||
Custom capital: invest-bot --backtest --years 5 --capital 50000\n \
|
||||
Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly"
|
||||
Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly\n \
|
||||
Custom date range: invest-bot --backtest --start-date 2007-01-01 --end-date 2008-12-31"
|
||||
)]
|
||||
struct Args {
|
||||
/// Run in backtest mode instead of live trading
|
||||
@@ -64,6 +66,14 @@ struct Args {
|
||||
#[arg(short, long, default_value_t = 0.0)]
|
||||
months: f64,
|
||||
|
||||
/// Start date for backtest (YYYY-MM-DD). Overrides --years/--months if provided.
|
||||
#[arg(long, value_name = "YYYY-MM-DD")]
|
||||
start_date: Option<String>,
|
||||
|
||||
/// End date for backtest (YYYY-MM-DD). Defaults to now if not provided.
|
||||
#[arg(long, value_name = "YYYY-MM-DD")]
|
||||
end_date: Option<String>,
|
||||
|
||||
/// Initial capital for backtesting
|
||||
#[arg(short, long, default_value_t = DEFAULT_INITIAL_CAPITAL)]
|
||||
capital: f64,
|
||||
@@ -170,14 +180,45 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
async fn run_backtest(api_key: String, api_secret: String, args: Args) -> Result<()> {
|
||||
// Combine years and months (default to 1 year if neither specified)
|
||||
let total_years = args.years + (args.months / 12.0);
|
||||
let total_years = if total_years <= 0.0 { 1.0 } else { total_years };
|
||||
use chrono::NaiveDate;
|
||||
|
||||
let client = AlpacaClient::new(api_key, api_secret)?;
|
||||
let mut backtester = Backtester::new(args.capital, args.timeframe);
|
||||
|
||||
let result = backtester.run(&client, total_years).await?;
|
||||
let result = if args.start_date.is_some() || args.end_date.is_some() {
|
||||
// Custom date range mode
|
||||
let start_date = if let Some(ref s) = args.start_date {
|
||||
NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.context("Invalid start date format. Use YYYY-MM-DD (e.g., 2007-01-01)")?
|
||||
} else {
|
||||
// If no start date provided, default to 1 year before end date
|
||||
let end = if let Some(ref e) = args.end_date {
|
||||
NaiveDate::parse_from_str(e, "%Y-%m-%d")?
|
||||
} else {
|
||||
chrono::Utc::now().date_naive()
|
||||
};
|
||||
end - chrono::Duration::days(365)
|
||||
};
|
||||
|
||||
let end_date = if let Some(ref e) = args.end_date {
|
||||
NaiveDate::parse_from_str(e, "%Y-%m-%d")
|
||||
.context("Invalid end date format. Use YYYY-MM-DD (e.g., 2008-12-31)")?
|
||||
} else {
|
||||
chrono::Utc::now().date_naive()
|
||||
};
|
||||
|
||||
// Validate date range
|
||||
if start_date >= end_date {
|
||||
anyhow::bail!("Start date must be before end date");
|
||||
}
|
||||
|
||||
backtester.run_with_dates(&client, start_date, end_date).await?
|
||||
} else {
|
||||
// Years/months mode (existing behavior)
|
||||
let total_years = args.years + (args.months / 12.0);
|
||||
let total_years = if total_years <= 0.0 { 1.0 } else { total_years };
|
||||
backtester.run(&client, total_years).await?
|
||||
};
|
||||
|
||||
// Save results to CSV
|
||||
save_backtest_results(&result)?;
|
||||
@@ -191,17 +232,27 @@ async fn run_live_trading(api_key: String, api_secret: String, args: Args) -> Re
|
||||
.parse()
|
||||
.unwrap_or(5000);
|
||||
|
||||
// Create the bot first to load its state
|
||||
let mut bot = TradingBot::new(api_key.clone(), api_secret.clone(), args.timeframe).await?;
|
||||
|
||||
// Create a separate client for the dashboard
|
||||
let dashboard_client = AlpacaClient::new(api_key.clone(), api_secret.clone())?;
|
||||
|
||||
// Extract data for the dashboard
|
||||
let init_data = dashboard::DashboardInitData {
|
||||
entry_atrs: bot.get_entry_atrs(),
|
||||
high_water_marks: bot.get_high_water_marks(),
|
||||
};
|
||||
|
||||
// Spawn dashboard in background
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = dashboard::start_dashboard(dashboard_client, dashboard_port).await {
|
||||
if let Err(e) =
|
||||
dashboard::start_dashboard(dashboard_client, dashboard_port, init_data).await
|
||||
{
|
||||
tracing::error!("Dashboard error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Run the trading bot
|
||||
let mut bot = TradingBot::new(api_key, api_secret, args.timeframe).await?;
|
||||
// Now run the bot's main loop
|
||||
bot.run().await
|
||||
}
|
||||
|
||||
29
src/paths.rs
29
src/paths.rs
@@ -37,10 +37,39 @@ lazy_static! {
|
||||
path
|
||||
};
|
||||
|
||||
/// Path to the live entry ATR values JSON file.
|
||||
pub static ref LIVE_ENTRY_ATRS_FILE: PathBuf = {
|
||||
let mut path = DATA_DIR.clone();
|
||||
path.push("live_entry_atrs.json");
|
||||
path
|
||||
};
|
||||
|
||||
/// Path to the live position metadata JSON file (bars held, partial exit status).
|
||||
pub static ref LIVE_POSITION_META_FILE: PathBuf = {
|
||||
let mut path = DATA_DIR.clone();
|
||||
path.push("live_position_meta.json");
|
||||
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.
|
||||
pub static ref LOG_FILE: PathBuf = {
|
||||
let mut path = DATA_DIR.clone();
|
||||
path.push("trading_bot.log");
|
||||
path
|
||||
};
|
||||
|
||||
/// Base directory for backtest data cache.
|
||||
pub static ref BACKTEST_CACHE_DIR: PathBuf = {
|
||||
let mut path = DATA_DIR.clone();
|
||||
path.push("cache");
|
||||
std::fs::create_dir_all(&path).expect("Failed to create cache directory");
|
||||
path
|
||||
};
|
||||
}
|
||||
|
||||
190
src/strategy.rs
Normal file
190
src/strategy.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! Common trading strategy logic used by both the live bot and the backtester.
|
||||
use std::collections::HashMap;
|
||||
use crate::config::{
|
||||
get_sector, IndicatorParams, Timeframe, ATR_STOP_MULTIPLIER,
|
||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER,
|
||||
BREAKEVEN_ACTIVATION_PCT, BREAKEVEN_MAX_LOSS_PCT,
|
||||
EARLY_TRAIL_ACTIVATION_MULTIPLIER, EARLY_TRAIL_MULTIPLIER,
|
||||
MAX_LOSS_PCT, MAX_POSITION_SIZE,
|
||||
MIN_ATR_PCT, RISK_PER_TRADE,
|
||||
SLOW_BLEED_BARS, SLOW_BLEED_MAX_LOSS, SLOW_BLEED_MIN_GAIN,
|
||||
STOP_LOSS_PCT, TIME_EXIT_BARS,
|
||||
TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
|
||||
};
|
||||
use crate::types::{Signal, TradeSignal};
|
||||
|
||||
/// Contains the core trading strategy logic.
|
||||
pub struct Strategy {
|
||||
pub params: IndicatorParams,
|
||||
pub high_water_marks: HashMap<String, f64>,
|
||||
pub entry_atrs: HashMap<String, f64>,
|
||||
pub entry_prices: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
impl Strategy {
|
||||
pub fn new(timeframe: Timeframe) -> Self {
|
||||
Self {
|
||||
params: timeframe.params(),
|
||||
high_water_marks: HashMap::new(),
|
||||
entry_atrs: HashMap::new(),
|
||||
entry_prices: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Volatility-adjusted position sizing using ATR (Kelly-inspired).
|
||||
///
|
||||
/// Position size = (Risk per trade / ATR stop distance) * confidence.
|
||||
/// The confidence scaling now has a much wider range (0.4 to 1.0) so that
|
||||
/// weak Buy signals (confidence ~0.4) get 40% size while StrongBuy signals
|
||||
/// (confidence ~1.0) get full size. This is a fractional Kelly approach:
|
||||
/// bet more when conviction is higher, less when marginal.
|
||||
pub fn calculate_position_size(
|
||||
&self,
|
||||
price: f64,
|
||||
portfolio_value: f64,
|
||||
available_cash: f64,
|
||||
signal: &TradeSignal,
|
||||
) -> f64 {
|
||||
if available_cash <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let position_value = if signal.atr_pct > MIN_ATR_PCT {
|
||||
let atr_stop_pct = signal.atr_pct * ATR_STOP_MULTIPLIER;
|
||||
let risk_amount = portfolio_value * RISK_PER_TRADE;
|
||||
let vol_adjusted = risk_amount / atr_stop_pct;
|
||||
// Wide confidence scaling: 0.4x for weak signals, 1.0x for strongest.
|
||||
// Old code used 0.7 + 0.3*conf which barely differentiated.
|
||||
let confidence_scale = 0.4 + 0.6 * signal.confidence;
|
||||
let sized = vol_adjusted * confidence_scale;
|
||||
sized.min(portfolio_value * MAX_POSITION_SIZE)
|
||||
} else {
|
||||
portfolio_value * MAX_POSITION_SIZE
|
||||
};
|
||||
|
||||
let position_value = position_value.min(available_cash);
|
||||
// Use fractional shares -- Alpaca supports them for paper trading.
|
||||
// Truncate to 4 decimal places to avoid floating point dust.
|
||||
((position_value / price) * 10000.0).floor() / 10000.0
|
||||
}
|
||||
|
||||
/// Check if stop-loss, trailing stop, or time exit should trigger.
|
||||
///
|
||||
/// Exit priority (checked in order):
|
||||
/// 1. Hard max-loss cap (MAX_LOSS_PCT) -- gap protection
|
||||
/// 2. ATR-based stop-loss -- primary risk control
|
||||
/// 3. Fixed % stop-loss -- fallback when ATR unavailable
|
||||
/// 4. Breakeven ratchet -- once in profit, never lose more than 1%
|
||||
/// 5. Tiered trailing stop:
|
||||
/// - Small gains (0.5x ATR): tight trail (1.5x ATR)
|
||||
/// - Big gains (2.0x ATR): wide trail (3.0x ATR)
|
||||
/// 6. Time-based exit -- only if position is LOSING
|
||||
pub fn check_stop_loss_take_profit(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
current_price: f64,
|
||||
bars_held: usize,
|
||||
) -> Option<Signal> {
|
||||
let entry_price = match self.entry_prices.get(symbol) {
|
||||
Some(&p) => p,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let pnl_pct = (current_price - entry_price) / entry_price;
|
||||
let entry_atr = self.entry_atrs.get(symbol).copied().unwrap_or(0.0);
|
||||
|
||||
// Update high water mark
|
||||
if let Some(hwm) = self.high_water_marks.get_mut(symbol) {
|
||||
if current_price > *hwm {
|
||||
*hwm = current_price;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Hard max-loss cap (catastrophic gap protection)
|
||||
if pnl_pct <= -MAX_LOSS_PCT {
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
|
||||
// 2. ATR-based initial stop-loss (primary risk control)
|
||||
if entry_atr > 0.0 {
|
||||
let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr;
|
||||
if current_price <= atr_stop_price {
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
} else if pnl_pct <= -STOP_LOSS_PCT {
|
||||
// 3. Fixed percentage fallback
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
|
||||
// 4. Breakeven ratchet: once we've been in profit, cap downside to -1%
|
||||
if pnl_pct <= -BREAKEVEN_MAX_LOSS_PCT {
|
||||
if let Some(&high_water) = self.high_water_marks.get(symbol) {
|
||||
let best_pnl = (high_water - entry_price) / entry_price;
|
||||
if best_pnl >= BREAKEVEN_ACTIVATION_PCT {
|
||||
// Was in profit but now losing > 1% — get out
|
||||
return Some(Signal::Sell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Tiered ATR trailing stop (profit protection)
|
||||
// Tier 1: small gains (0.5x ATR) → tight trail (1.5x ATR)
|
||||
// Tier 2: big gains (2.0x ATR) → wide trail (3.0x ATR) to let winners run
|
||||
if let Some(&high_water) = self.high_water_marks.get(symbol) {
|
||||
let best_pnl = (high_water - entry_price) / entry_price;
|
||||
|
||||
let (activation_gain, trail_distance) = if entry_atr > 0.0 {
|
||||
let big_activation = (ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price;
|
||||
let small_activation = (EARLY_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price;
|
||||
|
||||
if best_pnl >= big_activation {
|
||||
// Tier 2: big winner — wide trail
|
||||
(big_activation, ATR_TRAIL_MULTIPLIER * entry_atr)
|
||||
} else {
|
||||
// Tier 1: small gain — tight trail
|
||||
(small_activation, EARLY_TRAIL_MULTIPLIER * entry_atr)
|
||||
}
|
||||
} else {
|
||||
(TRAILING_STOP_ACTIVATION, high_water * TRAILING_STOP_DISTANCE)
|
||||
};
|
||||
|
||||
if pnl_pct >= activation_gain {
|
||||
let trailing_stop_price = high_water - trail_distance;
|
||||
if current_price <= trailing_stop_price {
|
||||
return Some(Signal::Sell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Count positions in a given sector.
|
||||
pub fn sector_position_count<'a, I>(&self, sector: &str, positions: I) -> usize
|
||||
where
|
||||
I: IntoIterator<Item = &'a String>,
|
||||
{
|
||||
positions
|
||||
.into_iter()
|
||||
.filter(|sym| get_sector(sym) == sector)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
40
src/types.rs
40
src/types.rs
@@ -1,8 +1,40 @@
|
||||
//! Data types and structures for the trading bot.
|
||||
|
||||
use crate::config::ALLOW_LONGS_IN_BEAR_MARKET;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Broad market regime determined from SPY price action.
|
||||
///
|
||||
/// Based on Faber (2007) dual moving average framework:
|
||||
/// - Bull: SPY above EMA-200 and EMA-50 above EMA-200 (golden cross territory)
|
||||
/// - Caution: SPY below EMA-50 but still above EMA-200 (early weakness)
|
||||
/// - Bear: SPY below EMA-200 and EMA-50 below EMA-200 (death cross territory)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MarketRegime {
|
||||
Bull,
|
||||
Caution,
|
||||
Bear,
|
||||
}
|
||||
|
||||
impl MarketRegime {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
MarketRegime::Bull => "BULL",
|
||||
MarketRegime::Caution => "CAUTION",
|
||||
MarketRegime::Bear => "BEAR",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether new long entries are permitted in this regime.
|
||||
pub fn allows_new_longs(&self) -> bool {
|
||||
match self {
|
||||
MarketRegime::Bear => ALLOW_LONGS_IN_BEAR_MARKET,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trading signal types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -48,6 +80,8 @@ pub struct TradeSignal {
|
||||
pub ema_long: f64,
|
||||
pub current_price: f64,
|
||||
pub confidence: f64,
|
||||
pub atr: f64,
|
||||
pub atr_pct: f64,
|
||||
}
|
||||
|
||||
/// Represents a completed trade for tracking.
|
||||
@@ -71,6 +105,8 @@ pub struct BacktestPosition {
|
||||
pub shares: f64,
|
||||
pub entry_price: f64,
|
||||
pub entry_time: DateTime<Utc>,
|
||||
pub entry_atr: f64,
|
||||
pub bars_held: usize,
|
||||
}
|
||||
|
||||
/// Results from a backtest run.
|
||||
@@ -106,7 +142,7 @@ pub struct EquityPoint {
|
||||
}
|
||||
|
||||
/// OHLCV bar data.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Bar {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub open: f64,
|
||||
@@ -129,6 +165,7 @@ pub struct IndicatorRow {
|
||||
|
||||
// RSI
|
||||
pub rsi: f64,
|
||||
pub rsi_short: f64, // RSI-2/3 for mean reversion
|
||||
|
||||
// MACD
|
||||
pub macd: f64,
|
||||
@@ -178,6 +215,7 @@ impl Default for IndicatorRow {
|
||||
close: 0.0,
|
||||
volume: 0.0,
|
||||
rsi: 0.0,
|
||||
rsi_short: 0.0,
|
||||
macd: 0.0,
|
||||
macd_signal: 0.0,
|
||||
macd_histogram: 0.0,
|
||||
|
||||
Reference in New Issue
Block a user