- 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>
850 lines
26 KiB
Rust
850 lines
26 KiB
Rust
//! 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);
|
||
}
|
||
}
|
||
}
|