Skip to main content

wickra_core/indicators/
demand_index.rs

1//! Demand Index (James Sibbet).
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// James Sibbet's Demand Index — a smoothed ratio of buying pressure to
9/// selling pressure, classifying each bar's volume by whether the close rose
10/// or fell relative to the previous close.
11///
12/// Sibbet's original 1970s formulation runs the raw buying/selling pressure
13/// through several smoothings and yields a number that swings in `[−100, 100]`.
14/// This implementation uses the textbook simplified form that captures the same
15/// signal in a streaming-friendly shape:
16///
17/// ```text
18/// pressure_t = volume_t · ((close_t − close_{t−1}) / max(close_{t−1}, ε))
19///              · (1 + (high_t − low_t) / max(close_{t−1}, ε))
20/// DI_t       = EMA(pressure, period)_t
21/// ```
22///
23/// Positive readings mean the smoothed money flow is leaning to the buy side
24/// (up-day volume dominates), negative to the sell side. The first candle only
25/// establishes the previous close, so the first non-`None` value lands once the
26/// EMA has accumulated `period` pressure samples. A previous close of zero
27/// contributes no signal (avoids division by zero). The output is unbounded;
28/// what matters is the sign and the divergence against price.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Candle, DemandIndex, Indicator};
34///
35/// let mut indicator = DemandIndex::new(10).unwrap();
36/// let mut last = None;
37/// for i in 0..120 {
38///     let base = 100.0 + f64::from(i);
39///     let candle =
40///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 50.0, i64::from(i)).unwrap();
41///     last = indicator.update(candle);
42/// }
43/// assert!(last.is_some());
44/// ```
45#[derive(Debug, Clone)]
46pub struct DemandIndex {
47    period: usize,
48    ema: Ema,
49    prev_close: Option<f64>,
50}
51
52impl DemandIndex {
53    /// Construct a new Demand Index with the given EMA smoothing period.
54    ///
55    /// # Errors
56    /// Returns [`Error::PeriodZero`] if `period == 0`.
57    pub fn new(period: usize) -> Result<Self> {
58        if period == 0 {
59            return Err(Error::PeriodZero);
60        }
61        if period > crate::error::MAX_PERIOD {
62            return Err(Error::InvalidPeriod {
63                message: crate::error::PERIOD_ABOVE_MAX,
64            });
65        }
66        Ok(Self {
67            period,
68            ema: Ema::new(period)?,
69            prev_close: None,
70        })
71    }
72
73    /// Configured EMA smoothing period.
74    pub const fn period(&self) -> usize {
75        self.period
76    }
77}
78
79impl Indicator for DemandIndex {
80    type Input = Candle;
81    type Output = f64;
82
83    #[inline]
84    fn update(&mut self, candle: Candle) -> Option<f64> {
85        let Some(prev) = self.prev_close else {
86            self.prev_close = Some(candle.close);
87            return None;
88        };
89        let pressure = if prev == 0.0 {
90            // No prior baseline -> can't normalise; treat as no flow.
91            0.0
92        } else {
93            let ret = (candle.close - prev) / prev;
94            let range_norm = (candle.high - candle.low) / prev;
95            candle.volume * ret * (1.0 + range_norm)
96        };
97        self.prev_close = Some(candle.close);
98        self.ema.update(pressure)
99    }
100
101    fn reset(&mut self) {
102        self.ema.reset();
103        self.prev_close = None;
104    }
105
106    #[inline]
107    fn warmup_period(&self) -> usize {
108        // One seed bar to establish the previous close, then the EMA needs
109        // `period` samples to seed.
110        self.period + 1
111    }
112
113    #[inline]
114    fn is_ready(&self) -> bool {
115        self.ema.is_ready()
116    }
117
118    #[inline]
119    fn name(&self) -> &'static str {
120        "DemandIndex"
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::traits::BatchExt;
128    use approx::assert_relative_eq;
129
130    fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
131        Candle::new(open, high, low, close, volume, ts).unwrap()
132    }
133
134    #[test]
135    fn rejects_zero_period() {
136        assert!(matches!(DemandIndex::new(0), Err(Error::PeriodZero)));
137    }
138
139    #[test]
140    fn accessors_and_metadata() {
141        let di = DemandIndex::new(10).unwrap();
142        assert_eq!(di.period(), 10);
143        assert_eq!(di.name(), "DemandIndex");
144        assert_eq!(di.warmup_period(), 11);
145    }
146
147    #[test]
148    fn constant_series_yields_zero() {
149        // No close change -> pressure = 0 on every bar -> EMA stays at 0.
150        let candles: Vec<Candle> = (0..40)
151            .map(|i| c(10.0, 10.0, 10.0, 10.0, 100.0, i))
152            .collect();
153        let mut di = DemandIndex::new(5).unwrap();
154        for v in di.batch(&candles).into_iter().flatten() {
155            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
156        }
157    }
158
159    #[test]
160    fn rising_series_yields_positive_signal() {
161        // Strictly rising closes on constant volume -> pressure is positive every
162        // bar -> smoothed DI must end up strictly positive.
163        let candles: Vec<Candle> = (0..40)
164            .map(|i| {
165                let f = i as f64;
166                c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i)
167            })
168            .collect();
169        let mut di = DemandIndex::new(5).unwrap();
170        let out = di.batch(&candles);
171        let last = out.iter().filter_map(|x| *x).next_back().unwrap();
172        assert!(
173            last > 0.0,
174            "rising series must yield positive DI, got {last}"
175        );
176    }
177
178    #[test]
179    fn falling_series_yields_negative_signal() {
180        let candles: Vec<Candle> = (0..40)
181            .map(|i| {
182                let f = i as f64;
183                c(200.0 - f, 201.0 - f, 199.0 - f, 199.5 - f, 100.0, i)
184            })
185            .collect();
186        let mut di = DemandIndex::new(5).unwrap();
187        let out = di.batch(&candles);
188        let last = out.iter().filter_map(|x| *x).next_back().unwrap();
189        assert!(
190            last < 0.0,
191            "falling series must yield negative DI, got {last}"
192        );
193    }
194
195    #[test]
196    fn zero_prev_close_contributes_no_signal() {
197        // First two bars: prev close is exactly zero -> pressure clipped to 0.
198        // We then continue with a non-zero series and confirm output behaves.
199        let mut di = DemandIndex::new(3).unwrap();
200        di.update(c(0.0, 0.0, 0.0, 0.0, 100.0, 0));
201        // Bar 2 sees prev_close == 0 -> pressure = 0.
202        di.update(c(0.0, 1.0, 0.0, 1.0, 100.0, 1));
203        // Subsequent bars now have non-zero prev_close.
204        di.update(c(1.0, 2.0, 1.0, 2.0, 100.0, 2));
205        // Just check that nothing exploded; an EMA(3) needs 3 samples post-seed.
206        // The first sample at bar 2 was zero, the second at bar 3 positive.
207        let v = di.update(c(2.0, 3.0, 2.0, 3.0, 100.0, 3));
208        assert!(v.is_some());
209        assert!(v.unwrap().is_finite());
210    }
211
212    #[test]
213    fn batch_equals_streaming() {
214        let candles: Vec<Candle> = (0..100i64)
215            .map(|i| {
216                let f = i as f64;
217                let mid = 100.0 + (f * 0.2).sin() * 5.0;
218                c(
219                    mid,
220                    mid + 1.5,
221                    mid - 1.5,
222                    mid + 0.3,
223                    80.0 + (i % 5) as f64,
224                    i,
225                )
226            })
227            .collect();
228        let mut a = DemandIndex::new(10).unwrap();
229        let mut b = DemandIndex::new(10).unwrap();
230        assert_eq!(
231            a.batch(&candles),
232            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
233        );
234    }
235
236    #[test]
237    fn reset_clears_state() {
238        let candles: Vec<Candle> = (0..40)
239            .map(|i| {
240                let f = i as f64;
241                c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i)
242            })
243            .collect();
244        let mut di = DemandIndex::new(5).unwrap();
245        di.batch(&candles);
246        assert!(di.is_ready());
247        di.reset();
248        assert!(!di.is_ready());
249        assert_eq!(di.update(candles[0]), None);
250    }
251}