Files
foxhunt/crates/ml/examples/backtest_ensemble.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

1001 lines
30 KiB
Rust

//! 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<String>,
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<u32>,
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<Utc>,
exit_time: DateTime<Utc>,
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<Self> {
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<Self> {
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<f32> = 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::<f32>()?;
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<f64>,
volume_history: Vec<f64>,
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<f64> {
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::<f64>() / 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<f64> = self
.price_history
.windows(2)
.map(|w| (w[1] - w[0]) / w[0])
.collect();
let mean = returns.iter().sum::<f64>() / returns.len() as f64;
let variance =
returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / 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<f64> = 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<ModelInference>,
weights: Vec<f64>,
}
impl EnsembleAggregator {
fn new(models: Vec<ModelInference>) -> 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::<f64>() / signals.len() as f64;
let avg_confidence = confidences.iter().sum::<f64>() / 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::<f64>() / 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::<f64>() / signals.len() as f64;
let variance = signals
.iter()
.map(|s| (s - mean_signal).powi(2))
.sum::<f64>()
/ signals.len() as f64;
variance.sqrt()
}
}
/// Market data bar
#[derive(Debug, Clone)]
struct MarketBar {
timestamp: DateTime<Utc>,
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<Vec<MarketBar>> {
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<PathBuf> = 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<PerformanceMetrics> {
let mut feature_extractor = FeatureExtractor::new(50);
let mut trades = Vec::new();
let mut position: Option<(TradeSide, f64, DateTime<Utc>, 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<PerformanceMetrics> {
let mut feature_extractor = FeatureExtractor::new(50);
let mut trades = Vec::new();
let mut position: Option<(TradeSide, f64, DateTime<Utc>, 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<u32>,
trades: Vec<Trade>,
equity_curve: Vec<f64>,
initial_capital: f64,
total_bars: usize,
) -> Result<PerformanceMetrics> {
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::<f64>()
/ 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<f64> = trades.iter().map(|t| t.pnl / initial_capital).collect();
let mean_return = returns.iter().sum::<f64>() / returns.len() as f64;
let variance = returns
.iter()
.map(|r| (r - mean_return).powi(2))
.sum::<f64>()
/ 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::<f64>() / 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");
}