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 Engine (0..100).
6/// Formula: 100 * log10( Sum(ATR(1), N) / (Highest(H, N) - Lowest(L, N)) ) / log10(N)
7#[derive(Debug, Clone)]
8pub struct ChoppinessIndexEngine {
9    period: usize,
10    prev_close: Option<f64>,
11    tr_sum_window: VecDeque<f64>,
12    bars: VecDeque<Bar>,
13}
14
15impl ChoppinessIndexEngine {
16    pub fn new(period: usize) -> Self {
17        Self {
18            period: period.max(2),
19            prev_close: None,
20            tr_sum_window: VecDeque::with_capacity(period),
21            bars: VecDeque::with_capacity(period),
22        }
23    }
24}
25
26impl Indicator for ChoppinessIndexEngine {
27    fn name(&self) -> &str {
28        "choppiness"
29    }
30
31    fn warmup_period(&self) -> usize {
32        self.period
33    }
34
35    fn reset(&mut self) {
36        self.prev_close = None;
37        self.tr_sum_window.clear();
38        self.bars.clear();
39    }
40
41    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
42        let tr = match self.prev_close {
43            Some(prev_c) => (bar.high - bar.low)
44                .max((bar.high - prev_c).abs())
45                .max((bar.low - prev_c).abs()),
46            None => bar.high - bar.low,
47        };
48        self.prev_close = Some(bar.close);
49
50        self.tr_sum_window.push_back(tr);
51        self.bars.push_back(bar.clone());
52
53        if self.tr_sum_window.len() > self.period {
54            self.tr_sum_window.pop_front();
55            self.bars.pop_front();
56        }
57
58        if self.tr_sum_window.len() < self.period {
59            return None;
60        }
61
62        let sum_tr: f64 = self.tr_sum_window.iter().sum();
63        let max_h = self.bars.iter().map(|b| b.high).fold(f64::MIN, f64::max);
64        let min_l = self.bars.iter().map(|b| b.low).fold(f64::MAX, f64::min);
65        let range = (max_h - min_l).max(1e-8);
66
67        let n_f64 = self.period as f64;
68        let chop = 100.0 * (sum_tr / range).log10() / n_f64.log10();
69        let chop_clamped = chop.clamp(0.0, 100.0);
70
71        Some(IndicatorOutput::new(chop_clamped))
72    }
73
74    fn alerts(&self) -> Vec<IndicatorAlert> {
75        Vec::new()
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn test_choppiness_index() {
85        let mut chop = ChoppinessIndexEngine::new(14);
86        let mut out = None;
87        for i in 0..30 {
88            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + (i % 2) as f64, 1000.0);
89            out = chop.on_bar(&b);
90        }
91        assert!(out.is_some());
92        let val = out.unwrap().value;
93        assert!((0.0..=100.0).contains(&val));
94    }
95}