Skip to main content

quantwave_core/indicators/
choppiness_index.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use crate::utils::RingBuffer as VecDeque;
4
5/// Choppiness Index
6///
7/// The Choppiness Index is a volatility indicator used to determine if the
8/// market is trending or ranging (choppy).
9/// Values above 61.8 indicate choppiness, while values below 38.2 indicate trending.
10#[derive(Debug, Clone)]
11pub struct ChoppinessIndex {
12    period: usize,
13    tr_window: VecDeque<f64>,
14    high_window: VecDeque<f64>,
15    low_window: VecDeque<f64>,
16    prev_close: Option<f64>,
17}
18
19impl ChoppinessIndex {
20    pub fn new(period: usize) -> Self {
21        Self {
22            period,
23            tr_window: VecDeque::with_capacity(period),
24            high_window: VecDeque::with_capacity(period),
25            low_window: VecDeque::with_capacity(period),
26            prev_close: None,
27        }
28    }
29}
30
31impl Default for ChoppinessIndex {
32    fn default() -> Self {
33        Self::new(14)
34    }
35}
36
37impl Next<(f64, f64, f64)> for ChoppinessIndex {
38    type Output = f64; // Choppiness value
39
40    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
41        // True Range calculation
42        let tr = match self.prev_close {
43            None => high - low,
44            Some(pc) => {
45                let h_pc = (high - pc).abs();
46                let l_pc = (low - pc).abs();
47                let h_l = high - low;
48                h_pc.max(l_pc).max(h_l)
49            }
50        };
51        self.prev_close = Some(close);
52
53        self.tr_window.push_front(tr);
54        self.high_window.push_front(high);
55        self.low_window.push_front(low);
56
57        if self.tr_window.len() > self.period {
58            self.tr_window.pop_back();
59            self.high_window.pop_back();
60            self.low_window.pop_back();
61        }
62
63        if self.tr_window.len() < self.period {
64            return 50.0; // Neutral value during startup
65        }
66
67        // sum(TrueRange, N)
68        let sum_tr: f64 = self.tr_window.iter().sum();
69
70        // MaxHigh(N) - MinLow(N)
71        let mut max_h = f64::MIN;
72        let mut min_l = f64::MAX;
73        for &h in &self.high_window {
74            if h > max_h {
75                max_h = h;
76            }
77        }
78        for &l in &self.low_window {
79            if l < min_l {
80                min_l = l;
81            }
82        }
83
84        let range = max_h - min_l;
85
86        if range == 0.0 {
87            100.0
88        } else {
89            let n_f = self.period as f64;
90            100.0 * (sum_tr / range).log10() / n_f.log10()
91        }
92    }
93}
94
95pub const CHOPPINESS_INDEX_METADATA: IndicatorMetadata = IndicatorMetadata {
96    name: "Choppiness Index",
97    description: "Determines if the market is trending (low values) or ranging/choppy (high values).",
98    usage: "Use to determine whether a market is trending or choppy before selecting a trading strategy. Values above 61.8 indicate chop; values below 38.2 indicate a strong trend.",
99    keywords: &["volatility", "trend-strength", "classic", "range"],
100    ehlers_summary: "The Choppiness Index, developed by E.W. Dreiss, measures how much of the total ATR-based range is consumed by the actual net price move over N bars. A value near 100 means price wandered back and forth using all available range without net progress (maximum chop); near 0 means a straight directional move with minimal retracement. — StockCharts ChartSchool",
101    params: &[ParamDef {
102        name: "period",
103        default: "14",
104        description: "Lookback period",
105    }],
106    formula_source: "https://www.tradingview.com/support/solutions/43000501980-choppiness-index-chop/",
107    formula_latex: r#"
108\[
109CHOP = 100 \times \frac{\log_{10}(\sum_{i=1}^n ATR(1)_i / (\max(H, n) - \min(L, n)))}{\log_{10}(n)}
110\]
111"#,
112    gold_standard_file: "choppiness_index.json",
113    category: "Modern",
114};
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::traits::Next;
120    use proptest::prelude::*;
121
122    #[test]
123    fn test_chop_basic() {
124        let mut chop = ChoppinessIndex::new(14);
125        for i in 0..30 {
126            let val = chop.next((100.0 + i as f64, 90.0 + i as f64, 95.0 + i as f64));
127            assert!(val >= 0.0 && val <= 100.0);
128        }
129    }
130
131    proptest! {
132        #[test]
133        fn test_chop_parity(
134            inputs in prop::collection::vec(1.0..100.0, 50..100),
135        ) {
136            let period = 14;
137            let mut chop = ChoppinessIndex::new(period);
138            // Mock H/L/C from single value
139            let ohlc_inputs: Vec<(f64, f64, f64)> = inputs.iter().map(|&x| (x + 1.0, x - 1.0, x)).collect();
140            let streaming_results: Vec<f64> = ohlc_inputs.iter().map(|&x| chop.next(x)).collect();
141
142            let mut chop_batch = ChoppinessIndex::new(period);
143            let batch_results: Vec<f64> = ohlc_inputs.iter().map(|&x| chop_batch.next(x)).collect();
144
145            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
146                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
147            }
148        }
149    }
150}