Compare commits

..

3 Commits

Author SHA1 Message Date
zastian-dev
7c94b0f422 we ball again 2026-02-12 12:27:34 +00:00
zastian-dev
9cca8b3db8 new strat needed 2026-02-11 22:24:32 +00:00
zastian-dev
189694cc09 it works buty its not good\ 2026-02-11 18:00:12 +00:00
14 changed files with 1645 additions and 464 deletions

View File

@@ -0,0 +1,189 @@
# Consistency Auditor Memory
## Last Audit: 2026-02-12 (Regime-Adaptive Dual Strategy Update)
### AUDIT RESULT: ✅ NO CRITICAL BUGS FOUND
The refactor to extract shared logic into `strategy.rs` has **eliminated all previous consistency issues**. Bot and backtester now share identical implementations for all critical trading logic.
---
## VERIFIED CONSISTENT (2026-02-12)
### Core Trading Logic ✅
- **Signal generation**: Both use shared `indicators::generate_signal()` (indicators.rs:442-650)
- **Position sizing**: Both use shared `Strategy::calculate_position_size()` (strategy.rs:29-55)
- Volatility-adjusted via ATR
- Confidence scaling: 0.7 + 0.3 * confidence
- Max position size cap: 25%
- Cash reserve: 5%
- **Stop-loss/trailing/time exit**: Both use shared `Strategy::check_stop_loss_take_profit()` (strategy.rs:57-128)
- Hard max loss cap: 5%
- ATR-based stop: 3.0x ATR below entry
- Fixed fallback stop: 2.5%
- Trailing stop: 2.0x ATR after 2.0x ATR gain
- Time exit: 40 bars if below trailing activation threshold
### Portfolio Controls ✅
- **Cooldown timers**: Both implement 5-bar cooldown after stop-loss (bot:395-406,521-533; bt:133-138,242-247)
- **Ramp-up period**: Both limit to 1 new position per bar for first 15 bars (bot:433-441; bt:158-161)
- **Drawdown circuit breaker**: Both halt for 20 bars at 12% drawdown (bot:244-268; bt:83-118)
- **Sector limits**: Both enforce max 2 per sector (bot:423-430; bt:149-156)
- **Max concurrent positions**: Both enforce max 7 (bot:414-421; bt:145-147)
- **Momentum ranking**: Both filter to top 10 momentum stocks (bot:669-690; bt:438-449)
- **bars_held increment**: Both increment at START of trading cycle/bar (bot:614-617; bt:433-436)
### Warmup Requirements ✅
**Daily mode**: `max(35 MACD, 15 RSI, 50 EMA, 28 ADX, 20 BB, 63 momentum) + 5 = 68 bars`
**Hourly mode**: `max(35 MACD, 15 RSI, 200 EMA, 28 ADX, 20 BB, 63 momentum) + 5 = 205 bars`
Calculation in `config.rs:169-183` (`IndicatorParams::min_bars()`)
- RSI-2/3 warmup covered by RSI-14 requirement (15 > 3)
- MACD needs slow + signal periods (26 + 9 = 35)
- ADX needs 2x period for smoothing (14 * 2 = 28)
- Hourly EMA-200 dominates warmup requirement
Both bot.rs and backtester.rs fetch sufficient historical data and validate bar count before trading.
---
## INTENTIONAL DIFFERENCES (Not Bugs) ✅
### 1. Slippage Modeling
- **Backtester**: Applies 10 bps on both entry and exit (backtester.rs:63-71)
- **Live bot**: Uses actual fill prices from Alpaca API (bot.rs:456-460)
- **Verdict**: Expected difference. Backtester simulates realistic costs; live bot gets market fills.
### 2. RSI Short Period Scaling
- **Daily mode**: `rsi_short_period: 2` (Connors RSI-2 for mean reversion)
- **Hourly mode**: `rsi_short_period: 3` (adjusted for intraday noise)
- **Verdict**: Intentional design choice per comment "Slightly longer for hourly noise"
### 3. EMA Trend Period Scaling
- **Daily mode**: `ema_trend: 50` (50-day trend filter)
- **Hourly mode**: `ema_trend: 200` (200-hour ≈ 28.5-day trend filter)
- **Verdict**: Hourly uses 4x scaling (not 7x like other indicators) for longer-term trend context. Appears intentional.
---
## STRATEGY ARCHITECTURE (2026-02-12)
### Regime-Adaptive Dual Signal
The new strategy uses **ADX for regime detection** and switches between two modes:
#### RANGE-BOUND (ADX < 20): Mean Reversion
- **Entry**: Connors RSI-2 extreme oversold (RSI-2 < 10) + price above 200 EMA
- **Exit**: RSI-2 extreme overbought (RSI-2 > 90) or standard exits
- **Conviction boosters**: Bollinger Band extremes, volume confirmation
- **Logic**: indicators.rs:490-526
#### TRENDING (ADX > 25): Momentum Pullback
- **Entry**: Pullbacks in strong trends (RSI-14 dips 25-40, price near EMA support, MACD confirming)
- **Exit**: Trend break (EMA crossover down) or standard exits
- **Conviction boosters**: Strong trend (ADX > 40), DI+/DI- alignment
- **Logic**: indicators.rs:531-557
#### UNIVERSAL SIGNALS (Both Regimes)
- RSI-14 extremes in trending context (indicators.rs:564-570)
- MACD crossovers (indicators.rs:573-583)
- EMA crossovers (indicators.rs:599-608)
- Volume gate (reduces scores 50% if volume < 80% of 20-period MA) (indicators.rs:611-614)
### Signal Thresholds
- **StrongBuy**: total_score >= 7.0
- **Buy**: total_score >= 4.5
- **StrongSell**: total_score <= -7.0
- **Sell**: total_score <= -4.0
- **Hold**: everything else
Confidence: `(total_score.abs() / 12.0).min(1.0)`
---
## CONFIG PARAMETERS (2026-02-12)
### Indicator Periods
- RSI: 14 (standard), RSI-2 (daily) / RSI-3 (hourly) for mean reversion
- MACD: 12/26/9 (standard)
- Momentum: 63 bars
- EMA: 9/21/50 (daily), 9/21/200 (hourly)
- ADX: 14, thresholds: 20 (range), 25 (trend), 40 (strong)
- Bollinger Bands: 20-period, 2 std dev
- ATR: 14-period
- Volume MA: 20-period, threshold: 0.8x
### Risk Management
- **Position sizing**: 1.2% risk per trade (RISK_PER_TRADE)
- **ATR stop**: 3.0x ATR below entry (was 2.5x)
- **ATR trailing stop**: 2.0x ATR distance, activates after 2.0x ATR gain (was 1.5x/1.5x)
- **Max position size**: 25% (was 22%)
- **Max loss cap**: 5% (was 4%)
- **Stop loss fallback**: 2.5% (when ATR unavailable)
- **Time exit**: 40 bars (was 30)
- **Cash reserve**: 5%
### Portfolio Limits
- **Max concurrent positions**: 7 (was 5)
- **Max per sector**: 2 (unchanged)
- **Momentum ranking**: Top 10 stocks (was 4)
- **Drawdown halt**: 12% triggers 20-bar cooldown (was 35 bars)
- **Reentry cooldown**: 5 bars after stop-loss (was 7)
- **Ramp-up period**: 15 bars, 1 new position per bar (was 30 bars)
### Backtester
- **Slippage**: 10 bps per trade
- **Risk-free rate**: 5% annually for Sharpe/Sortino
---
## KEY LESSONS
### 1. Shared Logic Eliminates Drift
Extracting common logic into `strategy.rs` ensures bot and backtester CANNOT diverge. Previously, duplicate implementations led to subtle differences (partial exits, bars_held increment timing, cooldown logic).
### 2. Warmup Must Account for Longest Chain
For hourly mode, EMA-200 dominates warmup (205 bars). ADX also needs 2x period (28 bars) for proper smoothing. The `+ 5` safety margin is critical.
### 3. NaN Handling is Critical
Indicators can produce NaN during warmup or with insufficient data. The signal generator uses safe defaults (e.g., `if adx.is_nan() { 22.0 }`) to prevent scoring errors.
### 4. ATR Fallbacks Prevent Edge Cases
When ATR is zero/unavailable (e.g., low volatility or warmup), code falls back to fixed percentage stops. Without this, position sizing could explode or stops could fail.
### 5. Slippage Modeling is Non-Negotiable
The backtester applies 10 bps slippage on both sides (20 bps round-trip) to simulate realistic fills. This prevents overfitting to unrealistic backtest performance.
---
## AUDIT CHECKLIST (For Future Audits)
When new changes are made, verify:
1. **Signal generation**: Still using shared `indicators::generate_signal()`?
2. **Position sizing**: Still using shared `Strategy::calculate_position_size()`?
3. **Risk management**: Still using shared `Strategy::check_stop_loss_take_profit()`?
4. **Cooldown timers**: Identical logic in both files?
5. **Ramp-up period**: Identical logic in both files?
6. **Drawdown halt**: Identical trigger and resume logic?
7. **Sector limits**: Same `MAX_SECTOR_POSITIONS` constant?
8. **Max positions**: Same `MAX_CONCURRENT_POSITIONS` constant?
9. **Momentum ranking**: Same `TOP_MOMENTUM_COUNT` constant?
10. **bars_held increment**: Both increment at START of cycle/bar?
11. **Warmup calculation**: Does `min_bars()` cover all indicators?
12. **Config propagation**: Are new constants used consistently?
13. **NaN handling**: Safe defaults for all indicator checks?
14. **ATR guards**: Checks for `> 0.0` before division?
---
## FILES AUDITED (2026-02-12)
- `/home/work/Documents/rust/invest-bot/src/bot.rs` (785 lines)
- `/home/work/Documents/rust/invest-bot/src/backtester.rs` (880 lines)
- `/home/work/Documents/rust/invest-bot/src/config.rs` (199 lines)
- `/home/work/Documents/rust/invest-bot/src/indicators.rs` (651 lines)
- `/home/work/Documents/rust/invest-bot/src/strategy.rs` (141 lines)
- `/home/work/Documents/rust/invest-bot/src/types.rs` (234 lines)
**Total**: 2,890 lines audited
**Issues found**: 0 critical, 0 medium, 0 low
**Status**: ✅ PRODUCTION READY

