Skip to main content

wickra_core/indicators/
chaikin_volatility.rs

1//! Chaikin Volatility.
2
3use crate::error::Result;
4use crate::indicators::ema::Ema;
5use crate::indicators::roc::Roc;
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Chaikin Volatility — the rate of change of a smoothed high-low spread.
10///
11/// ```text
12/// spread_t   = high_t − low_t
13/// smoothed_t = EMA(spread, ema_period)_t
14/// ChaikinVol = 100 · (smoothed_t − smoothed_{t−roc_period}) / smoothed_{t−roc_period}
15/// ```
16///
17/// Marc Chaikin's volatility measure tracks not the *level* of the trading
18/// range but how fast it is *widening or narrowing*. A rising value means
19/// ranges are expanding (often near a top, as fear spikes); a falling value
20/// means they are contracting (often a quiet, complacent market). The classic
21/// configuration smooths the spread with a `10`-period EMA and takes its
22/// `10`-period rate of change.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{Candle, Indicator, ChaikinVolatility};
28///
29/// let mut indicator = ChaikinVolatility::new(10, 10).unwrap();
30/// let mut last = None;
31/// for i in 0..80 {
32///     let base = 100.0 + f64::from(i);
33///     let candle =
34///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
35///     last = indicator.update(candle);
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct ChaikinVolatility {
41    ema: Ema,
42    roc: Roc,
43    ema_period: usize,
44    roc_period: usize,
45}
46
47impl ChaikinVolatility {
48    /// Construct a Chaikin Volatility with explicit EMA and rate-of-change
49    /// periods.
50    ///
51    /// # Errors
52    /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period
53    /// is zero.
54    pub fn new(ema_period: usize, roc_period: usize) -> Result<Self> {
55        Ok(Self {
56            ema: Ema::new(ema_period)?,
57            roc: Roc::new(roc_period)?,
58            ema_period,
59            roc_period,
60        })
61    }
62
63    /// Marc Chaikin's classic configuration: `EMA(10)` of the spread, `ROC(10)`.
64    pub fn classic() -> Self {
65        Self::new(10, 10).expect("classic Chaikin Volatility params are valid")
66    }
67
68    /// Configured `(ema_period, roc_period)`.
69    pub const fn periods(&self) -> (usize, usize) {
70        (self.ema_period, self.roc_period)
71    }
72}
73
74impl Indicator for ChaikinVolatility {
75    type Input = Candle;
76    type Output = f64;
77
78    #[inline]
79    fn update(&mut self, candle: Candle) -> Option<f64> {
80        let spread = candle.high - candle.low;
81        let smoothed = self.ema.update(spread)?;
82        self.roc.update(smoothed)
83    }
84
85    fn reset(&mut self) {
86        self.ema.reset();
87        self.roc.reset();
88    }
89
90    #[inline]
91    fn warmup_period(&self) -> usize {
92        // The EMA emits at candle `ema_period`; the ROC then needs
93        // `roc_period` more smoothed values to span its lookback.
94        self.ema_period + self.roc_period
95    }
96
97    #[inline]
98    fn is_ready(&self) -> bool {
99        self.roc.is_ready()
100    }
101
102    #[inline]
103    fn name(&self) -> &'static str {
104        "ChaikinVolatility"
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::traits::BatchExt;
112    use approx::assert_relative_eq;
113
114    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
115        Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
116    }
117
118    #[test]
119    fn constant_range_yields_zero() {
120        // A constant high-low spread smooths to a constant EMA, whose rate of
121        // change is zero.
122        let candles: Vec<Candle> = (0..60)
123            .map(|i| {
124                let base = 100.0 + i as f64;
125                c(base + 1.0, base - 1.0, base, i)
126            })
127            .collect();
128        let mut cv = ChaikinVolatility::new(10, 10).unwrap();
129        for v in cv.batch(&candles).into_iter().flatten() {
130            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
131        }
132    }
133
134    #[test]
135    fn widening_range_reads_positive() {
136        // Each bar's range is strictly wider than the last -> expanding
137        // volatility -> positive Chaikin Volatility.
138        let candles: Vec<Candle> = (0..60)
139            .map(|i| {
140                let half = 1.0 + i as f64 * 0.1;
141                c(100.0 + half, 100.0 - half, 100.0, i)
142            })
143            .collect();
144        let mut cv = ChaikinVolatility::new(10, 10).unwrap();
145        for v in cv.batch(&candles).into_iter().flatten() {
146            assert!(v > 0.0, "an expanding range should read positive, got {v}");
147        }
148    }
149
150    #[test]
151    fn matches_independent_ema_and_roc() {
152        let candles: Vec<Candle> = (0..80)
153            .map(|i| {
154                let half = 1.0 + (i as f64 * 0.2).sin().abs() * 2.0;
155                c(100.0 + half, 100.0 - half, 100.0, i)
156            })
157            .collect();
158        let mut cv = ChaikinVolatility::new(10, 10).unwrap();
159        let mut ema = Ema::new(10).unwrap();
160        let mut roc = Roc::new(10).unwrap();
161        for (i, candle) in candles.iter().enumerate() {
162            let got = cv.update(*candle);
163            match ema.update(candle.high - candle.low) {
164                Some(e) => {
165                    let want = roc.update(e);
166                    assert_eq!(got, want, "i={i}");
167                }
168                None => assert!(got.is_none(), "i={i}"),
169            }
170        }
171    }
172
173    #[test]
174    fn first_emission_matches_warmup_period() {
175        let candles: Vec<Candle> = (0..40)
176            .map(|i| {
177                let base = 100.0 + i as f64;
178                c(base + 1.0, base - 1.0, base, i)
179            })
180            .collect();
181        let mut cv = ChaikinVolatility::new(5, 5).unwrap();
182        let out = cv.batch(&candles);
183        assert_eq!(cv.warmup_period(), 10);
184        for (i, v) in out.iter().enumerate().take(9) {
185            assert!(v.is_none(), "index {i} must be None during warmup");
186        }
187        assert!(out[9].is_some(), "first value lands at warmup_period - 1");
188    }
189
190    #[test]
191    fn rejects_zero_period() {
192        assert!(ChaikinVolatility::new(0, 10).is_err());
193        assert!(ChaikinVolatility::new(10, 0).is_err());
194    }
195
196    /// Cover the const accessor `periods` (69-71) and the Indicator-impl
197    /// `name` body (99-101). `warmup_period` is exercised elsewhere.
198    #[test]
199    fn accessors_and_metadata() {
200        let cv = ChaikinVolatility::new(10, 10).unwrap();
201        assert_eq!(cv.periods(), (10, 10));
202        assert_eq!(cv.name(), "ChaikinVolatility");
203    }
204
205    #[test]
206    fn reset_clears_state() {
207        let candles: Vec<Candle> = (0..40)
208            .map(|i| {
209                let base = 100.0 + i as f64;
210                c(base + 1.0, base - 1.0, base, i)
211            })
212            .collect();
213        let mut cv = ChaikinVolatility::classic();
214        cv.batch(&candles);
215        assert!(cv.is_ready());
216        cv.reset();
217        assert!(!cv.is_ready());
218        assert_eq!(cv.update(candles[0]), None);
219    }
220
221    #[test]
222    fn batch_equals_streaming() {
223        let candles: Vec<Candle> = (0..80)
224            .map(|i| {
225                let half = 1.0 + (i as f64 * 0.25).sin().abs() * 3.0;
226                c(100.0 + half, 100.0 - half, 100.0, i)
227            })
228            .collect();
229        let mut a = ChaikinVolatility::classic();
230        let mut b = ChaikinVolatility::classic();
231        assert_eq!(
232            a.batch(&candles),
233            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
234        );
235    }
236}