4 tests validating the 51-dim feature extraction pipeline: - DBN data loading (graceful skip if file absent) - Synthetic bars: dimension check (51-dim), no NaN/Inf - Value range bounds (-100 to 100) - Streaming vs batch consistency (element-wise 1e-10 tolerance) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
266 lines
8.3 KiB
Rust
266 lines
8.3 KiB
Rust
//! Feature Extraction Pipeline Integration Tests
|
|
//!
|
|
//! Validates the 51-dimension feature extraction pipeline with both real DBN data
|
|
//! and synthetic OHLCV bars. Verifies:
|
|
//! - No NaN/Inf values in output
|
|
//! - Correct feature vector dimensions (51)
|
|
//! - Reasonable value ranges
|
|
//! - Streaming vs batch consistency
|
|
|
|
use ml::data_loaders::DbnSequenceLoader;
|
|
use ml::features::extraction::{extract_ml_features, FeatureExtractor, FeatureVector};
|
|
use ml::types::OHLCVBar;
|
|
|
|
use chrono::{Duration, Utc};
|
|
use std::path::Path;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Generate `count` synthetic OHLCV bars with a gentle upward trend and
|
|
/// realistic spreads, starting from a base price around 4500 (ES futures).
|
|
fn synthetic_bars(count: usize) -> Vec<OHLCVBar> {
|
|
let base_price = 4500.0_f64;
|
|
let now = Utc::now();
|
|
|
|
(0..count)
|
|
.map(|i| {
|
|
let drift = i as f64 * 0.05; // small upward drift
|
|
let noise = ((i as f64 * 0.7).sin()) * 2.0; // deterministic oscillation
|
|
let close = base_price + drift + noise;
|
|
let open = close - 0.3 + ((i as f64 * 0.3).cos()) * 0.5;
|
|
let high = close.max(open) + 1.0 + (i as f64 * 0.2).sin().abs();
|
|
let low = close.min(open) - 1.0 - (i as f64 * 0.4).cos().abs();
|
|
let volume = 10_000.0 + (i as f64 * 0.9).sin() * 3_000.0;
|
|
|
|
OHLCVBar {
|
|
timestamp: now + Duration::minutes(i as i64),
|
|
open,
|
|
high,
|
|
low,
|
|
close,
|
|
volume: volume.max(100.0), // floor at 100 to avoid zero-volume
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1 -- Real DBN data through DbnSequenceLoader
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn test_feature_extraction_from_dbn_data() {
|
|
// Resolve the test_data directory relative to the workspace root.
|
|
// The DBN file lives at test_data/ES_FUT_180d.dbn; DbnSequenceLoader
|
|
// scans a *directory* for .dbn files.
|
|
let dbn_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.expect("tests/ has a parent")
|
|
.join("test_data");
|
|
|
|
let dbn_file = dbn_dir.join("ES_FUT_180d.dbn");
|
|
if !dbn_file.exists() {
|
|
eprintln!(
|
|
"SKIP: test_feature_extraction_from_dbn_data -- {} not found",
|
|
dbn_file.display()
|
|
);
|
|
return;
|
|
}
|
|
|
|
// DbnSequenceLoader::new(seq_len, d_model) -- Wave A config expects d_model=26
|
|
let loader_result = DbnSequenceLoader::new(60, 26).await;
|
|
let mut loader = match loader_result {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
eprintln!(
|
|
"SKIP: DbnSequenceLoader::new failed (acceptable in CI): {}",
|
|
e
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// load_sequences expects a directory containing .dbn files
|
|
let result = loader.load_sequences(&dbn_dir, 0.8).await;
|
|
match result {
|
|
Ok((train, val)) => {
|
|
// At least one of train/val should be non-empty
|
|
assert!(
|
|
!train.is_empty() || !val.is_empty(),
|
|
"DbnSequenceLoader produced zero sequences from real DBN data"
|
|
);
|
|
eprintln!(
|
|
"OK: DbnSequenceLoader produced {} train + {} val sequences",
|
|
train.len(),
|
|
val.len()
|
|
);
|
|
}
|
|
Err(e) => {
|
|
// Some CI environments may not support DBN decoding -- skip gracefully
|
|
eprintln!(
|
|
"SKIP: load_sequences failed (acceptable in CI): {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 2 -- Synthetic bars: dimension check
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_feature_extraction_synthetic_bars_dimensions() {
|
|
let bars = synthetic_bars(200);
|
|
|
|
let features = extract_ml_features(&bars).expect("extract_ml_features should succeed on 200 synthetic bars");
|
|
|
|
// With 200 bars and warmup=50, we expect 150 feature vectors
|
|
// (indices 50..199 inclusive)
|
|
assert!(
|
|
!features.is_empty(),
|
|
"Feature extraction must produce at least one vector"
|
|
);
|
|
assert_eq!(
|
|
features.len(),
|
|
150,
|
|
"Expected 150 feature vectors (200 bars - 50 warmup), got {}",
|
|
features.len()
|
|
);
|
|
|
|
// Each feature vector must have exactly 51 dimensions
|
|
for (idx, fv) in features.iter().enumerate() {
|
|
assert_eq!(
|
|
fv.len(),
|
|
51,
|
|
"Feature vector at index {} has {} dims, expected 51",
|
|
idx,
|
|
fv.len()
|
|
);
|
|
|
|
// No NaN or Inf
|
|
for (dim, &val) in fv.iter().enumerate() {
|
|
assert!(
|
|
val.is_finite(),
|
|
"NaN/Inf at feature vector {}, dimension {}: {}",
|
|
idx,
|
|
dim,
|
|
val
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 3 -- Value ranges
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_feature_extraction_value_ranges() {
|
|
let bars = synthetic_bars(200);
|
|
|
|
let features = extract_ml_features(&bars).expect("extract_ml_features should succeed");
|
|
|
|
let mut global_min = f64::INFINITY;
|
|
let mut global_max = f64::NEG_INFINITY;
|
|
|
|
for fv in &features {
|
|
for &val in fv.iter() {
|
|
assert!(val.is_finite(), "Non-finite value found: {}", val);
|
|
if val < global_min {
|
|
global_min = val;
|
|
}
|
|
if val > global_max {
|
|
global_max = val;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Feature extraction uses safe_clip / safe_normalize which bound most
|
|
// features. The volume normalization (safe_normalize(vol, 0, 1_000_000))
|
|
// and other clipping functions keep values well within [-100, 100].
|
|
assert!(
|
|
global_min >= -100.0,
|
|
"Global min {} is below -100.0",
|
|
global_min
|
|
);
|
|
assert!(
|
|
global_max <= 100.0,
|
|
"Global max {} exceeds 100.0",
|
|
global_max
|
|
);
|
|
|
|
eprintln!(
|
|
"Value range check passed: min={:.6}, max={:.6}",
|
|
global_min, global_max
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 4 -- Streaming vs batch consistency
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_streaming_feature_extractor_matches_batch() {
|
|
let bars = synthetic_bars(200);
|
|
const WARMUP_PERIOD: usize = 50;
|
|
|
|
// ---- Batch path ----
|
|
let batch_features =
|
|
extract_ml_features(&bars).expect("batch extract_ml_features should succeed");
|
|
|
|
// ---- Streaming path ----
|
|
let mut extractor = FeatureExtractor::new();
|
|
let mut streaming_features: Vec<FeatureVector> = Vec::new();
|
|
|
|
for (i, bar) in bars.iter().enumerate() {
|
|
extractor
|
|
.update(bar)
|
|
.expect("FeatureExtractor::update should succeed");
|
|
|
|
if i >= WARMUP_PERIOD {
|
|
let fv = extractor
|
|
.extract_current_features()
|
|
.expect("extract_current_features should succeed");
|
|
streaming_features.push(fv);
|
|
}
|
|
}
|
|
|
|
// Same number of vectors
|
|
assert_eq!(
|
|
batch_features.len(),
|
|
streaming_features.len(),
|
|
"Batch produced {} vectors but streaming produced {}",
|
|
batch_features.len(),
|
|
streaming_features.len()
|
|
);
|
|
|
|
// Element-wise equality within tolerance
|
|
let tolerance = 1e-10;
|
|
for (vec_idx, (batch_fv, stream_fv)) in batch_features
|
|
.iter()
|
|
.zip(streaming_features.iter())
|
|
.enumerate()
|
|
{
|
|
for (dim, (&b, &s)) in batch_fv.iter().zip(stream_fv.iter()).enumerate() {
|
|
let diff = (b - s).abs();
|
|
assert!(
|
|
diff <= tolerance,
|
|
"Mismatch at vector {}, dim {}: batch={} stream={} diff={}",
|
|
vec_idx,
|
|
dim,
|
|
b,
|
|
s,
|
|
diff
|
|
);
|
|
}
|
|
}
|
|
|
|
eprintln!(
|
|
"Streaming vs batch consistency verified for {} vectors x 51 dims",
|
|
batch_features.len()
|
|
);
|
|
}
|