Skip to main content

egui_charts/studies/builtin/
cci.rs

1//! Commodity Channel Index (CCI) Indicator
2//!
3//! CCI measures the current price level relative to an avg price level over
4//! a given period. It oscillates above and below zero, helping identify cyclical
5//! trends in a commodity or stock.
6//!
7//! # Interpretation
8//! - Readings above +100: Overbought, potential reversal
9//! - Readings below -100: Oversold, potential reversal
10//! - Zero crossings can signal trend changes
11//!
12//! # Calculation
13//! Typical Price (TP) = (High + Low + Close) / 3
14//! SMA = Simple Moving Avg of TP
15//! Mean Deviation = Avg of |TP - SMA|
16//! CCI = (TP - SMA) / (0.015 * Mean Deviation)
17//!
18//! # Example
19//! ```
20//! # let bars: Vec<egui_charts::model::Bar> = (0..60).map(|i| { let p = 100.0 + (i as f64 * 0.3).sin() * 5.0; egui_charts::model::Bar::new(chrono::Utc::now(), p, p + 1.0, p - 1.0, p + 0.5, 1_000.0) }).collect();
21//! use egui_charts::studies::{CCI, Indicator};
22//!
23//! let mut cci = CCI::new(20);
24//! cci.calculate(&bars);
25//! ```
26
27use crate::model::Bar;
28use crate::studies::palette;
29use crate::studies::stepper::update_window;
30use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
31use egui::Color32;
32
33/// Commodity Channel Index indicator
34#[derive(Clone)]
35pub struct CCI {
36    period: usize,
37    values: IndicatorValues,
38    color: Color32,
39    visible: bool,
40}
41
42impl CCI {
43    /// Create a new CCI indicator
44    ///
45    /// # Arguments
46    /// * `period` - Lookback period (typically 20)
47    pub fn new(period: usize) -> Self {
48        Self {
49            period: period.max(1),
50            values: IndicatorValues::new(),
51            color: palette::BLUE,
52            visible: true,
53        }
54    }
55
56    /// Set the indicator color
57    pub fn with_color(mut self, color: Color32) -> Self {
58        self.color = color;
59        self
60    }
61}
62
63/// Construct with the conventional default parameters.
64impl Default for CCI {
65    fn default() -> Self {
66        Self::new(20)
67    }
68}
69
70impl CCI {
71    fn compute(&self, data: &[Bar]) -> IndicatorValues {
72        let mut values = IndicatorValues::new();
73
74        if data.len() < self.period {
75            for _ in 0..data.len() {
76                values.push(IndicatorValue::None);
77            }
78            return values;
79        }
80
81        // Calculate typical prices
82        let tp: Vec<f64> = data.iter().map(Bar::typical_price).collect();
83
84        for i in 0..data.len() {
85            if i + 1 < self.period {
86                values.push(IndicatorValue::None);
87            } else {
88                // Calculate SMA of typical price
89                let start = i + 1 - self.period;
90                let sma: f64 = tp[start..=i].iter().sum::<f64>() / self.period as f64;
91                // Correct the sum's rounding so equal typical prices have no deviation.
92                let sma =
93                    sma + tp[start..=i].iter().map(|p| p - sma).sum::<f64>() / self.period as f64;
94
95                // Calculate mean deviation
96                let mean_dev: f64 =
97                    tp[start..=i].iter().map(|p| (p - sma).abs()).sum::<f64>() / self.period as f64;
98
99                // Calculate CCI
100                if mean_dev == 0.0 {
101                    values.push(IndicatorValue::Single(0.0));
102                } else {
103                    // Lambert's constant 0.015 ensures ~70-80% of values fall between -100 and +100
104                    let cci = (tp[i] - sma) / (0.015 * mean_dev);
105                    values.push(IndicatorValue::Single(cci));
106                }
107            }
108        }
109        values
110    }
111}
112
113impl Indicator for CCI {
114    fn source(&self) -> Option<crate::studies::StudySource> {
115        Some(crate::studies::StudySource::Price(
116            crate::model::PriceSource::HLC3,
117        ))
118    }
119
120    crate::studies::inputs::study_inputs!(self;
121        period: length("Period", 1, usize::MAX)
122    ; {});
123
124    fn name(&self) -> &str {
125        "CCI"
126    }
127
128    fn desc(&self) -> &str {
129        "Commodity Channel Index - Cyclical trend indicator"
130    }
131
132    fn calculate(&mut self, data: &[Bar]) {
133        self.update(data, 0);
134    }
135
136    fn update(&mut self, data: &[Bar], from: usize) {
137        let mut values = std::mem::take(&mut self.values);
138        update_window(
139            &mut values,
140            data,
141            from,
142            |_data, from| from.saturating_sub(self.period - 1),
143            |data| self.compute(data),
144        );
145        self.values = values;
146    }
147
148    fn is_incremental(&self) -> bool {
149        true
150    }
151
152    fn values(&self) -> &IndicatorValues {
153        &self.values
154    }
155
156    fn colors(&self) -> Vec<Color32> {
157        vec![self.color]
158    }
159
160    fn set_colors(&mut self, colors: Vec<Color32>) {
161        if !colors.is_empty() {
162            self.color = colors[0];
163        }
164    }
165
166    fn is_overlay(&self) -> bool {
167        false // CCI is drawn in separate pane
168    }
169
170    fn is_visible(&self) -> bool {
171        self.visible
172    }
173
174    fn set_visible(&mut self, visible: bool) {
175        self.visible = visible;
176    }
177
178    fn clone_box(&self) -> Box<dyn Indicator> {
179        Box::new(self.clone())
180    }
181
182    fn line_names(&self) -> Vec<String> {
183        vec![format!("CCI({})", self.period)]
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use chrono::{Duration, Utc};
191
192    #[test]
193    fn test_cci_flat_prices() {
194        // A zero mean deviation is reported as 0 rather than dividing by zero.
195        let start = Utc::now();
196        let mut bars: Vec<Bar> = (0..30)
197            .map(|i| Bar {
198                time: start + Duration::minutes(i * 5),
199                open: 100.0,
200                high: 100.0,
201                low: 100.0,
202                close: 100.0,
203                volume: 1000.0,
204                colors: None,
205                payload: None,
206            })
207            .collect();
208
209        for price in [100.0, 0.1, 1e-17] {
210            for bar in &mut bars {
211                bar.open = price;
212                bar.high = price;
213                bar.low = price;
214                bar.close = price;
215            }
216            let mut cci = CCI::new(20);
217            cci.calculate(&bars);
218            for value in cci.values().to_rows().iter().skip(19) {
219                assert!(
220                    matches!(value, IndicatorValue::Single(v) if *v == 0.0),
221                    "a window without deviation reads 0, got {value:?} at {price}"
222                );
223            }
224        }
225    }
226}