quantwave_core/indicators/
cybernetic_oscillator.rs1use 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#[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 oldest = self.rms_window.pop_front().unwrap();
52 self.sum_sq -= oldest * oldest;
53 }
54
55 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 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 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}