Skip to main content

wickra_core/indicators/
demand_index.rs

1//! Demand Index (James Sibbet).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::ema::Ema;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Cap on the exponent of the pressure ratio (as in the `TradeStation` function),
11/// keeping `exp` finite on a violent bar.
12const MAX_EXPONENT: f64 = 88.0;
13
14/// James Sibbet's Demand Index — the ratio of buying pressure to selling
15/// pressure, bounded in `[−100, +100]`.
16///
17/// Each bar's volume (relative to its `period` average) is assigned in full to
18/// the side the weighted close moved towards, and the other side receives that
19/// volume damped exponentially by the size of the move against the market's
20/// typical range. Both pressures are exponentially smoothed and compared. This is
21/// Sibbet's construction as published in the `TradeStation` `DemandIndex`
22/// function:
23///
24/// ```text
25/// WC     = (high + low + 2·close) / 4
26/// ratio  = (WC_t − WC_{t−1}) / min(WC_t, WC_{t−1})
27/// vol    = volume / SMA(volume, period)
28/// K      = 3·WC / SMA(max(high, high_{t−1}) − min(low, low_{t−1}), period)
29/// damped = vol / exp(min(K · |ratio|, 88))
30/// ratio > 0:  BP = vol,     SP = damped
31/// otherwise:  BP = damped,  SP = vol
32/// B, S   = EMA(BP, period), EMA(SP, period)     (seeded with the first value)
33/// DI     = +100 · (1 − S / B)   if B > S
34///          −100 · (1 − B / S)   if B < S
35///          0                    if B = S
36/// ```
37///
38/// Positive readings mean buying pressure dominates, negative selling pressure;
39/// the magnitude says by how much. A bar whose averages or weighted closes are
40/// zero carries no measurable pressure and repeats the previous reading. The
41/// first value lands once the two-bar range average is full, on bar `period + 1`.
42///
43/// # Example
44///
45/// ```
46/// use wickra_core::{Candle, DemandIndex, Indicator};
47///
48/// let mut indicator = DemandIndex::new(10).unwrap();
49/// let mut last = None;
50/// for i in 0..120 {
51///     let base = 100.0 + f64::from(i);
52///     let candle =
53///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 50.0, i64::from(i)).unwrap();
54///     last = indicator.update(candle);
55/// }
56/// assert!(last.is_some());
57/// ```
58#[derive(Debug, Clone)]
59pub struct DemandIndex {
60    period: usize,
61    buy: Ema,
62    sell: Ema,
63    prev: Option<Candle>,
64    volumes: VecDeque<f64>,
65    volume_sum: f64,
66    ranges: VecDeque<f64>,
67    range_sum: f64,
68    last: f64,
69    ready: bool,
70}
71
72impl DemandIndex {
73    /// Construct a new Demand Index with the given averaging period.
74    ///
75    /// # Errors
76    /// Returns [`Error::PeriodZero`] if `period == 0`.
77    pub fn new(period: usize) -> Result<Self> {
78        if period == 0 {
79            return Err(Error::PeriodZero);
80        }
81        if period > crate::error::MAX_PERIOD {
82            return Err(Error::InvalidPeriod {
83                message: crate::error::PERIOD_ABOVE_MAX,
84            });
85        }
86        let alpha = 2.0 / (period as f64 + 1.0);
87        Ok(Self {
88            period,
89            buy: Ema::with_alpha(alpha)?,
90            sell: Ema::with_alpha(alpha)?,
91            prev: None,
92            volumes: VecDeque::with_capacity(period),
93            volume_sum: 0.0,
94            ranges: VecDeque::with_capacity(period),
95            range_sum: 0.0,
96            last: 0.0,
97            ready: false,
98        })
99    }
100
101    /// Configured averaging period.
102    pub const fn period(&self) -> usize {
103        self.period
104    }
105
106    /// Push `x` into a fixed-length window with a running sum.
107    fn push(window: &mut VecDeque<f64>, sum: &mut f64, len: usize, x: f64) {
108        if window.len() == len {
109            *sum -= window.pop_front().expect("window is non-empty");
110        }
111        window.push_back(x);
112        *sum += x;
113    }
114}
115
116/// The weighted close `(high + low + 2·close) / 4`.
117fn weighted_close(c: &Candle) -> f64 {
118    (c.high + c.low + 2.0 * c.close) * 0.25
119}
120
121impl Indicator for DemandIndex {
122    type Input = Candle;
123    type Output = f64;
124
125    #[inline]
126    fn update(&mut self, candle: Candle) -> Option<f64> {
127        let period = self.period;
128        Self::push(
129            &mut self.volumes,
130            &mut self.volume_sum,
131            period,
132            candle.volume,
133        );
134        let prev = self.prev.replace(candle)?;
135        let two_bar_range = candle.high.max(prev.high) - candle.low.min(prev.low);
136        Self::push(&mut self.ranges, &mut self.range_sum, period, two_bar_range);
137        if self.ranges.len() < period {
138            return None;
139        }
140        let n = period as f64;
141        let avg_range = self.range_sum / n;
142        let avg_volume = self.volume_sum / n;
143        let wc = weighted_close(&candle);
144        let wc_prev = weighted_close(&prev);
145        if wc != 0.0 && wc_prev != 0.0 && avg_range != 0.0 && avg_volume != 0.0 {
146            let ratio = (wc - wc_prev) / wc.min(wc_prev);
147            let vol = candle.volume / avg_volume;
148            let exponent = ((3.0 * wc / avg_range) * ratio.abs()).min(MAX_EXPONENT);
149            let damped = vol / exponent.exp();
150            let (bp, sp) = if ratio > 0.0 {
151                (vol, damped)
152            } else {
153                (damped, vol)
154            };
155            let b = self.buy.update(bp).unwrap_or(bp);
156            let s = self.sell.update(sp).unwrap_or(sp);
157            self.last = if b > s {
158                100.0 * (1.0 - s / b)
159            } else if b < s {
160                -100.0 * (1.0 - b / s)
161            } else {
162                0.0
163            };
164        }
165        self.ready = true;
166        Some(self.last)
167    }
168
169    fn reset(&mut self) {
170        self.buy.reset();
171        self.sell.reset();
172        self.prev = None;
173        self.volumes.clear();
174        self.volume_sum = 0.0;
175        self.ranges.clear();
176        self.range_sum = 0.0;
177        self.last = 0.0;
178        self.ready = false;
179    }
180
181    #[inline]
182    fn warmup_period(&self) -> usize {
183        // One seed bar for the previous candle, then `period` two-bar ranges.
184        self.period + 1
185    }
186
187    #[inline]
188    fn is_ready(&self) -> bool {
189        self.ready
190    }
191
192    #[inline]
193    fn name(&self) -> &'static str {
194        "DemandIndex"
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::traits::BatchExt;
202    use approx::assert_relative_eq;
203
204    fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
205        Candle::new(open, high, low, close, volume, ts).unwrap()
206    }
207
208    #[test]
209    fn rejects_zero_period() {
210        assert!(matches!(DemandIndex::new(0), Err(Error::PeriodZero)));
211    }
212
213    #[test]
214    fn accessors_and_metadata() {
215        let di = DemandIndex::new(10).unwrap();
216        assert_eq!(di.period(), 10);
217        assert_eq!(di.name(), "DemandIndex");
218        assert_eq!(di.warmup_period(), 11);
219    }
220
221    #[test]
222    fn constant_series_yields_zero() {
223        // Flat bars have no range -> no measurable pressure -> DI stays at 0.
224        let candles: Vec<Candle> = (0..40)
225            .map(|i| c(10.0, 10.0, 10.0, 10.0, 100.0, i))
226            .collect();
227        let mut di = DemandIndex::new(5).unwrap();
228        for v in di.batch(&candles).into_iter().flatten() {
229            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
230        }
231    }
232
233    #[test]
234    fn rising_series_yields_positive_signal() {
235        // Strictly rising closes on constant volume -> pressure is positive every
236        // bar -> smoothed DI must end up strictly positive.
237        let candles: Vec<Candle> = (0..40)
238            .map(|i| {
239                let f = i as f64;
240                c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i)
241            })
242            .collect();
243        let mut di = DemandIndex::new(5).unwrap();
244        let out = di.batch(&candles);
245        let last = out.iter().filter_map(|x| *x).next_back().unwrap();
246        assert!(last > 0.0, "rising series must yield positive DI");
247    }
248
249    #[test]
250    fn falling_series_yields_negative_signal() {
251        let candles: Vec<Candle> = (0..40)
252            .map(|i| {
253                let f = i as f64;
254                c(200.0 - f, 201.0 - f, 199.0 - f, 199.5 - f, 100.0, i)
255            })
256            .collect();
257        let mut di = DemandIndex::new(5).unwrap();
258        let out = di.batch(&candles);
259        let last = out.iter().filter_map(|x| *x).next_back().unwrap();
260        assert!(last < 0.0, "falling series must yield negative DI");
261    }
262
263    #[test]
264    fn zero_weighted_close_contributes_no_signal() {
265        // The first bars have a zero weighted close -> no pressure is measured.
266        // We then continue with a non-zero series and confirm output behaves.
267        let mut di = DemandIndex::new(3).unwrap();
268        di.update(c(0.0, 0.0, 0.0, 0.0, 100.0, 0));
269        // Bar 2 sees a zero previous weighted close -> no pressure.
270        di.update(c(0.0, 1.0, 0.0, 1.0, 100.0, 1));
271        // Subsequent bars now have non-zero prev_close.
272        di.update(c(1.0, 2.0, 1.0, 2.0, 100.0, 2));
273        // Just check that nothing exploded; the range average fills on bar 4.
274        let v = di.update(c(2.0, 3.0, 2.0, 3.0, 100.0, 3));
275        assert!(v.is_some());
276        assert!(v.unwrap().is_finite());
277    }
278
279    #[test]
280    fn batch_equals_streaming() {
281        let candles: Vec<Candle> = (0..100i64)
282            .map(|i| {
283                let f = i as f64;
284                let mid = 100.0 + (f * 0.2).sin() * 5.0;
285                c(
286                    mid,
287                    mid + 1.5,
288                    mid - 1.5,
289                    mid + 0.3,
290                    80.0 + (i % 5) as f64,
291                    i,
292                )
293            })
294            .collect();
295        let mut a = DemandIndex::new(10).unwrap();
296        let mut b = DemandIndex::new(10).unwrap();
297        assert_eq!(
298            a.batch(&candles),
299            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
300        );
301    }
302
303    #[test]
304    fn reset_clears_state() {
305        let candles: Vec<Candle> = (0..40)
306            .map(|i| {
307                let f = i as f64;
308                c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i)
309            })
310            .collect();
311        let mut di = DemandIndex::new(5).unwrap();
312        di.batch(&candles);
313        assert!(di.is_ready());
314        di.reset();
315        assert!(!di.is_ready());
316        assert_eq!(di.update(candles[0]), None);
317    }
318
319    fn wavy_series() -> Vec<Candle> {
320        (0..60i64)
321            .map(|i| {
322                let f = i as f64;
323                let mid = 100.0 + (f * 0.37).sin() * 4.0 + f * 0.05;
324                c(
325                    mid,
326                    mid + 1.0 + (f * 0.11).cos().abs(),
327                    mid - 1.2,
328                    mid + (f * 0.5).sin() * 0.8,
329                    50.0 + (i % 7) as f64 * 10.0,
330                    i,
331                )
332            })
333            .collect()
334    }
335
336    #[test]
337    fn rejects_period_above_maximum() {
338        assert!(matches!(
339            DemandIndex::new(crate::error::MAX_PERIOD + 1),
340            Err(Error::InvalidPeriod { .. })
341        ));
342    }
343
344    #[test]
345    fn first_value_lands_exactly_at_warmup_minus_one() {
346        let candles = wavy_series();
347        for period in [1usize, 3, 7] {
348            let mut di = DemandIndex::new(period).unwrap();
349            let out = di.batch(&candles);
350            let warm = di.warmup_period();
351            assert_eq!(warm, period + 1);
352            assert!(out[..warm - 1].iter().all(Option::is_none));
353            assert!(out[warm - 1].is_some());
354        }
355    }
356
357    #[test]
358    fn not_ready_before_warmup_completes() {
359        let candles = wavy_series();
360        let mut di = DemandIndex::new(4).unwrap();
361        for candle in &candles[..4] {
362            assert_eq!(di.update(*candle), None);
363            assert!(!di.is_ready());
364        }
365        assert!(di.update(candles[4]).is_some());
366        assert!(di.is_ready());
367    }
368
369    #[test]
370    fn reset_replays_identically_to_fresh_instance() {
371        let candles = wavy_series();
372        let mut di = DemandIndex::new(5).unwrap();
373        let first = di.batch(&candles);
374        di.reset();
375        let second = di.batch(&candles);
376        let fresh = DemandIndex::new(5).unwrap().batch(&candles);
377        assert_eq!(first, second);
378        assert_eq!(second, fresh);
379    }
380
381    #[test]
382    fn batch_nan_into_is_bit_identical_to_streaming() {
383        let candles = wavy_series();
384        let mut batch_di = DemandIndex::new(6).unwrap();
385        let mut out = vec![0.0; candles.len()];
386        batch_di.batch_nan_into(&candles, &mut out);
387        let mut stream_di = DemandIndex::new(6).unwrap();
388        let streamed: Vec<f64> = candles
389            .iter()
390            .map(|x| stream_di.update(*x).unwrap_or(f64::NAN))
391            .collect();
392        assert!(out
393            .iter()
394            .zip(&streamed)
395            .all(|(a, b)| a.to_bits() == b.to_bits()));
396    }
397
398    #[test]
399    fn hand_computed_period_one_up_then_down() {
400        // period 1 -> EMA alpha = 2 / 2 = 1, so B and S equal the raw pressures.
401        let mut di = DemandIndex::new(1).unwrap();
402        // Bar 0: WC = (11 + 9 + 2*10) / 4 = 10. Seed bar -> None.
403        assert_eq!(di.update(c(10.0, 11.0, 9.0, 10.0, 100.0, 0)), None);
404        // Bar 1: WC = (12 + 10 + 2*11.5) / 4 = 11.25.
405        // ratio = (11.25 - 10) / min(11.25, 10) = 0.125
406        // range = max(12, 11) - min(10, 9) = 3, avg_range = 3; avg_volume = 200 -> vol = 1
407        // K = 3 * 11.25 / 3 = 11.25; exponent = 11.25 * 0.125 = 1.40625
408        // ratio > 0 -> BP = 1, SP = exp(-1.40625)
409        // B > S -> DI = 100 * (1 - exp(-1.40625)) = 75.493_946...
410        let up = di.update(c(10.0, 12.0, 10.0, 11.5, 200.0, 1)).unwrap();
411        assert_relative_eq!(up, 100.0 * (1.0 - (-1.40625f64).exp()), epsilon = 1e-12);
412        assert_relative_eq!(up, 75.493_946_075_447_41, epsilon = 1e-9);
413        // Bar 2: WC = (11.5 + 9.5 + 2*10) / 4 = 10.25.
414        // ratio = (10.25 - 11.25) / 10.25 = -1 / 10.25
415        // range = max(11.5, 12) - min(9.5, 10) = 2.5; avg_volume = 50 -> vol = 1
416        // K = 3 * 10.25 / 2.5 = 12.3; exponent = 12.3 / 10.25 = 1.2
417        // ratio <= 0 -> BP = exp(-1.2), SP = 1
418        // B < S -> DI = -100 * (1 - exp(-1.2)) = -69.880_578...
419        let down = di.update(c(11.0, 11.5, 9.5, 10.0, 50.0, 2)).unwrap();
420        assert_relative_eq!(down, -100.0 * (1.0 - (-1.2f64).exp()), epsilon = 1e-9);
421        assert_relative_eq!(down, -69.880_578_808_779_77, epsilon = 1e-9);
422    }
423
424    #[test]
425    fn equal_pressure_returns_exact_zero() {
426        // Identical candles with a non-zero range and volume: the weighted close
427        // is unchanged -> ratio = 0 -> exponent 0 -> damped = vol, so BP = SP and
428        // B == S, which yields exactly 0 (not a repeat of the previous reading).
429        let mut di = DemandIndex::new(1).unwrap();
430        di.update(c(10.0, 11.0, 9.0, 10.0, 100.0, 0));
431        let up = di.update(c(10.0, 12.0, 10.0, 11.5, 200.0, 1)).unwrap();
432        assert!(up > 0.0);
433        let flat = di.update(c(10.0, 12.0, 10.0, 11.5, 200.0, 2)).unwrap();
434        assert_eq!(flat.to_bits(), 0.0f64.to_bits());
435        // Longer period: a constant non-degenerate bar feeds equal pressures into
436        // both EMAs, so they stay equal.
437        let mut di5 = DemandIndex::new(5).unwrap();
438        let candle = c(10.0, 12.0, 8.0, 10.0, 100.0, 0);
439        let out = di5.batch(&[candle; 12]);
440        assert!(out
441            .iter()
442            .flatten()
443            .all(|v| v.to_bits() == 0.0f64.to_bits()));
444    }
445
446    #[test]
447    fn zero_volume_repeats_previous_reading() {
448        // avg_volume == 0 -> no measurable pressure -> the last value is repeated.
449        let mut di = DemandIndex::new(1).unwrap();
450        di.update(c(10.0, 11.0, 9.0, 10.0, 100.0, 0));
451        let up = di.update(c(10.0, 12.0, 10.0, 11.5, 200.0, 1)).unwrap();
452        let held = di.update(c(11.0, 11.5, 9.5, 10.0, 0.0, 2)).unwrap();
453        assert_eq!(held.to_bits(), up.to_bits());
454        // Zero volume from the start: the reading stays at its initial 0.
455        let mut quiet = DemandIndex::new(2).unwrap();
456        let candles: Vec<Candle> = (0..6)
457            .map(|i| {
458                c(
459                    10.0 + i as f64,
460                    11.0 + i as f64,
461                    9.0 + i as f64,
462                    10.5 + i as f64,
463                    0.0,
464                    i,
465                )
466            })
467            .collect();
468        assert!(quiet
469            .batch(&candles)
470            .iter()
471            .flatten()
472            .all(|v| v.to_bits() == 0.0f64.to_bits()));
473    }
474
475    #[test]
476    fn exponent_is_capped_at_max_exponent() {
477        // Bar 0: flat at 1 -> WC = 1. Bar 1: flat at 1000 -> WC = 1000.
478        // ratio = 999 / 1 = 999; range = 1000 - 1 = 999; K = 3 * 1000 / 999
479        // K * |ratio| = 3000 > 88 -> capped at 88 -> SP = 1 / exp(88), a
480        // positive value (uncapped, exp(-3000) would underflow to 0).
481        let mut di = DemandIndex::new(1).unwrap();
482        di.update(c(1.0, 1.0, 1.0, 1.0, 100.0, 0));
483        let v = di
484            .update(c(1000.0, 1000.0, 1000.0, 1000.0, 100.0, 1))
485            .unwrap();
486        let sell = di.sell.value().unwrap();
487        assert_eq!(sell.to_bits(), (1.0 / MAX_EXPONENT.exp()).to_bits());
488        assert!(sell > 0.0);
489        assert_eq!(di.buy.value(), Some(1.0));
490        assert_relative_eq!(v, 100.0, epsilon = 1e-12);
491    }
492}