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    /// Ports `100 * log10(sum_atr / (max_high - min_low)) / log10(period)`.
65    ///
66    /// `sum_atr` = rolling sum of `high - low` (true range for OHLC-equal bars).
67    /// Returns `NaN` when the denominator or `log10(period)` is zero.
68    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
69        self.check_len(candles)?;
70
71        let n = candles.len();
72        let p = self.params.period;
73        let log_period = (p as f64).log10();
74
75        let mut values = vec![f64::NAN; n];
76
77        for i in (p - 1)..n {
78            let window = &candles[(i + 1 - p)..=i];
79            let atr_sum: f64 = window.iter().map(|c| c.high - c.low).sum();
80            let max_h = window
81                .iter()
82                .map(|c| c.high)
83                .fold(f64::NEG_INFINITY, f64::max);
84            let min_l = window.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
85            let denom = max_h - min_l;
86            values[i] = if denom == 0.0 || log_period == 0.0 {
87                f64::NAN
88            } else {
89                100.0 * (atr_sum / denom).log10() / log_period
90            };
91        }
92
93        Ok(IndicatorOutput::from_pairs([(self.output_key(), values)]))
94    }
95}
96
97pub fn factory<S: ::std::hash::BuildHasher>(
98    params: &HashMap<String, String, S>,
99) -> Result<Box<dyn Indicator>, IndicatorError> {
100    Ok(Box::new(ChoppinessIndex::new(ChopParams {
101        period: param_usize(params, "period", 14)?,
102    })))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn candles(n: usize, range: f64) -> Vec<Candle> {
110        (0..n)
111            .map(|i| Candle {
112                time: i64::try_from(i).expect("time index fits i64"),
113                open: 10.0,
114                high: 10.0 + range,
115                low: 10.0 - range,
116                close: 10.0,
117                volume: 100.0,
118            })
119            .collect()
120    }
121
122    #[test]
123    fn chop_output_column() {
124        let out = ChoppinessIndex::with_period(14)
125            .calculate(&candles(20, 1.0))
126            .unwrap();
127        assert!(out.get("CHOP_14").is_some());
128    }
129
130    #[test]
131    fn chop_constant_range_near_100() {
132        // Constant H-L with the same max_h−min_l → ratio=1 → log10(1)=0 → CHOP=0?
133        // Python: 100 * log10(sum_atr / (max_h - min_l)) / log10(period)
134        // With constant bars: sum_atr = period * range, max_h-min_l = range
135        // → log10(period) / log10(period) = 1 → CHOP = 100
136        let out = ChoppinessIndex::with_period(14)
137            .calculate(&candles(20, 1.0))
138            .unwrap();
139        let vals = out.get("CHOP_14").unwrap();
140        let last = vals.iter().rev().find(|v| !v.is_nan()).copied().unwrap();
141        assert!((last - 100.0).abs() < 1e-6, "got {last}");
142    }
143
144    #[test]
145    fn factory_creates_chop() {
146        assert_eq!(factory(&HashMap::new()).unwrap().name(), "ChoppinessIndex");
147    }
148}