Files
foxhunt/crates/ml/tests/adx_features_test.rs
jgrusewski ca4c38d921 fix(tests): CI GPU test stability, walltime reduction, BF16 tolerance
- Reduce CI GPU test datasets 16x for walltime reduction
- Reduce early-stop epochs 50→10, add --test-threads=1
- Serialize all GPU lib tests to prevent cuBLAS init race
- Align state_dim to 16 for BF16 tensor core HMMA dispatch
- BF16 precision tolerance in ml-dqn tests
- Enable branching DQN + tracing subscriber in smoke tests
- Prevent min_replay_size > buffer_size deadlock in early-stop tests
- Prevent AutoReplaySizer from breaking gradient collapse warmup
- Replace racy tokio::spawn checkpoint counter with AtomicUsize
- Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests
- RealDataLoader respects TEST_DATA_DIR for CI PVC layout
- Add collapse_warmup_capacity to gpu_smoketest DQNConfig
- Drain CUDA context between test binaries
- Detached HEAD checkout prevents local branch corruption
- GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions
- OOD input handling tests use use_gpu: true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:00:13 +01:00

585 lines
16 KiB
Rust
Raw Permalink 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.
#![allow(
clippy::assertions_on_constants,
clippy::assertions_on_result_states,
clippy::clone_on_copy,
clippy::decimal_literal_representation,
clippy::doc_markdown,
clippy::empty_line_after_doc_comments,
clippy::field_reassign_with_default,
clippy::get_unwrap,
clippy::identity_op,
clippy::inconsistent_digit_grouping,
clippy::indexing_slicing,
clippy::integer_division,
clippy::len_zero,
clippy::let_underscore_must_use,
clippy::manual_div_ceil,
clippy::manual_let_else,
clippy::manual_range_contains,
clippy::modulo_arithmetic,
clippy::needless_range_loop,
clippy::non_ascii_literal,
clippy::redundant_clone,
clippy::shadow_reuse,
clippy::shadow_same,
clippy::shadow_unrelated,
clippy::single_match_else,
clippy::str_to_string,
clippy::string_slice,
clippy::tests_outside_test_module,
clippy::too_many_lines,
clippy::unnecessary_wraps,
clippy::unseparated_literal_suffix,
clippy::use_debug,
clippy::useless_vec,
clippy::wildcard_enum_match_arm,
clippy::else_if_without_else,
clippy::expect_used,
clippy::missing_const_for_fn,
clippy::similar_names,
clippy::type_complexity,
clippy::collapsible_else_if,
clippy::doc_lazy_continuation,
clippy::items_after_test_module,
clippy::map_clone,
clippy::multiple_unsafe_ops_per_block,
clippy::unwrap_or_default,
clippy::assign_op_pattern,
clippy::needless_borrow,
clippy::println_empty_string,
clippy::unnecessary_cast,
clippy::used_underscore_binding,
clippy::create_dir,
clippy::implicit_saturating_sub,
clippy::exit,
clippy::expect_fun_call,
clippy::too_many_arguments,
clippy::unnecessary_map_or,
clippy::unwrap_used,
dead_code,
unused_imports,
unused_variables,
clippy::cloned_ref_to_slice_refs,
clippy::neg_multiply,
clippy::while_let_loop,
clippy::bool_assert_comparison,
clippy::excessive_precision,
clippy::trivially_copy_pass_by_ref,
clippy::op_ref,
clippy::redundant_closure,
clippy::unnecessary_lazy_evaluations,
clippy::if_then_some_else_none,
clippy::unnecessary_to_owned,
clippy::single_component_path_imports,
)]
//! Integration tests for ADX Feature Extractor (Agent D14)
//!
//! This test suite validates the 5 ADX features:
//! - Feature 211: ADX (Average Directional Index)
//! - Feature 212: +DI (Positive Directional Indicator)
//! - Feature 213: -DI (Negative Directional Indicator)
//! - Feature 214: DX (Directional Movement Index)
//! - Feature 215: Trend Classification (0=weak, 1=moderate, 2=strong)
//!
//! ## Test Coverage
//! 1. Wilder's 14-period algorithm correctness
//! 2. Incremental vs. batch processing consistency
//! 3. Performance benchmark (<80μs target)
//! 4. Real market data validation
//! 5. Edge case handling (constant prices, extreme volatility)
use ml::features::adx_features::{AdxFeatureExtractor, OHLCVBar};
use std::collections::VecDeque;
use std::time::Instant;
use tracing::info;
// ===== Test Helper Functions =====
fn create_bars(prices: Vec<f64>) -> VecDeque<OHLCVBar> {
prices
.into_iter()
.map(|p| OHLCVBar {
timestamp: chrono::Utc::now(),
open: p,
high: p * 1.01,
low: p * 0.99,
close: p,
volume: 1000.0,
})
.collect()
}
fn create_trending_bars(start: f64, count: usize, trend_strength: f64) -> VecDeque<OHLCVBar> {
(0..count)
.map(|i| {
let price = start + trend_strength * i as f64;
OHLCVBar {
timestamp: chrono::Utc::now(),
open: price,
high: price * 1.02,
low: price * 0.98,
close: price,
volume: 1000.0,
}
})
.collect()
}
fn create_ranging_bars(center: f64, count: usize) -> VecDeque<OHLCVBar> {
(0..count)
.map(|i| {
let price = center + 0.5 * ((i as f64 * 0.5).sin());
OHLCVBar {
timestamp: chrono::Utc::now(),
open: price,
high: price * 1.005,
low: price * 0.995,
close: price,
volume: 1000.0,
}
})
.collect()
}
fn assert_approx_eq(a: f64, b: f64, epsilon: f64) {
assert!(
(a - b).abs() < epsilon,
"{} != {} (epsilon: {})",
a,
b,
epsilon
);
}
// ===== Feature Validation Tests =====
#[test]
fn test_adx_trending_uptrend() {
let mut extractor = AdxFeatureExtractor::new();
let bars = create_trending_bars(100.0, 40, 0.5); // Strong uptrend
let mut features = [0.0; 5];
for bar in bars.iter() {
features = extractor.update(bar);
}
// ADX should detect trending market
assert!(
extractor.is_initialized(),
"Extractor not initialized after 40 bars"
);
assert!(features[0] > 0.0, "ADX: {}", features[0]); // ADX > 0
assert!(
features[1] > features[2],
"+DI ({}) should be > -DI ({}) in uptrend",
features[1],
features[2]
); // +DI > -DI in uptrend
assert!(features[3] > 0.0, "DX: {}", features[3]); // DX > 0
// Validate feature ranges
assert!(
features[0] >= 0.0 && features[0] <= 100.0,
"ADX out of range: {}",
features[0]
);
assert!(
features[1] >= 0.0 && features[1] <= 100.0,
"+DI out of range: {}",
features[1]
);
assert!(
features[2] >= 0.0 && features[2] <= 100.0,
"-DI out of range: {}",
features[2]
);
assert!(
features[3] >= 0.0 && features[3] <= 100.0,
"DX out of range: {}",
features[3]
);
assert!(
features[4] == 0.0 || features[4] == 1.0 || features[4] == 2.0,
"Classification invalid: {}",
features[4]
);
}
#[test]
fn test_adx_trending_downtrend() {
let mut extractor = AdxFeatureExtractor::new();
let bars = create_trending_bars(150.0, 40, -0.5); // Strong downtrend
let mut features = [0.0; 5];
for bar in bars.iter() {
features = extractor.update(bar);
}
// ADX should detect trending market
assert!(extractor.is_initialized());
assert!(features[0] > 0.0, "ADX: {}", features[0]);
assert!(
features[2] > features[1],
"-DI ({}) should be > +DI ({}) in downtrend",
features[2],
features[1]
); // -DI > +DI in downtrend
assert!(features[3] > 0.0, "DX: {}", features[3]);
}
#[test]
fn test_adx_ranging_market() {
let mut extractor = AdxFeatureExtractor::new();
let bars = create_ranging_bars(100.0, 40); // Oscillating market
let mut features = [0.0; 5];
for bar in bars.iter() {
features = extractor.update(bar);
}
// ADX should be lower in ranging market
assert!(extractor.is_initialized());
assert!(
features[0] >= 0.0 && features[0] <= 100.0,
"ADX: {}",
features[0]
);
// Classification should be valid
assert!(
features[4] >= 0.0 && features[4] <= 2.0,
"Classification: {}",
features[4]
);
}
#[test]
fn test_adx_constant_prices() {
let mut extractor = AdxFeatureExtractor::new();
let bars = create_bars(vec![100.0; 40]);
let mut features = [0.0; 5];
for bar in bars.iter() {
features = extractor.update(bar);
}
// Constant prices should result in very low ADX
assert!(
features[0] < 5.0,
"ADX should be low for constant prices: {}",
features[0]
);
assert_eq!(
features[4], 0.0,
"Classification should be weak: {}",
features[4]
);
}
#[test]
fn test_adx_initialization_phase() {
let mut extractor = AdxFeatureExtractor::new();
let bars = create_trending_bars(100.0, 15, 0.3);
// Process bars incrementally
for (i, bar) in bars.iter().enumerate() {
let features = extractor.update(bar);
if i < 27 {
// Before bar 28, ADX should be zero
assert_eq!(features[0], 0.0, "ADX should be 0 at bar {}", i + 1);
}
}
// After 27 bars, should not be initialized yet
assert!(
!extractor.is_initialized(),
"Should not be initialized before 28 bars"
);
// Add more bars to reach initialization
let more_bars = create_trending_bars(105.0, 15, 0.3);
for bar in more_bars.iter() {
extractor.update(bar);
}
// Now should be initialized
assert!(
extractor.is_initialized(),
"Should be initialized after 28+ bars"
);
}
#[test]
fn test_adx_classification_thresholds() {
// Test weak trend classification (ADX < 20)
let mut extractor = AdxFeatureExtractor::new();
let bars = create_ranging_bars(100.0, 40);
let mut features = [0.0; 5];
for bar in bars.iter() {
features = extractor.update(bar);
}
// Note: Ranging market might not always produce ADX < 20 depending on oscillation
// This test validates that classification is in valid range
assert!(
features[4] == 0.0 || features[4] == 1.0 || features[4] == 2.0,
"Classification: {}",
features[4]
);
// Test strong trend classification (ADX >= 40)
// This requires very strong trending data
let mut extractor_strong = AdxFeatureExtractor::new();
let strong_bars = create_trending_bars(100.0, 50, 1.0); // Very strong trend
let mut strong_features = [0.0; 5];
for bar in strong_bars.iter() {
strong_features = extractor_strong.update(bar);
}
// Strong trend should have high ADX
assert!(
strong_features[0] > 20.0,
"Strong trend should have ADX > 20: {}",
strong_features[0]
);
}
// ===== Consistency Tests =====
#[test]
fn test_incremental_vs_batch_consistency() {
let bars = create_trending_bars(100.0, 40, 0.4);
// Incremental processing
let mut extractor_incremental = AdxFeatureExtractor::new();
let mut features_incremental = [0.0; 5];
for bar in bars.iter() {
features_incremental = extractor_incremental.update(bar);
}
// Batch processing
let features_batch = AdxFeatureExtractor::extract_from_window(&bars);
// Results should be identical
for i in 0..5 {
assert_approx_eq(features_incremental[i], features_batch[i], 0.01);
}
}
#[test]
fn test_reset_functionality() {
let mut extractor = AdxFeatureExtractor::new();
let bars = create_trending_bars(100.0, 30, 0.5);
// Process bars
for bar in bars.iter() {
extractor.update(bar);
}
assert!(extractor.bar_count() > 0);
// Reset
extractor.reset();
// Verify reset state
assert_eq!(extractor.bar_count(), 0);
assert!(!extractor.is_initialized());
// Process new bars after reset
let new_bars = create_trending_bars(150.0, 30, -0.5);
for bar in new_bars.iter() {
extractor.update(bar);
}
assert_eq!(extractor.bar_count(), 30);
}
// ===== Performance Tests =====
#[test]
fn test_performance_benchmark() {
let bars = create_trending_bars(100.0, 1000, 0.3);
let mut extractor = AdxFeatureExtractor::new();
// Warm-up: Initialize extractor
for bar in bars.iter().take(28) {
extractor.update(bar);
}
// Benchmark: Process remaining bars
let start = Instant::now();
let iterations = bars.len() - 28;
for bar in bars.iter().skip(28) {
extractor.update(bar);
}
let elapsed = start.elapsed();
let avg_time_us = elapsed.as_micros() as f64 / iterations as f64;
info!(avg_time_us, iterations, "ADX Performance (target: <80μs)");
// Target: <80μs per bar
assert!(
avg_time_us < 80.0,
"Performance regression: {:.2}μs per bar (target: <80μs)",
avg_time_us
);
}
#[test]
fn test_batch_processing_performance() {
let bars = create_trending_bars(100.0, 1000, 0.3);
let start = Instant::now();
let _features = AdxFeatureExtractor::extract_from_window(&bars);
let elapsed = start.elapsed();
let avg_time_us = elapsed.as_micros() as f64 / bars.len() as f64;
info!(avg_time_us, num_bars = bars.len(), "ADX Batch Performance (target: <80μs)");
// Batch processing should also meet performance target
assert!(
avg_time_us < 80.0,
"Batch performance regression: {:.2}μs per bar (target: <80μs)",
avg_time_us
);
}
// ===== Edge Case Tests =====
#[test]
fn test_extreme_volatility() {
let mut extractor = AdxFeatureExtractor::new();
let mut bars = create_ranging_bars(100.0, 30);
// Add extreme spike
bars.push_back(OHLCVBar {
timestamp: chrono::Utc::now(),
open: 150.0,
high: 180.0,
low: 140.0,
close: 170.0,
volume: 5000.0,
});
let mut features = [0.0; 5];
for bar in bars.iter() {
features = extractor.update(bar);
}
// Should handle extreme volatility gracefully
assert!(
features[0].is_finite() && features[0] >= 0.0,
"ADX should be finite: {}",
features[0]
);
assert!(
features[1].is_finite() && features[1] >= 0.0,
"+DI should be finite: {}",
features[1]
);
assert!(
features[2].is_finite() && features[2] >= 0.0,
"-DI should be finite: {}",
features[2]
);
}
#[test]
fn test_custom_period() {
let mut extractor = AdxFeatureExtractor::with_period(10);
assert_eq!(extractor.bar_count(), 0);
let bars = create_trending_bars(100.0, 30, 0.5);
let mut features = [0.0; 5];
for bar in bars.iter() {
features = extractor.update(bar);
}
// Should initialize faster with shorter period (10 × 2 = 20 bars)
assert!(extractor.is_initialized());
assert!(features[0] >= 0.0);
}
#[test]
fn test_insufficient_data() {
let mut extractor = AdxFeatureExtractor::new();
let bars = create_bars(vec![100.0, 101.0, 102.0]);
for bar in bars.iter() {
let features = extractor.update(bar);
// All zeros until we have enough data
assert_eq!(
features, [0.0; 5],
"Features should be zero with insufficient data"
);
}
}
// ===== Real Market Data Simulation =====
#[test]
fn test_realistic_market_data() {
let mut extractor = AdxFeatureExtractor::new();
// Simulate realistic price movement with noise
let mut bars = VecDeque::new();
let mut price = 100.0;
for i in 0..60 {
// Add trend + noise
price += 0.1 + 0.05 * ((i as f64 * 0.3).sin());
bars.push_back(OHLCVBar {
timestamp: chrono::Utc::now(),
open: price - 0.2,
high: price + 0.5,
low: price - 0.5,
close: price,
volume: 1000.0 + (i as f64 * 10.0),
});
}
let mut features = [0.0; 5];
for bar in bars.iter() {
features = extractor.update(bar);
}
// After 60 bars, should be initialized and have valid features
assert!(extractor.is_initialized());
assert!(
features[0].is_finite() && features[0] >= 0.0,
"ADX: {}",
features[0]
);
assert!(
features[1].is_finite() && features[1] >= 0.0,
"+DI: {}",
features[1]
);
assert!(
features[2].is_finite() && features[2] >= 0.0,
"-DI: {}",
features[2]
);
assert!(
features[3].is_finite() && features[3] >= 0.0,
"DX: {}",
features[3]
);
assert!(
features[4] == 0.0 || features[4] == 1.0 || features[4] == 2.0,
"Classification: {}",
features[4]
);
}
// ===== Integration Test Summary =====
#[test]
fn test_integration_summary() {
info!("ADX Feature Extractor Integration Test Summary: 5 features (211-215), Wilder's 14-period smoothing, 28-bar init, <80μs target");
}