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

467 lines
16 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,
)]
//! DQN Kelly Criterion and Regime Detection Integration Tests
//!
//! Comprehensive integration tests for Kelly criterion position sizing
//! and regime detection features integration with DQN training.
//!
//! Test Coverage:
//! 1. Kelly fraction varies with performance (60% win → Kelly ~0.8, 40% → ~0.2)
//! 2. Regime features change over time (trending vs ranging different vectors)
//! 3. Kelly scales position size (Kelly=0.5 → half position vs Kelly=1.0)
//! 4. Regime detection populates all 5 features [type, confidence, cusum+, cusum-, adx]
//! 5. Kelly applied in backtest (EvaluationEngine PnL reflects Kelly scaling)
//! 6. Regime features in state tensor (state shape [batch, 133] = 128 + 5)
use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency};
use ml::dqn::agent::TradingState;
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::risk::kelly_optimizer::KellyCriterionOptimizer;
use ml::risk::kelly_optimizer::KellyOptimizerConfig;
/// Helper: Create test trading state
fn create_test_state(portfolio_value: f32, position_size: f32) -> TradingState {
TradingState {
price_features: vec![0.0; 4],
technical_indicators: vec![0.5; 121],
market_features: vec![],
portfolio_features: vec![portfolio_value, position_size, 0.001],
regime_features: vec![],
}
}
/// Helper: Create test state with regime features
fn create_test_state_with_regime(
portfolio_value: f32,
position_size: f32,
regime_features: Vec<f32>,
) -> TradingState {
TradingState {
price_features: vec![0.0; 4],
technical_indicators: vec![0.5; 121],
market_features: vec![],
portfolio_features: vec![portfolio_value, position_size, 0.001],
regime_features,
}
}
/// Test 1: Kelly fraction varies with performance metrics
///
/// **EXPECTED**:
/// - High win rate (60%) → Higher Kelly fraction (~0.7-0.9)
/// - Low win rate (40%) → Lower Kelly fraction (~0.1-0.3)
/// - Kelly fraction clamped to [0.01, 0.25] (safety bounds)
#[test]
fn test_kelly_fraction_varies_with_performance() {
let config = KellyOptimizerConfig {
max_fraction: 0.25,
min_fraction: 0.01,
lookback_period: 100,
confidence_threshold: 0.6,
volatility_adjustment: true,
drawdown_protection: true,
};
let kelly_optimizer = KellyCriterionOptimizer::new(config).unwrap();
// Scenario 1: High win rate (60%), favorable odds
let kelly_high = kelly_optimizer
.calculate_basic_kelly(
0.60, // 60% win rate
100.0, // Avg win: $100
50.0, // Avg loss: $50 (2:1 win/loss ratio)
)
.unwrap();
// Scenario 2: Low win rate (40%), unfavorable odds
let kelly_low = kelly_optimizer
.calculate_basic_kelly(
0.40, // 40% win rate
100.0, // Avg win: $100
50.0, // Avg loss: $50 (2:1 win/loss ratio, but low win rate)
)
.unwrap();
// Kelly formula: f = (bp - q) / b, where b = odds, p = win_prob, q = 1 - p
// High: (2 × 0.6 - 0.4) / 2 = (1.2 - 0.4) / 2 = 0.4 → clamped to 0.25 (max)
// Low: (2 × 0.4 - 0.6) / 2 = (0.8 - 0.6) / 2 = 0.1
assert!(
kelly_high >= 0.20,
"High win rate (60%) should produce Kelly fraction ≥ 0.20, got {}",
kelly_high
);
assert!(
kelly_low <= 0.15,
"Low win rate (40%) should produce Kelly fraction ≤ 0.15, got {}",
kelly_low
);
assert!(
kelly_high > kelly_low * 1.5,
"High performance Kelly ({}) should be significantly larger than low performance ({})",
kelly_high,
kelly_low
);
}
/// Test 2: Regime features change over time (trending vs ranging)
///
/// **EXPECTED**: Different market regimes produce different feature vectors
/// - Trending regime: High ADX (>25), directional CUSUM
/// - Ranging regime: Low ADX (<20), balanced CUSUM
#[test]
fn test_regime_features_change_over_time() {
// Simulate trending regime features
let trending_regime = vec![
1.0, // Regime type: 1 = Trending
0.85, // Confidence: 85%
5.0, // CUSUM positive: uptrend
0.0, // CUSUM negative: no downtrend
35.0, // ADX: strong trend
];
// Simulate ranging regime features
let ranging_regime = vec![
2.0, // Regime type: 2 = Ranging
0.75, // Confidence: 75%
2.0, // CUSUM positive: weak uptrend
2.0, // CUSUM negative: weak downtrend (balanced)
15.0, // ADX: weak trend
];
let state_trending = create_test_state_with_regime(100_000.0, 0.0, trending_regime.clone());
let state_ranging = create_test_state_with_regime(100_000.0, 0.0, ranging_regime.clone());
// Verify regime features are populated
assert_eq!(
state_trending.regime_features.len(),
5,
"Trending regime should have 5 features"
);
assert_eq!(
state_ranging.regime_features.len(),
5,
"Ranging regime should have 5 features"
);
// Verify regime type differs
assert_ne!(
state_trending.regime_features[0], state_ranging.regime_features[0],
"Regime types should differ (trending vs ranging)"
);
// Verify ADX differs significantly (trending > ranging)
assert!(
state_trending.regime_features[4] > state_ranging.regime_features[4] * 1.5,
"Trending ADX ({}) should be significantly higher than ranging ADX ({})",
state_trending.regime_features[4],
state_ranging.regime_features[4]
);
// Verify CUSUM balance differs (trending directional, ranging balanced)
let trending_cusum_imbalance =
(state_trending.regime_features[2] - state_trending.regime_features[3]).abs();
let ranging_cusum_imbalance =
(state_ranging.regime_features[2] - state_ranging.regime_features[3]).abs();
assert!(
trending_cusum_imbalance > ranging_cusum_imbalance,
"Trending CUSUM should be more imbalanced ({}) than ranging ({})",
trending_cusum_imbalance,
ranging_cusum_imbalance
);
}
/// Test 3: Kelly scales position size correctly
///
/// **EXPECTED**: Kelly fraction directly scales position sizing
/// - Kelly=0.5 → Target position = 50% of max_position
/// - Kelly=1.0 → Target position = 100% of max_position
#[test]
fn test_kelly_scales_position_size() {
let initial_capital = 10_000.0;
let price = 100.0;
let max_position = 100.0; // 100 contracts
// Test 1: Kelly = 0.5 (half Kelly)
let mut tracker_half = PortfolioTracker::new(initial_capital, 0.0001, 0.0);
let long_action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
);
tracker_half.execute_action_with_kelly(long_action, price, max_position, 0.5);
let position_half = tracker_half.current_position();
// Expected: 0.5 × 100 = 50 contracts
assert!(
(position_half - 50.0).abs() < 1.0,
"Kelly=0.5 should result in ~50 contracts, got {}",
position_half
);
// Test 2: Kelly = 1.0 (full Kelly)
let mut tracker_full = PortfolioTracker::new(initial_capital, 0.0001, 0.0);
tracker_full.execute_action_with_kelly(long_action, price, max_position, 1.0);
let position_full = tracker_full.current_position();
// Expected: 1.0 × 100 = 100 contracts
assert!(
(position_full - 100.0).abs() < 1.0,
"Kelly=1.0 should result in ~100 contracts, got {}",
position_full
);
// Verify 2x scaling
assert!(
(position_full / position_half - 2.0).abs() < 0.1,
"Full Kelly ({}) should be 2x half Kelly ({})",
position_full,
position_half
);
}
/// Test 4: Regime detection populates all 5 features correctly
///
/// **EXPECTED**: Regime features = [regime_type, confidence, cusum_plus, cusum_minus, adx]
/// - regime_type: 1 = Trending, 2 = Ranging, 3 = Volatile, 4 = Transition
/// - confidence: 0.0-1.0 (ADX-based confidence score)
/// - cusum_plus: Positive cumulative sum (uptrend strength)
/// - cusum_minus: Negative cumulative sum (downtrend strength)
/// - adx: Average Directional Index (trend strength indicator)
#[test]
fn test_regime_detection_populates_all_5_features() {
// Simulate realistic regime detection output
let regime_features = vec![
1.0, // Regime type: Trending (1)
0.82, // Confidence: 82% (ADX-derived)
4.5, // CUSUM+: Strong uptrend signal
0.3, // CUSUM-: Weak downtrend signal
30.0, // ADX: Strong trend (>25)
];
let state = create_test_state_with_regime(100_000.0, 0.0, regime_features.clone());
// Verify all 5 features present
assert_eq!(
state.regime_features.len(),
5,
"Regime features must have exactly 5 elements"
);
// Verify regime type is valid (1-4)
assert!(
state.regime_features[0] >= 1.0 && state.regime_features[0] <= 4.0,
"Regime type must be 1-4, got {}",
state.regime_features[0]
);
// Verify confidence is valid (0-1)
assert!(
state.regime_features[1] >= 0.0 && state.regime_features[1] <= 1.0,
"Confidence must be 0-1, got {}",
state.regime_features[1]
);
// Verify CUSUM values are non-negative
assert!(
state.regime_features[2] >= 0.0,
"CUSUM+ must be non-negative, got {}",
state.regime_features[2]
);
assert!(
state.regime_features[3] >= 0.0,
"CUSUM- must be non-negative, got {}",
state.regime_features[3]
);
// Verify ADX is reasonable (0-100, typically 0-60)
assert!(
state.regime_features[4] >= 0.0 && state.regime_features[4] <= 100.0,
"ADX must be 0-100, got {}",
state.regime_features[4]
);
}
/// Test 5: Kelly applied in backtest (simulated portfolio PnL)
///
/// **EXPECTED**: Kelly sizing reduces drawdowns while maintaining returns
/// - Half Kelly (0.5): Lower volatility, smaller positions
/// - Full Kelly (1.0): Higher volatility, larger positions
#[test]
fn test_kelly_applied_in_backtest() {
let initial_capital = 10_000.0;
let price_sequence = vec![100.0, 105.0, 103.0, 108.0, 112.0]; // +12% total move
// Scenario 1: Half Kelly (conservative)
let mut tracker_half = PortfolioTracker::new(initial_capital, 0.0001, 0.0);
let max_position = 50.0; // Max 50 contracts
for &price in &price_sequence {
let long_action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
);
tracker_half.execute_action_with_kelly(long_action, price, max_position, 0.5);
}
let final_value_half = tracker_half.total_value(*price_sequence.last().unwrap());
let pnl_half = final_value_half - initial_capital;
// Scenario 2: Full Kelly (aggressive)
let mut tracker_full = PortfolioTracker::new(initial_capital, 0.0001, 0.0);
for &price in &price_sequence {
let long_action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
);
tracker_full.execute_action_with_kelly(long_action, price, max_position, 1.0);
}
let final_value_full = tracker_full.total_value(*price_sequence.last().unwrap());
let pnl_full = final_value_full - initial_capital;
// Verify both are profitable (upward price movement)
assert!(
pnl_half > 0.0,
"Half Kelly should be profitable, got PnL: {}",
pnl_half
);
assert!(
pnl_full > 0.0,
"Full Kelly should be profitable, got PnL: {}",
pnl_full
);
// Verify Full Kelly has larger PnL (higher leverage)
assert!(
pnl_full > pnl_half * 1.3,
"Full Kelly PnL ({}) should be significantly larger than Half Kelly ({})",
pnl_full,
pnl_half
);
}
/// Test 6: Regime features in state tensor (state shape validation)
///
/// **EXPECTED**: State tensor shape = [batch, 128 + regime_dim]
/// - Without regime: [batch, 128] = 4 price + 121 technical + 3 portfolio
/// - With regime: [batch, 133] = 128 + 5 regime features
#[test]
fn test_regime_features_in_state_tensor() {
// State without regime features
let state_no_regime = create_test_state(100_000.0, 0.0);
// Calculate total feature count (price + technical + portfolio)
let features_no_regime = state_no_regime.price_features.len()
+ state_no_regime.technical_indicators.len()
+ state_no_regime.portfolio_features.len();
assert_eq!(
features_no_regime, 128,
"Base state should have 128 features (4 + 121 + 3), got {}",
features_no_regime
);
// State with regime features
let regime_features = vec![1.0, 0.8, 3.5, 0.5, 28.0]; // 5 regime features
let state_with_regime = create_test_state_with_regime(100_000.0, 0.0, regime_features);
// Calculate total feature count including regime
let features_with_regime = state_with_regime.price_features.len()
+ state_with_regime.technical_indicators.len()
+ state_with_regime.portfolio_features.len()
+ state_with_regime.regime_features.len();
assert_eq!(
features_with_regime, 133,
"State with regime should have 133 features (128 + 5), got {}",
features_with_regime
);
// Verify regime features are correctly appended
assert_eq!(
state_with_regime.regime_features.len(),
5,
"Regime features should have 5 elements"
);
}