//! Comprehensive Backtesting Integration Test Suite //! //! This test suite validates the entire backtesting pipeline including: //! - Position tracking logic and state transitions //! - P&L calculation accuracy across different scenarios //! - Metrics calculation formulas (Sharpe, Sortino, Drawdown, etc.) //! - Edge cases and error handling //! - End-to-end integration with realistic market data //! //! Test Coverage: 50 tests across 5 modules //! - Module 1: Position Tracking (10 tests) //! - Module 2: P&L Calculation (12 tests) //! - Module 3: Metrics Calculation (15 tests) //! - Module 4: Edge Cases (8 tests) //! - Module 5: Integration (5 tests) use chrono::{DateTime, Utc}; // ============================================================================= // Test Data Structures (Mirrors wave_d_backtest.rs implementation) // ============================================================================= #[derive(Debug, Clone, Copy, PartialEq)] enum PositionState { Flat, Long, Short, } #[derive(Debug, Clone, Copy, PartialEq)] enum Action { Buy, Sell, Hold, } #[derive(Debug, Clone)] struct Position { state: PositionState, entry_price: f64, entry_time: DateTime, size: f64, } #[derive(Debug, Clone)] struct Trade { entry_time: DateTime, exit_time: DateTime, entry_price: f64, exit_price: f64, side: PositionState, // Long or Short pnl: f64, size: f64, } #[derive(Debug, Clone)] struct BacktestMetrics { total_trades: usize, winning_trades: usize, losing_trades: usize, win_rate: f64, total_pnl: f64, total_return: f64, sharpe_ratio: f64, sortino_ratio: f64, max_drawdown: f64, max_drawdown_duration: usize, calmar_ratio: f64, profit_factor: f64, avg_win: f64, avg_loss: f64, buy_and_hold_return: f64, alpha: f64, recovery_factor: f64, } // ============================================================================= // Backtesting Engine Implementation // ============================================================================= struct BacktestEngine { initial_capital: f64, commission_per_side: f64, } impl BacktestEngine { fn new(initial_capital: f64, commission_per_side: f64) -> Self { Self { initial_capital, commission_per_side, } } /// Update position state based on action fn update_position( &self, current_position: Option, action: Action, price: f64, timestamp: DateTime, size: f64, ) -> (Option, Option) { match (current_position, action) { // From flat position (None, Action::Buy) => { let new_position = Position { state: PositionState::Long, entry_price: price, entry_time: timestamp, size, }; (Some(new_position), None) }, (None, Action::Sell) => { let new_position = Position { state: PositionState::Short, entry_price: price, entry_time: timestamp, size, }; (Some(new_position), None) }, (None, Action::Hold) => (None, None), // From long position (Some(pos), Action::Sell) if pos.state == PositionState::Long => { let pnl = self.calculate_pnl(PositionState::Long, pos.entry_price, price, pos.size); let trade = Trade { entry_time: pos.entry_time, exit_time: timestamp, entry_price: pos.entry_price, exit_price: price, side: PositionState::Long, pnl, size: pos.size, }; (None, Some(trade)) }, (Some(pos), Action::Hold) if pos.state == PositionState::Long => (Some(pos), None), (Some(pos), Action::Buy) if pos.state == PositionState::Long => (Some(pos), None), // Ignore duplicate buys // From short position (Some(pos), Action::Buy) if pos.state == PositionState::Short => { let pnl = self.calculate_pnl(PositionState::Short, pos.entry_price, price, pos.size); let trade = Trade { entry_time: pos.entry_time, exit_time: timestamp, entry_price: pos.entry_price, exit_price: price, side: PositionState::Short, pnl, size: pos.size, }; (None, Some(trade)) }, (Some(pos), Action::Hold) if pos.state == PositionState::Short => (Some(pos), None), (Some(pos), Action::Sell) if pos.state == PositionState::Short => (Some(pos), None), // Ignore duplicate sells _ => unreachable!("Invalid position state transition"), } } /// Calculate P&L for a trade fn calculate_pnl(&self, side: PositionState, entry: f64, exit: f64, size: f64) -> f64 { let gross_pnl = match side { PositionState::Long => size * (exit - entry), PositionState::Short => size * (entry - exit), PositionState::Flat => 0.0, }; // Deduct commissions (entry + exit) gross_pnl - (2.0 * self.commission_per_side) } /// Calculate comprehensive backtest metrics fn calculate_metrics( &self, trades: &[Trade], equity_curve: &[f64], first_price: f64, last_price: f64, ) -> BacktestMetrics { let total_trades = trades.len(); let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count(); let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count(); let win_rate = if total_trades > 0 { winning_trades as f64 / total_trades as f64 } else { 0.0 }; let total_pnl: f64 = trades.iter().map(|t| t.pnl).sum(); let total_return = if self.initial_capital > 0.0 { total_pnl / self.initial_capital } else { 0.0 }; // Calculate returns for Sharpe/Sortino let returns: Vec = trades .iter() .map(|t| t.pnl / self.initial_capital) .collect(); let sharpe_ratio = self.calculate_sharpe(&returns); let sortino_ratio = self.calculate_sortino(&returns); let (max_drawdown, max_drawdown_duration) = self.calculate_max_drawdown(equity_curve); let calmar_ratio = if max_drawdown > 0.0 { (total_return * 100.0) / (max_drawdown * 100.0) } else { 0.0 }; 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 avg_win = if winning_trades > 0 { gross_profit / winning_trades as f64 } else { 0.0 }; let avg_loss = if losing_trades > 0 { gross_loss / losing_trades as f64 } else { 0.0 }; let buy_and_hold_return = if first_price > 0.0 { (last_price - first_price) / first_price } else { 0.0 }; let alpha = total_return - buy_and_hold_return; let recovery_factor = if max_drawdown > 0.0 { total_return / max_drawdown } else { 0.0 }; BacktestMetrics { total_trades, winning_trades, losing_trades, win_rate, total_pnl, total_return, sharpe_ratio, sortino_ratio, max_drawdown, max_drawdown_duration, calmar_ratio, profit_factor, avg_win, avg_loss, buy_and_hold_return, alpha, recovery_factor, } } fn calculate_sharpe(&self, returns: &[f64]) -> f64 { if returns.is_empty() { return 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 std_dev = variance.sqrt(); if std_dev > 0.0 { (mean_return / std_dev) * (252.0_f64).sqrt() // Annualized } else { 0.0 } } fn calculate_sortino(&self, returns: &[f64]) -> f64 { if returns.is_empty() { return 0.0; } let mean_return = returns.iter().sum::() / returns.len() as f64; // Only consider downside deviation (negative returns) let downside_returns: Vec = returns.iter().filter(|&&r| r < 0.0).copied().collect(); if downside_returns.is_empty() { return if mean_return > 0.0 { f64::INFINITY } else { 0.0 }; } let downside_variance = downside_returns.iter().map(|r| r.powi(2)).sum::() / downside_returns.len() as f64; let downside_std_dev = downside_variance.sqrt(); if downside_std_dev > 0.0 { (mean_return / downside_std_dev) * (252.0_f64).sqrt() // Annualized } else { 0.0 } } fn calculate_max_drawdown(&self, equity_curve: &[f64]) -> (f64, usize) { if equity_curve.is_empty() { return (0.0, 0); } let mut max_drawdown = 0.0; let mut max_duration = 0; let mut peak = equity_curve[0]; let mut current_duration = 0; for &equity in equity_curve { if equity > peak { peak = equity; current_duration = 0; } else { current_duration += 1; let drawdown = (peak - equity) / peak; if drawdown > max_drawdown { max_drawdown = drawdown; max_duration = current_duration; } } } (max_drawdown, max_duration) } /// Force close position at end of data fn force_close_position( &self, position: Option, price: f64, timestamp: DateTime, ) -> Option { position.map(|pos| { let pnl = self.calculate_pnl(pos.state, pos.entry_price, price, pos.size); Trade { entry_time: pos.entry_time, exit_time: timestamp, entry_price: pos.entry_price, exit_price: price, side: pos.state, pnl, size: pos.size, } }) } } // ============================================================================= // MODULE 1: Position Tracking Tests (10 tests) // ============================================================================= #[cfg(test)] mod position_tracking_tests { use super::*; fn create_timestamp(offset_secs: i64) -> DateTime { DateTime::from_timestamp(1_700_000_000 + offset_secs, 0).unwrap() } #[test] fn test_01_open_long_position_from_flat() { let engine = BacktestEngine::new(100_000.0, 2.50); let (position, trade) = engine.update_position(None, Action::Buy, 100.0, create_timestamp(0), 10.0); assert!(position.is_some()); assert!(trade.is_none()); let pos = position.unwrap(); assert_eq!(pos.state, PositionState::Long); assert_eq!(pos.entry_price, 100.0); assert_eq!(pos.size, 10.0); } #[test] fn test_02_close_long_position_on_sell() { let engine = BacktestEngine::new(100_000.0, 2.50); let initial_position = Position { state: PositionState::Long, entry_price: 100.0, entry_time: create_timestamp(0), size: 10.0, }; let (position, trade) = engine.update_position( Some(initial_position), Action::Sell, 110.0, create_timestamp(100), 10.0, ); assert!(position.is_none()); assert!(trade.is_some()); let t = trade.unwrap(); assert_eq!(t.side, PositionState::Long); assert_eq!(t.entry_price, 100.0); assert_eq!(t.exit_price, 110.0); assert_eq!(t.pnl, 10.0 * (110.0 - 100.0) - 5.0); // $95 profit } #[test] fn test_03_reverse_from_long_to_flat_on_sell() { let engine = BacktestEngine::new(100_000.0, 2.50); let long_position = Position { state: PositionState::Long, entry_price: 100.0, entry_time: create_timestamp(0), size: 10.0, }; let (position, trade) = engine.update_position( Some(long_position), Action::Sell, 95.0, create_timestamp(50), 10.0, ); assert!(position.is_none()); // Closes to flat assert!(trade.is_some()); let t = trade.unwrap(); assert_eq!(t.pnl, 10.0 * (95.0 - 100.0) - 5.0); // -$55 loss } #[test] fn test_04_reverse_from_short_to_flat_on_buy() { let engine = BacktestEngine::new(100_000.0, 2.50); let short_position = Position { state: PositionState::Short, entry_price: 110.0, entry_time: create_timestamp(0), size: 10.0, }; let (position, trade) = engine.update_position( Some(short_position), Action::Buy, 100.0, create_timestamp(50), 10.0, ); assert!(position.is_none()); // Closes to flat assert!(trade.is_some()); let t = trade.unwrap(); assert_eq!(t.side, PositionState::Short); assert_eq!(t.pnl, 10.0 * (110.0 - 100.0) - 5.0); // $95 profit } #[test] fn test_05_hold_maintains_position() { let engine = BacktestEngine::new(100_000.0, 2.50); let long_position = Position { state: PositionState::Long, entry_price: 100.0, entry_time: create_timestamp(0), size: 10.0, }; let (position, trade) = engine.update_position( Some(long_position.clone()), Action::Hold, 105.0, create_timestamp(10), 10.0, ); assert!(position.is_some()); assert!(trade.is_none()); assert_eq!(position.unwrap().state, PositionState::Long); } #[test] fn test_06_multiple_buys_dont_stack() { let engine = BacktestEngine::new(100_000.0, 2.50); let long_position = Position { state: PositionState::Long, entry_price: 100.0, entry_time: create_timestamp(0), size: 10.0, }; let (position, trade) = engine.update_position( Some(long_position.clone()), Action::Buy, 105.0, create_timestamp(10), 10.0, ); assert!(position.is_some()); assert!(trade.is_none()); // Position should remain unchanged assert_eq!(position.unwrap().entry_price, 100.0); } #[test] fn test_07_multiple_sells_dont_stack() { let engine = BacktestEngine::new(100_000.0, 2.50); let short_position = Position { state: PositionState::Short, entry_price: 110.0, entry_time: create_timestamp(0), size: 10.0, }; let (position, trade) = engine.update_position( Some(short_position.clone()), Action::Sell, 105.0, create_timestamp(10), 10.0, ); assert!(position.is_some()); assert!(trade.is_none()); assert_eq!(position.unwrap().entry_price, 110.0); } #[test] fn test_08_position_closes_at_end_of_data() { let engine = BacktestEngine::new(100_000.0, 2.50); let long_position = Position { state: PositionState::Long, entry_price: 100.0, entry_time: create_timestamp(0), size: 10.0, }; let trade = engine.force_close_position(Some(long_position), 108.0, create_timestamp(1000)); assert!(trade.is_some()); let t = trade.unwrap(); assert_eq!(t.side, PositionState::Long); assert_eq!(t.exit_price, 108.0); assert_eq!(t.pnl, 10.0 * (108.0 - 100.0) - 5.0); // $75 profit } #[test] fn test_09_empty_position_list_when_only_hold() { let engine = BacktestEngine::new(100_000.0, 2.50); let mut trades = Vec::new(); // Simulate 10 bars with only HOLD actions for i in 0..10 { let (_, trade) = engine.update_position( None, Action::Hold, 100.0 + i as f64, create_timestamp(i * 60), 10.0, ); if let Some(t) = trade { trades.push(t); } } assert_eq!(trades.len(), 0); } #[test] fn test_10_position_state_transitions_correct() { let engine = BacktestEngine::new(100_000.0, 2.50); // Flat -> Long let (pos, _) = engine.update_position(None, Action::Buy, 100.0, create_timestamp(0), 10.0); assert_eq!(pos.as_ref().unwrap().state, PositionState::Long); // Long -> Flat let (pos, _) = engine.update_position(pos, Action::Sell, 105.0, create_timestamp(10), 10.0); assert!(pos.is_none()); // Flat -> Short let (pos, _) = engine.update_position(None, Action::Sell, 105.0, create_timestamp(20), 10.0); assert_eq!(pos.as_ref().unwrap().state, PositionState::Short); // Short -> Flat let (pos, _) = engine.update_position(pos, Action::Buy, 102.0, create_timestamp(30), 10.0); assert!(pos.is_none()); } } // ============================================================================= // MODULE 2: P&L Calculation Tests (12 tests) // ============================================================================= #[cfg(test)] mod pnl_calculation_tests { use super::*; #[test] fn test_11_long_profit() { let engine = BacktestEngine::new(100_000.0, 2.50); let pnl = engine.calculate_pnl(PositionState::Long, 100.0, 110.0, 1.0); assert_eq!(pnl, 10.0 - 5.0); // $10 profit - $5 commission } #[test] fn test_12_long_loss() { let engine = BacktestEngine::new(100_000.0, 2.50); let pnl = engine.calculate_pnl(PositionState::Long, 110.0, 100.0, 1.0); assert_eq!(pnl, -10.0 - 5.0); // -$10 loss - $5 commission } #[test] fn test_13_short_profit() { let engine = BacktestEngine::new(100_000.0, 2.50); let pnl = engine.calculate_pnl(PositionState::Short, 110.0, 100.0, 1.0); assert_eq!(pnl, 10.0 - 5.0); // $10 profit - $5 commission } #[test] fn test_14_short_loss() { let engine = BacktestEngine::new(100_000.0, 2.50); let pnl = engine.calculate_pnl(PositionState::Short, 100.0, 110.0, 1.0); assert_eq!(pnl, -10.0 - 5.0); // -$10 loss - $5 commission } #[test] fn test_15_commissions_deducted() { let engine = BacktestEngine::new(100_000.0, 2.50); // Zero price movement, only commissions let pnl = engine.calculate_pnl(PositionState::Long, 100.0, 100.0, 1.0); assert_eq!(pnl, -5.0); // $2.50 entry + $2.50 exit } #[test] fn test_16_multiple_trades_accumulate_correctly() { let engine = BacktestEngine::new(100_000.0, 2.50); let mut total_pnl = 0.0; // Trade 1: Long profit total_pnl += engine.calculate_pnl(PositionState::Long, 100.0, 110.0, 1.0); // Trade 2: Short profit total_pnl += engine.calculate_pnl(PositionState::Short, 110.0, 105.0, 1.0); // Trade 3: Long loss total_pnl += engine.calculate_pnl(PositionState::Long, 105.0, 100.0, 1.0); assert_eq!(total_pnl, 5.0 + 0.0 - 10.0); // $5 - $15 commissions } #[test] fn test_17_percentage_returns_calculated_correctly() { let engine = BacktestEngine::new(100_000.0, 2.50); let pnl = engine.calculate_pnl(PositionState::Long, 100.0, 110.0, 10.0); let return_pct = pnl / engine.initial_capital; // Expected: (10 * 10 - 5) / 100000 = 95 / 100000 = 0.00095 assert!((return_pct - 0.00095).abs() < 1e-6); } #[test] fn test_18_zero_profit_trades_handled() { let engine = BacktestEngine::new(100_000.0, 2.50); let pnl = engine.calculate_pnl(PositionState::Long, 100.0, 100.0, 10.0); assert_eq!(pnl, -5.0); // Only commissions } #[test] fn test_19_very_small_price_moves() { let engine = BacktestEngine::new(100_000.0, 2.50); let pnl = engine.calculate_pnl(PositionState::Long, 100.0, 100.005, 100.0); // Profit: 100 * 0.005 = 0.50, minus commissions = -4.50 assert!((pnl - (-4.5)).abs() < 1e-6); // Use floating-point tolerance } #[test] fn test_20_large_price_moves() { let engine = BacktestEngine::new(100_000.0, 2.50); let pnl = engine.calculate_pnl(PositionState::Long, 1000.0, 2500.0, 10.0); // Profit: 10 * 1500 = 15000, minus commissions assert_eq!(pnl, 15000.0 - 5.0); } #[test] fn test_21_negative_prices_handled_gracefully() { let engine = BacktestEngine::new(100_000.0, 2.50); // Theoretical negative prices (e.g., oil futures) let pnl = engine.calculate_pnl(PositionState::Short, -10.0, -20.0, 1.0); assert_eq!(pnl, 10.0 - 5.0); // Short profits when price goes down } #[test] fn test_22_price_gaps_handled() { let engine = BacktestEngine::new(100_000.0, 2.50); // Large gap down let pnl = engine.calculate_pnl(PositionState::Long, 100.0, 50.0, 10.0); assert_eq!(pnl, -500.0 - 5.0); // -$505 total } } // ============================================================================= // MODULE 3: Metrics Calculation Tests (15 tests) // ============================================================================= #[cfg(test)] mod metrics_calculation_tests { use super::*; fn create_timestamp(offset_secs: i64) -> DateTime { DateTime::from_timestamp(1_700_000_000 + offset_secs, 0).unwrap() } #[test] fn test_23_sharpe_ratio_formula_correct() { let engine = BacktestEngine::new(100_000.0, 2.50); let returns = vec![0.01, 0.02, -0.01, 0.015, 0.005]; // Manual calculation let mean = returns.iter().sum::() / returns.len() as f64; let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::() / returns.len() as f64; let std_dev = variance.sqrt(); let expected_sharpe = (mean / std_dev) * (252.0_f64).sqrt(); let sharpe = engine.calculate_sharpe(&returns); assert!((sharpe - expected_sharpe).abs() < 1e-6); } #[test] fn test_24_sharpe_with_zero_std_dev() { let engine = BacktestEngine::new(100_000.0, 2.50); let returns = vec![0.01, 0.01, 0.01]; // No variance let sharpe = engine.calculate_sharpe(&returns); assert_eq!(sharpe, 0.0); // Should return 0 when std_dev is 0 } #[test] fn test_25_sortino_ratio_formula_correct() { let engine = BacktestEngine::new(100_000.0, 2.50); let returns = vec![0.02, -0.01, 0.015, -0.005, 0.01]; let sortino = engine.calculate_sortino(&returns); assert!(sortino.is_finite()); assert!(sortino > 0.0); // Positive mean return } #[test] fn test_26_max_drawdown_calculation_correct() { let engine = BacktestEngine::new(100_000.0, 2.50); let equity_curve = vec![100_000.0, 110_000.0, 105_000.0, 95_000.0, 100_000.0]; let (max_dd, _) = engine.calculate_max_drawdown(&equity_curve); // Peak at 110000, trough at 95000 = (110000 - 95000) / 110000 = 0.1364 assert!((max_dd - 0.1364).abs() < 0.001); } #[test] fn test_27_max_drawdown_zero_for_all_wins() { let engine = BacktestEngine::new(100_000.0, 2.50); let equity_curve = vec![100_000.0, 105_000.0, 110_000.0, 115_000.0]; let (max_dd, _) = engine.calculate_max_drawdown(&equity_curve); assert_eq!(max_dd, 0.0); } #[test] fn test_28_drawdown_duration_tracked() { let engine = BacktestEngine::new(100_000.0, 2.50); let equity_curve = vec![ 100_000.0, 110_000.0, 105_000.0, 100_000.0, 95_000.0, 100_000.0, ]; let (_, duration) = engine.calculate_max_drawdown(&equity_curve); assert!(duration > 0); } #[test] fn test_29_win_rate_equals_wins_divided_by_total() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![ Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 110.0, side: PositionState::Long, pnl: 95.0, // Win size: 10.0, }, Trade { entry_time: create_timestamp(200), exit_time: create_timestamp(300), entry_price: 110.0, exit_price: 105.0, side: PositionState::Long, pnl: -55.0, // Loss size: 10.0, }, ]; let equity = vec![100_000.0, 100_095.0, 100_040.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 105.0); assert_eq!(metrics.win_rate, 0.5); // 1 win / 2 total } #[test] fn test_30_profit_factor_calculation() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![ Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 120.0, side: PositionState::Long, pnl: 195.0, // $200 - $5 size: 10.0, }, Trade { entry_time: create_timestamp(200), exit_time: create_timestamp(300), entry_price: 110.0, exit_price: 100.0, side: PositionState::Long, pnl: -105.0, // -$100 - $5 size: 10.0, }, ]; let equity = vec![100_000.0, 100_195.0, 100_090.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 100.0); // Profit factor = 195 / 105 = 1.857 assert!((metrics.profit_factor - 1.857).abs() < 0.01); } #[test] fn test_31_profit_factor_infinity_when_no_losses() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 110.0, side: PositionState::Long, pnl: 95.0, size: 10.0, }]; let equity = vec![100_000.0, 100_095.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 110.0); assert_eq!(metrics.profit_factor, f64::INFINITY); } #[test] fn test_32_buy_and_hold_calculation_correct() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![]; let equity = vec![100_000.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 120.0); // Buy and hold: (120 - 100) / 100 = 0.20 (20%) assert_eq!(metrics.buy_and_hold_return, 0.20); } #[test] fn test_33_alpha_equals_returns_minus_buy_and_hold() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 130.0, side: PositionState::Long, pnl: 295.0, // $300 - $5 size: 10.0, }]; let equity = vec![100_000.0, 100_295.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 120.0); // Strategy return: 295 / 100000 = 0.00295 // Buy and hold: 0.20 // Alpha: 0.00295 - 0.20 = -0.19705 assert!((metrics.alpha - (-0.19705)).abs() < 0.0001); } #[test] fn test_34_calmar_ratio_calculation() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 110.0, side: PositionState::Long, pnl: 95.0, size: 10.0, }]; let equity = vec![100_000.0, 105_000.0, 102_000.0, 100_095.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 110.0); // Calmar = total_return% / max_drawdown% // If return is 0.095% and max DD is ~2.857%, Calmar ≈ 0.033 assert!(metrics.calmar_ratio > 0.0); } #[test] fn test_35_recovery_factor_calculated() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 115.0, side: PositionState::Long, pnl: 145.0, size: 10.0, }]; let equity = vec![100_000.0, 108_000.0, 105_000.0, 100_145.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 115.0); // Recovery factor = total_return / max_drawdown assert!(metrics.recovery_factor.is_finite()); } #[test] fn test_36_avg_win_loss_correct() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![ Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 120.0, side: PositionState::Long, pnl: 195.0, // Win size: 10.0, }, Trade { entry_time: create_timestamp(200), exit_time: create_timestamp(300), entry_price: 110.0, exit_price: 130.0, side: PositionState::Long, pnl: 195.0, // Win size: 10.0, }, Trade { entry_time: create_timestamp(400), exit_time: create_timestamp(500), entry_price: 120.0, exit_price: 100.0, side: PositionState::Long, pnl: -205.0, // Loss size: 10.0, }, ]; let equity = vec![100_000.0, 100_195.0, 100_390.0, 100_185.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 100.0); assert_eq!(metrics.avg_win, 195.0); assert_eq!(metrics.avg_loss, 205.0); } #[test] fn test_37_all_metrics_serialize_to_json() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![]; let equity = vec![100_000.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 100.0); // Verify all metrics are accessible assert!(metrics.sharpe_ratio.is_finite() || metrics.sharpe_ratio == 0.0); assert!( metrics.sortino_ratio.is_finite() || metrics.sortino_ratio == 0.0 || metrics.sortino_ratio.is_infinite() ); assert!(metrics.max_drawdown >= 0.0); assert!(metrics.profit_factor >= 0.0 || metrics.profit_factor.is_infinite()); assert!(metrics.win_rate >= 0.0 && metrics.win_rate <= 1.0); } } // ============================================================================= // MODULE 4: Edge Cases Tests (8 tests) // ============================================================================= #[cfg(test)] mod edge_cases_tests { use super::*; fn create_timestamp(offset_secs: i64) -> DateTime { DateTime::from_timestamp(1_700_000_000 + offset_secs, 0).unwrap() } #[test] fn test_38_single_trade() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 105.0, side: PositionState::Long, pnl: 45.0, size: 10.0, }]; let equity = vec![100_000.0, 100_045.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 105.0); assert_eq!(metrics.total_trades, 1); assert_eq!(metrics.win_rate, 1.0); } #[test] fn test_39_no_trades_all_hold() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![]; let equity = vec![100_000.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 100.0); assert_eq!(metrics.total_trades, 0); assert_eq!(metrics.win_rate, 0.0); assert_eq!(metrics.total_pnl, 0.0); } #[test] fn test_40_all_wins_100_percent_win_rate() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![ Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 105.0, side: PositionState::Long, pnl: 45.0, size: 10.0, }, Trade { entry_time: create_timestamp(200), exit_time: create_timestamp(300), entry_price: 105.0, exit_price: 110.0, side: PositionState::Long, pnl: 45.0, size: 10.0, }, ]; let equity = vec![100_000.0, 100_045.0, 100_090.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 110.0); assert_eq!(metrics.win_rate, 1.0); assert_eq!(metrics.profit_factor, f64::INFINITY); } #[test] fn test_41_all_losses_0_percent_win_rate() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![ Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 95.0, side: PositionState::Long, pnl: -55.0, size: 10.0, }, Trade { entry_time: create_timestamp(200), exit_time: create_timestamp(300), entry_price: 95.0, exit_price: 90.0, side: PositionState::Long, pnl: -55.0, size: 10.0, }, ]; let equity = vec![100_000.0, 99_945.0, 99_890.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 90.0); assert_eq!(metrics.win_rate, 0.0); assert_eq!(metrics.profit_factor, 0.0); } #[test] fn test_42_alternating_wins_losses() { let engine = BacktestEngine::new(100_000.0, 2.50); let mut trades = Vec::new(); for i in 0..10 { let pnl = if i % 2 == 0 { 45.0 } else { -55.0 }; trades.push(Trade { entry_time: create_timestamp(i * 100), exit_time: create_timestamp(i * 100 + 50), entry_price: 100.0, exit_price: if pnl > 0.0 { 105.0 } else { 95.0 }, side: PositionState::Long, pnl, size: 10.0, }); } let equity: Vec = (0..=10).map(|_| 100_000.0).collect(); // Simplified let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 100.0); assert_eq!(metrics.win_rate, 0.5); } #[test] fn test_43_very_long_hold_periods() { let engine = BacktestEngine::new(100_000.0, 2.50); let long_position = Position { state: PositionState::Long, entry_price: 100.0, entry_time: create_timestamp(0), size: 10.0, }; // Hold for 1000 bars (simulated) let trade = engine.force_close_position( Some(long_position), 120.0, create_timestamp(1000 * 60), // 1000 minutes ); assert!(trade.is_some()); let t = trade.unwrap(); assert_eq!(t.pnl, 10.0 * (120.0 - 100.0) - 5.0); } #[test] fn test_44_rapid_trading_every_bar() { let engine = BacktestEngine::new(100_000.0, 2.50); let mut trades = Vec::new(); // Simulate 100 rapid trades for i in 0..100 { let pnl = if i % 3 == 0 { 5.0 } else { -5.0 }; trades.push(Trade { entry_time: create_timestamp(i * 10), exit_time: create_timestamp(i * 10 + 5), entry_price: 100.0, exit_price: 100.0 + pnl / 10.0, side: PositionState::Long, pnl, size: 10.0, }); } let equity: Vec = (0..=100).map(|_| 100_000.0).collect(); let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 100.0); assert_eq!(metrics.total_trades, 100); } #[test] fn test_45_empty_data_array_handled() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![]; let equity = vec![]; // Should not panic with empty data let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 100.0); assert_eq!(metrics.total_trades, 0); assert_eq!(metrics.max_drawdown, 0.0); } } // ============================================================================= // MODULE 5: Integration Tests (5 tests) // ============================================================================= #[cfg(test)] mod integration_tests { use super::*; fn create_timestamp(offset_secs: i64) -> DateTime { DateTime::from_timestamp(1_700_000_000 + offset_secs, 0).unwrap() } fn create_synthetic_prices(count: usize, trend: f64) -> Vec { (0..count) .map(|i| 100.0 + (i as f64) * trend + ((i as f64 / 10.0).sin() * 2.0)) .collect() } #[test] fn test_46_full_pipeline_on_synthetic_data() { let engine = BacktestEngine::new(100_000.0, 2.50); let prices = create_synthetic_prices(100, 0.1); let mut trades = Vec::new(); let mut equity_curve = vec![100_000.0]; let mut current_capital = 100_000.0; let mut position: Option = None; // Simple strategy: Buy when price below 105, Sell when above 110 for (i, &price) in prices.iter().enumerate() { let action = if position.is_none() && price < 105.0 { Action::Buy } else if position.is_some() && price > 110.0 { Action::Sell } else { Action::Hold }; let (new_pos, trade) = engine.update_position( position, action, price, create_timestamp(i as i64 * 60), 10.0, ); position = new_pos; if let Some(t) = trade { current_capital += t.pnl; equity_curve.push(current_capital); trades.push(t); } } // Force close remaining position if let Some(final_trade) = engine.force_close_position( position, *prices.last().unwrap(), create_timestamp(prices.len() as i64 * 60), ) { current_capital += final_trade.pnl; equity_curve.push(current_capital); trades.push(final_trade); } let metrics = engine.calculate_metrics(&trades, &equity_curve, prices[0], *prices.last().unwrap()); // Validate metrics make sense assert!(metrics.total_trades > 0); assert!(metrics.win_rate >= 0.0 && metrics.win_rate <= 1.0); assert!(metrics.sharpe_ratio.is_finite()); assert!(metrics.max_drawdown >= 0.0); } #[test] fn test_47_results_match_manual_calculation() { let engine = BacktestEngine::new(100_000.0, 2.50); // Manually create 3 trades let trades = vec![ Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 110.0, side: PositionState::Long, pnl: 95.0, // 10 * 10 - 5 size: 10.0, }, Trade { entry_time: create_timestamp(200), exit_time: create_timestamp(300), entry_price: 110.0, exit_price: 105.0, side: PositionState::Long, pnl: -55.0, // 10 * -5 - 5 size: 10.0, }, Trade { entry_time: create_timestamp(400), exit_time: create_timestamp(500), entry_price: 105.0, exit_price: 115.0, side: PositionState::Long, pnl: 95.0, // 10 * 10 - 5 size: 10.0, }, ]; let equity = vec![100_000.0, 100_095.0, 100_040.0, 100_135.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 115.0); // Manual verification assert_eq!(metrics.total_trades, 3); assert_eq!(metrics.winning_trades, 2); assert_eq!(metrics.losing_trades, 1); assert_eq!(metrics.win_rate, 2.0 / 3.0); assert_eq!(metrics.total_pnl, 95.0 - 55.0 + 95.0); } #[test] fn test_48_json_output_parseable() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 105.0, side: PositionState::Long, pnl: 45.0, size: 10.0, }]; let equity = vec![100_000.0, 100_045.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 105.0); // Serialize to JSON-like format (verify all fields are serializable) let json_str = format!( r#"{{"total_trades": {}, "win_rate": {}, "sharpe_ratio": {}, "max_drawdown": {}}}"#, metrics.total_trades, metrics.win_rate, metrics.sharpe_ratio, metrics.max_drawdown ); assert!(json_str.contains("total_trades")); assert!(json_str.contains("win_rate")); } #[test] fn test_49_markdown_report_generated() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 110.0, side: PositionState::Long, pnl: 95.0, size: 10.0, }]; let equity = vec![100_000.0, 100_095.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 110.0); // Generate markdown report let report = format!( "# Backtest Results\n\n\ - Total Trades: {}\n\ - Win Rate: {:.2}%\n\ - Sharpe Ratio: {:.2}\n\ - Max Drawdown: {:.2}%\n", metrics.total_trades, metrics.win_rate * 100.0, metrics.sharpe_ratio, metrics.max_drawdown * 100.0 ); assert!(report.contains("# Backtest Results")); assert!(report.contains("Total Trades")); } #[test] fn test_50_baseline_comparison_correct() { let engine = BacktestEngine::new(100_000.0, 2.50); let trades = vec![Trade { entry_time: create_timestamp(0), exit_time: create_timestamp(100), entry_price: 100.0, exit_price: 120.0, side: PositionState::Long, pnl: 195.0, // 10 * 20 - 5 size: 10.0, }]; let equity = vec![100_000.0, 100_195.0]; let metrics = engine.calculate_metrics(&trades, &equity, 100.0, 120.0); // Buy-and-hold: (120 - 100) / 100 = 0.20 (20%) // Strategy: 195 / 100000 = 0.00195 (0.195%) // Alpha: 0.00195 - 0.20 = -0.19805 assert_eq!(metrics.buy_and_hold_return, 0.20); assert!((metrics.alpha - (-0.19805)).abs() < 0.0001); // Strategy underperformed buy-and-hold assert!(metrics.total_return < metrics.buy_and_hold_return); } }