quantwave-core 0.1.4

A high-performance, Polars-native technical analysis library for Rust.
Documentation
use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
use crate::traits::Next;
use crate::indicators::high_pass::HighPass;
use crate::indicators::super_smoother::SuperSmoother;
use std::collections::VecDeque;

/// Cybernetic Oscillator
///
/// Based on John Ehlers' "Making A Better Oscillator" (TASC June 2025).
/// Combines a HighPass filter and a SuperSmoother, then normalizes by RMS.
#[derive(Debug, Clone)]
pub struct CyberneticOscillator {
    hp: HighPass,
    ss: SuperSmoother,
    rms_window: VecDeque<f64>,
    rms_len: usize,
    sum_sq: f64,
}

impl CyberneticOscillator {
    pub fn new(hp_length: usize, lp_length: usize, rms_len: usize) -> Self {
        Self {
            hp: HighPass::new(hp_length),
            ss: SuperSmoother::new(lp_length),
            rms_window: VecDeque::with_capacity(rms_len),
            rms_len,
            sum_sq: 0.0,
        }
    }
}

impl Default for CyberneticOscillator {
    fn default() -> Self {
        Self::new(30, 20, 100)
    }
}

impl Next<f64> for CyberneticOscillator {
    type Output = f64;

    fn next(&mut self, input: f64) -> Self::Output {
        let hp_val = self.hp.next(input);
        let lp_val = self.ss.next(hp_val);

        // Update RMS
        let val_sq = lp_val * lp_val;
        self.rms_window.push_back(lp_val);
        self.sum_sq += val_sq;

        if self.rms_window.len() > self.rms_len {
            let oldest = self.rms_window.pop_front().unwrap();
            self.sum_sq -= oldest * oldest;
        }

        // Avoid precision issues resulting in small negative sum_sq
        if self.sum_sq < 0.0 {
            self.sum_sq = 0.0;
        }

        let rms = (self.sum_sq / self.rms_len as f64).sqrt();

        if rms != 0.0 {
            lp_val / rms
        } else {
            0.0
        }
    }
}

pub const CYBERNETIC_OSCILLATOR_METADATA: IndicatorMetadata = IndicatorMetadata {
    name: "CyberneticOscillator",
    description: "Combined HighPass and SuperSmoother filters normalized by RMS.",
    params: &[
        ParamDef {
            name: "hp_length",
            default: "30",
            description: "HighPass filter length",
        },
        ParamDef {
            name: "lp_length",
            default: "20",
            description: "LowPass (SuperSmoother) length",
        },
        ParamDef {
            name: "rms_len",
            default: "100",
            description: "RMS normalization length",
        },
    ],
    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS’%20TIPS%20-%20JUNE%202025.html",
    formula_latex: r#"
\[
HP = HighPass(Price, HPLen)
\]
\[
LP = SuperSmoother(HP, LPLen)
\]
\[
RMS = \sqrt{\frac{1}{N} \sum_{i=0}^{N-1} LP_{t-i}^2}
\]
\[
CO = \frac{LP}{RMS}
\]
"#,
    gold_standard_file: "cybernetic_oscillator.json",
    category: "Ehlers DSP",
};

#[cfg(test)]
mod tests {
    use super::*;
    use crate::traits::Next;
    use proptest::prelude::*;

    #[test]
    fn test_cybernetic_oscillator_basic() {
        let mut co = CyberneticOscillator::new(30, 20, 100);
        for i in 0..150 {
            let val = co.next(100.0 + (i as f64).sin());
            assert!(!val.is_nan());
        }
    }

    proptest! {
        #[test]
        fn test_cybernetic_oscillator_parity(
            inputs in prop::collection::vec(1.0..100.0, 150..250),
        ) {
            let hp_len = 30;
            let lp_len = 20;
            let rms_len = 100;
            let mut co = CyberneticOscillator::new(hp_len, lp_len, rms_len);
            let streaming_results: Vec<f64> = inputs.iter().map(|&x| co.next(x)).collect();

            // Batch implementation
            let mut batch_results = Vec::with_capacity(inputs.len());
            
            let mut hp = HighPass::new(hp_len);
            let mut ss = SuperSmoother::new(lp_len);
            let lp_vals: Vec<f64> = inputs.iter().map(|&x| ss.next(hp.next(x))).collect();

            for i in 0..lp_vals.len() {
                let start = if i >= rms_len - 1 { i + 1 - rms_len } else { 0 };
                let window = &lp_vals[start..i + 1];
                
                let mut sum_sq = 0.0;
                for &v in window {
                    sum_sq += v * v;
                }
                
                // Note: The denominator in Ehlers' EasyLanguage is constant (Length)
                // whereas the window may be smaller initially. 
                // But Ehlers' code: $RMS = SquareRoot(SumSq / Length)
                // So we always divide by rms_len.
                let rms = (sum_sq / rms_len as f64).sqrt();
                
                if rms != 0.0 {
                    batch_results.push(lp_vals[i] / rms);
                } else {
                    batch_results.push(0.0);
                }
            }

            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
            }
        }
    }
}