use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
use crate::traits::Next;
use crate::indicators::hann::HannFilter;
use crate::indicators::high_pass::HighPass;
use crate::indicators::super_smoother::SuperSmoother;
use crate::indicators::ultimate_smoother::UltimateSmoother;
use std::collections::VecDeque;
use std::f64::consts::PI;
#[derive(Debug, Clone)]
pub struct SyntheticOscillator {
hann_price: HannFilter,
hp: HighPass,
ss: SuperSmoother,
lp_window: VecDeque<f64>,
lp_sum_sq: f64,
re_prev: f64,
roc_window: VecDeque<f64>,
roc_sum_sq: f64,
im_prev: f64,
dc_prev: f64,
hp2: HighPass,
us: UltimateSmoother,
bp_prev: f64,
phase: f64,
synth_prev: f64,
lower_bound: f64,
upper_bound: f64,
}
impl SyntheticOscillator {
pub fn new(lower_bound: usize, upper_bound: usize) -> Self {
let mid = ((lower_bound * upper_bound) as f64).sqrt();
Self {
hann_price: HannFilter::new(12),
hp: HighPass::new(upper_bound),
ss: SuperSmoother::new(lower_bound),
lp_window: VecDeque::with_capacity(100),
lp_sum_sq: 0.0,
re_prev: 0.0,
roc_window: VecDeque::with_capacity(100),
roc_sum_sq: 0.0,
im_prev: 0.0,
dc_prev: lower_bound as f64,
hp2: HighPass::new(mid as usize),
us: UltimateSmoother::new(mid as usize),
bp_prev: 0.0,
phase: 0.0,
synth_prev: 0.0,
lower_bound: lower_bound as f64,
upper_bound: upper_bound as f64,
}
}
}
impl Default for SyntheticOscillator {
fn default() -> Self {
Self::new(15, 25)
}
}
impl Next<f64> for SyntheticOscillator {
type Output = f64;
fn next(&mut self, input: f64) -> Self::Output {
let price = self.hann_price.next(input);
let hp = self.hp.next(price);
let lp = self.ss.next(hp);
self.lp_window.push_back(lp);
self.lp_sum_sq += lp * lp;
if self.lp_window.len() > 100 && let Some(old) = self.lp_window.pop_front() {
self.lp_sum_sq -= old * old;
}
let rms_lp = (self.lp_sum_sq / self.lp_window.len() as f64).sqrt();
let re = if rms_lp > 1e-10 { lp / rms_lp } else { 0.0 };
let roc = re - self.re_prev;
self.roc_window.push_back(roc);
self.roc_sum_sq += roc * roc;
if self.roc_window.len() > 100 && let Some(old) = self.roc_window.pop_front() {
self.roc_sum_sq -= old * old;
}
let qrms = (self.roc_sum_sq / self.roc_window.len() as f64).sqrt();
let im = if qrms > 1e-10 { roc / qrms } else { 0.0 };
let denom = (re - self.re_prev) * im - (im - self.im_prev) * re;
let mut dc = if denom.abs() > 1e-10 {
(2.0 * PI * (re * re + im * im)) / denom
} else {
self.dc_prev
};
if dc < self.lower_bound { dc = self.lower_bound; }
if dc > self.upper_bound { dc = self.upper_bound; }
let hp2 = self.hp2.next(input);
let bp = self.us.next(hp2);
self.phase += 2.0 * PI / dc;
if self.bp_prev <= 0.0 && bp > 0.0 {
self.phase = PI / dc;
} else if self.bp_prev >= 0.0 && bp < 0.0 {
self.phase = PI + PI / dc;
}
let mut synth = self.phase.sin();
let norm_phase = self.phase % (2.0 * PI);
if (norm_phase > 0.0 && norm_phase < PI / 2.0 && synth < self.synth_prev) || (norm_phase > PI && norm_phase < 1.5 * PI && synth > self.synth_prev) {
synth = self.synth_prev;
}
self.re_prev = re;
self.im_prev = im;
self.dc_prev = dc;
self.bp_prev = bp;
self.synth_prev = synth;
synth
}
}
pub const SYNTHETIC_OSCILLATOR_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "Synthetic Oscillator",
description: "A nonlinear oscillator designed to reduce lag while maintaining smoothness by adapting to the dominant cycle.",
usage: "Use to construct a synthetic oscillator from dominant cycle sine components when direct price oscillators are too noisy. Most effective in clearly cyclical markets.",
keywords: &["oscillator", "ehlers", "dsp", "cycle", "synthetic"],
ehlers_summary: "Ehlers constructs a Synthetic Oscillator by generating a synthetic sine wave at the measured dominant cycle period and comparing it to price. The phase difference between the synthetic sine and actual price reveals whether the market is ahead of or behind its expected cycle position.",
params: &[
ParamDef { name: "lower_bound", default: "15", description: "Lower bound of cycle period" },
ParamDef { name: "upper_bound", default: "25", description: "Upper bound of cycle period" },
],
formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS’%20TIPS%20-%20APRIL%202026.html",
formula_latex: r#"
\[
Price = \text{Hann}(Close, 12)
\]
\[
LP = \text{SuperSmoother}(\text{HighPass}(Price, UB), LB)
\]
\[
Re = \frac{LP}{RMS(LP, 100)}, \quad Im = \frac{Re - Re_{t-1}}{RMS(Re - Re_{t-1}, 100)}
\]
\[
DC = \frac{2\pi(Re^2 + Im^2)}{(Re - Re_{t-1})Im - (Im - Im_{t-1})Re}
\]
\[
BP = \text{UltimateSmoother}(\text{HighPass}(Close, Mid), Mid)
\]
\[
Phase = Phase_{t-1} + \frac{2\pi}{DC}
\]
\[
Synth = \sin(Phase)
\]
"#,
gold_standard_file: "synthetic_oscillator.json",
category: "Ehlers DSP",
};
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::Next;
use proptest::prelude::*;
#[test]
fn test_synthetic_oscillator_basic() {
let mut so = SyntheticOscillator::default();
let inputs = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
for input in inputs {
let res = so.next(input);
assert!(!res.is_nan());
}
}
proptest! {
#[test]
fn test_synthetic_oscillator_parity(
inputs in prop::collection::vec(1.0..100.0, 200..300),
) {
let lb = 15;
let ub = 25;
let mut so = SyntheticOscillator::new(lb, ub);
let streaming_results: Vec<f64> = inputs.iter().map(|&x| so.next(x)).collect();
let mut batch_results = Vec::with_capacity(inputs.len());
let mut hann = HannFilter::new(12);
let mut hp = HighPass::new(ub);
let mut ss = SuperSmoother::new(lb);
let mut lp_win = VecDeque::new();
let mut lp_sum_sq = 0.0;
let mut re_prev = 0.0;
let mut roc_win = VecDeque::new();
let mut roc_sum_sq = 0.0;
let mut im_prev = 0.0;
let mut dc_prev = lb as f64;
let mid = ((lb * ub) as f64).sqrt();
let mut hp2 = HighPass::new(mid as usize);
let mut us = UltimateSmoother::new(mid as usize);
let mut bp_prev = 0.0;
let mut phase = 0.0;
let mut synth_prev = 0.0;
for &input in &inputs {
let p = hann.next(input);
let h = hp.next(p);
let l = ss.next(h);
lp_win.push_back(l);
lp_sum_sq += l * l;
if lp_win.len() > 100 {
let old = lp_win.pop_front().unwrap();
lp_sum_sq -= old * old;
}
let rms_lp = (lp_sum_sq / lp_win.len() as f64).sqrt();
let re = if rms_lp > 1e-10 { l / rms_lp } else { 0.0 };
let roc = re - re_prev;
roc_win.push_back(roc);
roc_sum_sq += roc * roc;
if roc_win.len() > 100 {
let old = roc_win.pop_front().unwrap();
roc_sum_sq -= old * old;
}
let qrms = (roc_sum_sq / roc_win.len() as f64).sqrt();
let im = if qrms > 1e-10 { roc / qrms } else { 0.0 };
let denom = (re - re_prev) * im - (im - im_prev) * re;
let mut dc = if denom.abs() > 1e-10 {
(2.0 * PI * (re * re + im * im)) / denom
} else {
dc_prev
};
if dc < lb as f64 { dc = lb as f64; }
if dc > ub as f64 { dc = ub as f64; }
let h2 = hp2.next(input);
let bp = us.next(h2);
phase += 2.0 * PI / dc;
if bp_prev <= 0.0 && bp > 0.0 {
phase = PI / dc;
} else if bp_prev >= 0.0 && bp < 0.0 {
phase = PI + PI / dc;
}
let mut synth = phase.sin();
let norm_phase = phase % (2.0 * PI);
if norm_phase > 0.0 && norm_phase < PI / 2.0 && synth < synth_prev {
synth = synth_prev;
} else if norm_phase > PI && norm_phase < 1.5 * PI && synth > synth_prev {
synth = synth_prev;
}
batch_results.push(synth);
re_prev = re;
im_prev = im;
dc_prev = dc;
bp_prev = bp;
synth_prev = synth;
}
for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
approx::assert_relative_eq!(s, b, epsilon = 1e-10);
}
}
}
}