use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
use crate::indicators::math::AGC;
use crate::traits::Next;
use std::f64::consts::PI;
#[derive(Debug, Clone)]
pub struct UniversalOscillator {
c1: f64,
c2: f64,
c3: f64,
price_prev1: f64,
price_prev2: f64,
wn_prev1: f64,
filt_history: [f64; 2],
agc: AGC,
count: usize,
}
impl UniversalOscillator {
pub fn new(band_edge: usize) -> Self {
let band_edge_f = band_edge as f64;
let r2 = 2.0f64.sqrt();
let a1 = (-r2 * PI / band_edge_f).exp();
let b1 = 2.0 * a1 * (r2 * PI / band_edge_f).cos();
let c2 = b1;
let c3 = -a1 * a1;
let c1 = 1.0 - c2 - c3;
Self {
c1,
c2,
c3,
price_prev1: 0.0,
price_prev2: 0.0,
wn_prev1: 0.0,
filt_history: [0.0; 2],
agc: AGC::new(0.991),
count: 0,
}
}
}
impl Next<f64> for UniversalOscillator {
type Output = f64;
fn next(&mut self, input: f64) -> Self::Output {
self.count += 1;
if self.count < 3 {
self.price_prev2 = self.price_prev1;
self.price_prev1 = input;
return 0.0;
}
let wn = (input - self.price_prev2) / 2.0;
let white_noise_avg = (wn + self.wn_prev1) / 2.0;
let filt = self.c1 * white_noise_avg
+ self.c2 * self.filt_history[0]
+ self.c3 * self.filt_history[1];
let universal = self.agc.next(filt);
self.filt_history[1] = self.filt_history[0];
self.filt_history[0] = filt;
self.wn_prev1 = wn;
self.price_prev2 = self.price_prev1;
self.price_prev1 = input;
universal
}
}
pub const UNIVERSAL_OSCILLATOR_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "Universal Oscillator",
description: "An adaptive oscillator that normalizes price momentum using a SuperSmoother filter and AGC.",
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.",
keywords: &["oscillator", "ehlers", "dsp", "universal", "momentum"],
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.",
params: &[
ParamDef {
name: "band_edge",
default: "20",
description: "Critical period for the SuperSmoother filter",
},
],
formula_source: "https://www.traders.com/Documentation/FEEDbk_docs/2015/01/TradersTips.html",
formula_latex: r#"
\[
WN = (Price - Price_{t-2}) / 2
\]
\[
AvgWN = (WN + WN_{t-1}) / 2
\]
\[
Filt = c_1 AvgWN + c_2 Filt_{t-1} + c_3 Filt_{t-2}
\]
\[
Peak = \max(0.991 \times Peak_{t-1}, |Filt|)
\]
\[
Universal = Filt / Peak
\]
"#,
gold_standard_file: "universal_oscillator.json",
category: "Ehlers DSP",
};
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::Next;
use proptest::prelude::*;
#[test]
fn test_universal_oscillator_basic() {
let mut uo = UniversalOscillator::new(20);
let prices = vec![10.0, 10.5, 11.0, 11.5, 12.0, 11.0, 10.0];
for p in prices {
let res = uo.next(p);
assert!(res >= -1.0 && res <= 1.0);
}
}
proptest! {
#[test]
fn test_universal_oscillator_parity(
inputs in prop::collection::vec(1.0..100.0, 50..100),
) {
let band_edge = 20;
let mut uo = UniversalOscillator::new(band_edge);
let streaming_results: Vec<f64> = inputs.iter().map(|&x| uo.next(x)).collect();
let mut uo_batch = UniversalOscillator::new(band_edge);
let batch_results: Vec<f64> = inputs.iter().map(|&x| uo_batch.next(x)).collect();
for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
approx::assert_relative_eq!(s, b, epsilon = 1e-10);
}
}
}
}