feat(ml): WAVE 29 DQN Codebase Cleanup & Refactoring Campaign
BREAKING CHANGES: - Removed orphaned dqn.rs monolithic trainer (4,975 lines) - Removed orphaned dqn_ensemble.rs module (816 lines) - Removed orphaned tft.rs and tft_complete_int8_integration_test.rs - TFT trainer split into modular directory structure DQN Module Refactoring: - Split trainers/dqn.rs into modular structure (config.rs, statistics.rs, trainer.rs) - Fixed hyperopt 39D search space (continuous params only) - Boolean flags (use_dueling, use_double_dqn, use_per, use_noisy_nets) are now FIXED architectural decisions - use_distributional defaults to false (Candle BUG #36 - scatter_add gradient issues) Clean Module Structure: - ml/src/trainers/dqn/ directory with proper mod.rs exports - ml/src/trainers/tft/ directory with config.rs, types.rs, model.rs, trainer.rs, tests.rs - All P0 features validated: TD-error clamping, batch diversity, LR scheduler, priority staleness Documentation: - Added comprehensive docs in docs/codebase-cleanup/ - ADR-001 for DQN refactoring decisions - Rainbow DQN component matrix and quick reference guides Build Status: Compiles with zero errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
383
ml/src/risk/tests/kelly_warmup_tests.rs
Normal file
383
ml/src/risk/tests/kelly_warmup_tests.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
//! Tests for Kelly Criterion warmup signal leakage prevention
|
||||
//!
|
||||
//! These tests verify that the concentration penalty logic:
|
||||
//! 1. Does not use hardcoded thresholds that models can memorize
|
||||
//! 2. Respects Kelly's warmup period and statistical confidence
|
||||
//! 3. Prevents signal leakage from future-looking information
|
||||
|
||||
use super::super::kelly_position_sizing_service::{
|
||||
KellyPositionSizingService, KellyServiceConfig, PositionSizingRequest, PositionTracker,
|
||||
RiskTolerance,
|
||||
};
|
||||
use crate::risk::KellyOptimizerConfig;
|
||||
use common::types::Price;
|
||||
|
||||
/// Create a test service with default configuration
|
||||
fn create_test_service() -> KellyPositionSizingService {
|
||||
let config = KellyServiceConfig::default();
|
||||
let position_tracker = PositionTracker::new();
|
||||
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(KellyPositionSizingService::new(config, position_tracker))
|
||||
.expect("Failed to create test service")
|
||||
}
|
||||
|
||||
/// Create a test service with custom warmup configuration
|
||||
fn create_custom_test_service(
|
||||
warmup_sample_size: usize,
|
||||
warmup_min_penalty: f64,
|
||||
confidence_penalty_range: f64,
|
||||
) -> KellyPositionSizingService {
|
||||
let mut config = KellyServiceConfig::default();
|
||||
config.kelly_warmup_sample_size = warmup_sample_size;
|
||||
config.warmup_min_penalty = warmup_min_penalty;
|
||||
config.confidence_penalty_range = confidence_penalty_range;
|
||||
|
||||
let position_tracker = PositionTracker::new();
|
||||
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(KellyPositionSizingService::new(config, position_tracker))
|
||||
.expect("Failed to create custom test service")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concentration_penalty_warmup_progression() {
|
||||
let service = create_test_service();
|
||||
|
||||
// Test warmup progression (0-20 trades)
|
||||
let test_cases = vec![
|
||||
(0, 0.7, "Zero samples"),
|
||||
(5, 0.7, "Early warmup"),
|
||||
(10, 0.7, "Mid warmup"),
|
||||
(15, 0.7, "Late warmup"),
|
||||
(20, 0.7, "Warmup complete"),
|
||||
];
|
||||
|
||||
let mut previous_penalty = 0.0;
|
||||
|
||||
for (sample_size, confidence, description) in test_cases {
|
||||
let penalty = service
|
||||
.apply_concentration_limits(
|
||||
0.1, // fraction
|
||||
0.05, // current_allocation
|
||||
0.6, // portfolio_concentration (> 0.5 to trigger penalty)
|
||||
confidence, // kelly_confidence
|
||||
sample_size, // kelly_sample_size
|
||||
)
|
||||
.expect(&format!("Failed to calculate penalty for {}", description));
|
||||
|
||||
// Verify penalty is within expected warmup range
|
||||
if sample_size < 20 {
|
||||
assert!(
|
||||
penalty >= 0.5,
|
||||
"{}: Penalty {:.3} should be >= 0.5 (warmup_min_penalty)",
|
||||
description,
|
||||
penalty
|
||||
);
|
||||
assert!(
|
||||
penalty <= 1.0,
|
||||
"{}: Penalty {:.3} should be <= 1.0",
|
||||
description,
|
||||
penalty
|
||||
);
|
||||
|
||||
// Verify monotonic increase during warmup
|
||||
if sample_size > 0 {
|
||||
assert!(
|
||||
penalty >= previous_penalty,
|
||||
"{}: Penalty should increase with sample size (current: {:.3}, previous: {:.3})",
|
||||
description,
|
||||
penalty,
|
||||
previous_penalty
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
previous_penalty = penalty;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concentration_penalty_confidence_scaling() {
|
||||
let service = create_test_service();
|
||||
|
||||
// Test post-warmup confidence scaling
|
||||
let confidence_levels = vec![
|
||||
(0.5, "Low confidence"),
|
||||
(0.7, "Medium confidence"),
|
||||
(0.85, "High confidence"),
|
||||
(0.95, "Very high confidence"),
|
||||
];
|
||||
|
||||
for (confidence, description) in confidence_levels {
|
||||
let penalty = service
|
||||
.apply_concentration_limits(
|
||||
0.1, // fraction
|
||||
0.05, // current_allocation
|
||||
0.6, // portfolio_concentration (> 0.5)
|
||||
confidence,
|
||||
25, // Post-warmup (> 20)
|
||||
)
|
||||
.expect(&format!(
|
||||
"Failed to calculate penalty for {}",
|
||||
description
|
||||
));
|
||||
|
||||
// Calculate expected penalty: base_penalty + (confidence * range)
|
||||
// With defaults: 0.8 + (confidence * 0.20)
|
||||
let expected = 0.8 + (confidence * 0.20);
|
||||
|
||||
assert!(
|
||||
(penalty - expected).abs() < 0.01,
|
||||
"{}: Expected penalty {:.3}, got {:.3}",
|
||||
description,
|
||||
expected,
|
||||
penalty
|
||||
);
|
||||
|
||||
// Verify penalty scales with confidence
|
||||
assert!(
|
||||
penalty >= 0.8,
|
||||
"{}: Penalty {:.3} should be >= base_penalty (0.8)",
|
||||
description,
|
||||
penalty
|
||||
);
|
||||
assert!(
|
||||
penalty <= 1.0,
|
||||
"{}: Penalty {:.3} should be <= 1.0",
|
||||
description,
|
||||
penalty
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_hardcoded_thresholds() {
|
||||
// Verify no magic numbers in concentration penalty logic
|
||||
let service = create_test_service();
|
||||
|
||||
let mut penalties = Vec::new();
|
||||
|
||||
for sample_size in 0..30 {
|
||||
let confidence = (sample_size as f64 / 30.0).min(1.0);
|
||||
let penalty = service
|
||||
.apply_concentration_limits(
|
||||
0.1, // fraction
|
||||
0.05, // current_allocation
|
||||
0.6, // portfolio_concentration
|
||||
confidence,
|
||||
sample_size,
|
||||
)
|
||||
.expect("Failed to calculate penalty");
|
||||
|
||||
penalties.push((penalty * 1000.0) as i64);
|
||||
}
|
||||
|
||||
// Convert to set to count unique values
|
||||
let unique_penalties: std::collections::HashSet<_> = penalties.iter().cloned().collect();
|
||||
|
||||
// Should have significant variation, not constant values
|
||||
// With 30 samples, we should have at least 15 unique penalties
|
||||
assert!(
|
||||
unique_penalties.len() >= 15,
|
||||
"Expected at least 15 unique penalties during 30-sample progression, got {}. \
|
||||
This suggests hardcoded thresholds.",
|
||||
unique_penalties.len()
|
||||
);
|
||||
|
||||
// Verify penalties are not clustered around the old hardcoded 0.8 value
|
||||
let near_old_threshold = penalties
|
||||
.iter()
|
||||
.filter(|&&p| (p - 800).abs() < 10) // Within 1% of 0.8
|
||||
.count();
|
||||
|
||||
assert!(
|
||||
near_old_threshold < penalties.len() / 3,
|
||||
"Too many penalties ({}/{}) clustered near old hardcoded 0.8 threshold. \
|
||||
Expected dynamic scaling.",
|
||||
near_old_threshold,
|
||||
penalties.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_kelly_warmup_prevents_signal_leakage() {
|
||||
let service = create_test_service();
|
||||
|
||||
// Simulate training scenario with gradual data accumulation
|
||||
let mut recommendations = Vec::new();
|
||||
|
||||
for epoch in 0..50 {
|
||||
let sample_size = epoch / 2; // Gradual data accumulation
|
||||
let confidence = (sample_size as f64 / 20.0).min(0.95);
|
||||
|
||||
let penalty = service
|
||||
.apply_concentration_limits(
|
||||
0.1, // fraction
|
||||
0.05, // current_allocation
|
||||
0.6, // portfolio_concentration
|
||||
confidence,
|
||||
sample_size,
|
||||
)
|
||||
.expect("Failed to calculate penalty");
|
||||
|
||||
recommendations.push((sample_size, (penalty * 10000.0) as i64));
|
||||
}
|
||||
|
||||
// Verify: No repeated fractions during warmup period
|
||||
let warmup_penalties: Vec<i64> = recommendations
|
||||
.iter()
|
||||
.filter(|(size, _)| *size < 20)
|
||||
.map(|(_, penalty)| *penalty)
|
||||
.collect();
|
||||
|
||||
let unique_warmup: std::collections::HashSet<_> =
|
||||
warmup_penalties.iter().cloned().collect();
|
||||
|
||||
// During warmup, should have variety not constant values
|
||||
// With ~20 warmup epochs, should have at least 10 unique penalties
|
||||
assert!(
|
||||
unique_warmup.len() >= 10,
|
||||
"Expected at least 10 unique penalties during warmup (20 samples), got {}. \
|
||||
This suggests signal leakage from hardcoded values.",
|
||||
unique_warmup.len()
|
||||
);
|
||||
|
||||
// Verify penalties increase monotonically during warmup
|
||||
let mut warmup_monotonic = true;
|
||||
for i in 1..warmup_penalties.len() {
|
||||
if warmup_penalties[i] < warmup_penalties[i - 1] {
|
||||
warmup_monotonic = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
warmup_monotonic,
|
||||
"Penalties should increase monotonically during warmup to prevent memorization"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concentration_penalty_temporal_safety() {
|
||||
let service = create_test_service();
|
||||
|
||||
// Verify penalty at time T doesn't depend on data from T+1
|
||||
let penalty_t0 = service
|
||||
.apply_concentration_limits(
|
||||
0.1, // fraction
|
||||
0.05, // current_allocation
|
||||
0.6, // portfolio_concentration
|
||||
0.7, // confidence
|
||||
10, // sample_size
|
||||
)
|
||||
.expect("Failed to calculate penalty at T0");
|
||||
|
||||
// Simulate "future" data accumulation (should not affect past penalty)
|
||||
// In real scenario, more trades would accumulate
|
||||
// But re-computing with same inputs should give same result
|
||||
|
||||
let penalty_t0_recomputed = service
|
||||
.apply_concentration_limits(
|
||||
0.1, // Same inputs
|
||||
0.05, 0.6, 0.7, 10,
|
||||
)
|
||||
.expect("Failed to recompute penalty at T0");
|
||||
|
||||
// Penalty should be identical (no look-ahead bias)
|
||||
assert_eq!(
|
||||
(penalty_t0 * 10000.0) as i64,
|
||||
(penalty_t0_recomputed * 10000.0) as i64,
|
||||
"Penalty changed when recomputed with same inputs - suggests temporal leakage"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_low_concentration_no_penalty() {
|
||||
let service = create_test_service();
|
||||
|
||||
// Test that low concentration (< 0.5) results in no penalty
|
||||
let penalty = service
|
||||
.apply_concentration_limits(
|
||||
0.1, // fraction
|
||||
0.05, // current_allocation
|
||||
0.3, // portfolio_concentration (< 0.5, should trigger no penalty)
|
||||
0.7, // confidence
|
||||
10, // sample_size
|
||||
)
|
||||
.expect("Failed to calculate penalty for low concentration");
|
||||
|
||||
// Should return the input fraction without penalty adjustment
|
||||
// (after applying max allocation limits)
|
||||
assert!(
|
||||
(penalty - 0.1).abs() < 0.001,
|
||||
"Expected no penalty for low concentration, got penalty factor {:.3}",
|
||||
penalty
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_warmup_configuration_customization() {
|
||||
// Test that custom warmup configuration is respected
|
||||
let service = create_custom_test_service(
|
||||
30, // warmup_sample_size
|
||||
0.4, // warmup_min_penalty
|
||||
0.25, // confidence_penalty_range
|
||||
);
|
||||
|
||||
// Test warmup period extends to 30 samples
|
||||
let penalty_at_25 = service
|
||||
.apply_concentration_limits(0.1, 0.05, 0.6, 0.7, 25)
|
||||
.expect("Failed to calculate penalty");
|
||||
|
||||
// Should still be in warmup mode (< 30)
|
||||
let expected_warmup_progress = 25.0 / 30.0;
|
||||
let expected_penalty = 0.4 + (0.6 * expected_warmup_progress);
|
||||
|
||||
assert!(
|
||||
(penalty_at_25 - expected_penalty).abs() < 0.01,
|
||||
"Custom warmup configuration not respected: expected {:.3}, got {:.3}",
|
||||
expected_penalty,
|
||||
penalty_at_25
|
||||
);
|
||||
|
||||
// Test post-warmup uses custom confidence range
|
||||
let penalty_at_35 = service
|
||||
.apply_concentration_limits(0.1, 0.05, 0.6, 0.8, 35)
|
||||
.expect("Failed to calculate penalty");
|
||||
|
||||
// Should be post-warmup (>= 30)
|
||||
let expected_post_warmup = 0.75 + (0.8 * 0.25); // base + (confidence * range)
|
||||
|
||||
assert!(
|
||||
(penalty_at_35 - expected_post_warmup).abs() < 0.01,
|
||||
"Custom confidence range not respected: expected {:.3}, got {:.3}",
|
||||
expected_post_warmup,
|
||||
penalty_at_35
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_zero_sample_size_handling() {
|
||||
let service = create_test_service();
|
||||
|
||||
// Verify safe handling of zero sample size (start of training)
|
||||
let penalty = service
|
||||
.apply_concentration_limits(
|
||||
0.1, // fraction
|
||||
0.05, // current_allocation
|
||||
0.6, // portfolio_concentration
|
||||
0.0, // confidence (should be 0 with no samples)
|
||||
0, // sample_size (zero)
|
||||
)
|
||||
.expect("Failed to handle zero sample size");
|
||||
|
||||
// Should apply most conservative warmup penalty
|
||||
assert!(
|
||||
(penalty - 0.5).abs() < 0.01,
|
||||
"Expected minimum warmup penalty (0.5) for zero samples, got {:.3}",
|
||||
penalty
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user