#![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, )] //! Bug #3: Net P&L Regression Tests //! //! Tests that evaluation engine calculates NET P&L (after transaction costs), //! not GROSS P&L. Ensures all metrics (Sharpe, win rate, returns) are accurate. use ml::evaluation::engine::{Action, EvaluationEngine, PositionDirection}; use ml::evaluation::metrics::{OHLCVBarF32, PerformanceMetrics}; /// Test that Trade struct includes transaction cost fields #[test] fn test_trade_struct_has_cost_fields() { // Create test bars let bars = create_test_bars(); // Create engine and execute a trade let mut engine = EvaluationEngine::new(10000.0); // Open long position at bar 0 (price 100.0) engine.process_bar(0, &bars[0], Action::Buy); assert!(engine.current_position.is_some()); // Close position at bar 1 (price 100.5) engine.process_bar(1, &bars[1], Action::Sell); assert_eq!(engine.trades.len(), 1); let trade = &engine.trades[0]; // Verify Trade struct has the required fields assert_eq!(trade.entry_price, 100.0); assert_eq!(trade.exit_price, 100.5); // Calculate expected values let gross_pnl = 0.5; // exit - entry // Assume market orders (0.15% each) let entry_cost = 100.0 * 0.0015; // 0.15 let exit_cost = 100.5 * 0.0015; // 0.15075 let total_cost = entry_cost + exit_cost; // 0.30075 let expected_net_pnl = gross_pnl - total_cost; // 0.5 - 0.30075 = 0.19925 // Verify trade.pnl is NET (after costs), not GROSS // Allow small floating point error assert!( (trade.pnl - expected_net_pnl).abs() < 0.001, "Trade.pnl should be NET P&L ({}), got {}. Gross would be {}", expected_net_pnl, trade.pnl, gross_pnl ); // Verify trade is still profitable after costs assert!(trade.pnl > 0.0, "Trade should be profitable after costs"); } /// Test that small profit becomes loss after transaction costs #[test] fn test_losing_trade_after_costs() { let bars = vec![ OHLCVBarF32 { timestamp: 1000, open: 100.0, high: 100.0, low: 100.0, close: 100.0, volume: 1000.0, }, OHLCVBarF32 { timestamp: 2000, open: 100.2, high: 100.2, low: 100.2, close: 100.2, // Only 0.2% profit volume: 1000.0, }, ]; let mut engine = EvaluationEngine::new(10000.0); // Open long at 100.0 engine.process_bar(0, &bars[0], Action::Buy); // Close at 100.2 (0.2% gross profit) engine.process_bar(1, &bars[1], Action::Sell); assert_eq!(engine.trades.len(), 1); let trade = &engine.trades[0]; // Gross profit: 0.2 let gross_pnl = 0.2; // Transaction costs: 0.15% entry + 0.15% exit = 0.30% let entry_cost = 100.0 * 0.0015; // 0.15 let exit_cost = 100.2 * 0.0015; // 0.1503 let total_cost = entry_cost + exit_cost; // 0.3003 let expected_net_pnl = gross_pnl - total_cost; // 0.2 - 0.3003 = -0.1003 (LOSS!) // Verify trade is a LOSS after costs assert!( (trade.pnl - expected_net_pnl).abs() < 0.001, "Trade should be a loss after costs ({}), got {}", expected_net_pnl, trade.pnl ); assert!(trade.pnl < 0.0, "Trade should be a loss after costs (gross profit too small)"); } /// Test that performance metrics use net P&L #[test] fn test_metrics_use_net_pnl() { let bars = create_test_bars(); let mut engine = EvaluationEngine::new(10000.0); // Execute a single trade (gross profit 0.5) engine.process_bar(0, &bars[0], Action::Buy); engine.process_bar(1, &bars[1], Action::Sell); let metrics = PerformanceMetrics::from_trades(&engine.trades, 10000.0, &bars); // Calculate expected net P&L let gross_pnl = 0.5; let total_cost = 100.0 * 0.0015 + 100.5 * 0.0015; // 0.30075 let expected_net_pnl = gross_pnl - total_cost; // 0.19925 // Verify metrics use net P&L let expected_return = (expected_net_pnl / 10000.0) * 100.0; // 0.0019925% assert!( (metrics.total_return_pct - expected_return).abs() < 0.0001, "Total return should be based on NET P&L ({}%), got {}%", expected_return, metrics.total_return_pct ); let expected_final_equity = 10000.0 + expected_net_pnl; // 10000.19925 assert!( (metrics.final_equity - expected_final_equity).abs() < 0.001, "Final equity should be based on NET P&L ({}), got {}", expected_final_equity, metrics.final_equity ); } /// Test that win rate calculation uses net P&L #[test] fn test_win_rate_uses_net_pnl() { let bars = vec![ // Trade 1: Large profit (0.8) - still profitable after costs OHLCVBarF32 { timestamp: 1000, open: 100.0, high: 100.0, low: 100.0, close: 100.0, volume: 1000.0 }, OHLCVBarF32 { timestamp: 2000, open: 100.8, high: 100.8, low: 100.8, close: 100.8, volume: 1000.0 }, // Hold to avoid opening new position OHLCVBarF32 { timestamp: 3000, open: 100.8, high: 100.8, low: 100.8, close: 100.8, volume: 1000.0 }, // Trade 2: Small profit (0.2) - becomes loss after costs OHLCVBarF32 { timestamp: 4000, open: 100.8, high: 100.8, low: 100.8, close: 100.8, volume: 1000.0 }, OHLCVBarF32 { timestamp: 5000, open: 101.0, high: 101.0, low: 101.0, close: 101.0, volume: 1000.0 }, ]; let mut engine = EvaluationEngine::new(10000.0); // Trade 1: Buy at 100.0, Sell at 100.8 (gross profit 0.8) engine.process_bar(0, &bars[0], Action::Buy); engine.process_bar(1, &bars[1], Action::Sell); // This closes long AND opens short // Hold to close the short position first engine.process_bar(2, &bars[2], Action::Buy); // This closes short AND opens long // Trade 2: Already have long from previous Buy, now hold then sell engine.process_bar(3, &bars[3], Action::Hold); // Hold the long position engine.process_bar(4, &bars[4], Action::Sell); // Close long at 101.0 AND open short // We should have 3 trades total: // 1. Long 100.0->100.8 (win) // 2. Short 100.8->100.8 (break-even, but costs make it a loss) // 3. Long 100.8->101.0 (small profit, but costs make it a loss) assert_eq!(engine.trades.len(), 3); // Trade 1: Long 100.0->100.8, net = 0.8 - (100.0*0.0015 + 100.8*0.0015) = 0.4988 (WIN) assert!(engine.trades[0].pnl > 0.0, "Trade 1 should be a win, got {}", engine.trades[0].pnl); // Trade 2: Short 100.8->100.8, gross = 0.0, costs = 0.3012, net = -0.3012 (LOSS) assert!(engine.trades[1].pnl < 0.0, "Trade 2 should be a loss after costs, got {}", engine.trades[1].pnl); // Trade 3: Long 100.8->101.0, gross = 0.2, costs = 0.3027, net = -0.1027 (LOSS) assert!(engine.trades[2].pnl < 0.0, "Trade 3 should be a loss after costs, got {}", engine.trades[2].pnl); let metrics = PerformanceMetrics::from_trades(&engine.trades, 10000.0, &bars); // Win rate should be 33.3% (1 win, 2 losses), NOT 66.7% or 100% assert!( (metrics.win_rate - 33.333).abs() < 1.0, "Win rate should be ~33% (1 net win, 2 net losses), got {}%", metrics.win_rate ); } /// Test short position with transaction costs #[test] fn test_short_position_transaction_costs() { let bars = vec![ OHLCVBarF32 { timestamp: 1000, open: 100.0, high: 100.0, low: 100.0, close: 100.0, volume: 1000.0, }, OHLCVBarF32 { timestamp: 2000, open: 99.5, high: 99.5, low: 99.5, close: 99.5, // Price dropped 0.5 volume: 1000.0, }, ]; let mut engine = EvaluationEngine::new(10000.0); // Open short at 100.0 engine.process_bar(0, &bars[0], Action::Sell); assert!(engine.current_position.is_some()); if let Some(pos) = &engine.current_position { assert_eq!(pos.direction, PositionDirection::Short); } // Close short at 99.5 (gross profit: 100.0 - 99.5 = 0.5) engine.process_bar(1, &bars[1], Action::Buy); assert_eq!(engine.trades.len(), 1); let trade = &engine.trades[0]; // Gross profit: 0.5 let gross_pnl = 0.5; // Transaction costs let entry_cost = 100.0 * 0.0015; // 0.15 let exit_cost = 99.5 * 0.0015; // 0.14925 let total_cost = entry_cost + exit_cost; // 0.29925 let expected_net_pnl = gross_pnl - total_cost; // 0.5 - 0.29925 = 0.20075 assert!( (trade.pnl - expected_net_pnl).abs() < 0.001, "Short trade net P&L should be {} (gross {} - costs {}), got {}", expected_net_pnl, gross_pnl, total_cost, trade.pnl ); } /// Test Kelly fraction with transaction costs #[test] fn test_kelly_fraction_with_costs() { let bars = create_test_bars(); // Kelly fraction = 0.5 (half position) let mut engine = EvaluationEngine::new_with_kelly(10000.0, 0.5); engine.process_bar(0, &bars[0], Action::Buy); engine.process_bar(1, &bars[1], Action::Sell); assert_eq!(engine.trades.len(), 1); let trade = &engine.trades[0]; // Base gross P&L (full position): 0.5 // Kelly-scaled gross P&L: 0.5 * 0.5 = 0.25 let kelly_scaled_gross = 0.5 * 0.5; // Transaction costs (FULL position value, not scaled) // Note: Costs are based on entry/exit prices, not scaled by Kelly let entry_cost = 100.0 * 0.5 * 0.0015; // 0.075 (half position) let exit_cost = 100.5 * 0.5 * 0.0015; // 0.075375 (half position) let total_cost = entry_cost + exit_cost; // 0.150375 let expected_net_pnl = kelly_scaled_gross - total_cost; // 0.25 - 0.150375 = 0.099625 assert!( (trade.pnl - expected_net_pnl).abs() < 0.001, "Kelly-scaled trade should have net P&L {} (gross {} - costs {}), got {}", expected_net_pnl, kelly_scaled_gross, total_cost, trade.pnl ); } /// Test that zero profit after costs is counted as loss #[test] fn test_zero_profit_after_costs() { // Set up a trade that exactly breaks even after costs // Gross profit = transaction costs let bars = vec![ OHLCVBarF32 { timestamp: 1000, open: 100.0, high: 100.0, low: 100.0, close: 100.0, volume: 1000.0, }, OHLCVBarF32 { timestamp: 2000, open: 100.3, high: 100.3, low: 100.3, close: 100.3, // Gross profit ~0.30 ≈ costs volume: 1000.0, }, ]; let mut engine = EvaluationEngine::new(10000.0); engine.process_bar(0, &bars[0], Action::Buy); engine.process_bar(1, &bars[1], Action::Sell); let trade = &engine.trades[0]; // This trade should break even or have tiny profit/loss assert!( trade.pnl.abs() < 0.05, "Trade should be near break-even after costs, got {}", trade.pnl ); } // Helper function to create standard test bars fn create_test_bars() -> Vec { vec![ OHLCVBarF32 { timestamp: 1000, open: 100.0, high: 100.0, low: 100.0, close: 100.0, volume: 1000.0, }, OHLCVBarF32 { timestamp: 2000, open: 100.5, high: 100.5, low: 100.5, close: 100.5, volume: 1000.0, }, ] }