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>
428 lines
14 KiB
Rust
428 lines
14 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,
|
||
)]
|
||
//! 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
|
||
);
|
||
}
|
||
}
|