Files
foxhunt/crates/ml-features/src/adx_features.rs
jgrusewski fca2495a73 fix(clippy): add doc backticks and const fn across ML crates
- Add backticks to type names in doc comments (doc_markdown)
- Mark eligible functions as const fn (missing_const_for_fn)
No behavior changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:51:31 +01:00

850 lines
26 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.
//! ADX Feature Extractor for Wave D Feature Engineering (Agent D14)
//!
//! This module implements 5 ADX-based features using Wilder's 14-period algorithm:
//! - Feature 211: ADX (Average Directional Index) - trend strength
//! - Feature 212: +DI (Positive Directional Indicator) - bullish pressure
//! - Feature 213: -DI (Negative Directional Indicator) - bearish pressure
//! - Feature 214: DX (Directional Movement Index) - directional strength
//! - Feature 215: Trend Classification (0=weak <20, 1=moderate 20-40, 2=strong >40)
//!
//! ## Performance Target
//! - <80μs for all 5 features per bar (incremental ADX updates)
//!
//! ## Feature Index Allocation
//! - Features 211-215: ADX directional indicators (5 total)
//! - Part of Wave D Phase 3 (24 regime features, indices 201-225)
//!
//! ## Algorithm: Wilder's 14-Period ADX
//! 1. **True Range (TR)**:
//! TR = max(high - low, |high - `prev_close`|, |low - `prev_close`|)
//!
//! 2. **Directional Movement (+DM, -DM)**:
//! +DM = max(0, high - `prev_high`) if `up_move` > `down_move`
//! -DM = max(0, `prev_low` - low) if `down_move` > `up_move`
//!
//! 3. **Wilder's Smoothing** (exponential moving average with α = 1/14):
//! First 14 bars: simple sum
//! Bar 14: smoothed = sum / 14
//! Bar 15+: smoothed = (`smoothed_prev` × 13 + `new_value`) / 14
//!
//! 4. **Directional Indicators**:
//! +DI = (smoothed_+DM / `smoothed_TR`) × 100
//! -DI = (smoothed_-DM / `smoothed_TR`) × 100
//!
//! 5. **DX (Directional Movement Index)**:
//! DX = (|+DI - -DI| / (+DI + -DI)) × 100
//!
//! 6. **ADX (Average of DX)**:
//! First 28 bars: simple average of DX
//! Bar 29+: ADX = (`ADX_prev` × 13 + DX) / 14
//!
//! ## References
//! - Wilder, J. Wells (1978). "New Concepts in Technical Trading Systems"
//! - `WAVE_19_COMPREHENSIVE_FEATURE_ENGINEERING_PLAN.md`
//! - ml/src/regime/trending.rs (ADX implementation reference)
use std::collections::VecDeque;
pub use crate::OHLCVBar;
/// ADX feature extractor using Wilder's 14-period algorithm
///
/// This extractor maintains incremental state for efficient real-time calculation.
/// The ADX requires 28 bars to stabilize (14 bars for smoothing + 14 bars for ADX smoothing).
#[derive(Debug)]
pub struct AdxFeatureExtractor {
/// Period for Wilder's smoothing (default: 14)
period: usize,
/// Bar counter (tracks initialization phase)
bar_count: usize,
/// Previous bar (for calculating directional movement)
prev_bar: Option<OHLCVBar>,
// Smoothed values (Wilder's EMA)
/// Smoothed True Range
smoothed_tr: f64,
/// Smoothed +DM (Positive Directional Movement)
smoothed_plus_dm: f64,
/// Smoothed -DM (Negative Directional Movement)
smoothed_minus_dm: f64,
/// Smoothed ADX (average of DX)
smoothed_adx: f64,
// Accumulation buffers (for first 'period' bars)
/// Accumulated TR during initialization
tr_sum: f64,
/// Accumulated +DM during initialization
plus_dm_sum: f64,
/// Accumulated -DM during initialization
minus_dm_sum: f64,
/// DX history for ADX initialization (first 'period' DX values)
dx_history: VecDeque<f64>,
}
impl AdxFeatureExtractor {
/// Create new ADX feature extractor with default 14-period Wilder's smoothing
pub fn new() -> Self {
Self::with_period(14)
}
/// Create new ADX feature extractor with custom period
///
/// ## Arguments
/// - `period`: Wilder's smoothing period (typical: 14)
///
/// ## Example
/// ```
/// use ml::features::adx_features::AdxFeatureExtractor;
///
/// // Standard 14-period ADX
/// let extractor = AdxFeatureExtractor::new();
///
/// // Custom 20-period ADX (smoother, slower)
/// let extractor = AdxFeatureExtractor::with_period(20);
/// ```
pub fn with_period(period: usize) -> Self {
assert!(period >= 2, "Period must be at least 2");
Self {
period,
bar_count: 0,
prev_bar: None,
smoothed_tr: 0.0,
smoothed_plus_dm: 0.0,
smoothed_minus_dm: 0.0,
smoothed_adx: 0.0,
tr_sum: 0.0,
plus_dm_sum: 0.0,
minus_dm_sum: 0.0,
dx_history: VecDeque::with_capacity(period),
}
}
/// Update ADX state with new bar and return 5 features
///
/// ## Returns
/// - `[f64; 5]`: [ADX, +DI, -DI, DX, Classification]
/// - `[0]` Feature 211: ADX (0-100, trend strength)
/// - `[1]` Feature 212: +DI (0-100, bullish pressure)
/// - `[2]` Feature 213: -DI (0-100, bearish pressure)
/// - `[3]` Feature 214: DX (0-100, directional strength)
/// - `[4]` Feature 215: Classification (0=weak, 1=moderate, 2=strong)
///
/// ## Algorithm
/// 1. Bars 0: Initialize with first bar (return zeros)
/// 2. Bars 1-13: Accumulate TR, +DM, -DM sums
/// 3. Bar 14: Initialize smoothed values (sum / period)
/// 4. Bars 15-27: Update smoothed values, accumulate DX
/// 5. Bar 28: Initialize ADX (average of DX history)
/// 6. Bar 29+: Update ADX incrementally
///
/// ## Performance
/// - O(1) per bar after initialization
/// - Target: <80μs per bar (validated on real data)
pub fn update(&mut self, bar: &OHLCVBar) -> [f64; 5] {
// Bar 0: Initialize with first bar
if self.prev_bar.is_none() {
self.prev_bar = Some(*bar);
self.bar_count = 1;
return [0.0; 5];
}
let Some(prev) = self.prev_bar.as_ref() else {
return [0.0; 5];
};
// Calculate True Range (TR)
let tr = calculate_true_range(bar, prev);
// Calculate Directional Movement (+DM, -DM)
let (plus_dm, minus_dm) = calculate_directional_movement(bar, prev);
// Bars 1-13: Accumulate sums for initial smoothing
if self.bar_count < self.period {
self.tr_sum += tr;
self.plus_dm_sum += plus_dm;
self.minus_dm_sum += minus_dm;
}
// Bar 14: Initialize smoothed values
else if self.bar_count == self.period {
self.smoothed_tr = self.tr_sum / self.period as f64;
self.smoothed_plus_dm = self.plus_dm_sum / self.period as f64;
self.smoothed_minus_dm = self.minus_dm_sum / self.period as f64;
}
// Bars 15+: Update smoothed values using Wilder's formula
else {
self.smoothed_tr = wilder_smooth(self.smoothed_tr, tr, self.period);
self.smoothed_plus_dm = wilder_smooth(self.smoothed_plus_dm, plus_dm, self.period);
self.smoothed_minus_dm = wilder_smooth(self.smoothed_minus_dm, minus_dm, self.period);
}
self.bar_count += 1;
self.prev_bar = Some(*bar);
// Return zeros until we have enough data for DI calculation
if self.bar_count < self.period + 1 {
return [0.0; 5];
}
// Calculate +DI, -DI (after bar 14)
let (plus_di, minus_di) = calculate_directional_indicators(
self.smoothed_plus_dm,
self.smoothed_minus_dm,
self.smoothed_tr,
);
// Calculate DX (Directional Movement Index)
let dx = calculate_dx(plus_di, minus_di);
// Bars 15-27: Accumulate DX history for ADX initialization
if self.bar_count < 2 * self.period {
self.dx_history.push_back(dx);
// Return partial results (DI and DX available, ADX still initializing)
return [0.0, plus_di, minus_di, dx, 0.0];
}
// Bar 28: Initialize ADX (simple average of DX history)
if self.bar_count == 2 * self.period {
let dx_sum: f64 = self.dx_history.iter().sum();
self.smoothed_adx = dx_sum / self.period as f64;
self.dx_history.clear(); // Free memory after initialization
}
// Bars 29+: Update ADX using Wilder's smoothing
else {
self.smoothed_adx = wilder_smooth(self.smoothed_adx, dx, self.period);
}
// Feature 215: Trend Classification
let classification = classify_trend_strength(self.smoothed_adx);
[self.smoothed_adx, plus_di, minus_di, dx, classification]
}
/// Extract ADX features from a rolling window of bars (batch processing)
///
/// ## Arguments
/// - `bars`: Rolling window of OHLCV bars (minimum 28 for stable ADX)
///
/// ## Returns
/// - `[f64; 5]`: ADX features from the latest bar
///
/// ## Example
/// ```
/// use ml::features::adx_features::{AdxFeatureExtractor, OHLCVBar};
/// use std::collections::VecDeque;
///
/// let mut bars = VecDeque::new();
/// // ... add bars ...
///
/// let features = AdxFeatureExtractor::extract_from_window(&bars);
/// assert_eq!(features.len(), 5);
/// ```
pub fn extract_from_window(bars: &VecDeque<OHLCVBar>) -> [f64; 5] {
if bars.len() < 2 {
return [0.0; 5];
}
let mut extractor = Self::new();
let mut result = [0.0; 5];
for bar in bars.iter() {
result = extractor.update(bar);
}
result
}
/// Reset extractor state (useful for backtesting multiple symbols)
pub fn reset(&mut self) {
self.bar_count = 0;
self.prev_bar = None;
self.smoothed_tr = 0.0;
self.smoothed_plus_dm = 0.0;
self.smoothed_minus_dm = 0.0;
self.smoothed_adx = 0.0;
self.tr_sum = 0.0;
self.plus_dm_sum = 0.0;
self.minus_dm_sum = 0.0;
self.dx_history.clear();
}
/// Get current bar count (useful for checking initialization status)
pub const fn bar_count(&self) -> usize {
self.bar_count
}
/// Check if ADX is fully initialized (requires 2 × period bars)
pub const fn is_initialized(&self) -> bool {
self.bar_count >= 2 * self.period
}
}
impl Default for AdxFeatureExtractor {
fn default() -> Self {
Self::new()
}
}
// ===== Helper Functions =====
/// Calculate True Range (TR)
///
/// TR = max(high - low, |high - `prev_close`|, |low - `prev_close`|)
#[inline]
fn calculate_true_range(bar: &OHLCVBar, prev: &OHLCVBar) -> f64 {
let hl = bar.high - bar.low;
let hc = (bar.high - prev.close).abs();
let lc = (bar.low - prev.close).abs();
hl.max(hc).max(lc)
}
/// Calculate Directional Movement (+DM, -DM)
///
/// Rules:
/// - If (high - `prev_high`) > (`prev_low` - low) AND (high - `prev_high`) > 0:
/// +DM = high - `prev_high`, -DM = 0
/// - Else if (`prev_low` - low) > (high - `prev_high`) AND (`prev_low` - low) > 0:
/// +DM = 0, -DM = `prev_low` - low
/// - Else:
/// +DM = 0, -DM = 0
#[inline]
fn calculate_directional_movement(bar: &OHLCVBar, prev: &OHLCVBar) -> (f64, f64) {
let up_move = bar.high - prev.high;
let down_move = prev.low - bar.low;
let plus_dm = if up_move > down_move && up_move > 0.0 {
up_move
} else {
0.0
};
let minus_dm = if down_move > up_move && down_move > 0.0 {
down_move
} else {
0.0
};
(plus_dm, minus_dm)
}
/// Apply Wilder's smoothing formula
///
/// `smoothed_new` = (`smoothed_old` × (period - 1) + `new_value`) / period
#[inline]
fn wilder_smooth(smoothed: f64, new_value: f64, period: usize) -> f64 {
(smoothed * (period - 1) as f64 + new_value) / period as f64
}
/// Calculate Directional Indicators (+DI, -DI)
///
/// +DI = (smoothed_+DM / `smoothed_TR`) × 100
/// -DI = (smoothed_-DM / `smoothed_TR`) × 100
#[inline]
fn calculate_directional_indicators(
smoothed_plus_dm: f64,
smoothed_minus_dm: f64,
smoothed_tr: f64,
) -> (f64, f64) {
if smoothed_tr < 1e-10 {
return (0.0, 0.0);
}
let plus_di = (smoothed_plus_dm / smoothed_tr) * 100.0;
let minus_di = (smoothed_minus_dm / smoothed_tr) * 100.0;
(
safe_clip(plus_di, 0.0, 100.0),
safe_clip(minus_di, 0.0, 100.0),
)
}
/// Calculate DX (Directional Movement Index)
///
/// DX = (|+DI - -DI| / (+DI + -DI)) × 100
#[inline]
fn calculate_dx(plus_di: f64, minus_di: f64) -> f64 {
let sum = plus_di + minus_di;
if sum < 1e-10 {
return 0.0;
}
let dx = ((plus_di - minus_di).abs() / sum) * 100.0;
safe_clip(dx, 0.0, 100.0)
}
/// Classify trend strength based on ADX value
///
/// Classification:
/// - 0: Weak trend (ADX < 20) - ranging/choppy market
/// - 1: Moderate trend (20 ≤ ADX < 40) - established trend
/// - 2: Strong trend (ADX ≥ 40) - powerful trend
#[inline]
fn classify_trend_strength(adx: f64) -> f64 {
if adx < 20.0 {
0.0
} else if adx < 40.0 {
1.0
} else {
2.0
}
}
/// Safe clipping: Clip value to [min, max] range, handles NaN/Inf
#[inline]
const fn safe_clip(value: f64, min: f64, max: f64) -> f64 {
if !value.is_finite() {
return 0.0;
}
value.clamp(min, max)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
// ===== Test Helper Functions =====
fn create_bars(prices: Vec<f64>) -> VecDeque<OHLCVBar> {
prices
.into_iter()
.map(|p| OHLCVBar {
timestamp: 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: 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: 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
);
}
// ===== Unit Tests: Helper Functions =====
#[test]
fn test_true_range_calculation() {
let prev = OHLCVBar {
timestamp: Utc::now(),
open: 100.0,
high: 102.0,
low: 98.0,
close: 100.0,
volume: 1000.0,
};
let bar = OHLCVBar {
timestamp: Utc::now(),
open: 101.0,
high: 104.0,
low: 99.0,
close: 103.0,
volume: 1000.0,
};
let tr = calculate_true_range(&bar, &prev);
// TR = max(104 - 99, |104 - 100|, |99 - 100|) = max(5, 4, 1) = 5
assert_approx_eq(tr, 5.0, 0.01);
}
#[test]
fn test_directional_movement_uptrend() {
let prev = OHLCVBar {
timestamp: Utc::now(),
open: 100.0,
high: 102.0,
low: 98.0,
close: 100.0,
volume: 1000.0,
};
let bar = OHLCVBar {
timestamp: Utc::now(),
open: 101.0,
high: 105.0,
low: 99.0,
close: 104.0,
volume: 1000.0,
};
let (plus_dm, minus_dm) = calculate_directional_movement(&bar, &prev);
// up_move = 105 - 102 = 3, down_move = 98 - 99 = -1
// +DM = 3 (up_move > down_move), -DM = 0
assert_approx_eq(plus_dm, 3.0, 0.01);
assert_eq!(minus_dm, 0.0);
}
#[test]
fn test_directional_movement_downtrend() {
let prev = OHLCVBar {
timestamp: Utc::now(),
open: 100.0,
high: 102.0,
low: 98.0,
close: 100.0,
volume: 1000.0,
};
let bar = OHLCVBar {
timestamp: Utc::now(),
open: 99.0,
high: 101.0,
low: 95.0,
close: 96.0,
volume: 1000.0,
};
let (plus_dm, minus_dm) = calculate_directional_movement(&bar, &prev);
// up_move = 101 - 102 = -1, down_move = 98 - 95 = 3
// +DM = 0, -DM = 3 (down_move > up_move)
assert_eq!(plus_dm, 0.0);
assert_approx_eq(minus_dm, 3.0, 0.01);
}
#[test]
fn test_wilder_smooth() {
let smoothed = 10.0;
let new_value = 14.0;
let period = 14;
let result = wilder_smooth(smoothed, new_value, period);
// (10 × 13 + 14) / 14 = (130 + 14) / 14 = 144 / 14 = 10.2857
assert_approx_eq(result, 10.2857, 0.01);
}
#[test]
fn test_directional_indicators() {
let (plus_di, minus_di) = calculate_directional_indicators(3.0, 1.0, 5.0);
// +DI = (3 / 5) × 100 = 60, -DI = (1 / 5) × 100 = 20
assert_approx_eq(plus_di, 60.0, 0.01);
assert_approx_eq(minus_di, 20.0, 0.01);
}
#[test]
fn test_directional_indicators_zero_tr() {
let (plus_di, minus_di) = calculate_directional_indicators(3.0, 1.0, 0.0);
// Zero TR should return (0, 0) to avoid division by zero
assert_eq!(plus_di, 0.0);
assert_eq!(minus_di, 0.0);
}
#[test]
fn test_calculate_dx() {
let dx = calculate_dx(60.0, 20.0);
// DX = |60 - 20| / (60 + 20) × 100 = 40 / 80 × 100 = 50
assert_approx_eq(dx, 50.0, 0.01);
}
#[test]
fn test_calculate_dx_equal_di() {
let dx = calculate_dx(50.0, 50.0);
// DX = |50 - 50| / (50 + 50) × 100 = 0 / 100 × 100 = 0
assert_eq!(dx, 0.0);
}
#[test]
fn test_classify_trend_strength() {
assert_eq!(classify_trend_strength(15.0), 0.0); // Weak
assert_eq!(classify_trend_strength(25.0), 1.0); // Moderate
assert_eq!(classify_trend_strength(45.0), 2.0); // Strong
}
#[test]
fn test_safe_clip() {
assert_eq!(safe_clip(50.0, 0.0, 100.0), 50.0);
assert_eq!(safe_clip(-10.0, 0.0, 100.0), 0.0);
assert_eq!(safe_clip(150.0, 0.0, 100.0), 100.0);
assert_eq!(safe_clip(f64::NAN, 0.0, 100.0), 0.0);
assert_eq!(safe_clip(f64::INFINITY, 0.0, 100.0), 0.0);
}
// ===== Integration Tests: AdxFeatureExtractor =====
#[test]
fn test_extractor_initialization() {
let extractor = AdxFeatureExtractor::new();
assert_eq!(extractor.period, 14);
assert_eq!(extractor.bar_count, 0);
assert!(!extractor.is_initialized());
}
#[test]
fn test_extractor_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]);
}
}
#[test]
fn test_extractor_trending_market() {
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);
}
// After 40 bars, ADX should be initialized and detect trending market
assert!(extractor.is_initialized());
assert!(features[0] > 0.0, "ADX: {}", features[0]); // ADX > 0
assert!(features[1] > features[2], "+DI > -DI in uptrend"); // +DI > -DI
assert!(features[3] > 0.0, "DX: {}", features[3]); // DX > 0
}
#[test]
fn test_extractor_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);
}
// After 40 bars, ADX should be initialized
assert!(extractor.is_initialized());
// ADX should be lower in ranging market (typically <20)
// But classification depends on oscillation amplitude
assert!(
features[0] >= 0.0 && features[0] <= 100.0,
"ADX: {}",
features[0]
);
assert!(
features[4] >= 0.0 && features[4] <= 2.0,
"Classification: {}",
features[4]
);
}
#[test]
fn test_extract_from_window() {
let bars = create_trending_bars(100.0, 40, 0.3);
let features = AdxFeatureExtractor::extract_from_window(&bars);
// Should return valid features after processing 40 bars
assert_eq!(features.len(), 5);
assert!(features[0].is_finite(), "ADX: {}", features[0]);
assert!(features[1].is_finite(), "+DI: {}", features[1]);
assert!(features[2].is_finite(), "-DI: {}", features[2]);
assert!(features[3].is_finite(), "DX: {}", features[3]);
assert!(
features[4] >= 0.0 && features[4] <= 2.0,
"Classification: {}",
features[4]
);
}
#[test]
fn test_extractor_reset() {
let mut extractor = AdxFeatureExtractor::new();
let bars = create_trending_bars(100.0, 30, 0.5);
for bar in bars.iter() {
extractor.update(bar);
}
assert!(extractor.bar_count() > 0);
extractor.reset();
assert_eq!(extractor.bar_count(), 0);
assert!(!extractor.is_initialized());
assert!(extractor.prev_bar.is_none());
}
#[test]
fn test_extractor_feature_ranges() {
let mut extractor = AdxFeatureExtractor::new();
let bars = create_trending_bars(100.0, 40, 0.4);
let mut features = [0.0; 5];
for bar in bars.iter() {
features = extractor.update(bar);
}
// Validate feature ranges
assert!(
features[0] >= 0.0 && features[0] <= 100.0,
"ADX: {}",
features[0]
);
assert!(
features[1] >= 0.0 && features[1] <= 100.0,
"+DI: {}",
features[1]
);
assert!(
features[2] >= 0.0 && features[2] <= 100.0,
"-DI: {}",
features[2]
);
assert!(
features[3] >= 0.0 && features[3] <= 100.0,
"DX: {}",
features[3]
);
assert!(
features[4] == 0.0 || features[4] == 1.0 || features[4] == 2.0,
"Classification: {}",
features[4]
);
}
#[test]
fn test_extractor_custom_period() {
let mut extractor = AdxFeatureExtractor::with_period(10);
assert_eq!(extractor.period, 10);
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_extractor_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);
}
// After 40 bars, should detect downtrend
assert!(extractor.is_initialized());
assert!(features[0] > 0.0, "ADX: {}", features[0]); // ADX > 0
assert!(features[2] > features[1], "-DI > +DI in downtrend"); // -DI > +DI
}
#[test]
fn test_extractor_extreme_volatility() {
let mut extractor = AdxFeatureExtractor::new();
let mut bars = create_ranging_bars(100.0, 30);
// Add extreme spike
bars.push_back(OHLCVBar {
timestamp: 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: {}",
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]
);
}
#[test]
fn test_extractor_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: {}", features[0]);
assert_eq!(features[4], 0.0, "Classification: {}", features[4]); // Weak trend
}
// ===== Performance Regression 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);
}
}
}