WAVE 21: Core type definitions and trainer configs updated Files Modified (13 files): - ml/src/features/extraction.rs: FeatureVector = [f64; 54] - common/src/features/types.rs: Added FeatureVector54 - ml/src/trainers/dqn.rs: state_dim 225→54 - ml/src/trainers/ppo.rs: state_dim 225→54 - ml/src/dqn/dqn.rs, config.rs, replay_buffer.rs: Updated configs - ml/src/hyperopt/adapters/: All adapters updated to 54-dim - ml/src/features/unified.rs: Struct fields updated - ml/src/trainers/tft_parquet.rs: Return types updated Agents Deployed: 5 parallel agents Test Results: cargo check --package ml --lib PASSING Next: Wave 2 (examples), Wave 3 (tests), Wave 4 (OFI integration) Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
482 lines
13 KiB
Rust
482 lines
13 KiB
Rust
//! Edge Case Tests for 46-Feature Extraction
|
|
//!
|
|
//! This test suite validates feature extraction under extreme conditions:
|
|
//! - Market open/close boundaries
|
|
//! - Missing data and gaps
|
|
//! - Zero volume bars
|
|
//! - Flat price bars
|
|
//! - Extreme price movements
|
|
//! - Overnight gaps
|
|
//! - Data quality edge cases
|
|
|
|
#![allow(unused_crate_dependencies)]
|
|
|
|
use ml::features::extraction::{extract_ml_features, OHLCVBar};
|
|
use anyhow::Result;
|
|
use chrono::{Duration, Utc};
|
|
|
|
/// Test: Market open boundary handling
|
|
#[test]
|
|
fn test_market_open_boundary() -> Result<()> {
|
|
// OBJECTIVE: Test feature extraction at market open
|
|
// EXPECTED: is_market_open=1, minutes_since_open=~0
|
|
|
|
let mut bars = Vec::new();
|
|
let mut timestamp = Utc::now()
|
|
.with_hour(13) // 9:30 AM ET = 13:30 UTC
|
|
.unwrap()
|
|
.with_minute(30)
|
|
.unwrap();
|
|
|
|
for _ in 0..60 {
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: 4500.0,
|
|
high: 4505.0,
|
|
low: 4495.0,
|
|
close: 4500.0,
|
|
volume: 1000.0,
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
assert!(!features.is_empty(), "Should extract features");
|
|
|
|
println!("✅ Market open boundary handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Market close boundary handling
|
|
#[test]
|
|
fn test_market_close_boundary() -> Result<()> {
|
|
// OBJECTIVE: Test feature extraction at market close
|
|
// EXPECTED: is_market_open=1, minutes_to_close=~0
|
|
|
|
let mut bars = Vec::new();
|
|
let mut timestamp = Utc::now()
|
|
.with_hour(20) // 4:00 PM ET = 20:00 UTC
|
|
.unwrap()
|
|
.with_minute(0)
|
|
.unwrap();
|
|
|
|
for _ in 0..60 {
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: 4500.0,
|
|
high: 4505.0,
|
|
low: 4495.0,
|
|
close: 4500.0,
|
|
volume: 1000.0,
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
assert!(!features.is_empty(), "Should extract features");
|
|
|
|
println!("✅ Market close boundary handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Overnight gap handling
|
|
#[test]
|
|
fn test_overnight_gap() -> Result<()> {
|
|
// OBJECTIVE: Test handling of overnight gap (market closed)
|
|
// EXPECTED: is_market_open=0, features computed correctly, no NaN/Inf
|
|
|
|
let mut bars = Vec::new();
|
|
|
|
// Last bar of day 1 (4:00 PM ET)
|
|
let mut timestamp = Utc::now()
|
|
.with_hour(20)
|
|
.unwrap()
|
|
.with_minute(0)
|
|
.unwrap();
|
|
|
|
for _ in 0..30 {
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: 4500.0,
|
|
high: 4505.0,
|
|
low: 4495.0,
|
|
close: 4500.0,
|
|
volume: 1000.0,
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
// Overnight gap (16 hours)
|
|
timestamp = timestamp + Duration::hours(16);
|
|
|
|
// First bar of day 2 (9:30 AM ET next day)
|
|
for _ in 0..30 {
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: 4510.0,
|
|
high: 4515.0,
|
|
low: 4505.0,
|
|
close: 4510.0,
|
|
volume: 1000.0,
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
|
|
// Should handle overnight gap without NaN/Inf
|
|
for fv in &features {
|
|
for &value in fv.iter() {
|
|
assert!(value.is_finite(), "Found NaN/Inf after overnight gap");
|
|
}
|
|
}
|
|
|
|
println!("✅ Overnight gap handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Zero volume bars
|
|
#[test]
|
|
fn test_zero_volume_bars() -> Result<()> {
|
|
// OBJECTIVE: Test handling of zero-volume bars
|
|
// EXPECTED: No division-by-zero, features computed safely
|
|
|
|
let mut bars = Vec::new();
|
|
let mut timestamp = Utc::now();
|
|
|
|
for i in 0..60 {
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: 4500.0,
|
|
high: 4505.0,
|
|
low: 4495.0,
|
|
close: 4500.0,
|
|
volume: if i % 10 == 0 { 0.0 } else { 1000.0 },
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
|
|
// Should handle zero volume without NaN/Inf
|
|
for (vec_idx, fv) in features.iter().enumerate() {
|
|
for (feat_idx, &value) in fv.iter().enumerate() {
|
|
assert!(value.is_finite(),
|
|
"Found NaN/Inf at vector {} feature {} (zero volume handling)",
|
|
vec_idx, feat_idx);
|
|
}
|
|
}
|
|
|
|
println!("✅ Zero volume bars handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Flat price bars (open=high=low=close)
|
|
#[test]
|
|
fn test_flat_price_bars() -> Result<()> {
|
|
// OBJECTIVE: Test handling of flat price bars
|
|
// EXPECTED: No division-by-zero, features computed correctly
|
|
|
|
let mut bars = Vec::new();
|
|
let mut timestamp = Utc::now();
|
|
|
|
for i in 0..60 {
|
|
let price = if i % 10 == 0 { 4500.0 } else { 4500.0 + (i as f64) };
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: price,
|
|
high: price, // Flat bar
|
|
low: price,
|
|
close: price,
|
|
volume: 1000.0,
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
|
|
// Should handle flat bars without NaN/Inf
|
|
for fv in &features {
|
|
for &value in fv.iter() {
|
|
assert!(value.is_finite(), "Found NaN/Inf from flat price bars");
|
|
}
|
|
}
|
|
|
|
println!("✅ Flat price bars handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Extreme price spike (>5% move)
|
|
#[test]
|
|
fn test_extreme_price_spike() -> Result<()> {
|
|
// OBJECTIVE: Test handling of extreme price movements
|
|
// EXPECTED: Features computed correctly, no infinite values
|
|
|
|
let mut bars = Vec::new();
|
|
let mut timestamp = Utc::now();
|
|
let mut price = 4500.0;
|
|
|
|
for i in 0..60 {
|
|
if i == 30 {
|
|
// Extreme spike: +10%
|
|
price *= 1.10;
|
|
}
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: price,
|
|
high: price * 1.05,
|
|
low: price * 0.95,
|
|
close: price,
|
|
volume: 1000.0,
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
|
|
// Features should be bounded even with extreme moves
|
|
for fv in &features {
|
|
for &value in fv.iter() {
|
|
assert!(value.is_finite(), "Found infinite value from extreme price spike");
|
|
assert!(value.abs() < 10000.0, "Feature value too extreme: {}", value);
|
|
}
|
|
}
|
|
|
|
println!("✅ Extreme price spike handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Extreme volume spike (100x normal)
|
|
#[test]
|
|
fn test_extreme_volume_spike() -> Result<()> {
|
|
// OBJECTIVE: Test handling of extreme volume spikes
|
|
// EXPECTED: Features computed safely, no NaN/Inf
|
|
|
|
let mut bars = Vec::new();
|
|
let mut timestamp = Utc::now();
|
|
|
|
for i in 0..60 {
|
|
let volume = if i == 30 { 100000.0 } else { 1000.0 };
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: 4500.0,
|
|
high: 4505.0,
|
|
low: 4495.0,
|
|
close: 4500.0,
|
|
volume,
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
|
|
// Should handle volume spikes safely
|
|
for fv in &features {
|
|
for &value in fv.iter() {
|
|
assert!(value.is_finite(), "Found NaN/Inf from volume spike");
|
|
}
|
|
}
|
|
|
|
println!("✅ Extreme volume spike handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Single bar after warmup (51 bars total)
|
|
#[test]
|
|
fn test_single_bar_after_warmup() -> Result<()> {
|
|
// OBJECTIVE: Extract from exactly 51 bars
|
|
// EXPECTED: 1 feature vector
|
|
|
|
let bars = create_test_bars(51)?;
|
|
let features = extract_ml_features(&bars)?;
|
|
|
|
assert_eq!(features.len(), 1, "Should produce exactly 1 feature");
|
|
assert_eq!(features[0].len(), 46, "Feature should be 46-dimensional");
|
|
|
|
println!("✅ Single bar after warmup handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Weekend bars (market closed)
|
|
#[test]
|
|
fn test_weekend_bars() -> Result<()> {
|
|
// OBJECTIVE: Test handling of weekend data
|
|
// EXPECTED: is_market_open=0
|
|
|
|
let mut bars = Vec::new();
|
|
// Saturday
|
|
let mut timestamp = Utc::now()
|
|
.with_weekday(chrono::Weekday::Sat)
|
|
.with_hour(14)
|
|
.unwrap()
|
|
.with_minute(0)
|
|
.unwrap();
|
|
|
|
for _ in 0..60 {
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: 4500.0,
|
|
high: 4505.0,
|
|
low: 4495.0,
|
|
close: 4500.0,
|
|
volume: 1000.0,
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
|
|
// Features should be computed even on non-trading days
|
|
for fv in &features {
|
|
for &value in fv.iter() {
|
|
assert!(value.is_finite(), "Found NaN/Inf on weekend");
|
|
}
|
|
}
|
|
|
|
println!("✅ Weekend bars handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Premarket hours (before 9:30 AM ET)
|
|
#[test]
|
|
fn test_premarket_bars() -> Result<()> {
|
|
// OBJECTIVE: Test handling of premarket data
|
|
// EXPECTED: is_market_open=0
|
|
|
|
let mut bars = Vec::new();
|
|
let mut timestamp = Utc::now()
|
|
.with_hour(12) // 8:00 AM ET = 12:00 UTC (premarket)
|
|
.unwrap()
|
|
.with_minute(0)
|
|
.unwrap();
|
|
|
|
for _ in 0..60 {
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: 4500.0,
|
|
high: 4505.0,
|
|
low: 4495.0,
|
|
close: 4500.0,
|
|
volume: 100.0, // Low volume in premarket
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
|
|
// Features should be computed for premarket
|
|
for fv in &features {
|
|
for &value in fv.iter() {
|
|
assert!(value.is_finite(), "Found NaN/Inf in premarket");
|
|
}
|
|
}
|
|
|
|
println!("✅ Premarket bars handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Postmarket hours (after 4:00 PM ET)
|
|
#[test]
|
|
fn test_postmarket_bars() -> Result<()> {
|
|
// OBJECTIVE: Test handling of postmarket data
|
|
// EXPECTED: is_market_open=0
|
|
|
|
let mut bars = Vec::new();
|
|
let mut timestamp = Utc::now()
|
|
.with_hour(21) // 5:00 PM ET = 21:00 UTC (postmarket)
|
|
.unwrap()
|
|
.with_minute(0)
|
|
.unwrap();
|
|
|
|
for _ in 0..60 {
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: 4500.0,
|
|
high: 4505.0,
|
|
low: 4495.0,
|
|
close: 4500.0,
|
|
volume: 100.0, // Low volume in postmarket
|
|
});
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
|
|
// Features should be computed for postmarket
|
|
for fv in &features {
|
|
for &value in fv.iter() {
|
|
assert!(value.is_finite(), "Found NaN/Inf in postmarket");
|
|
}
|
|
}
|
|
|
|
println!("✅ Postmarket bars handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test: Data quality with missing bars (gaps)
|
|
#[test]
|
|
fn test_data_quality_with_gaps() -> Result<()> {
|
|
// OBJECTIVE: Test handling of time gaps in data
|
|
// EXPECTED: Features still computed correctly
|
|
|
|
let mut bars = Vec::new();
|
|
let mut timestamp = Utc::now();
|
|
|
|
for i in 0..60 {
|
|
bars.push(OHLCVBar {
|
|
timestamp,
|
|
open: 4500.0,
|
|
high: 4505.0,
|
|
low: 4495.0,
|
|
close: 4500.0,
|
|
volume: 1000.0,
|
|
});
|
|
|
|
// Add random gaps (simulate market halts)
|
|
timestamp = if i % 10 == 9 {
|
|
timestamp + Duration::minutes(5) // 5-minute gap
|
|
} else {
|
|
timestamp + Duration::minutes(1)
|
|
};
|
|
}
|
|
|
|
let features = extract_ml_features(&bars)?;
|
|
|
|
// Features should be computed despite gaps
|
|
for fv in &features {
|
|
for &value in fv.iter() {
|
|
assert!(value.is_finite(), "Found NaN/Inf despite gaps");
|
|
}
|
|
}
|
|
|
|
println!("✅ Data gaps handled correctly");
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
fn create_test_bars(count: usize) -> Result<Vec<OHLCVBar>> {
|
|
let mut bars = Vec::with_capacity(count);
|
|
let mut timestamp = Utc::now();
|
|
let mut price = 4500.0;
|
|
|
|
for _ in 0..count {
|
|
let bar = OHLCVBar {
|
|
timestamp,
|
|
open: price,
|
|
high: price + 2.0,
|
|
low: price - 2.0,
|
|
close: price + 1.0,
|
|
volume: 1000.0,
|
|
};
|
|
bars.push(bar);
|
|
|
|
timestamp = timestamp + Duration::minutes(1);
|
|
price += (rand::random::<f64>() - 0.5) * 2.0;
|
|
}
|
|
|
|
Ok(bars)
|
|
}
|