Skip to main content

quantwave_core/indicators/
madh.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::hann::HannFilter;
3use crate::traits::Next;
4
5/// Moving Average Difference with Hann Windowing (MADH)
6///
7/// Based on John Ehlers' "The MADH: The MAD Indicator, Enhanced" (S&C 2021).
8/// It computes the percentage difference between a short-term Hann-windowed FIR filter
9/// and a long-term Hann-windowed FIR filter.
10#[derive(Debug, Clone)]
11pub struct MADH {
12    filter1: HannFilter,
13    filter2: HannFilter,
14    _short_length: usize,
15    _dominant_cycle: usize,
16}
17
18impl MADH {
19    pub fn new(short_length: usize, dominant_cycle: usize) -> Self {
20        let long_length = short_length + dominant_cycle / 2;
21        Self {
22            filter1: HannFilter::new(short_length),
23            filter2: HannFilter::new(long_length),
24            _short_length: short_length,
25            _dominant_cycle: dominant_cycle,
26        }
27    }
28}
29
30impl Default for MADH {
31    fn default() -> Self {
32        Self::new(8, 27)
33    }
34}
35
36impl Next<f64> for MADH {
37    type Output = f64;
38
39    fn next(&mut self, input: f64) -> Self::Output {
40        let f1 = self.filter1.next(input);
41        let f2 = self.filter2.next(input);
42        if f2.abs() > 1e-10 {
43            100.0 * (f1 - f2) / f2
44        } else {
45            0.0
46        }
47    }
48}
49
50pub const MADH_METADATA: IndicatorMetadata = IndicatorMetadata {
51    name: "MADH",
52    description: "Moving Average Difference with Hann Windowing: 100 * (Hann(short) - Hann(long)) / Hann(long)",
53    usage: "Use to measure the volatility of the cyclical price component only, filtering out trend-driven amplitude changes that inflate standard volatility measures in trending markets.",
54    keywords: &["volatility", "statistics", "ehlers", "high-pass"],
55    ehlers_summary: "MADH applies Mean Absolute Deviation to the high-pass filtered price series rather than raw price. By isolating the cyclical component before measuring dispersion, it quantifies the noise level within the current market cycle rather than conflating it with trend amplitude.",
56    params: &[
57        ParamDef {
58            name: "short_length",
59            default: "8",
60            description: "Short-term filter length",
61        },
62        ParamDef {
63            name: "dominant_cycle",
64            default: "27",
65            description: "Dominant cycle for calculating long length",
66        },
67    ],
68    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS’ TIPS - NOVEMBER 2021.html",
69    formula_latex: r#"
70\[
71LongLength = \lfloor ShortLength + DominantCycle / 2 \rfloor
72\]
73\[
74Filt1 = HannWindow(Price, ShortLength)
75\]
76\[
77Filt2 = HannWindow(Price, LongLength)
78\]
79\[
80MADH = 100 \times \frac{Filt1 - Filt2}{Filt2}
81\]
82"#,
83    gold_standard_file: "madh.json",
84    category: "Ehlers DSP",
85};
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::traits::Next;
91    use proptest::prelude::*;
92    use std::f64::consts::PI;
93
94    #[test]
95    fn test_madh_basic() {
96        let mut madh = MADH::new(8, 27);
97        let inputs = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
98        for input in inputs {
99            let res = madh.next(input);
100            assert!(!res.is_nan());
101        }
102    }
103
104    proptest! {
105        #[test]
106        fn test_madh_parity(
107            inputs in prop::collection::vec(1.0..100.0, 50..100),
108        ) {
109            let short = 8;
110            let dc = 27;
111            let long = short + dc / 2;
112            let mut madh = MADH::new(short, dc);
113            let streaming_results: Vec<f64> = inputs.iter().map(|&x| madh.next(x)).collect();
114
115            // Reference implementation
116            let mut batch_results = Vec::with_capacity(inputs.len());
117            
118            let mut coeffs1 = Vec::new();
119            let mut sum1 = 0.0;
120            for count in 1..=short {
121                let c = 1.0 - (2.0 * PI * count as f64 / (short as f64 + 1.0)).cos();
122                coeffs1.push(c);
123                sum1 += c;
124            }
125            
126            let mut coeffs2 = Vec::new();
127            let mut sum2 = 0.0;
128            for count in 1..=long {
129                let c = 1.0 - (2.0 * PI * count as f64 / (long as f64 + 1.0)).cos();
130                coeffs2.push(c);
131                sum2 += c;
132            }
133
134            for i in 0..inputs.len() {
135                let f1 = if i < short - 1 {
136                    inputs[i]
137                } else {
138                    let mut sum = 0.0;
139                    for j in 0..short {
140                        sum += coeffs1[j] * inputs[i - j];
141                    }
142                    sum / sum1
143                };
144
145                let f2 = if i < long - 1 {
146                    inputs[i]
147                } else {
148                    let mut sum = 0.0;
149                    for j in 0..long {
150                        sum += coeffs2[j] * inputs[i - j];
151                    }
152                    sum / sum2
153                };
154
155                let res = if f2.abs() > 1e-10 {
156                    100.0 * (f1 - f2) / f2
157                } else {
158                    0.0
159                };
160                batch_results.push(res);
161            }
162
163            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
164                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
165            }
166        }
167    }
168}