Files
foxhunt/crates/ml/tests/transaction_cost_application_test.rs
jgrusewski 448b61d095 refactor: collapse 9-level to 7-level ExposureLevel — eliminate degenerate Flat variants
The 4-branch DQN (direction x magnitude) had 3 degenerate variants
(Short25, Flat, Long25) that all mapped to 0.0 target exposure when
direction=Flat, causing 82% Flat collapse. Collapse these into a
single Flat variant, giving 7 levels (ShortSmall/Half/Full, Flat,
LongSmall/Half/Full) and 63 total factored actions (7x3x3).

- ExposureLevel enum: 9 variants -> 7 (add direction/magnitude/from_dir_mag)
- FactoredAction: 81 -> 63 total actions, from_index/to_index updated
- DQN epsilon-greedy: use from_dir_mag() instead of dir*3+mag indexing
- DQN config: num_actions default 9 -> 7
- PPO action space: 45 -> 63 actions, action masking updated
- Signal adapter CUDA kernel: 5-bin -> 7-bin exposure aggregation
- All tests updated for new variant names and index ranges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:54:09 +02:00

321 lines
12 KiB
Rust

#![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::LongFull, 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::LongFull, 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::LongFull, 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::LongFull, 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::LongSmall, 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::LongFull, 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::LongFull, 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::LongSmall, 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::LongFull, 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::ShortSmall, 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
);
}