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    fn update(&mut self, candle: Candle) -> Option<f64> {
77        let cci = self.cci.update(candle)?;
78        if self.window.len() == self.period {
79            self.window.pop_front();
80        }
81        self.window.push_back(cci);
82        if self.window.len() < self.period {
83            return None;
84        }
85        let mut lo = f64::MAX;
86        let mut hi = f64::MIN;
87        for &v in &self.window {
88            if v < lo {
89                lo = v;
90            }
91            if v > hi {
92                hi = v;
93            }
94        }
95        let range = hi - lo;
96        if range == 0.0 {
97            return Some(50.0);
98        }
99        // Ratio first, then scale: `100 * x / x` can round to 100.0000…1.
100        Some(100.0 * ((cci - lo) / range))
101    }
102
103    fn reset(&mut self) {
104        self.cci.reset();
105        self.window.clear();
106    }
107
108    fn warmup_period(&self) -> usize {
109        // CCI seeds at `period`, then `period` CCI values fill the stochastic window.
110        2 * self.period - 1
111    }
112
113    fn is_ready(&self) -> bool {
114        self.window.len() == self.period
115    }
116
117    fn name(&self) -> &'static str {
118        "StochasticCCI"
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::traits::BatchExt;
126    use approx::assert_relative_eq;
127
128    fn candle(high: f64, low: f64, close: f64) -> Candle {
129        Candle::new(close, high, low, close, 1.0, 0).unwrap()
130    }
131
132    #[test]
133    fn rejects_zero_period() {
134        assert!(StochasticCci::new(0).is_err());
135    }
136
137    /// Cover the const accessor `period` and the Indicator-impl `warmup_period`
138    /// + `name`.
139    #[test]
140    fn accessors_and_metadata() {
141        let sc = StochasticCci::new(14).unwrap();
142        assert_eq!(sc.period(), 14);
143        assert_eq!(sc.warmup_period(), 27);
144        assert_eq!(sc.name(), "StochasticCCI");
145    }
146
147    #[test]
148    fn first_emission_matches_warmup_period() {
149        let bars: Vec<Candle> = (0..40)
150            .map(|i| {
151                let base = 100.0 + (f64::from(i) * 0.4).sin() * 8.0;
152                candle(base + 1.0, base - 1.0, base)
153            })
154            .collect();
155        let mut sc = StochasticCci::new(5).unwrap();
156        let out = sc.batch(&bars);
157        let warmup = sc.warmup_period();
158        assert_eq!(warmup, 9);
159        for (i, v) in out.iter().enumerate().take(warmup - 1) {
160            assert!(v.is_none(), "index {i} must be None during warmup");
161        }
162        assert!(out[warmup - 1].is_some());
163    }
164
165    #[test]
166    fn bounded_zero_to_hundred() {
167        let bars: Vec<Candle> = (0..80)
168            .map(|i| {
169                let base = 100.0 + (f64::from(i) * 0.35).sin() * 12.0;
170                candle(base + 2.0, base - 2.0, base)
171            })
172            .collect();
173        let mut sc = StochasticCci::new(9).unwrap();
174        for v in sc.batch(&bars).into_iter().flatten() {
175            assert!((0.0..=100.0).contains(&v), "%K {v} left [0, 100]");
176        }
177    }
178
179    #[test]
180    fn flat_market_is_neutral() {
181        // Constant candles -> CCI pinned at 0 -> zero range -> neutral 50.
182        let mut sc = StochasticCci::new(4).unwrap();
183        let bars = vec![candle(10.0, 10.0, 10.0); 20];
184        let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
185        assert_relative_eq!(last, 50.0, epsilon = 1e-12);
186    }
187
188    #[test]
189    fn highest_cci_in_window_is_hundred() {
190        // When the latest CCI is the window maximum, %K must be 100.
191        // A long rise then makes the final CCI the highest in its window.
192        let mut bars: Vec<Candle> = (0..20)
193            .map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
194            .collect();
195        // Strong final push so the last CCI tops its window.
196        bars.push(candle(100.0, 98.0, 100.0));
197        let mut sc = StochasticCci::new(5).unwrap();
198        let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
199        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
200    }
201
202    #[test]
203    fn reset_clears_state() {
204        let mut sc = StochasticCci::new(5).unwrap();
205        sc.batch(
206            &(0..30)
207                .map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
208                .collect::<Vec<_>>(),
209        );
210        assert!(sc.is_ready());
211        sc.reset();
212        assert!(!sc.is_ready());
213        assert_eq!(sc.update(candle(2.0, 0.0, 1.0)), None);
214    }
215
216    #[test]
217    fn batch_equals_streaming() {
218        let bars: Vec<Candle> = (0..60)
219            .map(|i| {
220                let base = 50.0 + (f64::from(i) * 0.5).sin() * 10.0;
221                candle(base + 1.5, base - 1.5, base)
222            })
223            .collect();
224        let mut a = StochasticCci::new(9).unwrap();
225        let mut b = StochasticCci::new(9).unwrap();
226        assert_eq!(
227            a.batch(&bars),
228            bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
229        );
230    }
231}