Created ml/src/types/ohlcv.rs as the single source of truth for OHLCVBar (DateTime<Utc> timestamp, f64 OHLCV fields). Replaced all 13 duplicate definitions across features/, regime/, real_data_loader, and evaluation/ with imports from crate::types::OHLCVBar. Key changes: - New: ml/src/types/mod.rs + ohlcv.rs with canonical OHLCVBar (derives: Debug, Clone, Copy, PartialEq, Serialize, Deserialize + Default) - Renamed: evaluation::metrics::OHLCVBar → OHLCVBarF32 (genuinely different type: f32 fields, i64 timestamp for compact backtesting) - Eliminated all import aliases (ExtractionOHLCVBar, RegimeOHLCVBar, PriceOHLCVBar, VolumeOHLCVBar) in dbn_sequence_loader.rs and pipeline.rs - Renamed regime::orchestrator::Bar → OHLCVBar (same fields, just aliased) - Updated 39 files total (13 definitions removed, imports normalized) 1883 lib tests passing, compilation clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
630 lines
21 KiB
Rust
630 lines
21 KiB
Rust
//! Trending Regime Classifier
|
||
//!
|
||
//! This module implements trend detection using:
|
||
//! - ADX (Average Directional Index) for trend strength measurement
|
||
//! - Hurst exponent for trend persistence analysis
|
||
//! - Linear regression slope significance testing
|
||
//!
|
||
//! ## Design Goals
|
||
//! - Performance: <150μs per bar (ADX + Hurst calculation)
|
||
//! - Accuracy: Discriminate trending vs ranging markets with >80% precision
|
||
//! - Real-time: Incremental ADX updates using Wilder's smoothing
|
||
//!
|
||
//! ## References
|
||
//! - Wilder, J. Wells (1978). "New Concepts in Technical Trading Systems"
|
||
//! - Hurst, H.E. (1951). "Long-term storage capacity of reservoirs"
|
||
//! - Peters, Edgar (1994). "Fractal Market Analysis"
|
||
|
||
use std::collections::VecDeque;
|
||
|
||
use crate::types::OHLCVBar;
|
||
|
||
/// Trending signal output
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum TrendingSignal {
|
||
/// Strong trending market (ADX > threshold, Hurst > 0.55)
|
||
StrongTrend { direction: Direction, strength: f64 },
|
||
/// Weak trend (ADX near threshold)
|
||
WeakTrend { direction: Direction, strength: f64 },
|
||
/// Ranging/choppy market (ADX < threshold or Hurst < 0.45)
|
||
Ranging { adx: f64, hurst: f64 },
|
||
}
|
||
|
||
/// Trend direction
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Direction {
|
||
/// Uptrend (+DI > -DI)
|
||
Bullish,
|
||
/// Downtrend (-DI > +DI)
|
||
Bearish,
|
||
}
|
||
|
||
/// Trending regime classifier using ADX and Hurst exponent
|
||
///
|
||
/// ## Algorithm
|
||
/// 1. **ADX Calculation** (Wilder's 14-period):
|
||
/// - True Range (TR) = max(high - low, |high - prev_close|, |low - prev_close|)
|
||
/// - +DM = max(0, high - prev_high), -DM = max(0, prev_low - low)
|
||
/// - Smooth TR, +DM, -DM using Wilder's EMA (α = 1/14)
|
||
/// - +DI = (+DM_smooth / TR_smooth) × 100, -DI = (-DM_smooth / TR_smooth) × 100
|
||
/// - DX = |+DI - -DI| / (+DI + -DI) × 100
|
||
/// - ADX = Wilder's EMA of DX
|
||
///
|
||
/// 2. **Hurst Exponent** (R/S analysis):
|
||
/// - H < 0.5: Mean-reverting (anti-persistent)
|
||
/// - H ≈ 0.5: Random walk
|
||
/// - H > 0.5: Trending (persistent)
|
||
///
|
||
/// 3. **Classification Rules**:
|
||
/// - Strong Trend: ADX > 25 AND Hurst > 0.55
|
||
/// - Weak Trend: ADX > 20 AND Hurst > 0.5
|
||
/// - Ranging: ADX < 20 OR Hurst < 0.5
|
||
#[derive(Debug)]
|
||
pub struct TrendingClassifier {
|
||
/// ADX threshold for trend detection (default 25.0)
|
||
adx_threshold: f64,
|
||
/// Hurst threshold for persistence (default 0.55)
|
||
hurst_threshold: f64,
|
||
/// Lookback period for calculations (default 50 bars)
|
||
lookback_period: usize,
|
||
/// Rolling OHLCV history
|
||
bars: VecDeque<OHLCVBar>,
|
||
|
||
// Incremental ADX state (Wilder's 14-period smoothing)
|
||
/// Smoothed ATR (Average True Range)
|
||
atr: Option<f64>,
|
||
/// Smoothed +DM (Positive Directional Movement)
|
||
plus_dm_smooth: Option<f64>,
|
||
/// Smoothed -DM (Negative Directional Movement)
|
||
minus_dm_smooth: Option<f64>,
|
||
/// +DI (Positive Directional Indicator)
|
||
plus_di: Option<f64>,
|
||
/// -DI (Negative Directional Indicator)
|
||
minus_di: Option<f64>,
|
||
/// ADX (Average Directional Index)
|
||
adx: Option<f64>,
|
||
|
||
/// Wilder's smoothing constant (1/14 for 14-period)
|
||
alpha_wilder: f64,
|
||
}
|
||
|
||
impl TrendingClassifier {
|
||
/// Create new trending classifier with custom thresholds
|
||
///
|
||
/// ## Arguments
|
||
/// - `adx_threshold`: ADX level for strong trend (typical: 20-30)
|
||
/// - `hurst_threshold`: Hurst exponent for persistence (typical: 0.5-0.6)
|
||
/// - `lookback_period`: Bars to retain for calculations (minimum 50)
|
||
///
|
||
/// ## Example
|
||
/// ```
|
||
/// use ml::regime::trending::TrendingClassifier;
|
||
///
|
||
/// // Conservative trend detection (fewer false positives)
|
||
/// let classifier = TrendingClassifier::new(30.0, 0.6, 100);
|
||
/// ```
|
||
pub fn new(adx_threshold: f64, hurst_threshold: f64, lookback_period: usize) -> Self {
|
||
assert!(
|
||
adx_threshold >= 0.0 && adx_threshold <= 100.0,
|
||
"ADX threshold must be in [0, 100]"
|
||
);
|
||
assert!(
|
||
hurst_threshold >= 0.0 && hurst_threshold <= 1.0,
|
||
"Hurst threshold must be in [0, 1]"
|
||
);
|
||
assert!(
|
||
lookback_period >= 20,
|
||
"Lookback period must be at least 20 bars"
|
||
);
|
||
|
||
Self {
|
||
adx_threshold,
|
||
hurst_threshold,
|
||
lookback_period,
|
||
bars: VecDeque::with_capacity(lookback_period + 1),
|
||
atr: None,
|
||
plus_dm_smooth: None,
|
||
minus_dm_smooth: None,
|
||
plus_di: None,
|
||
minus_di: None,
|
||
adx: None,
|
||
alpha_wilder: 1.0 / 14.0, // Wilder's 14-period EMA constant
|
||
}
|
||
}
|
||
|
||
/// Create default classifier (ADX 25, Hurst 0.55, 50 bars)
|
||
pub fn default() -> Self {
|
||
Self::new(25.0, 0.55, 50)
|
||
}
|
||
|
||
/// Classify market regime using latest OHLCV bar
|
||
///
|
||
/// ## Algorithm
|
||
/// 1. Update ADX incrementally using Wilder's smoothing
|
||
/// 2. Compute Hurst exponent over lookback window
|
||
/// 3. Determine trend direction from +DI/-DI comparison
|
||
/// 4. Classify as StrongTrend, WeakTrend, or Ranging
|
||
///
|
||
/// ## Performance
|
||
/// - ADX update: O(1) incremental
|
||
/// - Hurst calculation: O(n) where n = lookback_period
|
||
/// - Total: <150μs per bar (validated on RTX 3050 Ti)
|
||
pub fn classify(&mut self, bar: OHLCVBar) -> TrendingSignal {
|
||
// Add bar to history
|
||
self.bars.push_back(bar);
|
||
if self.bars.len() > self.lookback_period {
|
||
self.bars.pop_front();
|
||
}
|
||
|
||
// Need at least 2 bars for ADX calculation
|
||
if self.bars.len() < 2 {
|
||
return TrendingSignal::Ranging {
|
||
adx: 0.0,
|
||
hurst: 0.5,
|
||
};
|
||
}
|
||
|
||
// Update ADX incrementally
|
||
self.update_adx();
|
||
|
||
// Compute Hurst exponent (requires minimum 20 bars for statistical significance)
|
||
let hurst = if self.bars.len() >= 20 {
|
||
self.compute_hurst_exponent()
|
||
} else {
|
||
0.5 // Default to random walk
|
||
};
|
||
|
||
// Get current ADX value
|
||
let adx = self.adx.unwrap_or(0.0);
|
||
|
||
// Determine trend direction from +DI/-DI
|
||
let direction = match (self.plus_di, self.minus_di) {
|
||
(Some(plus), Some(minus)) if plus > minus => Direction::Bullish,
|
||
(Some(_plus), Some(_minus)) => Direction::Bearish,
|
||
_ => return TrendingSignal::Ranging { adx, hurst },
|
||
};
|
||
|
||
// Classification logic
|
||
if adx >= self.adx_threshold && hurst >= self.hurst_threshold {
|
||
TrendingSignal::StrongTrend {
|
||
direction,
|
||
strength: adx,
|
||
}
|
||
} else if adx >= (self.adx_threshold * 0.8) && hurst >= (self.hurst_threshold * 0.9) {
|
||
TrendingSignal::WeakTrend {
|
||
direction,
|
||
strength: adx,
|
||
}
|
||
} else {
|
||
TrendingSignal::Ranging { adx, hurst }
|
||
}
|
||
}
|
||
|
||
/// Get current ADX value (0-100 scale)
|
||
pub fn get_trend_strength(&self) -> f64 {
|
||
self.adx.unwrap_or(0.0)
|
||
}
|
||
|
||
/// Get current trend direction (None if ranging)
|
||
pub fn get_trend_direction(&self) -> Option<Direction> {
|
||
match (self.plus_di, self.minus_di) {
|
||
(Some(plus), Some(minus)) if plus > minus => Some(Direction::Bullish),
|
||
(Some(_plus), Some(_minus)) => Some(Direction::Bearish),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Get +DI and -DI values for directional analysis
|
||
pub fn get_directional_indicators(&self) -> (Option<f64>, Option<f64>) {
|
||
(self.plus_di, self.minus_di)
|
||
}
|
||
|
||
/// Get number of bars in history (for testing)
|
||
pub fn bar_count(&self) -> usize {
|
||
self.bars.len()
|
||
}
|
||
|
||
/// Get current ATR value (for testing)
|
||
pub fn get_atr(&self) -> Option<f64> {
|
||
self.atr
|
||
}
|
||
|
||
/// Get Wilder's smoothing constant (for testing)
|
||
pub fn get_alpha_wilder(&self) -> f64 {
|
||
self.alpha_wilder
|
||
}
|
||
|
||
// ========================================================================
|
||
// Private Implementation Methods
|
||
// ========================================================================
|
||
|
||
/// Update ADX incrementally using Wilder's smoothing (O(1) complexity)
|
||
///
|
||
/// ## Algorithm (Wilder's 14-period ADX)
|
||
/// 1. True Range (TR) = max(H-L, |H-C_prev|, |L-C_prev|)
|
||
/// 2. +DM = max(0, H - H_prev), -DM = max(0, L_prev - L)
|
||
/// 3. Smooth: ATR = ATR_prev × (13/14) + TR × (1/14)
|
||
/// 4. +DI = (+DM_smooth / ATR) × 100, -DI = (-DM_smooth / ATR) × 100
|
||
/// 5. DX = |+DI - -DI| / (+DI + -DI) × 100
|
||
/// 6. ADX = ADX_prev × (13/14) + DX × (1/14)
|
||
fn update_adx(&mut self) {
|
||
let len = self.bars.len();
|
||
if len < 2 {
|
||
return;
|
||
}
|
||
|
||
let current_bar = &self.bars[len - 1];
|
||
let prev_bar = &self.bars[len - 2];
|
||
|
||
// 1. Calculate True Range (TR)
|
||
let hl = current_bar.high - current_bar.low;
|
||
let hc = (current_bar.high - prev_bar.close).abs();
|
||
let lc = (current_bar.low - prev_bar.close).abs();
|
||
let tr = hl.max(hc).max(lc);
|
||
|
||
// 2. Calculate Directional Movements (+DM, -DM)
|
||
let high_diff = current_bar.high - prev_bar.high;
|
||
let low_diff = prev_bar.low - current_bar.low;
|
||
let plus_dm = if high_diff > low_diff && high_diff > 0.0 {
|
||
high_diff
|
||
} else {
|
||
0.0
|
||
};
|
||
let minus_dm = if low_diff > high_diff && low_diff > 0.0 {
|
||
low_diff
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
// 3. Smooth TR, +DM, -DM using Wilder's EMA (α = 1/14)
|
||
self.atr = Some(match self.atr {
|
||
Some(prev_atr) => prev_atr * (1.0 - self.alpha_wilder) + tr * self.alpha_wilder,
|
||
None => tr,
|
||
});
|
||
|
||
self.plus_dm_smooth = Some(match self.plus_dm_smooth {
|
||
Some(prev) => prev * (1.0 - self.alpha_wilder) + plus_dm * self.alpha_wilder,
|
||
None => plus_dm,
|
||
});
|
||
|
||
self.minus_dm_smooth = Some(match self.minus_dm_smooth {
|
||
Some(prev) => prev * (1.0 - self.alpha_wilder) + minus_dm * self.alpha_wilder,
|
||
None => minus_dm,
|
||
});
|
||
|
||
// 4. Calculate +DI and -DI
|
||
let atr_val = self.atr.unwrap_or(1.0);
|
||
if atr_val > 1e-8 {
|
||
self.plus_di = Some((self.plus_dm_smooth.unwrap_or(0.0) / atr_val) * 100.0);
|
||
self.minus_di = Some((self.minus_dm_smooth.unwrap_or(0.0) / atr_val) * 100.0);
|
||
} else {
|
||
self.plus_di = Some(0.0);
|
||
self.minus_di = Some(0.0);
|
||
}
|
||
|
||
// 5. Calculate DX (Directional Index)
|
||
let plus_di_val = self.plus_di.unwrap_or(0.0);
|
||
let minus_di_val = self.minus_di.unwrap_or(0.0);
|
||
let di_sum = plus_di_val + minus_di_val;
|
||
let dx = if di_sum > 1e-8 {
|
||
((plus_di_val - minus_di_val).abs() / di_sum) * 100.0
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
// 6. Smooth DX to get ADX using Wilder's EMA
|
||
self.adx = Some(match self.adx {
|
||
Some(prev_adx) => prev_adx * (1.0 - self.alpha_wilder) + dx * self.alpha_wilder,
|
||
None => dx,
|
||
});
|
||
}
|
||
|
||
/// Compute Hurst exponent using R/S (Rescaled Range) analysis
|
||
///
|
||
/// ## Algorithm
|
||
/// 1. Calculate log returns: r_i = ln(P_i / P_{i-1})
|
||
/// 2. Mean-adjusted cumulative deviations: Y_i = Σ(r_j - r_mean)
|
||
/// 3. Range: R = max(Y) - min(Y)
|
||
/// 4. Standard deviation: S = √(Σ(r_i - r_mean)² / n)
|
||
/// 5. Hurst exponent: H ≈ log(R/S) / log(n)
|
||
///
|
||
/// ## Interpretation
|
||
/// - H < 0.5: Mean-reverting (anti-persistent)
|
||
/// - H ≈ 0.5: Random walk (Brownian motion)
|
||
/// - H > 0.5: Trending (persistent, long memory)
|
||
fn compute_hurst_exponent(&self) -> f64 {
|
||
if self.bars.len() < 20 {
|
||
return 0.5; // Random walk default
|
||
}
|
||
|
||
// Calculate log returns
|
||
let prices: Vec<f64> = self.bars.iter().map(|b| b.close).collect();
|
||
let returns: Vec<f64> = prices
|
||
.windows(2)
|
||
.map(|w| if w[0] > 1e-8 { (w[1] / w[0]).ln() } else { 0.0 })
|
||
.collect();
|
||
|
||
if returns.is_empty() {
|
||
return 0.5;
|
||
}
|
||
|
||
// Mean return
|
||
let mean_return = returns.iter().sum::<f64>() / returns.len() as f64;
|
||
|
||
// Cumulative deviations from mean
|
||
let mut cumulative = vec![0.0];
|
||
let mut sum = 0.0;
|
||
for &ret in &returns {
|
||
sum += ret - mean_return;
|
||
cumulative.push(sum);
|
||
}
|
||
|
||
// Range: R = max - min
|
||
let max_cum = cumulative.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||
let min_cum = cumulative.iter().copied().fold(f64::INFINITY, f64::min);
|
||
let range = max_cum - min_cum;
|
||
|
||
// Standard deviation: S
|
||
let variance: f64 = returns
|
||
.iter()
|
||
.map(|&r| (r - mean_return).powi(2))
|
||
.sum::<f64>()
|
||
/ returns.len() as f64;
|
||
let std = variance.sqrt();
|
||
|
||
// Handle edge cases
|
||
if std < 1e-8 || range < 1e-8 {
|
||
return 0.5;
|
||
}
|
||
|
||
// R/S statistic
|
||
let rs = range / std;
|
||
|
||
// Hurst exponent: H ≈ log(R/S) / log(n) (safe version to prevent division by zero)
|
||
let n = returns.len() as f64;
|
||
let hurst = if n > 1.5 {
|
||
rs.ln() / n.ln()
|
||
} else {
|
||
0.5 // Random walk assumption for small windows
|
||
};
|
||
|
||
// Clamp to valid range [0, 1]
|
||
hurst.clamp(0.0, 1.0)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use chrono::Utc;
|
||
|
||
fn create_test_bar(close: f64, high: f64, low: f64, volume: f64) -> OHLCVBar {
|
||
OHLCVBar {
|
||
timestamp: Utc::now(),
|
||
open: close,
|
||
high,
|
||
low,
|
||
close,
|
||
volume,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_classifier_creation() {
|
||
let classifier = TrendingClassifier::new(25.0, 0.55, 50);
|
||
assert_eq!(classifier.adx_threshold, 25.0);
|
||
assert_eq!(classifier.hurst_threshold, 0.55);
|
||
assert_eq!(classifier.lookback_period, 50);
|
||
}
|
||
|
||
#[test]
|
||
fn test_default_classifier() {
|
||
let classifier = TrendingClassifier::default();
|
||
assert_eq!(classifier.adx_threshold, 25.0);
|
||
assert_eq!(classifier.hurst_threshold, 0.55);
|
||
assert_eq!(classifier.lookback_period, 50);
|
||
}
|
||
|
||
#[test]
|
||
#[should_panic(expected = "ADX threshold must be in [0, 100]")]
|
||
fn test_invalid_adx_threshold() {
|
||
let _classifier = TrendingClassifier::new(150.0, 0.55, 50);
|
||
}
|
||
|
||
#[test]
|
||
#[should_panic(expected = "Hurst threshold must be in [0, 1]")]
|
||
fn test_invalid_hurst_threshold() {
|
||
let _classifier = TrendingClassifier::new(25.0, 1.5, 50);
|
||
}
|
||
|
||
#[test]
|
||
#[should_panic(expected = "Lookback period must be at least 20 bars")]
|
||
fn test_invalid_lookback_period() {
|
||
let _classifier = TrendingClassifier::new(25.0, 0.55, 10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_insufficient_data_returns_ranging() {
|
||
let mut classifier = TrendingClassifier::default();
|
||
let bar = create_test_bar(100.0, 101.0, 99.0, 1000.0);
|
||
let signal = classifier.classify(bar);
|
||
|
||
match signal {
|
||
TrendingSignal::Ranging { adx, hurst } => {
|
||
assert_eq!(adx, 0.0);
|
||
assert_eq!(hurst, 0.5);
|
||
},
|
||
_ => panic!("Expected Ranging signal with insufficient data"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_strong_uptrend_detection() {
|
||
let mut classifier = TrendingClassifier::new(20.0, 0.5, 50);
|
||
|
||
// Simulate strong uptrend: consistent price increases
|
||
let mut price = 100.0;
|
||
for _ in 0..60 {
|
||
price += 1.0; // +1% per bar
|
||
let bar = create_test_bar(price, price * 1.005, price * 0.995, 1000.0);
|
||
let signal = classifier.classify(bar);
|
||
|
||
// After sufficient data, should detect strong trend
|
||
if classifier.bars.len() >= 30 {
|
||
match signal {
|
||
TrendingSignal::StrongTrend {
|
||
direction,
|
||
strength,
|
||
} => {
|
||
assert_eq!(direction, Direction::Bullish);
|
||
assert!(strength > 20.0, "Expected ADX > 20, got {}", strength);
|
||
},
|
||
TrendingSignal::WeakTrend { direction, .. } => {
|
||
assert_eq!(direction, Direction::Bullish);
|
||
},
|
||
_ => {
|
||
// Early bars may still be ranging, accept this
|
||
if classifier.bars.len() > 40 {
|
||
panic!("Expected trending signal after 40 bars, got {:?}", signal);
|
||
}
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
// Final validation: ADX should be elevated
|
||
let final_adx = classifier.get_trend_strength();
|
||
assert!(
|
||
final_adx > 15.0,
|
||
"Final ADX should be > 15 for strong trend, got {}",
|
||
final_adx
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_ranging_market_detection() {
|
||
let mut classifier = TrendingClassifier::new(20.0, 0.5, 50);
|
||
|
||
// Simulate ranging market: random walk with frequent direction changes
|
||
// Use a predictable pseudo-random sequence for reproducibility
|
||
let base_price = 100.0;
|
||
let mut price = base_price;
|
||
|
||
// Create bars with alternating small movements to simulate chop
|
||
for i in 0..60 {
|
||
// Alternate between small up/down moves with varying magnitude
|
||
let movement = match i % 5 {
|
||
0 => 0.3, // Small up
|
||
1 => -0.25, // Small down
|
||
2 => 0.15, // Tiny up
|
||
3 => -0.2, // Small down
|
||
4 => 0.1, // Tiny up
|
||
_ => 0.0,
|
||
};
|
||
price += movement;
|
||
|
||
// Keep high-low range tight (0.3% intrabar range)
|
||
let high = price + 0.15;
|
||
let low = price - 0.15;
|
||
let bar = create_test_bar(price, high, low, 1000.0);
|
||
classifier.classify(bar);
|
||
}
|
||
|
||
// Ranging market should have low ADX
|
||
let final_adx = classifier.get_trend_strength();
|
||
assert!(
|
||
final_adx < 25.0,
|
||
"Ranging market should have ADX < 25, got {}",
|
||
final_adx
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_direction_detection() {
|
||
let mut classifier = TrendingClassifier::default();
|
||
|
||
// Simulate downtrend
|
||
let mut price = 100.0;
|
||
for _ in 0..60 {
|
||
price -= 0.5; // -0.5% per bar
|
||
let bar = create_test_bar(price, price * 1.003, price * 0.997, 1000.0);
|
||
classifier.classify(bar);
|
||
}
|
||
|
||
if let Some(direction) = classifier.get_trend_direction() {
|
||
assert_eq!(direction, Direction::Bearish, "Expected bearish trend");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_directional_indicators() {
|
||
let mut classifier = TrendingClassifier::default();
|
||
|
||
// Add sufficient bars
|
||
let mut price = 100.0;
|
||
for _ in 0..30 {
|
||
price += 0.5;
|
||
let bar = create_test_bar(price, price * 1.01, price * 0.99, 1000.0);
|
||
classifier.classify(bar);
|
||
}
|
||
|
||
let (plus_di, minus_di) = classifier.get_directional_indicators();
|
||
assert!(plus_di.is_some(), "+DI should be calculated");
|
||
assert!(minus_di.is_some(), "-DI should be calculated");
|
||
|
||
if let (Some(plus), Some(minus)) = (plus_di, minus_di) {
|
||
assert!(plus > 0.0, "+DI should be positive");
|
||
assert!(minus >= 0.0, "-DI should be non-negative");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_incremental_adx_update() {
|
||
let mut classifier = TrendingClassifier::default();
|
||
|
||
// First bar
|
||
let bar1 = create_test_bar(100.0, 101.0, 99.0, 1000.0);
|
||
classifier.classify(bar1);
|
||
|
||
// Second bar - should trigger ADX calculation
|
||
let bar2 = create_test_bar(102.0, 103.0, 101.0, 1100.0);
|
||
classifier.classify(bar2);
|
||
|
||
assert!(classifier.atr.is_some(), "ATR should be initialized");
|
||
assert!(classifier.adx.is_some(), "ADX should be initialized");
|
||
}
|
||
|
||
#[test]
|
||
fn test_hurst_mean_reverting() {
|
||
let mut classifier = TrendingClassifier::new(25.0, 0.55, 50);
|
||
|
||
// Simulate mean-reverting series: alternating up/down
|
||
let mut price = 100.0;
|
||
for i in 0..60 {
|
||
if i % 2 == 0 {
|
||
price += 1.0;
|
||
} else {
|
||
price -= 1.0;
|
||
}
|
||
let bar = create_test_bar(price, price * 1.005, price * 0.995, 1000.0);
|
||
classifier.classify(bar);
|
||
}
|
||
|
||
// Mean-reverting should have Hurst < 0.5, leading to Ranging signal
|
||
let signal =
|
||
classifier.classify(create_test_bar(price, price * 1.01, price * 0.99, 1000.0));
|
||
match signal {
|
||
TrendingSignal::Ranging { hurst, .. } => {
|
||
assert!(
|
||
hurst < 0.6,
|
||
"Mean-reverting series should have lower Hurst, got {}",
|
||
hurst
|
||
);
|
||
},
|
||
_ => {
|
||
// Acceptable if classified as weak trend with low Hurst
|
||
},
|
||
}
|
||
}
|
||
}
|