//! 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, // 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, } 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) -> [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) -> VecDeque { 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 { (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 { (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); } } }