Files
foxhunt/ml/tests/preprocessing_integration_test.rs
jgrusewski e166a4fc02 Wave 3: Update LOW RISK test files (225→54 features)
- Updated 73 test files across 10 categories
- Total 557 replacements (225 → 54)
- DQN tests: 252/262 passing (9 failures - slice index blocker)
- TFT tests: 98/98 passing
- MAMBA-2 tests: 11/11 passing
- Hyperopt tests: 98/98 passing

Critical findings:
- Blocker: ml/src/trainers/dqn.rs:3444 hardcoded slice indices
- Architecture mismatch: extract_current_features() vs extract_current_features_v2()

Wave 3 Agent breakdown:
- Agent 1: DQN test files (12 files)
- Agent 2: PPO test files (2 files)
- Agent 3: TFT test files (6 files)
- Agent 4: MAMBA-2 test files (2 files)
- Agent 5: Feature extraction tests (3 files)
- Agent 6: Integration test files (9 files)
- Agent 7: Data loader test files (3 files)
- Agent 8: Hyperopt test files (1 file)
- Agent 9: Benchmark test files (9 files)
- Agent 10: Utility & misc test files (73 files)

Next: Fix slice index blocker, then Wave 4 (OFI integration 46→54)
2025-11-23 01:22:32 +01:00

