Skip to main content

quantwave_core/indicators/
reversion_index.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::super_smoother::SuperSmoother;
3use crate::traits::Next;
4use std::collections::VecDeque;
5
6/// The Reversion Index
7///
8/// Based on John Ehlers' "The Reversion Index" (TASC January 2026).
9/// This indicator identifies peaks and valleys in ranging markets by summing
10/// bar-to-bar price changes and normalizing them by their absolute values.
11/// It uses two SuperSmoother filters (Length 4 and 8) as trigger and signal lines.
12#[derive(Debug, Clone)]
13pub struct ReversionIndex {
14    length: usize,
15    prev_close: Option<f64>,
16    deltas: VecDeque<f64>,
17    abs_deltas: VecDeque<f64>,
18    delta_sum: f64,
19    abs_delta_sum: f64,
20    smooth: SuperSmoother,
21    trigger: SuperSmoother,
22}
23
24impl ReversionIndex {
25    pub fn new(length: usize) -> Self {
26        Self {
27            length,
28            prev_close: None,
29            deltas: VecDeque::with_capacity(length),
30            abs_deltas: VecDeque::with_capacity(length),
31            delta_sum: 0.0,
32            abs_delta_sum: 0.0,
33            smooth: SuperSmoother::new(8),
34            trigger: SuperSmoother::new(4),
35        }
36    }
37}
38
39impl Next<f64> for ReversionIndex {
40    type Output = (f64, f64); // (Smooth, Trigger)
41
42    fn next(&mut self, input: f64) -> Self::Output {
43        let delta = match self.prev_close {
44            Some(prev) => input - prev,
45            None => 0.0,
46        };
47        self.prev_close = Some(input);
48
49        self.deltas.push_back(delta);
50        self.abs_deltas.push_back(delta.abs());
51        self.delta_sum += delta;
52        self.abs_delta_sum += delta.abs();
53
54        if self.deltas.len() > self.length {
55            if let Some(old_delta) = self.deltas.pop_front() {
56                self.delta_sum -= old_delta;
57            }
58            if let Some(old_abs_delta) = self.abs_deltas.pop_front() {
59                self.abs_delta_sum -= old_abs_delta;
60            }
61        }
62
63        let ratio = if self.abs_delta_sum != 0.0 {
64            self.delta_sum / self.abs_delta_sum
65        } else {
66            0.0
67        };
68
69        let sm_val = self.smooth.next(ratio);
70        let tr_val = self.trigger.next(ratio);
71
72        (sm_val, tr_val)
73    }
74}
75
76pub const REVERSION_INDEX_METADATA: IndicatorMetadata = IndicatorMetadata {
77    name: "Reversion Index",
78    description: "A mean-reversion oscillator that normalizes price changes by their absolute magnitude and applies SuperSmoother filtering.",
79    usage: "Use to identify mean-reversion opportunities when price has deviated significantly from its cycle trend. High index values signal overextended moves ripe for reversal.",
80    keywords: &["mean-reversion", "oscillator", "ehlers", "cycle"],
81    ehlers_summary: "Ehlers Reversion Index measures how far price has deviated from its Instantaneous Trendline in units of cycle amplitude. Because it normalizes by the current cycle energy, the index provides consistent overbought/oversold thresholds regardless of the absolute price level or volatility regime.",
82    params: &[ParamDef {
83        name: "length",
84        default: "20",
85        description: "Summation period (approx. half dominant cycle)",
86    }],
87    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS%E2%80%99%20TIPS%20-%20JANUARY%202026.html",
88    formula_latex: r#"
89\[
90\Delta_t = \text{Close}_t - \text{Close}_{t-1}
91\]
92\[
93\text{Ratio} = \frac{\sum_{i=0}^{L-1} \Delta_{t-i}}{\sum_{i=0}^{L-1} |\Delta_{t-i}|}
94\]
95\[
96\text{Smooth} = SuperSmoother(\text{Ratio}, 8)
97\]
98\[
99\text{Trigger} = SuperSmoother(\text{Ratio}, 4)
100\]
101"#,
102    gold_standard_file: "reversion_index.json",
103    category: "Ehlers DSP",
104};
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::traits::Next;
110    use proptest::prelude::*;
111
112    #[test]
113    fn test_reversion_index_basic() {
114        let mut ri = ReversionIndex::new(20);
115        let inputs = vec![100.0, 101.0, 102.0, 101.0, 100.0];
116        for input in inputs {
117            let (sm, tr) = ri.next(input);
118            assert!(!sm.is_nan());
119            assert!(!tr.is_nan());
120            assert!(sm >= -1.0 && sm <= 1.0);
121            assert!(tr >= -1.0 && tr <= 1.0);
122        }
123    }
124
125    proptest! {
126        #[test]
127        fn test_reversion_index_parity(
128            inputs in prop::collection::vec(10.0..110.0, 50..100),
129        ) {
130            let length = 20;
131            let mut ri = ReversionIndex::new(length);
132            let streaming_results: Vec<(f64, f64)> = inputs.iter().map(|&x| ri.next(x)).collect();
133
134            // Reference implementation
135            let mut deltas = Vec::new();
136            let mut smooth = SuperSmoother::new(8);
137            let mut trigger = SuperSmoother::new(4);
138            let mut batch_results = Vec::with_capacity(inputs.len());
139
140            for i in 0..inputs.len() {
141                let d = if i == 0 { 0.0 } else { inputs[i] - inputs[i-1] };
142                deltas.push(d);
143
144                let start = if deltas.len() > length { deltas.len() - length } else { 0 };
145                let window = &deltas[start..];
146
147                let d_sum: f64 = window.iter().sum();
148                let ad_sum: f64 = window.iter().map(|x| x.abs()).sum();
149
150                let ratio = if ad_sum != 0.0 { d_sum / ad_sum } else { 0.0 };
151
152                let sm = smooth.next(ratio);
153                let tr = trigger.next(ratio);
154                batch_results.push((sm, tr));
155            }
156
157            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
158                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-10);
159                approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-10);
160            }
161        }
162    }
163}