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) -> &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.iter().map(|c| c.high).fold(f64::NEG_INFINITY, f64::max);
79            let min_l = window.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
80            let denom = max_h - min_l;
81            values[i] = if denom == 0.0 || log_period == 0.0 {
82                f64::NAN
83            } else {
84                100.0 * (atr_sum / denom).log10() / log_period
85            };
86        }
87
88        Ok(IndicatorOutput::from_pairs([(self.output_key(), values)]))
89    }
90}
91
92pub fn factory(params: &HashMap<String, String>) -> Result<Box<dyn Indicator>, IndicatorError> {
93    Ok(Box::new(ChoppinessIndex::new(ChopParams {
94        period: param_usize(params, "period", 14)?,
95    })))
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn candles(n: usize, range: f64) -> Vec<Candle> {
103        (0..n).map(|i| Candle {
104            time: i as i64, open: 10.0, high: 10.0 + range, low: 10.0 - range,
105            close: 10.0, volume: 100.0,
106        }).collect()
107    }
108
109    #[test]
110    fn chop_output_column() {
111        let out = ChoppinessIndex::with_period(14).calculate(&candles(20, 1.0)).unwrap();
112        assert!(out.get("CHOP_14").is_some());
113    }
114
115    #[test]
116    fn chop_constant_range_near_100() {
117        // Constant H-L with the same max_h−min_l → ratio=1 → log10(1)=0 → CHOP=0?
118        // Python: 100 * log10(sum_atr / (max_h - min_l)) / log10(period)
119        // With constant bars: sum_atr = period * range, max_h-min_l = range
120        // → log10(period) / log10(period) = 1 → CHOP = 100
121        let out = ChoppinessIndex::with_period(14).calculate(&candles(20, 1.0)).unwrap();
122        let vals = out.get("CHOP_14").unwrap();
123        let last = vals.iter().rev().find(|v| !v.is_nan()).copied().unwrap();
124        assert!((last - 100.0).abs() < 1e-6, "got {last}");
125    }
126
127    #[test]
128    fn factory_creates_chop() {
129        assert_eq!(factory(&HashMap::new()).unwrap().name(), "ChoppinessIndex");
130    }
131}