Files
foxhunt/ml/src/regime/trending.rs
jgrusewski 1934367bfa refactor(ml): consolidate 13 duplicate OHLCVBar definitions into single canonical type
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>
2026-02-20 18:14:42 +01:00

630 lines
21 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.
//! 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
},
}
}
}