- 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>
585 lines
16 KiB
Rust
585 lines
16 KiB
Rust
#![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");
|
||
}
|