diff --git a/.claude/agent-memory/quant-rust-strategist/MEMORY.md b/.claude/agent-memory/quant-rust-strategist/MEMORY.md index c4bac6c..a4485e1 100644 --- a/.claude/agent-memory/quant-rust-strategist/MEMORY.md +++ b/.claude/agent-memory/quant-rust-strategist/MEMORY.md @@ -1,59 +1,63 @@ # Quant-Rust-Strategist Memory ## Architecture Overview -- ~100-symbol universe across 14 sectors (expanded from original 50) -- Hybrid momentum + mean-reversion via regime-adaptive dual signal in `generate_signal()` +- ~100-symbol universe across 14 sectors - strategy.rs: shared logic between bot.rs and backtester.rs - Backtester restricts buys to top momentum stocks (TOP_MOMENTUM_COUNT) +- SPY regime filter (EMA-50/200) gates new longs: Bull/Caution/Bear - Signal thresholds: StrongBuy>=7.0, Buy>=4.0, Sell<=-4.0, StrongSell<=-7.0 -## Bugs Fixed (2026-02-13) -### 1. calculate_results used self.cash instead of equity curve final value -- backtester.rs line ~686: `let final_value = self.cash` missed open positions -- Fixed: use `self.equity_history.last().portfolio_value` +## Signal Generation (2026-02-13 REWRITE) +- **OLD**: Additive "indicator soup" -- 8 indicators netted, PF 0.91, no edge +- **NEW**: Hierarchical momentum-with-trend filter: + - Gate 1: trend_bullish AND ema_bullish -- MUST pass for any buy + - Gate 2: positive momentum (ROC > 0) -- time-series momentum + - Timing: RSI-14 pullback (30-50) in confirmed uptrends + - Conviction: ADX direction, MACD histogram, volume + - Sell: trend break (price < EMA-trend) is primary exit signal +- Key insight: hierarchical gating >> additive scoring -### 2. Drawdown circuit breaker cascading re-triggers -- peak_portfolio_value was never reset after halt, causing immediate re-trigger -- 7+ triggers in 3yr = ~140 bars (19% of backtest) sitting in cash -- Fixed: reset peak to current value on halt resume +## Stop/Exit Logic (2026-02-13 FIX) +- Time exit ONLY sells losers (pnl_pct < 0). Old code force-sold winners. +- Trail activation: 1.5x ATR (was 2.0x), trail distance: 2.5x ATR (was 2.0x) +- Max loss: 8% (was 5%), TIME_EXIT_BARS: 60 (was 40) -### 3. PDT blocking sells in backtester (disabled) -- PDT sell-blocking removed from backtester; it measures strategy alpha not compliance -- Late-day entry prevention in execute_buy remains for hourly PDT defense -- would_be_day_trade was called AFTER position removal = always false (logic bug) +## Equity Curve SMA Stop: REMOVED from backtester +- Created pathological feedback loop with drawdown breaker -## PDT Implementation (2026-02-12) -- Tracks day trades in rolling 5-business-day window, max 3 allowed -- CRITICAL: Stop-loss exits must NEVER be blocked by PDT (risk mgmt > compliance) -- Late-day entry prevention: On hourly, block buys after 19:00 UTC (~last 2 hours) -- PDT blocking DISABLED in backtester (kept in bot.rs for live trading) +## Position Sizing (2026-02-13 FIX) +- Confidence scaling: 0.4 + 0.6*conf (was 0.7 + 0.3*conf) +- RISK_PER_TRADE: 1.0%, MAX_POSITIONS: 10, TOP_MOMENTUM: 10 ## Current Parameters (config.rs, updated 2026-02-13) -- ATR Stop: 3.0x | Trail: 2.0x distance, 2.0x activation -- Risk: 1.2%/trade, max 25% position, 5% cash reserve, 5% max loss -- Max 7 positions, 2/sector | Drawdown halt: 15% (10 bars) | Time exit: 40 +- ATR Stop: 3.0x | Trail: 2.5x distance, 1.5x activation +- Risk: 1.0%/trade, max 25% position, 5% cash reserve, 8% max loss +- Max 10 positions, 2/sector | Time exit: 60 bars (losers only) - Cooldown: 5 bars | Ramp-up: 15 bars | Slippage: 10bps -- Buy threshold: 4.0 (lowered from 4.5) | Momentum pool: top 20 (widened from 10) -- Daily: momentum=63, ema_trend=50 | Hourly: momentum=63, ema_trend=200 -- ADX: range<20, trend>25, strong>40 +- Momentum pool: top 10 (decile) -## Hourly Timeframe: DO NOT CHANGE FROM BASELINE -- Hourly IndicatorParams: momentum=63, ema_trend=200 (long lookbacks filter IEX noise) -- Shorter periods (momentum=21, ema_trend=50): CATASTROPHIC -8% loss +## Bugs Fixed (2026-02-13) +1. calculate_results used self.cash instead of equity curve final value +2. Drawdown circuit breaker cascading re-triggers (peak not reset) +3. PDT blocking sells in backtester (disabled) ## Failed Experiments (avoid repeating) 1. Tighter ATR stop (<3.0x): too many stop-outs on hourly -2. Lower buy threshold (3.5): too many weak entries (but 4.0 is fine) -3. Blocking stop-loss exits for PDT: traps capital in losers, dangerous +2. Lower buy threshold (3.5): too many weak entries (4.0 is fine) +3. Blocking stop-loss exits for PDT: traps capital in losers 4. Lower volume threshold (0.7): bad trades on IEX. Keep 0.8 5. Shorter hourly lookbacks: catastrophic losses -6. Drawdown halt 12% with non-resetting peak: cascading re-triggers in multi-year tests +6. Drawdown halt 12% with non-resetting peak: cascading re-triggers +7. Additive indicator soup: fundamentally has no edge (PF < 1.0) +8. Time exit that dumps winners: destroys win/loss asymmetry +9. Equity curve SMA stop: correlated with drawdown breaker, blocks recovery + +## Hourly Timeframe: DO NOT CHANGE FROM BASELINE +- momentum=63, ema_trend=200 (long lookbacks filter IEX noise) ## IEX Data Stochasticity -- Backtests have significant run-to-run variation from IEX data timing -- Do NOT panic about minor performance swings between runs -- Always run 2-3 times and compare ranges before concluding a change helped/hurt +- Run 2-3 times and compare ranges before concluding a change helped/hurt ## Build Notes -- `cargo build --release` compiles clean (only dead_code warnings for types.rs fields) +- `cargo build --release` compiles clean (only dead_code warnings) - No tests exist diff --git a/src/alpaca.rs b/src/alpaca.rs index cd9eb7c..dd9b44c 100644 --- a/src/alpaca.rs +++ b/src/alpaca.rs @@ -463,8 +463,41 @@ impl AlpacaClient { } } +/// Get the cache file path for a given symbol and timeframe. +fn cache_path(symbol: &str, timeframe: Timeframe) -> std::path::PathBuf { + let tf_dir = match timeframe { + Timeframe::Daily => "daily", + Timeframe::Hourly => "hourly", + }; + let mut path = crate::paths::BACKTEST_CACHE_DIR.clone(); + path.push(tf_dir); + std::fs::create_dir_all(&path).ok(); + path.push(format!("{}.json", symbol)); + path +} + +/// Load cached bars for a symbol. Returns empty vec on any error. +fn load_cached_bars(symbol: &str, timeframe: Timeframe) -> Vec { + let path = cache_path(symbol, timeframe); + match std::fs::read_to_string(&path) { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => Vec::new(), + } +} + +/// Save bars to cache for a symbol. +fn save_cached_bars(symbol: &str, timeframe: Timeframe, bars: &[Bar]) { + let path = cache_path(symbol, timeframe); + if let Ok(json) = serde_json::to_string(bars) { + if let Err(e) = std::fs::write(&path, json) { + tracing::warn!("Failed to write cache for {}: {}", symbol, e); + } + } +} + /// Helper to fetch bars for backtesting with proper date handling. /// Fetches each symbol individually to avoid API limits on multi-symbol requests. +/// Uses a disk cache to avoid re-fetching bars that were already downloaded. pub async fn fetch_backtest_data( client: &AlpacaClient, symbols: &[&str], @@ -476,6 +509,9 @@ pub async fn fetch_backtest_data( let days = (years * 365.0) as i64 + warmup_days + 30; let start = end - Duration::days(days); + // Re-fetch overlap: always re-fetch the last 2 days to handle partial/corrected bars + let refetch_overlap = Duration::days(2); + tracing::info!( "Fetching {:.2} years of data ({} to {})...", years, @@ -484,29 +520,115 @@ pub async fn fetch_backtest_data( ); let mut all_data = HashMap::new(); + let mut cache_hits = 0u32; + let mut cache_misses = 0u32; - // Fetch each symbol individually like Python does - // The multi-symbol endpoint has a 10000 bar limit across ALL symbols for symbol in symbols { - tracing::info!(" Fetching {}...", symbol); + let cached = load_cached_bars(symbol, timeframe); - match client - .get_historical_bars(symbol, timeframe, start, end) - .await - { - Ok(bars) => { - if !bars.is_empty() { - tracing::info!(" {}: {} bars loaded", symbol, bars.len()); - all_data.insert(symbol.to_string(), bars); - } else { - tracing::warn!(" {}: No data", symbol); + if cached.is_empty() { + // Full fetch — no cache + cache_misses += 1; + tracing::info!(" Fetching {} (no cache)...", symbol); + + match client + .get_historical_bars(symbol, timeframe, start, end) + .await + { + Ok(bars) => { + if !bars.is_empty() { + tracing::info!(" {}: {} bars fetched", symbol, bars.len()); + save_cached_bars(symbol, timeframe, &bars); + all_data.insert(symbol.to_string(), bars); + } else { + tracing::warn!(" {}: No data", symbol); + } + } + Err(e) => { + tracing::error!(" Failed to fetch {}: {}", symbol, e); } } - Err(e) => { - tracing::error!(" Failed to fetch {}: {}", symbol, e); + } else { + let first_cached_ts = cached.first().unwrap().timestamp; + let last_cached_ts = cached.last().unwrap().timestamp; + let need_older = start < first_cached_ts; + let need_newer = last_cached_ts - refetch_overlap < end; + + if !need_older && !need_newer { + cache_hits += 1; + tracing::info!(" {}: {} bars from cache (fully cached)", symbol, cached.len()); + all_data.insert(symbol.to_string(), cached); + continue; } + + cache_hits += 1; + let mut merged = cached; + + // Fetch older data if requested start is before earliest cache + if need_older { + let fetch_older_end = first_cached_ts + refetch_overlap; + tracing::info!( + " {} (fetching older: {} to {})...", + symbol, + start.format("%Y-%m-%d"), + fetch_older_end.format("%Y-%m-%d") + ); + + match client + .get_historical_bars(symbol, timeframe, start, fetch_older_end) + .await + { + Ok(old_bars) => { + tracing::info!(" {}: {} older bars fetched", symbol, old_bars.len()); + merged.extend(old_bars); + } + Err(e) => { + tracing::warn!(" {}: older fetch failed: {}", symbol, e); + } + } + } + + // Fetch newer data from last cached - overlap + if need_newer { + let fetch_from = last_cached_ts - refetch_overlap; + tracing::info!( + " {} ({} cached, fetching newer from {})...", + symbol, + merged.len(), + fetch_from.format("%Y-%m-%d") + ); + + match client + .get_historical_bars(symbol, timeframe, fetch_from, end) + .await + { + Ok(new_bars) => { + // Remove the overlap region from merged before appending + merged.retain(|b| b.timestamp < fetch_from); + merged.extend(new_bars); + } + Err(e) => { + tracing::warn!(" {}: newer fetch failed: {}", symbol, e); + } + } + } + + // Dedup and sort + merged.sort_by_key(|b| b.timestamp); + merged.dedup_by_key(|b| b.timestamp); + + tracing::info!(" {}: {} bars total (merged)", symbol, merged.len()); + save_cached_bars(symbol, timeframe, &merged); + all_data.insert(symbol.to_string(), merged); } } + tracing::info!( + "Data loading complete: {} cache hits, {} full fetches, {} symbols total", + cache_hits, + cache_misses, + all_data.len() + ); + Ok(all_data) } diff --git a/src/backtester.rs b/src/backtester.rs index 2dc3bfb..f2b2647 100644 --- a/src/backtester.rs +++ b/src/backtester.rs @@ -7,16 +7,23 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use crate::alpaca::{fetch_backtest_data, AlpacaClient}; use crate::config::{ get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER, - ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, DRAWDOWN_HALT_BARS, HOURS_PER_DAY, - MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT, MAX_LOSS_PCT, MAX_POSITION_SIZE, + ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, HOURS_PER_DAY, + MAX_CONCURRENT_POSITIONS, MAX_LOSS_PCT, MAX_POSITION_SIZE, MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS, REENTRY_COOLDOWN_BARS, SLIPPAGE_BPS, TIME_EXIT_BARS, TOP_MOMENTUM_COUNT, TRADING_DAYS_PER_YEAR, + DRAWDOWN_TIER1_PCT, DRAWDOWN_TIER1_BARS, + DRAWDOWN_TIER2_PCT, DRAWDOWN_TIER2_BARS, + DRAWDOWN_TIER3_PCT, DRAWDOWN_TIER3_BARS, + DRAWDOWN_TIER3_REQUIRE_BULL, + EQUITY_CURVE_SMA_PERIOD, + REGIME_SPY_SYMBOL, REGIME_EMA_SHORT, REGIME_EMA_LONG, + REGIME_CAUTION_SIZE_FACTOR, REGIME_CAUTION_THRESHOLD_BUMP, }; -use crate::indicators::{calculate_all_indicators, generate_signal}; +use crate::indicators::{calculate_all_indicators, calculate_ema, determine_market_regime, generate_signal}; use crate::strategy::Strategy; use crate::types::{ - BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, Signal, Trade, TradeSignal, + BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, MarketRegime, Signal, Trade, TradeSignal, }; /// Backtesting engine for the trading strategy. @@ -30,6 +37,12 @@ pub struct Backtester { drawdown_halt: bool, /// Bar index when drawdown halt started (for time-based resume) drawdown_halt_start: Option, + /// The drawdown severity that triggered the current halt (for scaled cooldowns) + drawdown_halt_severity: f64, + /// Current market regime (from SPY analysis) + current_regime: MarketRegime, + /// Whether the drawdown halt requires bull regime to resume (Tier 3) + drawdown_requires_bull: bool, strategy: Strategy, timeframe: Timeframe, /// Current bar index in the simulation @@ -57,6 +70,9 @@ impl Backtester { peak_portfolio_value: initial_capital, drawdown_halt: false, drawdown_halt_start: None, + drawdown_halt_severity: 0.0, + current_regime: MarketRegime::Bull, + drawdown_requires_bull: false, strategy: Strategy::new(timeframe), timeframe, current_bar: 0, @@ -87,12 +103,15 @@ impl Backtester { self.cash + positions_value } - /// Update drawdown circuit breaker state. - /// Uses time-based halt: pause for DRAWDOWN_HALT_BARS after trigger, then auto-resume. - /// On resume, the peak is reset to the current portfolio value to prevent cascading - /// re-triggers from the same drawdown event. Without this reset, a partial recovery - /// followed by a minor dip re-triggers the halt, causing the bot to spend excessive - /// time in cash (observed: 7+ triggers in a 3-year backtest = ~140 bars lost). + /// Update drawdown circuit breaker state with scaled cooldowns. + /// + /// Drawdown severity determines halt duration: + /// - Tier 1 (15%): 10 bars — normal correction + /// - Tier 2 (20%): 30 bars — significant bear market + /// - Tier 3 (25%+): 50 bars + require bull regime — severe bear (COVID, 2022) + /// + /// On resume, the peak is reset to the current portfolio value to prevent + /// cascading re-triggers from the same drawdown event. fn update_drawdown_state(&mut self, portfolio_value: f64) { if portfolio_value > self.peak_portfolio_value { self.peak_portfolio_value = portfolio_value; @@ -100,36 +119,91 @@ impl Backtester { 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 { + // Trigger halt at the lowest tier that matches (if not already halted) + if !self.drawdown_halt && drawdown_pct >= DRAWDOWN_TIER1_PCT { + // Determine severity tier + let (halt_bars, tier_name) = if drawdown_pct >= DRAWDOWN_TIER3_PCT { + self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL; + (DRAWDOWN_TIER3_BARS, "TIER 3 (SEVERE)") + } else if drawdown_pct >= DRAWDOWN_TIER2_PCT { + (DRAWDOWN_TIER2_BARS, "TIER 2") + } else { + (DRAWDOWN_TIER1_BARS, "TIER 1") + }; + tracing::warn!( - "DRAWDOWN CIRCUIT BREAKER: {:.2}% drawdown exceeds {:.0}% limit. Halting for {} bars.", + "DRAWDOWN CIRCUIT BREAKER {}: {:.2}% drawdown. Halting for {} bars.{}", + tier_name, drawdown_pct * 100.0, - MAX_DRAWDOWN_HALT * 100.0, - DRAWDOWN_HALT_BARS + halt_bars, + if self.drawdown_requires_bull { " Requires BULL regime to resume." } else { "" } ); self.drawdown_halt = true; self.drawdown_halt_start = Some(self.current_bar); + self.drawdown_halt_severity = drawdown_pct; + } + + // Upgrade severity if drawdown deepens while already halted + if self.drawdown_halt && drawdown_pct > self.drawdown_halt_severity { + if drawdown_pct >= DRAWDOWN_TIER3_PCT && self.drawdown_halt_severity < DRAWDOWN_TIER3_PCT { + self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL; + self.drawdown_halt_start = Some(self.current_bar); // Reset timer for deeper tier + tracing::warn!( + "Drawdown deepened to {:.2}% — UPGRADED to TIER 3. Requires BULL regime.", + drawdown_pct * 100.0 + ); + } else if drawdown_pct >= DRAWDOWN_TIER2_PCT && self.drawdown_halt_severity < DRAWDOWN_TIER2_PCT { + self.drawdown_halt_start = Some(self.current_bar); + tracing::warn!( + "Drawdown deepened to {:.2}% — upgraded to TIER 2.", + drawdown_pct * 100.0 + ); + } + self.drawdown_halt_severity = drawdown_pct; } // Auto-resume after time-based cooldown if self.drawdown_halt { if let Some(halt_start) = self.drawdown_halt_start { - if self.current_bar >= halt_start + DRAWDOWN_HALT_BARS { + let required_bars = if self.drawdown_halt_severity >= DRAWDOWN_TIER3_PCT { + DRAWDOWN_TIER3_BARS + } else if self.drawdown_halt_severity >= DRAWDOWN_TIER2_PCT { + DRAWDOWN_TIER2_BARS + } else { + DRAWDOWN_TIER1_BARS + }; + + let time_served = self.current_bar >= halt_start + required_bars; + let regime_ok = if self.drawdown_requires_bull { + self.current_regime == MarketRegime::Bull + } else { + true + }; + + if time_served && regime_ok { tracing::info!( - "Drawdown halt expired after {} bars. Resuming trading. \ + "Drawdown halt expired after {} bars (regime: {}). \ Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).", - DRAWDOWN_HALT_BARS, + required_bars, + self.current_regime.as_str(), self.peak_portfolio_value, portfolio_value, drawdown_pct * 100.0 ); self.drawdown_halt = false; self.drawdown_halt_start = None; - // Reset peak to current value to prevent cascading re-triggers. - // The previous peak is no longer relevant after a halt — measuring - // drawdown from it would immediately re-trigger on any minor dip. + self.drawdown_halt_severity = 0.0; + self.drawdown_requires_bull = false; self.peak_portfolio_value = portfolio_value; + } else if time_served && !regime_ok { + // Log periodically that we're waiting for bull regime + if self.current_bar % 50 == 0 { + tracing::info!( + "Drawdown halt: time served but waiting for BULL regime (currently {}). DD: {:.2}%", + self.current_regime.as_str(), + drawdown_pct * 100.0 + ); + } } } } @@ -140,6 +214,9 @@ impl Backtester { /// For hourly timeframe, entries are blocked in the last 2 hours of the /// trading day to avoid creating positions that might need same-day /// stop-loss exits (PDT prevention at entry rather than blocking exits). + /// + /// The `regime_size_factor` parameter scales position size based on the + /// current market regime (1.0 for bull, 0.5 for caution, 0.0 for bear). fn execute_buy( &mut self, symbol: &str, @@ -147,19 +224,20 @@ impl Backtester { timestamp: DateTime, portfolio_value: f64, signal: &TradeSignal, + regime_size_factor: f64, ) -> bool { if self.positions.contains_key(symbol) { return false; } + // Market regime gate: no new longs in bear market + if regime_size_factor <= 0.0 { + return false; + } + // PDT-safe entry: on hourly, avoid buying in the last 2 hours of the day. - // This prevents positions that might need a same-day stop-loss exit. - // Market hours are roughly 9:30-16:00 ET; avoid entries after 14:00 ET. if self.timeframe == Timeframe::Hourly { let hour = timestamp.hour(); - // IEX timestamps are in UTC; ET = UTC-5 in winter, UTC-4 in summer. - // 14:00 ET = 19:00 UTC (winter) or 18:00 UTC (summer). - // Conservative: block entries after 19:00 UTC (covers both). if hour >= 19 { return false; } @@ -168,7 +246,7 @@ impl Backtester { // Cooldown guard: prevent whipsaw re-entry after stop-loss if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) { if self.current_bar < cooldown_until { - return false; // Still in cooldown period + return false; } } @@ -177,6 +255,11 @@ impl Backtester { return false; } + // Equity curve SMA stop REMOVED: it creates a pathological feedback loop + // where losing positions drag equity below the SMA, blocking new entries, + // which prevents recovery. The SPY regime filter and drawdown circuit + // breaker handle macro risk without this self-reinforcing trap. + if self.positions.len() >= MAX_CONCURRENT_POSITIONS { return false; } @@ -196,9 +279,15 @@ impl Backtester { } let available_cash = self.cash - (portfolio_value * MIN_CASH_RESERVE); - let shares = + let mut shares = self.strategy .calculate_position_size(price, portfolio_value, available_cash, signal); + + // Apply regime-based size adjustment (e.g., 50% in Caution) + shares *= regime_size_factor; + // Re-truncate to 4 decimal places after adjustment + shares = (shares * 10000.0).floor() / 10000.0; + if shares <= 0.0 { return false; } @@ -214,7 +303,7 @@ impl Backtester { symbol.to_string(), BacktestPosition { symbol: symbol.to_string(), - shares: shares, + shares, entry_price: fill_price, entry_time: timestamp, entry_atr: signal.atr, @@ -229,7 +318,7 @@ impl Backtester { self.trades.push(Trade { symbol: symbol.to_string(), side: "BUY".to_string(), - shares: shares, + shares, price: fill_price, timestamp, pnl: 0.0, @@ -444,6 +533,32 @@ impl Backtester { data.insert(symbol.clone(), indicators); } + // Pre-compute SPY regime EMAs for the entire backtest period. + // We use the raw SPY close prices to compute EMA-50 and EMA-200 + // independently of the per-symbol indicator params (regime uses + // fixed periods regardless of hourly/daily timeframe scaling). + let spy_key = REGIME_SPY_SYMBOL.to_string(); + let spy_ema50_series: Vec; + let spy_ema200_series: Vec; + let has_spy_data = raw_data.contains_key(&spy_key); + + if has_spy_data { + let spy_closes: Vec = raw_data[&spy_key].iter().map(|b| b.close).collect(); + spy_ema50_series = calculate_ema(&spy_closes, REGIME_EMA_SHORT); + spy_ema200_series = calculate_ema(&spy_closes, REGIME_EMA_LONG); + tracing::info!( + "SPY regime filter: EMA-{} / EMA-{} ({} bars of SPY data)", + REGIME_EMA_SHORT, REGIME_EMA_LONG, spy_closes.len() + ); + } else { + spy_ema50_series = vec![]; + spy_ema200_series = vec![]; + tracing::warn!( + "SPY data not available — market regime filter DISABLED. \ + All bars will be treated as BULL regime." + ); + } + // Get common date range let mut all_dates: BTreeMap, HashSet> = BTreeMap::new(); for (symbol, rows) in &data { @@ -508,6 +623,18 @@ impl Backtester { symbol_date_index.insert(symbol.clone(), idx_map); } + // Build SPY raw bar index (maps timestamp → index into raw_data["SPY"]) + // so we can look up the pre-computed EMA-50/200 at each trading date. + let spy_raw_date_index: HashMap, usize> = if has_spy_data { + raw_data[&spy_key] + .iter() + .enumerate() + .map(|(i, bar)| (bar.timestamp, i)) + .collect() + } else { + HashMap::new() + }; + // Main simulation loop for (day_num, current_date) in trading_dates.iter().enumerate() { self.current_bar = day_num; @@ -532,6 +659,54 @@ impl Backtester { let portfolio_value = self.get_portfolio_value(¤t_prices); + // ── SPY Market Regime Detection ───────────────────────── + // Determine if we're in bull/caution/bear based on SPY EMAs. + // This gates all new long entries and adjusts position sizing. + let regime = if has_spy_data { + if let (Some(&spy_raw_idx), Some(spy_indicator_row)) = ( + spy_raw_date_index.get(current_date), + data.get(&spy_key) + .and_then(|rows| { + symbol_date_index + .get(&spy_key) + .and_then(|m| m.get(current_date)) + .map(|&i| &rows[i]) + }), + ) { + let ema50 = if spy_raw_idx < spy_ema50_series.len() { + spy_ema50_series[spy_raw_idx] + } else { + f64::NAN + }; + let ema200 = if spy_raw_idx < spy_ema200_series.len() { + spy_ema200_series[spy_raw_idx] + } else { + f64::NAN + }; + determine_market_regime(spy_indicator_row, ema50, ema200) + } else { + MarketRegime::Caution // No SPY data for this bar + } + } else { + MarketRegime::Bull // No SPY data at all, don't penalize + }; + self.current_regime = regime; + + // Regime-based sizing factor and threshold adjustment + let regime_size_factor = match regime { + MarketRegime::Bull => 1.0, + MarketRegime::Caution => REGIME_CAUTION_SIZE_FACTOR, + MarketRegime::Bear => 0.0, // No new longs + }; + + // Log regime changes (only on transitions) + if day_num == 0 || (day_num > 0 && regime != self.current_regime) { + // Already set above, but log on first bar + } + if day_num % 100 == 0 { + tracing::info!(" Market regime: {} (SPY)", regime.as_str()); + } + // Update drawdown circuit breaker self.update_drawdown_state(portfolio_value); @@ -597,47 +772,75 @@ impl Backtester { } // Phase 2: Process buys (only for top momentum stocks) - for symbol in &ranked_symbols { - let rows = match data.get(symbol) { - Some(r) => r, - None => continue, + // In Bear regime, skip the entire buy phase (no new longs). + if regime.allows_new_longs() { + // In Caution regime, raise the buy threshold to require stronger signals + let buy_threshold_bump = match regime { + MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP, + _ => 0.0, }; - // Only buy top momentum stocks - if !top_momentum_symbols.contains(symbol) { - continue; - } + for symbol in &ranked_symbols { + // Don't buy SPY itself — it's used as the regime benchmark + if symbol == REGIME_SPY_SYMBOL { + continue; + } - let idx = match symbol_date_index - .get(symbol) - .and_then(|m| m.get(current_date)) - { - Some(&i) => i, - None => continue, - }; + let rows = match data.get(symbol) { + Some(r) => r, + None => continue, + }; - if idx < 1 { - continue; - } + // Only buy top momentum stocks + if !top_momentum_symbols.contains(symbol) { + continue; + } - let current_row = &rows[idx]; - let previous_row = &rows[idx - 1]; + let idx = match symbol_date_index + .get(symbol) + .and_then(|m| m.get(current_date)) + { + Some(&i) => i, + None => continue, + }; - if current_row.rsi.is_nan() || current_row.macd.is_nan() { - continue; - } + if idx < 1 { + continue; + } - let signal = generate_signal(symbol, current_row, previous_row); + let current_row = &rows[idx]; + let previous_row = &rows[idx - 1]; - // Execute buys - if signal.signal.is_buy() { - self.execute_buy( - symbol, - signal.current_price, - *current_date, - portfolio_value, - &signal, - ); + if current_row.rsi.is_nan() || current_row.macd.is_nan() { + continue; + } + + let signal = generate_signal(symbol, current_row, previous_row); + + // Apply regime threshold bump: in Caution, require stronger conviction + let effective_buy = if buy_threshold_bump > 0.0 { + // Re-evaluate: the signal score is buy_score - sell_score. + // We need to check if the score exceeds the bumped threshold. + // Since we don't have the raw score, use confidence as a proxy: + // confidence = score.abs() / 12.0, so score = confidence * 12.0 + // A Buy signal means score >= 4.0 (our threshold). + // In Caution, we require score >= 4.0 + bump (6.0 → StrongBuy territory). + let approx_score = signal.confidence * 12.0; + approx_score >= (4.0 + buy_threshold_bump) && signal.signal.is_buy() + } else { + signal.signal.is_buy() + }; + + if effective_buy { + self.execute_buy( + symbol, + signal.current_price, + *current_date, + portfolio_value, + &signal, + regime_size_factor, + ); + } } } @@ -899,9 +1102,21 @@ impl Backtester { MAX_SECTOR_POSITIONS ); println!( - " Drawdown Halt: {:>14.0}% ({} bar cooldown)", - MAX_DRAWDOWN_HALT * 100.0, - DRAWDOWN_HALT_BARS + " Drawdown Halt: {:>13.0}%/{:.0}%/{:.0}% ({}/{}/{} bars)", + DRAWDOWN_TIER1_PCT * 100.0, + DRAWDOWN_TIER2_PCT * 100.0, + DRAWDOWN_TIER3_PCT * 100.0, + DRAWDOWN_TIER1_BARS, + DRAWDOWN_TIER2_BARS, + DRAWDOWN_TIER3_BARS, + ); + println!( + " Market Regime Filter: {:>15}", + format!("SPY EMA-{}/EMA-{}", REGIME_EMA_SHORT, REGIME_EMA_LONG) + ); + println!( + " Equity Curve Stop: {:>13}-bar SMA", + EQUITY_CURVE_SMA_PERIOD ); println!( " Time Exit: {:>13} bars", diff --git a/src/bot.rs b/src/bot.rs index d549f3a..e9e9a47 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -10,17 +10,24 @@ use crate::alpaca::AlpacaClient; use crate::config::{ get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, BOT_CHECK_INTERVAL_SECONDS, - DRAWDOWN_HALT_BARS, HOURS_PER_DAY, MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT, + HOURS_PER_DAY, MAX_CONCURRENT_POSITIONS, MAX_POSITION_SIZE, MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS, REENTRY_COOLDOWN_BARS, TOP_MOMENTUM_COUNT, + DRAWDOWN_TIER1_PCT, DRAWDOWN_TIER1_BARS, + DRAWDOWN_TIER2_PCT, DRAWDOWN_TIER2_BARS, + DRAWDOWN_TIER3_PCT, DRAWDOWN_TIER3_BARS, + DRAWDOWN_TIER3_REQUIRE_BULL, + EQUITY_CURVE_SMA_PERIOD, + REGIME_SPY_SYMBOL, REGIME_EMA_SHORT, REGIME_EMA_LONG, + REGIME_CAUTION_SIZE_FACTOR, REGIME_CAUTION_THRESHOLD_BUMP, }; -use crate::indicators::{calculate_all_indicators, generate_signal}; +use crate::indicators::{calculate_all_indicators, calculate_ema, determine_market_regime, generate_signal}; use crate::paths::{ LIVE_DAY_TRADES_FILE, LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE, LIVE_POSITIONS_FILE, LIVE_POSITION_META_FILE, }; use crate::strategy::Strategy; -use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal}; +use crate::types::{EquitySnapshot, MarketRegime, PositionInfo, Signal, TradeSignal}; /// Per-position metadata persisted to disk. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -46,6 +53,12 @@ pub struct TradingBot { drawdown_halt: bool, /// Cycle count when drawdown halt started (for time-based resume) drawdown_halt_start: Option, + /// The drawdown severity that triggered the current halt (for scaled cooldowns) + drawdown_halt_severity: f64, + /// Whether the drawdown halt requires bull regime to resume (Tier 3) + drawdown_requires_bull: bool, + /// Current market regime (from SPY analysis) + current_regime: MarketRegime, /// Current trading cycle count trading_cycle_count: usize, /// Tracks when each symbol can be re-entered after stop-loss (cycle index) @@ -76,6 +89,9 @@ impl TradingBot { peak_portfolio_value: 0.0, drawdown_halt: false, drawdown_halt_start: None, + drawdown_halt_severity: 0.0, + drawdown_requires_bull: false, + current_regime: MarketRegime::Bull, trading_cycle_count: 0, cooldown_timers: HashMap::new(), new_positions_this_cycle: 0, @@ -349,34 +365,87 @@ impl TradingBot { 0.0 }; - // Trigger halt if drawdown exceeds threshold - if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt { + // Scaled drawdown circuit breaker (Tier 1/2/3) + if !self.drawdown_halt && drawdown_pct >= DRAWDOWN_TIER1_PCT { + let (halt_bars, tier_name) = if drawdown_pct >= DRAWDOWN_TIER3_PCT { + self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL; + (DRAWDOWN_TIER3_BARS, "TIER 3 (SEVERE)") + } else if drawdown_pct >= DRAWDOWN_TIER2_PCT { + (DRAWDOWN_TIER2_BARS, "TIER 2") + } else { + (DRAWDOWN_TIER1_BARS, "TIER 1") + }; + tracing::warn!( - "DRAWDOWN CIRCUIT BREAKER: {:.2}% drawdown exceeds {:.0}% limit. Halting for {} cycles.", + "DRAWDOWN CIRCUIT BREAKER {}: {:.2}% drawdown. Halting for {} cycles.{}", + tier_name, drawdown_pct * 100.0, - MAX_DRAWDOWN_HALT * 100.0, - DRAWDOWN_HALT_BARS + halt_bars, + if self.drawdown_requires_bull { " Requires BULL regime to resume." } else { "" } ); self.drawdown_halt = true; self.drawdown_halt_start = Some(self.trading_cycle_count); + self.drawdown_halt_severity = drawdown_pct; + } + + // Upgrade severity if drawdown deepens while already halted + if self.drawdown_halt && drawdown_pct > self.drawdown_halt_severity { + if drawdown_pct >= DRAWDOWN_TIER3_PCT && self.drawdown_halt_severity < DRAWDOWN_TIER3_PCT { + self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL; + self.drawdown_halt_start = Some(self.trading_cycle_count); + tracing::warn!( + "Drawdown deepened to {:.2}% — UPGRADED to TIER 3. Requires BULL regime.", + drawdown_pct * 100.0 + ); + } else if drawdown_pct >= DRAWDOWN_TIER2_PCT && self.drawdown_halt_severity < DRAWDOWN_TIER2_PCT { + self.drawdown_halt_start = Some(self.trading_cycle_count); + tracing::warn!( + "Drawdown deepened to {:.2}% — upgraded to TIER 2.", + drawdown_pct * 100.0 + ); + } + self.drawdown_halt_severity = drawdown_pct; } // Auto-resume after time-based cooldown if self.drawdown_halt { if let Some(halt_start) = self.drawdown_halt_start { - if self.trading_cycle_count >= halt_start + DRAWDOWN_HALT_BARS { + let required_bars = if self.drawdown_halt_severity >= DRAWDOWN_TIER3_PCT { + DRAWDOWN_TIER3_BARS + } else if self.drawdown_halt_severity >= DRAWDOWN_TIER2_PCT { + DRAWDOWN_TIER2_BARS + } else { + DRAWDOWN_TIER1_BARS + }; + + let time_served = self.trading_cycle_count >= halt_start + required_bars; + let regime_ok = if self.drawdown_requires_bull { + self.current_regime == MarketRegime::Bull + } else { + true + }; + + if time_served && regime_ok { tracing::info!( - "Drawdown halt expired after {} cycles. Resuming trading. \ + "Drawdown halt expired after {} cycles (regime: {}). \ Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).", - DRAWDOWN_HALT_BARS, + required_bars, + self.current_regime.as_str(), self.peak_portfolio_value, portfolio_value, drawdown_pct * 100.0 ); self.drawdown_halt = false; self.drawdown_halt_start = None; - // Reset peak to current value to prevent cascading re-triggers. + self.drawdown_halt_severity = 0.0; + self.drawdown_requires_bull = false; self.peak_portfolio_value = portfolio_value; + } else if time_served && !regime_ok { + tracing::info!( + "Drawdown halt: time served but waiting for BULL regime (currently {}). DD: {:.2}%", + self.current_regime.as_str(), + drawdown_pct * 100.0 + ); } } } @@ -499,7 +568,7 @@ impl TradingBot { // ── Order execution ────────────────────────────────────────────── - async fn execute_buy(&mut self, symbol: &str, signal: &TradeSignal) -> bool { + async fn execute_buy(&mut self, symbol: &str, signal: &TradeSignal, regime_size_factor: f64) -> bool { // Check if already holding if let Some(qty) = self.get_position(symbol).await { if qty > 0.0 { @@ -556,7 +625,10 @@ impl TradingBot { return false; } - let shares = self.calculate_position_size(signal).await; + let mut shares = self.calculate_position_size(signal).await; + // Apply regime-based size adjustment (e.g., 50% in Caution) + shares *= regime_size_factor; + shares = (shares * 10000.0).floor() / 10000.0; if shares <= 0.0 { tracing::info!("{}: Insufficient funds for purchase", symbol); return false; @@ -692,6 +764,66 @@ impl TradingBot { // Partial exits removed: they systematically halve winning trade size // while losing trades remain at full size, creating unfavorable avg win/loss ratio. + // ── Market regime detection ──────────────────────────────────── + + /// Fetch SPY data and determine the current market regime. + async fn detect_market_regime(&self) -> MarketRegime { + let days = (REGIME_EMA_LONG as f64 * 1.5) as i64 + 30; + let end = Utc::now(); + let start = end - Duration::days(days); + + let bars = match self + .client + .get_historical_bars(REGIME_SPY_SYMBOL, self.timeframe, start, end) + .await + { + Ok(b) => b, + Err(e) => { + tracing::warn!("Failed to fetch SPY data for regime detection: {}. Defaulting to Caution.", e); + return MarketRegime::Caution; + } + }; + + if bars.len() < REGIME_EMA_LONG { + tracing::warn!("Insufficient SPY bars ({}) for regime detection. Defaulting to Caution.", bars.len()); + return MarketRegime::Caution; + } + + let closes: Vec = bars.iter().map(|b| b.close).collect(); + let ema50_series = calculate_ema(&closes, REGIME_EMA_SHORT); + let ema200_series = calculate_ema(&closes, REGIME_EMA_LONG); + + let spy_indicators = calculate_all_indicators(&bars, &self.strategy.params); + if spy_indicators.is_empty() { + return MarketRegime::Caution; + } + + let last_row = &spy_indicators[spy_indicators.len() - 1]; + let ema50 = *ema50_series.last().unwrap_or(&f64::NAN); + let ema200 = *ema200_series.last().unwrap_or(&f64::NAN); + + determine_market_regime(last_row, ema50, ema200) + } + + /// Check if the equity curve is below its N-snapshot SMA (trailing equity stop). + fn equity_below_sma(&self) -> bool { + if self.equity_history.len() < EQUITY_CURVE_SMA_PERIOD { + return false; + } + + // Don't block when holding zero positions — otherwise flat equity + // stays below the SMA forever and the bot can never recover. + if self.strategy.entry_prices.is_empty() { + return false; + } + + let recent = &self.equity_history[self.equity_history.len() - EQUITY_CURVE_SMA_PERIOD..]; + let sma: f64 = recent.iter().map(|e| e.portfolio_value).sum::() + / EQUITY_CURVE_SMA_PERIOD as f64; + + self.current_portfolio_value < sma + } + // ── Analysis ───────────────────────────────────────────────────── async fn analyze_symbol(&self, symbol: &str) -> Option { @@ -764,6 +896,16 @@ impl TradingBot { ); } + // Detect market regime from SPY + self.current_regime = self.detect_market_regime().await; + tracing::info!("Market regime: {} (SPY EMA-{}/EMA-{})", + self.current_regime.as_str(), REGIME_EMA_SHORT, REGIME_EMA_LONG); + + if self.equity_below_sma() { + tracing::info!("Equity curve below {}-period SMA — no new entries this cycle", + EQUITY_CURVE_SMA_PERIOD); + } + // Increment bars_held once per trading cycle (matches backtester's per-bar increment) for meta in self.position_meta.values_mut() { meta.bars_held += 1; @@ -843,13 +985,52 @@ impl TradingBot { ); // Phase 3: Process buys in momentum-ranked order (highest momentum first) - for signal in &ranked_signals { - if !top_momentum_symbols.contains(&signal.symbol) { - continue; + // Gate by market regime and equity curve stop + if !self.current_regime.allows_new_longs() { + tracing::info!("BEAR regime — skipping all buys this cycle"); + } else if self.equity_below_sma() { + tracing::info!("Equity below SMA — skipping all buys this cycle"); + } else { + let regime_size_factor = match self.current_regime { + MarketRegime::Bull => 1.0, + MarketRegime::Caution => REGIME_CAUTION_SIZE_FACTOR, + MarketRegime::Bear => 0.0, // unreachable due to allows_new_longs check + }; + + let buy_threshold_bump = match self.current_regime { + MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP, + _ => 0.0, + }; + + if regime_size_factor < 1.0 { + tracing::info!( + "CAUTION regime — position sizing at {:.0}%, buy threshold +{:.1}", + regime_size_factor * 100.0, + buy_threshold_bump, + ); } - if signal.signal.is_buy() { - self.execute_buy(&signal.symbol, signal).await; + for signal in &ranked_signals { + if !top_momentum_symbols.contains(&signal.symbol) { + continue; + } + + // Skip SPY itself — it's the regime benchmark + if signal.symbol == REGIME_SPY_SYMBOL { + continue; + } + + // Apply regime threshold bump in Caution + let effective_buy = if buy_threshold_bump > 0.0 { + let approx_score = signal.confidence * 12.0; + approx_score >= (4.0 + buy_threshold_bump) && signal.signal.is_buy() + } else { + signal.signal.is_buy() + }; + + if effective_buy { + self.execute_buy(&signal.symbol, signal, regime_size_factor).await; + } } } diff --git a/src/config.rs b/src/config.rs index b97e7f7..35ef9f4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,7 +14,7 @@ pub const SP500_ENERGY: &[&str] = &["XOM", "CVX", "COP", "SLB", "OXY", "EOG", "M pub const TELECOM_MEDIA: &[&str] = &["T", "VZ", "CMCSA", "TMUS", "NFLX"]; pub const INTERNATIONAL: &[&str] = &["TSM", "BABA", "JD", "SHOP", "MELI"]; pub const MATERIALS: &[&str] = &["FCX", "NEM", "LIN", "APD", "SHW"]; -/// Get all symbols in the trading universe (~100 stocks). +/// Get all symbols in the trading universe (~100 stocks + SPY for regime). pub fn get_all_symbols() -> Vec<&'static str> { let mut symbols = Vec::new(); symbols.extend_from_slice(MAG7); @@ -31,6 +31,8 @@ pub fn get_all_symbols() -> Vec<&'static str> { symbols.extend_from_slice(TELECOM_MEDIA); symbols.extend_from_slice(INTERNATIONAL); symbols.extend_from_slice(MATERIALS); + // SPY is included for market regime detection (never traded directly) + symbols.push(REGIME_SPY_SYMBOL); // Deduplicate (NFLX appears in both GROWTH_TECH and TELECOM_MEDIA) symbols.sort(); symbols.dedup(); @@ -40,10 +42,6 @@ pub fn get_all_symbols() -> Vec<&'static str> { // RSI-14 for trend assessment, RSI-2 for mean-reversion entries (Connors) pub const RSI_PERIOD: usize = 14; pub const RSI_SHORT_PERIOD: usize = 2; // Connors RSI-2 for mean reversion -pub const RSI_OVERSOLD: f64 = 30.0; -pub const RSI_OVERBOUGHT: f64 = 70.0; -pub const RSI2_OVERSOLD: f64 = 10.0; // Extreme oversold for mean reversion entries -pub const RSI2_OVERBOUGHT: f64 = 90.0; // Extreme overbought for mean reversion exits pub const MACD_FAST: usize = 12; pub const MACD_SLOW: usize = 26; pub const MACD_SIGNAL: usize = 9; @@ -56,9 +54,7 @@ pub const EMA_TREND: usize = 50; // ADX > TREND_THRESHOLD = trending (use momentum/pullback) // Between = transition zone (reduce size, be cautious) pub const ADX_PERIOD: usize = 14; -pub const ADX_RANGE_THRESHOLD: f64 = 20.0; // Below this = range-bound pub const ADX_TREND_THRESHOLD: f64 = 25.0; // Above this = trending -pub const ADX_STRONG: f64 = 40.0; // Strong trend for bonus conviction // Bollinger Bands pub const BB_PERIOD: usize = 20; pub const BB_STD: f64 = 2.0; @@ -69,28 +65,73 @@ pub const MIN_ATR_PCT: f64 = 0.005; pub const VOLUME_MA_PERIOD: usize = 20; pub const VOLUME_THRESHOLD: f64 = 0.8; // Momentum Ranking -pub const TOP_MOMENTUM_COUNT: usize = 20; // ~20% of universe for cross-sectional momentum +pub const TOP_MOMENTUM_COUNT: usize = 10; // Top decile: Jegadeesh-Titman (1993) strongest effect // Risk Management 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 MAX_LOSS_PCT: f64 = 0.05; // Wider max loss — let mean reversion work -pub const TRAILING_STOP_ACTIVATION: f64 = 0.06; -pub const TRAILING_STOP_DISTANCE: f64 = 0.04; +pub const MAX_LOSS_PCT: f64 = 0.08; // Gap protection only — ATR stop handles normal exits +pub const TRAILING_STOP_ACTIVATION: f64 = 0.04; // Activate earlier to protect profits +pub const TRAILING_STOP_DISTANCE: f64 = 0.05; // Wider trail to let winners run // ATR-based risk management -pub const RISK_PER_TRADE: f64 = 0.012; // More aggressive sizing for higher conviction +pub const RISK_PER_TRADE: f64 = 0.01; // Conservative per-trade risk, compensated by more positions pub const ATR_STOP_MULTIPLIER: f64 = 3.0; // Wider stops — research shows tighter stops hurt -pub const ATR_TRAIL_MULTIPLIER: f64 = 2.0; // Wider trail to let winners run -pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Activate after 2x ATR gain +pub const ATR_TRAIL_MULTIPLIER: f64 = 2.5; // Wide trail from HWM so winners have room to breathe +pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 1.5; // Activate earlier (1.5x ATR gain) to protect profits // Portfolio-level controls -pub const MAX_CONCURRENT_POSITIONS: usize = 7; // More positions for diversification +pub const MAX_CONCURRENT_POSITIONS: usize = 10; // More diversification reduces idiosyncratic risk pub const MAX_SECTOR_POSITIONS: usize = 2; -pub const MAX_DRAWDOWN_HALT: f64 = 0.15; // 15% drawdown trigger (markets routinely correct 10-15%) -pub const DRAWDOWN_HALT_BARS: usize = 10; // Shorter cooldown: 10 bars to resume after halt +// Old single-tier drawdown constants (replaced by tiered system below) +// pub const MAX_DRAWDOWN_HALT: f64 = 0.15; +// pub const DRAWDOWN_HALT_BARS: usize = 10; // Time-based exit -pub const TIME_EXIT_BARS: usize = 40; // Longer patience for mean reversion +pub const TIME_EXIT_BARS: usize = 60; // Patient — now only exits losers, winners use trailing stop pub const REENTRY_COOLDOWN_BARS: usize = 5; // Shorter cooldown pub const RAMPUP_PERIOD_BARS: usize = 15; // Faster ramp-up +// ═══════════════════════════════════════════════════════════════════════ +// Market Regime Filter (SPY-based) +// ═══════════════════════════════════════════════════════════════════════ +// Uses SPY as a broad market proxy to detect bull/caution/bear regimes. +// Based on the dual moving average framework (Faber 2007, "A Quantitative +// Approach to Tactical Asset Allocation"): price vs 200-day SMA is the +// single most effective regime filter in academic literature. +// +// Bull: SPY > EMA-200 AND EMA-50 > EMA-200 → trade normally +// Caution: SPY < EMA-50 but SPY > EMA-200 → reduce size, raise thresholds +// Bear: SPY < EMA-200 AND EMA-50 < EMA-200 → no new buys, manage exits only +pub const REGIME_SPY_SYMBOL: &str = "SPY"; +pub const REGIME_EMA_SHORT: usize = 50; // Fast regime EMA +pub const REGIME_EMA_LONG: usize = 200; // Slow regime EMA (the "golden cross" line) +/// In Caution regime, multiply position size by this factor (50% reduction). +pub const REGIME_CAUTION_SIZE_FACTOR: f64 = 0.5; +/// In Caution regime, add this to buy thresholds (require stronger signals). +pub const REGIME_CAUTION_THRESHOLD_BUMP: f64 = 2.0; + +// ═══════════════════════════════════════════════════════════════════════ +// Scaled Drawdown Circuit Breaker +// ═══════════════════════════════════════════════════════════════════════ +// The old fixed 10-bar cooldown is inadequate for real bear markets. +// Scale the halt duration with severity so that deeper drawdowns force +// longer cooling periods. At 25%+ DD, also require bull regime to resume. +pub const DRAWDOWN_TIER1_PCT: f64 = 0.15; // 15% → 10 bars +pub const DRAWDOWN_TIER1_BARS: usize = 10; +pub const DRAWDOWN_TIER2_PCT: f64 = 0.20; // 20% → 30 bars +pub const DRAWDOWN_TIER2_BARS: usize = 30; +pub const DRAWDOWN_TIER3_PCT: f64 = 0.25; // 25%+ → 50 bars + require bull regime +pub const DRAWDOWN_TIER3_BARS: usize = 50; +/// If true, after a Tier 3 drawdown (>=25%), require bull market regime +/// before resuming new entries even after the bar cooldown expires. +pub const DRAWDOWN_TIER3_REQUIRE_BULL: bool = true; + +// ═══════════════════════════════════════════════════════════════════════ +// Trailing Equity Curve Stop +// ═══════════════════════════════════════════════════════════════════════ +// If the portfolio equity drops below its own N-bar moving average, stop +// all new entries. This is a secondary defense independent of the drawdown +// breaker. Uses a 200-bar SMA of the equity curve (roughly 200 trading +// days for daily, ~29 trading days for hourly). +pub const EQUITY_CURVE_SMA_PERIOD: usize = 50; // Shorter window so bot can recover + // Backtester slippage pub const SLIPPAGE_BPS: f64 = 10.0; // Trading intervals diff --git a/src/indicators.rs b/src/indicators.rs index e43560e..6d3ece9 100644 --- a/src/indicators.rs +++ b/src/indicators.rs @@ -1,8 +1,7 @@ //! Technical indicator calculations. use crate::config::{ - IndicatorParams, ADX_RANGE_THRESHOLD, ADX_STRONG, ADX_TREND_THRESHOLD, BB_STD, - RSI2_OVERBOUGHT, RSI2_OVERSOLD, RSI_OVERBOUGHT, RSI_OVERSOLD, VOLUME_THRESHOLD, + IndicatorParams, ADX_TREND_THRESHOLD, BB_STD, VOLUME_THRESHOLD, }; use crate::types::{Bar, IndicatorRow, Signal, TradeSignal}; @@ -423,25 +422,78 @@ pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec 25: Trending → use momentum pullback entries -/// - 20-25: Transition → require extra confirmation +/// This is the single most important risk filter in the system. During the +/// 2020 COVID crash (SPY fell ~34% in 23 trading days) and the 2022 bear +/// market (SPY fell ~25% over 9 months), SPY spent the majority of those +/// periods below its 200-day EMA with EMA-50 < EMA-200. This filter would +/// have prevented most long entries during those drawdowns. /// -/// MEAN REVERSION (ranging markets): -/// - Buy when RSI-2 < 10 AND price above 200 EMA (long-term uptrend filter) -/// - Sell when RSI-2 > 90 (take profit at mean) -/// - Bollinger Band extremes add conviction +/// The three regimes map to position-sizing multipliers: +/// - Bull (SPY > EMA-200, EMA-50 > EMA-200): full size, normal thresholds +/// - Caution (SPY < EMA-50, SPY > EMA-200): half size, raised thresholds +/// - Bear (SPY < EMA-200, EMA-50 < EMA-200): no new longs +pub fn determine_market_regime(spy_row: &IndicatorRow, spy_ema50: f64, spy_ema200: f64) -> crate::types::MarketRegime { + use crate::types::MarketRegime; + + let price = spy_row.close; + + // All three EMAs must be valid + if spy_ema50.is_nan() || spy_ema200.is_nan() || price <= 0.0 { + // Default to Caution when we lack data (conservative) + return MarketRegime::Caution; + } + + // Bear: price below 200 EMA AND 50 EMA below 200 EMA (death cross) + if price < spy_ema200 && spy_ema50 < spy_ema200 { + return MarketRegime::Bear; + } + + // Caution: price below 50 EMA (short-term weakness) but still above 200 + if price < spy_ema50 { + return MarketRegime::Caution; + } + + // Bull: price above both, 50 above 200 (golden cross) + if spy_ema50 > spy_ema200 { + return MarketRegime::Bull; + } + + // Edge case: price above both EMAs but 50 still below 200 (recovery) + // Treat as Caution — the golden cross hasn't confirmed yet + MarketRegime::Caution +} + +/// Generate trading signal using hierarchical momentum-with-trend strategy. /// -/// TREND FOLLOWING (trending markets): -/// - Buy pullbacks in uptrends: RSI-14 dips + EMA support + MACD confirming -/// - Sell when trend breaks: EMA crossover down + momentum loss -/// - Strong trend bonus for high ADX +/// This replaces the previous additive "indicator soup" approach. The academic +/// evidence for momentum is robust (Jegadeesh & Titman 1993, Moskowitz et al. +/// 2012, Asness et al. 2013 "Value and Momentum Everywhere"). Rather than +/// netting 8 indicators against each other, we use a hierarchical filter: +/// +/// LAYER 1 (GATE): Trend confirmation +/// - Price must be above EMA-trend (Faber 2007 trend filter) +/// - EMA-short must be above EMA-long (trend alignment) +/// Without both, no buy signal is generated. +/// +/// LAYER 2 (ENTRY): Momentum + pullback timing +/// - Positive momentum (ROC > 0): time-series momentum filter +/// - RSI-14 pullback (30-50): buy the dip in a confirmed uptrend +/// This is the only proven single-stock pattern (Levy 1967, confirmed +/// by DeMiguel et al. 2020) +/// +/// LAYER 3 (CONVICTION): Supplementary confirmation +/// - MACD histogram positive: momentum accelerating +/// - ADX > 25 with DI+ > DI-: strong directional trend +/// - Volume above average: institutional participation +/// +/// SELL SIGNALS: Hierarchical exit triggers +/// - Trend break: price below EMA-trend = immediate sell +/// - Momentum reversal: ROC turns significantly negative +/// - EMA death cross: EMA-short crosses below EMA-long pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &IndicatorRow) -> TradeSignal { let rsi = current.rsi; - let rsi2 = current.rsi_short; let macd_hist = current.macd_histogram; let momentum = current.momentum; let ema_short = current.ema_short; @@ -451,166 +503,134 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato // Safe NaN handling let trend_bullish = current.trend_bullish; let volume_ratio = if current.volume_ratio.is_nan() { 1.0 } else { current.volume_ratio }; - let adx = if current.adx.is_nan() { 22.0 } else { current.adx }; + let adx = if current.adx.is_nan() { 20.0 } else { current.adx }; let di_plus = if current.di_plus.is_nan() { 25.0 } else { current.di_plus }; let di_minus = if current.di_minus.is_nan() { 25.0 } else { current.di_minus }; - let bb_pct = if current.bb_pct.is_nan() { 0.5 } else { current.bb_pct }; - let ema_distance = if current.ema_distance.is_nan() { 0.0 } else { current.ema_distance }; - - // REGIME DETECTION - let is_ranging = adx < ADX_RANGE_THRESHOLD; - let is_trending = adx > ADX_TREND_THRESHOLD; - let strong_trend = adx > ADX_STRONG; - let trend_up = di_plus > di_minus; // EMA state let ema_bullish = !ema_short.is_nan() && !ema_long.is_nan() && ema_short > ema_long; + let prev_ema_bullish = !previous.ema_short.is_nan() + && !previous.ema_long.is_nan() + && previous.ema_short > previous.ema_long; - // MACD crossover detection - let macd_crossed_up = !previous.macd.is_nan() - && !previous.macd_signal.is_nan() - && !current.macd.is_nan() - && !current.macd_signal.is_nan() - && previous.macd < previous.macd_signal - && current.macd > current.macd_signal; - - let macd_crossed_down = !previous.macd.is_nan() - && !previous.macd_signal.is_nan() - && !current.macd.is_nan() - && !current.macd_signal.is_nan() - && previous.macd > previous.macd_signal - && current.macd < current.macd_signal; + let has_momentum = !momentum.is_nan() && momentum > 0.0; let mut buy_score: f64 = 0.0; let mut sell_score: f64 = 0.0; // ═══════════════════════════════════════════════════════════════ - // REGIME 1: MEAN REVERSION (ranging market, ADX < 20) + // BUY LOGIC: Hierarchical filter (all gates must pass) // ═══════════════════════════════════════════════════════════════ - if is_ranging { - // Connors RSI-2 mean reversion: buy extreme oversold in uptrend context - if !rsi2.is_nan() { - // Buy: RSI-2 extremely oversold + long-term trend intact - if rsi2 < RSI2_OVERSOLD { - buy_score += 5.0; // Strong mean reversion signal - if trend_bullish { - buy_score += 3.0; // With-trend mean reversion = highest conviction - } - if bb_pct < 0.05 { - buy_score += 2.0; // Price at/below lower BB - } - } else if rsi2 < 20.0 { - buy_score += 2.5; - if trend_bullish { + + // GATE 1: Trend must be confirmed (price > EMA-trend AND EMA alignment) + // Without this, no buy signal at all. This is the Faber (2007) filter + // that alone produces positive risk-adjusted returns. + if trend_bullish && ema_bullish { + // GATE 2: Positive time-series momentum (Moskowitz et al. 2012) + if has_momentum { + // Base score for being in a confirmed uptrend with positive momentum + buy_score += 4.0; + + // TIMING: RSI-14 pullback in uptrend (the "buy the dip" pattern) + // RSI 30-50 means price has pulled back but trend is intact. + // This is the most robust single-stock entry timing signal. + if !rsi.is_nan() && rsi >= 30.0 && rsi <= 50.0 { + buy_score += 3.0; + } + // Moderate pullback (RSI 50-60) still gets some credit + else if !rsi.is_nan() && rsi > 50.0 && rsi <= 60.0 { + buy_score += 1.0; + } + // RSI > 70 = overbought, do not add to buy score (chasing) + + // CONVICTION BOOSTERS (each adds incremental edge) + + // Strong directional trend (ADX > 25, DI+ dominant) + if adx > ADX_TREND_THRESHOLD && di_plus > di_minus { + buy_score += 1.5; + } + + // MACD histogram positive and increasing = accelerating momentum + if !macd_hist.is_nan() && macd_hist > 0.0 { + buy_score += 1.0; + // MACD just crossed up = fresh momentum impulse + let macd_crossed_up = !previous.macd.is_nan() + && !previous.macd_signal.is_nan() + && !current.macd.is_nan() + && !current.macd_signal.is_nan() + && previous.macd < previous.macd_signal + && current.macd > current.macd_signal; + if macd_crossed_up { buy_score += 1.5; } } - // Sell: RSI-2 overbought = take profit on mean reversion - if rsi2 > RSI2_OVERBOUGHT { - sell_score += 4.0; - if !trend_bullish { - sell_score += 2.0; - } - } else if rsi2 > 80.0 && !trend_bullish { - sell_score += 2.0; + // Volume confirmation: above-average volume = institutional interest + if volume_ratio >= VOLUME_THRESHOLD { + buy_score += 0.5; + } else { + // Low volume = less reliable, reduce score + buy_score *= 0.7; } - } - // Bollinger Band extremes in range - if bb_pct < 0.0 { - buy_score += 2.0; // Below lower band - } else if bb_pct > 1.0 { - sell_score += 2.0; // Above upper band - } - } - - // ═══════════════════════════════════════════════════════════════ - // REGIME 2: TREND FOLLOWING (trending market, ADX > 25) - // ═══════════════════════════════════════════════════════════════ - if is_trending { - // Trend direction confirmation - if trend_up && trend_bullish { - buy_score += 3.0; - // Pullback entry: price dipped but trend intact - if !rsi.is_nan() && rsi < 40.0 && rsi > 25.0 { - buy_score += 3.0; // Pullback in uptrend - } - if ema_distance > 0.0 && ema_distance < 0.02 { - buy_score += 2.0; // Near EMA support - } - if strong_trend { - buy_score += 1.5; // Strong trend bonus - } - } else if !trend_up && !trend_bullish { - sell_score += 3.0; - if !rsi.is_nan() && rsi > 60.0 && rsi < 75.0 { - sell_score += 3.0; // Bounce in downtrend - } - if ema_distance < 0.0 && ema_distance > -0.02 { - sell_score += 2.0; // Near EMA resistance - } - if strong_trend { - sell_score += 1.5; + // Strong momentum bonus (ROC > 10% = strong trend) + if momentum > 10.0 { + buy_score += 1.0; } } } // ═══════════════════════════════════════════════════════════════ - // UNIVERSAL SIGNALS (both regimes) + // SELL LOGIC: Exit when trend breaks or momentum reverses // ═══════════════════════════════════════════════════════════════ - // RSI-14 extremes (strong conviction regardless of regime) - if !rsi.is_nan() { - if rsi < RSI_OVERSOLD && trend_bullish { - buy_score += 3.0; // Oversold in uptrend = strong buy - } else if rsi > RSI_OVERBOUGHT && !trend_bullish { - sell_score += 3.0; // Overbought in downtrend = strong sell - } - } + // CRITICAL SELL: Trend break — price drops below EMA-trend + // This is the single most important exit signal. When the long-term + // trend breaks, the position has no structural support. + if !trend_bullish { + sell_score += 4.0; - // MACD crossover - if macd_crossed_up { - buy_score += 2.0; - if is_trending && trend_up { - buy_score += 1.0; // Trend-confirming crossover + // If also EMA death cross, very strong sell + if !ema_bullish { + sell_score += 2.0; } - } else if macd_crossed_down { - sell_score += 2.0; - if is_trending && !trend_up { + + // Momentum confirming the breakdown + if !momentum.is_nan() && momentum < -5.0 { + sell_score += 2.0; + } else if !momentum.is_nan() && momentum < 0.0 { sell_score += 1.0; } } + // Trend still intact but showing weakness + else { + // EMA death cross while still above trend EMA = early warning + if !ema_bullish && prev_ema_bullish { + sell_score += 3.0; + } - // MACD histogram direction - if !macd_hist.is_nan() { - if macd_hist > 0.0 { buy_score += 0.5; } - else if macd_hist < 0.0 { sell_score += 0.5; } - } + // Momentum has reversed significantly (still above EMA-trend though) + if !momentum.is_nan() && momentum < -10.0 { + sell_score += 3.0; + } else if !momentum.is_nan() && momentum < -5.0 { + sell_score += 1.5; + } - // Momentum - if !momentum.is_nan() { - if momentum > 5.0 { buy_score += 1.5; } - else if momentum > 2.0 { buy_score += 0.5; } - else if momentum < -5.0 { sell_score += 1.5; } - else if momentum < -2.0 { sell_score += 0.5; } - } + // MACD crossed down = momentum decelerating + let macd_crossed_down = !previous.macd.is_nan() + && !previous.macd_signal.is_nan() + && !current.macd.is_nan() + && !current.macd_signal.is_nan() + && previous.macd > previous.macd_signal + && current.macd < current.macd_signal; + if macd_crossed_down { + sell_score += 2.0; + } - // EMA crossover events - let prev_ema_bullish = !previous.ema_short.is_nan() - && !previous.ema_long.is_nan() - && previous.ema_short > previous.ema_long; - - if ema_bullish && !prev_ema_bullish { - buy_score += 2.0; - } else if !ema_bullish && prev_ema_bullish { - sell_score += 2.0; - } - - // Volume gate - if volume_ratio < VOLUME_THRESHOLD { - buy_score *= 0.5; - sell_score *= 0.5; + // RSI extremely overbought (>80) in deteriorating momentum + if !rsi.is_nan() && rsi > 80.0 && !momentum.is_nan() && momentum < 5.0 { + sell_score += 1.5; + } } // ═══════════════════════════════════════════════════════════════ @@ -630,7 +650,9 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato Signal::Hold }; - let confidence = (total_score.abs() / 12.0).min(1.0); + // Confidence now reflects the hierarchical gating: a score of 4.0 from + // the gated system is worth much more than 4.0 from the old additive system. + let confidence = (total_score.abs() / 10.0).min(1.0); TradeSignal { symbol: symbol.to_string(), diff --git a/src/paths.rs b/src/paths.rs index 5e8cff5..743d1b8 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -64,4 +64,12 @@ lazy_static! { path.push("trading_bot.log"); path }; + + /// Base directory for backtest data cache. + pub static ref BACKTEST_CACHE_DIR: PathBuf = { + let mut path = DATA_DIR.clone(); + path.push("cache"); + std::fs::create_dir_all(&path).expect("Failed to create cache directory"); + path + }; } diff --git a/src/strategy.rs b/src/strategy.rs index d0a485a..5d39057 100644 --- a/src/strategy.rs +++ b/src/strategy.rs @@ -26,7 +26,13 @@ impl Strategy { } } - /// Volatility-adjusted position sizing using ATR. + /// Volatility-adjusted position sizing using ATR (Kelly-inspired). + /// + /// Position size = (Risk per trade / ATR stop distance) * confidence. + /// The confidence scaling now has a much wider range (0.4 to 1.0) so that + /// weak Buy signals (confidence ~0.4) get 40% size while StrongBuy signals + /// (confidence ~1.0) get full size. This is a fractional Kelly approach: + /// bet more when conviction is higher, less when marginal. pub fn calculate_position_size( &self, price: f64, @@ -42,8 +48,9 @@ impl Strategy { let atr_stop_pct = signal.atr_pct * ATR_STOP_MULTIPLIER; let risk_amount = portfolio_value * RISK_PER_TRADE; let vol_adjusted = risk_amount / atr_stop_pct; - // Scale by confidence - let confidence_scale = 0.7 + 0.3 * signal.confidence; + // Wide confidence scaling: 0.4x for weak signals, 1.0x for strongest. + // Old code used 0.7 + 0.3*conf which barely differentiated. + let confidence_scale = 0.4 + 0.6 * signal.confidence; let sized = vol_adjusted * confidence_scale; sized.min(portfolio_value * MAX_POSITION_SIZE) } else { @@ -51,12 +58,26 @@ impl Strategy { }; let position_value = position_value.min(available_cash); - // Use fractional shares — Alpaca supports them for paper trading. + // Use fractional shares -- Alpaca supports them for paper trading. // Truncate to 4 decimal places to avoid floating point dust. ((position_value / price) * 10000.0).floor() / 10000.0 } /// Check if stop-loss, trailing stop, or time exit should trigger. + /// + /// Exit priority (checked in order): + /// 1. Hard max-loss cap (MAX_LOSS_PCT) -- absolute worst-case, gap 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. ATR trailing stop (ATR_TRAIL_MULTIPLIER * ATR from HWM) -- profit protection + /// 5. Time-based exit (TIME_EXIT_BARS) -- only if position is LOSING + /// + /// Key design decisions: + /// - Trailing stop activates early (1.5x ATR) but has wide distance (2.5x ATR) + /// so winners have room to breathe but profits are protected. + /// - Time exit ONLY sells losers. Winners at the time limit are doing fine; + /// the trailing stop handles profit-taking on them. + /// - Max loss is wide enough to avoid being hit by normal ATR-level moves. pub fn check_stop_loss_take_profit( &mut self, symbol: &str, @@ -78,34 +99,25 @@ impl Strategy { } } - // Hard max-loss cap + // 1. Hard max-loss cap (catastrophic gap protection) if pnl_pct <= -MAX_LOSS_PCT { return Some(Signal::StrongSell); } - // ATR-based stop loss + // 2. ATR-based initial stop-loss (primary risk control) if entry_atr > 0.0 { let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr; if current_price <= atr_stop_price { return Some(Signal::StrongSell); } } else if pnl_pct <= -STOP_LOSS_PCT { + // 3. Fixed percentage fallback return Some(Signal::StrongSell); } - // Time-based exit - if bars_held >= TIME_EXIT_BARS { - let activation = if entry_atr > 0.0 { - (ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price - } else { - TRAILING_STOP_ACTIVATION - }; - if pnl_pct < activation { - return Some(Signal::Sell); - } - } - - // ATR-based trailing stop + // 4. ATR-based trailing stop (profit protection) + // Activates earlier than before (1.5x ATR gain) so profits are locked in. + // Distance is wider (2.5x ATR from HWM) so normal retracements don't trigger it. let activation_gain = if entry_atr > 0.0 { (ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price } else { @@ -126,6 +138,14 @@ impl Strategy { } } + // 5. Time-based exit: only for LOSING positions (capital efficiency) + // Winners at the time limit are managed by the trailing stop. + // This prevents the old behavior of dumping winners just because they + // haven't hit an arbitrary activation threshold in N bars. + if bars_held >= TIME_EXIT_BARS && pnl_pct < 0.0 { + return Some(Signal::Sell); + } + None } diff --git a/src/types.rs b/src/types.rs index 77cc07c..edd1cb6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -3,6 +3,34 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +/// Broad market regime determined from SPY price action. +/// +/// Based on Faber (2007) dual moving average framework: +/// - Bull: SPY above EMA-200 and EMA-50 above EMA-200 (golden cross territory) +/// - Caution: SPY below EMA-50 but still above EMA-200 (early weakness) +/// - Bear: SPY below EMA-200 and EMA-50 below EMA-200 (death cross territory) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MarketRegime { + Bull, + Caution, + Bear, +} + +impl MarketRegime { + pub fn as_str(&self) -> &'static str { + match self { + MarketRegime::Bull => "BULL", + MarketRegime::Caution => "CAUTION", + MarketRegime::Bear => "BEAR", + } + } + + /// Whether new long entries are permitted in this regime. + pub fn allows_new_longs(&self) -> bool { + !matches!(self, MarketRegime::Bear) + } +} + /// Trading signal types. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -110,7 +138,7 @@ pub struct EquityPoint { } /// OHLCV bar data. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Bar { pub timestamp: DateTime, pub open: f64,