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        if period > crate::error::MAX_PERIOD {
56            return Err(Error::InvalidPeriod {
57                message: crate::error::PERIOD_ABOVE_MAX,
58            });
59        }
60        Ok(Self {
61            period,
62            prev: None,
63            plus_dm_seed: 0.0,
64            minus_dm_seed: 0.0,
65            tr_seed: 0.0,
66            seed_count: 0,
67            plus_dm_smooth: None,
68            minus_dm_smooth: None,
69            tr_smooth: None,
70        })
71    }
72
73    /// Configured period.
74    pub const fn period(&self) -> usize {
75        self.period
76    }
77}
78
79impl Indicator for Dx {
80    type Input = Candle;
81    type Output = f64;
82
83    fn update(&mut self, candle: Candle) -> Option<f64> {
84        let Some(prev) = self.prev else {
85            self.prev = Some(candle);
86            return None;
87        };
88        self.prev = Some(candle);
89
90        let (plus_dm, minus_dm) = directional_movement(&prev, &candle);
91        let tr = candle.true_range(Some(prev.close));
92        let n = self.period as f64;
93
94        let (plus_v, minus_v, tr_v) = if let (Some(p), Some(m), Some(t)) =
95            (self.plus_dm_smooth, self.minus_dm_smooth, self.tr_smooth)
96        {
97            let p_new = p - p / n + plus_dm;
98            let m_new = m - m / n + minus_dm;
99            let t_new = t - t / n + tr;
100            self.plus_dm_smooth = Some(p_new);
101            self.minus_dm_smooth = Some(m_new);
102            self.tr_smooth = Some(t_new);
103            (p_new, m_new, t_new)
104        } else {
105            self.plus_dm_seed += plus_dm;
106            self.minus_dm_seed += minus_dm;
107            self.tr_seed += tr;
108            self.seed_count += 1;
109            if self.seed_count < self.period {
110                return None;
111            }
112            self.plus_dm_smooth = Some(self.plus_dm_seed);
113            self.minus_dm_smooth = Some(self.minus_dm_seed);
114            self.tr_smooth = Some(self.tr_seed);
115            (self.plus_dm_seed, self.minus_dm_seed, self.tr_seed)
116        };
117
118        let (plus_di, minus_di) = if tr_v == 0.0 {
119            (0.0, 0.0)
120        } else {
121            (100.0 * plus_v / tr_v, 100.0 * minus_v / tr_v)
122        };
123        let di_sum = plus_di + minus_di;
124        let dx = if di_sum == 0.0 {
125            0.0
126        } else {
127            100.0 * (plus_di - minus_di).abs() / di_sum
128        };
129        Some(dx)
130    }
131
132    fn reset(&mut self) {
133        self.prev = None;
134        self.plus_dm_seed = 0.0;
135        self.minus_dm_seed = 0.0;
136        self.tr_seed = 0.0;
137        self.seed_count = 0;
138        self.plus_dm_smooth = None;
139        self.minus_dm_smooth = None;
140        self.tr_smooth = None;
141    }
142
143    #[inline]
144    fn warmup_period(&self) -> usize {
145        self.period + 1
146    }
147
148    #[inline]
149    fn is_ready(&self) -> bool {
150        self.tr_smooth.is_some()
151    }
152
153    #[inline]
154    fn name(&self) -> &'static str {
155        "DX"
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::traits::BatchExt;
163    use approx::assert_relative_eq;
164
165    fn c(h: f64, l: f64, cl: f64) -> Candle {
166        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
167    }
168
169    #[test]
170    fn rejects_zero_period() {
171        assert!(matches!(Dx::new(0), Err(Error::PeriodZero)));
172    }
173
174    #[test]
175    fn accessors_report_config() {
176        let dx = Dx::new(7).unwrap();
177        assert_eq!(dx.period(), 7);
178        assert_eq!(dx.name(), "DX");
179        assert_eq!(dx.warmup_period(), 8);
180        assert!(!dx.is_ready());
181    }
182
183    #[test]
184    fn warmup_period_matches_the_first_emitted_value() {
185        // The first candle only seeds `prev`, so seeding starts on bar 2 and the
186        // first value lands on bar `period + 1`. Pin that against the declared
187        // warmup so the two can never drift apart again.
188        let candles: Vec<Candle> = (0..12)
189            .map(|i| {
190                let x = f64::from(i);
191                c(11.0 + x, 9.0 + 0.5 * x, 10.0 + x)
192            })
193            .collect();
194        for period in 1..=5 {
195            let mut dx = Dx::new(period).unwrap();
196            let out: Vec<Option<f64>> = dx.batch(&candles);
197            let first = out.iter().position(Option::is_some).unwrap();
198            assert_eq!(first + 1, dx.warmup_period());
199        }
200    }
201
202    #[test]
203    fn strong_trend_drives_dx_high() {
204        // A clean uptrend has one-sided directional movement, so DX is large.
205        let candles: Vec<Candle> = (0..12)
206            .map(|i| {
207                let base = 100.0 + f64::from(i) * 2.0;
208                c(base + 1.0, base - 0.5, base + 0.5)
209            })
210            .collect();
211        let mut dx = Dx::new(3).unwrap();
212        let out: Vec<Option<f64>> = dx.batch(&candles);
213        assert_eq!(out[0], None);
214        assert!(out[3].is_some());
215        let last = out.into_iter().flatten().last().unwrap();
216        assert!(last > 50.0 && last <= 100.0);
217        assert!(dx.is_ready());
218    }
219
220    #[test]
221    fn flat_market_returns_zero() {
222        // Both directional indicators collapse to zero -> DX is zero.
223        let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
224        let mut dx = Dx::new(3).unwrap();
225        let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
226        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
227    }
228
229    #[test]
230    fn balanced_directional_movement_is_low() {
231        // Alternating up and down bars of equal magnitude keep +DI and -DI close,
232        // so DX stays well below a trending reading.
233        let candles: Vec<Candle> = (0..30)
234            .map(|i| {
235                let base = if i % 2 == 0 { 100.0 } else { 101.0 };
236                c(base + 1.0, base - 1.0, base)
237            })
238            .collect();
239        let mut dx = Dx::new(5).unwrap();
240        let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
241        assert!((0.0..=100.0).contains(&last));
242    }
243
244    #[test]
245    fn reset_restores_initial_state() {
246        let candles: Vec<Candle> = (0..6)
247            .map(|i| {
248                let base = 100.0 + f64::from(i) * 2.0;
249                c(base + 1.0, base - 0.5, base + 0.5)
250            })
251            .collect();
252        let mut dx = Dx::new(3).unwrap();
253        let _ = dx.batch(&candles);
254        assert!(dx.is_ready());
255        dx.reset();
256        assert!(!dx.is_ready());
257        assert_eq!(dx.update(candles[0]), None);
258    }
259}