Files
foxhunt/ml/tests/bug19_bug20_integration_test.rs
jgrusewski 15496deb1d docs: Fix hyperopt blocker investigation - all systems operational
Investigation revealed all 3 "blockers" were false alarms:

BLOCKER #1 (FALSE): 45-action space already operational
- ml/src/trainers/dqn.rs:573 uses num_actions=45 (production)
- ml/src/hyperopt/adapters/dqn.rs:286 had stale comment (3→45)
- Fix: Updated documentation to reflect reality

BLOCKER #2 (COMPLETE): Action masking params already exposed
- max_position_absolute field exists in DQNHyperparameters
- Search space: 1.0-10.0 contracts (6D hyperopt)
- Thrashing risk constraint implemented

BLOCKER #3 (FALSE): Transaction costs fully implemented
- Order-type specific fees: LimitMaker 0.05%, Market 0.15%, IoC 0.10%
- PortfolioTracker applies costs during trade execution
- Cumulative tracking operational since Wave 9-A3

Files Modified:
- ml/src/hyperopt/adapters/dqn.rs (3 lines - doc corrections)
- CLAUDE.md (hyperopt status updated to READY)

Production Readiness:  CERTIFIED
- 6D parameter space operational
- All Wave 9-16 features integrated
- Ready for 30-100 trial hyperopt campaign

Report: /tmp/HYPEROPT_BLOCKER_INVESTIGATION_COMPLETE.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:22:57 +01:00

205 lines
6.9 KiB
Rust

