Skip to main content

wickra_core/indicators/
chaikin_oscillator.rs

1//! Chaikin Oscillator.
2
3use crate::error::{Error, Result};
4use crate::indicators::adl::Adl;
5use crate::indicators::ema::Ema;
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Chaikin Oscillator — the MACD of the Accumulation/Distribution Line.
10///
11/// ```text
12/// ChaikinOsc_t = EMA(ADL, fast)_t − EMA(ADL, slow)_t
13/// ```
14///
15/// It turns the unbounded, ever-drifting [`Adl`](crate::Adl) into a
16/// zero-centred momentum oscillator: positive when short-term accumulation
17/// outpaces the longer trend, negative when distribution leads. Because the
18/// ADL emits from the very first candle, the slow EMA gates the first output —
19/// the warmup period is exactly `slow`. Chaikin's classic configuration is
20/// `fast = 3`, `slow = 10`.
21///
22/// # Example
23///
24/// ```
25/// use wickra_core::{Candle, Indicator, ChaikinOscillator};
26///
27/// let mut indicator = ChaikinOscillator::classic();
28/// let mut last = None;
29/// for i in 0..80 {
30///     let base = 100.0 + f64::from(i);
31///     let candle =
32///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
33///     last = indicator.update(candle);
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct ChaikinOscillator {
39    adl: Adl,
40    fast: Ema,
41    slow: Ema,
42    fast_period: usize,
43    slow_period: usize,
44}
45
46impl ChaikinOscillator {
47    /// Construct a Chaikin Oscillator with explicit fast / slow EMA periods.
48    ///
49    /// # Errors
50    /// Returns [`Error::PeriodZero`] if either period is zero, or
51    /// [`Error::InvalidPeriod`] if `fast >= slow`.
52    pub fn new(fast: usize, slow: usize) -> Result<Self> {
53        if fast == 0 || slow == 0 {
54            return Err(Error::PeriodZero);
55        }
56        if fast >= slow {
57            return Err(Error::InvalidPeriod {
58                message: "Chaikin Oscillator needs fast < slow",
59            });
60        }
61        Ok(Self {
62            adl: Adl::new(),
63            fast: Ema::new(fast)?,
64            slow: Ema::new(slow)?,
65            fast_period: fast,
66            slow_period: slow,
67        })
68    }
69
70    /// Chaikin's classic configuration: `EMA(ADL, 3) − EMA(ADL, 10)`.
71    pub fn classic() -> Self {
72        Self::new(3, 10).expect("classic Chaikin Oscillator params are valid")
73    }
74
75    /// Configured `(fast, slow)` periods.
76    pub const fn periods(&self) -> (usize, usize) {
77        (self.fast_period, self.slow_period)
78    }
79}
80
81impl Indicator for ChaikinOscillator {
82    type Input = Candle;
83    type Output = f64;
84
85    #[inline]
86    fn update(&mut self, candle: Candle) -> Option<f64> {
87        // The ADL emits a value from the very first candle, so both EMAs are
88        // fed on every bar and warm up in parallel.
89        let adl = self.adl.update(candle)?;
90        let fast = self.fast.update(adl);
91        let slow = self.slow.update(adl);
92        Some(fast? - slow?)
93    }
94
95    fn reset(&mut self) {
96        self.adl.reset();
97        self.fast.reset();
98        self.slow.reset();
99    }
100
101    #[inline]
102    fn warmup_period(&self) -> usize {
103        // ADL is ready at candle 1; the slow EMA gates the first emission.
104        self.slow_period
105    }
106
107    #[inline]
108    fn is_ready(&self) -> bool {
109        self.fast.is_ready() && self.slow.is_ready()
110    }
111
112    #[inline]
113    fn name(&self) -> &'static str {
114        "ChaikinOscillator"
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::traits::BatchExt;
122    use approx::assert_relative_eq;
123
124    fn cdl(base: f64, volume: f64, ts: i64) -> Candle {
125        Candle::new(base, base + 1.0, base - 1.0, base, volume, ts).unwrap()
126    }
127
128    fn flat(price: f64, ts: i64) -> Candle {
129        Candle::new(price, price, price, price, 100.0, ts).unwrap()
130    }
131
132    #[test]
133    fn matches_independent_adl_and_emas() {
134        // The oscillator must equal feeding a standalone ADL into two
135        // standalone EMAs and differencing them once both are ready.
136        let candles: Vec<Candle> = (0..80)
137            .map(|i| {
138                let mid = 100.0 + (i as f64 * 0.2).sin() * 6.0;
139                Candle::new(
140                    mid,
141                    mid + 1.5,
142                    mid - 1.5,
143                    mid + 0.3,
144                    10.0 + (i % 6) as f64,
145                    i,
146                )
147                .unwrap()
148            })
149            .collect();
150        let mut osc = ChaikinOscillator::classic();
151        let mut adl = Adl::new();
152        let mut fast = Ema::new(3).unwrap();
153        let mut slow = Ema::new(10).unwrap();
154        for (i, candle) in candles.iter().enumerate() {
155            let got = osc.update(*candle);
156            let a = adl.update(*candle).expect("ADL emits from candle 1");
157            let f = fast.update(a);
158            let s = slow.update(a);
159            match (f, s) {
160                (Some(fv), Some(sv)) => {
161                    assert_relative_eq!(
162                        got.expect("oscillator ready once slow EMA is"),
163                        fv - sv,
164                        epsilon = 1e-9
165                    );
166                }
167                _ => assert!(got.is_none(), "must be None until slow EMA ready (i={i})"),
168            }
169        }
170    }
171
172    #[test]
173    fn flat_market_yields_zero() {
174        // A flat candle has zero money-flow volume, so the ADL never moves and
175        // both EMAs of a constant-zero series stay at zero.
176        let candles: Vec<Candle> = (0..60).map(|i| flat(10.0, i)).collect();
177        let mut osc = ChaikinOscillator::classic();
178        for v in osc.batch(&candles).into_iter().flatten() {
179            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
180        }
181    }
182
183    #[test]
184    fn first_emission_matches_warmup_period() {
185        let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
186        let mut osc = ChaikinOscillator::classic();
187        let out = osc.batch(&candles);
188        assert_eq!(osc.warmup_period(), 10);
189        for (i, v) in out.iter().enumerate().take(9) {
190            assert!(v.is_none(), "index {i} must be None during warmup");
191        }
192        assert!(out[9].is_some(), "first value lands at warmup_period - 1");
193    }
194
195    #[test]
196    fn rejects_invalid_params() {
197        assert!(ChaikinOscillator::new(0, 10).is_err());
198        assert!(ChaikinOscillator::new(3, 0).is_err());
199        assert!(ChaikinOscillator::new(10, 3).is_err());
200        assert!(ChaikinOscillator::new(5, 5).is_err());
201    }
202
203    /// Cover the const accessor `periods` (76-78) and the Indicator-impl
204    /// `name` body (109-111). `warmup_period` is exercised elsewhere.
205    #[test]
206    fn accessors_and_metadata() {
207        let osc = ChaikinOscillator::classic();
208        assert_eq!(osc.periods(), (3, 10));
209        assert_eq!(osc.name(), "ChaikinOscillator");
210    }
211
212    #[test]
213    fn reset_clears_state() {
214        let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
215        let mut osc = ChaikinOscillator::classic();
216        osc.batch(&candles);
217        assert!(osc.is_ready());
218        osc.reset();
219        assert!(!osc.is_ready());
220        assert_eq!(osc.update(candles[0]), None);
221    }
222
223    #[test]
224    fn batch_equals_streaming() {
225        let candles: Vec<Candle> = (0..80)
226            .map(|i| {
227                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
228                Candle::new(
229                    mid,
230                    mid + 2.0,
231                    mid - 2.0,
232                    mid + 0.5,
233                    10.0 + (i % 5) as f64,
234                    i,
235                )
236                .unwrap()
237            })
238            .collect();
239        let mut a = ChaikinOscillator::classic();
240        let mut b = ChaikinOscillator::classic();
241        assert_eq!(
242            a.batch(&candles),
243            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
244        );
245    }
246}