use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
use crate::traits::Next;
use std::collections::VecDeque;
use std::f64::consts::PI;
#[derive(Debug, Clone)]
pub struct AMDetector {
highest_len: usize,
avg_len: usize,
deriv_history: VecDeque<f64>,
envelope_history: VecDeque<f64>,
sum_envelope: f64,
}
impl AMDetector {
pub fn new(highest_len: usize, avg_len: usize) -> Self {
Self {
highest_len,
avg_len,
deriv_history: VecDeque::with_capacity(highest_len),
envelope_history: VecDeque::with_capacity(avg_len),
sum_envelope: 0.0,
}
}
}
impl Next<(f64, f64)> for AMDetector {
type Output = f64;
fn next(&mut self, (close, open): (f64, f64)) -> Self::Output {
let deriv = (close - open).abs();
self.deriv_history.push_front(deriv);
if self.deriv_history.len() > self.highest_len {
self.deriv_history.pop_back();
}
let envelope = self.deriv_history.iter().fold(f64::MIN, |a, &b| a.max(b));
self.envelope_history.push_back(envelope);
self.sum_envelope += envelope;
if self.envelope_history.len() > self.avg_len && let Some(old) = self.envelope_history.pop_front() {
self.sum_envelope -= old;
}
self.sum_envelope / self.envelope_history.len() as f64
}
}
#[derive(Debug, Clone)]
pub struct FMDemodulator {
_period: usize,
c1: f64,
c2: f64,
c3: f64,
hl_prev: f64,
ss_history: [f64; 2],
count: usize,
}
impl FMDemodulator {
pub fn new(period: usize) -> Self {
let a1 = (-1.414 * PI / period as f64).exp();
let c2 = 2.0 * a1 * (1.414 * PI / period as f64).cos();
let c3 = -a1 * a1;
let c1 = 1.0 - c2 - c3;
Self {
_period: period,
c1,
c2,
c3,
hl_prev: 0.0,
ss_history: [0.0; 2],
count: 0,
}
}
}
impl Next<(f64, f64)> for FMDemodulator {
type Output = f64;
fn next(&mut self, (close, open): (f64, f64)) -> Self::Output {
self.count += 1;
let deriv = close - open;
let hl = (10.0 * deriv).clamp(-1.0, 1.0);
let ss = if self.count < 3 {
deriv
} else {
self.c1 * (hl + self.hl_prev) / 2.0
+ self.c2 * self.ss_history[0]
+ self.c3 * self.ss_history[1]
};
self.ss_history[1] = self.ss_history[0];
self.ss_history[0] = ss;
self.hl_prev = hl;
ss
}
}
pub const AM_DETECTOR_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "AM Detector",
description: "Recovers market volatility from the amplitude-modulated whitened price spectrum.",
usage: "Use to extract the instantaneous amplitude and frequency of market cycles. The AM output measures cycle energy for position sizing; the FM output tracks cycle period for adaptive indicator tuning.",
keywords: &["cycle", "ehlers", "dsp", "amplitude", "frequency"],
ehlers_summary: "Ehlers adapts AM and FM demodulation techniques from radio engineering in Cycle Analytics for Traders to extract cycle amplitude and instantaneous frequency from market data. The amplitude envelope measures how energetic the current cycle is, while FM reveals whether the cycle period is expanding or contracting.",
params: &[
ParamDef { name: "highest_len", default: "4", description: "Envelope lookback length" },
ParamDef { name: "avg_len", default: "8", description: "Smoothing length" },
],
formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/AMFM.pdf",
formula_latex: r#"
\[
Deriv = |Close - Open|, Envel = \max(Deriv, 4), Volatil = \text{Avg}(Envel, 8)
\]
"#,
gold_standard_file: "am_detector.json",
category: "Ehlers DSP",
};
pub const FM_DEMODULATOR_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "FM Demodulator",
description: "Extracts market timing information by demodulating the frequency-modulated price spectrum.",
usage: "Use to extract the instantaneous amplitude and frequency of market cycles. The AM output measures cycle energy for position sizing; the FM output tracks cycle period for adaptive indicator tuning.",
keywords: &["cycle", "ehlers", "dsp", "amplitude", "frequency"],
ehlers_summary: "Ehlers adapts AM and FM demodulation techniques from radio engineering in Cycle Analytics for Traders to extract cycle amplitude and instantaneous frequency from market data. The amplitude envelope measures how energetic the current cycle is, while FM reveals whether the cycle period is expanding or contracting.",
params: &[
ParamDef { name: "period", default: "30", description: "SuperSmoother period" },
],
formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/AMFM.pdf",
formula_latex: r#"
\[
Deriv = Close - Open, HL = \text{Clip}(10 \times Deriv, -1, 1)
\]
\[
SS = c_1 \frac{HL + HL_{t-1}}{2} + c_2 SS_{t-1} + c_3 SS_{t-2}
\]
"#,
gold_standard_file: "fm_demodulator.json",
category: "Ehlers DSP",
};
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::Next;
use proptest::prelude::*;
#[test]
fn test_am_detector_basic() {
let mut am = AMDetector::new(4, 8);
let inputs = vec![(10.0, 9.0), (11.0, 10.0), (12.0, 11.0)];
for input in inputs {
let res = am.next(input);
assert!(res >= 0.0);
}
}
#[test]
fn test_fm_demodulator_basic() {
let mut fm = FMDemodulator::new(30);
let inputs = vec![(10.0, 9.0), (11.0, 10.0), (12.0, 11.0)];
for input in inputs {
let res = fm.next(input);
assert!(!res.is_nan());
}
}
proptest! {
#[test]
fn test_am_detector_parity(
closes in prop::collection::vec(1.0..100.0, 50..100),
opens in prop::collection::vec(1.0..100.0, 50..100),
) {
let h_len = 4;
let a_len = 8;
let mut am = AMDetector::new(h_len, a_len);
let inputs: Vec<(f64, f64)> = closes.iter().zip(opens.iter()).map(|(&c, &o)| (c, o)).collect();
let streaming_results: Vec<f64> = inputs.iter().map(|&x| am.next(x)).collect();
let mut batch_results = Vec::with_capacity(inputs.len());
let mut envelope_hist = VecDeque::new();
let mut sum_env = 0.0;
for i in 0..inputs.len() {
let start = if i >= h_len { i + 1 - h_len } else { 0 };
let mut max_deriv = f64::MIN;
for j in start..=i {
let deriv = (inputs[j].0 - inputs[j].1).abs();
if deriv > max_deriv { max_deriv = deriv; }
}
envelope_hist.push_back(max_deriv);
sum_env += max_deriv;
if envelope_hist.len() > a_len {
if let Some(old) = envelope_hist.pop_front() {
sum_env -= old;
}
}
batch_results.push(sum_env / envelope_hist.len() as f64);
}
for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
approx::assert_relative_eq!(s, b, epsilon = 1e-10);
}
}
#[test]
fn test_fm_demodulator_parity(
closes in prop::collection::vec(1.0..100.0, 50..100),
opens in prop::collection::vec(1.0..100.0, 50..100),
) {
let period = 30;
let mut fm = FMDemodulator::new(period);
let inputs: Vec<(f64, f64)> = closes.iter().zip(opens.iter()).map(|(&c, &o)| (c, o)).collect();
let streaming_results: Vec<f64> = inputs.iter().map(|&x| fm.next(x)).collect();
let mut batch_results = Vec::with_capacity(inputs.len());
let a1 = (-1.414 * PI / period as f64).exp();
let c2 = 2.0 * a1 * (1.414 * PI / period as f64).cos();
let c3 = -a1 * a1;
let c1 = 1.0 - c2 - c3;
let mut hl_prev = 0.0;
let mut ss_hist = [0.0; 2];
for (i, &input) in inputs.iter().enumerate() {
let deriv = input.0 - input.1;
let mut hl = 10.0 * deriv;
if hl > 1.0 { hl = 1.0; }
if hl < -1.0 { hl = -1.0; }
let res = if i + 1 < 3 {
deriv
} else {
c1 * (hl + hl_prev) / 2.0 + c2 * ss_hist[0] + c3 * ss_hist[1]
};
ss_hist[1] = ss_hist[0];
ss_hist[0] = res;
hl_prev = hl;
batch_results.push(res);
}
for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
approx::assert_relative_eq!(s, b, epsilon = 1e-10);
}
}
}
}