Skip to main content

kestrel_chartkit/indicator/
choppiness.rs

1use super::{Indicator, IndicatorAlert, IndicatorOutput};
2use crate::model::Bar;
3use std::collections::VecDeque;
4
5/// Choppiness Index, `0..=100`.
6///
7/// `CHOP = 100 * log10(sum(TR) / (HH - LL)) / log10(period)` over the last `period` bars, the true
8/// ranges with `high - low` on the first bar and the range floored at `1e-8`, clamped to
9/// `0..=100`. High values mean the bars overlap (chop), low values that they line up (trend).
10/// `period` is at least 2.
11///
12/// First output: with the `period`-th bar. [`Indicator::reset`] clears the windows.
13#[derive(Debug, Clone)]
14pub struct ChoppinessIndexEngine {
15    period: usize,
16    prev_close: Option<f64>,
17    tr_sum_window: VecDeque<f64>,
18    bars: VecDeque<Bar>,
19}
20
21impl ChoppinessIndexEngine {
22    pub fn new(period: usize) -> Self {
23        Self {
24            period: period.max(2),
25            prev_close: None,
26            tr_sum_window: VecDeque::with_capacity(period),
27            bars: VecDeque::with_capacity(period),
28        }
29    }
30}
31
32impl Indicator for ChoppinessIndexEngine {
33    fn name(&self) -> &str {
34        "choppiness"
35    }
36
37    fn warmup_period(&self) -> usize {
38        self.period
39    }
40
41    fn reset(&mut self) {
42        self.prev_close = None;
43        self.tr_sum_window.clear();
44        self.bars.clear();
45    }
46
47    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
48        let tr = match self.prev_close {
49            Some(prev_c) => (bar.high - bar.low)
50                .max((bar.high - prev_c).abs())
51                .max((bar.low - prev_c).abs()),
52            None => bar.high - bar.low,
53        };
54        self.prev_close = Some(bar.close);
55
56        self.tr_sum_window.push_back(tr);
57        self.bars.push_back(bar.clone());
58
59        if self.tr_sum_window.len() > self.period {
60            self.tr_sum_window.pop_front();
61            self.bars.pop_front();
62        }
63
64        if self.tr_sum_window.len() < self.period {
65            return None;
66        }
67
68        let sum_tr: f64 = self.tr_sum_window.iter().sum();
69        let max_h = self.bars.iter().map(|b| b.high).fold(f64::MIN, f64::max);
70        let min_l = self.bars.iter().map(|b| b.low).fold(f64::MAX, f64::min);
71        let range = (max_h - min_l).max(1e-8);
72
73        let n_f64 = self.period as f64;
74        let chop = 100.0 * (sum_tr / range).log10() / n_f64.log10();
75        let chop_clamped = chop.clamp(0.0, 100.0);
76
77        Some(IndicatorOutput::new(chop_clamped))
78    }
79
80    fn alerts(&self) -> Vec<IndicatorAlert> {
81        Vec::new()
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_choppiness_index() {
91        let mut chop = ChoppinessIndexEngine::new(14);
92        let mut out = None;
93        for i in 0..30 {
94            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + (i % 2) as f64, 1000.0);
95            out = chop.on_bar(&b);
96        }
97        assert!(out.is_some());
98        let val = out.unwrap().value;
99        assert!((0.0..=100.0).contains(&val));
100    }
101}