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

242 lines
8.4 KiB
Rust
Raw Permalink 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,
)]
//! Bug #2: Transaction Cost Weight Fix - Regression Tests
//!
//! **BUG DESCRIPTION**:
//! Transaction costs were hardcoded to 0.05 instead of using proper market rates.
//! This caused the agent to undertrain on transaction cost awareness.
//!
//! **ROOT CAUSE**:
//! File: `/ml/src/dqn/reward.rs:153` (Default impl) and line 221 (Builder)
//! ```rust
//! cost_weight: Decimal::try_from(0.05).unwrap_or(Decimal::ZERO), // WAS: HARDCODED 0.05
//! ```
//!
//! **STATUS**: ✅ Bug #2 fix IMPLEMENTED
//!
//! **FIX APPLIED**:
//! ```rust
//! cost_weight: Decimal::ONE, // Apply transaction costs at full weight
//! ```
//!
//! **WHY transaction_cost_multiplier SHOULD BE REMOVED**:
//! - Transaction costs are FIXED market rates (0.05%-0.15%) from OrderType enum
//! - These are real exchange fees, not tunable hyperparameters
//! - Making them tunable allows the agent to "cheat" by learning with fake low costs
//! - Real trading must use actual market fees
//!
//! **CURRENT STATE**:
//! - cost_weight is hardcoded to 0.05 (line 982)
//! - transaction_cost_multiplier is still in hyperopt search space (dimension #8)
//! - These tests verify current behavior, not fixed behavior
use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency};
use ml::dqn::reward::RewardConfig;
use ml::hyperopt::adapters::dqn::DQNParams;
use ml::hyperopt::ParameterSpace;
use rust_decimal::Decimal;
use tracing::info;
/// Test that cost_weight is now 1.0 (full weight) - verifies Bug #2 fix
///
/// **Before Fix**: cost_weight = 0.05 (hardcoded in reward.rs)
/// **After Fix**: cost_weight = 1.0 (full weight)
/// **Status**: ✅ Bug #2 fix IMPLEMENTED
#[test]
fn test_cost_weight_is_full_weight() {
// Create reward config using Default (should now have cost_weight = 1.0)
let reward_config = RewardConfig::default();
// Verify cost_weight is now 1.0 (full weight)
assert_eq!(
reward_config.cost_weight,
Decimal::ONE,
"Bug #2 fix: cost_weight should be 1.0 (full weight)"
);
info!("cost_weight = 1.0 (Bug #2 fix implemented): before=0.05 (5% of actual costs), after=1.0 (100% of actual costs)");
}
/// Test that transaction_cost_multiplier has been removed from DQNParams.
///
/// After the 14D family-intensity restructuring, transaction_cost_multiplier
/// is no longer a tunable hyperparameter. Transaction costs are fixed market
/// rates from OrderType enum.
#[test]
fn test_transaction_cost_multiplier_removed() {
// DQNParams now only has 14 fields (3 breakouts + 11 family intensities).
// transaction_cost_multiplier was removed -- verify the search space is 14D.
let bounds = DQNParams::continuous_bounds();
assert_eq!(bounds.len(), 14, "Search space should be 14D after family consolidation");
let names = DQNParams::param_names();
assert!(
!names.contains(&"transaction_cost_multiplier"),
"transaction_cost_multiplier should NOT be in search space"
);
info!("transaction_cost_multiplier removed from DQNParams; transaction costs are fixed market rates from OrderType");
}
/// Test that LimitMaker orders have lowest cost (0.05%)
///
/// **Expected**: LimitMaker cost < IoC cost < Market cost
/// **Verifies**: OrderType enum transaction costs are correct
#[test]
fn test_order_type_transaction_costs() {
let market_cost = OrderType::Market.transaction_cost();
let limit_cost = OrderType::LimitMaker.transaction_cost();
let ioc_cost = OrderType::IoC.transaction_cost();
// Verify costs are in correct order
assert!(
limit_cost < ioc_cost && ioc_cost < market_cost,
"Order type costs should be: LimitMaker < IoC < Market\n\
Got: LimitMaker={:.4}, IoC={:.4}, Market={:.4}",
limit_cost,
ioc_cost,
market_cost
);
// Verify exact values (from action_space.rs)
assert_eq!(market_cost, 0.0015, "Market orders should cost 0.15%");
assert_eq!(limit_cost, 0.0005, "LimitMaker orders should cost 0.05%");
assert_eq!(ioc_cost, 0.0010, "IoC orders should cost 0.10%");
info!(market_pct = market_cost * 100.0, limit_pct = limit_cost * 100.0, ioc_pct = ioc_cost * 100.0, "Order type transaction costs are correct");
}
/// Integration test: Verify hyperopt search space dimensions
///
/// Search space is now 14D (3 breakouts + 5 core families + 6 gen families).
/// transaction_cost_multiplier was removed during family-intensity consolidation.
#[test]
fn test_hyperopt_search_space_reduced() {
let bounds = DQNParams::continuous_bounds();
assert_eq!(
bounds.len(),
14,
"Hyperopt search space should be 14D (3 breakouts + 5 core + 6 gen families), got {}D",
bounds.len()
);
let names = DQNParams::param_names();
assert!(
!names.contains(&"transaction_cost_multiplier"),
"transaction_cost_multiplier should not be in 14D search space"
);
info!(dims = bounds.len(), "Hyperopt search space is 14D; transaction_cost_multiplier removed (fixed market rates)");
}
/// Test that transaction costs correctly reflect factored action order types
#[test]
fn test_factored_action_transaction_costs() {
// Market order (most expensive)
let market_action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
);
// Limit maker order (cheapest)
let limit_action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::LimitMaker,
Urgency::Patient,
);
// IoC order (middle)
let ioc_action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::IoC,
Urgency::Normal,
);
// Verify costs are correctly propagated from OrderType
assert_eq!(market_action.transaction_cost(), 0.0015);
assert_eq!(limit_action.transaction_cost(), 0.0005);
assert_eq!(ioc_action.transaction_cost(), 0.0010);
// Verify cost calculations for $10,000 trade
let trade_value = 10_000.0;
assert_eq!(market_action.calculate_transaction_cost(trade_value), 15.0); // 0.15% × $10k
assert_eq!(limit_action.calculate_transaction_cost(trade_value), 5.0); // 0.05% × $10k
assert_eq!(ioc_action.calculate_transaction_cost(trade_value), 10.0); // 0.10% × $10k
info!("Factored actions correctly use OrderType transaction costs");
}