Skip to main content

wickra_core/indicators/
cci.rs

1//! Commodity Channel Index (CCI).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Commodity Channel Index.
11///
12/// `CCI = (TP - SMA(TP)) / (0.015 * mean absolute deviation of TP)`, where
13/// `TP = (high + low + close) / 3`.
14///
15/// # Example
16///
17/// ```
18/// use wickra_core::{Candle, Indicator, Cci};
19///
20/// let mut indicator = Cci::new(5).unwrap();
21/// let mut last = None;
22/// for i in 0..80 {
23///     let base = 100.0 + f64::from(i);
24///     let candle =
25///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
26///     last = indicator.update(candle);
27/// }
28/// assert!(last.is_some());
29/// ```
30#[derive(Debug, Clone)]
31pub struct Cci {
32    period: usize,
33    factor: f64,
34    window: VecDeque<f64>,
35    sum: RollingSum,
36}
37
38impl Cci {
39    /// Construct a new CCI with the canonical 0.015 scaling factor.
40    ///
41    /// # Errors
42    /// Returns [`Error::PeriodZero`] if `period == 0`.
43    pub fn new(period: usize) -> Result<Self> {
44        Self::with_factor(period, 0.015)
45    }
46
47    /// Construct a CCI with a custom scaling factor (the standard literature
48    /// uses 0.015 to put roughly 70 % of values inside ±100).
49    ///
50    /// # Errors
51    /// Returns [`Error::PeriodZero`] if `period == 0` and
52    /// [`Error::NonPositiveMultiplier`] if `factor <= 0`.
53    pub fn with_factor(period: usize, factor: f64) -> Result<Self> {
54        if period == 0 {
55            return Err(Error::PeriodZero);
56        }
57        if period > crate::error::MAX_PERIOD {
58            return Err(Error::InvalidPeriod {
59                message: crate::error::PERIOD_ABOVE_MAX,
60            });
61        }
62        if !factor.is_finite() || factor <= 0.0 {
63            return Err(Error::NonPositiveMultiplier);
64        }
65        Ok(Self {
66            period,
67            factor,
68            window: VecDeque::with_capacity(period),
69            sum: RollingSum::new(),
70        })
71    }
72
73    /// Configured period.
74    pub const fn period(&self) -> usize {
75        self.period
76    }
77}
78
79impl Indicator for Cci {
80    type Input = Candle;
81    type Output = f64;
82
83    #[inline]
84    fn update(&mut self, candle: Candle) -> Option<f64> {
85        let tp = candle.typical_price();
86        if self.window.len() == self.period {
87            let old = self.window.pop_front().expect("non-empty");
88            self.sum.evict(old);
89        }
90        self.window.push_back(tp);
91        self.sum.push(tp);
92        if self.sum.needs_reseed(self.period) {
93            self.sum.reseed(self.window.iter().copied());
94        }
95        if self.window.len() < self.period {
96            return None;
97        }
98        let n = self.period as f64;
99        let mean = self.sum.value() / n;
100        let mad: f64 = self.window.iter().map(|v| (v - mean).abs()).sum::<f64>() / n;
101        if mad == 0.0 {
102            return Some(0.0);
103        }
104        Some((tp - mean) / (self.factor * mad))
105    }
106
107    fn reset(&mut self) {
108        self.window.clear();
109        self.sum.reset();
110    }
111
112    #[inline]
113    fn warmup_period(&self) -> usize {
114        self.period
115    }
116
117    #[inline]
118    fn is_ready(&self) -> bool {
119        self.window.len() == self.period
120    }
121
122    #[inline]
123    fn name(&self) -> &'static str {
124        "CCI"
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::traits::BatchExt;
132    use approx::assert_relative_eq;
133
134    fn c(h: f64, l: f64, cl: f64) -> Candle {
135        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
136    }
137
138    #[test]
139    fn flat_candles_yield_zero() {
140        let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
141        let mut cci = Cci::new(20).unwrap();
142        for v in cci.batch(&candles).into_iter().flatten() {
143            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
144        }
145    }
146
147    #[test]
148    fn rejects_invalid_input() {
149        assert!(Cci::new(0).is_err());
150        assert!(Cci::with_factor(20, 0.0).is_err());
151        assert!(Cci::with_factor(20, -1.0).is_err());
152    }
153
154    /// Cover the const accessor `period` (68-70) and the Indicator-impl
155    /// `warmup_period` (102-104) + `name` (110-112). Existing tests never
156    /// inspect these metadata methods.
157    #[test]
158    fn accessors_and_metadata() {
159        let cci = Cci::new(20).unwrap();
160        assert_eq!(cci.period(), 20);
161        assert_eq!(cci.warmup_period(), 20);
162        assert_eq!(cci.name(), "CCI");
163    }
164
165    #[test]
166    fn batch_equals_streaming() {
167        let candles: Vec<Candle> = (0..60)
168            .map(|i| {
169                let m = 50.0 + (f64::from(i) * 0.2).sin() * 10.0;
170                c(m + 1.0, m - 1.0, m)
171            })
172            .collect();
173        let mut a = Cci::new(20).unwrap();
174        let mut b = Cci::new(20).unwrap();
175        assert_eq!(
176            a.batch(&candles),
177            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
178        );
179    }
180
181    #[test]
182    fn reset_clears_state() {
183        let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
184        let mut cci = Cci::new(20).unwrap();
185        cci.batch(&candles);
186        assert!(cci.is_ready());
187        cci.reset();
188        assert!(!cci.is_ready());
189    }
190}