Skip to main content

quantwave_core/indicators/
pairs_rotation.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use crate::indicators::high_pass::HighPass;
4use crate::indicators::super_smoother::SuperSmoother;
5
6/// Pairs Rotation (Ehlers Loops)
7///
8/// Based on John Ehlers' "Pairs Rotation".
9/// Filters two price streams using HighPass and SuperSmoother filters,
10/// then normalizes them by their RMS (calculated via EMA of squares).
11/// This allows visualizing the relative performance and rotation of two securities.
12#[derive(Debug, Clone)]
13pub struct PairsRotation {
14    hp1: HighPass,
15    ss1: SuperSmoother,
16    ms1: f64,
17    hp2: HighPass,
18    ss2: SuperSmoother,
19    ms2: f64,
20    count: usize,
21}
22
23impl PairsRotation {
24    pub fn new(hp_len: usize, lp_len: usize) -> Self {
25        Self {
26            hp1: HighPass::new(hp_len),
27            ss1: SuperSmoother::new(lp_len),
28            ms1: 0.0,
29            hp2: HighPass::new(hp_len),
30            ss2: SuperSmoother::new(lp_len),
31            ms2: 0.0,
32            count: 0,
33        }
34    }
35}
36
37impl Default for PairsRotation {
38    fn default() -> Self {
39        Self::new(125, 20)
40    }
41}
42
43impl Next<(f64, f64)> for PairsRotation {
44    type Output = (f64, f64); // (Normalized1, Normalized2)
45
46    fn next(&mut self, input: (f64, f64)) -> Self::Output {
47        self.count += 1;
48        let (p1, p2) = input;
49
50        let filt1 = self.ss1.next(self.hp1.next(p1));
51        let filt2 = self.ss2.next(self.hp2.next(p2));
52
53        // RMS update using EMA (alpha = 0.0242 for ~1 year period)
54        let alpha = 0.0242;
55        if self.count == 1 {
56            self.ms1 = filt1 * filt1;
57            self.ms2 = filt2 * filt2;
58        } else {
59            self.ms1 = alpha * filt1 * filt1 + (1.0 - alpha) * self.ms1;
60            self.ms2 = alpha * filt2 * filt2 + (1.0 - alpha) * self.ms2;
61        }
62
63        let norm1 = if self.ms1 > 0.0 {
64            filt1 / self.ms1.sqrt()
65        } else {
66            0.0
67        };
68
69        let norm2 = if self.ms2 > 0.0 {
70            filt2 / self.ms2.sqrt()
71        } else {
72            0.0
73        };
74
75        (norm1, norm2)
76    }
77}
78
79pub const PAIRS_ROTATION_METADATA: IndicatorMetadata = IndicatorMetadata {
80    name: "PairsRotation",
81    description: "Relative rotation of two securities using normalized roofing filters.",
82    usage: "Use to detect and trade rotation between two correlated assets. When one asset leads and the other lags, the indicator signals a rotation trade opportunity.",
83    keywords: &["pairs-trading", "rotation", "relative-strength", "ehlers"],
84    ehlers_summary: "Pairs Rotation analysis measures the relative cycle phase between two correlated assets. When one asset is at a cycle peak while its correlated partner is at a trough, a statistical rotation trade can be placed — long the laggard, short the leader — anticipating mean reversion of the spread.",
85    params: &[
86        ParamDef {
87            name: "hp_len",
88            default: "125",
89            description: "HighPass filter length",
90        },
91        ParamDef {
92            name: "lp_len",
93            default: "20",
94            description: "LowPass (SuperSmoother) length",
95        },
96    ],
97    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/PAIRS%20ROTATION.pdf",
98    formula_latex: r#"
99\[
100Filt = SuperSmoother(HighPass(Price, HPLen), LPLen)
101\]
102\[
103MS = 0.0242 \cdot Filt^2 + 0.9758 \cdot MS_{t-1}
104\]
105\[
106Normalized = \frac{Filt}{\sqrt{MS}}
107\]
108"#,
109    gold_standard_file: "pairs_rotation.json",
110    category: "Ehlers DSP",
111};
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::traits::Next;
117    use proptest::prelude::*;
118
119    #[test]
120    fn test_pairs_rotation_basic() {
121        let mut pr = PairsRotation::new(125, 20);
122        for i in 0..100 {
123            let (n1, n2) = pr.next((100.0 + i as f64, 100.0 - i as f64));
124            assert!(!n1.is_nan());
125            assert!(!n2.is_nan());
126        }
127    }
128
129    proptest! {
130        #[test]
131        fn test_pairs_rotation_parity(
132            inputs1 in prop::collection::vec(1.0..100.0, 100..200),
133            inputs2 in prop::collection::vec(1.0..100.0, 100..200),
134        ) {
135            let hp_len = 125;
136            let lp_len = 20;
137            let mut pr = PairsRotation::new(hp_len, lp_len);
138            
139            let min_len = inputs1.len().min(inputs2.len());
140            let inputs: Vec<(f64, f64)> = inputs1[..min_len].iter().cloned().zip(inputs2[..min_len].iter().cloned()).collect();
141            let streaming_results: Vec<(f64, f64)> = inputs.iter().map(|&x| pr.next(x)).collect();
142
143            // Batch implementation
144            let mut batch_results = Vec::with_capacity(min_len);
145            let mut hp1 = HighPass::new(hp_len);
146            let mut ss1 = SuperSmoother::new(lp_len);
147            let mut hp2 = HighPass::new(hp_len);
148            let mut ss2 = SuperSmoother::new(lp_len);
149            let mut ms1 = 0.0;
150            let mut ms2 = 0.0;
151            let alpha = 0.0242;
152
153            for (i, &(p1, p2)) in inputs.iter().enumerate() {
154                let f1 = ss1.next(hp1.next(p1));
155                let f2 = ss2.next(hp2.next(p2));
156                
157                if i == 0 {
158                    ms1 = f1 * f1;
159                    ms2 = f2 * f2;
160                } else {
161                    ms1 = alpha * f1 * f1 + (1.0 - alpha) * ms1;
162                    ms2 = alpha * f2 * f2 + (1.0 - alpha) * ms2;
163                }
164                
165                let n1 = if ms1 > 0.0 { f1 / ms1.sqrt() } else { 0.0 };
166                let n2 = if ms2 > 0.0 { f2 / ms2.sqrt() } else { 0.0 };
167                batch_results.push((n1, n2));
168            }
169
170            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
171                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-10);
172                approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-10);
173            }
174        }
175    }
176}