//! Backtesting engine for trading strategies. //! //! Tests strategies against generated scenarios. use crate::MarketSimError; use marketsim_shared::{ AggregateBacktestStats, BacktestRequest, BacktestResult, ScenarioBacktestResult, ScenarioResult, Strategy, StrategyType, }; /// Backtest engine. #[derive(Debug)] pub struct BacktestEngine { /// Risk-free rate for Sharpe calculation. risk_free_rate: f64, } impl Default for BacktestEngine { fn default() -> Self { Self::new() } } impl BacktestEngine { /// Create a new backtest engine. #[must_use] pub fn new() -> Self { Self { risk_free_rate: 0.04, } } /// Run backtest. pub fn run(&self, request: &BacktestRequest) -> Result { let mut scenario_results = Vec::with_capacity(request.scenarios.len()); for scenario in &request.scenarios { let result = self.backtest_scenario(&request.strategy, scenario, request)?; scenario_results.push(result); } // Calculate aggregate statistics let aggregate_stats = self.calculate_aggregate_stats(&scenario_results); Ok(BacktestResult { strategy: request.strategy.clone(), scenario_results, aggregate_stats, }) } /// Backtest a single scenario. fn backtest_scenario( &self, strategy: &Strategy, scenario: &ScenarioResult, request: &BacktestRequest, ) -> Result { if scenario.price_paths.is_empty() { return Err(MarketSimError::BacktestFailed("No price paths".to_string())); } let num_paths = scenario.price_paths[0].paths.len(); let _num_assets = scenario.price_paths.len(); let _horizon = scenario.price_paths[0].paths[0].len() - 1; let mut final_values = Vec::with_capacity(num_paths); let mut total_returns = Vec::with_capacity(num_paths); let mut sharpe_ratios = Vec::with_capacity(num_paths); let mut max_drawdowns = Vec::with_capacity(num_paths); for path_idx in 0..num_paths { // Get prices for this path let prices: Vec> = scenario .price_paths .iter() .map(|ap| ap.paths[path_idx].clone()) .collect(); // Run strategy let (portfolio_values, returns) = self.run_strategy( strategy, &prices, request.initial_capital, request.transaction_cost_bps, ); let final_value = *portfolio_values.last().unwrap(); let total_return = (final_value / request.initial_capital) - 1.0; // Calculate Sharpe ratio let mean_return: f64 = returns.iter().sum::() / returns.len() as f64; let variance: f64 = returns .iter() .map(|r| (r - mean_return).powi(2)) .sum::() / returns.len() as f64; let std_dev = variance.sqrt(); let annualized_return = mean_return * 252.0; let annualized_std = std_dev * (252.0_f64).sqrt(); let sharpe = if annualized_std > 0.0 { (annualized_return - self.risk_free_rate) / annualized_std } else { 0.0 }; // Calculate max drawdown let max_dd = self.calculate_max_drawdown(&portfolio_values); final_values.push(final_value); total_returns.push(total_return); sharpe_ratios.push(sharpe); max_drawdowns.push(max_dd); } let mean_final_value = final_values.iter().sum::() / num_paths as f64; let mean_total_return = total_returns.iter().sum::() / num_paths as f64; let mean_sharpe = sharpe_ratios.iter().sum::() / num_paths as f64; let mean_max_dd = max_drawdowns.iter().sum::() / num_paths as f64; let prob_loss = total_returns.iter().filter(|&&r| r < 0.0).count() as f64 / num_paths as f64; Ok(ScenarioBacktestResult { scenario_name: scenario.scenario.name.clone(), mean_final_value, mean_total_return, mean_sharpe_ratio: mean_sharpe, mean_max_drawdown: mean_max_dd, probability_of_loss: prob_loss, }) } /// Run strategy on price paths. fn run_strategy( &self, strategy: &Strategy, prices: &[Vec], initial_capital: f64, transaction_cost_bps: f64, ) -> (Vec, Vec) { let num_assets = prices.len(); let horizon = prices[0].len(); // Initialize equal weights let mut weights: Vec = vec![1.0 / num_assets as f64; num_assets]; let mut portfolio_value: f64 = initial_capital; let mut portfolio_values = Vec::with_capacity(horizon); let mut returns = Vec::with_capacity(horizon - 1); portfolio_values.push(portfolio_value); for t in 1..horizon { // Calculate asset returns let asset_returns: Vec = (0..num_assets) .map(|i| (prices[i][t] / prices[i][t - 1]) - 1.0) .collect(); // Portfolio return let portfolio_return: f64 = weights .iter() .zip(asset_returns.iter()) .map(|(w, r)| w * r) .sum(); // Update portfolio value portfolio_value *= 1.0 + portfolio_return; // Rebalance if needed if t % strategy.parameters.rebalance_frequency == 0 { let new_weights = self.calculate_weights(strategy, prices, t); // Transaction costs let turnover: f64 = weights .iter() .zip(new_weights.iter()) .map(|(old, new)| (new - old).abs()) .sum::() / 2.0; let cost = turnover * transaction_cost_bps / 10000.0; portfolio_value *= 1.0 - cost; weights = new_weights; } returns.push(portfolio_return); portfolio_values.push(portfolio_value); } (portfolio_values, returns) } /// Calculate strategy weights. fn calculate_weights(&self, strategy: &Strategy, prices: &[Vec], t: usize) -> Vec { let num_assets = prices.len(); let lookback = strategy.parameters.lookback.min(t); match strategy.strategy_type { StrategyType::BuyAndHold => { // Equal weight vec![1.0 / num_assets as f64; num_assets] } StrategyType::Momentum => { // Weight by past returns let mut momentum: Vec = (0..num_assets) .map(|i| { if t > lookback && prices[i][t - lookback] > 0.0 { (prices[i][t] / prices[i][t - lookback]) - 1.0 } else { 0.0 } }) .collect(); // Normalize to positive weights let min_mom = momentum.iter().copied().fold(f64::INFINITY, f64::min); for m in &mut momentum { *m -= min_mom - 0.001; } let sum: f64 = momentum.iter().sum(); if sum > 0.0 { momentum.iter().map(|m| m / sum).collect() } else { vec![1.0 / num_assets as f64; num_assets] } } StrategyType::MeanReversion => { // Weight inversely by past returns let mut z_scores: Vec = (0..num_assets) .map(|i| { if t > lookback { let returns: Vec = (t - lookback..t) .map(|j| (prices[i][j + 1] / prices[i][j]) - 1.0) .collect(); let mean: f64 = returns.iter().sum::() / returns.len() as f64; -mean // Negative = mean reversion } else { 0.0 } }) .collect(); // Normalize let min_z = z_scores.iter().copied().fold(f64::INFINITY, f64::min); for z in &mut z_scores { *z -= min_z - 0.001; } let sum: f64 = z_scores.iter().sum(); if sum > 0.0 { z_scores.iter().map(|z| z / sum).collect() } else { vec![1.0 / num_assets as f64; num_assets] } } StrategyType::RiskParity => { // Weight inversely by volatility let inv_vol: Vec = (0..num_assets) .map(|i| { if t > lookback { let returns: Vec = (t - lookback..t) .map(|j| (prices[i][j + 1] / prices[i][j]) - 1.0) .collect(); let mean: f64 = returns.iter().sum::() / returns.len() as f64; let variance: f64 = returns.iter().map(|r| (r - mean).powi(2)).sum::() / returns.len() as f64; let vol = variance.sqrt(); if vol > 0.001 { 1.0 / vol } else { 1.0 } } else { 1.0 } }) .collect(); let sum: f64 = inv_vol.iter().sum(); inv_vol.iter().map(|v| v / sum).collect() } StrategyType::TrendFollowing => { // Binary: 100% if above MA, 0% if below let short_lookback = lookback / 2; let weights: Vec = (0..num_assets) .map(|i| { if t > lookback { let long_ma: f64 = (t - lookback..t).map(|j| prices[i][j]).sum::() / lookback as f64; let short_ma: f64 = if t > short_lookback { (t - short_lookback..t).map(|j| prices[i][j]).sum::() / short_lookback as f64 } else { prices[i][t] }; if short_ma > long_ma { 1.0 } else { 0.1 // Minimum weight } } else { 1.0 } }) .collect(); let sum: f64 = weights.iter().sum(); weights.iter().map(|w| w / sum).collect() } StrategyType::Custom => { // Default to equal weight vec![1.0 / num_assets as f64; num_assets] } } } /// Calculate max drawdown. fn calculate_max_drawdown(&self, values: &[f64]) -> f64 { let mut peak: f64 = values[0]; let mut max_dd: f64 = 0.0; for &value in values.iter().skip(1) { peak = peak.max(value); let dd = (peak - value) / peak; max_dd = max_dd.max(dd); } max_dd } /// Calculate aggregate statistics. fn calculate_aggregate_stats( &self, results: &[ScenarioBacktestResult], ) -> AggregateBacktestStats { let n = results.len() as f64; let overall_mean_return = results.iter().map(|r| r.mean_total_return).sum::() / n; let overall_sharpe = results.iter().map(|r| r.mean_sharpe_ratio).sum::() / n; let worst_case = results .iter() .map(|r| r.mean_total_return) .fold(f64::INFINITY, f64::min); let best_case = results .iter() .map(|r| r.mean_total_return) .fold(f64::NEG_INFINITY, f64::max); let win_rate = results.iter().filter(|r| r.mean_total_return > 0.0).count() as f64 / n; AggregateBacktestStats { overall_mean_return, overall_sharpe_ratio: overall_sharpe, worst_case_return: worst_case, best_case_return: best_case, win_rate, } } } #[cfg(test)] mod tests { use super::*; use marketsim_shared::{ AssetPricePaths, PathStatistics, ScenarioDescription, ScenarioType, SimulationMetadata, Strategy, StrategyParameters, }; fn create_test_scenario() -> ScenarioResult { // Create simple price paths: 10 paths, 20 steps let paths: Vec> = (0..10) .map(|i| { let mut path = vec![100.0]; for t in 1..21 { // Simple random walk let change = 1.0 + 0.01 * (i as f64 - 5.0) * 0.1; path.push(path.last().unwrap() * change); } path }) .collect(); let return_paths: Vec> = paths .iter() .map(|p| p.windows(2).map(|w| (w[1] / w[0]).ln()).collect()) .collect(); ScenarioResult { scenario: ScenarioDescription { name: "Test".to_string(), scenario_type: ScenarioType::Crisis, description: "Test".to_string(), severity: 0.5, duration_days: 20, }, price_paths: vec![AssetPricePaths { symbol: "SPY".to_string(), paths, return_paths, }], statistics: PathStatistics { asset_stats: vec![], final_correlation: vec![], }, metadata: SimulationMetadata { computation_time_ms: 1, model_version: "1.0".to_string(), seed_used: 42, }, } } #[test] fn test_backtest_engine_creation() { let engine = BacktestEngine::new(); assert!(engine.risk_free_rate > 0.0); } #[test] fn test_run_backtest() { let engine = BacktestEngine::new(); let scenario = create_test_scenario(); let request = BacktestRequest { strategy: Strategy { name: "Momentum".to_string(), strategy_type: StrategyType::Momentum, parameters: StrategyParameters::default(), }, scenarios: vec![scenario], initial_capital: 100000.0, transaction_cost_bps: 10.0, }; let result = engine.run(&request); assert!(result.is_ok()); let backtest = result.unwrap(); assert_eq!(backtest.scenario_results.len(), 1); } #[test] fn test_strategy_types() { let engine = BacktestEngine::new(); let scenario = create_test_scenario(); for strategy_type in [ StrategyType::BuyAndHold, StrategyType::Momentum, StrategyType::MeanReversion, StrategyType::RiskParity, ] { let request = BacktestRequest { strategy: Strategy { name: "Test".to_string(), strategy_type, parameters: StrategyParameters::default(), }, scenarios: vec![scenario.clone()], initial_capital: 100000.0, transaction_cost_bps: 10.0, }; let result = engine.run(&request); assert!(result.is_ok()); } } #[test] fn test_max_drawdown() { let engine = BacktestEngine::new(); let values = vec![100.0, 110.0, 105.0, 95.0, 100.0]; let dd = engine.calculate_max_drawdown(&values); // Max drawdown from 110 to 95 = (110-95)/110 ≈ 0.136 assert!((dd - 0.136).abs() < 0.01); } }