Skip to main content

wickra_core/indicators/
dx.rs

1//! Directional Movement Index (DX), Wilder-smoothed.
2
3use crate::error::{Error, Result};
4use crate::indicators::adx::directional_movement;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Wilder's Directional Movement Index (`DX`).
9///
10/// `DX = 100 · |+DI − −DI| / (+DI + −DI)`, the un-smoothed precursor to
11/// [`Adx`](crate::Adx) (which is the Wilder average of `DX`). Both directional
12/// indicators are derived from Wilder-smoothed `+DM`, `−DM` and true range over
13/// `period` bars, so the first value is emitted after `period + 1` candles.
14///
15/// `DX` ranges over `[0, 100]`: high when one side of the directional system
16/// clearly dominates (a strong trend) and near zero when `+DI` and `−DI` are
17/// balanced (a range). When both directional indicators are zero — a perfectly
18/// flat market — the index returns `0`.
19///
20/// # Example
21///
22/// ```
23/// use wickra_core::{Candle, Indicator, Dx};
24///
25/// let mut indicator = Dx::new(5).unwrap();
26/// let mut last = None;
27/// for i in 0..40 {
28///     let base = 100.0 + f64::from(i);
29///     let candle =
30///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
31///     last = indicator.update(candle);
32/// }
33/// assert!(last.is_some());
34/// ```
35#[derive(Debug, Clone)]
36pub struct Dx {
37    period: usize,
38    prev: Option<Candle>,
39    plus_dm_seed: f64,
40    minus_dm_seed: f64,
41    tr_seed: f64,
42    seed_count: usize,
43    plus_dm_smooth: Option<f64>,
44    minus_dm_smooth: Option<f64>,
45    tr_smooth: Option<f64>,
46}
47
48impl Dx {
49    /// # Errors
50    /// Returns [`Error::PeriodZero`] if `period == 0`.
51    pub fn new(period: usize) -> Result<Self> {
52        if period == 0 {
53            return Err(Error::PeriodZero);
54        }
55        Ok(Self {
56            period,
57            prev: None,
58            plus_dm_seed: 0.0,
59            minus_dm_seed: 0.0,
60            tr_seed: 0.0,
61            seed_count: 0,
62            plus_dm_smooth: None,
63            minus_dm_smooth: None,
64            tr_smooth: None,
65        })
66    }
67
68    /// Configured period.
69    pub const fn period(&self) -> usize {
70        self.period
71    }
72}
73
74impl Indicator for Dx {
75    type Input = Candle;
76    type Output = f64;
77
78    fn update(&mut self, candle: Candle) -> Option<f64> {
79        let Some(prev) = self.prev else {
80            self.prev = Some(candle);
81            return None;
82        };
83        self.prev = Some(candle);
84
85        let (plus_dm, minus_dm) = directional_movement(&prev, &candle);
86        let tr = candle.true_range(Some(prev.close));
87        let n = self.period as f64;
88
89        let (plus_v, minus_v, tr_v) = if let (Some(p), Some(m), Some(t)) =
90            (self.plus_dm_smooth, self.minus_dm_smooth, self.tr_smooth)
91        {
92            let p_new = p - p / n + plus_dm;
93            let m_new = m - m / n + minus_dm;
94            let t_new = t - t / n + tr;
95            self.plus_dm_smooth = Some(p_new);
96            self.minus_dm_smooth = Some(m_new);
97            self.tr_smooth = Some(t_new);
98            (p_new, m_new, t_new)
99        } else {
100            self.plus_dm_seed += plus_dm;
101            self.minus_dm_seed += minus_dm;
102            self.tr_seed += tr;
103            self.seed_count += 1;
104            if self.seed_count < self.period {
105                return None;
106            }
107            self.plus_dm_smooth = Some(self.plus_dm_seed);
108            self.minus_dm_smooth = Some(self.minus_dm_seed);
109            self.tr_smooth = Some(self.tr_seed);
110            (self.plus_dm_seed, self.minus_dm_seed, self.tr_seed)
111        };
112
113        let (plus_di, minus_di) = if tr_v == 0.0 {
114            (0.0, 0.0)
115        } else {
116            (100.0 * plus_v / tr_v, 100.0 * minus_v / tr_v)
117        };
118        let di_sum = plus_di + minus_di;
119        let dx = if di_sum == 0.0 {
120            0.0
121        } else {
122            100.0 * (plus_di - minus_di).abs() / di_sum
123        };
124        Some(dx)
125    }
126
127    fn reset(&mut self) {
128        self.prev = None;
129        self.plus_dm_seed = 0.0;
130        self.minus_dm_seed = 0.0;
131        self.tr_seed = 0.0;
132        self.seed_count = 0;
133        self.plus_dm_smooth = None;
134        self.minus_dm_smooth = None;
135        self.tr_smooth = None;
136    }
137
138    fn warmup_period(&self) -> usize {
139        self.period
140    }
141
142    fn is_ready(&self) -> bool {
143        self.tr_smooth.is_some()
144    }
145
146    fn name(&self) -> &'static str {
147        "DX"
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::traits::BatchExt;
155    use approx::assert_relative_eq;
156
157    fn c(h: f64, l: f64, cl: f64) -> Candle {
158        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
159    }
160
161    #[test]
162    fn rejects_zero_period() {
163        assert!(matches!(Dx::new(0), Err(Error::PeriodZero)));
164    }
165
166    #[test]
167    fn accessors_report_config() {
168        let dx = Dx::new(7).unwrap();
169        assert_eq!(dx.period(), 7);
170        assert_eq!(dx.name(), "DX");
171        assert_eq!(dx.warmup_period(), 7);
172        assert!(!dx.is_ready());
173    }
174
175    #[test]
176    fn strong_trend_drives_dx_high() {
177        // A clean uptrend has one-sided directional movement, so DX is large.
178        let candles: Vec<Candle> = (0..12)
179            .map(|i| {
180                let base = 100.0 + f64::from(i) * 2.0;
181                c(base + 1.0, base - 0.5, base + 0.5)
182            })
183            .collect();
184        let mut dx = Dx::new(3).unwrap();
185        let out: Vec<Option<f64>> = dx.batch(&candles);
186        assert_eq!(out[0], None);
187        assert!(out[3].is_some());
188        let last = out.into_iter().flatten().last().unwrap();
189        assert!(last > 50.0 && last <= 100.0);
190        assert!(dx.is_ready());
191    }
192
193    #[test]
194    fn flat_market_returns_zero() {
195        // Both directional indicators collapse to zero -> DX is zero.
196        let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
197        let mut dx = Dx::new(3).unwrap();
198        let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
199        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
200    }
201
202    #[test]
203    fn balanced_directional_movement_is_low() {
204        // Alternating up and down bars of equal magnitude keep +DI and -DI close,
205        // so DX stays well below a trending reading.
206        let candles: Vec<Candle> = (0..30)
207            .map(|i| {
208                let base = if i % 2 == 0 { 100.0 } else { 101.0 };
209                c(base + 1.0, base - 1.0, base)
210            })
211            .collect();
212        let mut dx = Dx::new(5).unwrap();
213        let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
214        assert!((0.0..=100.0).contains(&last));
215    }
216
217    #[test]
218    fn reset_restores_initial_state() {
219        let candles: Vec<Candle> = (0..6)
220            .map(|i| {
221                let base = 100.0 + f64::from(i) * 2.0;
222                c(base + 1.0, base - 0.5, base + 0.5)
223            })
224            .collect();
225        let mut dx = Dx::new(3).unwrap();
226        let _ = dx.batch(&candles);
227        assert!(dx.is_ready());
228        dx.reset();
229        assert!(!dx.is_ready());
230        assert_eq!(dx.update(candles[0]), None);
231    }
232}