Skip to main content

quantwave_core/indicators/
cybernetic_oscillator.rs

1use crate::indicators::high_pass::HighPass;
2use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
3use crate::indicators::super_smoother::SuperSmoother;
4use crate::traits::Next;
5use crate::utils::RingBuffer as 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 Some(oldest) = self.rms_window.pop_front()
52        {
53            self.sum_sq -= oldest * oldest;
54        }
55
56        // Avoid precision issues resulting in small negative sum_sq
57        if self.sum_sq < 0.0 {
58            self.sum_sq = 0.0;
59        }
60
61        let rms = (self.sum_sq / self.rms_len as f64).sqrt();
62
63        if rms != 0.0 { lp_val / rms } else { 0.0 }
64    }
65}
66
67pub const CYBERNETIC_OSCILLATOR_METADATA: IndicatorMetadata = IndicatorMetadata {
68    name: "CyberneticOscillator",
69    description: "Combined HighPass and SuperSmoother filters normalized by RMS.",
70    usage: "Use as a generalized Ehlers cycle oscillator when you need a configurable bandpass response tuned to a specific dominant cycle period.",
71    keywords: &["oscillator", "ehlers", "dsp", "cycle", "momentum"],
72    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.",
73    params: &[
74        ParamDef {
75            name: "hp_length",
76            default: "30",
77            description: "HighPass filter length",
78        },
79        ParamDef {
80            name: "lp_length",
81            default: "20",
82            description: "LowPass (SuperSmoother) length",
83        },
84        ParamDef {
85            name: "rms_len",
86            default: "100",
87            description: "RMS normalization length",
88        },
89    ],
90    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS’%20TIPS%20-%20JUNE%202025.html",
91    formula_latex: r#"
92\[
93HP = HighPass(Price, HPLen)
94\]
95\[
96LP = SuperSmoother(HP, LPLen)
97\]
98\[
99RMS = \sqrt{\frac{1}{N} \sum_{i=0}^{N-1} LP_{t-i}^2}
100\]
101\[
102CO = \frac{LP}{RMS}
103\]
104"#,
105    gold_standard_file: "cybernetic_oscillator.json",
106    category: "Ehlers DSP",
107};
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::traits::Next;
113    use proptest::prelude::*;
114
115    #[test]
116    fn test_cybernetic_oscillator_basic() {
117        let mut co = CyberneticOscillator::new(30, 20, 100);
118        for i in 0..150 {
119            let val = co.next(100.0 + (i as f64).sin());
120            assert!(!val.is_nan());
121        }
122    }
123
124    proptest! {
125        #[test]
126        fn test_cybernetic_oscillator_parity(
127            inputs in prop::collection::vec(1.0..100.0, 150..250),
128        ) {
129            let hp_len = 30;
130            let lp_len = 20;
131            let rms_len = 100;
132            let mut co = CyberneticOscillator::new(hp_len, lp_len, rms_len);
133            let streaming_results: Vec<f64> = inputs.iter().map(|&x| co.next(x)).collect();
134
135            // Batch implementation
136            let mut batch_results = Vec::with_capacity(inputs.len());
137
138            let mut hp = HighPass::new(hp_len);
139            let mut ss = SuperSmoother::new(lp_len);
140            let lp_vals: Vec<f64> = inputs.iter().map(|&x| ss.next(hp.next(x))).collect();
141
142            for i in 0..lp_vals.len() {
143                let start = if i >= rms_len - 1 { i + 1 - rms_len } else { 0 };
144                let window = &lp_vals[start..i + 1];
145
146                let mut sum_sq = 0.0;
147                for &v in window {
148                    sum_sq += v * v;
149                }
150
151                // Note: The denominator in Ehlers' EasyLanguage is constant (Length)
152                // whereas the window may be smaller initially.
153                // But Ehlers' code: $RMS = SquareRoot(SumSq / Length)
154                // So we always divide by rms_len.
155                let rms = (sum_sq / rms_len as f64).sqrt();
156
157                if rms != 0.0 {
158                    batch_results.push(lp_vals[i] / rms);
159                } else {
160                    batch_results.push(0.0);
161                }
162            }
163
164            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
165                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
166            }
167        }
168    }
169}