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 < 2 {
68            return Err(Error::InvalidPeriod {
69                message: "adaptive CCI needs period >= 2",
70            });
71        }
72        Ok(Self {
73            period,
74            window: VecDeque::with_capacity(period),
75            mean: None,
76            last: None,
77        })
78    }
79
80    /// Configured period.
81    pub const fn period(&self) -> usize {
82        self.period
83    }
84
85    /// Current value if available.
86    pub const fn value(&self) -> Option<f64> {
87        self.last
88    }
89}
90
91impl Indicator for AdaptiveCci {
92    type Input = Candle;
93    type Output = f64;
94
95    fn update(&mut self, candle: Candle) -> Option<f64> {
96        let tp = candle.typical_price();
97        if self.window.len() == self.period {
98            self.window.pop_front();
99        }
100        self.window.push_back(tp);
101        if self.window.len() < self.period {
102            return None;
103        }
104        let n = self.period as f64;
105
106        // Efficiency ratio over the window.
107        let oldest = self.window[0];
108        let direction = (tp - oldest).abs();
109        let mut path = 0.0;
110        for pair in self.window.iter().collect::<Vec<_>>().windows(2) {
111            path += (pair[1] - pair[0]).abs();
112        }
113        let er = if path > 0.0 {
114            (direction / path).clamp(0.0, 1.0)
115        } else {
116            0.0
117        };
118        let fast = 2.0 / 3.0;
119        let slow = 2.0 / 31.0;
120        let sc = (er * (fast - slow) + slow).powi(2);
121
122        let mean = match self.mean {
123            None => self.window.iter().sum::<f64>() / n,
124            Some(prev) => prev + sc * (tp - prev),
125        };
126        self.mean = Some(mean);
127
128        let md = self.window.iter().map(|&v| (v - mean).abs()).sum::<f64>() / n;
129        let cci = if md > 0.0 {
130            (tp - mean) / (0.015 * md)
131        } else {
132            0.0
133        };
134        self.last = Some(cci);
135        Some(cci)
136    }
137
138    fn reset(&mut self) {
139        self.window.clear();
140        self.mean = None;
141        self.last = None;
142    }
143
144    fn warmup_period(&self) -> usize {
145        self.period
146    }
147
148    fn is_ready(&self) -> bool {
149        self.last.is_some()
150    }
151
152    fn name(&self) -> &'static str {
153        "AdaptiveCci"
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::traits::BatchExt;
161    use approx::assert_relative_eq;
162
163    fn candle(tp: f64) -> Candle {
164        // open=high=low=close=tp -> typical price == tp.
165        Candle::new_unchecked(tp, tp, tp, tp, 1_000.0, 0)
166    }
167
168    #[test]
169    fn rejects_invalid_period() {
170        assert!(matches!(AdaptiveCci::new(0), Err(Error::PeriodZero)));
171        assert!(matches!(
172            AdaptiveCci::new(1),
173            Err(Error::InvalidPeriod { .. })
174        ));
175    }
176
177    #[test]
178    fn accessors_and_metadata() {
179        let c = AdaptiveCci::new(20).unwrap();
180        assert_eq!(c.period(), 20);
181        assert_eq!(c.warmup_period(), 20);
182        assert_eq!(c.name(), "AdaptiveCci");
183        assert!(!c.is_ready());
184        assert_eq!(c.value(), None);
185    }
186
187    #[test]
188    fn first_emission_at_warmup_period() {
189        let mut c = AdaptiveCci::new(4).unwrap();
190        let candles: Vec<Candle> = (0..6).map(|i| candle(100.0 + f64::from(i))).collect();
191        let out = c.batch(&candles);
192        for v in out.iter().take(3) {
193            assert!(v.is_none());
194        }
195        assert!(out[3].is_some());
196    }
197
198    #[test]
199    fn uptrend_is_positive() {
200        let mut c = AdaptiveCci::new(10).unwrap();
201        let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + f64::from(i))).collect();
202        let last = c.batch(&candles).into_iter().flatten().last().unwrap();
203        assert!(last > 0.0, "uptrend should give positive CCI, got {last}");
204    }
205
206    #[test]
207    fn downtrend_is_negative() {
208        let mut c = AdaptiveCci::new(10).unwrap();
209        let candles: Vec<Candle> = (0..40).map(|i| candle(200.0 - f64::from(i))).collect();
210        let last = c.batch(&candles).into_iter().flatten().last().unwrap();
211        assert!(last < 0.0, "downtrend should give negative CCI, got {last}");
212    }
213
214    #[test]
215    fn flat_window_is_zero() {
216        let mut c = AdaptiveCci::new(5).unwrap();
217        let candles: Vec<Candle> = (0..10).map(|_| candle(100.0)).collect();
218        for v in c.batch(&candles).into_iter().flatten() {
219            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
220        }
221    }
222
223    #[test]
224    fn reset_clears_state() {
225        let mut c = AdaptiveCci::new(5).unwrap();
226        let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + f64::from(i))).collect();
227        c.batch(&candles);
228        assert!(c.is_ready());
229        c.reset();
230        assert!(!c.is_ready());
231        assert_eq!(c.value(), None);
232        assert_eq!(c.update(candle(100.0)), None);
233    }
234
235    #[test]
236    fn batch_equals_streaming() {
237        let candles: Vec<Candle> = (0..120)
238            .map(|i| candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
239            .collect();
240        let batch = AdaptiveCci::new(20).unwrap().batch(&candles);
241        let mut b = AdaptiveCci::new(20).unwrap();
242        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
243        assert_eq!(batch, streamed);
244    }
245}