Files
foxhunt/crates/ml/tests/dqn_transaction_costs_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

428 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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,
)]
//! Wave 9-A3: Transaction Cost Integration Tests
//!
//! Validates that transaction costs are correctly applied based on order type
//! and tracked throughout training.
//!
//! Test Coverage:
//! - Order-type-specific costs (Market 0.15%, LimitMaker 0.05%, IoC 0.10%)
//! - Cost accumulation tracking
//! - HOLD action has zero cost
//! - Cost scaling with reward normalization
//! - Cost breakdown logging
use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency};
use ml::trainers::{DQNHyperparameters, DQNTrainer};
/// Helper to create minimal test hyperparameters
fn create_test_hyperparams() -> DQNHyperparameters {
let mut params = DQNHyperparameters::conservative();
params.epochs = 1; // Single epoch for fast tests
params.batch_size = 32;
params.buffer_size = 1000;
params.min_replay_size = 100;
params
}
#[tokio::test]
async fn test_order_type_transaction_costs() {
// Test that FactoredAction correctly calculates transaction costs for each order type
let trade_value = 10_000.0; // $10,000 trade
// Market order: 0.15% (highest cost)
let market_action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
);
let market_cost = market_action.calculate_transaction_cost(trade_value);
assert_eq!(
market_cost, 15.0,
"Market order should cost $15 (0.15% of $10k)"
);
// LimitMaker order: 0.05% (lowest cost)
let limit_action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::LimitMaker,
Urgency::Patient,
);
let limit_cost = limit_action.calculate_transaction_cost(trade_value);
assert_eq!(
limit_cost, 5.0,
"LimitMaker order should cost $5 (0.05% of $10k)"
);
// IoC order: 0.10% (medium cost)
let ioc_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::IoC, Urgency::Normal);
let ioc_cost = ioc_action.calculate_transaction_cost(trade_value);
assert_eq!(ioc_cost, 10.0, "IoC order should cost $10 (0.10% of $10k)");
}
#[tokio::test]
async fn test_market_costs_twice_limitmaker() {
// Validate that Market orders cost 3x LimitMaker (0.15% vs 0.05%)
let trade_value = 5_000.0;
let market_action =
FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
let market_cost = market_action.calculate_transaction_cost(trade_value);
let limit_action = FactoredAction::new(
ExposureLevel::LongSmall,
OrderType::LimitMaker,
Urgency::Normal,
);
let limit_cost = limit_action.calculate_transaction_cost(trade_value);
// Market should be 3x LimitMaker
let ratio = market_cost / limit_cost;
assert_eq!(
ratio, 3.0,
"Market cost should be 3x LimitMaker cost (0.15% / 0.05%)"
);
// Verify absolute values
assert_eq!(market_cost, 7.5, "Market cost should be $7.50");
assert_eq!(limit_cost, 2.5, "LimitMaker cost should be $2.50");
}
#[test]
fn test_zero_trade_value_zero_cost() {
// HOLD action (zero exposure) should have zero transaction cost
let flat_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
let trade_value = 0.0; // Zero exposure = zero trade value
let cost = flat_action.calculate_transaction_cost(trade_value);
assert_eq!(cost, 0.0, "Zero trade value should have zero cost");
}
#[test]
fn test_exposure_scaling_transaction_costs() {
// Transaction costs should scale linearly with exposure level
let entry_price = 4000.0;
let position_size = 1.0;
// Short100 (-100% exposure)
let short100 = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
let short100_value = entry_price * position_size * short100.target_exposure().abs();
let short100_cost = short100.calculate_transaction_cost(short100_value);
// Short50 (-50% exposure)
let short50 = FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal);
let short50_value = entry_price * position_size * short50.target_exposure().abs();
let short50_cost = short50.calculate_transaction_cost(short50_value);
// Flat (0% exposure)
let flat = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
let flat_value = entry_price * position_size * flat.target_exposure().abs();
let flat_cost = flat.calculate_transaction_cost(flat_value);
// Long50 (+50% exposure)
let long50 = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
let long50_value = entry_price * position_size * long50.target_exposure().abs();
let long50_cost = long50.calculate_transaction_cost(long50_value);
// Long100 (+100% exposure)
let long100 = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let long100_value = entry_price * position_size * long100.target_exposure().abs();
let long100_cost = long100.calculate_transaction_cost(long100_value);
// Validate linear scaling
assert_eq!(flat_cost, 0.0, "Flat should have zero cost");
assert_eq!(
short50_cost, long50_cost,
"±50% exposure should have equal costs"
);
assert_eq!(
short100_cost, long100_cost,
"±100% exposure should have equal costs"
);
// Verify 50% costs are half of 100%
let ratio = long100_cost / long50_cost;
assert_eq!(
ratio, 2.0,
"100% exposure cost should be 2x 50% exposure cost"
);
// Verify absolute values (Market 0.15%)
assert_eq!(
long100_cost, 6.0,
"100% exposure at $4000 should cost $6.00"
);
assert_eq!(long50_cost, 3.0, "50% exposure at $4000 should cost $3.00");
}
#[test]
fn test_urgency_does_not_affect_transaction_costs() {
// Urgency level should NOT affect transaction costs (only order type matters)
let trade_value = 8_000.0;
let patient = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Patient);
let normal = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let aggressive = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
);
let patient_cost = patient.calculate_transaction_cost(trade_value);
let normal_cost = normal.calculate_transaction_cost(trade_value);
let aggressive_cost = aggressive.calculate_transaction_cost(trade_value);
assert_eq!(patient_cost, normal_cost, "Urgency should not affect cost");
assert_eq!(
normal_cost, aggressive_cost,
"Urgency should not affect cost"
);
assert_eq!(
patient_cost, 12.0,
"All urgencies should cost $12 (0.15% of $8k)"
);
}
#[test]
fn test_all_45_actions_have_valid_transaction_costs() {
// Validate that all 45 factored actions produce valid transaction costs
let trade_value = 10_000.0;
for action_idx in 0..63 {
let action = FactoredAction::from_index(action_idx)
.expect(&format!("Action index {} should be valid", action_idx));
let cost = action.calculate_transaction_cost(trade_value);
// Validate cost is non-negative
assert!(
cost >= 0.0,
"Action {} cost should be non-negative, got {}",
action_idx,
cost
);
// Validate cost matches expected order type
match action.order {
OrderType::Market => {
assert_eq!(
cost, 15.0,
"Market order (action {}) should cost $15",
action_idx
);
},
OrderType::LimitMaker => {
assert_eq!(
cost, 5.0,
"LimitMaker order (action {}) should cost $5",
action_idx
);
},
OrderType::IoC => {
assert_eq!(
cost, 10.0,
"IoC order (action {}) should cost $10",
action_idx
);
},
}
}
}
#[tokio::test]
async fn test_transaction_cost_accumulation() {
// Test that DQNTrainer correctly accumulates transaction costs by order type
// Note: This is a smoke test - full integration test requires actual training
let hyperparams = create_test_hyperparams();
let trainer = DQNTrainer::new(hyperparams);
assert!(
trainer.is_ok(),
"DQNTrainer should initialize successfully: {:?}",
trainer.err()
);
// Verify initial state has zero accumulated costs
let trainer = trainer.unwrap();
// Access transaction costs via Debug formatting
let debug_str = format!("{:?}", trainer);
// Verify trainer initialized (cost tracking happens during training)
assert!(
debug_str.contains("DQNTrainer"),
"Trainer should be initialized"
);
}
#[test]
fn test_cost_calculation_matches_documentation() {
// Validate that actual costs match the documented rates in action_space.rs
// Documentation states:
// - Market: 0.15% (0.0015 × trade_value)
// - LimitMaker: 0.05% (0.0005 × trade_value)
// - IoC: 0.10% (0.0010 × trade_value)
let trade_value = 100_000.0; // $100k trade
let market_action =
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
assert_eq!(
market_action.calculate_transaction_cost(trade_value),
150.0,
"Market: 0.15% of $100k = $150"
);
let limit_action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::LimitMaker,
Urgency::Normal,
);
assert_eq!(
limit_action.calculate_transaction_cost(trade_value),
50.0,
"LimitMaker: 0.05% of $100k = $50"
);
let ioc_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::IoC, Urgency::Normal);
assert_eq!(
ioc_action.calculate_transaction_cost(trade_value),
100.0,
"IoC: 0.10% of $100k = $100"
);
}
#[test]
fn test_transaction_cost_precision() {
// Verify that transaction costs maintain precision for small trade values
let small_trade = 100.0; // $100 trade
let market_action =
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let cost = market_action.calculate_transaction_cost(small_trade);
// 0.15% of $100 = $0.15
assert!(
(cost - 0.15).abs() < 1e-10,
"Small trade cost should be precise: expected 0.15, got {}",
cost
);
}
#[test]
fn test_hold_action_indices_have_zero_exposure() {
// Verify that HOLD action indices (18-26) have zero exposure
// Flat exposure (index 2) * 9 + order (0-2) * 3 + urgency (0-2) = 18-26
// This ensures zero transaction cost for HOLD in reward calculation
let hold_indices = [18, 19, 20, 21, 22, 23, 24, 25, 26];
for &idx in &hold_indices {
let action =
FactoredAction::from_index(idx).expect(&format!("HOLD index {} should be valid", idx));
assert_eq!(
action.exposure,
ExposureLevel::Flat,
"HOLD action {} should have Flat exposure",
idx
);
assert_eq!(
action.target_exposure(),
0.0,
"HOLD action {} should have zero target exposure",
idx
);
// Verify zero cost when trade value is zero
let cost = action.calculate_transaction_cost(0.0);
assert_eq!(
cost, 0.0,
"HOLD action {} should have zero cost with zero exposure",
idx
);
}
}