//! Directional Movement Index (DX), Wilder-smoothed. use crate::error::{Error, Result}; use crate::indicators::adx::directional_movement; use crate::ohlcv::Candle; use crate::traits::Indicator; /// Wilder's Directional Movement Index (`DX`). /// /// `DX = 100 · |+DI − −DI| / (+DI + −DI)`, the un-smoothed precursor to /// [`Adx`](crate::Adx) (which is the Wilder average of `DX`). Both directional /// indicators are derived from Wilder-smoothed `+DM`, `−DM` and true range over /// `period` bars, so the first value is emitted after `period + 1` candles. /// /// `DX` ranges over `[0, 100]`: high when one side of the directional system /// clearly dominates (a strong trend) and near zero when `+DI` and `−DI` are /// balanced (a range). When both directional indicators are zero — a perfectly /// flat market — the index returns `0`. /// /// # Example /// /// ``` /// use wickra_core::{Candle, Indicator, Dx}; /// /// let mut indicator = Dx::new(5).unwrap(); /// let mut last = None; /// for i in 0..40 { /// let base = 100.0 + f64::from(i); /// let candle = /// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); /// last = indicator.update(candle); /// } /// assert!(last.is_some()); /// ``` #[derive(Debug, Clone)] pub struct Dx { period: usize, prev: Option, plus_dm_seed: f64, minus_dm_seed: f64, tr_seed: f64, seed_count: usize, plus_dm_smooth: Option, minus_dm_smooth: Option, tr_smooth: Option, } impl Dx { /// # Errors /// Returns [`Error::PeriodZero`] if `period == 0`. pub fn new(period: usize) -> Result { if period == 0 { return Err(Error::PeriodZero); } Ok(Self { period, prev: None, plus_dm_seed: 0.0, minus_dm_seed: 0.0, tr_seed: 0.0, seed_count: 0, plus_dm_smooth: None, minus_dm_smooth: None, tr_smooth: None, }) } /// Configured period. pub const fn period(&self) -> usize { self.period } } impl Indicator for Dx { type Input = Candle; type Output = f64; fn update(&mut self, candle: Candle) -> Option { let Some(prev) = self.prev else { self.prev = Some(candle); return None; }; self.prev = Some(candle); let (plus_dm, minus_dm) = directional_movement(&prev, &candle); let tr = candle.true_range(Some(prev.close)); let n = self.period as f64; let (plus_v, minus_v, tr_v) = if let (Some(p), Some(m), Some(t)) = (self.plus_dm_smooth, self.minus_dm_smooth, self.tr_smooth) { let p_new = p - p / n + plus_dm; let m_new = m - m / n + minus_dm; let t_new = t - t / n + tr; self.plus_dm_smooth = Some(p_new); self.minus_dm_smooth = Some(m_new); self.tr_smooth = Some(t_new); (p_new, m_new, t_new) } else { self.plus_dm_seed += plus_dm; self.minus_dm_seed += minus_dm; self.tr_seed += tr; self.seed_count += 1; if self.seed_count < self.period { return None; } self.plus_dm_smooth = Some(self.plus_dm_seed); self.minus_dm_smooth = Some(self.minus_dm_seed); self.tr_smooth = Some(self.tr_seed); (self.plus_dm_seed, self.minus_dm_seed, self.tr_seed) }; let (plus_di, minus_di) = if tr_v == 0.0 { (0.0, 0.0) } else { (100.0 * plus_v / tr_v, 100.0 * minus_v / tr_v) }; let di_sum = plus_di + minus_di; let dx = if di_sum == 0.0 { 0.0 } else { 100.0 * (plus_di - minus_di).abs() / di_sum }; Some(dx) } fn reset(&mut self) { self.prev = None; self.plus_dm_seed = 0.0; self.minus_dm_seed = 0.0; self.tr_seed = 0.0; self.seed_count = 0; self.plus_dm_smooth = None; self.minus_dm_smooth = None; self.tr_smooth = None; } fn warmup_period(&self) -> usize { self.period } fn is_ready(&self) -> bool { self.tr_smooth.is_some() } fn name(&self) -> &'static str { "DX" } } #[cfg(test)] mod tests { use super::*; use crate::traits::BatchExt; use approx::assert_relative_eq; fn c(h: f64, l: f64, cl: f64) -> Candle { Candle::new(cl, h, l, cl, 1.0, 0).unwrap() } #[test] fn rejects_zero_period() { assert!(matches!(Dx::new(0), Err(Error::PeriodZero))); } #[test] fn accessors_report_config() { let dx = Dx::new(7).unwrap(); assert_eq!(dx.period(), 7); assert_eq!(dx.name(), "DX"); assert_eq!(dx.warmup_period(), 7); assert!(!dx.is_ready()); } #[test] fn strong_trend_drives_dx_high() { // A clean uptrend has one-sided directional movement, so DX is large. let candles: Vec = (0..12) .map(|i| { let base = 100.0 + f64::from(i) * 2.0; c(base + 1.0, base - 0.5, base + 0.5) }) .collect(); let mut dx = Dx::new(3).unwrap(); let out: Vec> = dx.batch(&candles); assert_eq!(out[0], None); assert!(out[3].is_some()); let last = out.into_iter().flatten().last().unwrap(); assert!(last > 50.0 && last <= 100.0); assert!(dx.is_ready()); } #[test] fn flat_market_returns_zero() { // Both directional indicators collapse to zero -> DX is zero. let candles: Vec = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect(); let mut dx = Dx::new(3).unwrap(); let last = dx.batch(&candles).into_iter().flatten().last().unwrap(); assert_relative_eq!(last, 0.0, epsilon = 1e-12); } #[test] fn balanced_directional_movement_is_low() { // Alternating up and down bars of equal magnitude keep +DI and -DI close, // so DX stays well below a trending reading. let candles: Vec = (0..30) .map(|i| { let base = if i % 2 == 0 { 100.0 } else { 101.0 }; c(base + 1.0, base - 1.0, base) }) .collect(); let mut dx = Dx::new(5).unwrap(); let last = dx.batch(&candles).into_iter().flatten().last().unwrap(); assert!((0.0..=100.0).contains(&last)); } #[test] fn reset_restores_initial_state() { let candles: Vec = (0..6) .map(|i| { let base = 100.0 + f64::from(i) * 2.0; c(base + 1.0, base - 0.5, base + 0.5) }) .collect(); let mut dx = Dx::new(3).unwrap(); let _ = dx.batch(&candles); assert!(dx.is_ready()); dx.reset(); assert!(!dx.is_ready()); assert_eq!(dx.update(candles[0]), None); } }