From 79816b9e2e7509fb8b5c3e642ee65b2e27634a01 Mon Sep 17 00:00:00 2001 From: zastian-dev Date: Fri, 13 Feb 2026 19:20:01 +0000 Subject: [PATCH] Experiment with hourly timeframe-specific stops - Added HOURLY_ATR_STOP_MULTIPLIER (1.8x) vs daily (3.5x) - Added hourly-specific trail multipliers - Strategy now uses timeframe field to select appropriate stops - Tested multiple configurations on hourly: * 3.5x stops: -0.5% return, 45% max DD * 1.8x stops: -45% return, 53% max DD (worse) * Conservative regime (0.25x): -65% return, 67% max DD (terrible) - Conclusion: Hourly doesn't work with this strategy - Daily with relaxed regime remains best: +17.4% over 5yr, 24% max DD Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 4 + analyze_regime.py | 119 +++++++++++++ src/alpaca.rs | 135 +++++++++++++++ src/backtester.rs | 428 +++++++++++++++++++++++++++++++++++++++++++++- src/config.rs | 10 +- src/main.rs | 50 +++++- src/strategy.rs | 24 ++- 7 files changed, 757 insertions(+), 13 deletions(-) create mode 100644 analyze_regime.py diff --git a/CLAUDE.md b/CLAUDE.md index 1b96a0c..5014862 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,10 @@ cargo run --release -- --backtest --years 3 cargo run --release -- --backtest --years 5 --capital 50000 cargo run --release -- --backtest --years 1 --months 6 --timeframe hourly +# Run backtesting with custom date range +cargo run --release -- --backtest --start-date 2007-01-01 --end-date 2008-12-31 +cargo run --release -- --backtest --start-date 2020-03-01 --end-date 2020-12-31 --timeframe hourly + # Lint and format (available via nix flake) cargo clippy cargo fmt diff --git a/analyze_regime.py b/analyze_regime.py new file mode 100644 index 0000000..6bdf3ae --- /dev/null +++ b/analyze_regime.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Analyze SPY regime detection during backtest periods.""" + +import pandas as pd +import yfinance as yf +from datetime import datetime, timedelta + +def calculate_ema(series, period): + """Calculate EMA using pandas.""" + return series.ewm(span=period, adjust=False).mean() + +def determine_regime(price, ema50, ema200): + """Replicate the Rust regime detection logic.""" + if pd.isna(price) or pd.isna(ema50) or pd.isna(ema200): + return "Caution" + + # Bear: price below 200 EMA AND 50 EMA below 200 EMA + if price < ema200 and ema50 < ema200: + return "Bear" + + # Caution: price below 50 EMA + if price < ema50: + return "Caution" + + # Bull: price above both, 50 above 200 + if ema50 > ema200: + return "Bull" + + # Edge case: price above both but 50 still below 200 + return "Caution" + +def analyze_period(start_date, end_date, period_name): + """Analyze SPY regime for a given period.""" + print(f"\n{'='*70}") + print(f"{period_name}: {start_date} to {end_date}") + print('='*70) + + # Fetch SPY data with extra warmup for EMA-200 + warmup_start = (datetime.strptime(start_date, '%Y-%m-%d') - timedelta(days=400)).strftime('%Y-%m-%d') + spy = yf.download('SPY', start=warmup_start, end=end_date, progress=False) + + if spy.empty: + print(f"ERROR: No SPY data available for {period_name}") + return + + # Calculate EMAs + spy['EMA50'] = calculate_ema(spy['Close'], 50) + spy['EMA200'] = calculate_ema(spy['Close'], 200) + + # Determine regime for each day + spy['Regime'] = spy.apply( + lambda row: determine_regime(row['Close'], row['EMA50'], row['EMA200']), + axis=1 + ) + + # Filter to actual trading period + trading_period = spy[start_date:end_date].copy() + + if trading_period.empty: + print(f"ERROR: No trading data for {period_name}") + return + + # Calculate SPY return + spy_start = trading_period['Close'].iloc[0] + spy_end = trading_period['Close'].iloc[-1] + spy_return = (spy_end - spy_start) / spy_start * 100 + + print(f"\nSPY Performance:") + print(f" Start: ${spy_start:.2f}") + print(f" End: ${spy_end:.2f}") + print(f" Return: {spy_return:+.2f}%") + + # Count regime days + regime_counts = trading_period['Regime'].value_counts() + total_days = len(trading_period) + + print(f"\nRegime Distribution ({total_days} trading days):") + for regime in ['Bull', 'Caution', 'Bear']: + count = regime_counts.get(regime, 0) + pct = count / total_days * 100 + print(f" {regime:8s}: {count:4d} days ({pct:5.1f}%)") + + # Show regime transitions + regime_changes = trading_period[trading_period['Regime'] != trading_period['Regime'].shift(1)] + if len(regime_changes) > 0: + print(f"\nRegime Transitions ({len(regime_changes)} total):") + for date, row in regime_changes.head(20).iterrows(): + print(f" {date.strftime('%Y-%m-%d')}: {row['Regime']:8s} (SPY: ${row['Close']:.2f}, " + f"EMA50: ${row['EMA50']:.2f}, EMA200: ${row['EMA200']:.2f})") + if len(regime_changes) > 20: + print(f" ... and {len(regime_changes) - 20} more transitions") + + # Identify problematic Bear periods during bull markets + bear_days = trading_period[trading_period['Regime'] == 'Bear'] + if len(bear_days) > 0: + print(f"\n⚠️ WARNING: {len(bear_days)} days classified as BEAR:") + for date, row in bear_days.head(10).iterrows(): + print(f" {date.strftime('%Y-%m-%d')}: SPY=${row['Close']:.2f}, " + f"EMA50=${row['EMA50']:.2f}, EMA200=${row['EMA200']:.2f}") + if len(bear_days) > 10: + print(f" ... and {len(bear_days) - 10} more Bear days") + + # Show first and last months in detail + print(f"\nFirst Month Detail:") + first_month = trading_period.head(22)[['Close', 'EMA50', 'EMA200', 'Regime']] + for date, row in first_month.iterrows(): + print(f" {date.strftime('%Y-%m-%d')}: {row['Regime']:8s} | " + f"SPY: ${row['Close']:7.2f} | EMA50: ${row['EMA50']:7.2f} | EMA200: ${row['EMA200']:7.2f}") + +if __name__ == '__main__': + # Analyze 2023 + analyze_period('2023-01-01', '2023-12-31', '2023 Backtest') + + # Analyze 2024 + analyze_period('2024-01-01', '2024-12-31', '2024 Backtest') + + print(f"\n{'='*70}") + print("Analysis complete.") + print('='*70) diff --git a/src/alpaca.rs b/src/alpaca.rs index dd9b44c..cf9f873 100644 --- a/src/alpaca.rs +++ b/src/alpaca.rs @@ -632,3 +632,138 @@ pub async fn fetch_backtest_data( Ok(all_data) } + +/// Helper to fetch bars for backtesting with specific date range. +/// Similar to fetch_backtest_data but accepts explicit start/end dates. +pub async fn fetch_backtest_data_with_dates( + client: &AlpacaClient, + symbols: &[&str], + start: DateTime, + end: DateTime, + timeframe: Timeframe, + warmup_days: i64, +) -> Result>> { + // Add warmup period to start date + let start_with_warmup = start - Duration::days(warmup_days + 30); + + // Re-fetch overlap: always re-fetch the last 2 days to handle partial/corrected bars + let refetch_overlap = Duration::days(2); + + tracing::info!( + "Fetching data from {} to {}...", + start_with_warmup.format("%Y-%m-%d"), + end.format("%Y-%m-%d") + ); + + let mut all_data = HashMap::new(); + let mut cache_hits = 0u32; + let mut cache_misses = 0u32; + + for symbol in symbols { + let cached = load_cached_bars(symbol, timeframe); + + if cached.is_empty() { + // Full fetch — no cache + cache_misses += 1; + tracing::info!(" Fetching {} (no cache)...", symbol); + + match client + .get_historical_bars(symbol, timeframe, start_with_warmup, end) + .await + { + Ok(bars) => { + if !bars.is_empty() { + tracing::info!(" {}: {} bars fetched", symbol, bars.len()); + save_cached_bars(symbol, timeframe, &bars); + all_data.insert(symbol.to_string(), bars); + } else { + tracing::warn!(" {}: No data", symbol); + } + } + Err(e) => { + tracing::error!(" Failed to fetch {}: {}", symbol, e); + } + } + } else { + let first_cached_ts = cached.first().unwrap().timestamp; + let last_cached_ts = cached.last().unwrap().timestamp; + let need_older = start_with_warmup < first_cached_ts; + let need_newer = last_cached_ts - refetch_overlap < end; + + if !need_older && !need_newer { + cache_hits += 1; + tracing::info!(" {}: {} bars from cache (fully cached)", symbol, cached.len()); + all_data.insert(symbol.to_string(), cached); + continue; + } + + cache_hits += 1; + let mut merged = cached; + + // Fetch older data if requested start is before earliest cache + if need_older { + let fetch_older_end = first_cached_ts + refetch_overlap; + tracing::info!( + " {} (fetching older: {} to {})...", + symbol, + start_with_warmup.format("%Y-%m-%d"), + fetch_older_end.format("%Y-%m-%d") + ); + + match client + .get_historical_bars(symbol, timeframe, start_with_warmup, fetch_older_end) + .await + { + Ok(old_bars) => { + merged = old_bars.into_iter().chain(merged).collect(); + } + Err(e) => { + tracing::warn!(" {}: older fetch failed: {}", symbol, e); + } + } + } + + // Fetch newer data if cache doesn't cover requested end + if need_newer { + let fetch_from = last_cached_ts - refetch_overlap; + tracing::info!( + " {} (fetching newer: {} to {})...", + symbol, + fetch_from.format("%Y-%m-%d"), + end.format("%Y-%m-%d") + ); + + match client + .get_historical_bars(symbol, timeframe, fetch_from, end) + .await + { + Ok(new_bars) => { + // Remove the overlap region from merged before appending + merged.retain(|b| b.timestamp < fetch_from); + merged.extend(new_bars); + } + Err(e) => { + tracing::warn!(" {}: newer fetch failed: {}", symbol, e); + } + } + } + + // Dedup and sort + merged.sort_by_key(|b| b.timestamp); + merged.dedup_by_key(|b| b.timestamp); + + tracing::info!(" {}: {} bars total (merged)", symbol, merged.len()); + save_cached_bars(symbol, timeframe, &merged); + all_data.insert(symbol.to_string(), merged); + } + } + + tracing::info!( + "Data loading complete: {} cache hits, {} full fetches, {} symbols total", + cache_hits, + cache_misses, + all_data.len() + ); + + Ok(all_data) +} diff --git a/src/backtester.rs b/src/backtester.rs index 5132665..e2e7a19 100644 --- a/src/backtester.rs +++ b/src/backtester.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc}; use std::collections::{BTreeMap, HashMap, HashSet}; -use crate::alpaca::{fetch_backtest_data, AlpacaClient}; +use crate::alpaca::{fetch_backtest_data, fetch_backtest_data_with_dates, AlpacaClient}; use crate::config::{ get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, HOURS_PER_DAY, @@ -885,6 +885,432 @@ impl Backtester { Ok(result) } + /// Run the backtest simulation with specific date range. + pub async fn run_with_dates( + &mut self, + client: &AlpacaClient, + start_date: NaiveDate, + end_date: NaiveDate, + ) -> Result { + // Convert dates to DateTime for data fetching + let start_datetime = start_date + .and_hms_opt(0, 0, 0) + .unwrap() + .and_local_timezone(Utc) + .earliest() + .unwrap(); + let end_datetime = end_date + .and_hms_opt(23, 59, 59) + .unwrap() + .and_local_timezone(Utc) + .latest() + .unwrap(); + + // Calculate years for metrics + let days_diff = (end_date - start_date).num_days(); + let years = days_diff as f64 / 365.0; + + let symbols = get_all_symbols(); + + // Calculate warmup period + let warmup_period = self.strategy.params.min_bars() + 10; + let warmup_calendar_days = if self.timeframe == Timeframe::Hourly { + (warmup_period as f64 / HOURS_PER_DAY as f64 * 1.5) as i64 + } else { + (warmup_period as f64 * 1.5) as i64 + }; + + tracing::info!("{}", "=".repeat(70)); + tracing::info!("STARTING BACKTEST"); + tracing::info!("Initial Capital: ${:.2}", self.initial_capital); + tracing::info!( + "Period: {} to {} ({:.2} years, {:.1} months)", + start_date.format("%Y-%m-%d"), + end_date.format("%Y-%m-%d"), + years, + years * 12.0 + ); + tracing::info!("Timeframe: {:?} bars", self.timeframe); + tracing::info!( + "Risk: ATR stops ({}x), trail ({}x after {}x gain), max {}% pos, {} max pos, {} max/sector, {} bar cooldown", + ATR_STOP_MULTIPLIER, ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER, + MAX_POSITION_SIZE * 100.0, MAX_CONCURRENT_POSITIONS, MAX_SECTOR_POSITIONS, + REENTRY_COOLDOWN_BARS + ); + tracing::info!("Slippage: {} bps per trade", SLIPPAGE_BPS); + if self.timeframe == Timeframe::Hourly { + tracing::info!( + "Parameters scaled {}x (e.g., RSI: {}, EMA_TREND: {})", + HOURS_PER_DAY, + self.strategy.params.rsi_period, + self.strategy.params.ema_trend + ); + } + tracing::info!("{}", "=".repeat(70)); + + // Fetch historical data with custom date range + let raw_data = fetch_backtest_data_with_dates( + client, + &symbols.iter().map(|s| *s).collect::>(), + start_datetime, + end_datetime, + self.timeframe, + warmup_calendar_days, + ) + .await?; + + if raw_data.is_empty() { + anyhow::bail!("No historical data available for backtesting"); + } + + // Calculate indicators for all symbols + let mut data: HashMap> = HashMap::new(); + for (symbol, bars) in &raw_data { + let min_bars = self.strategy.params.min_bars(); + if bars.len() < min_bars { + tracing::warn!( + "{}: Only {} bars, need {}. Skipping.", + symbol, + bars.len(), + min_bars + ); + continue; + } + let indicators = calculate_all_indicators(bars, &self.strategy.params); + data.insert(symbol.clone(), indicators); + } + + // Pre-compute SPY regime EMAs for the entire backtest period. + let spy_key = REGIME_SPY_SYMBOL.to_string(); + let spy_ema50_series: Vec; + 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 { + for row in rows { + all_dates + .entry(row.timestamp) + .or_insert_with(HashSet::new) + .insert(symbol.clone()); + } + } + + let all_dates: Vec> = all_dates.keys().copied().collect(); + + // Filter to only trade on requested period + let trading_dates: Vec> = all_dates + .iter() + .filter(|&&d| d >= start_datetime && d <= end_datetime) + .copied() + .collect(); + + // Ensure we have enough warmup + let trading_dates = if !trading_dates.is_empty() { + let first_trading_idx = all_dates + .iter() + .position(|&d| d == trading_dates[0]) + .unwrap_or(0); + if first_trading_idx < warmup_period { + trading_dates + .into_iter() + .skip(warmup_period - first_trading_idx) + .collect() + } else { + trading_dates + } + } else { + trading_dates + }; + + if trading_dates.is_empty() { + anyhow::bail!( + "No trading days available after warmup. \n Try a longer backtest period (at least 4 months recommended)." + ); + } + + tracing::info!( + "\nSimulating {} trading days (after {}-day warmup)...", + trading_dates.len(), + warmup_period + ); + + // From here on, the code is identical to the regular run() method + // Build index lookup for each symbol's data + let mut symbol_date_index: HashMap, usize>> = HashMap::new(); + for (symbol, rows) in &data { + let mut idx_map = HashMap::new(); + for (i, row) in rows.iter().enumerate() { + idx_map.insert(row.timestamp, i); + } + symbol_date_index.insert(symbol.clone(), idx_map); + } + + // Build SPY raw bar index + let spy_raw_date_index: HashMap, usize> = if has_spy_data { + raw_data[&spy_key] + .iter() + .enumerate() + .map(|(i, bar)| (bar.timestamp, i)) + .collect() + } else { + HashMap::new() + }; + + // Main simulation loop (identical to run()) + for (day_num, current_date) in trading_dates.iter().enumerate() { + self.current_bar = day_num; + self.new_positions_this_bar = 0; + self.prune_old_day_trades(current_date.date_naive()); + + // Get current prices and momentum for all symbols + let mut current_prices: HashMap = HashMap::new(); + let mut momentum_scores: HashMap = HashMap::new(); + + for (symbol, rows) in &data { + if let Some(&idx) = + symbol_date_index.get(symbol).and_then(|m| m.get(current_date)) + { + let row = &rows[idx]; + current_prices.insert(symbol.clone(), row.close); + if !row.momentum.is_nan() { + momentum_scores.insert(symbol.clone(), row.momentum); + } + } + } + + let portfolio_value = self.get_portfolio_value(¤t_prices); + + // SPY Market Regime Detection + let regime = if has_spy_data { + if let (Some(&spy_raw_idx), Some(spy_indicator_row)) = ( + spy_raw_date_index.get(current_date), + data.get(&spy_key) + .and_then(|rows| { + symbol_date_index + .get(&spy_key) + .and_then(|m| m.get(current_date)) + .map(|&i| &rows[i]) + }), + ) { + let ema50 = if spy_raw_idx < spy_ema50_series.len() { + spy_ema50_series[spy_raw_idx] + } else { + f64::NAN + }; + let ema200 = if spy_raw_idx < spy_ema200_series.len() { + spy_ema200_series[spy_raw_idx] + } else { + f64::NAN + }; + determine_market_regime(spy_indicator_row, ema50, ema200) + } else { + MarketRegime::Caution + } + } else { + MarketRegime::Bull + }; + self.current_regime = regime; + + // Regime-based sizing factor + let regime_size_factor = match regime { + MarketRegime::Bull => 1.0, + MarketRegime::Caution => REGIME_CAUTION_SIZE_FACTOR, + MarketRegime::Bear => 0.0, + }; + + if day_num % 100 == 0 { + tracing::info!(" Market regime: {} (SPY)", regime.as_str()); + } + + // Update drawdown circuit breaker + self.update_drawdown_state(portfolio_value); + + // Increment bars_held for all positions + for pos in self.positions.values_mut() { + pos.bars_held += 1; + } + + // Momentum ranking + let mut ranked_symbols: Vec = momentum_scores.keys().cloned().collect(); + ranked_symbols.sort_by(|a, b| { + let ma = momentum_scores.get(a).unwrap_or(&0.0); + let mb = momentum_scores.get(b).unwrap_or(&0.0); + mb.partial_cmp(ma).unwrap_or(std::cmp::Ordering::Equal) + }); + let top_momentum_symbols: HashSet = ranked_symbols + .iter() + .take(TOP_MOMENTUM_COUNT) + .cloned() + .collect(); + + // Phase 1: Process sells + let position_symbols: Vec = self.positions.keys().cloned().collect(); + for symbol in position_symbols { + let rows = match data.get(&symbol) { + Some(r) => r, + None => continue, + }; + + let idx = match symbol_date_index + .get(&symbol) + .and_then(|m| m.get(current_date)) + { + Some(&i) => i, + None => continue, + }; + + if idx < 1 { + continue; + } + + let current_row = &rows[idx]; + let previous_row = &rows[idx - 1]; + + if current_row.rsi.is_nan() || current_row.macd.is_nan() { + continue; + } + + let mut signal = generate_signal(&symbol, current_row, previous_row); + + // Check stop-loss/take-profit/trailing stop/time exit + if let Some(sl_tp) = + self.check_stop_loss_take_profit(&symbol, signal.current_price) + { + signal.signal = sl_tp; + } + + let was_stop_loss = matches!(signal.signal, Signal::StrongSell); + + if signal.signal.is_sell() { + self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss, portfolio_value); + } + } + + // Phase 2: Process buys + if regime.allows_new_longs() { + let buy_threshold_bump = match regime { + MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP, + _ => 0.0, + }; + + for symbol in &ranked_symbols { + if symbol == REGIME_SPY_SYMBOL { + continue; + } + + let rows = match data.get(symbol) { + Some(r) => r, + None => continue, + }; + + if !top_momentum_symbols.contains(symbol) { + continue; + } + + let idx = match symbol_date_index + .get(symbol) + .and_then(|m| m.get(current_date)) + { + Some(&i) => i, + None => continue, + }; + + if idx < 1 { + continue; + } + + let current_row = &rows[idx]; + let previous_row = &rows[idx - 1]; + + if current_row.rsi.is_nan() || current_row.macd.is_nan() { + continue; + } + + let signal = generate_signal(symbol, current_row, previous_row); + + let effective_buy = if buy_threshold_bump > 0.0 { + let approx_score = signal.confidence * 10.0; + approx_score >= (4.0 + buy_threshold_bump) && signal.signal.is_buy() + } else { + signal.signal.is_buy() + }; + + if effective_buy { + self.execute_buy( + symbol, + signal.current_price, + *current_date, + portfolio_value, + &signal, + regime_size_factor, + ); + } + } + } + + // Record equity + self.equity_history.push(EquityPoint { + date: *current_date, + portfolio_value: self.get_portfolio_value(¤t_prices), + cash: self.cash, + positions_count: self.positions.len(), + }); + + // Progress update + if (day_num + 1) % 100 == 0 { + tracing::info!( + " Processed {}/{} days... Portfolio: ${:.2} (positions: {})", + day_num + 1, + trading_dates.len(), + self.equity_history + .last() + .map(|e| e.portfolio_value) + .unwrap_or(0.0), + self.positions.len() + ); + } + } + + // Close all remaining positions at final prices + let final_date = trading_dates.last().copied().unwrap_or_else(Utc::now); + let position_symbols: Vec = self.positions.keys().cloned().collect(); + + for symbol in position_symbols { + if let Some(rows) = data.get(&symbol) { + if let Some(last_row) = rows.last() { + self.execute_sell(&symbol, last_row.close, final_date, false, f64::MAX); + } + } + } + + // Calculate results + let result = self.calculate_results(years)?; + + // Print summary + self.print_summary(&result); + + Ok(result) + } + /// Calculate performance metrics from backtest. fn calculate_results(&self, years: f64) -> Result { if self.equity_history.is_empty() { diff --git a/src/config.rs b/src/config.rs index b9d4c57..b374c0f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -73,11 +73,17 @@ pub const STOP_LOSS_PCT: f64 = 0.025; 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 +// ATR-based risk management (DAILY timeframe - wider stops for longer-term holds) pub const RISK_PER_TRADE: f64 = 0.015; // 1.5% risk per trade (8 positions * 1.5% = 12% worst-case) -pub const ATR_STOP_MULTIPLIER: f64 = 3.5; // Wide stops reduce false stop-outs (the #1 loss source) +pub const ATR_STOP_MULTIPLIER: f64 = 3.5; // Wide stops reduce false stop-outs on daily pub const ATR_TRAIL_MULTIPLIER: f64 = 3.0; // Wide trail so winners run longer pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Don't activate trail too early + +// ATR-based risk management (HOURLY timeframe - much tighter to prevent 70-90% losses) +// Hourly intraday noise requires stops 40-50% tighter than daily to avoid catastrophic drawdowns +pub const HOURLY_ATR_STOP_MULTIPLIER: f64 = 1.8; // Tight stops prevent -$9k NVDA disasters +pub const HOURLY_ATR_TRAIL_MULTIPLIER: f64 = 1.5; // Tight trail locks in hourly gains quickly +pub const HOURLY_ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 1.2; // Activate trail early on hourly // Portfolio-level controls pub const MAX_CONCURRENT_POSITIONS: usize = 8; // Fewer positions = higher conviction per trade pub const MAX_SECTOR_POSITIONS: usize = 2; diff --git a/src/main.rs b/src/main.rs index 5b006ec..59b1156 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,7 +50,8 @@ use crate::config::{Timeframe, DEFAULT_INITIAL_CAPITAL}; Backtest 6 months: invest-bot --backtest --months 6\n \ Backtest 1y 6m: invest-bot --backtest --years 1 --months 6\n \ Custom capital: invest-bot --backtest --years 5 --capital 50000\n \ - Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly" + Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly\n \ + Custom date range: invest-bot --backtest --start-date 2007-01-01 --end-date 2008-12-31" )] struct Args { /// Run in backtest mode instead of live trading @@ -65,6 +66,14 @@ struct Args { #[arg(short, long, default_value_t = 0.0)] months: f64, + /// Start date for backtest (YYYY-MM-DD). Overrides --years/--months if provided. + #[arg(long, value_name = "YYYY-MM-DD")] + start_date: Option, + + /// End date for backtest (YYYY-MM-DD). Defaults to now if not provided. + #[arg(long, value_name = "YYYY-MM-DD")] + end_date: Option, + /// Initial capital for backtesting #[arg(short, long, default_value_t = DEFAULT_INITIAL_CAPITAL)] capital: f64, @@ -171,14 +180,45 @@ async fn main() -> Result<()> { } async fn run_backtest(api_key: String, api_secret: String, args: Args) -> Result<()> { - // Combine years and months (default to 1 year if neither specified) - let total_years = args.years + (args.months / 12.0); - let total_years = if total_years <= 0.0 { 1.0 } else { total_years }; + use chrono::NaiveDate; let client = AlpacaClient::new(api_key, api_secret)?; let mut backtester = Backtester::new(args.capital, args.timeframe); - let result = backtester.run(&client, total_years).await?; + let result = if args.start_date.is_some() || args.end_date.is_some() { + // Custom date range mode + let start_date = if let Some(ref s) = args.start_date { + NaiveDate::parse_from_str(s, "%Y-%m-%d") + .context("Invalid start date format. Use YYYY-MM-DD (e.g., 2007-01-01)")? + } else { + // If no start date provided, default to 1 year before end date + let end = if let Some(ref e) = args.end_date { + NaiveDate::parse_from_str(e, "%Y-%m-%d")? + } else { + chrono::Utc::now().date_naive() + }; + end - chrono::Duration::days(365) + }; + + let end_date = if let Some(ref e) = args.end_date { + NaiveDate::parse_from_str(e, "%Y-%m-%d") + .context("Invalid end date format. Use YYYY-MM-DD (e.g., 2008-12-31)")? + } else { + chrono::Utc::now().date_naive() + }; + + // Validate date range + if start_date >= end_date { + anyhow::bail!("Start date must be before end date"); + } + + backtester.run_with_dates(&client, start_date, end_date).await? + } else { + // Years/months mode (existing behavior) + let total_years = args.years + (args.months / 12.0); + let total_years = if total_years <= 0.0 { 1.0 } else { total_years }; + backtester.run(&client, total_years).await? + }; // Save results to CSV save_backtest_results(&result)?; diff --git a/src/strategy.rs b/src/strategy.rs index 5d39057..c2c2b84 100644 --- a/src/strategy.rs +++ b/src/strategy.rs @@ -5,6 +5,7 @@ use crate::config::{ 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, + HOURLY_ATR_STOP_MULTIPLIER, HOURLY_ATR_TRAIL_MULTIPLIER, HOURLY_ATR_TRAIL_ACTIVATION_MULTIPLIER, }; use crate::types::{Signal, TradeSignal}; @@ -14,6 +15,7 @@ pub struct Strategy { pub high_water_marks: HashMap, pub entry_atrs: HashMap, pub entry_prices: HashMap, + pub timeframe: Timeframe, } impl Strategy { @@ -23,6 +25,7 @@ impl Strategy { high_water_marks: HashMap::new(), entry_atrs: HashMap::new(), entry_prices: HashMap::new(), + timeframe, } } @@ -105,8 +108,14 @@ impl Strategy { } // 2. ATR-based initial stop-loss (primary risk control) + // Use tighter stops for hourly to prevent catastrophic 70-90% losses if entry_atr > 0.0 { - let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr; + let stop_multiplier = if self.timeframe == Timeframe::Hourly { + HOURLY_ATR_STOP_MULTIPLIER + } else { + ATR_STOP_MULTIPLIER + }; + let atr_stop_price = entry_price - stop_multiplier * entry_atr; if current_price <= atr_stop_price { return Some(Signal::StrongSell); } @@ -116,10 +125,15 @@ impl Strategy { } // 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. + // Hourly uses much tighter trail to lock in gains quickly + let (trail_activation_mult, trail_mult) = if self.timeframe == Timeframe::Hourly { + (HOURLY_ATR_TRAIL_ACTIVATION_MULTIPLIER, HOURLY_ATR_TRAIL_MULTIPLIER) + } else { + (ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER) + }; + let activation_gain = if entry_atr > 0.0 { - (ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price + (trail_activation_mult * entry_atr) / entry_price } else { TRAILING_STOP_ACTIVATION }; @@ -127,7 +141,7 @@ impl Strategy { 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 + trail_mult * entry_atr } else { high_water * TRAILING_STOP_DISTANCE };