//! 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::Long100, 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)"); }