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        Ok(Self {
55            period,
56            prev: None,
57            dm_seed: 0.0,
58            tr_seed: 0.0,
59            seed_count: 0,
60            dm_smooth: None,
61            tr_smooth: None,
62        })
63    }
64
65    /// Configured period.
66    pub const fn period(&self) -> usize {
67        self.period
68    }
69}
70
71impl Indicator for PlusDi {
72    type Input = Candle;
73    type Output = f64;
74
75    fn update(&mut self, candle: Candle) -> Option<f64> {
76        let Some(prev) = self.prev else {
77            self.prev = Some(candle);
78            return None;
79        };
80        self.prev = Some(candle);
81
82        let (plus_dm, _) = directional_movement(&prev, &candle);
83        let tr = candle.true_range(Some(prev.close));
84        let n = self.period as f64;
85
86        let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) {
87            let d_new = d - d / n + plus_dm;
88            let t_new = t - t / n + tr;
89            self.dm_smooth = Some(d_new);
90            self.tr_smooth = Some(t_new);
91            (d_new, t_new)
92        } else {
93            self.dm_seed += plus_dm;
94            self.tr_seed += tr;
95            self.seed_count += 1;
96            if self.seed_count < self.period {
97                return None;
98            }
99            self.dm_smooth = Some(self.dm_seed);
100            self.tr_smooth = Some(self.tr_seed);
101            (self.dm_seed, self.tr_seed)
102        };
103
104        let di = if tr_v == 0.0 {
105            0.0
106        } else {
107            100.0 * dm_v / tr_v
108        };
109        Some(di)
110    }
111
112    fn reset(&mut self) {
113        self.prev = None;
114        self.dm_seed = 0.0;
115        self.tr_seed = 0.0;
116        self.seed_count = 0;
117        self.dm_smooth = None;
118        self.tr_smooth = None;
119    }
120
121    fn warmup_period(&self) -> usize {
122        self.period
123    }
124
125    fn is_ready(&self) -> bool {
126        self.dm_smooth.is_some()
127    }
128
129    fn name(&self) -> &'static str {
130        "PLUS_DI"
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::traits::BatchExt;
138    use approx::assert_relative_eq;
139
140    fn c(h: f64, l: f64, cl: f64) -> Candle {
141        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
142    }
143
144    #[test]
145    fn rejects_zero_period() {
146        assert!(matches!(PlusDi::new(0), Err(Error::PeriodZero)));
147    }
148
149    #[test]
150    fn accessors_report_config() {
151        let di = PlusDi::new(7).unwrap();
152        assert_eq!(di.period(), 7);
153        assert_eq!(di.name(), "PLUS_DI");
154        assert_eq!(di.warmup_period(), 7);
155        assert!(!di.is_ready());
156    }
157
158    #[test]
159    fn uptrend_drives_plus_di_high() {
160        // Strict uptrend: +DM dominates, so +DI is large and bounded by 100.
161        let candles: Vec<Candle> = (0..12)
162            .map(|i| {
163                let base = 100.0 + f64::from(i) * 2.0;
164                c(base + 1.0, base - 0.5, base + 0.5)
165            })
166            .collect();
167        let mut di = PlusDi::new(3).unwrap();
168        let out: Vec<Option<f64>> = di.batch(&candles);
169        assert_eq!(out[0], None);
170        // Seeds after `period` directional moves (candle index `period`).
171        assert!(out[3].is_some());
172        let last = out.into_iter().flatten().last().unwrap();
173        assert!(last > 0.0 && last <= 100.0);
174        assert!(di.is_ready());
175    }
176
177    #[test]
178    fn flat_market_returns_zero() {
179        // No range and no movement: smoothed true range is zero -> +DI is zero.
180        let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
181        let mut di = PlusDi::new(3).unwrap();
182        let last = di.batch(&candles).into_iter().flatten().last().unwrap();
183        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
184    }
185
186    #[test]
187    fn reset_restores_initial_state() {
188        let candles: Vec<Candle> = (0..6)
189            .map(|i| {
190                let base = 100.0 + f64::from(i) * 2.0;
191                c(base + 1.0, base - 0.5, base + 0.5)
192            })
193            .collect();
194        let mut di = PlusDi::new(3).unwrap();
195        let _ = di.batch(&candles);
196        assert!(di.is_ready());
197        di.reset();
198        assert!(!di.is_ready());
199        assert_eq!(di.update(candles[0]), None);
200    }
201}