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