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    fn update(&mut self, candle: Candle) -> Option<f64> {
79        let spread = candle.high - candle.low;
80        let smoothed = self.ema.update(spread)?;
81        self.roc.update(smoothed)
82    }
83
84    fn reset(&mut self) {
85        self.ema.reset();
86        self.roc.reset();
87    }
88
89    fn warmup_period(&self) -> usize {
90        // The EMA emits at candle `ema_period`; the ROC then needs
91        // `roc_period` more smoothed values to span its lookback.
92        self.ema_period + self.roc_period
93    }
94
95    fn is_ready(&self) -> bool {
96        self.roc.is_ready()
97    }
98
99    fn name(&self) -> &'static str {
100        "ChaikinVolatility"
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::traits::BatchExt;
108    use approx::assert_relative_eq;
109
110    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
111        Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
112    }
113
114    #[test]
115    fn constant_range_yields_zero() {
116        // A constant high-low spread smooths to a constant EMA, whose rate of
117        // change is zero.
118        let candles: Vec<Candle> = (0..60)
119            .map(|i| {
120                let base = 100.0 + i as f64;
121                c(base + 1.0, base - 1.0, base, i)
122            })
123            .collect();
124        let mut cv = ChaikinVolatility::new(10, 10).unwrap();
125        for v in cv.batch(&candles).into_iter().flatten() {
126            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
127        }
128    }
129
130    #[test]
131    fn widening_range_reads_positive() {
132        // Each bar's range is strictly wider than the last -> expanding
133        // volatility -> positive Chaikin Volatility.
134        let candles: Vec<Candle> = (0..60)
135            .map(|i| {
136                let half = 1.0 + i as f64 * 0.1;
137                c(100.0 + half, 100.0 - half, 100.0, i)
138            })
139            .collect();
140        let mut cv = ChaikinVolatility::new(10, 10).unwrap();
141        for v in cv.batch(&candles).into_iter().flatten() {
142            assert!(v > 0.0, "an expanding range should read positive, got {v}");
143        }
144    }
145
146    #[test]
147    fn matches_independent_ema_and_roc() {
148        let candles: Vec<Candle> = (0..80)
149            .map(|i| {
150                let half = 1.0 + (i as f64 * 0.2).sin().abs() * 2.0;
151                c(100.0 + half, 100.0 - half, 100.0, i)
152            })
153            .collect();
154        let mut cv = ChaikinVolatility::new(10, 10).unwrap();
155        let mut ema = Ema::new(10).unwrap();
156        let mut roc = Roc::new(10).unwrap();
157        for (i, candle) in candles.iter().enumerate() {
158            let got = cv.update(*candle);
159            match ema.update(candle.high - candle.low) {
160                Some(e) => {
161                    let want = roc.update(e);
162                    assert_eq!(got, want, "i={i}");
163                }
164                None => assert!(got.is_none(), "i={i}"),
165            }
166        }
167    }
168
169    #[test]
170    fn first_emission_matches_warmup_period() {
171        let candles: Vec<Candle> = (0..40)
172            .map(|i| {
173                let base = 100.0 + i as f64;
174                c(base + 1.0, base - 1.0, base, i)
175            })
176            .collect();
177        let mut cv = ChaikinVolatility::new(5, 5).unwrap();
178        let out = cv.batch(&candles);
179        assert_eq!(cv.warmup_period(), 10);
180        for (i, v) in out.iter().enumerate().take(9) {
181            assert!(v.is_none(), "index {i} must be None during warmup");
182        }
183        assert!(out[9].is_some(), "first value lands at warmup_period - 1");
184    }
185
186    #[test]
187    fn rejects_zero_period() {
188        assert!(ChaikinVolatility::new(0, 10).is_err());
189        assert!(ChaikinVolatility::new(10, 0).is_err());
190    }
191
192    /// Cover the const accessor `periods` (69-71) and the Indicator-impl
193    /// `name` body (99-101). `warmup_period` is exercised elsewhere.
194    #[test]
195    fn accessors_and_metadata() {
196        let cv = ChaikinVolatility::new(10, 10).unwrap();
197        assert_eq!(cv.periods(), (10, 10));
198        assert_eq!(cv.name(), "ChaikinVolatility");
199    }
200
201    #[test]
202    fn reset_clears_state() {
203        let candles: Vec<Candle> = (0..40)
204            .map(|i| {
205                let base = 100.0 + i as f64;
206                c(base + 1.0, base - 1.0, base, i)
207            })
208            .collect();
209        let mut cv = ChaikinVolatility::classic();
210        cv.batch(&candles);
211        assert!(cv.is_ready());
212        cv.reset();
213        assert!(!cv.is_ready());
214        assert_eq!(cv.update(candles[0]), None);
215    }
216
217    #[test]
218    fn batch_equals_streaming() {
219        let candles: Vec<Candle> = (0..80)
220            .map(|i| {
221                let half = 1.0 + (i as f64 * 0.25).sin().abs() * 3.0;
222                c(100.0 + half, 100.0 - half, 100.0, i)
223            })
224            .collect();
225        let mut a = ChaikinVolatility::classic();
226        let mut b = ChaikinVolatility::classic();
227        assert_eq!(
228            a.batch(&candles),
229            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
230        );
231    }
232}