#![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, )] //! Portfolio Features Integration Tests (TDD Approach) //! //! These tests verify that portfolio features are correctly integrated into //! the DQN training pipeline, from PortfolioTracker -> TradingState -> RewardFunction. //! //! Test Strategy: //! - Test 1-2: Verify portfolio features are populated in TradingState //! - Test 3-4: Verify P&L rewards are calculated correctly //! - Test 5-6: Verify portfolio tracking across different actions //! - Test 7-8: Verify edge cases (zero position, negative P&L, large positions) //! - Test 9-10: Integration tests with batch processing use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency}; use ml::dqn::agent::TradingState; use ml::dqn::portfolio_tracker::PortfolioTracker; use ml::dqn::reward::{RewardConfig, RewardFunction}; use tracing::info; // Helper functions for consistent 3-action semantics in tests fn buy_action() -> FactoredAction { FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal) } fn sell_action() -> FactoredAction { FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal) } fn hold_action() -> FactoredAction { FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal) } /// Helper function for approximate equality checks with transaction costs /// Tolerance of 0.2% (20 basis points) to account for: /// - Transaction costs: 0.05-0.15% per trade /// - Slippage and fees in HFT environments /// - Floating point precision errors fn assert_approx_eq(actual: f32, expected: f32, context: &str) { let tolerance = expected.abs() * 0.002; // 0.2% tolerance let diff = (actual - expected).abs(); assert!( diff < tolerance, "{}: Expected {:.2}, got {:.2} (diff: {:.2}, tolerance: {:.2})", context, expected, actual, diff, tolerance ); } // ============================================================================ // Test 1: Portfolio Features Populated in TradingState // ============================================================================ /// Test that portfolio features are correctly populated in TradingState /// from PortfolioTracker.get_portfolio_features() #[test] fn test_portfolio_features_populated() -> anyhow::Result<()> { // Setup: Create portfolio tracker with known state let tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0); let current_price = 100.0; // Execute: Get portfolio features let features = tracker.get_raw_portfolio_features(current_price); // Verify: Features array has expected structure [value, position, spread] assert_eq!( features.len(), 3, "Portfolio features should have 3 elements" ); assert_eq!( features[0], 10_000.0, "Portfolio value should equal cash when no position" ); assert_eq!(features[1], 0.0, "Position size should be 0 initially"); assert_eq!(features[2], 0.0001, "Spread should match initialization"); // Test with active position (long) let mut tracker_long = PortfolioTracker::new(10_000.0, 0.0001, 0.0); tracker_long.execute_action(buy_action(), 100.0, 10.0); let features_long = tracker_long.get_raw_portfolio_features(110.0); assert_approx_eq( features_long[0], 10_100.0, "Portfolio value = cash + position_value = 9000 + (10*110) = 10100" ); assert_eq!( features_long[1], 10.0, "Position size should be 10.0 (long)" ); // Test with active position (short) let mut tracker_short = PortfolioTracker::new(10_000.0, 0.0001, 0.0); tracker_short.execute_action(sell_action(), 100.0, 10.0); let features_short = tracker_short.get_raw_portfolio_features(90.0); assert_approx_eq( features_short[0], 10_100.0, "Portfolio value = cash + position_value = 11000 + (-10*90) = 10100" ); assert_eq!( features_short[1], -10.0, "Position size should be -10.0 (short)" ); Ok(()) } // ============================================================================ // Test 2: Portfolio Features Dimension in TradingState // ============================================================================ /// Test that TradingState correctly includes portfolio features in its dimension /// calculation. The state should have 128 dimensions, not 125 (after adding /// 3 portfolio features to the existing 125 features). #[test] fn test_portfolio_features_dimension() -> anyhow::Result<()> { // Setup: Create TradingState with portfolio features let state = TradingState::from_normalized( vec![0.0; 16], // 16 price features vec![0.0; 16], // 16 technical indicators vec![0.0; 16], // 16 market features vec![0.0; 3], // 3 portfolio features (value, position, spread, vec![]) vec![], // 0 regime features (legacy test) ); // Verify: Dimension should be 16 + 16 + 16 + 3 = 51 assert_eq!( state.dimension(), 51, "State dimension should include all feature groups" ); // Verify: to_vector() produces correct length let vec = state.to_vector(); assert_eq!( vec.len(), 51, "State vector should have 51 elements (16+16+16+3)" ); // Verify: Portfolio features are at the end of the vector assert_eq!( vec[48], 0.0, "Portfolio feature 0 (value) should be at index 48" ); assert_eq!( vec[49], 0.0, "Portfolio feature 1 (position) should be at index 49" ); assert_eq!( vec[50], 0.0, "Portfolio feature 2 (spread) should be at index 50" ); Ok(()) } // ============================================================================ // Test 3: P&L Reward Non-Zero for Profitable Trades // ============================================================================ /// Test that P&L rewards are calculated correctly for profitable trades. /// This verifies Bug #2 fix (portfolio features populated). #[test] fn test_pnl_reward_nonzero() -> anyhow::Result<()> { // Setup: Create reward function with default config let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config)?; // Create current state (no position) let current_state = TradingState::from_normalized( vec![0.0; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], // spread at index 0 vec![1.0, 0.0, 0.0001], // portfolio: normalized value=1.0 (10000, vec![]), position=0, spread=0.0001 vec![], // 0 regime features (legacy test) ); // Create next state (profitable BUY position) // Portfolio value increased from 10000 to 10100 (1% gain) let next_state = TradingState::from_normalized( vec![0.01; 16], // log return = 0.01 vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.01, 0.1, 0.0001], // portfolio: value=1.01 (10100), position=0.1 (10/100 normalized), spread=0.0001 vec![], // 0 regime features (legacy test) ); // Execute: Calculate reward for BUY action // Provide diverse recent actions to avoid diversity penalty (entropy threshold = 0.5) let recent_actions = vec![buy_action(), hold_action(), sell_action()]; let reward = reward_fn.calculate_reward(buy_action(), ¤t_state, &next_state, &recent_actions)?; // Verify: Reward should be positive for profitable trade // Note: Reward is clamped to [-1.0, 1.0] range let reward_f64: f64 = reward.try_into().unwrap(); assert!( reward_f64 > 0.0, "Reward should be positive for profitable BUY trade, got {}", reward_f64 ); // Test losing trade let next_state_loss = TradingState::from_normalized( vec![-0.01; 16], // log return = -0.01 vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![0.99, 0.1, 0.0001], // portfolio: value=0.99 (9900), position=10, spread=0.0001 vec![], // 0 regime features (legacy test) ); let reward_loss = reward_fn.calculate_reward( buy_action(), ¤t_state, &next_state_loss, &recent_actions, )?; // Verify: Reward should be negative for losing trade let reward_loss_f64: f64 = reward_loss.try_into().unwrap(); assert!( reward_loss_f64 < 0.0, "Reward should be negative for losing BUY trade, got {}", reward_loss_f64 ); Ok(()) } // ============================================================================ // Test 4: P&L Calculation Accuracy // ============================================================================ /// Test that P&L rewards accurately reflect the magnitude of profit/loss. /// This verifies the normalization by initial_capital (Bug #4 fix). #[test] fn test_pnl_calculation_accuracy() -> anyhow::Result<()> { let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config)?; // Test case 1: Small profit (1%) let current_state = TradingState::from_normalized( vec![0.0; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.0, 0.0, 0.0001], vec![], // 0 regime features (legacy test) ); let next_state_1pct = TradingState::from_normalized( vec![0.01; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.01, 0.1, 0.0001], // 1% gain vec![], // 0 regime features (legacy test) ); let recent_actions_diverse = vec![buy_action(), hold_action(), sell_action()]; let reward_1pct = reward_fn.calculate_reward( buy_action(), ¤t_state, &next_state_1pct, &recent_actions_diverse, )?; // Test case 2: Large profit (5%) let next_state_5pct = TradingState::from_normalized( vec![0.05; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.05, 0.1, 0.0001], // 5% gain vec![], // 0 regime features (legacy test) ); let reward_5pct = reward_fn.calculate_reward( buy_action(), ¤t_state, &next_state_5pct, &recent_actions_diverse, )?; // Verify: Larger profit should yield larger reward let r1: f64 = reward_1pct.try_into().unwrap(); let r5: f64 = reward_5pct.try_into().unwrap(); assert!( r5 > r1, "5% profit reward ({}) should be greater than 1% profit reward ({})", r5, r1 ); Ok(()) } // ============================================================================ // Test 5: Portfolio Tracking Across BUY Actions // ============================================================================ /// Test that portfolio state updates correctly after BUY actions #[test] fn test_portfolio_tracking_buy_action() -> anyhow::Result<()> { let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0); // Initial state let features_init = tracker.get_raw_portfolio_features(100.0); assert_eq!(features_init[0], 10_000.0, "Initial portfolio value"); assert_eq!(features_init[1], 0.0, "Initial position"); // Execute BUY action tracker.execute_action(buy_action(), 100.0, 10.0); let features_after_buy = tracker.get_raw_portfolio_features(100.0); // Verify: Position opened, cash reduced assert_eq!( features_after_buy[1], 10.0, "Position size should be 10.0 after BUY" ); assert_approx_eq( features_after_buy[0], 10_000.0, "Portfolio value = cash + position_value = 9000 + (10*100) = 10000" ); // Price increases to 110 let features_profit = tracker.get_raw_portfolio_features(110.0); assert_approx_eq( features_profit[0], 10_100.0, "Portfolio value = cash + position_value = 9000 + (10*110) = 10100" ); Ok(()) } // ============================================================================ // Test 6: Portfolio Tracking Across SELL Actions // ============================================================================ /// Test that portfolio state updates correctly after SELL actions #[test] fn test_portfolio_tracking_sell_action() -> anyhow::Result<()> { let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0); // Execute SELL action (open short) tracker.execute_action(sell_action(), 100.0, 10.0); let features_after_sell = tracker.get_raw_portfolio_features(100.0); // Verify: Short position opened, cash increased assert_eq!( features_after_sell[1], -10.0, "Position size should be -10.0 after SELL" ); assert_approx_eq( features_after_sell[0], 10_000.0, "Portfolio value = cash + position_value = 11000 + (-10*100) = 10000" ); // Price decreases to 90 (profitable for short) let features_profit = tracker.get_raw_portfolio_features(90.0); assert_approx_eq( features_profit[0], 10_100.0, "Portfolio value = cash + position_value = 11000 + (-10*90) = 10100" ); Ok(()) } // ============================================================================ // Test 7: Portfolio Tracking Across HOLD Actions // ============================================================================ /// Test that portfolio state remains unchanged after HOLD actions #[test] fn test_portfolio_tracking_hold_action() -> anyhow::Result<()> { let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0); // Execute BUY to create a position tracker.execute_action(buy_action(), 100.0, 10.0); let _features_after_buy = tracker.get_raw_portfolio_features(100.0); // Execute HOLD action (Flat exposure = close position) // Note: In FactoredAction system, HOLD means "target Flat exposure" = close all positions tracker.execute_action(hold_action(), 110.0, 10.0); let features_after_hold = tracker.get_raw_portfolio_features(100.0); // Verify: Position closed (Flat exposure) assert_eq!( features_after_hold[1], 0.0, "Position should be Flat (0) after HOLD action" ); Ok(()) } // ============================================================================ // Test 8: Edge Case - Zero Position // ============================================================================ /// Test portfolio features when position size is zero #[test] fn test_edge_case_zero_position() -> anyhow::Result<()> { let tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0); let features = tracker.get_raw_portfolio_features(100.0); assert_eq!(features[1], 0.0, "Position should be zero initially"); assert_eq!( features[0], 10_000.0, "Portfolio value should equal cash when no position" ); Ok(()) } // ============================================================================ // Test 9: Edge Case - Negative P&L // ============================================================================ /// Test that negative P&L is correctly reflected in portfolio value #[test] fn test_edge_case_negative_pnl() -> anyhow::Result<()> { let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0); // Open long position at 100 tracker.execute_action(buy_action(), 100.0, 10.0); // Price drops to 90 (10 point loss per unit) let features_loss = tracker.get_raw_portfolio_features(90.0); // Portfolio value = cash + position_value = 9000 + (10*90) = 9900 // Unrealized P&L = 9900 - 10000 = -100 (loss) assert_approx_eq( features_loss[0], 9_900.0, "Portfolio value = 9000 + (10*90) = 9900" ); Ok(()) } // ============================================================================ // Test 10: Edge Case - Large Positions // ============================================================================ /// Test portfolio features with large position sizes #[test] fn test_edge_case_large_positions() -> anyhow::Result<()> { let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 0.0); // Open large long position tracker.execute_action(buy_action(), 100.0, 100.0); let features = tracker.get_raw_portfolio_features(101.0); // Portfolio value = cash + position_value = 90000 + (100*101) = 100100 assert_eq!(features[1], 100.0, "Position size should be 100.0 units"); assert_approx_eq( features[0], 100_100.0, "Portfolio value = 90000 + (100*101) = 100100" ); Ok(()) } // ============================================================================ // Test 11: Reward Function Receives Portfolio Data // ============================================================================ /// Test that reward function correctly receives and uses portfolio features /// from TradingState #[test] fn test_reward_function_receives_portfolio() -> anyhow::Result<()> { let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config)?; // Create states with different portfolio values let state_low = TradingState::from_normalized( vec![0.0; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![0.9, 0.1, 0.0001], // Portfolio value = 0.9 (9000), position normalized (10/100) vec![], // 0 regime features (legacy test) ); let state_high = TradingState::from_normalized( vec![0.0; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.1, 0.1, 0.0001], // Portfolio value = 1.1 (11000) vec![], // 0 regime features (legacy test) ); // Calculate reward for portfolio value increase let recent_actions_diverse = vec![buy_action(), hold_action(), sell_action()]; let reward = reward_fn.calculate_reward( buy_action(), &state_low, &state_high, &recent_actions_diverse, )?; // Verify: Reward should be positive (portfolio value increased) let reward_f64: f64 = reward.try_into().unwrap(); assert!( reward_f64 > 0.0, "Reward should be positive when portfolio value increases, got {}", reward_f64 ); Ok(()) } // ============================================================================ // Test 12: Integration - Portfolio Tracking Through Full Trade Cycle // ============================================================================ /// Test portfolio tracking through a complete trade cycle: /// Flat -> Long -> Flat -> Short -> Flat #[test] fn test_integration_full_trade_cycle() -> anyhow::Result<()> { let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0); // 1. Initial state (flat) let features_flat1 = tracker.get_raw_portfolio_features(100.0); assert_eq!(features_flat1[1], 0.0, "Should start flat"); assert_eq!(features_flat1[0], 10_000.0, "Initial capital"); // 2. Open long position tracker.execute_action(buy_action(), 100.0, 10.0); let features_long = tracker.get_raw_portfolio_features(110.0); assert_eq!(features_long[1], 10.0, "Should be long 10 units"); assert_approx_eq( features_long[0], 10_100.0, "Portfolio value = 9000 + (10*110) = 10100" ); // 3. Close long position (HOLD = Flat exposure) tracker.execute_action(hold_action(), 110.0, 10.0); let features_flat2 = tracker.get_raw_portfolio_features(110.0); assert_eq!(features_flat2[1], 0.0, "Should be flat after close"); assert_approx_eq( features_flat2[0], 10_100.0, "Cash should reflect realized profit" ); // 4. Open short position tracker.execute_action(sell_action(), 110.0, 10.0); let features_short = tracker.get_raw_portfolio_features(100.0); assert_eq!(features_short[1], -10.0, "Should be short 10 units"); // Cash after long close: 10100, then open short: +1100, so cash = 11200 // Portfolio = 11200 + (-10 * 100) = 10200 assert_approx_eq( features_short[0], 10_200.0, "Portfolio value = 11200 + (-10*100) = 10200" ); // 5. Close short position (HOLD = Flat exposure) tracker.execute_action(hold_action(), 100.0, 10.0); let features_flat3 = tracker.get_raw_portfolio_features(100.0); assert_eq!(features_flat3[1], 0.0, "Should be flat after close"); assert_approx_eq( features_flat3[0], 10_200.0, "Cash should reflect all realized profits" ); Ok(()) } // ============================================================================ // Test 13: Integration - Batch Reward Calculation // ============================================================================ /// Test batch reward calculation with portfolio features #[test] fn test_integration_batch_rewards() -> anyhow::Result<()> { use ml::dqn::reward::calculate_batch_rewards; let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config)?; // Create batch of state transitions let actions = vec![buy_action(), hold_action(), sell_action()]; let current_states = vec![ TradingState::from_normalized( vec![0.0; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.0, 0.0, 0.0001], vec![], // 0 regime features (legacy test) ), TradingState::from_normalized( vec![0.0; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.0, 0.1, 0.0001], vec![], // 0 regime features (legacy test) ), TradingState::from_normalized( vec![0.0; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.0, 0.1, 0.0001], vec![], // 0 regime features (legacy test) ), ]; let next_states = vec![ TradingState::from_normalized( vec![0.01; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.01, 0.1, 0.0001], vec![], // 0 regime features (legacy test) ), TradingState::from_normalized( vec![0.005; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.005, 0.1, 0.0001], vec![], // 0 regime features (legacy test) ), TradingState::from_normalized( vec![-0.01; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![0.99, 0.0, 0.0001], vec![], // 0 regime features (legacy test) ), ]; let recent_actions = vec![buy_action(), hold_action(), sell_action()]; // Calculate batch rewards let rewards = calculate_batch_rewards( &mut reward_fn, &actions, ¤t_states, &next_states, &recent_actions, )?; // Verify: Should have 3 rewards assert_eq!(rewards.len(), 3, "Should have 3 rewards for batch of 3"); // Verify: Rewards should be non-zero (portfolio features used) for (i, reward) in rewards.iter().enumerate() { let r: f64 = (*reward).try_into().unwrap(); info!(index = i, reward = r, "Reward calculated"); // Note: HOLD action might have small rewards due to hold_reward config // We just verify they're calculated (not NaN) assert!(!r.is_nan(), "Reward {} should not be NaN", i); } Ok(()) } // ============================================================================ // Test 14: Edge Case - Portfolio Value Near Zero // ============================================================================ /// Test edge case where portfolio value approaches zero (large losses) #[test] fn test_edge_case_portfolio_near_zero() -> anyhow::Result<()> { let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0); // Open large position tracker.execute_action(buy_action(), 100.0, 100.0); // Catastrophic price drop (90% loss) let features_crash = tracker.get_raw_portfolio_features(10.0); // Expected: cash=0, unrealized loss=-9000, total=-9000 // (This represents a margin call scenario in real trading) assert!( features_crash[0] < 10_000.0, "Portfolio value should drop significantly" ); Ok(()) } // ============================================================================ // Test 15: Transaction Cost Tolerance Regression Test (Wave 7.1) // ============================================================================ /// Regression test to verify transaction cost tolerance is working correctly /// This test documents the expected behavior where portfolio values have /// small discrepancies due to realistic transaction costs (0.05-0.15%) #[test] fn test_transaction_cost_tolerance() -> anyhow::Result<()> { // Setup: Create tracker with 0.01% spread (10 basis points) let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0); // Test 1: Buy 10 units at $100 tracker.execute_action(buy_action(), 100.0, 10.0); let features_buy = tracker.get_raw_portfolio_features(100.0); // Expected: Portfolio value = $10,000 (cash + position value) // Actual: Slightly less due to transaction costs // Tolerance: 0.2% (20 basis points) to account for fees assert_approx_eq( features_buy[0], 10_000.0, "Portfolio value after buy (with transaction costs)" ); // Test 2: Verify transaction costs are within expected range let actual_value = features_buy[0]; let expected_value = 10_000.0; let cost_percentage = ((expected_value - actual_value) / expected_value * 100.0).abs(); assert!( cost_percentage <= 0.2, "Transaction costs should be within 0.2% (20 bps), got {:.4}%", cost_percentage ); // Test 3: Verify transaction costs are not zero (realistic costs applied) assert!( (actual_value - expected_value).abs() > 0.01, "Transaction costs should be applied (value difference > $0.01)" ); // Test 4: Multiple trades compound transaction costs tracker.execute_action(hold_action(), 110.0, 10.0); // Close long at profit tracker.execute_action(sell_action(), 110.0, 10.0); // Open short tracker.execute_action(hold_action(), 100.0, 10.0); // Close short at profit let features_final = tracker.get_raw_portfolio_features(100.0); // Expected: ~$10,200 (profit from both trades) // Actual: Slightly less due to accumulated transaction costs from 4 trades assert_approx_eq( features_final[0], 10_200.0, "Final portfolio value after multiple trades (with costs)" ); Ok(()) } // ============================================================================ // Test 16: Reward Calculation Consistency // ============================================================================ /// Test that reward calculations are consistent and deterministic #[test] fn test_reward_calculation_consistency() -> anyhow::Result<()> { let config = RewardConfig::default(); // Calculate same reward twice let mut reward_fn1 = RewardFunction::new(config.clone())?; let mut reward_fn2 = RewardFunction::new(config)?; let current_state = TradingState::from_normalized( vec![0.0; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.0, 0.0, 0.0001], vec![], // 0 regime features (legacy test) ); let next_state = TradingState::from_normalized( vec![0.01; 16], vec![0.0; 16], vec![ 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], vec![1.01, 0.1, 0.0001], vec![], // 0 regime features (legacy test) ); let recent_actions = vec![buy_action(), hold_action(), sell_action()]; let reward1 = reward_fn1.calculate_reward(buy_action(), ¤t_state, &next_state, &recent_actions)?; let reward2 = reward_fn2.calculate_reward(buy_action(), ¤t_state, &next_state, &recent_actions)?; // Verify: Same inputs should produce same reward assert_eq!( reward1, reward2, "Reward calculation should be deterministic" ); Ok(()) }