Skip to main content

wickra_core/indicators/
plus_di.rs

1//! Plus 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 Plus Directional Indicator (`PLUS_DI`).
9///
10/// `+DI = 100 · smoothed(+DM) / smoothed(TR)`, where both the plus directional
11/// movement and the true range are Wilder-smoothed over `period` bars. It is the
12/// bullish half of the directional system that drives [`Adx`](crate::Adx);
13/// readings above [`MinusDi`](crate::MinusDi) mark an up-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, PlusDi};
25///
26/// let mut indicator = PlusDi::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 PlusDi {
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 PlusDi {
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 PlusDi {
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 (plus_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 + plus_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 += plus_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        "PLUS_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!(PlusDi::new(0), Err(Error::PeriodZero)));
156    }
157
158    #[test]
159    fn accessors_report_config() {
160        let di = PlusDi::new(7).unwrap();
161        assert_eq!(di.period(), 7);
162        assert_eq!(di.name(), "PLUS_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(11.0 + x, 9.0 + 0.5 * x, 10.0 + x)
176            })
177            .collect();
178        for period in 1..=5 {
179            let mut di = PlusDi::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 uptrend_drives_plus_di_high() {
188        // Strict uptrend: +DM dominates, so +DI is large and bounded by 100.
189        let candles: Vec<Candle> = (0..12)
190            .map(|i| {
191                let base = 100.0 + f64::from(i) * 2.0;
192                c(base + 1.0, base - 0.5, base + 0.5)
193            })
194            .collect();
195        let mut di = PlusDi::new(3).unwrap();
196        let out: Vec<Option<f64>> = di.batch(&candles);
197        assert_eq!(out[0], None);
198        // Seeds after `period` directional moves (candle index `period`).
199        assert!(out[3].is_some());
200        let last = out.into_iter().flatten().last().unwrap();
201        assert!(last > 0.0 && last <= 100.0);
202        assert!(di.is_ready());
203    }
204
205    #[test]
206    fn flat_market_returns_zero() {
207        // No range and no movement: smoothed true range is zero -> +DI is zero.
208        let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
209        let mut di = PlusDi::new(3).unwrap();
210        let last = di.batch(&candles).into_iter().flatten().last().unwrap();
211        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
212    }
213
214    #[test]
215    fn reset_restores_initial_state() {
216        let candles: Vec<Candle> = (0..6)
217            .map(|i| {
218                let base = 100.0 + f64::from(i) * 2.0;
219                c(base + 1.0, base - 0.5, base + 0.5)
220            })
221            .collect();
222        let mut di = PlusDi::new(3).unwrap();
223        let _ = di.batch(&candles);
224        assert!(di.is_ready());
225        di.reset();
226        assert!(!di.is_ready());
227        assert_eq!(di.update(candles[0]), None);
228    }
229}