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>
242 lines
8.4 KiB
Rust
242 lines
8.4 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,
|
||
)]
|
||
//! 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");
|
||
}
|