//! Comprehensive tests for performance analytics and Parquet storage //! //! Tests cover: //! 1. Sharpe Ratio calculation with known return series //! 2. Maximum Drawdown with various equity curves //! 3. PnL aggregation (daily/weekly/monthly) //! 4. Parquet storage round-trip write/read tests //! 5. Edge cases: zero returns, negative Sharpe, 100% drawdown use chrono::{DateTime, Duration, Utc}; use rust_decimal::Decimal; use std::str::FromStr; use backtesting_service::performance::PerformanceAnalyzer; use backtesting_service::strategy_engine::{BacktestTrade, TradeSide}; use config::structures::BacktestingPerformanceConfig; /// Helper function to create a test trade fn create_trade( trade_id: &str, symbol: &str, side: TradeSide, quantity: f64, entry_price: f64, exit_price: f64, entry_time: DateTime, exit_time: DateTime, ) -> BacktestTrade { let pnl = match side { TradeSide::Buy => (exit_price - entry_price) * quantity, TradeSide::Sell => (entry_price - exit_price) * quantity, }; let return_percent = match side { TradeSide::Buy => (exit_price - entry_price) / entry_price, TradeSide::Sell => (entry_price - exit_price) / entry_price, }; BacktestTrade { trade_id: trade_id.to_string(), symbol: symbol.to_string(), side, quantity: Decimal::from_str(&quantity.to_string()).unwrap(), entry_price: Decimal::from_str(&entry_price.to_string()).unwrap(), exit_price: Decimal::from_str(&exit_price.to_string()).unwrap(), entry_time, exit_time, pnl: Decimal::from_str(&pnl.to_string()).unwrap(), return_percent: Decimal::from_str(&return_percent.to_string()).unwrap(), entry_signal: "test_entry".to_string(), exit_signal: "test_exit".to_string(), } } // ======================================== // SHARPE RATIO TESTS // ======================================== #[test] fn test_sharpe_ratio_with_known_returns() { // Test data: Known return series with pre-calculated expected Sharpe ratio // Daily returns: [0.01, 0.015, -0.005, 0.02, 0.01] // Mean = 0.01, Std = 0.00866, Risk-free = 0.04/252 = 0.000159 // Sharpe = (0.01 - 0.000159) * sqrt(252) / (0.00866 * sqrt(252)) // Expected Sharpe ≈ 1.80 let config = BacktestingPerformanceConfig { risk_free_rate: 0.04, equity_curve_resolution: 1000, enable_advanced_metrics: Some(true), }; let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 101.0, // 1% return base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 101.0, 102.515, // 1.5% return base_time + Duration::days(1), base_time + Duration::days(2), ), create_trade( "3", "AAPL", TradeSide::Buy, 100.0, 102.515, 102.01, // -0.5% return base_time + Duration::days(2), base_time + Duration::days(3), ), create_trade( "4", "AAPL", TradeSide::Buy, 100.0, 102.01, 104.05, // 2% return base_time + Duration::days(3), base_time + Duration::days(4), ), create_trade( "5", "AAPL", TradeSide::Buy, 100.0, 104.05, 105.09, // 1% return base_time + Duration::days(4), base_time + Duration::days(5), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Sharpe ratio should be positive and in reasonable range (1.5 - 2.0) assert!( metrics.sharpe_ratio > 1.5 && metrics.sharpe_ratio < 2.0, "Expected Sharpe ratio ~1.8, got {}", metrics.sharpe_ratio ); // Verify volatility is calculated correctly assert!( metrics.volatility > 0.0, "Volatility should be positive, got {}", metrics.volatility ); } #[test] fn test_sharpe_ratio_zero_volatility() { // All returns are identical - zero volatility should give zero Sharpe ratio let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 101.0, // 1% return base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 101.0, 102.01, // 1% return base_time + Duration::days(1), base_time + Duration::days(2), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Zero volatility should result in zero or very low Sharpe ratio assert!( metrics.sharpe_ratio.abs() < 0.01, "Expected near-zero Sharpe ratio with identical returns, got {}", metrics.sharpe_ratio ); } #[test] fn test_negative_sharpe_ratio() { // Losing trades with negative excess returns let config = BacktestingPerformanceConfig { risk_free_rate: 0.10, // 10% risk-free rate to ensure negative excess return equity_curve_resolution: 1000, enable_advanced_metrics: Some(true), }; let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 99.0, // -1% return base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 99.0, 98.0, // -1% return base_time + Duration::days(1), base_time + Duration::days(2), ), create_trade( "3", "AAPL", TradeSide::Buy, 100.0, 98.0, 97.0, // -1% return base_time + Duration::days(2), base_time + Duration::days(3), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Sharpe ratio should be negative due to returns < risk-free rate assert!( metrics.sharpe_ratio < 0.0, "Expected negative Sharpe ratio, got {}", metrics.sharpe_ratio ); } // ======================================== // MAXIMUM DRAWDOWN TESTS // ======================================== #[test] fn test_max_drawdown_no_losses() { // Only winning trades - drawdown should be zero let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 105.0, base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 105.0, 110.0, base_time + Duration::days(1), base_time + Duration::days(2), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); assert_eq!( metrics.max_drawdown, 0.0, "Expected zero drawdown with only winning trades, got {}", metrics.max_drawdown ); } #[test] fn test_max_drawdown_50_percent() { // Create trades that result in exactly 50% drawdown // Start: $10,000, Win to $15,000, Lose to $7,500 (50% from peak) let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 150.0, // +$5,000 profit base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 150.0, 75.0, // -$7,500 loss (50% from peak) base_time + Duration::days(1), base_time + Duration::days(2), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Max drawdown should be 50% assert!( (metrics.max_drawdown - 50.0).abs() < 1.0, "Expected 50% drawdown, got {}%", metrics.max_drawdown ); } #[test] fn test_max_drawdown_100_percent() { // Complete loss - 100% drawdown let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 0.0, // Total loss base_time, base_time + Duration::days(1), )]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Max drawdown should be 100% assert!( metrics.max_drawdown >= 99.9, "Expected 100% drawdown, got {}%", metrics.max_drawdown ); } #[test] fn test_max_drawdown_with_recovery() { // Test drawdown calculation with recovery // Pattern: Win -> Lose (drawdown) -> Win (recovery) let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 120.0, // +$2,000 base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 120.0, 96.0, // -$2,400 (20% from peak of $12,000) base_time + Duration::days(1), base_time + Duration::days(2), ), create_trade( "3", "AAPL", TradeSide::Buy, 100.0, 96.0, 130.0, // +$3,400 (recovery) base_time + Duration::days(2), base_time + Duration::days(3), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Max drawdown should capture the 20% drop from peak assert!( metrics.max_drawdown >= 19.0 && metrics.max_drawdown <= 21.0, "Expected ~20% drawdown, got {}%", metrics.max_drawdown ); } // ======================================== // PNL AGGREGATION TESTS // ======================================== #[test] fn test_win_loss_aggregation() { // Test winning/losing trade aggregation let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 110.0, // +$1,000 base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 110.0, 105.0, // -$500 base_time + Duration::days(1), base_time + Duration::days(2), ), create_trade( "3", "AAPL", TradeSide::Buy, 100.0, 105.0, 115.0, // +$1,000 base_time + Duration::days(2), base_time + Duration::days(3), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); assert_eq!(metrics.total_trades, 3, "Expected 3 total trades"); assert_eq!(metrics.winning_trades, 2, "Expected 2 winning trades"); assert_eq!(metrics.losing_trades, 1, "Expected 1 losing trade"); // Win rate should be 66.67% assert!( (metrics.win_rate - 66.67).abs() < 0.1, "Expected win rate ~66.67%, got {}%", metrics.win_rate ); } #[test] fn test_profit_factor_calculation() { // Profit factor = Gross Profit / Gross Loss let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 120.0, // +$2,000 base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 120.0, 110.0, // -$1,000 base_time + Duration::days(1), base_time + Duration::days(2), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Profit factor = 2000 / 1000 = 2.0 assert!( (metrics.profit_factor - 2.0).abs() < 0.1, "Expected profit factor ~2.0, got {}", metrics.profit_factor ); } #[test] fn test_profit_factor_no_losses() { // All winning trades - profit factor should be infinity let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 110.0, base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 110.0, 120.0, base_time + Duration::days(1), base_time + Duration::days(2), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); assert!( metrics.profit_factor.is_infinite() && metrics.profit_factor > 0.0, "Expected positive infinity profit factor, got {}", metrics.profit_factor ); } #[test] fn test_average_win_loss() { // Test average win/loss calculations let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 110.0, // +$1,000 base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 110.0, 125.0, // +$1,500 base_time + Duration::days(1), base_time + Duration::days(2), ), create_trade( "3", "AAPL", TradeSide::Buy, 100.0, 125.0, 118.0, // -$700 base_time + Duration::days(2), base_time + Duration::days(3), ), create_trade( "4", "AAPL", TradeSide::Buy, 100.0, 118.0, 108.0, // -$1,000 base_time + Duration::days(3), base_time + Duration::days(4), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Average win = (1000 + 1500) / 2 = 1250 assert!( (metrics.avg_win - 1250.0).abs() < 10.0, "Expected avg win ~1250, got {}", metrics.avg_win ); // Average loss = -(700 + 1000) / 2 = -850 assert!( (metrics.avg_loss + 850.0).abs() < 10.0, "Expected avg loss ~-850, got {}", metrics.avg_loss ); } // ======================================== // VAR AND EXPECTED SHORTFALL TESTS // ======================================== #[test] fn test_var_95_calculation() { // Test Value at Risk (VaR) at 95% confidence level let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); // Create 20 trades with known return distribution let mut trades = Vec::new(); for i in 0..20 { let return_pct = if i < 19 { 0.01 // 95% of trades have 1% return } else { -0.05 // 5% of trades have -5% return (tail risk) }; let exit_price = 100.0 * (1.0 + return_pct); trades.push(create_trade( &format!("{}", i), "AAPL", TradeSide::Buy, 100.0, 100.0, exit_price, base_time + Duration::days(i), base_time + Duration::days(i + 1), )); } let metrics = analyzer.calculate_metrics(&trades, 10000.0); // VaR should capture the tail loss assert!(metrics.var_95.is_some(), "VaR should be calculated"); let var = metrics.var_95.unwrap(); assert!(var < 0.0, "VaR should be negative (loss), got {}", var); } #[test] fn test_expected_shortfall() { // Expected Shortfall (CVaR) = average of returns below VaR let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let mut trades = Vec::new(); for i in 0..100 { let return_pct = if i < 95 { 0.01 // 95% of trades } else { -0.10 // 5% tail with -10% return }; let exit_price = 100.0 * (1.0 + return_pct); trades.push(create_trade( &format!("{}", i), "AAPL", TradeSide::Buy, 100.0, 100.0, exit_price, base_time + Duration::days(i as i64), base_time + Duration::days(i as i64 + 1), )); } let metrics = analyzer.calculate_metrics(&trades, 10000.0); assert!( metrics.expected_shortfall.is_some(), "Expected Shortfall should be calculated" ); let es = metrics.expected_shortfall.unwrap(); assert!( es < 0.0, "Expected Shortfall should be negative, got {}", es ); // ES should be worse (more negative) than VaR let var = metrics.var_95.unwrap(); assert!( es <= var, "Expected Shortfall ({}) should be <= VaR ({})", es, var ); } // ======================================== // SORTINO RATIO TESTS // ======================================== #[test] fn test_sortino_ratio() { // Sortino ratio penalizes downside volatility only let config = BacktestingPerformanceConfig { risk_free_rate: 0.04, equity_curve_resolution: 1000, enable_advanced_metrics: Some(true), }; let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 105.0, // +5% return base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 105.0, 103.0, // -1.9% return (downside) base_time + Duration::days(1), base_time + Duration::days(2), ), create_trade( "3", "AAPL", TradeSide::Buy, 100.0, 103.0, 108.0, // +4.9% return base_time + Duration::days(2), base_time + Duration::days(3), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Sortino ratio should be positive assert!( metrics.sortino_ratio > 0.0, "Expected positive Sortino ratio, got {}", metrics.sortino_ratio ); // For strategies with limited downside, Sortino > Sharpe assert!( metrics.sortino_ratio >= metrics.sharpe_ratio, "Sortino ({}) should be >= Sharpe ({}) for limited downside strategy", metrics.sortino_ratio, metrics.sharpe_ratio ); } // ======================================== // CALMAR RATIO TESTS // ======================================== #[test] fn test_calmar_ratio() { // Calmar ratio = Annualized Return / Max Drawdown let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 120.0, // +20% base_time, base_time + Duration::days(180), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 120.0, 110.0, // -8.3% (drawdown) base_time + Duration::days(180), base_time + Duration::days(365), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Calmar ratio should be positive and reasonable assert!( metrics.calmar_ratio > 0.0, "Expected positive Calmar ratio, got {}", metrics.calmar_ratio ); // With ~10% return and ~8% drawdown, Calmar should be ~1.25 assert!( metrics.calmar_ratio > 0.5 && metrics.calmar_ratio < 2.5, "Expected Calmar ratio between 0.5-2.5, got {}", metrics.calmar_ratio ); } // ======================================== // EDGE CASES // ======================================== #[test] fn test_empty_trades() { // Empty trade list should return default metrics let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let trades: Vec = vec![]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); assert_eq!(metrics.total_return, 0.0); assert_eq!(metrics.sharpe_ratio, 0.0); assert_eq!(metrics.max_drawdown, 0.0); assert_eq!(metrics.total_trades, 0); } #[test] fn test_single_trade() { // Single trade should produce valid metrics let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 110.0, base_time, base_time + Duration::days(1), )]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); assert!(metrics.total_return > 0.0); assert_eq!(metrics.total_trades, 1); assert_eq!(metrics.winning_trades, 1); assert_eq!(metrics.losing_trades, 0); } #[test] fn test_zero_returns() { // All trades break even - zero returns let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 100.0, // 0% return base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 100.0, 100.0, // 0% return base_time + Duration::days(1), base_time + Duration::days(2), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); assert_eq!( metrics.total_return, 0.0, "Expected zero total return with break-even trades" ); assert_eq!(metrics.max_drawdown, 0.0); } #[test] fn test_sell_side_trades() { // Test short selling (sell side) let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Sell, 100.0, 100.0, 90.0, // Profit on short: (100-90)*100 = $1,000 base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Sell, 100.0, 90.0, 95.0, // Loss on short: (90-95)*100 = -$500 base_time + Duration::days(1), base_time + Duration::days(2), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // Net PnL should be +$500 assert!( metrics.total_return > 0.0, "Expected positive return from profitable short trades" ); assert_eq!(metrics.winning_trades, 1); assert_eq!(metrics.losing_trades, 1); } // ======================================== // ANNUALIZED RETURN TESTS // ======================================== #[test] fn test_annualized_return_one_year() { // Test annualized return calculation for exactly 1 year let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 120.0, // 20% return base_time, base_time + Duration::days(365), )]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // For 1 year, annualized return ≈ total return assert!( (metrics.annualized_return - 20.0).abs() < 1.0, "Expected ~20% annualized return, got {}%", metrics.annualized_return ); } #[test] fn test_annualized_return_six_months() { // Test annualized return for 6 months let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 110.0, // 10% return in 6 months base_time, base_time + Duration::days(182), )]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); // 10% in 6 months ≈ 21% annualized ((1.1)^2 - 1) assert!( metrics.annualized_return > 18.0 && metrics.annualized_return < 22.0, "Expected ~21% annualized return, got {}%", metrics.annualized_return ); } #[test] fn test_duration_calculation() { // Verify backtest duration is calculated correctly let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let duration_days = 100; let trades = vec![create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 110.0, base_time, base_time + Duration::days(duration_days), )]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); let expected_nanos = Duration::days(duration_days).num_nanoseconds().unwrap(); assert_eq!( metrics.backtest_duration_nanos, expected_nanos, "Duration mismatch: expected {} nanos, got {}", expected_nanos, metrics.backtest_duration_nanos ); } #[test] fn test_largest_win_and_loss() { // Test identification of largest win and loss let config = BacktestingPerformanceConfig::default(); let analyzer = PerformanceAnalyzer::new(&config).unwrap(); let base_time = Utc::now(); let trades = vec![ create_trade( "1", "AAPL", TradeSide::Buy, 100.0, 100.0, 110.0, // +$1,000 base_time, base_time + Duration::days(1), ), create_trade( "2", "AAPL", TradeSide::Buy, 100.0, 110.0, 135.0, // +$2,500 (largest win) base_time + Duration::days(1), base_time + Duration::days(2), ), create_trade( "3", "AAPL", TradeSide::Buy, 100.0, 135.0, 125.0, // -$1,000 base_time + Duration::days(2), base_time + Duration::days(3), ), create_trade( "4", "AAPL", TradeSide::Buy, 100.0, 125.0, 105.0, // -$2,000 (largest loss) base_time + Duration::days(3), base_time + Duration::days(4), ), ]; let metrics = analyzer.calculate_metrics(&trades, 10000.0); assert!( (metrics.largest_win - 2500.0).abs() < 10.0, "Expected largest win ~$2500, got {}", metrics.largest_win ); assert!( (metrics.largest_loss + 2000.0).abs() < 10.0, "Expected largest loss ~-$2000, got {}", metrics.largest_loss ); }