Skip to main content

wickra_core/indicators/
disparity_index.rs

1//! Disparity Index.
2
3use crate::error::Result;
4use crate::indicators::sma::Sma;
5use crate::traits::Indicator;
6
7/// Disparity Index — the percentage gap between price and its moving average.
8///
9/// ```text
10/// Disparity = 100 * (price - SMA(price, period)) / SMA(price, period)
11/// ```
12///
13/// Originating in Japanese technical analysis (*kairi*), the disparity index
14/// expresses how far price has stretched from its `period`-bar simple moving
15/// average, as a percentage of that average. Positive readings mean price is
16/// above the mean (potentially overbought / strong), negative readings mean it
17/// is below (potentially oversold / weak); the magnitude measures how
18/// over-extended the move is.
19///
20/// The first output lands once the inner SMA is ready (input `period`). If the
21/// moving average is exactly zero the gap percentage is undefined and the index
22/// returns `0.0`.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{DisparityIndex, Indicator};
28///
29/// let mut indicator = DisparityIndex::new(14).unwrap();
30/// let mut last = None;
31/// for i in 0..80 {
32///     last = indicator.update(100.0 + f64::from(i));
33/// }
34/// assert!(last.is_some());
35/// ```
36#[derive(Debug, Clone)]
37pub struct DisparityIndex {
38    period: usize,
39    sma: Sma,
40}
41
42impl DisparityIndex {
43    /// Construct a disparity index over `period` inputs.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`crate::Error::PeriodZero`] if `period == 0`.
48    pub fn new(period: usize) -> Result<Self> {
49        Ok(Self {
50            period,
51            sma: Sma::new(period)?,
52        })
53    }
54
55    /// Configured period.
56    pub const fn period(&self) -> usize {
57        self.period
58    }
59}
60
61impl Indicator for DisparityIndex {
62    type Input = f64;
63    type Output = f64;
64
65    fn update(&mut self, input: f64) -> Option<f64> {
66        let mean = self.sma.update(input)?;
67        if mean == 0.0 {
68            return Some(0.0);
69        }
70        Some(100.0 * (input - mean) / mean)
71    }
72
73    fn reset(&mut self) {
74        self.sma.reset();
75    }
76
77    fn warmup_period(&self) -> usize {
78        self.period
79    }
80
81    fn is_ready(&self) -> bool {
82        self.sma.is_ready()
83    }
84
85    fn name(&self) -> &'static str {
86        "DisparityIndex"
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::traits::BatchExt;
94    use approx::assert_relative_eq;
95
96    #[test]
97    fn rejects_zero_period() {
98        assert!(DisparityIndex::new(0).is_err());
99    }
100
101    /// Cover the const accessor `period` and the Indicator-impl `warmup_period`
102    /// + `name`.
103    #[test]
104    fn accessors_and_metadata() {
105        let di = DisparityIndex::new(14).unwrap();
106        assert_eq!(di.period(), 14);
107        assert_eq!(di.warmup_period(), 14);
108        assert_eq!(di.name(), "DisparityIndex");
109    }
110
111    #[test]
112    fn warmup_then_known_value() {
113        // SMA(3) of [2, 4, 6] = 4; price 6 -> 100 * (6 - 4) / 4 = 50.
114        let mut di = DisparityIndex::new(3).unwrap();
115        assert_eq!(di.update(2.0), None);
116        assert_eq!(di.update(4.0), None);
117        assert_relative_eq!(di.update(6.0).unwrap(), 50.0, epsilon = 1e-12);
118    }
119
120    #[test]
121    fn constant_series_is_zero() {
122        // Price equals its own mean -> zero disparity.
123        let mut di = DisparityIndex::new(5).unwrap();
124        for v in di.batch(&[42.0; 20]).into_iter().flatten() {
125            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
126        }
127    }
128
129    #[test]
130    fn negative_when_below_mean() {
131        // SMA(3) of [10, 8, 6] = 8; price 6 -> 100 * (6 - 8) / 8 = -25.
132        let mut di = DisparityIndex::new(3).unwrap();
133        let v = di.batch(&[10.0, 8.0, 6.0]);
134        assert_relative_eq!(v[2].unwrap(), -25.0, epsilon = 1e-12);
135    }
136
137    #[test]
138    fn zero_mean_returns_zero() {
139        // A window summing to zero (mean 0) makes the percentage undefined; the
140        // index returns 0.0 rather than a non-finite value.
141        let mut di = DisparityIndex::new(2).unwrap();
142        assert_eq!(di.update(-3.0), None);
143        // SMA(2) of [-3, 3] = 0 -> guarded to 0.0.
144        assert_relative_eq!(di.update(3.0).unwrap(), 0.0, epsilon = 1e-12);
145    }
146
147    #[test]
148    fn reset_clears_state() {
149        let mut di = DisparityIndex::new(5).unwrap();
150        di.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
151        assert!(di.is_ready());
152        di.reset();
153        assert!(!di.is_ready());
154        assert_eq!(di.update(1.0), None);
155    }
156
157    #[test]
158    fn batch_equals_streaming() {
159        let prices: Vec<f64> = (1..=30)
160            .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
161            .collect();
162        let mut a = DisparityIndex::new(7).unwrap();
163        let mut b = DisparityIndex::new(7).unwrap();
164        assert_eq!(
165            a.batch(&prices),
166            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
167        );
168    }
169}