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 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 { 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 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 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}