#![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, )] use ml::dqn::portfolio_tracker::PortfolioTracker; use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency}; use tracing::info; #[test] fn test_position_size_respects_max_limit() { let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0); // Try to build massive 10,000 contract position (Wave 16R bug) for i in 0..100 { let long_action = FactoredAction::new( ExposureLevel::Long100, // Maximum long exposure OrderType::Market, Urgency::Aggressive ); tracker.execute_action( long_action, 5000.0, // ES futures price 200.0 // Max position parameter (should be enforced) ); // Debug: Check position after each iteration if i % 20 == 0 { let features = tracker.get_raw_portfolio_features(5000.0); info!(iteration = i, position = features[1], portfolio = features[0], "Position check"); } } // CRITICAL: Position must be clamped to max_position limit let features = tracker.get_raw_portfolio_features(5000.0); let final_position = features[1]; // Position size from features info!(position = final_position, portfolio = features[0], "Final position"); assert!( final_position.abs() <= 200.0, "Position size {} exceeds max_position limit 200! \ Bug #15 (unbounded position sizing) still exists!", final_position ); } #[test] fn test_position_prevents_portfolio_explosion() { let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0); let initial_capital = 100_000.0; // Build position to limit for _ in 0..50 { let action = FactoredAction::new( ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive ); tracker.execute_action(action, 5000.0, 100.0); } let features = tracker.get_raw_portfolio_features(5000.0); let portfolio_value = features[0]; let normalized_value = portfolio_value / initial_capital; // Portfolio should stay in realistic range (not $50M) assert!( portfolio_value < 500_000.0, "Portfolio value ${} is catastrophic! Expected <$500K. \ Position explosion indicates Bug #15 persists.", portfolio_value ); assert!( normalized_value < 5.0, "Normalized value {} is catastrophic! Expected <5.0. \ This will cause reward explosion and gradient collapse.", normalized_value ); } #[test] fn test_negative_position_also_clamped() { let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0); // Try to build massive short position for _ in 0..100 { let short_action = FactoredAction::new( ExposureLevel::Short100, // Maximum short exposure OrderType::Market, Urgency::Aggressive ); tracker.execute_action( short_action, 5000.0, // ES futures price 150.0 // Max position parameter ); } let features = tracker.get_raw_portfolio_features(5000.0); let final_position = features[1]; assert!( final_position >= -150.0, "Short position {} exceeds max_position limit -150! \ Position clipping should work both ways.", final_position ); } #[test] fn test_low_price_creates_catastrophic_positions() { let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0); // CRITICAL: Simulate low price scenario (e.g., penny stock or data bug) // At price=$10, max_position = $100K / $10 = 10,000 contracts! let low_price = 10.0; let max_position_uncapped = 100_000.0 / low_price; // = 10,000 contracts info!(price = low_price, max_position = max_position_uncapped, "Testing low price scenario"); // Execute Long100 action with uncapped max_position let long_action = FactoredAction::new( ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive ); tracker.execute_action(long_action, low_price, max_position_uncapped); let features = tracker.get_raw_portfolio_features(low_price); let final_position = features[1]; let portfolio_value = features[0]; info!(position = final_position, portfolio = portfolio_value, "After Long100"); // THIS IS THE BUG: Position can be 10,000 contracts! // At $10/contract, that's $100K exposure (OK) // But if price moves to $5000 (ES futures), portfolio = 10,000 × $5000 = $50M (CATASTROPHIC) // Demonstrate the explosion when price changes let es_price = 5000.0; let catastrophic_portfolio = tracker.get_raw_portfolio_features(es_price)[0]; info!(price = es_price, portfolio = catastrophic_portfolio, "Price move - portfolio value"); assert!( final_position.abs() <= 200.0, "Position size {} is catastrophic at low price! \ Should be clamped to reasonable limit (200 contracts), not based on price. \ Bug #15: max_position = capital/price creates unbounded positions at low prices!", final_position ); // Verify portfolio stays under $2M even at high prices (200 contracts × $5K = $1M position) assert!( catastrophic_portfolio < 2_000_000.0, "Portfolio value ${:.0} is still catastrophic! Expected <$2M after position clamping.", catastrophic_portfolio ); }