use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
#[derive(Debug, Clone)]
pub struct ChoppinessIndex {
period: usize,
log_n: f64,
prev_close: Option<f64>,
tr_window: VecDeque<f64>,
tr_sum: f64,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
}
impl ChoppinessIndex {
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "choppiness index needs period >= 2",
});
}
Ok(Self {
period,
log_n: (period as f64).log10(),
prev_close: None,
tr_window: VecDeque::with_capacity(period),
tr_sum: 0.0,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
})
}
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ChoppinessIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close);
if self.tr_window.len() == self.period {
self.tr_sum -= self.tr_window.pop_front().expect("non-empty");
self.highs.pop_front();
self.lows.pop_front();
}
self.tr_window.push_back(tr);
self.tr_sum += tr;
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
if self.tr_window.len() < self.period {
return None;
}
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let span = highest - lowest;
if span == 0.0 {
return Some(100.0);
}
Some(100.0 * (self.tr_sum / span).log10() / self.log_n)
}
fn reset(&mut self) {
self.prev_close = None;
self.tr_window.clear();
self.tr_sum = 0.0;
self.highs.clear();
self.lows.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.tr_window.len() == self.period
}
fn name(&self) -> &'static str {
"ChoppinessIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value_equal_range_bars() {
let mut ci = ChoppinessIndex::new(2).unwrap();
let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
}
#[test]
fn flat_window_yields_hundred() {
let candles: Vec<Candle> = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
for v in ci.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 100.0, epsilon = 1e-9);
}
}
#[test]
fn steady_trend_reads_low() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
for v in ci.batch(&candles).into_iter().flatten() {
assert!(v < 50.0, "a steady trend should read below 50, got {v}");
assert!(v >= 0.0, "CI must be non-negative, got {v}");
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(8).unwrap();
let out = ci.batch(&candles);
assert_eq!(ci.warmup_period(), 8);
for (i, v) in out.iter().enumerate().take(7) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_period_below_two() {
assert!(ChoppinessIndex::new(0).is_err());
assert!(ChoppinessIndex::new(1).is_err());
assert!(ChoppinessIndex::new(2).is_ok());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
ci.batch(&candles);
assert!(ci.is_ready());
ci.reset();
assert!(!ci.is_ready());
assert_eq!(ci.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = ChoppinessIndex::new(14).unwrap();
let mut b = ChoppinessIndex::new(14).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}