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        if period > crate::error::MAX_PERIOD {
62            return Err(Error::InvalidPeriod {
63                message: crate::error::PERIOD_ABOVE_MAX,
64            });
65        }
66        Ok(Self {
67            period,
68            log_n: (period as f64).log10(),
69            prev_close: None,
70            tr_window: VecDeque::with_capacity(period),
71            tr_sum: 0.0,
72            highs: VecDeque::with_capacity(period),
73            lows: VecDeque::with_capacity(period),
74        })
75    }
76
77    /// Configured period.
78    pub const fn period(&self) -> usize {
79        self.period
80    }
81}
82
83impl Indicator for ChoppinessIndex {
84    type Input = Candle;
85    type Output = f64;
86
87    #[inline]
88    fn update(&mut self, candle: Candle) -> Option<f64> {
89        let tr = candle.true_range(self.prev_close);
90        self.prev_close = Some(candle.close);
91
92        if self.tr_window.len() == self.period {
93            self.tr_sum -= self.tr_window.pop_front().expect("non-empty");
94            self.highs.pop_front();
95            self.lows.pop_front();
96        }
97        self.tr_window.push_back(tr);
98        self.tr_sum += tr;
99        self.highs.push_back(candle.high);
100        self.lows.push_back(candle.low);
101
102        if self.tr_window.len() < self.period {
103            return None;
104        }
105        let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
106        let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
107        let span = highest - lowest;
108        if span == 0.0 {
109            // A perfectly flat window: maximal choppiness by convention.
110            return Some(100.0);
111        }
112        Some(100.0 * (self.tr_sum / span).log10() / self.log_n)
113    }
114
115    fn reset(&mut self) {
116        self.prev_close = None;
117        self.tr_window.clear();
118        self.tr_sum = 0.0;
119        self.highs.clear();
120        self.lows.clear();
121    }
122
123    #[inline]
124    fn warmup_period(&self) -> usize {
125        self.period
126    }
127
128    #[inline]
129    fn is_ready(&self) -> bool {
130        self.tr_window.len() == self.period
131    }
132
133    #[inline]
134    fn name(&self) -> &'static str {
135        "ChoppinessIndex"
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::traits::BatchExt;
143    use approx::assert_relative_eq;
144
145    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
146        Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
147    }
148
149    #[test]
150    fn reference_value_equal_range_bars() {
151        // Two H=11 L=9 C=10 bars: TR = 2 each, ΣTR = 4; span = 11 - 9 = 2.
152        // CI = 100 · log10(4 / 2) / log10(2) = 100.
153        let mut ci = ChoppinessIndex::new(2).unwrap();
154        let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]);
155        assert!(out[0].is_none());
156        assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
157    }
158
159    #[test]
160    fn flat_window_yields_hundred() {
161        let candles: Vec<Candle> = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect();
162        let mut ci = ChoppinessIndex::new(14).unwrap();
163        for v in ci.batch(&candles).into_iter().flatten() {
164            assert_relative_eq!(v, 100.0, epsilon = 1e-9);
165        }
166    }
167
168    #[test]
169    fn steady_trend_reads_low() {
170        // A clean one-directional march travels close to its span -> low CI.
171        let candles: Vec<Candle> = (0..60)
172            .map(|i| {
173                let base = 100.0 + i as f64;
174                c(base + 1.0, base - 1.0, base, i)
175            })
176            .collect();
177        let mut ci = ChoppinessIndex::new(14).unwrap();
178        for v in ci.batch(&candles).into_iter().flatten() {
179            assert!(v < 50.0, "a steady trend should read below 50, got {v}");
180            assert!(v >= 0.0, "CI must be non-negative, got {v}");
181        }
182    }
183
184    #[test]
185    fn first_emission_matches_warmup_period() {
186        let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
187        let mut ci = ChoppinessIndex::new(8).unwrap();
188        let out = ci.batch(&candles);
189        assert_eq!(ci.warmup_period(), 8);
190        for (i, v) in out.iter().enumerate().take(7) {
191            assert!(v.is_none(), "index {i} must be None during warmup");
192        }
193        assert!(out[7].is_some(), "first value lands at warmup_period - 1");
194    }
195
196    #[test]
197    fn rejects_period_below_two() {
198        assert!(ChoppinessIndex::new(0).is_err());
199        assert!(ChoppinessIndex::new(1).is_err());
200        assert!(ChoppinessIndex::new(2).is_ok());
201    }
202
203    /// Cover the const accessor `period` (73-75) and the Indicator-impl
204    /// `name` body (125-127). `warmup_period` is exercised elsewhere.
205    #[test]
206    fn accessors_and_metadata() {
207        let ci = ChoppinessIndex::new(14).unwrap();
208        assert_eq!(ci.period(), 14);
209        assert_eq!(ci.name(), "ChoppinessIndex");
210    }
211
212    #[test]
213    fn reset_clears_state() {
214        let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
215        let mut ci = ChoppinessIndex::new(14).unwrap();
216        ci.batch(&candles);
217        assert!(ci.is_ready());
218        ci.reset();
219        assert!(!ci.is_ready());
220        assert_eq!(ci.update(candles[0]), None);
221    }
222
223    #[test]
224    fn batch_equals_streaming() {
225        let candles: Vec<Candle> = (0..80)
226            .map(|i| {
227                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
228                c(mid + 1.5, mid - 1.5, mid + 0.5, i)
229            })
230            .collect();
231        let mut a = ChoppinessIndex::new(14).unwrap();
232        let mut b = ChoppinessIndex::new(14).unwrap();
233        assert_eq!(
234            a.batch(&candles),
235            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
236        );
237    }
238}