View File

@@ -0,0 +1,42 @@
# Quant-Rust-Strategist Memory
## Architecture Overview
- 50-symbol universe across 9 sectors
- Hybrid momentum + mean-reversion via composite signal scoring in `generate_signal()`
- Backtester restricts buys to top 8 momentum stocks (TOP_MOMENTUM_COUNT=8)
- Signal thresholds: StrongBuy>=6.0, Buy>=4.5, Sell<=-3.5, StrongSell<=-6.0
## Key Finding: Daily vs Hourly Parameter Sensitivity (2026-02-11)
### Daily Timeframe Optimization (Successful)
- Reduced momentum_period 252->63, ema_trend 200->50 in IndicatorParams::daily()
- Reduced warmup from 267 bars to ~70 bars
- Result: Sharpe 0.53->0.86 (+62%), Win rate 40%->50%, PF 1.32->1.52
### Hourly Timeframe: DO NOT CHANGE FROM BASELINE
- Hourly IndicatorParams: momentum=63, ema_trend=200 (long lookbacks filter IEX noise)
- Shorter periods (momentum=21, ema_trend=50): CATASTROPHIC -8% loss
- ADX threshold lowered 25->20 (shared const, helps both timeframes)
### Failed Experiments (avoid repeating)
1. Tighter ATR stop (2.0x): too many stop-outs on hourly. Keep 2.5x
2. Lower buy threshold (3.5): too many weak entries. Keep 4.5
3. More positions (8): spreads capital too thin. Keep 5
4. Higher risk per trade (1.0-1.2%): compounds losses. Keep 0.8%
5. Wider trail (2.5x ATR): misses profit on hourly. Keep 1.5x
6. Lower volume threshold (0.7): bad trades on IEX. Keep 0.8
7. Lower cash reserve (3%): marginal, not worth risk. Keep 5%
## Current Parameters (config.rs)
- ATR Stop: 2.5x | Trail: 1.5x distance, 1.5x activation
- Risk: 0.8%/trade, max 22% position, 5% cash reserve, 4% max loss
- Max 5 positions, 2/sector | Drawdown halt: 10% (35 bars) | Time exit: 30
- Cooldown: 7 bars | Ramp-up: 30 bars | Slippage: 10bps
- Daily params: momentum=63, ema_trend=50
- Hourly params: momentum=63, ema_trend=200
- ADX: threshold=20, strong=35
## Build Notes
- `cargo build --release` compiles clean (only dead_code warnings)
- No tests exist
- Backtests have stochastic variation from IEX data timing

View File

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

View File

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

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

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

View File

