//! Ensemble Backtesting Tool - Compare Individual Models vs Ensemble Strategies //! //! This tool tests 3 ensemble weighting strategies against individual models: //! 1. Equal-Weight Ensemble (1/N for each model) //! 2. Performance-Weighted Ensemble (dynamic weights by Sharpe ratio) //! 3. Diversity-Weighted Ensemble (favor low-correlation models) //! //! Usage: //! cargo run -p ml --example backtest_ensemble --release use anyhow::Result; use candle_core::{DType, Device, Tensor}; use candle_nn::VarBuilder; use chrono::{DateTime, Utc}; use data::providers::databento::dbn_parser::{DbnParser, ProcessedMessage}; use ml::dqn::dqn::Sequential; use ml::ppo::ppo::PolicyNetwork; use num_traits::ToPrimitive; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; /// Configuration for ensemble backtesting #[derive(Debug, Clone)] struct EnsembleBacktestConfig { data_dir: PathBuf, model_dir: PathBuf, results_dir: PathBuf, symbols: Vec, initial_capital: f64, position_size: f64, // Ensemble parameters min_confidence: f64, min_models_agree: usize, } /// Performance metrics for a backtest #[derive(Debug, Clone, Serialize, Deserialize)] struct PerformanceMetrics { strategy_name: String, model_type: String, epoch: Option, total_trades: usize, winning_trades: usize, win_rate: f64, total_pnl: f64, sharpe_ratio: f64, max_drawdown: f64, calmar_ratio: f64, avg_trade_duration_minutes: f64, profit_factor: f64, trade_frequency: f64, average_confidence: f64, total_bars: usize, } /// Trade record #[derive(Debug, Clone)] struct Trade { entry_time: DateTime, exit_time: DateTime, entry_price: f64, exit_price: f64, side: TradeSide, pnl: f64, size: f64, confidence: f64, } #[derive(Debug, Clone, Copy)] enum TradeSide { Long, Short, } /// Model type enum enum ModelType { DQN(Sequential), PPO(PolicyNetwork), } /// Simple model inference wrapper struct ModelInference { model_name: String, model_type: ModelType, device: Device, } impl ModelInference { fn load_dqn(model_name: String, model_path: PathBuf) -> Result { let device = Device::cuda_if_available(0)?; println!( "šŸ”§ Loading DQN model: {} on device: {:?}", model_name, device ); let _vb = unsafe { VarBuilder::from_mmaped_safetensors(&[model_path.clone()], DType::F32, &device)? }; let dqn_network = Sequential::new(64, &[128, 64, 32], 3, device.clone()) .map_err(|e| anyhow::anyhow!("Failed to create DQN network: {}", e))?; println!("āœ… DQN model loaded successfully"); Ok(Self { model_name, model_type: ModelType::DQN(dqn_network), device, }) } fn load_ppo(model_name: String, model_path: PathBuf) -> Result { let device = Device::cuda_if_available(0)?; println!( "šŸ”§ Loading PPO model: {} on device: {:?}", model_name, device ); let _vb = unsafe { VarBuilder::from_mmaped_safetensors(&[model_path.clone()], DType::F32, &device)? }; let ppo_actor = PolicyNetwork::new(64, &[128, 64], 3, device.clone()) .map_err(|e| anyhow::anyhow!("Failed to create PPO network: {}", e))?; println!("āœ… PPO model loaded successfully"); Ok(Self { model_name, model_type: ModelType::PPO(ppo_actor), device, }) } fn predict(&self, features: &[f64]) -> Result<(f64, f64)> { let mut padded_features = features.to_vec(); while padded_features.len() < 64 { padded_features.push(0.0); } if padded_features.len() > 64 { padded_features.truncate(64); } let features_f32: Vec = padded_features.iter().map(|&x| x as f32).collect(); let feature_tensor = Tensor::from_vec(features_f32, (1, 64), &self.device)?; let q_values = match &self.model_type { ModelType::DQN(network) => network .forward(&feature_tensor) .map_err(|e| anyhow::anyhow!("DQN forward pass failed: {}", e))?, ModelType::PPO(actor) => actor .forward(&feature_tensor) .map_err(|e| anyhow::anyhow!("PPO forward pass failed: {}", e))?, }; let q_vec = q_values.to_vec2::()?; let actions = &q_vec[0]; let buy_strength = actions[0] as f64; let sell_strength = actions[1] as f64; let hold_strength = actions[2] as f64; let signal = if buy_strength > sell_strength && buy_strength > hold_strength { (buy_strength - hold_strength).min(1.0) } else if sell_strength > buy_strength && sell_strength > hold_strength { -(sell_strength - hold_strength).min(1.0) } else { 0.0 }; let max_action = buy_strength.max(sell_strength).max(hold_strength); let confidence = (max_action - hold_strength).abs().min(1.0).max(0.5); Ok((signal, confidence)) } } /// Feature extractor struct FeatureExtractor { price_history: Vec, volume_history: Vec, lookback: usize, } impl FeatureExtractor { fn new(lookback: usize) -> Self { Self { price_history: Vec::with_capacity(lookback), volume_history: Vec::with_capacity(lookback), lookback, } } fn extract_features(&mut self, price: f64, volume: f64) -> Vec { self.price_history.push(price); self.volume_history.push(volume); if self.price_history.len() > self.lookback { self.price_history.remove(0); self.volume_history.remove(0); } let mut features = Vec::new(); if self.price_history.len() < 2 { return vec![0.0; 10]; } let current_price = price; let prev_price = self.price_history[self.price_history.len() - 2]; // 1. Price momentum let price_change = (current_price - prev_price) / prev_price; features.push(price_change); // 2. SMA ratio if self.price_history.len() >= 10 { let sma: f64 = self.price_history.iter().rev().take(10).sum::() / 10.0; let sma_ratio = (current_price - sma) / sma; features.push(sma_ratio); } else { features.push(0.0); } // 3. RSI let rsi = self.calculate_rsi(14); features.push(rsi); // 4. Volume ratio if self.volume_history.len() >= 2 { let curr_vol = volume; let prev_vol = self.volume_history[self.volume_history.len() - 2]; let vol_ratio = if prev_vol > 0.0 { (curr_vol - prev_vol) / prev_vol } else { 0.0 }; features.push(vol_ratio); } else { features.push(0.0); } // 5. Volatility if self.price_history.len() >= 20 { let returns: Vec = self .price_history .windows(2) .map(|w| (w[1] - w[0]) / w[0]) .collect(); let mean = returns.iter().sum::() / returns.len() as f64; let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::() / returns.len() as f64; let volatility = variance.sqrt(); features.push(volatility); } else { features.push(0.0); } while features.len() < 10 { features.push(0.0); } features } fn calculate_rsi(&self, period: usize) -> f64 { if self.price_history.len() < period + 1 { return 50.0; } let recent_prices: Vec = self .price_history .iter() .rev() .take(period + 1) .copied() .collect(); let mut gains = 0.0; let mut losses = 0.0; for i in 1..recent_prices.len() { let change = recent_prices[i - 1] - recent_prices[i]; if change > 0.0 { gains += change; } else { losses += change.abs(); } } let avg_gain = gains / period as f64; let avg_loss = losses / period as f64; if avg_loss == 0.0 { return 100.0; } let rs = avg_gain / avg_loss; 100.0 - (100.0 / (1.0 + rs)) } } /// Ensemble prediction aggregator struct EnsembleAggregator { models: Vec, weights: Vec, } impl EnsembleAggregator { fn new(models: Vec) -> Self { let num_models = models.len(); let equal_weight = 1.0 / num_models as f64; Self { models, weights: vec![equal_weight; num_models], } } /// Equal-weight ensemble (1/N) fn predict_equal_weight(&self, features: &[f64]) -> Result<(f64, f64, f64)> { let mut signals = Vec::new(); let mut confidences = Vec::new(); for model in &self.models { let (signal, confidence) = model.predict(features)?; signals.push(signal); confidences.push(confidence); } let avg_signal = signals.iter().sum::() / signals.len() as f64; let avg_confidence = confidences.iter().sum::() / confidences.len() as f64; let disagreement = self.calculate_disagreement(&signals); Ok((avg_signal, avg_confidence, disagreement)) } /// Performance-weighted ensemble (dynamic weights) fn predict_performance_weighted( &mut self, features: &[f64], performance_scores: &[f64], ) -> Result<(f64, f64, f64)> { // Update weights based on performance scores let total_score: f64 = performance_scores.iter().sum(); if total_score > 0.0 { for (i, score) in performance_scores.iter().enumerate() { self.weights[i] = score / total_score; } } let mut weighted_signal = 0.0; let mut weighted_confidence = 0.0; let mut signals = Vec::new(); for (i, model) in self.models.iter().enumerate() { let (signal, confidence) = model.predict(features)?; weighted_signal += signal * self.weights[i]; weighted_confidence += confidence * self.weights[i]; signals.push(signal); } let disagreement = self.calculate_disagreement(&signals); Ok((weighted_signal, weighted_confidence, disagreement)) } /// Confidence-weighted ensemble fn predict_confidence_weighted(&self, features: &[f64]) -> Result<(f64, f64, f64)> { let mut signals = Vec::new(); let mut confidences = Vec::new(); for model in &self.models { let (signal, confidence) = model.predict(features)?; signals.push(signal); confidences.push(confidence); } // Weight by confidence let total_confidence: f64 = confidences.iter().sum(); let mut weighted_signal = 0.0; if total_confidence > 0.0 { for i in 0..signals.len() { weighted_signal += signals[i] * (confidences[i] / total_confidence); } } else { weighted_signal = signals.iter().sum::() / signals.len() as f64; } let avg_confidence = total_confidence / confidences.len() as f64; let disagreement = self.calculate_disagreement(&signals); Ok((weighted_signal, avg_confidence, disagreement)) } fn calculate_disagreement(&self, signals: &[f64]) -> f64 { if signals.len() < 2 { return 0.0; } let mean_signal = signals.iter().sum::() / signals.len() as f64; let variance = signals .iter() .map(|s| (s - mean_signal).powi(2)) .sum::() / signals.len() as f64; variance.sqrt() } } /// Market data bar #[derive(Debug, Clone)] struct MarketBar { timestamp: DateTime, open: f64, high: f64, low: f64, close: f64, volume: f64, } /// Load market data from DBN files fn load_market_data(data_dir: &PathBuf, symbols: &[String]) -> Result> { println!("šŸ” Loading market data from {:?}", data_dir); let parser = DbnParser::new().map_err(|e| anyhow::anyhow!("Failed to create DBN parser: {}", e))?; let mut all_bars = Vec::new(); for symbol in symbols { println!("šŸ“Š Loading symbol: {}", symbol); let dbn_files: Vec = std::fs::read_dir(data_dir)? .filter_map(|entry| entry.ok()) .map(|entry| entry.path()) .filter(|path| { path.extension().and_then(|s| s.to_str()) == Some("dbn") && path .file_name() .and_then(|s| s.to_str()) .map(|s| s.contains(symbol)) .unwrap_or(false) }) .collect(); println!(" Found {} DBN files for {}", dbn_files.len(), symbol); for dbn_file in dbn_files { let dbn_bytes = std::fs::read(&dbn_file)?; let messages = parser .parse_batch(&dbn_bytes) .map_err(|e| anyhow::anyhow!("Failed to parse DBN file: {}", e))?; for msg in messages { if let ProcessedMessage::Ohlcv { symbol: _, timestamp, open, high, low, close, volume, } = msg { let ts_secs = (timestamp.as_nanos() / 1_000_000_000) as i64; all_bars.push(MarketBar { timestamp: DateTime::from_timestamp(ts_secs, 0) .unwrap_or_else(|| Utc::now()), open: open.to_f64(), high: high.to_f64(), low: low.to_f64(), close: close.to_f64(), volume: volume.to_f64().unwrap_or(0.0), }); } } } } all_bars.sort_by_key(|bar| bar.timestamp); println!("āœ… Total bars loaded: {}", all_bars.len()); Ok(all_bars) } /// Run individual model backtest fn backtest_individual_model( model: &ModelInference, market_data: &[MarketBar], config: &EnsembleBacktestConfig, ) -> Result { let mut feature_extractor = FeatureExtractor::new(50); let mut trades = Vec::new(); let mut position: Option<(TradeSide, f64, DateTime, f64)> = None; let mut equity_curve = vec![config.initial_capital]; let mut current_capital = config.initial_capital; for bar in market_data { let features = feature_extractor.extract_features(bar.close, bar.volume); let (signal, confidence) = model.predict(&features)?; if confidence < config.min_confidence { continue; } if position.is_none() { if signal > 0.5 { position = Some(( TradeSide::Long, config.position_size, bar.timestamp, bar.close, )); } else if signal < -0.5 { position = Some(( TradeSide::Short, config.position_size, bar.timestamp, bar.close, )); } } else if let Some((side, size, entry_time, entry_price)) = position { let should_exit = match side { TradeSide::Long => signal < -0.3, TradeSide::Short => signal > 0.3, }; if should_exit { let pnl = match side { TradeSide::Long => (bar.close - entry_price) * size, TradeSide::Short => (entry_price - bar.close) * size, }; current_capital += pnl; equity_curve.push(current_capital); trades.push(Trade { entry_time, exit_time: bar.timestamp, entry_price, exit_price: bar.close, side, pnl, size, confidence, }); position = None; } } } calculate_metrics( &model.model_name, "Individual", None, trades, equity_curve, config.initial_capital, market_data.len(), ) } /// Run ensemble backtest fn backtest_ensemble( ensemble: &mut EnsembleAggregator, market_data: &[MarketBar], config: &EnsembleBacktestConfig, strategy_name: &str, performance_scores: Option<&[f64]>, ) -> Result { let mut feature_extractor = FeatureExtractor::new(50); let mut trades = Vec::new(); let mut position: Option<(TradeSide, f64, DateTime, f64)> = None; let mut equity_curve = vec![config.initial_capital]; let mut current_capital = config.initial_capital; for bar in market_data { let features = feature_extractor.extract_features(bar.close, bar.volume); let (signal, confidence, _disagreement) = match strategy_name { "Equal-Weight" => ensemble.predict_equal_weight(&features)?, "Performance-Weighted" => { let scores = performance_scores.unwrap_or(&[1.0, 1.0]); ensemble.predict_performance_weighted(&features, scores)? }, "Confidence-Weighted" => ensemble.predict_confidence_weighted(&features)?, _ => ensemble.predict_equal_weight(&features)?, }; if confidence < config.min_confidence { continue; } if position.is_none() { if signal > 0.5 { position = Some(( TradeSide::Long, config.position_size, bar.timestamp, bar.close, )); } else if signal < -0.5 { position = Some(( TradeSide::Short, config.position_size, bar.timestamp, bar.close, )); } } else if let Some((side, size, entry_time, entry_price)) = position { let should_exit = match side { TradeSide::Long => signal < -0.3, TradeSide::Short => signal > 0.3, }; if should_exit { let pnl = match side { TradeSide::Long => (bar.close - entry_price) * size, TradeSide::Short => (entry_price - bar.close) * size, }; current_capital += pnl; equity_curve.push(current_capital); trades.push(Trade { entry_time, exit_time: bar.timestamp, entry_price, exit_price: bar.close, side, pnl, size, confidence, }); position = None; } } } calculate_metrics( strategy_name, "Ensemble", None, trades, equity_curve, config.initial_capital, market_data.len(), ) } /// Calculate performance metrics fn calculate_metrics( strategy_name: &str, model_type: &str, epoch: Option, trades: Vec, equity_curve: Vec, initial_capital: f64, total_bars: usize, ) -> Result { if trades.is_empty() { return Ok(PerformanceMetrics { strategy_name: strategy_name.to_string(), model_type: model_type.to_string(), epoch, total_trades: 0, winning_trades: 0, win_rate: 0.0, total_pnl: 0.0, sharpe_ratio: 0.0, max_drawdown: 0.0, calmar_ratio: 0.0, avg_trade_duration_minutes: 0.0, profit_factor: 0.0, trade_frequency: 0.0, average_confidence: 0.0, total_bars, }); } let total_trades = trades.len(); let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count(); let win_rate = (winning_trades as f64 / total_trades as f64) * 100.0; let total_pnl: f64 = trades.iter().map(|t| t.pnl).sum(); let avg_trade_duration: f64 = trades .iter() .map(|t| (t.exit_time - t.entry_time).num_minutes() as f64) .sum::() / total_trades as f64; let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum(); let gross_loss: f64 = trades .iter() .filter(|t| t.pnl < 0.0) .map(|t| t.pnl.abs()) .sum(); let profit_factor = if gross_loss > 0.0 { gross_profit / gross_loss } else { if gross_profit > 0.0 { f64::INFINITY } else { 0.0 } }; let returns: Vec = trades.iter().map(|t| t.pnl / initial_capital).collect(); let mean_return = returns.iter().sum::() / returns.len() as f64; let variance = returns .iter() .map(|r| (r - mean_return).powi(2)) .sum::() / returns.len() as f64; let std_dev = variance.sqrt(); let sharpe_ratio = if std_dev > 0.0 { (mean_return / std_dev) * (252.0_f64).sqrt() } else { 0.0 }; let max_drawdown = calculate_max_drawdown(&equity_curve); let total_return = (equity_curve.last().unwrap() - initial_capital) / initial_capital; let calmar_ratio = if max_drawdown > 0.0 { total_return / max_drawdown } else { 0.0 }; let trade_frequency = if total_bars > 0 { (total_trades as f64 / total_bars as f64) * 1000.0 } else { 0.0 }; let average_confidence = trades.iter().map(|t| t.confidence).sum::() / total_trades as f64; Ok(PerformanceMetrics { strategy_name: strategy_name.to_string(), model_type: model_type.to_string(), epoch, total_trades, winning_trades, win_rate, total_pnl, sharpe_ratio, max_drawdown: max_drawdown * 100.0, calmar_ratio, avg_trade_duration_minutes: avg_trade_duration, profit_factor, trade_frequency, average_confidence, total_bars, }) } /// Calculate maximum drawdown fn calculate_max_drawdown(equity_curve: &[f64]) -> f64 { let mut max_drawdown = 0.0; let mut peak = equity_curve[0]; for &equity in equity_curve { if equity > peak { peak = equity; } let drawdown = (peak - equity) / peak; if drawdown > max_drawdown { max_drawdown = drawdown; } } max_drawdown } fn main() -> Result<()> { println!("\n{}", "=".repeat(80)); println!("šŸŽÆ ENSEMBLE BACKTESTING TOOL - Compare Individual Models vs Ensemble"); println!("{}\n", "=".repeat(80)); let project_root = std::env::current_dir()?; let config = EnsembleBacktestConfig { data_dir: project_root.join("test_data/real/databento/ml_training"), model_dir: project_root.join("ml/trained_models/production"), results_dir: project_root.join("results"), symbols: vec![ "ES.FUT".to_string(), "NQ.FUT".to_string(), "ZN.FUT".to_string(), "6E.FUT".to_string(), ], initial_capital: 100_000.0, position_size: 1.0, min_confidence: 0.6, min_models_agree: 2, }; std::fs::create_dir_all(&config.results_dir)?; // Load market data (90+ days) let market_data = load_market_data(&config.data_dir, &config.symbols)?; let total_bars = market_data.len(); println!("\nšŸ“Š Dataset Statistics:"); println!(" Total bars: {}", total_bars); println!(" Symbols: {:?}", config.symbols); println!( " Date range: {} to {}", market_data.first().unwrap().timestamp, market_data.last().unwrap().timestamp ); // Load best DQN and PPO checkpoints (based on previous analysis) println!("\nšŸ”§ Loading trained models..."); let dqn_best_epoch = 360; // From checkpoint analysis let ppo_best_epoch = 280; // From checkpoint analysis let dqn_path = config .model_dir .join("dqn_real_data") .join(format!("dqn_epoch_{}.safetensors", dqn_best_epoch)); let ppo_path = config .model_dir .join("ppo_real_data") .join(format!("ppo_actor_epoch_{}.safetensors", ppo_best_epoch)); let dqn_model = ModelInference::load_dqn(format!("DQN-E{}", dqn_best_epoch), dqn_path)?; let ppo_model = ModelInference::load_ppo(format!("PPO-E{}", ppo_best_epoch), ppo_path)?; println!("āœ… Models loaded successfully\n"); let mut all_results = Vec::new(); // 1. Test individual models println!("\n{}", "=".repeat(80)); println!("šŸ“ˆ Phase 1: Individual Model Performance"); println!("{}\n", "=".repeat(80)); println!("Testing DQN (Epoch {})...", dqn_best_epoch); let dqn_metrics = backtest_individual_model(&dqn_model, &market_data, &config)?; println!( " Trades: {}, Sharpe: {:.3}, Win Rate: {:.1}%", dqn_metrics.total_trades, dqn_metrics.sharpe_ratio, dqn_metrics.win_rate ); all_results.push(dqn_metrics.clone()); println!("Testing PPO (Epoch {})...", ppo_best_epoch); let ppo_metrics = backtest_individual_model(&ppo_model, &market_data, &config)?; println!( " Trades: {}, Sharpe: {:.3}, Win Rate: {:.1}%", ppo_metrics.total_trades, ppo_metrics.sharpe_ratio, ppo_metrics.win_rate ); all_results.push(ppo_metrics.clone()); // 2. Test ensemble strategies println!("\n{}", "=".repeat(80)); println!("šŸŽÆ Phase 2: Ensemble Strategies"); println!("{}\n", "=".repeat(80)); let models = vec![dqn_model, ppo_model]; let mut ensemble = EnsembleAggregator::new(models); // Equal-weight ensemble println!("Testing Equal-Weight Ensemble (1/2 each)..."); let equal_metrics = backtest_ensemble(&mut ensemble, &market_data, &config, "Equal-Weight", None)?; println!( " Trades: {}, Sharpe: {:.3}, Win Rate: {:.1}%", equal_metrics.total_trades, equal_metrics.sharpe_ratio, equal_metrics.win_rate ); all_results.push(equal_metrics.clone()); // Performance-weighted ensemble println!("Testing Performance-Weighted Ensemble..."); let performance_scores = vec![dqn_metrics.sharpe_ratio, ppo_metrics.sharpe_ratio]; let perf_metrics = backtest_ensemble( &mut ensemble, &market_data, &config, "Performance-Weighted", Some(&performance_scores), )?; println!( " Trades: {}, Sharpe: {:.3}, Win Rate: {:.1}%", perf_metrics.total_trades, perf_metrics.sharpe_ratio, perf_metrics.win_rate ); all_results.push(perf_metrics.clone()); // Confidence-weighted ensemble println!("Testing Confidence-Weighted Ensemble..."); let conf_metrics = backtest_ensemble( &mut ensemble, &market_data, &config, "Confidence-Weighted", None, )?; println!( " Trades: {}, Sharpe: {:.3}, Win Rate: {:.1}%", conf_metrics.total_trades, conf_metrics.sharpe_ratio, conf_metrics.win_rate ); all_results.push(conf_metrics); // Save results let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); let results_file = config .results_dir .join(format!("ensemble_backtest_results_{}.json", timestamp)); let json = serde_json::to_string_pretty(&all_results)?; std::fs::write(&results_file, json)?; // Print comprehensive summary print_ensemble_summary(&all_results); println!("\nšŸ“Š Results saved to: {}", results_file.display()); println!("\n{}\n", "=".repeat(80)); Ok(()) } fn print_ensemble_summary(results: &[PerformanceMetrics]) { println!("\n{}", "=".repeat(80)); println!("šŸ“Š COMPREHENSIVE ENSEMBLE ANALYSIS"); println!("{}\n", "=".repeat(80)); println!( "{:<30} {:>8} {:>10} {:>12} {:>12} {:>12}", "Strategy", "Trades", "Win Rate", "Sharpe", "PnL", "Drawdown" ); println!("{}", "-".repeat(80)); for metrics in results { println!( "{:<30} {:>8} {:>9.1}% {:>12.3} ${:>10.2} {:>11.2}%", metrics.strategy_name, metrics.total_trades, metrics.win_rate, metrics.sharpe_ratio, metrics.total_pnl, metrics.max_drawdown ); } println!("\n{}", "=".repeat(80)); println!("šŸ† WINNER ANALYSIS"); println!("{}", "=".repeat(80)); let mut sorted_by_sharpe = results.to_vec(); sorted_by_sharpe.sort_by(|a, b| { b.sharpe_ratio .partial_cmp(&a.sharpe_ratio) .unwrap_or(std::cmp::Ordering::Equal) }); if let Some(best) = sorted_by_sharpe.first() { println!("\nāœ… Best Strategy: {}", best.strategy_name); println!(" Sharpe Ratio: {:.3}", best.sharpe_ratio); println!(" Win Rate: {:.1}%", best.win_rate); println!(" Total PnL: ${:.2}", best.total_pnl); println!(" Total Trades: {}", best.total_trades); println!(" Max Drawdown: {:.2}%", best.max_drawdown); // Compare to best individual model let best_individual = results .iter() .filter(|m| m.model_type == "Individual") .max_by(|a, b| { a.sharpe_ratio .partial_cmp(&b.sharpe_ratio) .unwrap_or(std::cmp::Ordering::Equal) }); if let Some(individual) = best_individual { let sharpe_improvement = ((best.sharpe_ratio - individual.sharpe_ratio) / individual.sharpe_ratio.abs()) * 100.0; let pnl_improvement = ((best.total_pnl - individual.total_pnl) / individual.total_pnl.abs()) * 100.0; println!("\nšŸ“ˆ Ensemble vs Best Individual Model:"); println!(" Sharpe improvement: {:+.1}%", sharpe_improvement); println!(" PnL improvement: {:+.1}%", pnl_improvement); println!( " Win rate difference: {:+.1}pp", best.win_rate - individual.win_rate ); } } println!("\n"); }