Skip to main content

kestrel_chartkit/indicator/
cci.rs

1use std::collections::{HashMap, VecDeque};
2
3use crate::model::Bar;
4
5use super::divergence::SlopeDivergence;
6use super::{Indicator, IndicatorAlert, IndicatorOutput};
7
8/// Commodity Channel Index with a smoothed line, a signal and a context line.
9///
10/// Over the last `cci_len` typical prices `(high + low + close) / 3` the raw CCI is
11/// `(price - SMA) / (0.015 * mean_abs_deviation)`, `0` for a zero deviation. **`value` is the
12/// line**: an exponential average over `avg_len` of the raw CCI, seeded with its first value.
13/// `extra["signal"]` is the same average over `sig_len` of the line; the context line uses
14/// `ctx_len`. Alerts fire on line/signal and zero crosses and on divergences.
15///
16/// First output: with the `cci_len`-th bar. [`Indicator::reset`] clears windows and averages.
17pub struct Cci {
18    cci_len: usize,
19    avg_len: usize,
20    sig_len: usize,
21    lookback_extreme: usize,
22    oversold: f64,
23    overbought: f64,
24    require_extreme_zone: bool,
25    ctx_len: usize,
26
27    source_window: VecDeque<f64>,
28    cci_ema: Option<f64>,
29    signal_ema: Option<f64>,
30    cci_line_window: VecDeque<f64>,
31    prev_cci_line: Option<f64>,
32    prev_signal: Option<f64>,
33
34    ctx_window: VecDeque<f64>,
35    ctx_ema: Option<f64>,
36    divergence: SlopeDivergence,
37
38    alerts: CciAlerts,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Default)]
42pub struct CciAlerts {
43    pub bull_extreme: bool,
44    pub bear_extreme: bool,
45    pub bull_zero_cross: bool,
46    pub bear_zero_cross: bool,
47    pub bull_divergence: bool,
48    pub bear_divergence: bool,
49    pub extreme_strength: f64,
50    pub divergence_strength: f64,
51}
52
53impl Cci {
54    #[allow(clippy::too_many_arguments)]
55    pub fn new(
56        cci_len: usize,
57        avg_len: usize,
58        sig_len: usize,
59        lookback_extreme: usize,
60        oversold: f64,
61        overbought: f64,
62        require_extreme_zone: bool,
63        ctx_len: usize,
64        div_len: usize,
65        div_min: f64,
66    ) -> Self {
67        Self {
68            cci_len,
69            avg_len,
70            sig_len,
71            lookback_extreme,
72            oversold,
73            overbought,
74            require_extreme_zone,
75            ctx_len,
76            source_window: VecDeque::with_capacity(cci_len),
77            cci_ema: None,
78            signal_ema: None,
79            cci_line_window: VecDeque::with_capacity(lookback_extreme),
80            prev_cci_line: None,
81            prev_signal: None,
82            ctx_window: VecDeque::with_capacity(ctx_len),
83            ctx_ema: None,
84            divergence: SlopeDivergence::new(div_len, div_min),
85            alerts: CciAlerts::default(),
86        }
87    }
88
89    pub fn with_defaults() -> Self {
90        Self::new(20, 3, 3, 5, -100.0, 100.0, true, 100, 4, 25.0)
91    }
92
93    fn ema_step(state: &mut Option<f64>, src: f64, len: usize) -> f64 {
94        let alpha = 2.0 / (len as f64 + 1.0);
95        let next = match *state {
96            None => src,
97            Some(prev) => alpha * src + (1.0 - alpha) * prev,
98        };
99        *state = Some(next);
100        next
101    }
102}
103
104impl Indicator for Cci {
105    fn name(&self) -> &str {
106        "cci"
107    }
108
109    fn warmup_period(&self) -> usize {
110        self.cci_len.max(self.ctx_len)
111    }
112
113    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
114        self.alerts = CciAlerts::default();
115
116        let source = bar.typical_price();
117
118        if self.ctx_window.len() == self.ctx_len {
119            self.ctx_window.pop_front();
120        }
121        self.ctx_window.push_back(source);
122        let ctx_line = if self.ctx_window.len() == self.ctx_len {
123            let ctx_sma: f64 = self.ctx_window.iter().sum::<f64>() / self.ctx_len as f64;
124            let ctx_mean_dev: f64 = self
125                .ctx_window
126                .iter()
127                .map(|v| (v - ctx_sma).abs())
128                .sum::<f64>()
129                / self.ctx_len as f64;
130            let ctx_raw = if ctx_mean_dev != 0.0 {
131                (source - ctx_sma) / (0.015 * ctx_mean_dev)
132            } else {
133                0.0
134            };
135            Some(Self::ema_step(&mut self.ctx_ema, ctx_raw, self.avg_len))
136        } else {
137            None
138        };
139
140        if self.source_window.len() == self.cci_len {
141            self.source_window.pop_front();
142        }
143        self.source_window.push_back(source);
144        if self.source_window.len() < self.cci_len {
145            return None;
146        }
147
148        let sma: f64 = self.source_window.iter().sum::<f64>() / self.cci_len as f64;
149        let mean_dev: f64 = self
150            .source_window
151            .iter()
152            .map(|v| (v - sma).abs())
153            .sum::<f64>()
154            / self.cci_len as f64;
155        let raw_cci = if mean_dev != 0.0 {
156            (source - sma) / (0.015 * mean_dev)
157        } else {
158            0.0
159        };
160
161        let cci_line = Self::ema_step(&mut self.cci_ema, raw_cci, self.avg_len);
162        let signal = Self::ema_step(&mut self.signal_ema, cci_line, self.sig_len);
163
164        if self.cci_line_window.len() == self.lookback_extreme {
165            self.cci_line_window.pop_front();
166        }
167        self.cci_line_window.push_back(cci_line);
168        let was_oversold = self.cci_line_window.len() == self.lookback_extreme
169            && self
170                .cci_line_window
171                .iter()
172                .cloned()
173                .fold(f64::INFINITY, f64::min)
174                <= self.oversold;
175        let was_overbought = self.cci_line_window.len() == self.lookback_extreme
176            && self
177                .cci_line_window
178                .iter()
179                .cloned()
180                .fold(f64::NEG_INFINITY, f64::max)
181                >= self.overbought;
182
183        if let (Some(prev_cci), Some(prev_sig)) = (self.prev_cci_line, self.prev_signal) {
184            let bull_cross = prev_cci <= prev_sig && cci_line > signal;
185            let bear_cross = prev_cci >= prev_sig && cci_line < signal;
186            self.alerts.bull_extreme = bull_cross && (!self.require_extreme_zone || was_oversold);
187            self.alerts.bear_extreme = bear_cross && (!self.require_extreme_zone || was_overbought);
188            self.alerts.bull_zero_cross = prev_cci <= 0.0 && cci_line > 0.0;
189            self.alerts.bear_zero_cross = prev_cci >= 0.0 && cci_line < 0.0;
190
191            let lowest = self
192                .cci_line_window
193                .iter()
194                .cloned()
195                .fold(f64::INFINITY, f64::min);
196            let highest = self
197                .cci_line_window
198                .iter()
199                .cloned()
200                .fold(f64::NEG_INFINITY, f64::max);
201            self.alerts.extreme_strength = if self.alerts.bull_extreme {
202                ((self.oversold - lowest) / self.oversold.abs()).clamp(0.0, 1.0)
203            } else if self.alerts.bear_extreme {
204                ((highest - self.overbought) / self.overbought.abs()).clamp(0.0, 1.0)
205            } else {
206                0.0
207            };
208        }
209        self.prev_cci_line = Some(cci_line);
210        self.prev_signal = Some(signal);
211
212        let mut extra = HashMap::new();
213        extra.insert("signal".to_string(), signal);
214        if let Some(ctx_line) = ctx_line {
215            let div = self.divergence.update(cci_line, ctx_line);
216            self.alerts.bull_divergence = div.bull;
217            self.alerts.bear_divergence = div.bear;
218            self.alerts.divergence_strength = if div.bull || div.bear {
219                ((div.fast_dir.abs() - self.divergence.div_min()) / self.divergence.div_min())
220                    .clamp(0.0, 1.0)
221            } else {
222                0.0
223            };
224            extra.insert("ctx".to_string(), ctx_line);
225        }
226
227        Some(IndicatorOutput::with_extra(cci_line, extra))
228    }
229
230    fn reset(&mut self) {
231        self.source_window.clear();
232        self.cci_ema = None;
233        self.signal_ema = None;
234        self.cci_line_window.clear();
235        self.prev_cci_line = None;
236        self.prev_signal = None;
237        self.ctx_window.clear();
238        self.ctx_ema = None;
239        self.divergence.reset();
240        self.alerts = CciAlerts::default();
241    }
242
243    fn alerts(&self) -> Vec<IndicatorAlert> {
244        let a = self.alerts;
245        let mut out = Vec::new();
246        if a.bull_extreme {
247            out.push(IndicatorAlert {
248                kind: "bull_extreme".to_string(),
249                note: "CCI · BULL CROSS OVERSOLD".to_string(),
250                strength: a.extreme_strength,
251            });
252        }
253        if a.bear_extreme {
254            out.push(IndicatorAlert {
255                kind: "bear_extreme".to_string(),
256                note: "CCI · BEAR CROSS OVERBOUGHT".to_string(),
257                strength: a.extreme_strength,
258            });
259        }
260        if a.bull_zero_cross {
261            out.push(IndicatorAlert {
262                kind: "bull_zero_cross".to_string(),
263                note: "CCI · CROSS ABOVE ZERO".to_string(),
264                strength: 1.0,
265            });
266        }
267        if a.bear_zero_cross {
268            out.push(IndicatorAlert {
269                kind: "bear_zero_cross".to_string(),
270                note: "CCI · CROSS BELOW ZERO".to_string(),
271                strength: 1.0,
272            });
273        }
274        if a.bull_divergence {
275            out.push(IndicatorAlert {
276                kind: "bull_divergence".to_string(),
277                note: "CCI · BULL DIVERGENCE".to_string(),
278                strength: a.divergence_strength,
279            });
280        }
281        if a.bear_divergence {
282            out.push(IndicatorAlert {
283                kind: "bear_divergence".to_string(),
284                note: "CCI · BEAR DIVERGENCE".to_string(),
285                strength: a.divergence_strength,
286            });
287        }
288        out
289    }
290}