420 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Integration test for preprocessing module in DQN data pipeline
//!
//! Validates that preprocessing improves stationarity, reduces kurtosis,
//! and controls outliers while preserving feature extraction capabilities.
//!
//! Test Coverage:
//! 1. Stationarity improvement (ADF test)
//! 2. Kurtosis reduction (< 10 after clipping)
//! 3. Max z-score reduction (< 6.0 after clipping)
//! 4. Feature extraction compatibility with log returns
//! 5. NaN handling at start of series
//! 6. End-to-end pipeline integration
use anyhow::Result;
use candle_core::{Device, Tensor};
/// Test helper: Generate realistic price series with trend and volatility
fn generate_test_prices(n: usize) -> Vec<f64> {
let mut prices = Vec::with_capacity(n);
let mut price = 4000.0; // Start at ES futures level
for i in 0..n {
// Add trend + noise + occasional jumps
let trend = 0.0001 * (i as f64);
let noise = (i as f64 * 0.123).sin() * 2.0;
let jump = if i % 100 == 0 {
10.0 * ((i / 100) as f64).cos()
} else {
0.0
};
price += trend + noise + jump;
prices.push(price);
}
prices
}
#[test]
fn test_stationarity_improvement() -> Result<()> {
use ml::preprocessing::{preprocess_prices, PreprocessConfig};
// Generate non-stationary price series (1000 bars)
let prices_vec = generate_test_prices(1000);
let prices_tensor = Tensor::from_slice(&prices_vec, (1000,), &Device::Cpu)?;
// Apply preprocessing with default config
let config = PreprocessConfig::default();
let preprocessed = preprocess_prices(&prices_tensor, config)?;
// Verify output shape matches input
assert_eq!(preprocessed.dims(), &[1000]);
// Verify no NaN values in output (after warmup)
let preprocessed_vec: Vec<f32> = preprocessed.to_vec1()?;
let warmup = config.window_size as usize;
for (i, &val) in preprocessed_vec.iter().enumerate().skip(warmup) {
assert!(
val.is_finite(),
"Found non-finite value at index {}: {}",
i,
val
);
}
// Verify data has reasonable range (should be mostly within ±5σ after clipping)
let max_abs = preprocessed_vec
.iter()
.skip(warmup)
.map(|&x| x.abs())
.fold(0.0f32, f32::max);
assert!(
max_abs < 10.0,
"Max absolute value {} exceeds expected range after clipping",
max_abs
);
println!("✅ Stationarity test passed: max_abs={:.4}", max_abs);
Ok(())
}
#[test]
fn test_kurtosis_reduction() -> Result<()> {
use ml::preprocessing::{clip_outliers, compute_log_returns};
// Generate price series with extreme outliers
let mut prices_vec = generate_test_prices(500);
// Inject extreme outliers
prices_vec[100] *= 1.5; // 50% jump
prices_vec[200] *= 0.7; // 30% drop
prices_vec[300] *= 1.8; // 80% jump
let prices_tensor = Tensor::from_slice(&prices_vec, (500,), &Device::Cpu)?;
// Compute log returns
let returns = compute_log_returns(&prices_tensor)?;
// Compute kurtosis before clipping
let returns_vec: Vec<f32> = returns.to_vec1()?;
let mean = returns_vec.iter().sum::<f32>() / returns_vec.len() as f32;
let variance =
returns_vec.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / returns_vec.len() as f32;
let std = variance.sqrt();
let kurtosis_before = returns_vec
.iter()
.map(|&x| ((x - mean) / std).powi(4))
.sum::<f32>()
/ returns_vec.len() as f32;
println!("Kurtosis before clipping: {:.2}", kurtosis_before);
// Apply outlier clipping (±5σ)
let clipped = clip_outliers(&returns, 5.0)?;
// Compute kurtosis after clipping
let clipped_vec: Vec<f32> = clipped.to_vec1()?;
let mean_after = clipped_vec.iter().sum::<f32>() / clipped_vec.len() as f32;
let variance_after = clipped_vec
.iter()
.map(|&x| (x - mean_after).powi(2))
.sum::<f32>()
/ clipped_vec.len() as f32;
let std_after = variance_after.sqrt();
let kurtosis_after = clipped_vec
.iter()
.map(|&x| ((x - mean_after) / std_after).powi(4))
.sum::<f32>()
/ clipped_vec.len() as f32;
println!("Kurtosis after clipping: {:.2}", kurtosis_after);
// Verify kurtosis is reduced (should be closer to Gaussian kurtosis = 3.0)
assert!(
kurtosis_after < kurtosis_before,
"Kurtosis not reduced: before={:.2}, after={:.2}",
kurtosis_before,
kurtosis_after
);
// Verify kurtosis is reasonable (< 10 for fat tails)
assert!(
kurtosis_after < 10.0,
"Kurtosis still too high after clipping: {:.2}",
kurtosis_after
);
println!(
"✅ Kurtosis reduction test passed: {:.2}{:.2}",
kurtosis_before, kurtosis_after
);
Ok(())
}
#[test]
fn test_max_zscore_control() -> Result<()> {
use ml::preprocessing::{clip_outliers, windowed_normalize};
// Generate data with extreme outliers
let mut data_vec: Vec<f32> = (0..300).map(|i| (i as f32 * 0.1).sin()).collect();
// Inject extreme outliers
data_vec[50] = 100.0; // Extreme positive
data_vec[150] = -100.0; // Extreme negative
data_vec[250] = 150.0; // Very extreme
let data_tensor = Tensor::from_slice(&data_vec, (300,), &Device::Cpu)?;
// Apply windowed normalization
let normalized = windowed_normalize(&data_tensor, 50)?;
// Compute max z-score before clipping
let norm_vec: Vec<f32> = normalized.to_vec1()?;
let max_zscore_before = norm_vec.iter()
.skip(50) // Skip warmup
.map(|&x| x.abs())
.fold(0.0f32, f32::max);
println!("Max z-score before clipping: {:.2}", max_zscore_before);
// Apply clipping at ±5σ
let clipped = clip_outliers(&normalized, 5.0)?;
// Compute max z-score after clipping
let clipped_vec: Vec<f32> = clipped.to_vec1()?;
let max_zscore_after = clipped_vec.iter()
.skip(50) // Skip warmup
.map(|&x| x.abs())
.fold(0.0f32, f32::max);
println!("Max z-score after clipping: {:.2}", max_zscore_after);
// Verify max z-score is controlled
// Note: Clipping at ±5σ can result in values slightly above 5.0 due to windowed normalization
assert!(
max_zscore_after < 7.0,
"Max z-score {} exceeds 7.0 after clipping at ±5σ",
max_zscore_after
);
// Verify clipping actually reduced extreme values
assert!(
max_zscore_after < max_zscore_before,
"Clipping did not reduce max z-score: before={:.2}, after={:.2}",
max_zscore_before,
max_zscore_after
);
println!(
"✅ Z-score control test passed: {:.2}{:.2}",
max_zscore_before, max_zscore_after
);
Ok(())
}
#[test]
fn test_feature_extraction_compatibility() -> Result<()> {
use chrono::{DateTime, TimeZone, Utc};
use ml::features::extraction::{FeatureExtractor, OHLCVBar};
use ml::preprocessing::{preprocess_prices, PreprocessConfig};
// Generate realistic OHLCV bars
let n = 200;
let mut bars = Vec::with_capacity(n);
let mut price = 4000.0;
let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // 2021-01-01
for i in 0..n {
let noise = (i as f64 * 0.123).sin() * 2.0;
price += noise;
let bar = OHLCVBar {
timestamp: base_time + chrono::Duration::seconds(i as i64 * 60),
open: price - 0.5,
high: price + 1.0,
low: price - 1.0,
close: price,
volume: 1000.0 + (i as f64 * 10.0),
};
bars.push(bar);
}
// Extract close prices
let close_prices: Vec<f64> = bars.iter().map(|b| b.close).collect();
let close_tensor = Tensor::from_slice(&close_prices, (n,), &Device::Cpu)?;
// Apply preprocessing
let config = PreprocessConfig {
window_size: 50,
clip_sigma: 5.0,
use_log_returns: true,
};
let preprocessed = preprocess_prices(&close_tensor, config)?;
// Verify feature extractor still works with original bars
let mut extractor = FeatureExtractor::new();
const WARMUP: usize = 50;
for (i, bar) in bars.iter().enumerate() {
extractor.update(bar)?;
if i >= WARMUP {
let features = extractor.extract_current_features()?;
// Verify 54 features extracted
assert_eq!(
features.len(),
54,
"Expected 54 features, got {}",
features.len()
);
// Verify features are finite
for (j, &feat) in features.iter().enumerate() {
assert!(
feat.is_finite(),
"Non-finite feature at index {} in bar {}: {}",
j,
i,
feat
);
}
}
}
println!("✅ Feature extraction compatibility test passed");
Ok(())
}
#[test]
fn test_nan_handling_warmup() -> Result<()> {
use ml::preprocessing::{clip_outliers, compute_log_returns, windowed_normalize};
// Small dataset to test warmup behavior
let prices_vec: Vec<f64> = (0..150).map(|i| 4000.0 + (i as f64 * 0.1)).collect();
let prices_tensor = Tensor::from_slice(&prices_vec, (150,), &Device::Cpu)?;
// Compute log returns (first value should be 0.0)
let returns = compute_log_returns(&prices_tensor)?;
let returns_vec: Vec<f32> = returns.to_vec1()?;
assert_eq!(returns_vec.len(), 150);
assert!(
(returns_vec[0] - 0.0).abs() < 1e-6,
"First return should be 0.0, got {}",
returns_vec[0]
);
// Apply windowed normalization (warmup = 50)
let normalized = windowed_normalize(&returns, 50)?;
let norm_vec: Vec<f32> = normalized.to_vec1()?;
// First 50 values will use smaller windows, should still be finite
for (i, &val) in norm_vec.iter().enumerate() {
assert!(
val.is_finite(),
"Non-finite value at index {} during warmup: {}",
i,
val
);
}
// Apply clipping
let clipped = clip_outliers(&normalized, 3.0)?;
let clipped_vec: Vec<f32> = clipped.to_vec1()?;
// All values should be finite
for (i, &val) in clipped_vec.iter().enumerate() {
assert!(
val.is_finite(),
"Non-finite value at index {} after clipping: {}",
i,
val
);
}
println!("✅ NaN handling test passed");
Ok(())
}
#[test]
fn test_end_to_end_pipeline() -> Result<()> {
use ml::preprocessing::{preprocess_prices, PreprocessConfig};
// Generate realistic price series (500 bars = ~8 hours at 1-minute resolution)
let prices_vec = generate_test_prices(500);
let prices_tensor = Tensor::from_slice(&prices_vec, (500,), &Device::Cpu)?;
// Configure preprocessing
let config = PreprocessConfig {
window_size: 120, // 2-hour rolling window
clip_sigma: 3.0, // Clip at ±3σ
use_log_returns: true,
};
// Apply full pipeline
let preprocessed = preprocess_prices(&prices_tensor, config)?;
// Validate output
let preprocessed_vec: Vec<f32> = preprocessed.to_vec1()?;
// Check shape
assert_eq!(preprocessed_vec.len(), 500);
// Check warmup period has finite values
for i in 0..config.window_size as usize {
assert!(
preprocessed_vec[i].is_finite(),
"Non-finite value at index {} in warmup: {}",
i,
preprocessed_vec[i]
);
}
// Check post-warmup statistics
let post_warmup: Vec<f32> = preprocessed_vec[config.window_size as usize..].to_vec();
let mean = post_warmup.iter().sum::<f32>() / post_warmup.len() as f32;
let variance =
post_warmup.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / post_warmup.len() as f32;
let std = variance.sqrt();
let max_abs = post_warmup.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
println!("Post-warmup statistics:");
println!(" Mean: {:.6}", mean);
println!(" Std: {:.4}", std);
println!(" Max abs: {:.4}", max_abs);
// Validate statistics are reasonable
assert!(
mean.abs() < 0.5,
"Mean {} too far from zero (expected near 0 for normalized data)",
mean
);
assert!(
std > 0.5 && std < 2.0,
"Std {} outside reasonable range [0.5, 2.0]",
std
);
assert!(
max_abs < 6.0,
"Max absolute value {} exceeds clipping threshold",
max_abs
);
println!("✅ End-to-end pipeline test passed");
Ok(())
}