//! Performance analysis and metrics calculation for backtesting use anyhow::Result; use rust_decimal::{prelude::ToPrimitive, Decimal}; use serde::{Deserialize, Serialize}; use tracing::info; use crate::strategy_engine::BacktestTrade; use config::structures::BacktestingPerformanceConfig; /// Comprehensive performance metrics #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PerformanceMetrics { /// Total return (percentage) pub total_return: f64, /// Annualized return (percentage) pub annualized_return: f64, /// Sharpe ratio pub sharpe_ratio: f64, /// Sortino ratio pub sortino_ratio: f64, /// Maximum drawdown (percentage) pub max_drawdown: f64, /// Volatility (annualized) pub volatility: f64, /// Win rate (percentage) pub win_rate: f64, /// Profit factor pub profit_factor: f64, /// Total number of trades pub total_trades: u64, /// Number of winning trades pub winning_trades: u64, /// Number of losing trades pub losing_trades: u64, /// Average winning trade pub avg_win: f64, /// Average losing trade pub avg_loss: f64, /// Largest winning trade pub largest_win: f64, /// Largest losing trade pub largest_loss: f64, /// Calmar ratio pub calmar_ratio: f64, /// Backtest duration in nanoseconds pub backtest_duration_nanos: i64, /// Additional metrics pub beta: Option, /// Alpha vs benchmark pub alpha: Option, /// Information ratio pub information_ratio: Option, /// VaR at 95% confidence pub var_95: Option, /// Expected Shortfall (CVaR) pub expected_shortfall: Option, } /// Equity curve point for visualization #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EquityCurvePoint { /// Timestamp pub timestamp: chrono::DateTime, /// Portfolio equity value pub equity: f64, /// Drawdown from peak pub drawdown: f64, /// Benchmark value (if available) pub benchmark_equity: Option, } /// Drawdown period analysis #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DrawdownPeriod { /// Start time of drawdown pub start_time: chrono::DateTime, /// End time of drawdown pub end_time: chrono::DateTime, /// Peak value before drawdown pub peak_value: f64, /// Trough value during drawdown pub trough_value: f64, /// Drawdown percentage pub drawdown_percent: f64, /// Duration in days pub duration_days: u32, } /// Performance analyzer for backtesting results #[derive(Debug)] pub struct PerformanceAnalyzer { /// Configuration config: BacktestingPerformanceConfig, } impl PerformanceAnalyzer { /// Create a new performance analyzer pub fn new(config: &BacktestingPerformanceConfig) -> Result { info!("Initializing performance analyzer"); Ok(Self { config: config.clone(), }) } /// Calculate comprehensive performance metrics pub fn calculate_metrics( &self, trades: &[BacktestTrade], initial_capital: f64, ) -> PerformanceMetrics { info!( "Calculating performance metrics for {} trades", trades.len() ); if trades.is_empty() { return PerformanceMetrics::default(); } // Calculate basic statistics let total_pnl: f64 = trades.iter().filter_map(|t| t.pnl.to_f64()).sum(); let total_return = if initial_capital > 0.0 { let result = total_pnl / initial_capital; if !result.is_finite() { 0.0 } else { result } } else { 0.0 }; let winning_trades: Vec<&BacktestTrade> = trades.iter().filter(|t| t.pnl > Decimal::ZERO).collect(); let losing_trades: Vec<&BacktestTrade> = trades.iter().filter(|t| t.pnl < Decimal::ZERO).collect(); let win_rate = if trades.is_empty() { 0.0 } else { let result = (winning_trades.len() as f64 / trades.len() as f64) * 100.0; if !result.is_finite() { 0.0 } else { result } }; // Calculate profit factor let gross_profit: f64 = winning_trades.iter().filter_map(|t| t.pnl.to_f64()).sum(); let gross_loss: f64 = losing_trades .iter() .filter_map(|t| t.pnl.to_f64()) .map(|v| v.abs()) .sum(); let profit_factor = if gross_loss > 0.0 { let result = gross_profit / gross_loss; if !result.is_finite() { info!( "Float overflow in profit factor calculation: {} / {}", gross_profit, gross_loss ); f64::MAX } else { result } } else { f64::INFINITY }; // Calculate average wins and losses let avg_win = if winning_trades.is_empty() { 0.0 } else { let result = gross_profit / winning_trades.len() as f64; if !result.is_finite() { 0.0 } else { result } }; let avg_loss = if losing_trades.is_empty() { 0.0 } else { let result = -gross_loss / losing_trades.len() as f64; if !result.is_finite() { 0.0 } else { result } }; // Find largest win and loss let largest_win = winning_trades .iter() .filter_map(|t| t.pnl.to_f64()) .fold(0.0, f64::max); let largest_loss = losing_trades .iter() .filter_map(|t| t.pnl.to_f64()) .fold(0.0, f64::min); // Calculate time-based metrics let start_time = trades .first() .map(|t| t.entry_time) .unwrap_or_else(chrono::Utc::now); let end_time = trades .last() .map(|t| t.exit_time) .unwrap_or_else(chrono::Utc::now); let duration = end_time - start_time; let duration_years = { let result = duration.num_days() as f64 / 365.25; if !result.is_finite() { 0.0 } else { result } }; let annualized_return = if duration_years > 0.0 { let result = ((1.0 + total_return).powf(1.0 / duration_years) - 1.0) * 100.0; if !result.is_finite() { 0.0 } else { result } } else { 0.0 }; // Calculate volatility and Sharpe ratio let returns: Vec = trades .iter() .filter_map(|t| t.return_percent.to_f64()) .collect(); let (volatility, sharpe_ratio) = self.calculate_volatility_and_sharpe(&returns, duration_years); // Calculate Sortino ratio let sortino_ratio = self.calculate_sortino_ratio(&returns, duration_years); // Calculate maximum drawdown let (max_drawdown, _) = self.calculate_max_drawdown(trades, initial_capital); // Calculate Calmar ratio let calmar_ratio = if max_drawdown > 0.0 { let result = annualized_return / (max_drawdown * 100.0); if !result.is_finite() { 0.0 } else { result } } else { 0.0 }; // Calculate risk metrics let var_95 = self.calculate_var(&returns, 0.95); let expected_shortfall = self.calculate_expected_shortfall(&returns, 0.95); PerformanceMetrics { total_return: total_return * 100.0, annualized_return, sharpe_ratio, sortino_ratio, max_drawdown: max_drawdown * 100.0, volatility: volatility * 100.0, win_rate, profit_factor, total_trades: trades.len() as u64, winning_trades: winning_trades.len() as u64, losing_trades: losing_trades.len() as u64, avg_win, avg_loss, largest_win, largest_loss, calmar_ratio, backtest_duration_nanos: duration.num_nanoseconds().unwrap_or(0), // Benchmark-relative metrics require benchmark data to be passed in // These would be calculated as: beta = cov(returns, benchmark) / var(benchmark) // alpha = returns - (risk_free_rate + beta * (benchmark_returns - risk_free_rate)) // information_ratio = (returns - benchmark) / tracking_error beta: None, alpha: None, information_ratio: None, var_95: Some(var_95), expected_shortfall: Some(expected_shortfall), } } /// Generate equity curve from trades pub fn generate_equity_curve( &self, trades: &[BacktestTrade], initial_capital: f64, ) -> Vec { if trades.is_empty() { return Vec::new(); } let mut curve = Vec::new(); let mut running_equity = initial_capital; let mut peak_equity = initial_capital; // Add initial point curve.push(EquityCurvePoint { timestamp: trades .first() .map(|t| t.entry_time) .unwrap_or_else(chrono::Utc::now), equity: initial_capital, drawdown: 0.0, benchmark_equity: None, }); // Calculate equity at each trade for trade in trades { running_equity += trade.pnl.to_f64().unwrap_or(0.0); if running_equity > peak_equity { peak_equity = running_equity; } let drawdown = if peak_equity > 0.0 { (peak_equity - running_equity) / peak_equity } else { 0.0 }; curve.push(EquityCurvePoint { timestamp: trade.exit_time, equity: running_equity, drawdown, // Benchmark comparison requires an external data source (e.g., SPY or // ES continuous futures) aligned to the same timestamps. Until a // BenchmarkDataProvider trait is wired in, this field stays None. benchmark_equity: None, }); } // Resample to target resolution if needed if curve.len() > self.config.equity_curve_resolution { self.resample_equity_curve(curve) } else { curve } } /// Identify drawdown periods pub fn identify_drawdown_periods( &self, equity_curve: &[EquityCurvePoint], ) -> Vec { let mut periods = Vec::new(); let mut in_drawdown = false; let mut drawdown_start: Option = None; let mut peak_value = 0.0; for (i, point) in equity_curve.iter().enumerate() { if !in_drawdown && point.drawdown > 0.0 { // Start of new drawdown in_drawdown = true; drawdown_start = Some(i); peak_value = point.equity + (point.equity * point.drawdown); } else if in_drawdown && point.drawdown == 0.0 { // End of drawdown if let Some(start_idx) = drawdown_start { let start_point = &equity_curve[start_idx]; let trough_value = equity_curve[start_idx..=i] .iter() .map(|p| p.equity) .fold(f64::INFINITY, f64::min); let drawdown_percent = (peak_value - trough_value) / peak_value * 100.0; let duration_days = (point.timestamp - start_point.timestamp).num_days() as u32; periods.push(DrawdownPeriod { start_time: start_point.timestamp, end_time: point.timestamp, peak_value, trough_value, drawdown_percent, duration_days, }); } in_drawdown = false; drawdown_start = None; } } periods } /// Calculate volatility and Sharpe ratio fn calculate_volatility_and_sharpe(&self, returns: &[f64], duration_years: f64) -> (f64, f64) { if returns.is_empty() || duration_years <= 0.0 { return (0.0, 0.0); } 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 volatility = variance.sqrt(); let annualized_volatility = volatility * (252.0_f64).sqrt(); // Assuming 252 trading days let excess_return = mean_return - self.config.risk_free_rate / 252.0; // Daily risk-free rate let sharpe_ratio = if annualized_volatility > 0.0 { excess_return * (252.0_f64).sqrt() / annualized_volatility } else { 0.0 }; (annualized_volatility, sharpe_ratio) } /// Calculate Sortino ratio fn calculate_sortino_ratio(&self, returns: &[f64], duration_years: f64) -> f64 { if returns.is_empty() || duration_years <= 0.0 { return 0.0; } let mean_return = returns.iter().sum::() / returns.len() as f64; let target_return = self.config.risk_free_rate / 252.0; // Daily risk-free rate let downside_returns: Vec = returns .iter() .map(|r| { if *r < target_return { r - target_return } else { 0.0 } }) .collect(); let downside_variance = downside_returns.iter().map(|r| r.powi(2)).sum::() / downside_returns.len() as f64; let downside_deviation = downside_variance.sqrt(); let annualized_downside_deviation = downside_deviation * (252.0_f64).sqrt(); if annualized_downside_deviation > 0.0 { let excess_return = mean_return - target_return; excess_return * (252.0_f64).sqrt() / annualized_downside_deviation } else { 0.0 } } /// Calculate maximum drawdown fn calculate_max_drawdown(&self, trades: &[BacktestTrade], initial_capital: f64) -> (f64, f64) { let mut running_equity = initial_capital; let mut peak_equity = initial_capital; let mut max_drawdown = 0.0; let max_drawdown_duration = 0.0; for trade in trades { running_equity += trade.pnl.to_f64().unwrap_or(0.0); if running_equity > peak_equity { peak_equity = running_equity; } let current_drawdown = (peak_equity - running_equity) / peak_equity; if current_drawdown > max_drawdown { max_drawdown = current_drawdown; } } (max_drawdown, max_drawdown_duration) } /// Calculate Value at Risk (VaR) fn calculate_var(&self, returns: &[f64], confidence_level: f64) -> f64 { if returns.is_empty() { return 0.0; } let mut sorted_returns = returns.to_vec(); sorted_returns.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let index = ((1.0 - confidence_level) * sorted_returns.len() as f64) as usize; sorted_returns.get(index).copied().unwrap_or(0.0) } /// Calculate Expected Shortfall (Conditional VaR) fn calculate_expected_shortfall(&self, returns: &[f64], confidence_level: f64) -> f64 { let var = self.calculate_var(returns, confidence_level); let tail_returns: Vec = returns.iter().filter(|&&r| r <= var).copied().collect(); if tail_returns.is_empty() { 0.0 } else { tail_returns.iter().sum::() / tail_returns.len() as f64 } } /// Resample equity curve to target resolution fn resample_equity_curve(&self, curve: Vec) -> Vec { if curve.len() <= self.config.equity_curve_resolution { return curve; } let mut resampled = Vec::new(); let step = curve.len() / self.config.equity_curve_resolution; for i in (0..curve.len()).step_by(step) { resampled.push(curve[i].clone()); } // Always include the last point if let Some(last) = curve.last() { if resampled.last().map(|p| p.timestamp) != Some(last.timestamp) { resampled.push(last.clone()); } } resampled } } impl Default for PerformanceMetrics { fn default() -> Self { Self { total_return: 0.0, annualized_return: 0.0, sharpe_ratio: 0.0, sortino_ratio: 0.0, max_drawdown: 0.0, volatility: 0.0, win_rate: 0.0, profit_factor: 0.0, total_trades: 0, winning_trades: 0, losing_trades: 0, avg_win: 0.0, avg_loss: 0.0, largest_win: 0.0, largest_loss: 0.0, calmar_ratio: 0.0, backtest_duration_nanos: 0, beta: None, alpha: None, information_ratio: None, var_95: None, expected_shortfall: None, } } } impl From for crate::foxhunt::tli::BacktestMetrics { fn from(metrics: PerformanceMetrics) -> Self { Self { total_return: metrics.total_return, annualized_return: metrics.annualized_return, sharpe_ratio: metrics.sharpe_ratio, sortino_ratio: metrics.sortino_ratio, max_drawdown: metrics.max_drawdown, volatility: metrics.volatility, win_rate: metrics.win_rate, profit_factor: metrics.profit_factor, total_trades: metrics.total_trades, winning_trades: metrics.winning_trades, losing_trades: metrics.losing_trades, avg_win: metrics.avg_win, avg_loss: metrics.avg_loss, largest_win: metrics.largest_win, largest_loss: metrics.largest_loss, calmar_ratio: metrics.calmar_ratio, backtest_duration_nanos: metrics.backtest_duration_nanos, } } }