Skip to main content

wickra_core/indicators/
stochastic_cci.rs

1//! Stochastic CCI — a stochastic oscillator applied to the CCI.
2
3use std::collections::VecDeque;
4
5use crate::error::Result;
6use crate::indicators::cci::Cci;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Stochastic CCI — the stochastic oscillator computed over the
11/// [`Cci`](crate::Cci) instead of price.
12///
13/// The CCI is unbounded and spends most of its time inside `±100`, which makes
14/// fixed overbought/oversold lines awkward. Running a stochastic over the CCI
15/// re-scales it to `[0, 100]` relative to its own recent range, turning it into
16/// a bounded, self-normalising momentum oscillator:
17///
18/// ```text
19/// cci = CCI(typical price, period)
20/// %K  = 100 * (cci - lowest(cci, period)) / (highest(cci, period) - lowest(cci, period))
21/// ```
22///
23/// The same `period` is used for the CCI and the stochastic lookback. When the
24/// CCI range over the window is zero (a flat market, where the CCI is pinned at
25/// `0`) the oscillator returns the neutral `50`. The first value lands after
26/// `2·period − 1` bars: `period` to seed the CCI, then `period` CCI values to
27/// fill the stochastic window.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Candle, StochasticCci, Indicator};
33///
34/// let mut sc = StochasticCci::new(14).unwrap();
35/// let mut last = None;
36/// for i in 0..60 {
37///     let base = 100.0 + (f64::from(i) * 0.3).sin() * 10.0;
38///     let c = Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
39///     last = sc.update(c);
40/// }
41/// assert!(last.is_some());
42/// ```
43#[derive(Debug, Clone)]
44pub struct StochasticCci {
45    period: usize,
46    cci: Cci,
47    /// The last `period` CCI values.
48    window: VecDeque<f64>,
49}
50
51impl StochasticCci {
52    /// Construct a Stochastic CCI with the given period (shared by the CCI and
53    /// the stochastic lookback).
54    ///
55    /// # Errors
56    ///
57    /// Returns [`crate::Error::PeriodZero`] if `period == 0`.
58    pub fn new(period: usize) -> Result<Self> {
59        Ok(Self {
60            period,
61            cci: Cci::new(period)?,
62            window: VecDeque::with_capacity(period),
63        })
64    }
65
66    /// Configured period.
67    pub const fn period(&self) -> usize {
68        self.period
69    }
70}
71
72impl Indicator for StochasticCci {
73    type Input = Candle;
74    type Output = f64;
75
76    #[inline]
77    fn update(&mut self, candle: Candle) -> Option<f64> {
78        let cci = self.cci.update(candle)?;
79        if self.window.len() == self.period {
80            self.window.pop_front();
81        }
82        self.window.push_back(cci);
83        if self.window.len() < self.period {
84            return None;
85        }
86        let mut lo = f64::MAX;
87        let mut hi = f64::MIN;
88        for &v in &self.window {
89            if v < lo {
90                lo = v;
91            }
92            if v > hi {
93                hi = v;
94            }
95        }
96        let range = hi - lo;
97        if range == 0.0 {
98            return Some(50.0);
99        }
100        // Ratio first, then scale: `100 * x / x` can round to 100.0000…1.
101        Some(100.0 * ((cci - lo) / range))
102    }
103
104    fn reset(&mut self) {
105        self.cci.reset();
106        self.window.clear();
107    }
108
109    #[inline]
110    fn warmup_period(&self) -> usize {
111        // CCI seeds at `period`, then `period` CCI values fill the stochastic window.
112        2 * self.period - 1
113    }
114
115    #[inline]
116    fn is_ready(&self) -> bool {
117        self.window.len() == self.period
118    }
119
120    #[inline]
121    fn name(&self) -> &'static str {
122        "StochasticCCI"
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::traits::BatchExt;
130    use approx::assert_relative_eq;
131
132    fn candle(high: f64, low: f64, close: f64) -> Candle {
133        Candle::new(close, high, low, close, 1.0, 0).unwrap()
134    }
135
136    #[test]
137    fn rejects_zero_period() {
138        assert!(StochasticCci::new(0).is_err());
139    }
140
141    /// Cover the const accessor `period` and the Indicator-impl `warmup_period`
142    /// + `name`.
143    #[test]
144    fn accessors_and_metadata() {
145        let sc = StochasticCci::new(14).unwrap();
146        assert_eq!(sc.period(), 14);
147        assert_eq!(sc.warmup_period(), 27);
148        assert_eq!(sc.name(), "StochasticCCI");
149    }
150
151    #[test]
152    fn first_emission_matches_warmup_period() {
153        let bars: Vec<Candle> = (0..40)
154            .map(|i| {
155                let base = 100.0 + (f64::from(i) * 0.4).sin() * 8.0;
156                candle(base + 1.0, base - 1.0, base)
157            })
158            .collect();
159        let mut sc = StochasticCci::new(5).unwrap();
160        let out = sc.batch(&bars);
161        let warmup = sc.warmup_period();
162        assert_eq!(warmup, 9);
163        for (i, v) in out.iter().enumerate().take(warmup - 1) {
164            assert!(v.is_none(), "index {i} must be None during warmup");
165        }
166        assert!(out[warmup - 1].is_some());
167    }
168
169    #[test]
170    fn bounded_zero_to_hundred() {
171        let bars: Vec<Candle> = (0..80)
172            .map(|i| {
173                let base = 100.0 + (f64::from(i) * 0.35).sin() * 12.0;
174                candle(base + 2.0, base - 2.0, base)
175            })
176            .collect();
177        let mut sc = StochasticCci::new(9).unwrap();
178        for v in sc.batch(&bars).into_iter().flatten() {
179            assert!((0.0..=100.0).contains(&v), "%K {v} left [0, 100]");
180        }
181    }
182
183    #[test]
184    fn flat_market_is_neutral() {
185        // Constant candles -> CCI pinned at 0 -> zero range -> neutral 50.
186        let mut sc = StochasticCci::new(4).unwrap();
187        let bars = vec![candle(10.0, 10.0, 10.0); 20];
188        let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
189        assert_relative_eq!(last, 50.0, epsilon = 1e-12);
190    }
191
192    #[test]
193    fn highest_cci_in_window_is_hundred() {
194        // When the latest CCI is the window maximum, %K must be 100.
195        // A long rise then makes the final CCI the highest in its window.
196        let mut bars: Vec<Candle> = (0..20)
197            .map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
198            .collect();
199        // Strong final push so the last CCI tops its window.
200        bars.push(candle(100.0, 98.0, 100.0));
201        let mut sc = StochasticCci::new(5).unwrap();
202        let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
203        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
204    }
205
206    #[test]
207    fn reset_clears_state() {
208        let mut sc = StochasticCci::new(5).unwrap();
209        sc.batch(
210            &(0..30)
211                .map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
212                .collect::<Vec<_>>(),
213        );
214        assert!(sc.is_ready());
215        sc.reset();
216        assert!(!sc.is_ready());
217        assert_eq!(sc.update(candle(2.0, 0.0, 1.0)), None);
218    }
219
220    #[test]
221    fn batch_equals_streaming() {
222        let bars: Vec<Candle> = (0..60)
223            .map(|i| {
224                let base = 50.0 + (f64::from(i) * 0.5).sin() * 10.0;
225                candle(base + 1.5, base - 1.5, base)
226            })
227            .collect();
228        let mut a = StochasticCci::new(9).unwrap();
229        let mut b = StochasticCci::new(9).unwrap();
230        assert_eq!(
231            a.batch(&bars),
232            bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
233        );
234    }
235}