wickra_core/indicators/
choppiness_index.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
39pub struct ChoppinessIndex {
40 period: usize,
41 log_n: f64,
42 prev_close: Option<f64>,
43 tr_window: VecDeque<f64>,
44 tr_sum: f64,
45 highs: VecDeque<f64>,
46 lows: VecDeque<f64>,
47}
48
49impl ChoppinessIndex {
50 pub fn new(period: usize) -> Result<Self> {
56 if period < 2 {
57 return Err(Error::InvalidPeriod {
58 message: "choppiness index needs period >= 2",
59 });
60 }
61 Ok(Self {
62 period,
63 log_n: (period as f64).log10(),
64 prev_close: None,
65 tr_window: VecDeque::with_capacity(period),
66 tr_sum: 0.0,
67 highs: VecDeque::with_capacity(period),
68 lows: VecDeque::with_capacity(period),
69 })
70 }
71
72 pub const fn period(&self) -> usize {
74 self.period
75 }
76}
77
78impl Indicator for ChoppinessIndex {
79 type Input = Candle;
80 type Output = f64;
81
82 fn update(&mut self, candle: Candle) -> Option<f64> {
83 let tr = candle.true_range(self.prev_close);
84 self.prev_close = Some(candle.close);
85
86 if self.tr_window.len() == self.period {
87 self.tr_sum -= self.tr_window.pop_front().expect("non-empty");
88 self.highs.pop_front();
89 self.lows.pop_front();
90 }
91 self.tr_window.push_back(tr);
92 self.tr_sum += tr;
93 self.highs.push_back(candle.high);
94 self.lows.push_back(candle.low);
95
96 if self.tr_window.len() < self.period {
97 return None;
98 }
99 let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
100 let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
101 let span = highest - lowest;
102 if span == 0.0 {
103 return Some(100.0);
105 }
106 Some(100.0 * (self.tr_sum / span).log10() / self.log_n)
107 }
108
109 fn reset(&mut self) {
110 self.prev_close = None;
111 self.tr_window.clear();
112 self.tr_sum = 0.0;
113 self.highs.clear();
114 self.lows.clear();
115 }
116
117 fn warmup_period(&self) -> usize {
118 self.period
119 }
120
121 fn is_ready(&self) -> bool {
122 self.tr_window.len() == self.period
123 }
124
125 fn name(&self) -> &'static str {
126 "ChoppinessIndex"
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::traits::BatchExt;
134 use approx::assert_relative_eq;
135
136 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
137 Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
138 }
139
140 #[test]
141 fn reference_value_equal_range_bars() {
142 let mut ci = ChoppinessIndex::new(2).unwrap();
145 let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]);
146 assert!(out[0].is_none());
147 assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
148 }
149
150 #[test]
151 fn flat_window_yields_hundred() {
152 let candles: Vec<Candle> = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect();
153 let mut ci = ChoppinessIndex::new(14).unwrap();
154 for v in ci.batch(&candles).into_iter().flatten() {
155 assert_relative_eq!(v, 100.0, epsilon = 1e-9);
156 }
157 }
158
159 #[test]
160 fn steady_trend_reads_low() {
161 let candles: Vec<Candle> = (0..60)
163 .map(|i| {
164 let base = 100.0 + i as f64;
165 c(base + 1.0, base - 1.0, base, i)
166 })
167 .collect();
168 let mut ci = ChoppinessIndex::new(14).unwrap();
169 for v in ci.batch(&candles).into_iter().flatten() {
170 assert!(v < 50.0, "a steady trend should read below 50, got {v}");
171 assert!(v >= 0.0, "CI must be non-negative, got {v}");
172 }
173 }
174
175 #[test]
176 fn first_emission_matches_warmup_period() {
177 let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
178 let mut ci = ChoppinessIndex::new(8).unwrap();
179 let out = ci.batch(&candles);
180 assert_eq!(ci.warmup_period(), 8);
181 for (i, v) in out.iter().enumerate().take(7) {
182 assert!(v.is_none(), "index {i} must be None during warmup");
183 }
184 assert!(out[7].is_some(), "first value lands at warmup_period - 1");
185 }
186
187 #[test]
188 fn rejects_period_below_two() {
189 assert!(ChoppinessIndex::new(0).is_err());
190 assert!(ChoppinessIndex::new(1).is_err());
191 assert!(ChoppinessIndex::new(2).is_ok());
192 }
193
194 #[test]
197 fn accessors_and_metadata() {
198 let ci = ChoppinessIndex::new(14).unwrap();
199 assert_eq!(ci.period(), 14);
200 assert_eq!(ci.name(), "ChoppinessIndex");
201 }
202
203 #[test]
204 fn reset_clears_state() {
205 let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
206 let mut ci = ChoppinessIndex::new(14).unwrap();
207 ci.batch(&candles);
208 assert!(ci.is_ready());
209 ci.reset();
210 assert!(!ci.is_ready());
211 assert_eq!(ci.update(candles[0]), None);
212 }
213
214 #[test]
215 fn batch_equals_streaming() {
216 let candles: Vec<Candle> = (0..80)
217 .map(|i| {
218 let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
219 c(mid + 1.5, mid - 1.5, mid + 0.5, i)
220 })
221 .collect();
222 let mut a = ChoppinessIndex::new(14).unwrap();
223 let mut b = ChoppinessIndex::new(14).unwrap();
224 assert_eq!(
225 a.batch(&candles),
226 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
227 );
228 }
229}