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