Skip to main content

wickra_core/indicators/
minus_di.rs

1//! Minus Directional Indicator (-DI), 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 Minus Directional Indicator (`MINUS_DI`).
9///
10/// `-DI = 100 · smoothed(-DM) / smoothed(TR)`, where both the minus directional
11/// movement and the true range are Wilder-smoothed over `period` bars. It is the
12/// bearish half of the directional system that drives [`Adx`](crate::Adx);
13/// readings above [`PlusDi`](crate::PlusDi) mark a down-trending regime.
14///
15/// The first `period` raw values seed the two running sums; from then on each
16/// applies the Wilder recursion `smoothed − smoothed / period + raw`. Because a
17/// bar's directional movement and true range both need the previous bar, the
18/// first value is emitted after `period + 1` candles. When the smoothed true
19/// range is zero (a perfectly flat market) the indicator returns `0`.
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{Candle, Indicator, MinusDi};
25///
26/// let mut indicator = MinusDi::new(5).unwrap();
27/// let mut last = None;
28/// for i in 0..40 {
29///     let base = 100.0 - f64::from(i);
30///     let candle =
31///         Candle::new(base, base + 2.0, base - 2.0, base - 1.0, 10.0, i64::from(i)).unwrap();
32///     last = indicator.update(candle);
33/// }
34/// assert!(last.is_some());
35/// ```
36#[derive(Debug, Clone)]
37pub struct MinusDi {
38    period: usize,
39    prev: Option<Candle>,
40    dm_seed: f64,
41    tr_seed: f64,
42    seed_count: usize,
43    dm_smooth: Option<f64>,
44    tr_smooth: Option<f64>,
45}
46
47impl MinusDi {
48    /// # Errors
49    /// Returns [`Error::PeriodZero`] if `period == 0`.
50    pub fn new(period: usize) -> Result<Self> {
51        if period == 0 {
52            return Err(Error::PeriodZero);
53        }
54        if period > crate::error::MAX_PERIOD {
55            return Err(Error::InvalidPeriod {
56                message: crate::error::PERIOD_ABOVE_MAX,
57            });
58        }
59        Ok(Self {
60            period,
61            prev: None,
62            dm_seed: 0.0,
63            tr_seed: 0.0,
64            seed_count: 0,
65            dm_smooth: None,
66            tr_smooth: None,
67        })
68    }
69
70    /// Configured period.
71    pub const fn period(&self) -> usize {
72        self.period
73    }
74}
75
76impl Indicator for MinusDi {
77    type Input = Candle;
78    type Output = f64;
79
80    #[inline]
81    fn update(&mut self, candle: Candle) -> Option<f64> {
82        let Some(prev) = self.prev else {
83            self.prev = Some(candle);
84            return None;
85        };
86        self.prev = Some(candle);
87
88        let (_, minus_dm) = directional_movement(&prev, &candle);
89        let tr = candle.true_range(Some(prev.close));
90        let n = self.period as f64;
91
92        let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) {
93            let d_new = d - d / n + minus_dm;
94            let t_new = t - t / n + tr;
95            self.dm_smooth = Some(d_new);
96            self.tr_smooth = Some(t_new);
97            (d_new, t_new)
98        } else {
99            self.dm_seed += minus_dm;
100            self.tr_seed += tr;
101            self.seed_count += 1;
102            if self.seed_count < self.period {
103                return None;
104            }
105            self.dm_smooth = Some(self.dm_seed);
106            self.tr_smooth = Some(self.tr_seed);
107            (self.dm_seed, self.tr_seed)
108        };
109
110        let di = if tr_v == 0.0 {
111            0.0
112        } else {
113            100.0 * dm_v / tr_v
114        };
115        Some(di)
116    }
117
118    fn reset(&mut self) {
119        self.prev = None;
120        self.dm_seed = 0.0;
121        self.tr_seed = 0.0;
122        self.seed_count = 0;
123        self.dm_smooth = None;
124        self.tr_smooth = None;
125    }
126
127    #[inline]
128    fn warmup_period(&self) -> usize {
129        self.period + 1
130    }
131
132    #[inline]
133    fn is_ready(&self) -> bool {
134        self.dm_smooth.is_some()
135    }
136
137    #[inline]
138    fn name(&self) -> &'static str {
139        "MINUS_DI"
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::traits::BatchExt;
147    use approx::assert_relative_eq;
148
149    fn c(h: f64, l: f64, cl: f64) -> Candle {
150        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
151    }
152
153    #[test]
154    fn rejects_zero_period() {
155        assert!(matches!(MinusDi::new(0), Err(Error::PeriodZero)));
156    }
157
158    #[test]
159    fn accessors_report_config() {
160        let di = MinusDi::new(7).unwrap();
161        assert_eq!(di.period(), 7);
162        assert_eq!(di.name(), "MINUS_DI");
163        assert_eq!(di.warmup_period(), 8);
164        assert!(!di.is_ready());
165    }
166
167    #[test]
168    fn warmup_period_matches_the_first_emitted_value() {
169        // The first candle only seeds `prev`, so seeding starts on bar 2 and the
170        // first value lands on bar `period + 1`. Pin that against the declared
171        // warmup so the two can never drift apart again.
172        let candles: Vec<Candle> = (0..12)
173            .map(|i| {
174                let x = f64::from(i);
175                c(20.0 - x, 5.0 - x, 12.0 - x)
176            })
177            .collect();
178        for period in 1..=5 {
179            let mut di = MinusDi::new(period).unwrap();
180            let out: Vec<Option<f64>> = di.batch(&candles);
181            let first = out.iter().position(Option::is_some).unwrap();
182            assert_eq!(first + 1, di.warmup_period());
183        }
184    }
185
186    #[test]
187    fn downtrend_drives_minus_di_high() {
188        // Strict downtrend: -DM dominates, so -DI is large and bounded by 100.
189        let candles: Vec<Candle> = (0..12)
190            .map(|i| {
191                let base = 140.0 - f64::from(i) * 2.0;
192                c(base + 0.5, base - 1.0, base - 0.5)
193            })
194            .collect();
195        let mut di = MinusDi::new(3).unwrap();
196        let out: Vec<Option<f64>> = di.batch(&candles);
197        assert_eq!(out[0], None);
198        assert!(out[3].is_some());
199        let last = out.into_iter().flatten().last().unwrap();
200        assert!(last > 0.0 && last <= 100.0);
201        assert!(di.is_ready());
202    }
203
204    #[test]
205    fn flat_market_returns_zero() {
206        let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
207        let mut di = MinusDi::new(3).unwrap();
208        let last = di.batch(&candles).into_iter().flatten().last().unwrap();
209        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
210    }
211
212    #[test]
213    fn reset_restores_initial_state() {
214        let candles: Vec<Candle> = (0..6)
215            .map(|i| {
216                let base = 140.0 - f64::from(i) * 2.0;
217                c(base + 0.5, base - 1.0, base - 0.5)
218            })
219            .collect();
220        let mut di = MinusDi::new(3).unwrap();
221        let _ = di.batch(&candles);
222        assert!(di.is_ready());
223        di.reset();
224        assert!(!di.is_ready());
225        assert_eq!(di.update(candles[0]), None);
226    }
227}