Files
foxhunt/ml/tests/dqn_microstructure_integration_test.rs
jgrusewski c645e6222d Wave 11: Rainbow DQN integration + 23/23 tests passing
CRITICAL FINDINGS from 3-trial validation:
- 85,120 gradient clipping warnings (81.6% of logs) - REGRESSION
- Rainbow features DISABLED: use_dueling=false, use_distributional=false, use_noisy_nets=false
- Negative Q-values confirmed: HOLD -1000 to -3250
- Performance: Sharpe 0.29 (target 0.77)

Changes:
- Fixed N-Step compilation (7/7 tests passing)
- Fixed Distributional compilation (6/6 tests passing)
- Fixed Dueling CUDA errors (10/10 tests passing)
- Added TDD validation for state_dim=225
- Total: 23/23 Wave 11 tests passing (100%)

Issues requiring investigation:
1. Why are Dueling/Distributional/Noisy disabled in hyperopt?
2. Why gradient explosion despite previous fixes?
3. Test coverage gaps - unit tests pass but integration fails

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 13:53:59 +01:00

455 lines
14 KiB
Rust

//! WAVE 3.10: Microstructure Integration Test
//!
//! Validates that all 12 microstructure features are properly integrated into DQN training:
//! - Features 128-135: 8 new OHLCV-based features from microstructure_features.rs
//! - Features 136-139: Reserved for Wave A features (Roll, Corwin-Schultz, Amihud, VPIN)
//!
//! Tests verify:
//! 1. State dimension extended from 128 → 140
//! 2. All 12 microstructure features calculated correctly
//! 3. Features normalized to [-1, 1] range
//! 4. No shape errors during 1-epoch training
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::features::microstructure_features::*;
use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer};
/// Test that DQN trainer initializes with correct state dimension (140)
#[test]
fn test_dqn_state_dimension_140() {
let params = DQNHyperparameters {
epochs: 1,
batch_size: 32,
learning_rate: 0.0001,
gamma: 0.95,
epsilon_start: 0.3,
epsilon_end: 0.05,
epsilon_decay: 0.995,
buffer_size: 10000,
min_replay_size: 100,
checkpoint_frequency: 10,
early_stopping_enabled: false,
q_value_floor: 0.1,
min_loss_improvement_pct: 1.0,
plateau_window: 10,
min_epochs_before_stopping: 5,
hold_penalty: -0.001,
use_huber_loss: true,
huber_delta: 1.0,
use_double_dqn: true,
gradient_clip_norm: Some(10.0),
hold_penalty_weight: 0.01,
movement_threshold: 0.02,
enable_preprocessing: false,
preprocessing_window: 50,
preprocessing_clip_sigma: 5.0,
tau: 0.001,
target_update_mode: ml::trainers::TargetUpdateMode::Soft,
target_update_frequency: 1000,
warmup_steps: 0,
initial_capital: 100_000.0,
cash_reserve_percent: 0.0,
enable_kelly_sizing: false,
enable_volatility_epsilon: false,
enable_risk_adjusted_rewards: false,
kelly_fractional: 0.25,
kelly_max_fraction: 0.5,
kelly_min_trades: 20,
volatility_window: 20,
enable_regime_qnetwork: false,
enable_compliance: false,
enable_drawdown_monitoring: false,
enable_position_limits: false,
enable_circuit_breaker: false,
enable_action_masking: true,
enable_entropy_regularization: false,
enable_stress_testing: false,
max_position_absolute: 2.0,
entropy_coefficient: None,
transaction_cost_multiplier: 1.0,
enable_triple_barrier: false,
triple_barrier_profit_target_bps: 100,
triple_barrier_stop_loss_bps: 50,
triple_barrier_max_holding_seconds: 3600,
use_per: false,
per_alpha: 0.6,
per_beta_start: 0.4,
};
let trainer = DQNTrainer::new(params);
assert!(
trainer.is_ok(),
"Failed to create DQN trainer: {:?}",
trainer.err()
);
// Note: We can't directly access state_dim from the trainer
// But we can verify it works by checking that training doesn't crash
}
/// Test High-Low Spread calculator (Feature 128)
#[test]
fn test_high_low_spread_calculation() {
let mut hl_spread = HighLowSpread::new(0.1);
// Test with 2% spread
let value = hl_spread.update(102.0, 98.0);
let midpoint = (102.0 + 98.0) / 2.0;
let expected = (102.0 - 98.0) / midpoint;
assert!(
(value - expected).abs() < 1e-6,
"High-Low spread calculation incorrect: expected {}, got {}",
expected,
value
);
// Test normalization
let normalized = hl_spread.get_normalized();
assert!(
normalized >= -1.0 && normalized <= 1.0,
"High-Low spread normalization out of bounds: {}",
normalized
);
}
/// Test Volume-Weighted Spread calculator (Feature 129)
#[test]
fn test_volume_weighted_spread_calculation() {
let mut vw_spread = VolumeWeightedSpread::new(0.1);
// Update with spread and volume
let spread = 0.01; // 1% spread
let volume = 10000.0;
let value = vw_spread.update(spread, volume);
assert!(value > 0.0, "VW spread should be positive");
// Test with 5x volume - should increase VW spread
let value2 = vw_spread.update(spread, 50000.0);
assert!(
value2 > value,
"VW spread should increase with higher volume"
);
// Test normalization
let normalized = vw_spread.get_normalized();
assert!(
normalized >= -1.0 && normalized <= 1.0,
"VW spread normalization out of bounds: {}",
normalized
);
}
/// Test Tick Count calculator (Feature 130)
#[test]
fn test_tick_count_calculation() {
let mut tick_count = TickCount::new(10);
// Feed 10 price changes
for i in 0..10 {
tick_count.update(100.0 + i as f64 * 0.1);
}
let count = tick_count.compute();
assert_eq!(count, 9, "Expected 9 price changes, got {}", count);
// Test normalization
let normalized = tick_count.get_normalized();
assert!(
normalized >= -1.0 && normalized <= 1.0,
"Tick count normalization out of bounds: {}",
normalized
);
}
/// Test Inter-Arrival Time calculator (Feature 131)
#[test]
fn test_inter_arrival_time_calculation() {
let mut iat = InterArrivalTime::new(5);
// Feed 5 timestamps with 1-second intervals
for i in 0..5 {
iat.update(i * 1_000_000_000); // nanoseconds
}
let avg_time = iat.compute();
assert!(
(avg_time - 1.0).abs() < 1e-6,
"Expected 1 second avg, got {}",
avg_time
);
// Test normalization
let normalized = iat.get_normalized();
assert!(
normalized >= -2.0 && normalized <= 2.0,
"Inter-arrival normalization out of acceptable range: {}",
normalized
);
}
/// Test Buy/Sell Imbalance calculator (Feature 132)
#[test]
fn test_buy_sell_imbalance_calculation() {
let mut imbalance = BuySellImbalance::new(0.2);
// Feed 10 increasing prices (all buys)
for i in 0..10 {
imbalance.update(100.0 + i as f64, 1000.0);
}
let value = imbalance.compute();
assert!(value > 0.5, "Expected strong buy pressure, got {}", value);
// Test normalization (already in [-1, 1])
let normalized = imbalance.get_normalized();
assert!(
normalized >= -1.0 && normalized <= 1.0,
"Buy/Sell imbalance normalization out of bounds: {}",
normalized
);
}
/// Test Kyle's Lambda calculator (Feature 133)
#[test]
fn test_kyle_lambda_calculation() {
let mut lambda = KyleLambda::new(0, 50); // Update every call for testing
// Feed 50 correlated returns and signed volumes
for i in 0..50 {
let ret = 0.001 * (i as f64 / 50.0);
let signed_vol = 1000.0 * (i as f64 / 50.0);
lambda.maybe_update(i * 1_000_000_000, ret, signed_vol);
}
let value = lambda.compute();
assert!(
value > 0.0,
"Expected positive lambda for positive correlation, got {}",
value
);
// Test normalization
let normalized = lambda.get_normalized();
assert!(
normalized >= -1.0 && normalized <= 1.0,
"Kyle's lambda normalization out of bounds: {}",
normalized
);
}
/// Test Price Impact calculator (Feature 134)
#[test]
fn test_price_impact_calculation() {
let mut impact = PriceImpact::new(0.1, 2);
// Simulate buy followed by price increase
impact.update(100.5, 99.5, 100.0);
impact.update(101.0, 100.0, 100.5); // Buy (close > prev)
impact.update(101.5, 100.5, 101.0); // Price lifted
impact.update(102.0, 101.0, 101.5); // Continued lift
let value = impact.compute();
// Price impact should be non-negative after buy
assert!(value >= 0.0, "Expected non-negative price impact");
// Test normalization (can go beyond [-1, 1] for extreme price movements)
let normalized = impact.get_normalized();
assert!(
normalized.is_finite(),
"Price impact normalization should be finite, got: {}",
normalized
);
}
/// Test Variance Ratio calculator (Feature 135)
#[test]
fn test_variance_ratio_calculation() {
let mut vr = VarianceRatio::new(20, 5);
// Feed 20 random-walk returns
use std::f64::consts::PI;
for i in 0..20 {
let ret = (i as f64 * PI).sin() * 0.001;
vr.update(ret);
}
let ratio = vr.compute();
assert!(
ratio > 0.5 && ratio < 2.0,
"Variance ratio out of expected range: {}",
ratio
);
// Test normalization
let normalized = vr.get_normalized();
assert!(
normalized >= -1.0 && normalized <= 1.0,
"Variance ratio normalization out of bounds: {}",
normalized
);
}
/// Test that all microstructure features reset correctly
#[test]
fn test_microstructure_reset() {
let mut hl_spread = HighLowSpread::new(0.1);
let mut tick_count = TickCount::new(10);
let mut imbalance = BuySellImbalance::new(0.2);
// Update features
hl_spread.update(102.0, 98.0);
tick_count.update(100.0);
tick_count.update(101.0);
imbalance.update(101.0, 1000.0);
// Verify non-zero before reset
assert!(hl_spread.value() > 0.0);
assert!(tick_count.value() > 0.0);
// Reset all features
hl_spread.reset();
tick_count.reset();
imbalance.reset();
// Verify zero after reset
assert_eq!(hl_spread.value(), 0.0, "High-Low spread should reset to 0");
assert_eq!(tick_count.value(), 0.0, "Tick count should reset to 0");
assert_eq!(
imbalance.value(),
0.0,
"Buy/Sell imbalance should reset to 0"
);
}
/// Test portfolio tracker integration (Features 125-127)
#[test]
fn test_portfolio_features_integration() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 0.0);
// Get initial features (should be zeros except cash)
let features = tracker.get_raw_portfolio_features(4000.0);
// Verify 3 features returned
assert_eq!(features.len(), 3, "Expected 3 portfolio features");
// Execute a trade to update position
use ml::dqn::portfolio_tracker::TradeAction;
let _ = tracker.execute_trade(TradeAction::Buy(1.0), 4000.0);
// Get updated features
let features_after = tracker.get_raw_portfolio_features(4010.0);
// Verify features changed after trade
assert_ne!(
features[0], features_after[0],
"Position should change after trade"
);
}
/// Integration test: Full 1-epoch training with microstructure features
#[tokio::test]
async fn test_dqn_training_with_microstructure_no_shape_errors() {
// Create minimal params for fast test
let params = DQNHyperparameters {
epochs: 1,
batch_size: 32,
learning_rate: 0.0001,
gamma: 0.95,
epsilon_start: 0.3,
epsilon_end: 0.05,
epsilon_decay: 0.995,
buffer_size: 1000,
min_replay_size: 100,
checkpoint_frequency: 10,
early_stopping_enabled: false,
q_value_floor: 0.1,
min_loss_improvement_pct: 1.0,
plateau_window: 10,
min_epochs_before_stopping: 5,
hold_penalty: -0.001,
use_huber_loss: true,
huber_delta: 1.0,
use_double_dqn: true,
gradient_clip_norm: Some(10.0),
hold_penalty_weight: 0.01,
movement_threshold: 0.02,
enable_preprocessing: false,
preprocessing_window: 50,
preprocessing_clip_sigma: 5.0,
tau: 0.001,
target_update_mode: ml::trainers::TargetUpdateMode::Soft,
target_update_frequency: 1000,
warmup_steps: 0,
initial_capital: 100_000.0,
cash_reserve_percent: 0.0,
enable_kelly_sizing: false,
enable_volatility_epsilon: false,
enable_risk_adjusted_rewards: false,
kelly_fractional: 0.25,
kelly_max_fraction: 0.5,
kelly_min_trades: 20,
volatility_window: 20,
enable_regime_qnetwork: false,
enable_compliance: false,
enable_drawdown_monitoring: false,
enable_position_limits: false,
enable_circuit_breaker: false,
enable_action_masking: true,
enable_entropy_regularization: false,
enable_stress_testing: false,
max_position_absolute: 2.0,
entropy_coefficient: None,
transaction_cost_multiplier: 1.0,
enable_triple_barrier: false,
triple_barrier_profit_target_bps: 100,
triple_barrier_stop_loss_bps: 50,
triple_barrier_max_holding_seconds: 3600,
use_per: false,
per_alpha: 0.6,
per_beta_start: 0.4,
};
let mut trainer = DQNTrainer::new(params).expect("Failed to create trainer");
// Test with actual parquet file (same as used in other tests)
let parquet_file = "test_data/ES_FUT_180d.parquet";
// Skip if test data doesn't exist
if !std::path::Path::new(parquet_file).exists() {
eprintln!("Skipping test: {} not found", parquet_file);
return;
}
// Define checkpoint callback (no-op for test)
let mut checkpoint_callback = |_epoch: usize, _data: Vec<u8>, _is_best: bool| -> Result<String, anyhow::Error> {
Ok("test_checkpoint".to_string())
};
// Run 1-epoch training
let result = trainer
.train_from_parquet(parquet_file, &mut checkpoint_callback)
.await;
// Verify training completes without shape errors
assert!(
result.is_ok(),
"Training failed with microstructure features: {:?}",
result.err()
);
let metrics = result.unwrap();
// Verify metrics exist
assert!(metrics.loss > 0.0, "Loss should be positive");
assert!(
metrics.accuracy >= 0.0 && metrics.accuracy <= 1.0,
"Accuracy should be in [0,1]"
);
println!("✅ WAVE 3.10: 1-epoch training completed successfully with 140-dim state");
println!(" Loss: {:.6}", metrics.loss);
println!(" Accuracy: {:.4}", metrics.accuracy);
}