quantwave_core/indicators/
cybernetic_oscillator.rs1use 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#[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 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 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 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 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}