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>
455 lines
14 KiB
Rust
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);
|
|
}
|