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        Ok(Self {
62            period,
63            ema: Ema::new(period)?,
64            prev_close: None,
65        })
66    }
67
68    /// Configured EMA smoothing period.
69    pub const fn period(&self) -> usize {
70        self.period
71    }
72}
73
74impl Indicator for DemandIndex {
75    type Input = Candle;
76    type Output = f64;
77
78    fn update(&mut self, candle: Candle) -> Option<f64> {
79        let Some(prev) = self.prev_close else {
80            self.prev_close = Some(candle.close);
81            return None;
82        };
83        let pressure = if prev == 0.0 {
84            // No prior baseline -> can't normalise; treat as no flow.
85            0.0
86        } else {
87            let ret = (candle.close - prev) / prev;
88            let range_norm = (candle.high - candle.low) / prev;
89            candle.volume * ret * (1.0 + range_norm)
90        };
91        self.prev_close = Some(candle.close);
92        self.ema.update(pressure)
93    }
94
95    fn reset(&mut self) {
96        self.ema.reset();
97        self.prev_close = None;
98    }
99
100    fn warmup_period(&self) -> usize {
101        // One seed bar to establish the previous close, then the EMA needs
102        // `period` samples to seed.
103        self.period + 1
104    }
105
106    fn is_ready(&self) -> bool {
107        self.ema.is_ready()
108    }
109
110    fn name(&self) -> &'static str {
111        "DemandIndex"
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::traits::BatchExt;
119    use approx::assert_relative_eq;
120
121    fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
122        Candle::new(open, high, low, close, volume, ts).unwrap()
123    }
124
125    #[test]
126    fn rejects_zero_period() {
127        assert!(matches!(DemandIndex::new(0), Err(Error::PeriodZero)));
128    }
129
130    #[test]
131    fn accessors_and_metadata() {
132        let di = DemandIndex::new(10).unwrap();
133        assert_eq!(di.period(), 10);
134        assert_eq!(di.name(), "DemandIndex");
135        assert_eq!(di.warmup_period(), 11);
136    }
137
138    #[test]
139    fn constant_series_yields_zero() {
140        // No close change -> pressure = 0 on every bar -> EMA stays at 0.
141        let candles: Vec<Candle> = (0..40)
142            .map(|i| c(10.0, 10.0, 10.0, 10.0, 100.0, i))
143            .collect();
144        let mut di = DemandIndex::new(5).unwrap();
145        for v in di.batch(&candles).into_iter().flatten() {
146            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
147        }
148    }
149
150    #[test]
151    fn rising_series_yields_positive_signal() {
152        // Strictly rising closes on constant volume -> pressure is positive every
153        // bar -> smoothed DI must end up strictly positive.
154        let candles: Vec<Candle> = (0..40)
155            .map(|i| {
156                let f = i as f64;
157                c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i)
158            })
159            .collect();
160        let mut di = DemandIndex::new(5).unwrap();
161        let out = di.batch(&candles);
162        let last = out.iter().filter_map(|x| *x).next_back().unwrap();
163        assert!(
164            last > 0.0,
165            "rising series must yield positive DI, got {last}"
166        );
167    }
168
169    #[test]
170    fn falling_series_yields_negative_signal() {
171        let candles: Vec<Candle> = (0..40)
172            .map(|i| {
173                let f = i as f64;
174                c(200.0 - f, 201.0 - f, 199.0 - f, 199.5 - f, 100.0, i)
175            })
176            .collect();
177        let mut di = DemandIndex::new(5).unwrap();
178        let out = di.batch(&candles);
179        let last = out.iter().filter_map(|x| *x).next_back().unwrap();
180        assert!(
181            last < 0.0,
182            "falling series must yield negative DI, got {last}"
183        );
184    }
185
186    #[test]
187    fn zero_prev_close_contributes_no_signal() {
188        // First two bars: prev close is exactly zero -> pressure clipped to 0.
189        // We then continue with a non-zero series and confirm output behaves.
190        let mut di = DemandIndex::new(3).unwrap();
191        di.update(c(0.0, 0.0, 0.0, 0.0, 100.0, 0));
192        // Bar 2 sees prev_close == 0 -> pressure = 0.
193        di.update(c(0.0, 1.0, 0.0, 1.0, 100.0, 1));
194        // Subsequent bars now have non-zero prev_close.
195        di.update(c(1.0, 2.0, 1.0, 2.0, 100.0, 2));
196        // Just check that nothing exploded; an EMA(3) needs 3 samples post-seed.
197        // The first sample at bar 2 was zero, the second at bar 3 positive.
198        let v = di.update(c(2.0, 3.0, 2.0, 3.0, 100.0, 3));
199        assert!(v.is_some());
200        assert!(v.unwrap().is_finite());
201    }
202
203    #[test]
204    fn batch_equals_streaming() {
205        let candles: Vec<Candle> = (0..100i64)
206            .map(|i| {
207                let f = i as f64;
208                let mid = 100.0 + (f * 0.2).sin() * 5.0;
209                c(
210                    mid,
211                    mid + 1.5,
212                    mid - 1.5,
213                    mid + 0.3,
214                    80.0 + (i % 5) as f64,
215                    i,
216                )
217            })
218            .collect();
219        let mut a = DemandIndex::new(10).unwrap();
220        let mut b = DemandIndex::new(10).unwrap();
221        assert_eq!(
222            a.batch(&candles),
223            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
224        );
225    }
226
227    #[test]
228    fn reset_clears_state() {
229        let candles: Vec<Candle> = (0..40)
230            .map(|i| {
231                let f = i as f64;
232                c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i)
233            })
234            .collect();
235        let mut di = DemandIndex::new(5).unwrap();
236        di.batch(&candles);
237        assert!(di.is_ready());
238        di.reset();
239        assert!(!di.is_ready());
240        assert_eq!(di.update(candles[0]), None);
241    }
242}