Skip to main content

wickra_core/indicators/
choppiness_index.rs

1//! Choppiness Index.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Choppiness Index — is the market trending or just chopping sideways?
10///
11/// ```text
12/// CI = 100 · log10( Σ(TR, n) / (highest_high(n) − lowest_low(n)) ) / log10(n)
13/// ```
14///
15/// The ratio compares the *distance price actually travelled* (the summed true
16/// range) with the *net ground it covered* (the high-low span of the window).
17/// A clean trend travels almost exactly its span, so the ratio is near `1` and
18/// `CI` near `0`; a choppy market criss-crosses far more than its span, so the
19/// ratio is large and `CI` climbs toward `100`. The conventional reading is
20/// `CI > 61.8` ranging, `CI < 38.2` trending. A perfectly flat window yields
21/// `100` by convention.
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{Candle, Indicator, ChoppinessIndex};
27///
28/// let mut indicator = ChoppinessIndex::new(14).unwrap();
29/// let mut last = None;
30/// for i in 0..80 {
31///     let base = 100.0 + f64::from(i);
32///     let candle =
33///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
34///     last = indicator.update(candle);
35/// }
36/// assert!(last.is_some());
37/// ```
38#[derive(Debug, Clone)]
39pub struct ChoppinessIndex {
40    period: usize,
41    log_n: f64,
42    prev_close: Option<f64>,
43    tr_window: VecDeque<f64>,
44    tr_sum: f64,
45    highs: VecDeque<f64>,
46    lows: VecDeque<f64>,
47}
48
49impl ChoppinessIndex {
50    /// Construct a new Choppiness Index over `period` bars.
51    ///
52    /// # Errors
53    /// Returns [`Error::InvalidPeriod`] if `period < 2` — the `log10(period)`
54    /// denominator is zero for `period == 1` and undefined for `period == 0`.
55    pub fn new(period: usize) -> Result<Self> {
56        if period < 2 {
57            return Err(Error::InvalidPeriod {
58                message: "choppiness index needs period >= 2",
59            });
60        }
61        Ok(Self {
62            period,
63            log_n: (period as f64).log10(),
64            prev_close: None,
65            tr_window: VecDeque::with_capacity(period),
66            tr_sum: 0.0,
67            highs: VecDeque::with_capacity(period),
68            lows: VecDeque::with_capacity(period),
69        })
70    }
71
72    /// Configured period.
73    pub const fn period(&self) -> usize {
74        self.period
75    }
76}
77
78impl Indicator for ChoppinessIndex {
79    type Input = Candle;
80    type Output = f64;
81
82    fn update(&mut self, candle: Candle) -> Option<f64> {
83        let tr = candle.true_range(self.prev_close);
84        self.prev_close = Some(candle.close);
85
86        if self.tr_window.len() == self.period {
87            self.tr_sum -= self.tr_window.pop_front().expect("non-empty");
88            self.highs.pop_front();
89            self.lows.pop_front();
90        }
91        self.tr_window.push_back(tr);
92        self.tr_sum += tr;
93        self.highs.push_back(candle.high);
94        self.lows.push_back(candle.low);
95
96        if self.tr_window.len() < self.period {
97            return None;
98        }
99        let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
100        let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
101        let span = highest - lowest;
102        if span == 0.0 {
103            // A perfectly flat window: maximal choppiness by convention.
104            return Some(100.0);
105        }
106        Some(100.0 * (self.tr_sum / span).log10() / self.log_n)
107    }
108
109    fn reset(&mut self) {
110        self.prev_close = None;
111        self.tr_window.clear();
112        self.tr_sum = 0.0;
113        self.highs.clear();
114        self.lows.clear();
115    }
116
117    fn warmup_period(&self) -> usize {
118        self.period
119    }
120
121    fn is_ready(&self) -> bool {
122        self.tr_window.len() == self.period
123    }
124
125    fn name(&self) -> &'static str {
126        "ChoppinessIndex"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::traits::BatchExt;
134    use approx::assert_relative_eq;
135
136    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
137        Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
138    }
139
140    #[test]
141    fn reference_value_equal_range_bars() {
142        // Two H=11 L=9 C=10 bars: TR = 2 each, ΣTR = 4; span = 11 - 9 = 2.
143        // CI = 100 · log10(4 / 2) / log10(2) = 100.
144        let mut ci = ChoppinessIndex::new(2).unwrap();
145        let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]);
146        assert!(out[0].is_none());
147        assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
148    }
149
150    #[test]
151    fn flat_window_yields_hundred() {
152        let candles: Vec<Candle> = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect();
153        let mut ci = ChoppinessIndex::new(14).unwrap();
154        for v in ci.batch(&candles).into_iter().flatten() {
155            assert_relative_eq!(v, 100.0, epsilon = 1e-9);
156        }
157    }
158
159    #[test]
160    fn steady_trend_reads_low() {
161        // A clean one-directional march travels close to its span -> low CI.
162        let candles: Vec<Candle> = (0..60)
163            .map(|i| {
164                let base = 100.0 + i as f64;
165                c(base + 1.0, base - 1.0, base, i)
166            })
167            .collect();
168        let mut ci = ChoppinessIndex::new(14).unwrap();
169        for v in ci.batch(&candles).into_iter().flatten() {
170            assert!(v < 50.0, "a steady trend should read below 50, got {v}");
171            assert!(v >= 0.0, "CI must be non-negative, got {v}");
172        }
173    }
174
175    #[test]
176    fn first_emission_matches_warmup_period() {
177        let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
178        let mut ci = ChoppinessIndex::new(8).unwrap();
179        let out = ci.batch(&candles);
180        assert_eq!(ci.warmup_period(), 8);
181        for (i, v) in out.iter().enumerate().take(7) {
182            assert!(v.is_none(), "index {i} must be None during warmup");
183        }
184        assert!(out[7].is_some(), "first value lands at warmup_period - 1");
185    }
186
187    #[test]
188    fn rejects_period_below_two() {
189        assert!(ChoppinessIndex::new(0).is_err());
190        assert!(ChoppinessIndex::new(1).is_err());
191        assert!(ChoppinessIndex::new(2).is_ok());
192    }
193
194    /// Cover the const accessor `period` (73-75) and the Indicator-impl
195    /// `name` body (125-127). `warmup_period` is exercised elsewhere.
196    #[test]
197    fn accessors_and_metadata() {
198        let ci = ChoppinessIndex::new(14).unwrap();
199        assert_eq!(ci.period(), 14);
200        assert_eq!(ci.name(), "ChoppinessIndex");
201    }
202
203    #[test]
204    fn reset_clears_state() {
205        let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
206        let mut ci = ChoppinessIndex::new(14).unwrap();
207        ci.batch(&candles);
208        assert!(ci.is_ready());
209        ci.reset();
210        assert!(!ci.is_ready());
211        assert_eq!(ci.update(candles[0]), None);
212    }
213
214    #[test]
215    fn batch_equals_streaming() {
216        let candles: Vec<Candle> = (0..80)
217            .map(|i| {
218                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
219                c(mid + 1.5, mid - 1.5, mid + 0.5, i)
220            })
221            .collect();
222        let mut a = ChoppinessIndex::new(14).unwrap();
223        let mut b = ChoppinessIndex::new(14).unwrap();
224        assert_eq!(
225            a.batch(&candles),
226            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
227        );
228    }
229}