Skip to main content

quantwave_core/indicators/
cybernetic_oscillator.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use crate::indicators::high_pass::HighPass;
4use crate::indicators::super_smoother::SuperSmoother;
5use std::collections::VecDeque;
6
7/// Cybernetic Oscillator
8///
9/// Based on John Ehlers' "Making A Better Oscillator" (TASC June 2025).
10/// Combines a HighPass filter and a SuperSmoother, then normalizes by RMS.
11#[derive(Debug, Clone)]
12pub struct CyberneticOscillator {
13    hp: HighPass,
14    ss: SuperSmoother,
15    rms_window: VecDeque<f64>,
16    rms_len: usize,
17    sum_sq: f64,
18}
19
20impl CyberneticOscillator {
21    pub fn new(hp_length: usize, lp_length: usize, rms_len: usize) -> Self {
22        Self {
23            hp: HighPass::new(hp_length),
24            ss: SuperSmoother::new(lp_length),
25            rms_window: VecDeque::with_capacity(rms_len),
26            rms_len,
27            sum_sq: 0.0,
28        }
29    }
30}
31
32impl Default for CyberneticOscillator {
33    fn default() -> Self {
34        Self::new(30, 20, 100)
35    }
36}
37
38impl Next<f64> for CyberneticOscillator {
39    type Output = f64;
40
41    fn next(&mut self, input: f64) -> Self::Output {
42        let hp_val = self.hp.next(input);
43        let lp_val = self.ss.next(hp_val);
44
45        // Update RMS
46        let val_sq = lp_val * lp_val;
47        self.rms_window.push_back(lp_val);
48        self.sum_sq += val_sq;
49
50        if self.rms_window.len() > self.rms_len {
51            let oldest = self.rms_window.pop_front().unwrap();
52            self.sum_sq -= oldest * oldest;
53        }
54
55        // Avoid precision issues resulting in small negative sum_sq
56        if self.sum_sq < 0.0 {
57            self.sum_sq = 0.0;
58        }
59
60        let rms = (self.sum_sq / self.rms_len as f64).sqrt();
61
62        if rms != 0.0 {
63            lp_val / rms
64        } else {
65            0.0
66        }
67    }
68}
69
70pub const CYBERNETIC_OSCILLATOR_METADATA: IndicatorMetadata = IndicatorMetadata {
71    name: "CyberneticOscillator",
72    description: "Combined HighPass and SuperSmoother filters normalized by RMS.",
73    usage: "Use as a generalized Ehlers cycle oscillator when you need a configurable bandpass response tuned to a specific dominant cycle period.",
74    keywords: &["oscillator", "ehlers", "dsp", "cycle", "momentum"],
75    ehlers_summary: "The Cybernetic Oscillator is derived from the bandpass filter framework in Ehlers Cybernetic Analysis for Stocks and Futures (2004). By tuning the filter center frequency to the measured dominant cycle period, it extracts only the cyclical component and presents it as an oscillator ranging above and below zero.",
76    params: &[
77        ParamDef {
78            name: "hp_length",
79            default: "30",
80            description: "HighPass filter length",
81        },
82        ParamDef {
83            name: "lp_length",
84            default: "20",
85            description: "LowPass (SuperSmoother) length",
86        },
87        ParamDef {
88            name: "rms_len",
89            default: "100",
90            description: "RMS normalization length",
91        },
92    ],
93    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS’%20TIPS%20-%20JUNE%202025.html",
94    formula_latex: r#"
95\[
96HP = HighPass(Price, HPLen)
97\]
98\[
99LP = SuperSmoother(HP, LPLen)
100\]
101\[
102RMS = \sqrt{\frac{1}{N} \sum_{i=0}^{N-1} LP_{t-i}^2}
103\]
104\[
105CO = \frac{LP}{RMS}
106\]
107"#,
108    gold_standard_file: "cybernetic_oscillator.json",
109    category: "Ehlers DSP",
110};
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::traits::Next;
116    use proptest::prelude::*;
117
118    #[test]
119    fn test_cybernetic_oscillator_basic() {
120        let mut co = CyberneticOscillator::new(30, 20, 100);
121        for i in 0..150 {
122            let val = co.next(100.0 + (i as f64).sin());
123            assert!(!val.is_nan());
124        }
125    }
126
127    proptest! {
128        #[test]
129        fn test_cybernetic_oscillator_parity(
130            inputs in prop::collection::vec(1.0..100.0, 150..250),
131        ) {
132            let hp_len = 30;
133            let lp_len = 20;
134            let rms_len = 100;
135            let mut co = CyberneticOscillator::new(hp_len, lp_len, rms_len);
136            let streaming_results: Vec<f64> = inputs.iter().map(|&x| co.next(x)).collect();
137
138            // Batch implementation
139            let mut batch_results = Vec::with_capacity(inputs.len());
140            
141            let mut hp = HighPass::new(hp_len);
142            let mut ss = SuperSmoother::new(lp_len);
143            let lp_vals: Vec<f64> = inputs.iter().map(|&x| ss.next(hp.next(x))).collect();
144
145            for i in 0..lp_vals.len() {
146                let start = if i >= rms_len - 1 { i + 1 - rms_len } else { 0 };
147                let window = &lp_vals[start..i + 1];
148                
149                let mut sum_sq = 0.0;
150                for &v in window {
151                    sum_sq += v * v;
152                }
153                
154                // Note: The denominator in Ehlers' EasyLanguage is constant (Length)
155                // whereas the window may be smaller initially. 
156                // But Ehlers' code: $RMS = SquareRoot(SumSq / Length)
157                // So we always divide by rms_len.
158                let rms = (sum_sq / rms_len as f64).sqrt();
159                
160                if rms != 0.0 {
161                    batch_results.push(lp_vals[i] / rms);
162                } else {
163                    batch_results.push(0.0);
164                }
165            }
166
167            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
168                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
169            }
170        }
171    }
172}