#![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, )] //! Transaction Cost Application Tests //! //! Validates that transaction costs are properly deducted from portfolio value //! and P&L calculations in the PortfolioTracker. //! //! Bug Context: Agent B3 (Wave 16N) //! - Transaction costs defined in FactoredAction but NOT applied to portfolio //! - Results in P&L overstatement (0.01% leak per trade) use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency}; use ml::dqn::portfolio_tracker::PortfolioTracker; #[test] fn test_transaction_costs_reduce_portfolio_value_market_order() { // Given: PortfolioTracker with $100K initial capital let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0); // When: Execute Market order (0.15% fee) // Buy 10 contracts at $5000 = $50,000 trade value // Fee: $50,000 * 0.0015 = $75 let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); let price = 5000.0; let max_position = 10.0; tracker.execute_action(action, price, max_position); // Then: Portfolio value should be $100K - $75 (not $100K) let portfolio_value = tracker.get_raw_portfolio_features(price)[0]; let expected_value = 100000.0 - 75.0; // $100K - $75 fee assert_eq!( portfolio_value, expected_value, "Portfolio value should include transaction cost deduction. Expected {}, got {}", expected_value, portfolio_value ); } #[test] fn test_transaction_costs_reduce_portfolio_value_ioc_order() { // Given: PortfolioTracker let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0); // When: Execute IoC order (0.10% fee) // Buy 10 contracts at $5000 = $50,000 trade value // Fee: $50,000 * 0.0010 = $50 let action = FactoredAction::new(ExposureLevel::Long100, OrderType::IoC, Urgency::Aggressive); let price = 5000.0; let max_position = 10.0; tracker.execute_action(action, price, max_position); // Then: Portfolio value should be $100K - $50 let portfolio_value = tracker.get_raw_portfolio_features(price)[0]; let expected_value = 100000.0 - 50.0; // $100K - $50 fee assert_eq!( portfolio_value, expected_value, "Portfolio value should include IoC transaction cost. Expected {}, got {}", expected_value, portfolio_value ); } #[test] fn test_limitmaker_rebates_increase_portfolio_value() { // Given: PortfolioTracker let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0); // When: Execute LimitMaker order (0.05% rebate) // Buy 10 contracts at $5000 = $50,000 trade value // Rebate: $50,000 * 0.0005 = $25 let action = FactoredAction::new(ExposureLevel::Long100, OrderType::LimitMaker, Urgency::Patient); let price = 5000.0; let max_position = 10.0; tracker.execute_action(action, price, max_position); // Then: Portfolio value should be $100K + $25 (rebate) // Note: LimitMaker is treated as a "rebate" (reduces cost, effectively adds value) let portfolio_value = tracker.get_raw_portfolio_features(price)[0]; let expected_value = 100000.0 - 25.0; // Still a cost, but smaller assert_eq!( portfolio_value, expected_value, "Portfolio value should include LimitMaker rebate. Expected {}, got {}", expected_value, portfolio_value ); } #[test] fn test_pnl_includes_transaction_costs() { // Given: PortfolioTracker with trade sequence let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0); // When: Buy at $5000 (Market, fee: $75), sell at $5100 (Market, fee: $76.50) // Buy: 10 contracts at $5000 = $50,000 trade, fee = $75 let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); tracker.execute_action(buy_action, 5000.0, 10.0); // Sell: 10 contracts at $5100 = $51,000 trade, fee = $76.50 let sell_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); tracker.execute_action(sell_action, 5100.0, 10.0); // Then: P&L = gross profit ($1000) - fees ($151.50) = $848.50 let final_value = tracker.get_raw_portfolio_features(5100.0)[0]; let pnl = final_value - 100000.0; let expected_pnl = 1000.0 - 75.0 - 76.50; // Gross profit - buy fee - sell fee assert_eq!( pnl, expected_pnl, "P&L should include transaction costs. Expected {}, got {}", expected_pnl, pnl ); } #[test] fn test_transaction_costs_accumulate_across_trades() { // Given: PortfolioTracker let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0); // When: Execute multiple trades with different order types // Trade 1: Market order (0.15% fee) let action1 = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal); tracker.execute_action(action1, 5000.0, 10.0); // $25,000 trade, $37.50 fee // Trade 2: IoC order (0.10% fee) let action2 = FactoredAction::new(ExposureLevel::Long100, OrderType::IoC, Urgency::Aggressive); tracker.execute_action(action2, 5000.0, 10.0); // $25,000 trade (delta from Long50 to Long100), $25 fee // Trade 3: LimitMaker order (0.05% fee) let action3 = FactoredAction::new(ExposureLevel::Flat, OrderType::LimitMaker, Urgency::Patient); tracker.execute_action(action3, 5000.0, 10.0); // $50,000 trade (close position), $25 fee // Then: Total fees = $37.50 + $25 + $25 = $87.50 let final_value = tracker.get_raw_portfolio_features(5000.0)[0]; let total_costs = 100000.0 - final_value; let expected_costs = 37.50 + 25.0 + 25.0; // Sum of all fees assert_eq!( total_costs, expected_costs, "Transaction costs should accumulate. Expected {}, got {}", expected_costs, total_costs ); } #[test] fn test_transaction_costs_with_price_movement() { // Given: PortfolioTracker let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0); // When: Buy at $5000, price moves to $5100, then sell // Buy: Market order (0.15% fee) let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); tracker.execute_action(buy_action, 5000.0, 10.0); // $50,000 trade, $75 fee // Price moves to $5100 (unrealized gain: $1000) let mid_value = tracker.get_raw_portfolio_features(5100.0)[0]; let unrealized_pnl = mid_value - 100000.0; let expected_unrealized = 1000.0 - 75.0; // Gross gain - buy fee assert_eq!( unrealized_pnl, expected_unrealized, "Unrealized P&L should include buy transaction cost. Expected {}, got {}", expected_unrealized, unrealized_pnl ); // Sell: Market order (0.15% fee) let sell_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); tracker.execute_action(sell_action, 5100.0, 10.0); // $51,000 trade, $76.50 fee // Then: Realized P&L = gross profit - all fees let final_value = tracker.get_raw_portfolio_features(5100.0)[0]; let realized_pnl = final_value - 100000.0; let expected_realized = 1000.0 - 75.0 - 76.50; // Gross profit - buy fee - sell fee assert_eq!( realized_pnl, expected_realized, "Realized P&L should include all transaction costs. Expected {}, got {}", expected_realized, realized_pnl ); } #[test] fn test_zero_transaction_cost_for_flat_to_flat() { // Given: PortfolioTracker with no position let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0); // When: Execute Flat action (no position change) let action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); tracker.execute_action(action, 5000.0, 10.0); // Then: No transaction cost (no trade executed) let portfolio_value = tracker.get_raw_portfolio_features(5000.0)[0]; assert_eq!( portfolio_value, 100000.0, "Flat-to-Flat should incur no transaction cost. Expected 100000.0, got {}", portfolio_value ); } #[test] fn test_transaction_costs_for_partial_position_change() { // Given: PortfolioTracker let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0); // When: Go from Flat → Long50 → Long100 // Trade 1: Long50 (0.15% fee on $25,000 trade = $37.50) let action1 = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal); tracker.execute_action(action1, 5000.0, 10.0); // Trade value: $25,000 let value_after_trade1 = tracker.get_raw_portfolio_features(5000.0)[0]; let cost1 = 100000.0 - value_after_trade1; assert_eq!(cost1, 37.50, "Long50 should cost $37.50 in fees"); // Trade 2: Long100 (0.15% fee on $25,000 incremental trade = $37.50) let action2 = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); tracker.execute_action(action2, 5000.0, 10.0); // Additional $25,000 trade let value_after_trade2 = tracker.get_raw_portfolio_features(5000.0)[0]; let total_costs = 100000.0 - value_after_trade2; assert_eq!(total_costs, 75.0, "Long50 → Long100 should cost $75 total ($37.50 + $37.50)"); } #[test] fn test_transaction_costs_with_short_positions() { // Given: PortfolioTracker let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0); // When: Short 10 contracts at $5000 (Market order, 0.15% fee) // Short: $50,000 trade value, $75 fee let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal); tracker.execute_action(short_action, 5000.0, 10.0); // Then: Portfolio value should reflect transaction cost let portfolio_value = tracker.get_raw_portfolio_features(5000.0)[0]; let expected_value = 100000.0 - 75.0; // $100K - $75 fee assert_eq!( portfolio_value, expected_value, "Short position should incur transaction cost. Expected {}, got {}", expected_value, portfolio_value ); // When: Cover short at $4900 (profitable short, Market order 0.15% fee) // Cover: $49,000 trade value, $73.50 fee let cover_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); tracker.execute_action(cover_action, 4900.0, 10.0); // Then: P&L = gross profit ($1000) - fees ($148.50) let final_value = tracker.get_raw_portfolio_features(4900.0)[0]; let pnl = final_value - 100000.0; let expected_pnl = 1000.0 - 75.0 - 73.50; // Gross profit - short fee - cover fee assert_eq!( pnl, expected_pnl, "Short P&L should include transaction costs. Expected {}, got {}", expected_pnl, pnl ); }