Files
foxhunt/crates/ml/tests/dqn_transaction_cost_bug2_test.rs
jgrusewski ca4c38d921 fix(tests): CI GPU test stability, walltime reduction, BF16 tolerance
- Reduce CI GPU test datasets 16x for walltime reduction
- Reduce early-stop epochs 50→10, add --test-threads=1
- Serialize all GPU lib tests to prevent cuBLAS init race
- Align state_dim to 16 for BF16 tensor core HMMA dispatch
- BF16 precision tolerance in ml-dqn tests
- Enable branching DQN + tracing subscriber in smoke tests
- Prevent min_replay_size > buffer_size deadlock in early-stop tests
- Prevent AutoReplaySizer from breaking gradient collapse warmup
- Replace racy tokio::spawn checkpoint counter with AtomicUsize
- Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests
- RealDataLoader respects TEST_DATA_DIR for CI PVC layout
- Add collapse_warmup_capacity to gpu_smoketest DQNConfig
- Drain CUDA context between test binaries
- Detached HEAD checkout prevents local branch corruption
- GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions
- OOD input handling tests use use_gpu: true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:00:13 +01:00

251 lines
8.7 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,
)]
//! 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 parameter still exists (NOT removed yet)
///
/// **Current**: DQNParams DOES have transaction_cost_multiplier field
/// **Expected (after fix)**: Field should be removed
/// **Status**: Bug #2 fix NOT YET IMPLEMENTED
#[test]
fn test_transaction_cost_multiplier_removed() {
// Verify field still exists in current implementation
let params = DQNParams::default();
// Access the field to prove it exists
let tx_cost = params.transaction_cost_multiplier;
// Verify default value is 1.0
assert_eq!(
tx_cost,
1.0,
"transaction_cost_multiplier should default to 1.0"
);
info!(tx_cost, "transaction_cost_multiplier field still exists in DQNParams; Bug #2 fix will remove this field");
}
/// 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
///
/// **Current State**: Search space is 18D (11D base + 6D Rainbow + 1D Bug #7)
/// **Bug #2 STATUS**: Transaction cost multiplier NOT YET REMOVED (still at dimension #8)
/// **Note**: Future Bug #2 fix will remove transaction_cost_multiplier → 17D
#[test]
fn test_hyperopt_search_space_reduced() {
let bounds = DQNParams::continuous_bounds();
// Verify current search space is 18D
// Breakdown: 11D base (including transaction_cost_multiplier) + 6D Rainbow + 1D minimum_profit_factor
assert_eq!(
bounds.len(),
18,
"Current hyperopt search space should be 18D\n\
Got: {}D\n\
Breakdown: 11D base + 6D Rainbow + 1D minimum_profit_factor",
bounds.len()
);
// Verify transaction_cost_multiplier is STILL in search space at dimension #8
let tx_cost_bounds = bounds[8];
assert_eq!(
tx_cost_bounds,
(0.5, 2.0),
"Transaction cost multiplier should be at dimension #8 with bounds (0.5, 2.0)"
);
info!(dims = bounds.len(), "Hyperopt search space is 18D (11D base + 6D Rainbow + 1D profit); dimension #8: transaction_cost_multiplier (0.5-2.0) still present; Bug #2 fix not yet implemented");
}
/// 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::Long100,
OrderType::Market,
Urgency::Aggressive,
);
// Limit maker order (cheapest)
let limit_action = FactoredAction::new(
ExposureLevel::Long100,
OrderType::LimitMaker,
Urgency::Patient,
);
// IoC order (middle)
let ioc_action = FactoredAction::new(
ExposureLevel::Long100,
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");
}