#![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, )] // Test-Driven Development: P&L Calculation Bug Fix // // **Problem**: P&L values are 10,000× - 100,000× too large // - Epoch 10: -$578,040,448 loss (-5,780,405% return) // - Expected: ±$5,000 range for validation data // // **Root Cause**: max_position = 1.0 contract instead of fractional position // - ES futures price: ~$5,800 // - ES contract multiplier: 50× // - 1.0 contract = $5,800 × 50 = $290,000 notional // - For $10K capital, this is 29× leverage (CATASTROPHIC) // // **Solution**: Scale max_position to match capital // - Desired exposure: 1.0× capital ($10,000 notional) // - Fractional contracts: $10,000 / ($5,800 × 50) = 0.0345 contracts // - General formula: max_position = initial_capital / (price × contract_multiplier) use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency}; use ml::dqn::portfolio_tracker::PortfolioTracker; /// Test 1: Verify P&L calculation with realistic position sizing #[test] fn test_pnl_calculation_realistic_sizing() { // GIVEN: $10,000 initial capital, ES futures at $5,800 // NOTE: PortfolioTracker treats position_size as notional units (not contracts) // So we scale max_position to represent desired notional exposure let initial_capital = 10_000.0; let price = 5_800.0; // For 1× capital exposure: // max_position × price = initial_capital // max_position = initial_capital / price = 10000 / 5800 = 1.724 units let max_position = initial_capital / price; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0); // WHEN: Execute Long100 (full long position) let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); tracker.execute_action(action, price, max_position); // THEN: Position should be ~1.724 units (NOT 1.0 or 0.0345) let actual_position = tracker.current_position(); assert!( (actual_position - max_position).abs() < 0.0001, "Position size should be ~1.724 units, got {}", actual_position ); // Portfolio value should still be ~$10,000 (minus fees) let portfolio_value = tracker.total_value(price); assert!( (portfolio_value - initial_capital).abs() < 100.0, "Portfolio value should be ~$10,000, got ${}", portfolio_value ); } /// Test 2: Verify P&L stays within ±$5K range for 10% price move #[test] fn test_pnl_range_realistic() { // GIVEN: $10,000 initial capital, ES futures at $5,800 // NOTE: PortfolioTracker doesn't model contract multipliers - it treats position_size // as notional value directly. So we need to scale max_position accordingly. let initial_capital = 10_000.0; let price_entry = 5_800.0; // max_position should represent the number of "notional units" we want to hold // For 1× capital exposure, we want max_position such that: // position_size × price = initial_capital // So max_position = initial_capital / price let max_position = initial_capital / price_entry; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0); // WHEN: Go long at $5,800 let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); tracker.execute_action(action, price_entry, max_position); // AND: Price increases 10% to $6,380 let price_exit = price_entry * 1.10; let pnl = tracker.unrealized_pnl(price_exit); // THEN: P&L should be ~$1,000 (10% of $10K), NOT $290,000 assert!( pnl.abs() < 5_000.0, "P&L should be within ±$5K for 10% move, got ${:.2}", pnl ); // Expected P&L: max_position × (price_exit - price_entry) // = (10000/5800) × (6380 - 5800) = 1.724 × 580 = ~$1,000 let expected_pnl = max_position * (price_exit - price_entry); assert!( (pnl - expected_pnl).abs() < 100.0, "P&L should be ~${:.2}, got ${:.2}", expected_pnl, pnl ); } /// Test 3: Verify old bug (max_position = 1.0) produces WRONG P&L #[test] fn test_old_bug_wrong_pnl() { // GIVEN: $10,000 initial capital, ES futures at $5,800 let initial_capital = 10_000.0; let price_entry = 5_800.0; // OLD BUG: max_position = 1.0 (WRONG - too small) // This gives 1.0 units × $5,800 = $5,800 notional (58% capital exposure) let max_position_wrong = 1.0; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0); // WHEN: Go long at $5,800 with 1.0 unit let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); tracker.execute_action(action, price_entry, max_position_wrong); // AND: Price increases 10% to $6,380 let price_exit = price_entry * 1.10; let pnl = tracker.unrealized_pnl(price_exit); // THEN: P&L is WRONG ($580, not $1,000) // 1.0 units × (6,380 - 5,800) = $580 (should be $1,000 for 100% capital exposure) let wrong_pnl = max_position_wrong * (price_exit - price_entry); assert!( (pnl - wrong_pnl).abs() < 100.0, "Old bug should produce ~${:.2}, got ${:.2}", wrong_pnl, pnl ); // Expected with correct sizing: (10000/5800) × 580 = ~$1,000 let correct_max_position = initial_capital / price_entry; let correct_pnl = correct_max_position * (price_exit - price_entry); assert!( pnl < correct_pnl * 0.9, "Old bug P&L (${:.2}) should be <90% of correct P&L (${:.2})", pnl, correct_pnl ); } /// Test 4: Verify return percentage calculation #[test] fn test_return_percentage_scaling() { // GIVEN: $10,000 initial capital, 10% return let initial_capital = 10_000.0; let final_value = 11_000.0; // WHEN: Calculate return percentage let pnl = final_value - initial_capital; let return_pct = (pnl / initial_capital) * 100.0f32; // THEN: Should be 10.0% assert!( (return_pct - 10.0f32).abs() < 0.01, "Return should be 10%, got {:.2}%", return_pct ); } /// Test 5: Verify Short100 position sizing (symmetric to Long100) #[test] fn test_short_position_sizing() { // GIVEN: $10,000 initial capital, ES futures at $5,800 let initial_capital = 10_000.0; let price = 5_800.0; let max_position = initial_capital / price; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0); // WHEN: Execute Short100 (full short position) let action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal); tracker.execute_action(action, price, max_position); // THEN: Position should be -1.724 units (negative for short) let actual_position = tracker.current_position(); assert!( (actual_position + max_position).abs() < 0.0001, "Short position should be ~-1.724 units, got {}", actual_position ); // Portfolio value should still be ~$10,000 (cash increased, but position liability offsets) let portfolio_value = tracker.total_value(price); assert!( (portfolio_value - initial_capital).abs() < 100.0, "Portfolio value should be ~$10,000 after short, got ${}", portfolio_value ); } /// Test 6: Verify $100K initial capital (matches DQN trainer) #[test] fn test_trainer_initial_capital() { // GIVEN: $100K initial capital (same as DQN trainer line 535) let initial_capital = 100_000.0; let price = 5_800.0; let max_position = initial_capital / price; // Should be ~17.24 units let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0); // WHEN: Execute Long100 at $5,800 let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); tracker.execute_action(action, price, max_position); // THEN: Position should be ~17.24 units let actual_position = tracker.current_position(); assert!( (actual_position - max_position).abs() < 0.01, "Position size should be ~17.24 units, got {}", actual_position ); // Portfolio value should still be ~$100K let portfolio_value = tracker.total_value(price); assert!( (portfolio_value - initial_capital).abs() < 1000.0, "Portfolio value should be ~$100K, got ${}", portfolio_value ); // 10% price move should produce ~$10K P&L (10% of capital) let price_exit = price * 1.10; let pnl = tracker.unrealized_pnl(price_exit); let expected_pnl = 10_000.0; // 10% of $100K assert!( (pnl - expected_pnl).abs() < 1000.0, "P&L should be ~$10K for 10% move, got ${:.2}", pnl ); } /// Test 7: Verify last_price is updated after execute_action #[test] fn test_last_price_updated() { // GIVEN: Fresh portfolio tracker let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0); // WHEN: Execute action at $5,800 let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); let price = 5_800.0; let max_position = 100_000.0 / price; tracker.execute_action(action, price, max_position); // THEN: unrealized_pnl_cached() should work (not NaN) let pnl = tracker.unrealized_pnl_cached(); assert!( !pnl.is_nan(), "P&L should not be NaN after execute_action, got {}", pnl ); // P&L should be 0.0 (no price change yet) assert!( pnl.abs() < 0.01, "P&L should be ~0.0 (no price change), got ${:.2}", pnl ); } /// Test 8: Integration test - Full training epoch P&L range #[test] fn test_epoch_pnl_range() { // GIVEN: Realistic ES futures price range ($5,700 - $5,900) let initial_capital = 10_000.0; let price_min = 5_700.0; let price_max = 5_900.0; let price_avg = (price_min + price_max) / 2.0; // Calculate max_position at average price (dynamic sizing) // This will be recalculated per-trade in real implementation let max_position = initial_capital / price_avg; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0); // Simulate 10 trades across price range let actions = vec![ FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal), FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal), FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal), FactoredAction::new(ExposureLevel::Short50, OrderType::Market, Urgency::Normal), FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal), FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal), FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal), FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal), FactoredAction::new(ExposureLevel::Short50, OrderType::Market, Urgency::Normal), FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal), ]; let prices = vec![ 5700.0, 5720.0, 5750.0, 5780.0, 5800.0, 5820.0, 5850.0, 5870.0, 5890.0, 5900.0, ]; // Execute all trades (in real implementation, max_position would be recalculated per trade) for (action, &price) in actions.iter().zip(prices.iter()) { tracker.execute_action(*action, price, max_position); } // THEN: Final P&L should be within ±$10K (realistic for validation data) let final_pnl = tracker.unrealized_pnl(prices[prices.len() - 1]); assert!( final_pnl.abs() < 10_000.0, "Epoch P&L should be within ±$10K, got ${:.2}", final_pnl ); }