quantwave_core/indicators/
reversion_index.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::super_smoother::SuperSmoother;
3use crate::traits::Next;
4use std::collections::VecDeque;
5
6#[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); 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 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}