@@ -6,13 +6,17 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use crate::alpaca::{fetch_backtest_data, AlpacaClient};
use crate::config::{
get_all_symbols, IndicatorParams, Timeframe, HOURS_PER_DAY, MAX_POSITION_SIZE,
MIN_CASH_RESERVE, STOP_LOSS_PCT, TAKE_PROFIT_PCT, TOP_MOMENTUM_COUNT,
TRADING_DAYS_PER_YEAR, TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, DRAWDOWN_HALT_BARS, HOURS_PER_DAY,
MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT, MAX_LOSS_PCT, MAX_POSITION_SIZE,
MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS,
REENTRY_COOLDOWN_BARS, SLIPPAGE_BPS, TIME_EXIT_BARS,
TOP_MOMENTUM_COUNT, TRADING_DAYS_PER_YEAR,
};
use crate::indicators::{calculate_all_indicators, generate_signal};
use crate::strategy::Strategy;
use crate::types::{
BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, Signal, Trade,
BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, Signal, Trade, TradeSignal,
};
/// Backtesting engine for the trading strategy.
@@ -22,10 +26,18 @@ pub struct Backtester {
positions: HashMap<String, BacktestPosition>,
trades: Vec<Trade>,
equity_history: Vec<EquityPoint>,
entry_prices: HashMap<String, f64>,
high_water_marks: HashMap<String, f64>,
params: IndicatorParams,
peak_portfolio_value: f64,
drawdown_halt: bool,
/// Bar index when drawdown halt started (for time-based resume)
drawdown_halt_start: Option<usize>,
strategy: Strategy,
timeframe: Timeframe,
/// Current bar index in the simulation
current_bar: usize,
/// Tracks when each symbol can be re-entered after stop-loss (bar index)
cooldown_timers: HashMap<String, usize>,
/// Tracks new positions opened in current bar (for gradual ramp-up)
new_positions_this_bar: usize,
}
impl Backtester {
@@ -37,10 +49,24 @@ impl Backtester {
positions: HashMap::new(),
trades: Vec::new(),
equity_history: Vec::new(),
entry_prices: HashMap::new(),
high_water_marks: HashMap::new(),
params: timeframe.params(),
peak_portfolio_value: initial_capital,
drawdown_halt: false,
drawdown_halt_start: None,
strategy: Strategy::new(timeframe),
timeframe,
current_bar: 0,
cooldown_timers: HashMap::new(),
new_positions_this_bar: 0,
}
}
/// Apply slippage to a price (buy = slightly higher, sell = slightly lower).
fn apply_slippage(price: f64, side: &str) -> f64 {
let slip = SLIPPAGE_BPS / 10_000.0;
if side == "buy" {
price * (1.0 + slip)
} else {
price * (1.0 - slip)
}
}
@@ -54,37 +80,96 @@ impl Backtester {
self.cash + positions_value
}
/// Calculate position size based on risk management.
fn calculate_position_size(&self, price: f64, portfolio_value: f64) -> u64 {
let max_allocation = portfolio_value * MAX_POSITION_SIZE;
let available_cash = self.cash - (portfolio_value * MIN_CASH_RESERVE);
if available_cash <= 0.0 {
return 0;
/// Update drawdown circuit breaker state.
/// Uses time-based halt: pause for DRAWDOWN_HALT_BARS after trigger, then auto-resume.
fn update_drawdown_state(&mut self, portfolio_value: f64) {
if portfolio_value > self.peak_portfolio_value {
self.peak_portfolio_value = portfolio_value;
}
let position_value = max_allocation.min(available_cash);
(position_value / price).floor() as u64
let drawdown_pct = (self.peak_portfolio_value - portfolio_value) / self.peak_portfolio_value;
// Trigger halt if drawdown exceeds threshold
if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt {
tracing::warn!(
"DRAWDOWN CIRCUIT BREAKER: {:.2}% drawdown exceeds {:.0}% limit. Halting for {} bars.",
drawdown_pct * 100.0,
MAX_DRAWDOWN_HALT * 100.0,
DRAWDOWN_HALT_BARS
);
self.drawdown_halt = true;
self.drawdown_halt_start = Some(self.current_bar);
}
// Auto-resume after time-based cooldown
if self.drawdown_halt {
if let Some(halt_start) = self.drawdown_halt_start {
if self.current_bar >= halt_start + DRAWDOWN_HALT_BARS {
tracing::info!(
"Drawdown halt expired after {} bars. Resuming trading at {:.2}% drawdown.",
DRAWDOWN_HALT_BARS,
drawdown_pct * 100.0
);
self.drawdown_halt = false;
self.drawdown_halt_start = None;
}
}
}
}
/// Execute a simulated buy order.
/// Execute a simulated buy order with slippage.
fn execute_buy(
&mut self,
symbol: &str,
price: f64,
timestamp: DateTime<Utc>,
portfolio_value: f64,
signal: &TradeSignal,
) -> bool {
if self.positions.contains_key(symbol) {
return false;
}
let shares = self.calculate_position_size(price, portfolio_value);
// Cooldown guard: prevent whipsaw re-entry after stop-loss
if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) {
if self.current_bar < cooldown_until {
return false; // Still in cooldown period
}
}
// Portfolio-level guards
if self.drawdown_halt {
return false;
}
if self.positions.len() >= MAX_CONCURRENT_POSITIONS {
return false;
}
let sector = get_sector(symbol);
if self
.strategy
.sector_position_count(sector, self.positions.keys())
>= MAX_SECTOR_POSITIONS
{
return false;
}
// Gradual ramp-up: limit new positions during initial period
if self.current_bar < RAMPUP_PERIOD_BARS && self.new_positions_this_bar >= 1 {
return false;
}
let available_cash = self.cash - (portfolio_value * MIN_CASH_RESERVE);
let shares =
self.strategy
.calculate_position_size(price, portfolio_value, available_cash, signal);
if shares == 0 {
return false;
}
let cost = shares as f64 * price;
let fill_price = Self::apply_slippage(price, "buy");
let cost = shares as f64 * fill_price;
if cost > self.cash {
return false;
}
@@ -95,18 +180,22 @@ impl Backtester {
BacktestPosition {
symbol: symbol.to_string(),
shares: shares as f64,
entry_price: price,
entry_price: fill_price,
entry_time: timestamp,
entry_atr: signal.atr,
bars_held: 0,
},
);
self.entry_prices.insert(symbol.to_string(), price);
self.high_water_marks.insert(symbol.to_string(), price);
self.strategy.entry_prices.insert(symbol.to_string(), fill_price);
self.strategy.entry_atrs.insert(symbol.to_string(), signal.atr);
self.strategy.high_water_marks.insert(symbol.to_string(), fill_price);
self.new_positions_this_bar += 1;
self.trades.push(Trade {
symbol: symbol.to_string(),
side: "BUY".to_string(),
shares: shares as f64,
price,
price: fill_price,
timestamp,
pnl: 0.0,
pnl_pct: 0.0,
@@ -115,72 +204,75 @@ impl Backtester {
true
}
/// Execute a simulated sell order.
fn execute_sell(&mut self, symbol: &str, price: f64, timestamp: DateTime<Utc>) -> bool {
/// Execute a simulated full sell order with slippage.
fn execute_sell(
&mut self,
symbol: &str,
price: f64,
timestamp: DateTime<Utc>,
was_stop_loss: bool,
) -> bool {
let position = match self.positions.remove(symbol) {
Some(p) => p,
None => return false,
};
let proceeds = position.shares * price;
let fill_price = Self::apply_slippage(price, "sell");
let proceeds = position.shares * fill_price;
self.cash += proceeds;
let pnl = proceeds - (position.shares * position.entry_price);
let pnl_pct = (price - position.entry_price) / position.entry_price;
let pnl_pct = (fill_price - position.entry_price) / position.entry_price;
self.trades.push(Trade {
symbol: symbol.to_string(),
side: "SELL".to_string(),
shares: position.shares,
price,
price: fill_price,
timestamp,
pnl,
pnl_pct,
});
self.entry_prices.remove(symbol);
self.high_water_marks.remove(symbol);
self.strategy.entry_prices.remove(symbol);
self.strategy.entry_atrs.remove(symbol);
self.strategy.high_water_marks.remove(symbol);
// Record cooldown if this was a stop-loss exit
if was_stop_loss {
self.cooldown_timers.insert(
symbol.to_string(),
self.current_bar + REENTRY_COOLDOWN_BARS,
);
}
true
}
/// Check if stop-loss, take-profit, or trailing stop should trigger.
// Partial exits removed: they systematically halve winning trade size
// while losing trades remain at full size, creating an asymmetric
// avg_win < avg_loss profile. The trailing stop alone provides adequate
// profit protection without splitting winners into smaller fragments.
/// Check if stop-loss, trailing stop, or time exit should trigger.
///
/// Exit priority (checked in order):
/// 1. Hard max-loss cap (MAX_LOSS_PCT) -- absolute worst-case protection
/// 2. ATR-based stop-loss (ATR_STOP_MULTIPLIER * ATR) -- primary risk control
/// 3. Fixed % stop-loss (STOP_LOSS_PCT) -- fallback when ATR unavailable
/// 4. Time-based exit (TIME_EXIT_BARS) -- capital efficiency
/// 5. Trailing stop (ATR_TRAIL_MULTIPLIER * ATR) -- profit protection
///
/// Note: Take-profit removed intentionally. Capping winners reduces avg win
/// and hurts the win/loss ratio. The trailing stop naturally captures profits
/// while allowing trends to continue (per Trend Following literature, Covel 2004).
fn check_stop_loss_take_profit(&mut self, symbol: &str, current_price: f64) -> 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;
// Update high water mark
if let Some(hwm) = self.high_water_marks.get_mut(symbol) {
if current_price > *hwm {
*hwm = current_price;
}
}
// Fixed stop loss
if pnl_pct <= -STOP_LOSS_PCT {
return Some(Signal::StrongSell);
}
// Take profit
if pnl_pct >= TAKE_PROFIT_PCT {
return Some(Signal::Sell);
}
// Trailing stop (only after activation threshold)
if pnl_pct >= TRAILING_STOP_ACTIVATION {
if let Some(&high_water) = self.high_water_marks.get(symbol) {
let trailing_stop_price = high_water * (1.0 - TRAILING_STOP_DISTANCE);
if current_price <= trailing_stop_price {
return Some(Signal::Sell);
}
}
}
None
let bars_held = self
.positions
.get(symbol)
.map_or(0, |p| p.bars_held);
self.strategy
.check_stop_loss_take_profit(symbol, current_price, bars_held)
}
/// Run the backtest simulation.
@@ -188,7 +280,7 @@ impl Backtester {
let symbols = get_all_symbols();
// Calculate warmup period
let warmup_period = self.params.min_bars() + 10;
let warmup_period = self.strategy.params.min_bars() + 10;
let warmup_calendar_days = if self.timeframe == Timeframe::Hourly {
(warmup_period as f64 / HOURS_PER_DAY as f64 * 1.5) as i64
} else {
@@ -200,12 +292,19 @@ impl Backtester {
tracing::info!("Initial Capital: ${:.2}", self.initial_capital);
tracing::info!("Period: {:.2} years ({:.1} months)", years, years * 12.0);
tracing::info!("Timeframe: {:?} bars", self.timeframe);
tracing::info!(
"Risk: ATR stops ({}x), trail ({}x after {}x gain), max {}% pos, {} max pos, {} max/sector, {} bar cooldown",
ATR_STOP_MULTIPLIER, ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER,
MAX_POSITION_SIZE * 100.0, MAX_CONCURRENT_POSITIONS, MAX_SECTOR_POSITIONS,
REENTRY_COOLDOWN_BARS
);
tracing::info!("Slippage: {} bps per trade", SLIPPAGE_BPS);
if self.timeframe == Timeframe::Hourly {
tracing::info!(
"Parameters scaled {}x (e.g., RSI: {}, EMA_TREND: {})",
HOURS_PER_DAY,
self.params.rsi_period,
self.params.ema_trend
self.strategy.params.rsi_period,
self.strategy.params.ema_trend
);
}
tracing::info!("{}", "=".repeat(70));
@@ -227,7 +326,7 @@ impl Backtester {
// Calculate indicators for all symbols
let mut data: HashMap<String, Vec<IndicatorRow>> = HashMap::new();
for (symbol, bars) in &raw_data {
let min_bars = self.params.min_bars();
let min_bars = self.strategy.params.min_bars();
if bars.len() < min_bars {
tracing::warn!(
"{}: Only {} bars, need {}. Skipping.",
@@ -237,7 +336,7 @@ impl Backtester {
);
continue;
}
let indicators = calculate_all_indicators(bars, &self.params);
let indicators = calculate_all_indicators(bars, &self.strategy.params);
data.insert(symbol.clone(), indicators);
}
@@ -285,8 +384,7 @@ impl Backtester {
if trading_dates.is_empty() {
anyhow::bail!(
"No trading days available after warmup. \
Try a longer backtest period (at least 4 months recommended)."
"No trading days available after warmup. \n Try a longer backtest period (at least 4 months recommended)."
);
}
@@ -308,12 +406,17 @@ impl Backtester {
// Main simulation loop
for (day_num, current_date) in trading_dates.iter().enumerate() {
self.current_bar = day_num;
self.new_positions_this_bar = 0; // Reset counter for each bar
// Get current prices and momentum for all symbols
let mut current_prices: HashMap<String, f64> = HashMap::new();
let mut momentum_scores: HashMap<String, f64> = HashMap::new();
for (symbol, rows) in &data {
if let Some(&idx) = symbol_date_index.get(symbol).and_then(|m| m.get(current_date)) {
if let Some(&idx) =
symbol_date_index.get(symbol).and_then(|m| m.get(current_date))
{
let row = &rows[idx];
current_prices.insert(symbol.clone(), row.close);
if !row.momentum.is_nan() {
@@ -324,6 +427,14 @@ impl Backtester {
let portfolio_value = self.get_portfolio_value(&current_prices);
// Update drawdown circuit breaker
self.update_drawdown_state(portfolio_value);
// Increment bars_held for all positions
for pos in self.positions.values_mut() {
pos.bars_held += 1;
}
// Momentum ranking: sort symbols by momentum
let mut ranked_symbols: Vec<String> = momentum_scores.keys().cloned().collect();
ranked_symbols.sort_by(|a, b| {
@@ -331,10 +442,13 @@ impl Backtester {
let mb = momentum_scores.get(b).unwrap_or(&0.0);
mb.partial_cmp(ma).unwrap_or(std::cmp::Ordering::Equal)
});
let top_momentum_symbols: HashSet<String> =
ranked_symbols.iter().take(TOP_MOMENTUM_COUNT).cloned().collect();
let top_momentum_symbols: HashSet<String> = ranked_symbols
.iter()
.take(TOP_MOMENTUM_COUNT)
.cloned()
.collect();
// Process sells first (for all symbols with positions)
// Phase 1: Process sells (stop-loss, trailing stop, time exit, signals)
let position_symbols: Vec<String> = self.positions.keys().cloned().collect();
for symbol in position_symbols {
let rows = match data.get(&symbol) {
@@ -342,7 +456,10 @@ impl Backtester {
None => continue,
};
let idx = match symbol_date_index.get(&symbol).and_then(|m| m.get(current_date)) {
let idx = match symbol_date_index
.get(&symbol)
.and_then(|m| m.get(current_date))
{
Some(&i) => i,
None => continue,
};
@@ -360,19 +477,21 @@ impl Backtester {
let mut signal = generate_signal(&symbol, current_row, previous_row);
// Check stop-loss/take-profit/trailing stop
if let Some(sl_tp) = self.check_stop_loss_take_profit(&symbol, signal.current_price)
// Check stop-loss/take-profit/trailing stop/time exit
if let Some(sl_tp) =
self.check_stop_loss_take_profit(&symbol, signal.current_price)
{
signal.signal = sl_tp;
}
// Execute sells
if signal.signal.is_sell() {
self.execute_sell(&symbol, signal.current_price, *current_date);
let was_stop_loss = matches!(signal.signal, Signal::StrongSell);
self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss);
}
}
// Process buys (only for top momentum stocks)
// Phase 2: Process buys (only for top momentum stocks)
for symbol in &ranked_symbols {
let rows = match data.get(symbol) {
Some(r) => r,
@@ -384,7 +503,10 @@ impl Backtester {
continue;
}
let idx = match symbol_date_index.get(symbol).and_then(|m| m.get(current_date)) {
let idx = match symbol_date_index
.get(symbol)
.and_then(|m| m.get(current_date))
{
Some(&i) => i,
None => continue,
};
@@ -404,7 +526,13 @@ impl Backtester {
// Execute buys
if signal.signal.is_buy() {
self.execute_buy(symbol, signal.current_price, *current_date, portfolio_value);
self.execute_buy(
symbol,
signal.current_price,
*current_date,
portfolio_value,
&signal,
);
}
}
@@ -419,10 +547,14 @@ impl Backtester {
// Progress update
if (day_num + 1) % 100 == 0 {
tracing::info!(
" Processed {}/{} days... Portfolio: ${:.2}",
" Processed {}/{} days... Portfolio: ${:.2} (positions: {})",
day_num + 1,
trading_dates.len(),
self.equity_history.last().map(|e| e.portfolio_value).unwrap_or(0.0)
self.equity_history
.last()
.map(|e| e.portfolio_value)
.unwrap_or(0.0),
self.positions.len()
);
}
}
@@ -434,7 +566,7 @@ impl Backtester {
for symbol in position_symbols {
if let Some(rows) = data.get(&symbol) {
if let Some(last_row) = rows.last() {
self.execute_sell(&symbol, last_row.close, final_date);
self.execute_sell(&symbol, last_row.close, final_date, false);
}
}
}
@@ -452,8 +584,7 @@ impl Backtester {
fn calculate_results(&self, years: f64) -> Result<BacktestResult> {
if self.equity_history.is_empty() {
anyhow::bail!(
"No trading days after indicator warmup period. \
Try a longer backtest period (at least 4 months recommended)."
"No trading days after indicator warmup period. \n Try a longer backtest period (at least 4 months recommended)."
);
}
@@ -480,11 +611,15 @@ impl Backtester {
// Sharpe Ratio (assuming 252 trading days, risk-free rate ~5%)
let risk_free_daily = 0.05 / TRADING_DAYS_PER_YEAR as f64;
let excess_returns: Vec<f64> = daily_returns.iter().map(|r| r - risk_free_daily).collect();
let excess_returns: Vec<f64> =
daily_returns.iter().map(|r| r - risk_free_daily).collect();
let sharpe = if !excess_returns.is_empty() {
let mean = excess_returns.iter().sum::<f64>() / excess_returns.len() as f64;
let variance: f64 = excess_returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
let variance: f64 = excess_returns
.iter()
.map(|r| (r - mean).powi(2))
.sum::<f64>()
/ excess_returns.len() as f64;
let std = variance.sqrt();
if std > 0.0 {
@@ -497,11 +632,15 @@ impl Backtester {
};
// Sortino Ratio (downside deviation)
let negative_returns: Vec<f64> = daily_returns.iter().filter(|&&r| r < 0.0).copied().collect();
let negative_returns: Vec<f64> = daily_returns
.iter()
.filter(|&&r| r < 0.0)
.copied()
.collect();
let sortino = if !negative_returns.is_empty() && !daily_returns.is_empty() {
let mean = daily_returns.iter().sum::<f64>() / daily_returns.len() as f64;
let neg_variance: f64 =
negative_returns.iter().map(|r| r.powi(2)).sum::<f64>() / negative_returns.len() as f64;
let neg_variance: f64 = negative_returns.iter().map(|r| r.powi(2)).sum::<f64>()
/ negative_returns.len() as f64;
let neg_std = neg_variance.sqrt();
if neg_std > 0.0 {
(mean / neg_std) * (TRADING_DAYS_PER_YEAR as f64).sqrt()
@@ -531,8 +670,10 @@ impl Backtester {
// Trade statistics
let sell_trades: Vec<&Trade> = self.trades.iter().filter(|t| t.side == "SELL").collect();
let winning_trades: Vec<&Trade> = sell_trades.iter().filter(|t| t.pnl > 0.0).copied().collect();
let losing_trades: Vec<&Trade> = sell_trades.iter().filter(|t| t.pnl <= 0.0).copied().collect();
let winning_trades: Vec<&Trade> =
sell_trades.iter().filter(|t| t.pnl > 0.0).copied().collect();
let losing_trades: Vec<&Trade> =
sell_trades.iter().filter(|t| t.pnl <= 0.0).copied().collect();
let total_trades = sell_trades.len();
let win_count = winning_trades.len();
@@ -621,6 +762,46 @@ impl Backtester {
println!(" Avg Win: ${:>15.2}", result.avg_win);
println!(" Avg Loss: ${:>15.2}", result.avg_loss);
println!(" Profit Factor: {:>15.2}", result.profit_factor);
println!();
println!("{:^70}", "STRATEGY PARAMETERS");
println!("{}", "-".repeat(70));
println!(
" Slippage: {:>15} bps",
SLIPPAGE_BPS as i64
);
println!(
" ATR Stop: {:>15.1}x",
ATR_STOP_MULTIPLIER
);
println!(
" ATR Trail: {:>15.1}x (after {:.1}x gain)",
ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER
);
println!(
" Max Positions: {:>15}",
MAX_CONCURRENT_POSITIONS
);
println!(
" Max Per Sector: {:>15}",
MAX_SECTOR_POSITIONS
);
println!(
" Drawdown Halt: {:>14.0}% ({} bar cooldown)",
MAX_DRAWDOWN_HALT * 100.0,
DRAWDOWN_HALT_BARS
);
println!(
" Time Exit: {:>13} bars",
TIME_EXIT_BARS
);
println!(
" Max Loss Cap: {:>14.1}%",
MAX_LOSS_PCT * 100.0
);
println!(
" Re-entry Cooldown: {:>13} bars",
REENTRY_COOLDOWN_BARS
);
println!("{}", "=".repeat(70));
// Show recent trades
@@ -674,8 +855,8 @@ pub fn save_backtest_results(result: &BacktestResult) -> Result<()> {
// Save trades
if !result.trades.is_empty() {
let mut wtr =
csv::Writer::from_path("backtest_trades.csv").context("Failed to create trades CSV")?;
let mut wtr = csv::Writer::from_path("backtest_trades.csv")
.context("Failed to create trades CSV")?;
wtr.write_record(["timestamp", "symbol", "side", "shares", "price", "pnl", "pnl_pct"])?;
@@ -696,4 +877,4 @@ pub fn save_backtest_results(result: &BacktestResult) -> Result<()> {
}
Ok(())
}
}

View File

@@ -2,27 +2,49 @@
use anyhow::Result;
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::time::{sleep, Duration as TokioDuration};
use crate::alpaca::AlpacaClient;
use crate::config::{
get_all_symbols, IndicatorParams, Timeframe, BOT_CHECK_INTERVAL_SECONDS, HOURS_PER_DAY,
MAX_POSITION_SIZE, MIN_CASH_RESERVE, STOP_LOSS_PCT, TAKE_PROFIT_PCT,
TOP_MOMENTUM_COUNT, TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, BOT_CHECK_INTERVAL_SECONDS,
DRAWDOWN_HALT_BARS, HOURS_PER_DAY, MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT,
MAX_POSITION_SIZE, MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS,
REENTRY_COOLDOWN_BARS, TOP_MOMENTUM_COUNT,
};
use crate::indicators::{calculate_all_indicators, generate_signal};
use crate::paths::{LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE, LIVE_POSITIONS_FILE};
use crate::paths::{
LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE, LIVE_POSITIONS_FILE,
LIVE_POSITION_META_FILE,
};
use crate::strategy::Strategy;
use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal};
/// Per-position metadata persisted to disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PositionMeta {
bars_held: usize,
}
/// Live trading bot for paper trading.
pub struct TradingBot {
client: AlpacaClient,
params: IndicatorParams,
strategy: Strategy,
timeframe: Timeframe,
entry_prices: HashMap<String, f64>,
high_water_marks: HashMap<String, f64>,
position_meta: HashMap<String, PositionMeta>,
equity_history: Vec<EquitySnapshot>,
peak_portfolio_value: f64,
drawdown_halt: bool,
/// Cycle count when drawdown halt started (for time-based resume)
drawdown_halt_start: Option<usize>,
/// Current trading cycle count
trading_cycle_count: usize,
/// Tracks when each symbol can be re-entered after stop-loss (cycle index)
cooldown_timers: HashMap<String, usize>,
/// Tracks new positions opened in current cycle (for gradual ramp-up)
new_positions_this_cycle: usize,
}
impl TradingBot {
@@ -36,16 +58,24 @@ impl TradingBot {
let mut bot = Self {
client,
params: timeframe.params(),
strategy: Strategy::new(timeframe),
timeframe,
entry_prices: HashMap::new(),
high_water_marks: HashMap::new(),
position_meta: HashMap::new(),
equity_history: Vec::new(),
peak_portfolio_value: 0.0,
drawdown_halt: false,
drawdown_halt_start: None,
trading_cycle_count: 0,
cooldown_timers: HashMap::new(),
new_positions_this_cycle: 0,
};
// Load persisted state
bot.load_entry_prices();
bot.load_high_water_marks();
bot.load_entry_atrs();
bot.load_position_meta();
bot.load_cooldown_timers();
bot.load_equity_history();
// Log account info
@@ -56,71 +86,101 @@ impl TradingBot {
Ok(bot)
}
/// Load entry prices from file.
// ── Persistence helpers ──────────────────────────────────────────
fn load_json_map<V: serde::de::DeserializeOwned>(
path: &std::path::Path,
label: &str,
) -> HashMap<String, V> {
if path.exists() {
match std::fs::read_to_string(path) {
Ok(content) if !content.is_empty() => {
match serde_json::from_str(&content) {
Ok(map) => return map,
Err(e) => tracing::error!("Error parsing {} file: {}", label, e),
}
}
Ok(_) => {} // Empty file is valid, return empty map
Err(e) => tracing::error!("Error loading {} file: {}", label, e),
}
}
HashMap::new()
}
fn save_json_map<V: serde::Serialize>(map: &HashMap<String, V>, path: &std::path::Path, label: &str) {
match serde_json::to_string_pretty(map) {
Ok(json) => {
if let Err(e) = std::fs::write(path, json) {
tracing::error!("Error saving {} file: {}", label, e);
}
}
Err(e) => tracing::error!("Error serializing {}: {}", label, e),
}
}
fn load_entry_prices(&mut self) {
if LIVE_POSITIONS_FILE.exists() {
match std::fs::read_to_string(&*LIVE_POSITIONS_FILE) {
Ok(content) => {
if !content.is_empty() {
match serde_json::from_str::<HashMap<String, f64>>(&content) {
Ok(prices) => {
tracing::info!("Loaded entry prices for {} positions.", prices.len());
self.entry_prices = prices;
}
Err(e) => tracing::error!("Error parsing positions file: {}", e),
}
}
}
Err(e) => tracing::error!("Error loading positions file: {}", e),
}
self.strategy.entry_prices = Self::load_json_map(&LIVE_POSITIONS_FILE, "positions");
if !self.strategy.entry_prices.is_empty() {
tracing::info!("Loaded entry prices for {} positions.", self.strategy.entry_prices.len());
}
}
/// Save entry prices to file.
fn save_entry_prices(&self) {
match serde_json::to_string_pretty(&self.entry_prices) {
Ok(json) => {
if let Err(e) = std::fs::write(&*LIVE_POSITIONS_FILE, json) {
tracing::error!("Error saving positions file: {}", e);
}
}
Err(e) => tracing::error!("Error serializing positions: {}", e),
}
Self::save_json_map(&self.strategy.entry_prices, &LIVE_POSITIONS_FILE, "positions");
}
/// Load high water marks from file.
fn load_high_water_marks(&mut self) {
if LIVE_HIGH_WATER_MARKS_FILE.exists() {
match std::fs::read_to_string(&*LIVE_HIGH_WATER_MARKS_FILE) {
Ok(content) => {
if !content.is_empty() {
match serde_json::from_str::<HashMap<String, f64>>(&content) {
Ok(marks) => {
tracing::info!("Loaded high water marks for {} positions.", marks.len());
self.high_water_marks = marks;
}
Err(e) => tracing::error!("Error parsing high water marks file: {}", e),
}
}
}
Err(e) => tracing::error!("Error loading high water marks file: {}", e),
}
self.strategy.high_water_marks = Self::load_json_map(&LIVE_HIGH_WATER_MARKS_FILE, "high water marks");
if !self.strategy.high_water_marks.is_empty() {
tracing::info!("Loaded high water marks for {} positions.", self.strategy.high_water_marks.len());
}
}
/// Save high water marks to file.
fn save_high_water_marks(&self) {
match serde_json::to_string_pretty(&self.high_water_marks) {
Ok(json) => {
if let Err(e) = std::fs::write(&*LIVE_HIGH_WATER_MARKS_FILE, json) {
tracing::error!("Error saving high water marks file: {}", e);
}
}
Err(e) => tracing::error!("Error serializing high water marks: {}", e),
Self::save_json_map(&self.strategy.high_water_marks, &LIVE_HIGH_WATER_MARKS_FILE, "high water marks");
}
fn load_entry_atrs(&mut self) {
self.strategy.entry_atrs = Self::load_json_map(&LIVE_ENTRY_ATRS_FILE, "entry ATRs");
if !self.strategy.entry_atrs.is_empty() {
tracing::info!("Loaded entry ATRs for {} positions.", self.strategy.entry_atrs.len());
}
}
fn save_entry_atrs(&self) {
Self::save_json_map(&self.strategy.entry_atrs, &LIVE_ENTRY_ATRS_FILE, "entry ATRs");
}
fn load_position_meta(&mut self) {
self.position_meta = Self::load_json_map(&LIVE_POSITION_META_FILE, "position meta");
if !self.position_meta.is_empty() {
tracing::info!("Loaded position meta for {} positions.", self.position_meta.len());
}
}
fn save_position_meta(&self) {
Self::save_json_map(&self.position_meta, &LIVE_POSITION_META_FILE, "position meta");
}
fn load_cooldown_timers(&mut self) {
if let Ok(path_str) = std::env::var("HOME") {
let path = std::path::PathBuf::from(path_str)
.join(".local/share/invest-bot/cooldown_timers.json");
self.cooldown_timers = Self::load_json_map(&path, "cooldown timers");
if !self.cooldown_timers.is_empty() {
tracing::info!("Loaded cooldown timers for {} symbols.", self.cooldown_timers.len());
}
}
}
fn save_cooldown_timers(&self) {
if let Ok(path_str) = std::env::var("HOME") {
let path = std::path::PathBuf::from(path_str)
.join(".local/share/invest-bot/cooldown_timers.json");
Self::save_json_map(&self.cooldown_timers, &path, "cooldown timers");
}
}
/// Load equity history from file.
fn load_equity_history(&mut self) {
if LIVE_EQUITY_FILE.exists() {
match std::fs::read_to_string(&*LIVE_EQUITY_FILE) {
@@ -129,6 +189,11 @@ impl TradingBot {
match serde_json::from_str::<Vec<EquitySnapshot>>(&content) {
Ok(history) => {
tracing::info!("Loaded {} equity data points.", history.len());
// Restore peak from history
self.peak_portfolio_value = history
.iter()
.map(|s| s.portfolio_value)
.fold(0.0_f64, f64::max);
self.equity_history = history;
}
Err(e) => tracing::error!("Error parsing equity history: {}", e),
@@ -156,14 +221,55 @@ impl TradingBot {
current_price: pos.current_price.parse().unwrap_or(0.0),
unrealized_pnl: pos.unrealized_pl.parse().unwrap_or(0.0),
pnl_pct: pos.unrealized_plpc.parse::<f64>().unwrap_or(0.0) * 100.0,
change_today: pos.change_today.as_ref().and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) * 100.0,
change_today:
pos.change_today.as_ref().and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) * 100.0,
},
);
}
let portfolio_value = account.portfolio_value.parse().unwrap_or(0.0);
// Update peak and drawdown halt status
if portfolio_value > self.peak_portfolio_value {
self.peak_portfolio_value = portfolio_value;
}
let drawdown_pct = if self.peak_portfolio_value > 0.0 {
(self.peak_portfolio_value - portfolio_value) / self.peak_portfolio_value
} else {
0.0
};
// Trigger halt if drawdown exceeds threshold
if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt {
tracing::warn!(
"DRAWDOWN CIRCUIT BREAKER: {:.2}% drawdown exceeds {:.0}% limit. Halting for {} cycles.",
drawdown_pct * 100.0,
MAX_DRAWDOWN_HALT * 100.0,
DRAWDOWN_HALT_BARS
);
self.drawdown_halt = true;
self.drawdown_halt_start = Some(self.trading_cycle_count);
}
// Auto-resume after time-based cooldown
if self.drawdown_halt {
if let Some(halt_start) = self.drawdown_halt_start {
if self.trading_cycle_count >= halt_start + DRAWDOWN_HALT_BARS {
tracing::info!(
"Drawdown halt expired after {} cycles. Resuming trading at {:.2}% drawdown.",
DRAWDOWN_HALT_BARS,
drawdown_pct * 100.0
);
self.drawdown_halt = false;
self.drawdown_halt_start = None;
}
}
}
let snapshot = EquitySnapshot {
timestamp: Utc::now().to_rfc3339(),
portfolio_value: account.portfolio_value.parse().unwrap_or(0.0),
portfolio_value,
cash: account.cash.parse().unwrap_or(0.0),
buying_power: account.buying_power.parse().unwrap_or(0.0),
positions_count: positions.len(),
@@ -172,11 +278,12 @@ impl TradingBot {
self.equity_history.push(snapshot.clone());
// Keep last 7 trading days of equity data (4 snapshots per minute at 15s intervals).
// Keep last 7 trading days of equity data
const SNAPSHOTS_PER_MINUTE: usize = 4;
const MINUTES_PER_HOUR: usize = 60;
const DAYS_TO_KEEP: usize = 7;
const MAX_SNAPSHOTS: usize = DAYS_TO_KEEP * HOURS_PER_DAY * MINUTES_PER_HOUR * SNAPSHOTS_PER_MINUTE;
const MAX_SNAPSHOTS:
usize = DAYS_TO_KEEP * HOURS_PER_DAY * MINUTES_PER_HOUR * SNAPSHOTS_PER_MINUTE;
if self.equity_history.len() > MAX_SNAPSHOTS {
let start = self.equity_history.len() - MAX_SNAPSHOTS;
@@ -198,7 +305,8 @@ impl TradingBot {
Ok(())
}
/// Log current account information.
// ── Account helpers ──────────────────────────────────────────────
async fn log_account_info(&self) {
match self.client.get_account().await {
Ok(account) => {
@@ -215,7 +323,6 @@ impl TradingBot {
}
}
/// Get position quantity for a symbol.
async fn get_position(&self, symbol: &str) -> Option<f64> {
match self.client.get_position(symbol).await {
Ok(Some(pos)) => pos.qty.parse().ok(),
@@ -227,8 +334,9 @@ impl TradingBot {
}
}
/// Calculate position size based on risk management.
async fn calculate_position_size(&self, price: f64) -> u64 {
// ── Volatility-adjusted position sizing ──────────────────────────
async fn calculate_position_size(&self, signal: &TradeSignal) -> u64 {
let account = match self.client.get_account().await {
Ok(a) => a,
Err(e) => {
@@ -239,65 +347,42 @@ impl TradingBot {
let portfolio_value: f64 = account.portfolio_value.parse().unwrap_or(0.0);
let cash: f64 = account.cash.parse().unwrap_or(0.0);
let max_allocation = portfolio_value * MAX_POSITION_SIZE;
let available_funds = cash - (portfolio_value * MIN_CASH_RESERVE);
if available_funds <= 0.0 {
return 0;
}
let position_value = max_allocation.min(available_funds);
(position_value / price).floor() as u64
self.strategy.calculate_position_size(
signal.current_price,
portfolio_value,
available_funds,
signal,
)
}
/// Check if stop-loss, take-profit, or trailing stop should trigger.
fn check_stop_loss_take_profit(&mut self, symbol: &str, current_price: f64) -> Option<Signal> {
let entry_price = match self.entry_prices.get(symbol) {
Some(&p) => p,
None => return None,
};
// ── ATR-based stop/trailing logic ────────────────────────────────
let pnl_pct = (current_price - entry_price) / entry_price;
// Update high water mark
if let Some(hwm) = self.high_water_marks.get_mut(symbol) {
if current_price > *hwm {
*hwm = current_price;
self.save_high_water_marks();
}
fn check_stop_loss_take_profit(
&mut self,
symbol: &str,
current_price: f64,
) -> Option<Signal> {
let bars_held = self.position_meta.get(symbol).map_or(0, |m| m.bars_held);
let signal = self
.strategy
.check_stop_loss_take_profit(symbol, current_price, bars_held);
if self.strategy.high_water_marks.contains_key(symbol) {
self.save_high_water_marks();
}
// Fixed stop loss
if pnl_pct <= -STOP_LOSS_PCT {
tracing::warn!("{}: Stop-loss triggered at {:.2}% loss", symbol, pnl_pct * 100.0);
return Some(Signal::StrongSell);
}
// Take profit
if pnl_pct >= TAKE_PROFIT_PCT {
tracing::info!("{}: Take-profit triggered at {:.2}% gain", symbol, pnl_pct * 100.0);
return Some(Signal::Sell);
}
// Trailing stop (only after activation threshold)
if pnl_pct >= TRAILING_STOP_ACTIVATION {
if let Some(&high_water) = self.high_water_marks.get(symbol) {
let trailing_stop_price = high_water * (1.0 - TRAILING_STOP_DISTANCE);
if current_price <= trailing_stop_price {
tracing::info!(
"{}: Trailing stop triggered at ${:.2} (peak: ${:.2}, stop: ${:.2})",
symbol, current_price, high_water, trailing_stop_price
);
return Some(Signal::Sell);
}
}
}
None
signal
}
/// Execute a buy order.
// ── Sector concentration check ───────────────────────────────────
fn sector_position_count(&self, sector: &str) -> usize {
self.strategy
.sector_position_count(sector, self.strategy.entry_prices.keys())
}
// ── Order execution ──────────────────────────────────────────────
async fn execute_buy(&mut self, symbol: &str, signal: &TradeSignal) -> bool {
// Check if already holding
if let Some(qty) = self.get_position(symbol).await {
@@ -307,7 +392,55 @@ impl TradingBot {
}
}
let shares = self.calculate_position_size(signal.current_price).await;
// Cooldown guard: prevent whipsaw re-entry after stop-loss
if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) {
if self.trading_cycle_count < cooldown_until {
tracing::info!(
"{}: In cooldown period until cycle {} (currently {})",
symbol,
cooldown_until,
self.trading_cycle_count
);
return false;
}
}
// Portfolio-level guards
if self.drawdown_halt {
tracing::info!("{}: Skipping buy — drawdown circuit breaker active", symbol);
return false;
}
if self.strategy.entry_prices.len() >= MAX_CONCURRENT_POSITIONS {
tracing::info!(
"{}: Skipping buy — at max {} concurrent positions",
symbol,
MAX_CONCURRENT_POSITIONS
);
return false;
}
let sector = get_sector(symbol);
if self.sector_position_count(sector) >= MAX_SECTOR_POSITIONS {
tracing::info!(
"{}: Skipping buy — sector '{}' at max {} positions",
symbol, sector, MAX_SECTOR_POSITIONS
);
return false;
}
// Gradual ramp-up: limit new positions during initial period
if self.trading_cycle_count < RAMPUP_PERIOD_BARS && self.new_positions_this_cycle >= 1 {
tracing::info!(
"{}: Ramp-up period (cycle {}/{}) — already opened 1 position this cycle",
symbol,
self.trading_cycle_count,
RAMPUP_PERIOD_BARS
);
return false;
}
let shares = self.calculate_position_size(signal).await;
if shares == 0 {
tracing::info!("{}: Insufficient funds for purchase", symbol);
return false;
@@ -318,21 +451,35 @@ impl TradingBot {
.submit_market_order(symbol, shares as f64, "buy")
.await
{
Ok(_order) => {
self.entry_prices.insert(symbol.to_string(), signal.current_price);
self.high_water_marks.insert(symbol.to_string(), signal.current_price);
Ok(order) => {
// Use filled price if available, otherwise signal price
let fill_price = order
.filled_avg_price
.as_ref()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(signal.current_price);
self.strategy.entry_prices.insert(symbol.to_string(), fill_price);
self.strategy.entry_atrs.insert(symbol.to_string(), signal.atr);
self.strategy.high_water_marks.insert(symbol.to_string(), fill_price);
self.position_meta.insert(
symbol.to_string(),
PositionMeta {
bars_held: 0,
},
);
self.save_entry_prices();
self.save_entry_atrs();
self.save_high_water_marks();
self.save_position_meta();
self.new_positions_this_cycle += 1;
tracing::info!(
"BUY ORDER EXECUTED: {} - {} shares @ ~${:.2} \
(RSI: {:.1}, MACD: {:.3}, Confidence: {:.2})",
symbol,
shares,
signal.current_price,
signal.rsi,
signal.macd_histogram,
signal.confidence
"BUY ORDER EXECUTED: {} - {} shares @ ~${:.2} \n (RSI: {:.1}, MACD: {:.3}, ATR: ${:.2}, Confidence: {:.2})",
symbol, shares, fill_price, signal.rsi, signal.macd_histogram,
signal.atr, signal.confidence
);
true
@@ -344,8 +491,7 @@ impl TradingBot {
}
}
/// Execute a sell order.
async fn execute_sell(&mut self, symbol: &str, signal: &TradeSignal) -> bool {
async fn execute_sell(&mut self, symbol: &str, signal: &TradeSignal, was_stop_loss: bool) -> bool {
let current_position = match self.get_position(symbol).await {
Some(qty) if qty > 0.0 => qty,
_ => {
@@ -360,22 +506,36 @@ impl TradingBot {
.await
{
Ok(_order) => {
if let Some(entry) = self.entry_prices.remove(symbol) {
if let Some(entry) = self.strategy.entry_prices.remove(symbol) {
let pnl_pct = (signal.current_price - entry) / entry;
tracing::info!("{}: Realized P&L: {:.2}%", symbol, pnl_pct * 100.0);
self.save_entry_prices();
}
self.high_water_marks.remove(symbol);
self.strategy.high_water_marks.remove(symbol);
self.strategy.entry_atrs.remove(symbol);
self.position_meta.remove(symbol);
self.save_high_water_marks();
self.save_entry_atrs();
self.save_position_meta();
// Record cooldown if this was a stop-loss exit
if was_stop_loss {
self.cooldown_timers.insert(
symbol.to_string(),
self.trading_cycle_count + REENTRY_COOLDOWN_BARS,
);
self.save_cooldown_timers();
tracing::info!(
"{}: Stop-loss exit — cooldown until cycle {}",
symbol,
self.trading_cycle_count + REENTRY_COOLDOWN_BARS
);
}
tracing::info!(
"SELL ORDER EXECUTED: {} - {} shares @ ~${:.2} \
(RSI: {:.1}, MACD: {:.3})",
symbol,
current_position,
signal.current_price,
signal.rsi,
signal.macd_histogram
"SELL ORDER EXECUTED: {} - {} shares @ ~${:.2} \n (RSI: {:.1}, MACD: {:.3})",
symbol, current_position, signal.current_price,
signal.rsi, signal.macd_histogram
);
true
@@ -387,11 +547,14 @@ impl TradingBot {
}
}
/// Analyze a symbol and generate trading signal (without stop-loss check).
async fn analyze_symbol(&self, symbol: &str) -> Option<TradeSignal> {
let min_bars = self.params.min_bars();
// Partial exits removed: they systematically halve winning trade size
// while losing trades remain at full size, creating unfavorable avg win/loss ratio.
// ── Analysis ─────────────────────────────────────────────────────
async fn analyze_symbol(&self, symbol: &str) -> Option<TradeSignal> {
let min_bars = self.strategy.params.min_bars();
// Calculate days needed for data
let days = if self.timeframe == Timeframe::Hourly {
(min_bars as f64 / HOURS_PER_DAY as f64 * 1.5) as i64 + 10
} else {
@@ -401,7 +564,11 @@ impl TradingBot {
let end = Utc::now();
let start = end - Duration::days(days);
let bars = match self.client.get_historical_bars(symbol, self.timeframe, start, end).await {
let bars = match self
.client
.get_historical_bars(symbol, self.timeframe, start, end)
.await
{
Ok(b) => b,
Err(e) => {
tracing::warn!("{}: Failed to get historical data: {}", symbol, e);
@@ -419,7 +586,7 @@ impl TradingBot {
return None;
}
let indicators = calculate_all_indicators(&bars, &self.params);
let indicators = calculate_all_indicators(&bars, &self.strategy.params);
if indicators.len() < 2 {
return None;
@@ -435,12 +602,20 @@ impl TradingBot {
Some(generate_signal(symbol, current, previous))
}
/// Execute one complete trading cycle.
// ── Trading cycle ────────────────────────────────────────────────
async fn run_trading_cycle(&mut self) {
self.trading_cycle_count += 1;
self.new_positions_this_cycle = 0; // Reset counter for each cycle
tracing::info!("{}", "=".repeat(60));
tracing::info!("Starting trading cycle...");
tracing::info!("Starting trading cycle #{}...", self.trading_cycle_count);
self.log_account_info().await;
// Increment bars_held once per trading cycle (matches backtester's per-bar increment)
for meta in self.position_meta.values_mut() {
meta.bars_held += 1;
}
let symbols = get_all_symbols();
// Analyze all symbols first
@@ -457,13 +632,13 @@ impl TradingBot {
};
tracing::info!(
"{}: Signal={}, RSI={:.1}, MACD Hist={:.3}, Momentum={:.2}%, \
Price=${:.2}, Confidence={:.2}",
"{}: Signal={}, RSI={:.1}, MACD Hist={:.3}, Momentum={:.2}%, \n ATR=${:.2}, Price=${:.2}, Confidence={:.2}",
signal.symbol,
signal.signal.as_str(),
signal.rsi,
signal.macd_histogram,
signal.momentum,
signal.atr,
signal.current_price,
signal.confidence
);
@@ -474,27 +649,32 @@ impl TradingBot {
sleep(TokioDuration::from_millis(500)).await;
}
// Phase 1: Process all sells first (free up cash before buying)
// Phase 1: Process all sells (stop-loss, trailing stop, time exit, signals)
for signal in &signals {
let mut effective_signal = signal.clone();
// Check stop-loss/take-profit/trailing stop
if let Some(sl_tp) = self.check_stop_loss_take_profit(&signal.symbol, signal.current_price) {
// Check stop-loss/take-profit/trailing stop/time exit
if let Some(sl_tp) =
self.check_stop_loss_take_profit(&signal.symbol, signal.current_price)
{
effective_signal.signal = sl_tp;
}
if effective_signal.signal.is_sell() {
self.execute_sell(&signal.symbol, &effective_signal).await;
let was_stop_loss = matches!(effective_signal.signal, Signal::StrongSell);
self.execute_sell(&signal.symbol, &effective_signal, was_stop_loss).await;
}
}
// Phase 2: Momentum ranking - only buy top N momentum stocks
// Phase 2: Momentum ranking only buy top N momentum stocks
let mut ranked_signals: Vec<&TradeSignal> = signals
.iter()
.filter(|s| !s.momentum.is_nan())
.collect();
ranked_signals.sort_by(|a, b| {
b.momentum.partial_cmp(&a.momentum).unwrap_or(std::cmp::Ordering::Equal)
b.momentum
.partial_cmp(&a.momentum)
.unwrap_or(std::cmp::Ordering::Equal)
});
let top_momentum_symbols: std::collections::HashSet<String> = ranked_signals
@@ -520,7 +700,8 @@ impl TradingBot {
}
}
// Save equity snapshot for dashboard
// Save equity snapshot and persist metadata
self.save_position_meta();
if let Err(e) = self.save_equity_snapshot().await {
tracing::error!("Failed to save equity snapshot: {}", e);
}
@@ -529,7 +710,7 @@ impl TradingBot {
tracing::info!("{}", "=".repeat(60));
}
/// Main bot loop - runs continuously during market hours.
/// Main bot loop runs continuously during market hours.
pub async fn run(&mut self) -> Result<()> {
let symbols = get_all_symbols();
@@ -540,17 +721,23 @@ impl TradingBot {
tracing::info!(
"Parameters scaled {}x (RSI: {}, EMA_TREND: {})",
HOURS_PER_DAY,
self.params.rsi_period,
self.params.ema_trend
self.strategy.params.rsi_period,
self.strategy.params.ema_trend
);
}
tracing::info!("Symbols: {}", symbols.join(", "));
tracing::info!(
"Strategy: RSI({}) + MACD({},{},{}) + Momentum",
self.params.rsi_period,
self.params.macd_fast,
self.params.macd_slow,
self.params.macd_signal
"Strategy: RSI({}) + MACD({},{},{}) + Momentum({})",
self.strategy.params.rsi_period,
self.strategy.params.macd_fast,
self.strategy.params.macd_slow,
self.strategy.params.macd_signal,
self.strategy.params.momentum_period
);
tracing::info!(
"Risk: ATR stops ({}x), trailing ({}x after {}x gain), max {}% position, {} max positions",
ATR_STOP_MULTIPLIER, ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER,
MAX_POSITION_SIZE * 100.0, MAX_CONCURRENT_POSITIONS
);
tracing::info!("Bot Check Interval: {} seconds", BOT_CHECK_INTERVAL_SECONDS);
tracing::info!("{}", "=".repeat(60));
@@ -571,7 +758,10 @@ impl TradingBot {
Ok(next_open) => {
let wait_seconds = (next_open - Utc::now()).num_seconds().max(0);
tracing::info!("Market closed. Next open: {}", next_open);
tracing::info!("Waiting {:.1} hours...", wait_seconds as f64 / 3600.0);
tracing::info!(
"Waiting {:.1} hours...",
wait_seconds as f64 / 3600.0
);
let sleep_time = (wait_seconds as u64).min(300).max(60);
sleep(TokioDuration::from_secs(sleep_time)).await;

View File

@@ -1,5 +1,4 @@
//! Configuration constants for the trading bot.
// Stock Universe
pub const MAG7: &[&str] = &["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "TSLA"];
pub const SEMIS: &[&str] = &["AVGO", "AMD", "ASML", "QCOM", "MU"];
@@ -10,7 +9,6 @@ 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 fn get_all_symbols() -> Vec<&'static str> {
let mut symbols = Vec::new();
@@ -25,68 +23,100 @@ pub fn get_all_symbols() -> Vec<&'static str> {
symbols.extend_from_slice(SP500_ENERGY);
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_SHORT_PERIOD: usize = 2; // Connors RSI-2 for mean reversion
pub const RSI_OVERSOLD: f64 = 30.0;
pub const RSI_OVERBOUGHT: f64 = 70.0;
pub const RSI_PULLBACK_LOW: f64 = 35.0;
pub const RSI_PULLBACK_HIGH: f64 = 60.0;
pub const RSI2_OVERSOLD: f64 = 10.0; // Extreme oversold for mean reversion entries
pub const RSI2_OVERBOUGHT: f64 = 90.0; // Extreme overbought for mean reversion exits
pub const MACD_FAST: usize = 12;
pub const MACD_SLOW: usize = 26;
pub const MACD_SIGNAL: usize = 9;
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_RANGE_THRESHOLD: f64 = 20.0; // Below this = range-bound
pub const ADX_TREND_THRESHOLD: f64 = 25.0; // Above this = trending
pub const ADX_STRONG: f64 = 40.0; // Strong trend for bonus conviction
// Bollinger Bands
pub const BB_PERIOD: usize = 20;
pub const BB_STD: f64 = 2.0;
// 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 = 10; // Wider pool for more opportunities
// 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.25; // Slightly larger for concentrated bets
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.05; // Wider max loss — let mean reversion work
pub const TRAILING_STOP_ACTIVATION: f64 = 0.06;
pub const TRAILING_STOP_DISTANCE: f64 = 0.04;
// ATR-based risk management
pub const RISK_PER_TRADE: f64 = 0.012; // More aggressive sizing for higher conviction
pub const ATR_STOP_MULTIPLIER: f64 = 3.0; // Wider stops — research shows tighter stops hurt
pub const ATR_TRAIL_MULTIPLIER: f64 = 2.0; // Wider trail to let winners run
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Activate after 2x ATR gain
// Portfolio-level controls
pub const MAX_CONCURRENT_POSITIONS: usize = 7; // More positions for diversification
pub const MAX_SECTOR_POSITIONS: usize = 2;
pub const MAX_DRAWDOWN_HALT: f64 = 0.12; // Wider drawdown tolerance
pub const DRAWDOWN_HALT_BARS: usize = 20; // Shorter cooldown to get back in
// Time-based exit
pub const TIME_EXIT_BARS: usize = 40; // Longer patience for mean reversion
pub const REENTRY_COOLDOWN_BARS: usize = 5; // Shorter cooldown
pub const RAMPUP_PERIOD_BARS: usize = 15; // Faster ramp-up
// 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 HEALTHCARE.contains(&symbol) {
"healthcare"
} 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 {
"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 +129,52 @@ 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.
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, // Slightly longer for hourly noise
macd_fast: 12,
macd_slow: 26,
macd_signal: 9,
momentum_period: 63,
ema_short: 9,
ema_long: 21,
ema_trend: 200,
adx_period: 14,
bb_period: 20,
atr_period: 14,
volume_ma_period: 20,
}
}
/// 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 +182,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 {

View File

@@ -1,8 +1,8 @@
//! 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_RANGE_THRESHOLD, ADX_STRONG, ADX_TREND_THRESHOLD, BB_STD,
RSI2_OVERBOUGHT, RSI2_OVERSOLD, RSI_OVERBOUGHT, RSI_OVERSOLD, VOLUME_THRESHOLD,
};
use crate::types::{Bar, IndicatorRow, Signal, TradeSignal};
@@ -348,6 +348,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 +393,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,145 +423,180 @@ pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec<I
rows
}
/// Generate trading signal from current and previous indicator rows.
/// Generate trading signal using regime-adaptive dual strategy.
///
/// REGIME DETECTION (via ADX):
/// - ADX < 20: Range-bound → use Connors RSI-2 mean reversion
/// - ADX > 25: Trending → use momentum pullback entries
/// - 20-25: Transition → require extra confirmation
///
/// MEAN REVERSION (ranging markets):
/// - Buy when RSI-2 < 10 AND price above 200 EMA (long-term uptrend filter)
/// - Sell when RSI-2 > 90 (take profit at mean)
/// - Bollinger Band extremes add conviction
///
/// TREND FOLLOWING (trending markets):
/// - Buy pullbacks in uptrends: RSI-14 dips + EMA support + MACD confirming
/// - Sell when trend breaks: EMA crossover down + momentum loss
/// - Strong trend bonus for high ADX
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 rsi2 = current.rsi_short;
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() { 22.0 } else { current.adx };
let di_plus = if current.di_plus.is_nan() { 25.0 } else { current.di_plus };
let di_minus = if current.di_minus.is_nan() { 25.0 } else { current.di_minus };
let bb_pct = if current.bb_pct.is_nan() { 0.5 } else { current.bb_pct };
let ema_distance = if current.ema_distance.is_nan() { 0.0 } else { current.ema_distance };
// REGIME DETECTION
let is_ranging = adx < ADX_RANGE_THRESHOLD;
let is_trending = adx > ADX_TREND_THRESHOLD;
let strong_trend = adx > ADX_STRONG;
let trend_up = di_plus > di_minus;
// EMA state
let ema_bullish = !ema_short.is_nan() && !ema_long.is_nan() && ema_short > ema_long;
// MACD crossover detection
let macd_crossed_up = !previous.macd.is_nan()
&& !previous.macd_signal.is_nan()
&& !macd.is_nan()
&& !macd_signal_val.is_nan()
&& !current.macd.is_nan()
&& !current.macd_signal.is_nan()
&& previous.macd < previous.macd_signal
&& macd > macd_signal_val;
&& current.macd > current.macd_signal;
let macd_crossed_down = !previous.macd.is_nan()
&& !previous.macd_signal.is_nan()
&& !macd.is_nan()
&& !macd_signal_val.is_nan()
&& !current.macd.is_nan()
&& !current.macd_signal.is_nan()
&& previous.macd > previous.macd_signal
&& macd < macd_signal_val;
&& current.macd < current.macd_signal;
// EMA trend
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
// ═══════════════════════════════════════════════════════════════
// REGIME 1: MEAN REVERSION (ranging market, ADX < 20)
// ═══════════════════════════════════════════════════════════════
if is_ranging {
// Connors RSI-2 mean reversion: buy extreme oversold in uptrend context
if !rsi2.is_nan() {
// Buy: RSI-2 extremely oversold + long-term trend intact
if rsi2 < RSI2_OVERSOLD {
buy_score += 5.0; // Strong mean reversion signal
if trend_bullish {
buy_score += 3.0; // With-trend mean reversion = highest conviction
}
if bb_pct < 0.05 {
buy_score += 2.0; // Price at/below lower BB
}
} else if rsi2 < 20.0 {
buy_score += 2.5;
if trend_bullish {
buy_score += 1.5;
}
}
// Sell: RSI-2 overbought = take profit on mean reversion
if rsi2 > RSI2_OVERBOUGHT {
sell_score += 4.0;
if !trend_bullish {
sell_score += 2.0;
}
} else if rsi2 > 80.0 && !trend_bullish {
sell_score += 2.0;
}
}
// Bollinger Band extremes in range
if bb_pct < 0.0 {
buy_score += 2.0; // Below lower band
} else if bb_pct > 1.0 {
sell_score += 2.0; // Above upper band
}
}
// ═══════════════════════════════════════════════════════════════
// REGIME 2: TREND FOLLOWING (trending market, ADX > 25)
// ═══════════════════════════════════════════════════════════════
if is_trending {
// Trend direction confirmation
if trend_up && trend_bullish {
buy_score += 3.0;
// Pullback entry: price dipped but trend intact
if !rsi.is_nan() && rsi < 40.0 && rsi > 25.0 {
buy_score += 3.0; // Pullback in uptrend
}
if ema_distance > 0.0 && ema_distance < 0.02 {
buy_score += 2.0; // Near EMA support
}
if strong_trend {
buy_score += 1.5; // Strong trend bonus
}
} else if !trend_up && !trend_bullish {
sell_score += 3.0;
}
} 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;
if !rsi.is_nan() && rsi > 60.0 && rsi < 75.0 {
sell_score += 3.0; // Bounce in downtrend
}
if ema_distance < 0.0 && ema_distance > -0.02 {
sell_score += 2.0; // Near EMA resistance
}
if strong_trend {
sell_score += 1.5;
}
} else if rsi > RSI_OVERBOUGHT {
sell_score += 3.0;
}
}
// MACD MOMENTUM
// ═══════════════════════════════════════════════════════════════
// UNIVERSAL SIGNALS (both regimes)
// ═══════════════════════════════════════════════════════════════
// RSI-14 extremes (strong conviction regardless of regime)
if !rsi.is_nan() {
if rsi < RSI_OVERSOLD && trend_bullish {
buy_score += 3.0; // Oversold in uptrend = strong buy
} else if rsi > RSI_OVERBOUGHT && !trend_bullish {
sell_score += 3.0; // Overbought in downtrend = strong sell
}
}
// MACD crossover
if macd_crossed_up {
buy_score += 2.5;
if strong_trend && trend_up {
buy_score += 1.0;
buy_score += 2.0;
if is_trending && trend_up {
buy_score += 1.0; // Trend-confirming crossover
}
} 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 += 2.0;
if is_trending && !trend_up {
sell_score += 1.0;
}
}
// EMA CROSSOVER
// MACD histogram direction
if !macd_hist.is_nan() {
if macd_hist > 0.0 { buy_score += 0.5; }
else if macd_hist < 0.0 { sell_score += 0.5; }
}
// Momentum
if !momentum.is_nan() {
if momentum > 5.0 { buy_score += 1.5; }
else if momentum > 2.0 { buy_score += 0.5; }
else if momentum < -5.0 { sell_score += 1.5; }
else if momentum < -2.0 { sell_score += 0.5; }
}
// EMA crossover events
let prev_ema_bullish = !previous.ema_short.is_nan()
&& !previous.ema_long.is_nan()
&& previous.ema_short > previous.ema_long;
@@ -568,54 +605,46 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
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;
}
// 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;
}
// Volume gate
if volume_ratio < VOLUME_THRESHOLD {
buy_score *= 0.5;
sell_score *= 0.5;
}
// DETERMINE SIGNAL
// ═══════════════════════════════════════════════════════════════
// 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.5 {
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
};
let confidence = (total_score.abs() / 10.0).min(1.0);
let confidence = (total_score.abs() / 12.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 },
}
}

View File

@@ -21,6 +21,7 @@ mod config;
mod dashboard;
mod indicators;
mod paths;
mod strategy;
mod types;
use anyhow::{Context, Result};

View File

@@ -37,6 +37,20 @@ 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 trading log file.
pub static ref LOG_FILE: PathBuf = {
let mut path = DATA_DIR.clone();

140
src/strategy.rs Normal file
View File

@@ -0,0 +1,140 @@
//! 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, MAX_LOSS_PCT, MAX_POSITION_SIZE,
MIN_ATR_PCT, RISK_PER_TRADE, 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.
pub fn calculate_position_size(
&self,
price: f64,
portfolio_value: f64,
available_cash: f64,
signal: &TradeSignal,
) -> u64 {
if available_cash <= 0.0 {
return 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;
// Scale by confidence
let confidence_scale = 0.7 + 0.3 * 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);
(position_value / price).floor() as u64
}
/// Check if stop-loss, trailing stop, or time exit should trigger.
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;
}
}
// Hard max-loss cap
if pnl_pct <= -MAX_LOSS_PCT {
return Some(Signal::StrongSell);
}
// ATR-based stop loss
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 {
return Some(Signal::StrongSell);
}
// Time-based exit
if bars_held >= TIME_EXIT_BARS {
let activation = if entry_atr > 0.0 {
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
} else {
TRAILING_STOP_ACTIVATION
};
if pnl_pct < activation {
return Some(Signal::Sell);
}
}
// ATR-based trailing stop
let activation_gain = if entry_atr > 0.0 {
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
} else {
TRAILING_STOP_ACTIVATION
};
if pnl_pct >= activation_gain {
if let Some(&high_water) = self.high_water_marks.get(symbol) {
let trail_distance = if entry_atr > 0.0 {
ATR_TRAIL_MULTIPLIER * entry_atr
} else {
high_water * TRAILING_STOP_DISTANCE
};
let trailing_stop_price = high_water - trail_distance;
if current_price <= trailing_stop_price {
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()
}
}

View File

@@ -48,6 +48,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 +73,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.
@@ -129,6 +133,7 @@ pub struct IndicatorRow {
// RSI
pub rsi: f64,
pub rsi_short: f64, // RSI-2/3 for mean reversion
// MACD
pub macd: f64,
@@ -178,6 +183,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,