Skip to main content

quantwave_core/indicators/incremental/
dmi.rs

1//! Native O(1) DMI family — TA-Lib Wilder smoothing parity (ADX, ADXR, DX, +DI, -DI).
2
3use crate::traits::Next;
4
5/// Shared Wilder-smoothed TR / +DM / -DM state.
6#[derive(Debug, Clone)]
7struct DmiCore {
8    timeperiod: usize,
9    period_f: f64,
10    prev_high: Option<f64>,
11    prev_low: Option<f64>,
12    prev_close: Option<f64>,
13    bar_index: usize,
14    sum_tr: f64,
15    sum_pdm: f64,
16    sum_mdm: f64,
17    seeded: bool,
18}
19
20impl DmiCore {
21    fn new(timeperiod: usize) -> Self {
22        Self {
23            timeperiod,
24            period_f: timeperiod as f64,
25            prev_high: None,
26            prev_low: None,
27            prev_close: None,
28            bar_index: 0,
29            sum_tr: 0.0,
30            sum_pdm: 0.0,
31            sum_mdm: 0.0,
32            seeded: false,
33        }
34    }
35
36    #[inline]
37    fn dm_components(&self, high: f64, low: f64) -> (f64, f64, f64) {
38        let (ph, pl, pc) = match (self.prev_high, self.prev_low, self.prev_close) {
39            (Some(ph), Some(pl), Some(pc)) => (ph, pl, pc),
40            _ => return (0.0, 0.0, 0.0),
41        };
42        let hl = high - low;
43        let hc = (high - pc).abs();
44        let lc = (low - pc).abs();
45        let tr = hl.max(hc).max(lc);
46        let up = high - ph;
47        let down = pl - low;
48        let pdm = if up > down && up > 0.0 { up } else { 0.0 };
49        let mdm = if down > up && down > 0.0 { down } else { 0.0 };
50        (tr, pdm, mdm)
51    }
52
53    /// Advance one bar; returns `Some((pdi, mdi, dx))` from bar `timeperiod` on.
54    ///
55    /// Zero smoothed TR yields `+DI = -DI = 0`; a zero `+DI + -DI` yields `DX = 0`.
56    fn step(&mut self, high: f64, low: f64, close: f64) -> Option<(f64, f64, f64)> {
57        let period = self.timeperiod;
58        if period < 1 {
59            return None;
60        }
61
62        if self.prev_high.is_none() {
63            self.prev_high = Some(high);
64            self.prev_low = Some(low);
65            self.prev_close = Some(close);
66            self.bar_index = 1;
67            return None;
68        }
69
70        let (tr, pdm, mdm) = self.dm_components(high, low);
71        self.prev_high = Some(high);
72        self.prev_low = Some(low);
73        self.prev_close = Some(close);
74        let i = self.bar_index;
75        self.bar_index += 1;
76
77        if !self.seeded {
78            if i < period {
79                self.sum_tr += tr;
80                self.sum_pdm += pdm;
81                self.sum_mdm += mdm;
82                return None;
83            }
84            self.seeded = true;
85        }
86        self.sum_tr = self.sum_tr - self.sum_tr / self.period_f + tr;
87        self.sum_pdm = self.sum_pdm - self.sum_pdm / self.period_f + pdm;
88        self.sum_mdm = self.sum_mdm - self.sum_mdm / self.period_f + mdm;
89
90        // Zero smoothed true range (e.g. a flat / limit-locked stretch):
91        // +DI = -DI = 0 rather than undefined, so ADX seeding is never skipped
92        // (quantwave-ygg6).
93        let (pdi, mdi) = if self.sum_tr <= 0.0 {
94            (0.0, 0.0)
95        } else {
96            (
97                100.0 * self.sum_pdm / self.sum_tr,
98                100.0 * self.sum_mdm / self.sum_tr,
99            )
100        };
101        let sum_di = pdi + mdi;
102        let dx = if sum_di > 0.0 {
103            100.0 * (pdi - mdi).abs() / sum_di
104        } else {
105            0.0
106        };
107        Some((pdi, mdi, dx))
108    }
109}
110
111/// Plus Directional Indicator (+DI).
112#[derive(Debug, Clone)]
113#[allow(non_camel_case_types)]
114pub struct PLUS_DI {
115    pub timeperiod: usize,
116    core: DmiCore,
117}
118
119impl PLUS_DI {
120    pub fn new(timeperiod: usize) -> Self {
121        Self {
122            timeperiod,
123            core: DmiCore::new(timeperiod),
124        }
125    }
126}
127
128impl Next<(f64, f64, f64)> for PLUS_DI {
129    type Output = f64;
130
131    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
132        match self.core.step(high, low, close) {
133            Some((pdi, _, _)) => pdi,
134            None => f64::NAN,
135        }
136    }
137}
138
139/// Minus Directional Indicator (-DI).
140#[derive(Debug, Clone)]
141#[allow(non_camel_case_types)]
142pub struct MINUS_DI {
143    pub timeperiod: usize,
144    core: DmiCore,
145}
146
147impl MINUS_DI {
148    pub fn new(timeperiod: usize) -> Self {
149        Self {
150            timeperiod,
151            core: DmiCore::new(timeperiod),
152        }
153    }
154}
155
156impl Next<(f64, f64, f64)> for MINUS_DI {
157    type Output = f64;
158
159    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
160        match self.core.step(high, low, close) {
161            Some((_, mdi, _)) => mdi,
162            None => f64::NAN,
163        }
164    }
165}
166
167/// Directional Movement Index (DX).
168#[derive(Debug, Clone)]
169#[allow(non_camel_case_types)]
170pub struct DX {
171    pub timeperiod: usize,
172    core: DmiCore,
173}
174
175impl DX {
176    pub fn new(timeperiod: usize) -> Self {
177        Self {
178            timeperiod,
179            core: DmiCore::new(timeperiod),
180        }
181    }
182}
183
184impl Next<(f64, f64, f64)> for DX {
185    type Output = f64;
186
187    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
188        match self.core.step(high, low, close) {
189            Some((_, _, dx)) => dx,
190            None => f64::NAN,
191        }
192    }
193}
194
195/// Average Directional Index (ADX).
196#[derive(Debug, Clone)]
197#[allow(non_camel_case_types)]
198pub struct ADX {
199    pub timeperiod: usize,
200    core: DmiCore,
201    dx_values: Vec<f64>,
202    adx: f64,
203    adx_ready: bool,
204}
205
206impl ADX {
207    pub fn new(timeperiod: usize) -> Self {
208        Self {
209            timeperiod,
210            core: DmiCore::new(timeperiod),
211            dx_values: Vec::new(),
212            adx: 0.0,
213            adx_ready: false,
214        }
215    }
216}
217
218impl Next<(f64, f64, f64)> for ADX {
219    type Output = f64;
220
221    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
222        let period = self.timeperiod;
223        if period < 2 {
224            return f64::NAN;
225        }
226
227        let Some((_, _, dx)) = self.core.step(high, low, close) else {
228            return f64::NAN;
229        };
230
231        let adx_start = 2 * period - 1;
232        let bar = self.core.bar_index.saturating_sub(1);
233
234        if bar < period {
235            return f64::NAN;
236        }
237
238        if bar < adx_start {
239            self.dx_values.push(dx);
240            return f64::NAN;
241        }
242
243        if bar == adx_start {
244            self.dx_values.push(dx);
245            let seed: f64 = self.dx_values.iter().sum::<f64>() / period as f64;
246            self.adx = seed;
247            self.adx_ready = true;
248            return seed;
249        }
250
251        if self.adx_ready {
252            self.adx = (self.adx * (period as f64 - 1.0) + dx) / period as f64;
253            return self.adx;
254        }
255
256        f64::NAN
257    }
258}
259
260/// Average Directional Movement Index Rating (ADXR).
261#[derive(Debug, Clone)]
262#[allow(non_camel_case_types)]
263pub struct ADXR {
264    pub timeperiod: usize,
265    adx: ADX,
266    adx_history: Vec<f64>,
267}
268
269impl ADXR {
270    pub fn new(timeperiod: usize) -> Self {
271        Self {
272            timeperiod,
273            adx: ADX::new(timeperiod),
274            adx_history: Vec::new(),
275        }
276    }
277}
278
279impl Next<(f64, f64, f64)> for ADXR {
280    type Output = f64;
281
282    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
283        let period = self.timeperiod;
284        let adx_val = self.adx.next((high, low, close));
285        self.adx_history.push(adx_val);
286
287        let adxr_lookback = 3 * period - 2;
288        let bar = self.adx_history.len().saturating_sub(1);
289        if bar < adxr_lookback {
290            return f64::NAN;
291        }
292        if adx_val.is_nan() {
293            return f64::NAN;
294        }
295        let past_idx = bar + 1 - period;
296        let past = self.adx_history[past_idx];
297        if past.is_nan() {
298            return f64::NAN;
299        }
300        (adx_val + past) / 2.0
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use proptest::prelude::*;
308
309    fn hlc(len: usize, h: &[f64], l: &[f64], c: &[f64]) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
310        let mut high = Vec::with_capacity(len);
311        let mut low = Vec::with_capacity(len);
312        let mut close = Vec::with_capacity(len);
313        for i in 0..len {
314            let val_h = h[i];
315            let val_l = l[i];
316            let val_c = c[i];
317            high.push(val_h.max(val_l).max(val_c));
318            low.push(val_h.min(val_l).min(val_c));
319            close.push(val_c);
320        }
321        (high, low, close)
322    }
323
324    proptest! {
325        #[test]
326        fn test_adx_parity(
327            h in prop::collection::vec(1.0..100.0, 1..100),
328            l in prop::collection::vec(1.0..100.0, 1..100),
329            c in prop::collection::vec(1.0..100.0, 1..100)
330        ) {
331            let len = h.len().min(l.len()).min(c.len());
332            if len < 30 { return Ok(()); }
333            let (high, low, close) = hlc(len, &h, &l, &c);
334            let period = 14;
335            let mut adx = ADX::new(period);
336            let streaming: Vec<f64> = (0..len)
337                .map(|i| adx.next((high[i], low[i], close[i])))
338                .collect();
339            let batch = talib_rs::momentum::adx(&high, &low, &close, period)
340                .unwrap_or_else(|_| vec![f64::NAN; len]);
341            for (s, b) in streaming.iter().zip(batch.iter()) {
342                if s.is_nan() { assert!(b.is_nan()); }
343                else { approx::assert_relative_eq!(s, b, epsilon = 1e-6); }
344            }
345        }
346
347        #[test]
348        fn test_dx_parity(
349            h in prop::collection::vec(1.0..100.0, 1..100),
350            l in prop::collection::vec(1.0..100.0, 1..100),
351            c in prop::collection::vec(1.0..100.0, 1..100)
352        ) {
353            let len = h.len().min(l.len()).min(c.len());
354            if len < 20 { return Ok(()); }
355            let (high, low, close) = hlc(len, &h, &l, &c);
356            let period = 14;
357            let mut dx = DX::new(period);
358            let streaming: Vec<f64> = (0..len)
359                .map(|i| dx.next((high[i], low[i], close[i])))
360                .collect();
361            let batch = talib_rs::momentum::dx(&high, &low, &close, period)
362                .unwrap_or_else(|_| vec![f64::NAN; len]);
363            for (s, b) in streaming.iter().zip(batch.iter()) {
364                if s.is_nan() { assert!(b.is_nan()); }
365                else { approx::assert_relative_eq!(s, b, epsilon = 1e-6); }
366            }
367        }
368
369        #[test]
370        fn test_plus_di_parity(
371            h in prop::collection::vec(1.0..100.0, 1..100),
372            l in prop::collection::vec(1.0..100.0, 1..100),
373            c in prop::collection::vec(1.0..100.0, 1..100)
374        ) {
375            let len = h.len().min(l.len()).min(c.len());
376            if len < 20 { return Ok(()); }
377            let (high, low, close) = hlc(len, &h, &l, &c);
378            let period = 14;
379            let mut pdi = PLUS_DI::new(period);
380            let streaming: Vec<f64> = (0..len)
381                .map(|i| pdi.next((high[i], low[i], close[i])))
382                .collect();
383            let batch = talib_rs::momentum::plus_di(&high, &low, &close, period)
384                .unwrap_or_else(|_| vec![f64::NAN; len]);
385            for (s, b) in streaming.iter().zip(batch.iter()) {
386                if s.is_nan() { assert!(b.is_nan()); }
387                else { approx::assert_relative_eq!(s, b, epsilon = 1e-6); }
388            }
389        }
390    }
391}
392
393#[cfg(test)]
394#[path = "dmi_zero_tr_tests.rs"]
395pub(crate) mod zero_tr_tests;