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:
jgrusewski
2025-11-27 23:46:13 +01:00
parent 2c1acda2f3
commit 2df1ea92e1
763 changed files with 247870 additions and 1714 deletions

View 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
);
}