#![allow( clippy::assertions_on_constants, clippy::assertions_on_result_states, clippy::clone_on_copy, clippy::decimal_literal_representation, clippy::doc_markdown, clippy::empty_line_after_doc_comments, clippy::field_reassign_with_default, clippy::get_unwrap, clippy::identity_op, clippy::inconsistent_digit_grouping, clippy::indexing_slicing, clippy::integer_division, clippy::len_zero, clippy::let_underscore_must_use, clippy::manual_div_ceil, clippy::manual_let_else, clippy::manual_range_contains, clippy::modulo_arithmetic, clippy::needless_range_loop, clippy::non_ascii_literal, clippy::redundant_clone, clippy::shadow_reuse, clippy::shadow_same, clippy::shadow_unrelated, clippy::single_match_else, clippy::str_to_string, clippy::string_slice, clippy::tests_outside_test_module, clippy::too_many_lines, clippy::unnecessary_wraps, clippy::unseparated_literal_suffix, clippy::use_debug, clippy::useless_vec, clippy::wildcard_enum_match_arm, clippy::else_if_without_else, clippy::expect_used, clippy::missing_const_for_fn, clippy::similar_names, clippy::type_complexity, clippy::collapsible_else_if, clippy::doc_lazy_continuation, clippy::items_after_test_module, clippy::map_clone, clippy::multiple_unsafe_ops_per_block, clippy::unwrap_or_default, clippy::assign_op_pattern, clippy::needless_borrow, clippy::println_empty_string, clippy::unnecessary_cast, clippy::used_underscore_binding, clippy::create_dir, clippy::implicit_saturating_sub, clippy::exit, clippy::expect_fun_call, clippy::too_many_arguments, clippy::unnecessary_map_or, clippy::unwrap_used, dead_code, unused_imports, unused_variables, clippy::cloned_ref_to_slice_refs, clippy::neg_multiply, clippy::while_let_loop, clippy::bool_assert_comparison, clippy::excessive_precision, clippy::trivially_copy_pass_by_ref, clippy::op_ref, clippy::redundant_closure, clippy::unnecessary_lazy_evaluations, clippy::if_then_some_else_none, clippy::unnecessary_to_owned, clippy::single_component_path_imports, )] //! Integration tests for Advanced Performance Metrics //! //! Validates: //! 1. Sortino ratio calculated correctly (downside deviation focus) //! 2. Calmar ratio calculated correctly (return / max drawdown) //! 3. VaR (95%) calculated correctly (Value at Risk) //! 4. CVaR (95%) calculated correctly (Conditional VaR / Expected Shortfall) //! 5. Composite objective uses all 4 metrics //! //! Advanced Metrics: //! - Sortino Ratio: return / downside_deviation (punishes downside volatility) //! - Calmar Ratio: annualized_return / max_drawdown (risk-adjusted) //! - VaR (95%): 5th percentile of returns (worst 5% threshold) //! - CVaR (95%): avg of returns below VaR (tail risk) use ml::MLError; use tracing::info; /// Test 1: Verify Sortino ratio calculation #[test] fn test_sortino_ratio_calculation() -> Result<(), MLError> { // Sample returns: [+2%, -1%, +3%, -0.5%, +1%] let returns = vec![0.02, -0.01, 0.03, -0.005, 0.01]; // Calculate mean return let mean_return = returns.iter().sum::() / returns.len() as f64; // Calculate downside deviation (only negative returns) let downside_returns: Vec = returns .iter() .filter(|&&r| r < 0.0) .map(|&r| r) .collect(); let downside_deviation = if !downside_returns.is_empty() { let mean_downside = downside_returns.iter().sum::() / downside_returns.len() as f64; let variance: f64 = downside_returns .iter() .map(|&r| (r - mean_downside).powi(2)) .sum::() / downside_returns.len() as f64; variance.sqrt() } else { 0.0 }; let sortino_ratio = if downside_deviation > 0.0 { mean_return / downside_deviation } else { f64::INFINITY // No downside = infinite Sortino }; info!(mean_return, downside_deviation, sortino_ratio, "Sortino ratio calculation"); assert!( mean_return > 0.0, "Mean return should be positive for this sample" ); assert!( downside_deviation > 0.0, "Downside deviation should be positive (2 negative returns)" ); assert!( sortino_ratio.is_finite() && sortino_ratio > 0.0, "Sortino ratio should be finite and positive" ); info!(sortino_ratio, "Sortino ratio calculated correctly"); Ok(()) } /// Test 2: Verify Calmar ratio calculation #[test] fn test_calmar_ratio_calculation() -> Result<(), MLError> { // Sample equity curve (cumulative returns) let equity = vec![100.0, 102.0, 101.0, 104.0, 103.0, 108.0]; // Calculate annualized return (assume 252 trading days) let initial_equity = equity[0]; let final_equity = equity[equity.len() - 1]; let total_return = (final_equity - initial_equity) / initial_equity; let periods = equity.len() - 1; let annualized_return = total_return * (252.0 / periods as f64); // Calculate maximum drawdown let mut max_equity = equity[0]; let mut max_drawdown = 0.0; for ¤t_equity in &equity { if current_equity > max_equity { max_equity = current_equity; } let drawdown = (max_equity - current_equity) / max_equity; if drawdown > max_drawdown { max_drawdown = drawdown; } } let calmar_ratio = if max_drawdown > 0.0 { annualized_return / max_drawdown } else { f64::INFINITY // No drawdown = infinite Calmar }; info!(annualized_return, max_drawdown, calmar_ratio, "Calmar ratio calculation"); assert!( total_return > 0.0, "Total return should be positive (100 → 108)" ); assert!( max_drawdown > 0.0, "Max drawdown should be positive (drawdowns exist)" ); assert!( calmar_ratio.is_finite() && calmar_ratio > 0.0, "Calmar ratio should be finite and positive" ); info!(calmar_ratio, "Calmar ratio calculated correctly"); Ok(()) } /// Test 3: Verify VaR (95%) calculation #[test] fn test_var_95_calculation() -> Result<(), MLError> { // Sample returns (sorted for percentile calculation) let mut returns = vec![ 0.05, 0.03, 0.02, 0.01, 0.00, -0.01, -0.02, -0.03, -0.04, -0.05, 0.04, 0.02, 0.01, -0.01, -0.02, -0.03, 0.03, 0.01, -0.01, -0.06, ]; returns.sort_by(|a, b| a.partial_cmp(b).unwrap()); // VaR (95%): 5th percentile (worst 5% threshold) let percentile_idx = (returns.len() as f64 * 0.05).ceil() as usize - 1; let var_95 = returns[percentile_idx.min(returns.len() - 1)]; info!(first_5 = ?&returns[..5], percentile_idx, var_95, "VaR 95% calculation"); assert!( var_95 < 0.0, "VaR (95%) should be negative (represents worst 5% loss)" ); info!(var_95, "VaR 95% calculated correctly"); Ok(()) } /// Test 4: Verify CVaR (95%) calculation #[test] fn test_cvar_95_calculation() -> Result<(), MLError> { // Sample returns (sorted for tail risk calculation) let mut returns = vec![ 0.05, 0.03, 0.02, 0.01, 0.00, -0.01, -0.02, -0.03, -0.04, -0.05, 0.04, 0.02, 0.01, -0.01, -0.02, -0.03, 0.03, 0.01, -0.01, -0.06, ]; returns.sort_by(|a, b| a.partial_cmp(b).unwrap()); // VaR (95%): 5th percentile let percentile_idx = (returns.len() as f64 * 0.05).ceil() as usize - 1; let var_95 = returns[percentile_idx.min(returns.len() - 1)]; // CVaR (95%): average of returns below VaR (expected shortfall) let tail_returns: Vec = returns.iter().filter(|&&r| r <= var_95).copied().collect(); let cvar_95 = if !tail_returns.is_empty() { tail_returns.iter().sum::() / tail_returns.len() as f64 } else { var_95 // Fallback to VaR if no tail returns }; info!(var_95, ?tail_returns, cvar_95, "CVaR 95% calculation"); assert!( cvar_95 <= var_95, "CVaR should be ≤ VaR (tail average ≤ tail threshold)" ); info!(cvar_95, "CVaR 95% calculated correctly"); Ok(()) } /// Test 5: Verify composite objective uses all 4 metrics #[test] fn test_composite_objective_all_metrics() -> Result<(), MLError> { // Calculate all 4 metrics for a sample strategy let returns = vec![0.02, -0.01, 0.03, -0.005, 0.01, 0.02, -0.02]; // 1. Sortino ratio let mean_return = returns.iter().sum::() / returns.len() as f64; let downside_returns: Vec = returns.iter().filter(|&&r| r < 0.0).copied().collect(); let downside_dev = if !downside_returns.is_empty() { let mean_down = downside_returns.iter().sum::() / downside_returns.len() as f64; let var: f64 = downside_returns .iter() .map(|&r| (r - mean_down).powi(2)) .sum::() / downside_returns.len() as f64; var.sqrt() } else { 1.0 }; let sortino = mean_return / downside_dev; // 2. Calmar ratio (simplified) let total_return = returns.iter().sum::() / returns.len() as f64; let max_drawdown = 0.02; // Assume 2% max drawdown let calmar = total_return / max_drawdown; // 3. VaR (95%) let mut sorted_returns = returns.clone(); sorted_returns.sort_by(|a, b| a.partial_cmp(b).unwrap()); let var_idx = (sorted_returns.len() as f64 * 0.05).ceil() as usize - 1; let var_95 = sorted_returns[var_idx.min(sorted_returns.len() - 1)]; // 4. CVaR (95%) let tail: Vec = sorted_returns.iter().filter(|&&r| r <= var_95).copied().collect(); let cvar_95 = tail.iter().sum::() / tail.len() as f64; // Composite objective: weighted average let weight_sortino = 0.3; let weight_calmar = 0.3; let weight_var = 0.2; let weight_cvar = 0.2; // Normalize metrics to [0, 1] range (assume Sortino/Calmar ~ 0-5, VaR/CVaR ~ -0.1 to 0) let norm_sortino = (sortino / 5.0).clamp(0.0, 1.0); let norm_calmar = (calmar / 5.0).clamp(0.0, 1.0); let norm_var = (1.0 + var_95 / 0.1).clamp(0.0, 1.0); // Higher is better (less negative) let norm_cvar = (1.0 + cvar_95 / 0.1).clamp(0.0, 1.0); let composite_objective = weight_sortino * norm_sortino + weight_calmar * norm_calmar + weight_var * norm_var + weight_cvar * norm_cvar; info!( sortino, norm_sortino, calmar, norm_calmar, var_95, norm_var, cvar_95, norm_cvar, composite_objective, "Composite objective from all 4 metrics" ); assert!( composite_objective >= 0.0 && composite_objective <= 1.0, "Composite objective should be in [0, 1], got {}", composite_objective ); info!(composite_objective, "Composite objective uses all 4 metrics"); Ok(()) }