use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Next;
use std::collections::VecDeque;
#[derive(Debug, Clone)]
pub struct ReversionIndex {
length: usize,
prev_close: Option<f64>,
deltas: VecDeque<f64>,
abs_deltas: VecDeque<f64>,
delta_sum: f64,
abs_delta_sum: f64,
smooth: SuperSmoother,
trigger: SuperSmoother,
}
impl ReversionIndex {
pub fn new(length: usize) -> Self {
Self {
length,
prev_close: None,
deltas: VecDeque::with_capacity(length),
abs_deltas: VecDeque::with_capacity(length),
delta_sum: 0.0,
abs_delta_sum: 0.0,
smooth: SuperSmoother::new(8),
trigger: SuperSmoother::new(4),
}
}
}
impl Next<f64> for ReversionIndex {
type Output = (f64, f64);
fn next(&mut self, input: f64) -> Self::Output {
let delta = match self.prev_close {
Some(prev) => input - prev,
None => 0.0,
};
self.prev_close = Some(input);
self.deltas.push_back(delta);
self.abs_deltas.push_back(delta.abs());
self.delta_sum += delta;
self.abs_delta_sum += delta.abs();
if self.deltas.len() > self.length {
if let Some(old_delta) = self.deltas.pop_front() {
self.delta_sum -= old_delta;
}
if let Some(old_abs_delta) = self.abs_deltas.pop_front() {
self.abs_delta_sum -= old_abs_delta;
}
}
let ratio = if self.abs_delta_sum != 0.0 {
self.delta_sum / self.abs_delta_sum
} else {
0.0
};
let sm_val = self.smooth.next(ratio);
let tr_val = self.trigger.next(ratio);
(sm_val, tr_val)
}
}
pub const REVERSION_INDEX_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "Reversion Index",
description: "A mean-reversion oscillator that normalizes price changes by their absolute magnitude and applies SuperSmoother filtering.",
params: &[ParamDef {
name: "length",
default: "20",
description: "Summation period (approx. half dominant cycle)",
}],
formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS%E2%80%99%20TIPS%20-%20JANUARY%202026.html",
formula_latex: r#"
\[
\Delta_t = \text{Close}_t - \text{Close}_{t-1}
\]
\[
\text{Ratio} = \frac{\sum_{i=0}^{L-1} \Delta_{t-i}}{\sum_{i=0}^{L-1} |\Delta_{t-i}|}
\]
\[
\text{Smooth} = SuperSmoother(\text{Ratio}, 8)
\]
\[
\text{Trigger} = SuperSmoother(\text{Ratio}, 4)
\]
"#,
gold_standard_file: "reversion_index.json",
category: "Ehlers DSP",
};
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::Next;
use proptest::prelude::*;
#[test]
fn test_reversion_index_basic() {
let mut ri = ReversionIndex::new(20);
let inputs = vec![100.0, 101.0, 102.0, 101.0, 100.0];
for input in inputs {
let (sm, tr) = ri.next(input);
assert!(!sm.is_nan());
assert!(!tr.is_nan());
assert!(sm >= -1.0 && sm <= 1.0);
assert!(tr >= -1.0 && tr <= 1.0);
}
}
proptest! {
#[test]
fn test_reversion_index_parity(
inputs in prop::collection::vec(10.0..110.0, 50..100),
) {
let length = 20;
let mut ri = ReversionIndex::new(length);
let streaming_results: Vec<(f64, f64)> = inputs.iter().map(|&x| ri.next(x)).collect();
let mut deltas = Vec::new();
let mut smooth = SuperSmoother::new(8);
let mut trigger = SuperSmoother::new(4);
let mut batch_results = Vec::with_capacity(inputs.len());
for i in 0..inputs.len() {
let d = if i == 0 { 0.0 } else { inputs[i] - inputs[i-1] };
deltas.push(d);
let start = if deltas.len() > length { deltas.len() - length } else { 0 };
let window = &deltas[start..];
let d_sum: f64 = window.iter().sum();
let ad_sum: f64 = window.iter().map(|x| x.abs()).sum();
let ratio = if ad_sum != 0.0 { d_sum / ad_sum } else { 0.0 };
let sm = smooth.next(ratio);
let tr = trigger.next(ratio);
batch_results.push((sm, tr));
}
for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-10);
approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-10);
}
}
}
}