Skip to main content

indicators/volatility/
choppiness_index.rs

1//! Choppiness Index (CHOP).
2//!
3//! Python source: `indicators/other/choppiness_index.py :: class ChoppinessIndex`
4//!
5//! # Python algorithm (to port)
6//! ```python
7//! high_low_range = df["High"] - df["Low"]
8//! atr_sum        = high_low_range.rolling(window=self.period).sum()
9//! max_high       = df["High"].rolling(window=self.period).max()
10//! min_low        = df["Low"].rolling(window=self.period).min()
11//! denominator    = (max_high - min_low).replace(0, np.nan)
12//! chop           = 100 * np.log10(atr_sum / denominator) / np.log10(self.period)
13//! ```
14//!
15//! Readings above 61.8 → choppy/sideways; below 38.2 → trending.
16//!
17//! Output column: `"CHOP_{period}"`.
18
19use std::collections::HashMap;
20
21use crate::error::IndicatorError;
22use crate::indicator::{Indicator, IndicatorOutput};
23use crate::registry::param_usize;
24use crate::types::Candle;
25
26#[derive(Debug, Clone)]
27pub struct ChopParams {
28    pub period: usize,
29}
30impl Default for ChopParams {
31    fn default() -> Self {
32        Self { period: 14 }
33    }
34}
35
36#[derive(Debug, Clone)]
37pub struct ChoppinessIndex {
38    pub params: ChopParams,
39}
40
41impl ChoppinessIndex {
42    pub fn new(params: ChopParams) -> Self {
43        Self { params }
44    }
45    pub fn with_period(period: usize) -> Self {
46        Self::new(ChopParams { period })
47    }
48    fn output_key(&self) -> String {
49        format!("CHOP_{}", self.params.period)
50    }
51}
52
53impl Indicator for ChoppinessIndex {
54    fn name(&self) -> &'static str {
55        "ChoppinessIndex"
56    }
57    fn required_len(&self) -> usize {
58        self.params.period
59    }
60    fn required_columns(&self) -> &[&'static str] {
61        &["high", "low"]
62    }
63
64    /// TODO: port Python log10-based choppiness formula.
65    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
66        self.check_len(candles)?;
67
68        let n = candles.len();
69        let p = self.params.period;
70        let log_period = (p as f64).log10();
71
72        let mut values = vec![f64::NAN; n];
73
74        // TODO: port Python rolling logic.
75        for i in (p - 1)..n {
76            let window = &candles[(i + 1 - p)..=i];
77            let atr_sum: f64 = window.iter().map(|c| c.high - c.low).sum();
78            let max_h = window
79                .iter()
80                .map(|c| c.high)
81                .fold(f64::NEG_INFINITY, f64::max);
82            let min_l = window.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
83            let denom = max_h - min_l;
84            values[i] = if denom == 0.0 || log_period == 0.0 {
85                f64::NAN
86            } else {
87                100.0 * (atr_sum / denom).log10() / log_period
88            };
89        }
90
91        Ok(IndicatorOutput::from_pairs([(self.output_key(), values)]))
92    }
93}
94
95pub fn factory<S: ::std::hash::BuildHasher>(params: &HashMap<String, String, S>) -> Result<Box<dyn Indicator>, IndicatorError> {
96    Ok(Box::new(ChoppinessIndex::new(ChopParams {
97        period: param_usize(params, "period", 14)?,
98    })))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn candles(n: usize, range: f64) -> Vec<Candle> {
106        (0..n)
107            .map(|i| Candle {
108                time: i64::try_from(i).expect("time index fits i64"),
109                open: 10.0,
110                high: 10.0 + range,
111                low: 10.0 - range,
112                close: 10.0,
113                volume: 100.0,
114            })
115            .collect()
116    }
117
118    #[test]
119    fn chop_output_column() {
120        let out = ChoppinessIndex::with_period(14)
121            .calculate(&candles(20, 1.0))
122            .unwrap();
123        assert!(out.get("CHOP_14").is_some());
124    }
125
126    #[test]
127    fn chop_constant_range_near_100() {
128        // Constant H-L with the same max_h−min_l → ratio=1 → log10(1)=0 → CHOP=0?
129        // Python: 100 * log10(sum_atr / (max_h - min_l)) / log10(period)
130        // With constant bars: sum_atr = period * range, max_h-min_l = range
131        // → log10(period) / log10(period) = 1 → CHOP = 100
132        let out = ChoppinessIndex::with_period(14)
133            .calculate(&candles(20, 1.0))
134            .unwrap();
135        let vals = out.get("CHOP_14").unwrap();
136        let last = vals.iter().rev().find(|v| !v.is_nan()).copied().unwrap();
137        assert!((last - 100.0).abs() < 1e-6, "got {last}");
138    }
139
140    #[test]
141    fn factory_creates_chop() {
142        assert_eq!(factory(&HashMap::new()).unwrap().name(), "ChoppinessIndex");
143    }
144}