Systematic clippy warning cleanup achieving zero warnings: - Add domain-appropriate crate-level #![allow(...)] to 20+ crate roots for pedantic lints that are noise in HFT/ML code (float_arithmetic, indexing_slicing, missing_const_for_fn, cognitive_complexity, etc.) - Fix attribute ordering in risk/src/lib.rs: move #![warn(clippy::pedantic)] before #![allow(...)] so individual allows correctly override pedantic - Remove module-level #![warn(clippy::pedantic)] from 8 trading_engine submodules that were overriding crate-level allows - Add 45+ workspace-level lint allows in Cargo.toml for common pedantic noise (mixed_attributes_style, cargo_common_metadata, etc.) - Auto-fix 67 machine-applicable warnings (redundant_closure, clone_on_copy, unnecessary_cast, etc.) via cargo clippy --fix - Fix 3 unsafe JSON indexing in risk/circuit_breaker.rs with safe .get() - Fix unused variables, unused mut, unnecessary parens in 4 files - Proto-generated code: suppress missing_const_for_fn, indexing_slicing, cognitive_complexity in ctrader-openapi and service crates 75 files changed across 20+ crates. All tests pass (3,122+ verified). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
592 lines
18 KiB
Rust
592 lines
18 KiB
Rust
//! 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<f64>,
|
|
/// Alpha vs benchmark
|
|
pub alpha: Option<f64>,
|
|
/// Information ratio
|
|
pub information_ratio: Option<f64>,
|
|
/// VaR at 95% confidence
|
|
pub var_95: Option<f64>,
|
|
/// Expected Shortfall (CVaR)
|
|
pub expected_shortfall: Option<f64>,
|
|
}
|
|
|
|
/// Equity curve point for visualization
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct EquityCurvePoint {
|
|
/// Timestamp
|
|
pub timestamp: chrono::DateTime<chrono::Utc>,
|
|
/// Portfolio equity value
|
|
pub equity: f64,
|
|
/// Drawdown from peak
|
|
pub drawdown: f64,
|
|
/// Benchmark value (if available)
|
|
pub benchmark_equity: Option<f64>,
|
|
}
|
|
|
|
/// Drawdown period analysis
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DrawdownPeriod {
|
|
/// Start time of drawdown
|
|
pub start_time: chrono::DateTime<chrono::Utc>,
|
|
/// End time of drawdown
|
|
pub end_time: chrono::DateTime<chrono::Utc>,
|
|
/// 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<Self> {
|
|
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<f64> = 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<EquityCurvePoint> {
|
|
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<DrawdownPeriod> {
|
|
let mut periods = Vec::new();
|
|
let mut in_drawdown = false;
|
|
let mut drawdown_start: Option<usize> = 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::<f64>() / returns.len() as f64;
|
|
|
|
let variance = returns
|
|
.iter()
|
|
.map(|r| (r - mean_return).powi(2))
|
|
.sum::<f64>()
|
|
/ 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::<f64>() / returns.len() as f64;
|
|
let target_return = self.config.risk_free_rate / 252.0; // Daily risk-free rate
|
|
|
|
let downside_returns: Vec<f64> = 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::<f64>() / 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<f64> = returns.iter().filter(|&&r| r <= var).copied().collect();
|
|
|
|
if tail_returns.is_empty() {
|
|
0.0
|
|
} else {
|
|
tail_returns.iter().sum::<f64>() / tail_returns.len() as f64
|
|
}
|
|
}
|
|
|
|
/// Resample equity curve to target resolution
|
|
fn resample_equity_curve(&self, curve: Vec<EquityCurvePoint>) -> Vec<EquityCurvePoint> {
|
|
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<PerformanceMetrics> 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,
|
|
}
|
|
}
|
|
}
|