quantwave_core/indicators/
amfm.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use crate::utils::RingBuffer as VecDeque;
4use std::f64::consts::PI;
5
6#[derive(Debug, Clone)]
12pub struct AMDetector {
13 highest_len: usize,
14 avg_len: usize,
15 deriv_history: VecDeque<f64>,
16 envelope_history: VecDeque<f64>,
17 sum_envelope: f64,
18}
19
20impl AMDetector {
21 pub fn new(highest_len: usize, avg_len: usize) -> Self {
22 Self {
23 highest_len,
24 avg_len,
25 deriv_history: VecDeque::with_capacity(highest_len),
26 envelope_history: VecDeque::with_capacity(20),
27 sum_envelope: 0.0,
28 }
29 }
30}
31
32impl Next<(f64, f64)> for AMDetector {
33 type Output = f64;
34
35 fn next(&mut self, (close, open): (f64, f64)) -> Self::Output {
36 let deriv = (close - open).abs();
37 self.deriv_history.push_front(deriv);
38 if self.deriv_history.len() > self.highest_len {
39 self.deriv_history.pop_back();
40 }
41
42 let envelope = self.deriv_history.iter().fold(f64::MIN, |a, &b| a.max(b));
43
44 self.envelope_history.push_back(envelope);
45 self.sum_envelope += envelope;
46 if self.envelope_history.len() > self.avg_len
47 && let Some(old) = self.envelope_history.pop_front()
48 {
49 self.sum_envelope -= old;
50 }
51
52 self.sum_envelope / self.envelope_history.len() as f64
53 }
54}
55
56#[derive(Debug, Clone)]
62pub struct FMDemodulator {
63 _period: usize,
64 c1: f64,
65 c2: f64,
66 c3: f64,
67 hl_prev: f64,
68 ss_history: [f64; 2],
69 count: usize,
70}
71
72impl FMDemodulator {
73 pub fn new(period: usize) -> Self {
74 let a1 = (-1.414 * PI / period as f64).exp();
75 let c2 = 2.0 * a1 * (1.414 * PI / period as f64).cos();
76 let c3 = -a1 * a1;
77 let c1 = 1.0 - c2 - c3;
78
79 Self {
80 _period: period,
81 c1,
82 c2,
83 c3,
84 hl_prev: 0.0,
85 ss_history: [0.0; 2],
86 count: 0,
87 }
88 }
89}
90
91impl Next<(f64, f64)> for FMDemodulator {
92 type Output = f64;
93
94 fn next(&mut self, (close, open): (f64, f64)) -> Self::Output {
95 self.count += 1;
96 let deriv = close - open;
97 let hl = (10.0 * deriv).clamp(-1.0, 1.0);
98
99 let ss = if self.count < 3 {
100 deriv
101 } else {
102 self.c1 * (hl + self.hl_prev) / 2.0
103 + self.c2 * self.ss_history[0]
104 + self.c3 * self.ss_history[1]
105 };
106
107 self.ss_history[1] = self.ss_history[0];
108 self.ss_history[0] = ss;
109 self.hl_prev = hl;
110
111 ss
112 }
113}
114
115pub const AM_DETECTOR_METADATA: IndicatorMetadata = IndicatorMetadata {
116 name: "AM Detector",
117 description: "Recovers market volatility from the amplitude-modulated whitened price spectrum.",
118 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.",
119 keywords: &["cycle", "ehlers", "dsp", "amplitude", "frequency"],
120 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.",
121 params: &[
122 ParamDef {
123 name: "highest_len",
124 default: "4",
125 description: "Envelope lookback length",
126 },
127 ParamDef {
128 name: "avg_len",
129 default: "8",
130 description: "Smoothing length",
131 },
132 ],
133 formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/AMFM.pdf",
134 formula_latex: r#"
135\[
136Deriv = |Close - Open|, Envel = \max(Deriv, 4), Volatil = \text{Avg}(Envel, 8)
137\]
138"#,
139 gold_standard_file: "am_detector.json",
140 category: "Ehlers DSP",
141};
142
143pub const FM_DEMODULATOR_METADATA: IndicatorMetadata = IndicatorMetadata {
144 name: "FM Demodulator",
145 description: "Extracts market timing information by demodulating the frequency-modulated price spectrum.",
146 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.",
147 keywords: &["cycle", "ehlers", "dsp", "amplitude", "frequency"],
148 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.",
149 params: &[ParamDef {
150 name: "period",
151 default: "30",
152 description: "SuperSmoother period",
153 }],
154 formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/AMFM.pdf",
155 formula_latex: r#"
156\[
157Deriv = Close - Open, HL = \text{Clip}(10 \times Deriv, -1, 1)
158\]
159\[
160SS = c_1 \frac{HL + HL_{t-1}}{2} + c_2 SS_{t-1} + c_3 SS_{t-2}
161\]
162"#,
163 gold_standard_file: "fm_demodulator.json",
164 category: "Ehlers DSP",
165};
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::traits::Next;
171 use proptest::prelude::*;
172
173 #[test]
174 fn test_am_detector_basic() {
175 let mut am = AMDetector::new(4, 8);
176 let inputs = vec![(10.0, 9.0), (11.0, 10.0), (12.0, 11.0)];
177 for input in inputs {
178 let res = am.next(input);
179 assert!(res >= 0.0);
180 }
181 }
182
183 #[test]
184 fn test_fm_demodulator_basic() {
185 let mut fm = FMDemodulator::new(30);
186 let inputs = vec![(10.0, 9.0), (11.0, 10.0), (12.0, 11.0)];
187 for input in inputs {
188 let res = fm.next(input);
189 assert!(!res.is_nan());
190 }
191 }
192
193 proptest! {
194 #[test]
195 fn test_am_detector_parity(
196 closes in prop::collection::vec(1.0..100.0, 50..100),
197 opens in prop::collection::vec(1.0..100.0, 50..100),
198 ) {
199 let h_len = 4;
200 let a_len = 8;
201 let mut am = AMDetector::new(h_len, a_len);
202 let inputs: Vec<(f64, f64)> = closes.iter().zip(opens.iter()).map(|(&c, &o)| (c, o)).collect();
203 let streaming_results: Vec<f64> = inputs.iter().map(|&x| am.next(x)).collect();
204
205 let mut batch_results = Vec::with_capacity(inputs.len());
207 let mut envelope_hist = VecDeque::with_capacity(20);
208 let mut sum_env = 0.0;
209
210 for i in 0..inputs.len() {
211 let start = if i >= h_len { i + 1 - h_len } else { 0 };
212 let mut max_deriv = f64::MIN;
213 for j in start..=i {
214 let deriv = (inputs[j].0 - inputs[j].1).abs();
215 if deriv > max_deriv { max_deriv = deriv; }
216 }
217
218 envelope_hist.push_back(max_deriv);
219 sum_env += max_deriv;
220 if envelope_hist.len() > a_len {
221 if let Some(old) = envelope_hist.pop_front() {
222 sum_env -= old;
223 }
224 }
225 batch_results.push(sum_env / envelope_hist.len() as f64);
226 }
227
228 for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
229 approx::assert_relative_eq!(s, b, epsilon = 1e-10);
230 }
231 }
232
233 #[test]
234 fn test_fm_demodulator_parity(
235 closes in prop::collection::vec(1.0..100.0, 50..100),
236 opens in prop::collection::vec(1.0..100.0, 50..100),
237 ) {
238 let period = 30;
239 let mut fm = FMDemodulator::new(period);
240 let inputs: Vec<(f64, f64)> = closes.iter().zip(opens.iter()).map(|(&c, &o)| (c, o)).collect();
241 let streaming_results: Vec<f64> = inputs.iter().map(|&x| fm.next(x)).collect();
242
243 let mut batch_results = Vec::with_capacity(inputs.len());
245 let a1 = (-1.414 * PI / period as f64).exp();
246 let c2 = 2.0 * a1 * (1.414 * PI / period as f64).cos();
247 let c3 = -a1 * a1;
248 let c1 = 1.0 - c2 - c3;
249
250 let mut hl_prev = 0.0;
251 let mut ss_hist = [0.0; 2];
252
253 for (i, &input) in inputs.iter().enumerate() {
254 let deriv = input.0 - input.1;
255 let mut hl = 10.0 * deriv;
256 if hl > 1.0 { hl = 1.0; }
257 if hl < -1.0 { hl = -1.0; }
258
259 let res = if i + 1 < 3 {
260 deriv
261 } else {
262 c1 * (hl + hl_prev) / 2.0 + c2 * ss_hist[0] + c3 * ss_hist[1]
263 };
264
265 ss_hist[1] = ss_hist[0];
266 ss_hist[0] = res;
267 hl_prev = hl;
268 batch_results.push(res);
269 }
270
271 for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
272 approx::assert_relative_eq!(s, b, epsilon = 1e-10);
273 }
274 }
275 }
276}