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