Skip to main content

wickra_core/indicators/
adaptive_cci.rs

1//! Adaptive CCI — a CCI whose centre line adapts to the efficiency ratio.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Adaptive CCI — Lambert's Commodity Channel Index whose centre line is an
10/// **efficiency-ratio-adaptive** moving average of typical price instead of a
11/// plain SMA, so it leads in trends and stays calm in chop.
12///
13/// ```text
14/// TP   = (high + low + close) / 3
15/// ER   = |TP_t − TP_oldest| / Σ |ΔTP| over the window      (0..1)
16/// sc   = ( ER·(2/3 − 2/31) + 2/31 )²
17/// mean += sc·(TP_t − mean)                                  (adaptive centre, seeded with SMA)
18/// MD   = mean(|TP_i − mean|) over the window               (mean deviation)
19/// CCI  = (TP_t − mean) / (0.015 · MD)
20/// ```
21///
22/// The classic [`Cci`](crate::Cci) centres typical price on its simple moving
23/// average; the lag of that SMA delays the oscillator in fast moves. Replacing it
24/// with a KAMA-style adaptive average — driven by Kaufman's efficiency ratio —
25/// lets the centre line accelerate toward price in a clean trend (so the CCI
26/// reaches its `±100` bands sooner) and slow down in noise (fewer false pokes).
27/// The `0.015` scaling keeps Lambert's convention that roughly 70–80% of readings
28/// fall in `[−100, +100]`.
29///
30/// The output is unbounded around `0`; a flat window (zero mean deviation) returns
31/// `0`. The first value lands after `period` inputs; each `update` is O(`period`).
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Candle, Indicator, AdaptiveCci};
37///
38/// let mut indicator = AdaptiveCci::new(20).unwrap();
39/// let mut last = None;
40/// for i in 0..60 {
41///     let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
42///     let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
43///     last = indicator.update(c);
44/// }
45/// assert!(last.is_some());
46/// ```
47#[derive(Debug, Clone)]
48pub struct AdaptiveCci {
49    period: usize,
50    window: VecDeque<f64>,
51    mean: Option<f64>,
52    last: Option<f64>,
53}
54
55impl AdaptiveCci {
56    /// Construct an adaptive CCI with the given `period`.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`Error::PeriodZero`] if `period == 0` and
61    /// [`Error::InvalidPeriod`] if `period < 2` (the efficiency ratio needs a
62    /// path of at least one step).
63    pub fn new(period: usize) -> Result<Self> {
64        if period == 0 {
65            return Err(Error::PeriodZero);
66        }
67        if period > crate::error::MAX_PERIOD {
68            return Err(Error::InvalidPeriod {
69                message: crate::error::PERIOD_ABOVE_MAX,
70            });
71        }
72        if period < 2 {
73            return Err(Error::InvalidPeriod {
74                message: "adaptive CCI needs period >= 2",
75            });
76        }
77        Ok(Self {
78            period,
79            window: VecDeque::with_capacity(period),
80            mean: None,
81            last: None,
82        })
83    }
84
85    /// Configured period.
86    pub const fn period(&self) -> usize {
87        self.period
88    }
89
90    /// Current value if available.
91    pub const fn value(&self) -> Option<f64> {
92        self.last
93    }
94}
95
96impl Indicator for AdaptiveCci {
97    type Input = Candle;
98    type Output = f64;
99
100    fn update(&mut self, candle: Candle) -> Option<f64> {
101        let tp = candle.typical_price();
102        if self.window.len() == self.period {
103            self.window.pop_front();
104        }
105        self.window.push_back(tp);
106        if self.window.len() < self.period {
107            return None;
108        }
109        let n = self.period as f64;
110
111        // Efficiency ratio over the window.
112        let oldest = self.window[0];
113        let direction = (tp - oldest).abs();
114        let mut path = 0.0;
115        for pair in self.window.iter().collect::<Vec<_>>().windows(2) {
116            path += (pair[1] - pair[0]).abs();
117        }
118        let er = if path > 0.0 {
119            (direction / path).clamp(0.0, 1.0)
120        } else {
121            0.0
122        };
123        let fast = 2.0 / 3.0;
124        let slow = 2.0 / 31.0;
125        let sc = (er * (fast - slow) + slow).powi(2);
126
127        let mean = match self.mean {
128            None => self.window.iter().sum::<f64>() / n,
129            Some(prev) => prev + sc * (tp - prev),
130        };
131        self.mean = Some(mean);
132
133        let md = self.window.iter().map(|&v| (v - mean).abs()).sum::<f64>() / n;
134        let cci = if md > 0.0 {
135            (tp - mean) / (0.015 * md)
136        } else {
137            0.0
138        };
139        self.last = Some(cci);
140        Some(cci)
141    }
142
143    fn reset(&mut self) {
144        self.window.clear();
145        self.mean = None;
146        self.last = None;
147    }
148
149    #[inline]
150    fn warmup_period(&self) -> usize {
151        self.period
152    }
153
154    #[inline]
155    fn is_ready(&self) -> bool {
156        self.last.is_some()
157    }
158
159    #[inline]
160    fn name(&self) -> &'static str {
161        "AdaptiveCci"
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::traits::BatchExt;
169    use approx::assert_relative_eq;
170
171    fn candle(tp: f64) -> Candle {
172        // open=high=low=close=tp -> typical price == tp.
173        Candle::new_unchecked(tp, tp, tp, tp, 1_000.0, 0)
174    }
175
176    #[test]
177    fn rejects_invalid_period() {
178        assert!(matches!(AdaptiveCci::new(0), Err(Error::PeriodZero)));
179        assert!(matches!(
180            AdaptiveCci::new(1),
181            Err(Error::InvalidPeriod { .. })
182        ));
183    }
184
185    #[test]
186    fn accessors_and_metadata() {
187        let c = AdaptiveCci::new(20).unwrap();
188        assert_eq!(c.period(), 20);
189        assert_eq!(c.warmup_period(), 20);
190        assert_eq!(c.name(), "AdaptiveCci");
191        assert!(!c.is_ready());
192        assert_eq!(c.value(), None);
193    }
194
195    #[test]
196    fn first_emission_at_warmup_period() {
197        let mut c = AdaptiveCci::new(4).unwrap();
198        let candles: Vec<Candle> = (0..6).map(|i| candle(100.0 + f64::from(i))).collect();
199        let out = c.batch(&candles);
200        for v in out.iter().take(3) {
201            assert!(v.is_none());
202        }
203        assert!(out[3].is_some());
204    }
205
206    #[test]
207    fn uptrend_is_positive() {
208        let mut c = AdaptiveCci::new(10).unwrap();
209        let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + f64::from(i))).collect();
210        let last = c.batch(&candles).into_iter().flatten().last().unwrap();
211        assert!(last > 0.0, "uptrend should give positive CCI, got {last}");
212    }
213
214    #[test]
215    fn downtrend_is_negative() {
216        let mut c = AdaptiveCci::new(10).unwrap();
217        let candles: Vec<Candle> = (0..40).map(|i| candle(200.0 - f64::from(i))).collect();
218        let last = c.batch(&candles).into_iter().flatten().last().unwrap();
219        assert!(last < 0.0, "downtrend should give negative CCI, got {last}");
220    }
221
222    #[test]
223    fn flat_window_is_zero() {
224        let mut c = AdaptiveCci::new(5).unwrap();
225        let candles: Vec<Candle> = (0..10).map(|_| candle(100.0)).collect();
226        for v in c.batch(&candles).into_iter().flatten() {
227            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
228        }
229    }
230
231    #[test]
232    fn reset_clears_state() {
233        let mut c = AdaptiveCci::new(5).unwrap();
234        let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + f64::from(i))).collect();
235        c.batch(&candles);
236        assert!(c.is_ready());
237        c.reset();
238        assert!(!c.is_ready());
239        assert_eq!(c.value(), None);
240        assert_eq!(c.update(candle(100.0)), None);
241    }
242
243    #[test]
244    fn batch_equals_streaming() {
245        let candles: Vec<Candle> = (0..120)
246            .map(|i| candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
247            .collect();
248        let batch = AdaptiveCci::new(20).unwrap().batch(&candles);
249        let mut b = AdaptiveCci::new(20).unwrap();
250        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
251        assert_eq!(batch, streamed);
252    }
253}