// Bug #19 + Bug #20 Integration Test
// Tests that both fixes work together to prevent gradient collapse
use anyhow::Result;
use candle_core::{DType, Device, Tensor};
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::dqn::{WorkingDQN, WorkingDQNConfig};
#[test]
fn test_gradient_stability_with_normalized_portfolio() -> Result<()> {
// Test gradient stability across multiple epochs with normalized portfolio features
let mut config = WorkingDQNConfig::emergency_safe_defaults();
config.state_dim = 128;
config.num_actions = 45;
config.hidden_dims = vec![512, 256];
let dqn = WorkingDQN::new(config)?;
let tracker = PortfolioTracker::new(100_000.0, 0.0001, 0.0);
let device = dqn.device().clone();
// Simulate 5 epochs of 100 steps each
for epoch in 0..5 {
let mut epoch_q_values = Vec::new();
for step in 0..100 {
// Create state with normalized portfolio
let mut state_vec = vec![0.0f32; 128];
let portfolio_features = tracker.get_portfolio_features(4000.0);
state_vec[0..3].copy_from_slice(&portfolio_features);
// Add market features
for i in 3..128 {
state_vec[i] = ((step + epoch * 100) as f32 * 0.01).sin() * 0.1;
}
let state = Tensor::from_vec(state_vec, (1, 128), &device)?.to_dtype(DType::F32)?;
let q_values = dqn.forward(&state)?;
let q_vec = q_values.flatten_all()?.to_vec1::<f32>()?;
epoch_q_values.extend_from_slice(&q_vec);
}
// Check Q-values are finite
let max_q = epoch_q_values
.iter()
.copied()
.fold(f32::NEG_INFINITY, f32::max);
let min_q = epoch_q_values
.iter()
.copied()
.fold(f32::INFINITY, f32::min);
println!("Epoch {}: Q-range=[{:.2}, {:.2}]", epoch, min_q, max_q);
assert!(
max_q.is_finite() && min_q.is_finite(),
"Q-values became NaN/Inf in epoch {}",
epoch
);
}
println!("✅ Gradient stability maintained across all epochs");
Ok(())
}
#[test]
fn test_no_q_value_explosion_with_normalized_portfolio() -> Result<()> {
// Test that normalized portfolio features prevent Q-value explosion
let mut config = WorkingDQNConfig::emergency_safe_defaults();
config.state_dim = 128;
config.num_actions = 45;
config.hidden_dims = vec![512, 256];
let dqn = WorkingDQN::new(config)?;
let device = dqn.device().clone();
// Test with various portfolio values
let portfolio_values = vec![50_000.0, 100_000.0, 150_000.0, 200_000.0];
for &portfolio_val in &portfolio_values {
let tracker = PortfolioTracker::new(100_000.0, 0.0001, 0.0);
// Normalized value should be ratio to initial_capital
let normalized_value = (portfolio_val / 100_000.0) as f32;
let mut state_vec = vec![0.0f32; 128];
state_vec[0] = normalized_value; // After Bug #20 fix, this should be normalized
state_vec[1] = 0.5;
state_vec[2] = 0.0001;
let state = Tensor::from_vec(state_vec, (1, 128), &device)?.to_dtype(DType::F32)?;
let q_values = dqn.forward(&state)?;
let q_vec = q_values.flatten_all()?.to_vec1::<f32>()?;
let max_q = q_vec.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let min_q = q_vec.iter().copied().fold(f32::INFINITY, f32::min);
println!(
"Portfolio ${:.0}K (normalized {:.2}): Q-range=[{:.2}, {:.2}]",
portfolio_val / 1000.0,
normalized_value,
min_q,
max_q
);
// Q-values should stay reasonable (NOT explode to 1000-4197)
assert!(
max_q.abs() < 5000.0,
"Q-values exploded with portfolio ${}: max_q={}",
portfolio_val,
max_q
);
}
println!("✅ Q-values stay stable across different portfolio values");
Ok(())
}
#[test]
fn test_clamp_removal_allows_large_q_values() -> Result<()> {
// Bug #19: Test that Q-values are not artificially clamped
// Bug #20: Test that normalized portfolio prevents explosions
let mut config = WorkingDQNConfig::emergency_safe_defaults();
config.state_dim = 128;
config.num_actions = 45;
config.hidden_dims = vec![512, 256];
let dqn = WorkingDQN::new(config)?;
let tracker = PortfolioTracker::new(100_000.0, 0.0001, 0.0);
let device = dqn.device().clone();
// Test with normalized portfolio features
let mut state_vec = vec![0.0f32; 128];
let portfolio_features = tracker.get_portfolio_features(4000.0);
state_vec[0..3].copy_from_slice(&portfolio_features);
// Add large market features to test clamp removal
for i in 3..128 {
state_vec[i] = (i as f32 * 0.1).sin() * 10.0;
}
let state = Tensor::from_vec(state_vec, (1, 128), &device)?.to_dtype(DType::F32)?;
let q_values = dqn.forward(&state)?;
let q_vec = q_values.flatten_all()?.to_vec1::<f32>()?;
let max_q = q_vec.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let min_q = q_vec.iter().copied().fold(f32::INFINITY, f32::min);
println!("Q-value range with normalized portfolio + large features: [{:.2}, {:.2}]", min_q, max_q);
// After Bug #19 fix: Q-values are not clamped to ±1000
// After Bug #20 fix: Q-values don't explode due to normalized portfolio
assert!(q_vec.len() == 45, "Should have 45 Q-values");
// All Q-values should be finite (no explosion)
for &q in &q_vec {
assert!(q.is_finite(), "Q-value should be finite: {}", q);
}
println!("✅ Q-values are not clamped and remain stable");
Ok(())
}
#[test]
fn test_portfolio_normalization_prevents_feature_imbalance() -> Result<()> {
// Bug #20: Test that portfolio features are normalized
let tracker = PortfolioTracker::new(100_000.0, 0.0001, 0.0);
let features = tracker.get_portfolio_features(4000.0);
// Bug #20 fix: Portfolio value should be normalized to ~1.0
// NOT raw $100,000 value
let portfolio_feature = features[0];
println!("Portfolio feature value: {}", portfolio_feature);
// After fix, should be ~1.0 (normalized)
// Before fix, would be 100,000.0 (raw value)
assert!(
portfolio_feature.abs() < 10.0,
"Portfolio feature should be normalized, got: {}",
portfolio_feature
);
// Check feature scale consistency
let max_feature = features.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let min_feature = features.iter().copied().fold(f32::INFINITY, f32::min);
let scale_ratio = max_feature / min_feature.abs().max(0.001);
println!("Feature scale ratio: {:.2}", scale_ratio);
// Scale ratio should be reasonable (NOT 100,000x)
assert!(
scale_ratio < 1000.0,
"Feature scale ratio too large: {}",
scale_ratio
);
println!("✅ Portfolio features are properly normalized");
Ok(())
}