#![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, )] //! Unit tests for PortfolioTracker epoch reset functionality //! //! These tests validate that portfolio state is correctly reset between epochs //! to prevent P&L contamination across training epochs. use ml::dqn::portfolio_tracker::PortfolioTracker; use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency}; use ml::dqn::agent::TradingAction; #[test] fn test_portfolio_tracker_resets_between_epochs() { // Given: PortfolioTracker with modified state after some trading activity let initial_capital = 100_000.0; let avg_spread = 0.0001; let mut tracker = PortfolioTracker::new(initial_capital, avg_spread, 1.0); // Simulate epoch 1 trading activity tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0); // Verify state changed assert_eq!(tracker.current_position(), 10.0); assert_eq!(tracker.cash_balance(), 99_000.0); // 100_000 - (10 * 100) assert_eq!(tracker.average_entry_price(), 100.0); // When: Reset is called (simulating epoch boundary) tracker.reset(); // Then: State should be back to initial values assert_eq!(tracker.cash_balance(), initial_capital, "Cash should reset to initial capital"); assert_eq!(tracker.current_position(), 0.0, "Position should reset to 0 (flat)"); assert_eq!(tracker.average_entry_price(), 0.0, "Entry price should reset to 0"); assert_eq!(tracker.realized_pnl(), 0.0, "Realized P&L should reset to 0"); assert_eq!(tracker.unrealized_pnl(100.0), 0.0, "Unrealized P&L should reset to 0"); } #[test] fn test_epoch_reset_prevents_pnl_contamination() { // Given: PortfolioTracker with trading activity in epoch 1 let initial_capital = 100_000.0; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0); // Epoch 1: Buy at 100, sell at 110 (profit = $100) tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0); tracker.execute_legacy_action(TradingAction::Sell, 110.0, 10.0); let epoch1_pnl = tracker.realized_pnl(); assert_eq!(epoch1_pnl, 100.0, "Epoch 1 should have $100 profit"); assert_eq!(tracker.cash_balance(), 100_100.0, "Cash should be $100,100 after profit"); // When: Reset for epoch 2 tracker.reset(); // Then: Epoch 2 should start from clean slate let epoch2_initial_pnl = tracker.realized_pnl(); assert_eq!(epoch2_initial_pnl, 0.0, "Epoch 2 P&L should start at $0, not contaminated from epoch 1"); // Epoch 2: Independent trading activity tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0); let epoch2_unrealized = tracker.unrealized_pnl(95.0); // Price drops to 95 // Unrealized P&L should be -$50 (10 contracts × -$5), not affected by epoch 1 assert_eq!(epoch2_unrealized, -50.0, "Epoch 2 unrealized P&L should be independent"); } #[test] fn test_reset_clears_all_state_fields() { // Given: PortfolioTracker with complex state let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0); // Create complex state tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0); tracker.execute_legacy_action(TradingAction::Sell, 110.0, 10.0); tracker.execute_legacy_action(TradingAction::Sell, 105.0, 5.0); // Verify state is non-trivial assert_ne!(tracker.cash_balance(), 100_000.0); assert_ne!(tracker.current_position(), 0.0); // When: Reset tracker.reset(); // Then: All fields should be reset assert_eq!(tracker.cash_balance(), 100_000.0); assert_eq!(tracker.current_position(), 0.0); assert_eq!(tracker.average_entry_price(), 0.0); // Verify parameter-less methods also return 0 assert_eq!(tracker.total_value_cached(), 100_000.0); assert_eq!(tracker.unrealized_pnl_cached(), 0.0); } #[test] fn test_reset_with_factored_actions() { // Given: PortfolioTracker using new factored action space let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0); // Execute factored action (Long100 = full long exposure) let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal); tracker.execute_action(action, 100.0, 100.0); // max_position = 100 contracts // Verify position opened assert_eq!(tracker.current_position(), 100.0); assert_ne!(tracker.cash_balance(), 100_000.0); // When: Reset tracker.reset(); // Then: State should be clean for next epoch assert_eq!(tracker.cash_balance(), 100_000.0); assert_eq!(tracker.current_position(), 0.0); assert_eq!(tracker.average_entry_price(), 0.0); } #[test] fn test_reset_preserves_initial_capital_and_spread() { // Given: PortfolioTracker with specific initial values let initial_capital = 250_000.0; let avg_spread = 0.0005; let mut tracker = PortfolioTracker::new(initial_capital, avg_spread, 1.0); // Modify state tracker.execute_legacy_action(TradingAction::Buy, 100.0, 50.0); // When: Reset tracker.reset(); // Then: Initial capital and spread should be preserved assert_eq!(tracker.cash_balance(), initial_capital, "Initial capital should be preserved"); // Verify spread is still correct via features let features = tracker.get_portfolio_features(100.0); assert_eq!(features[2], avg_spread, "Spread should be preserved"); } #[test] fn test_multiple_resets_idempotent() { // Given: PortfolioTracker let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0); // Modify state tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0); // When: Multiple resets tracker.reset(); tracker.reset(); tracker.reset(); // Then: State should be same after each reset (idempotent) assert_eq!(tracker.cash_balance(), 100_000.0); assert_eq!(tracker.current_position(), 0.0); assert_eq!(tracker.average_entry_price(), 0.0); } #[test] fn test_reset_allows_fresh_trading_in_next_epoch() { // Given: PortfolioTracker after epoch 1 let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0); // Epoch 1: End with long position tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0); // When: Reset for epoch 2 tracker.reset(); // Then: Can open new position without interference from epoch 1 tracker.execute_legacy_action(TradingAction::Sell, 100.0, 10.0); // Short position assert_eq!(tracker.current_position(), -10.0, "Should be able to open short position"); assert_eq!(tracker.average_entry_price(), 100.0, "Entry price should be for new position"); assert_eq!(tracker.cash_balance(), 101_000.0, "Cash should reflect short opening (100k + 10*100)"); }