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