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    fn update(&mut self, candle: Candle) -> Option<f64> {
86        // The ADL emits a value from the very first candle, so both EMAs are
87        // fed on every bar and warm up in parallel.
88        let adl = self.adl.update(candle)?;
89        let fast = self.fast.update(adl);
90        let slow = self.slow.update(adl);
91        Some(fast? - slow?)
92    }
93
94    fn reset(&mut self) {
95        self.adl.reset();
96        self.fast.reset();
97        self.slow.reset();
98    }
99
100    fn warmup_period(&self) -> usize {
101        // ADL is ready at candle 1; the slow EMA gates the first emission.
102        self.slow_period
103    }
104
105    fn is_ready(&self) -> bool {
106        self.fast.is_ready() && self.slow.is_ready()
107    }
108
109    fn name(&self) -> &'static str {
110        "ChaikinOscillator"
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::traits::BatchExt;
118    use approx::assert_relative_eq;
119
120    fn cdl(base: f64, volume: f64, ts: i64) -> Candle {
121        Candle::new(base, base + 1.0, base - 1.0, base, volume, ts).unwrap()
122    }
123
124    fn flat(price: f64, ts: i64) -> Candle {
125        Candle::new(price, price, price, price, 100.0, ts).unwrap()
126    }
127
128    #[test]
129    fn matches_independent_adl_and_emas() {
130        // The oscillator must equal feeding a standalone ADL into two
131        // standalone EMAs and differencing them once both are ready.
132        let candles: Vec<Candle> = (0..80)
133            .map(|i| {
134                let mid = 100.0 + (i as f64 * 0.2).sin() * 6.0;
135                Candle::new(
136                    mid,
137                    mid + 1.5,
138                    mid - 1.5,
139                    mid + 0.3,
140                    10.0 + (i % 6) as f64,
141                    i,
142                )
143                .unwrap()
144            })
145            .collect();
146        let mut osc = ChaikinOscillator::classic();
147        let mut adl = Adl::new();
148        let mut fast = Ema::new(3).unwrap();
149        let mut slow = Ema::new(10).unwrap();
150        for (i, candle) in candles.iter().enumerate() {
151            let got = osc.update(*candle);
152            let a = adl.update(*candle).expect("ADL emits from candle 1");
153            let f = fast.update(a);
154            let s = slow.update(a);
155            match (f, s) {
156                (Some(fv), Some(sv)) => {
157                    assert_relative_eq!(
158                        got.expect("oscillator ready once slow EMA is"),
159                        fv - sv,
160                        epsilon = 1e-9
161                    );
162                }
163                _ => assert!(got.is_none(), "must be None until slow EMA ready (i={i})"),
164            }
165        }
166    }
167
168    #[test]
169    fn flat_market_yields_zero() {
170        // A flat candle has zero money-flow volume, so the ADL never moves and
171        // both EMAs of a constant-zero series stay at zero.
172        let candles: Vec<Candle> = (0..60).map(|i| flat(10.0, i)).collect();
173        let mut osc = ChaikinOscillator::classic();
174        for v in osc.batch(&candles).into_iter().flatten() {
175            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
176        }
177    }
178
179    #[test]
180    fn first_emission_matches_warmup_period() {
181        let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
182        let mut osc = ChaikinOscillator::classic();
183        let out = osc.batch(&candles);
184        assert_eq!(osc.warmup_period(), 10);
185        for (i, v) in out.iter().enumerate().take(9) {
186            assert!(v.is_none(), "index {i} must be None during warmup");
187        }
188        assert!(out[9].is_some(), "first value lands at warmup_period - 1");
189    }
190
191    #[test]
192    fn rejects_invalid_params() {
193        assert!(ChaikinOscillator::new(0, 10).is_err());
194        assert!(ChaikinOscillator::new(3, 0).is_err());
195        assert!(ChaikinOscillator::new(10, 3).is_err());
196        assert!(ChaikinOscillator::new(5, 5).is_err());
197    }
198
199    /// Cover the const accessor `periods` (76-78) and the Indicator-impl
200    /// `name` body (109-111). `warmup_period` is exercised elsewhere.
201    #[test]
202    fn accessors_and_metadata() {
203        let osc = ChaikinOscillator::classic();
204        assert_eq!(osc.periods(), (3, 10));
205        assert_eq!(osc.name(), "ChaikinOscillator");
206    }
207
208    #[test]
209    fn reset_clears_state() {
210        let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
211        let mut osc = ChaikinOscillator::classic();
212        osc.batch(&candles);
213        assert!(osc.is_ready());
214        osc.reset();
215        assert!(!osc.is_ready());
216        assert_eq!(osc.update(candles[0]), None);
217    }
218
219    #[test]
220    fn batch_equals_streaming() {
221        let candles: Vec<Candle> = (0..80)
222            .map(|i| {
223                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
224                Candle::new(
225                    mid,
226                    mid + 2.0,
227                    mid - 2.0,
228                    mid + 0.5,
229                    10.0 + (i % 5) as f64,
230                    i,
231                )
232                .unwrap()
233            })
234            .collect();
235        let mut a = ChaikinOscillator::classic();
236        let mut b = ChaikinOscillator::classic();
237        assert_eq!(
238            a.batch(&candles),
239            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
240        );
241    }
242}