Skip to main content

quantwave_core/indicators/
universal_oscillator.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::math::AGC;
3use crate::traits::Next;
4use std::f64::consts::PI;
5
6/// Universal Oscillator
7///
8/// Based on John Ehlers' "Whiter Is Brighter" (2015).
9/// It is an evolution of the SuperSmoother filter applied to 2-day price momentum,
10/// with built-in Automatic Gain Control (AGC) for normalization between -1 and +1.
11#[derive(Debug, Clone)]
12pub struct UniversalOscillator {
13    c1: f64,
14    c2: f64,
15    c3: f64,
16    
17    price_prev1: f64,
18    price_prev2: f64,
19    
20    wn_prev1: f64,
21    
22    filt_history: [f64; 2],
23    agc: AGC,
24    
25    count: usize,
26}
27
28impl UniversalOscillator {
29    pub fn new(band_edge: usize) -> Self {
30        let band_edge_f = band_edge as f64;
31        let r2 = 2.0f64.sqrt();
32        let a1 = (-r2 * PI / band_edge_f).exp();
33        let b1 = 2.0 * a1 * (r2 * PI / band_edge_f).cos();
34        let c2 = b1;
35        let c3 = -a1 * a1;
36        let c1 = 1.0 - c2 - c3;
37
38        Self {
39            c1,
40            c2,
41            c3,
42            price_prev1: 0.0,
43            price_prev2: 0.0,
44            wn_prev1: 0.0,
45            filt_history: [0.0; 2],
46            agc: AGC::new(0.991),
47            count: 0,
48        }
49    }
50}
51
52impl Next<f64> for UniversalOscillator {
53    type Output = f64;
54
55    fn next(&mut self, input: f64) -> Self::Output {
56        self.count += 1;
57
58        if self.count < 3 {
59            self.price_prev2 = self.price_prev1;
60            self.price_prev1 = input;
61            return 0.0;
62        }
63
64        // WhiteNoise = ( Close - Close[2] ) / 2;
65        let wn = (input - self.price_prev2) / 2.0;
66        
67        // input = ( WhiteNoise + WhiteNoise[1] ) / 2;
68        let white_noise_avg = (wn + self.wn_prev1) / 2.0;
69
70        // Filt = c1 * input + c2 * Filt[1] + c3 * Filt[2];
71        let filt = self.c1 * white_noise_avg
72            + self.c2 * self.filt_history[0]
73            + self.c3 * self.filt_history[1];
74
75        // Apply AGC
76        let universal = self.agc.next(filt);
77
78        // Update history
79        self.filt_history[1] = self.filt_history[0];
80        self.filt_history[0] = filt;
81        self.wn_prev1 = wn;
82        self.price_prev2 = self.price_prev1;
83        self.price_prev1 = input;
84
85        universal
86    }
87}
88
89pub const UNIVERSAL_OSCILLATOR_METADATA: IndicatorMetadata = IndicatorMetadata {
90    name: "Universal Oscillator",
91    description: "An adaptive oscillator that normalizes price momentum using a SuperSmoother filter and AGC.",
92    usage: "Use as a generic oscillator framework that works on any pre-filtered input. Feed it the output of any smoother or filter to produce a normalized zero-centered oscillator.",
93    keywords: &["oscillator", "ehlers", "dsp", "universal", "momentum"],
94    ehlers_summary: "Ehlers Universal Oscillator is a generic momentum computation that can be applied to any filtered price input. It computes the rate of change of the filtered series normalized by its RMS amplitude, producing a consistently scaled oscillator that works regardless of the underlying filter or price instrument.",
95    params: &[
96        ParamDef {
97            name: "band_edge",
98            default: "20",
99            description: "Critical period for the SuperSmoother filter",
100        },
101    ],
102    formula_source: "https://www.traders.com/Documentation/FEEDbk_docs/2015/01/TradersTips.html",
103    formula_latex: r#"
104\[
105WN = (Price - Price_{t-2}) / 2
106\]
107\[
108AvgWN = (WN + WN_{t-1}) / 2
109\]
110\[
111Filt = c_1 AvgWN + c_2 Filt_{t-1} + c_3 Filt_{t-2}
112\]
113\[
114Peak = \max(0.991 \times Peak_{t-1}, |Filt|)
115\]
116\[
117Universal = Filt / Peak
118\]
119"#,
120    gold_standard_file: "universal_oscillator.json",
121    category: "Ehlers DSP",
122};
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::traits::Next;
128    use proptest::prelude::*;
129
130    #[test]
131    fn test_universal_oscillator_basic() {
132        let mut uo = UniversalOscillator::new(20);
133        let prices = vec![10.0, 10.5, 11.0, 11.5, 12.0, 11.0, 10.0];
134        for p in prices {
135            let res = uo.next(p);
136            assert!(res >= -1.0 && res <= 1.0);
137        }
138    }
139
140    proptest! {
141        #[test]
142        fn test_universal_oscillator_parity(
143            inputs in prop::collection::vec(1.0..100.0, 50..100),
144        ) {
145            let band_edge = 20;
146            let mut uo = UniversalOscillator::new(band_edge);
147            let streaming_results: Vec<f64> = inputs.iter().map(|&x| uo.next(x)).collect();
148
149            // Reference implementation (batch)
150            let mut uo_batch = UniversalOscillator::new(band_edge);
151            let batch_results: Vec<f64> = inputs.iter().map(|&x| uo_batch.next(x)).collect();
152
153            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
154                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
155            }
156        }
157    }
158}