Skip to main content

wickra_core/indicators/
macd_fix.rs

1//! MACD with fixed 12/26 periods (MACDFIX).
2
3use crate::error::Result;
4use crate::indicators::macd::{MacdIndicator, MacdOutput};
5use crate::traits::Indicator;
6
7/// MACD Fix (`MACDFIX`): the classic MACD with the fast and slow EMAs fixed at
8/// 12 and 26, leaving only the signal period configurable.
9///
10/// This is TA-Lib's `MACDFIX`: the 12- and 26-bar EMAs smooth with Gerald
11/// Appel's rounded constants `0.15` and `0.075` instead of `2 / 13` and
12/// `2 / 27` (both still seeded with their simple means), so it differs slightly
13/// from [`MacdIndicator::new(12, 26, signal)`](crate::MacdIndicator). The signal
14/// line is an ordinary `signal`-period EMA of the MACD line. The output is the
15/// usual [`MacdOutput`] triple `{ macd, signal, histogram }`.
16///
17/// ```text
18/// fast = EMA(close; seed SMA(12), α = 0.15)
19/// slow = EMA(close; seed SMA(26), α = 0.075)
20/// macd = fast − slow,  signal = EMA(macd, signal),  histogram = macd − signal
21/// ```
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{Indicator, MacdFix};
27///
28/// let mut indicator = MacdFix::new(9).unwrap();
29/// let mut last = None;
30/// for i in 0..80 {
31///     last = indicator.update(100.0 + f64::from(i));
32/// }
33/// assert!(last.is_some());
34/// ```
35#[derive(Debug, Clone)]
36pub struct MacdFix {
37    inner: MacdIndicator,
38}
39
40impl MacdFix {
41    /// Construct a MACDFIX with fast = 12, slow = 26 and the given signal period.
42    ///
43    /// # Errors
44    /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `signal == 0`.
45    pub fn new(signal: usize) -> Result<Self> {
46        Ok(Self {
47            inner: MacdIndicator::fixed_12_26(signal)?,
48        })
49    }
50
51    /// Configured signal period.
52    pub fn signal_period(&self) -> usize {
53        self.inner.periods().2
54    }
55
56    /// Exact flat batch, `[macd, signal, histogram]` per input row, warmup rows
57    /// `NaN` — the fused path of [`MacdIndicator::batch_macd_into`].
58    ///
59    /// # Panics
60    ///
61    /// Panics if `out.len() != inputs.len() * 3`.
62    pub fn batch_macd_into(&mut self, inputs: &[f64], out: &mut [f64]) {
63        self.inner.batch_macd_into(inputs, out);
64    }
65
66    /// [`batch_macd_into`](Self::batch_macd_into) into a fresh vector.
67    pub fn batch_macd(&mut self, inputs: &[f64]) -> Vec<f64> {
68        self.inner.batch_macd(inputs)
69    }
70
71    /// The opt-in SIMD batch of [`MacdIndicator::batch_macd_fast_into`].
72    ///
73    /// # Panics
74    ///
75    /// Panics if `out.len() != inputs.len() * 3`.
76    pub fn batch_macd_fast_into(&mut self, inputs: &[f64], out: &mut [f64]) {
77        self.inner.batch_macd_fast_into(inputs, out);
78    }
79
80    /// [`batch_macd_fast_into`](Self::batch_macd_fast_into) into a fresh vector.
81    pub fn batch_macd_fast(&mut self, inputs: &[f64]) -> Vec<f64> {
82        self.inner.batch_macd_fast(inputs)
83    }
84}
85
86impl Indicator for MacdFix {
87    type Input = f64;
88    type Output = MacdOutput;
89
90    #[inline]
91    fn update(&mut self, value: f64) -> Option<MacdOutput> {
92        self.inner.update(value)
93    }
94
95    fn reset(&mut self) {
96        self.inner.reset();
97    }
98
99    #[inline]
100    fn warmup_period(&self) -> usize {
101        self.inner.warmup_period()
102    }
103
104    #[inline]
105    fn is_ready(&self) -> bool {
106        self.inner.is_ready()
107    }
108
109    #[inline]
110    fn name(&self) -> &'static str {
111        "MACDFIX"
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::traits::BatchExt;
119
120    #[test]
121    fn rejects_zero_signal() {
122        assert!(MacdFix::new(0).is_err());
123    }
124
125    #[test]
126    fn accessors_report_config() {
127        let m = MacdFix::new(9).unwrap();
128        assert_eq!(m.signal_period(), 9);
129        assert_eq!(m.name(), "MACDFIX");
130        assert!(!m.is_ready());
131        assert_eq!(
132            m.warmup_period(),
133            MacdIndicator::new(12, 26, 9).unwrap().warmup_period()
134        );
135    }
136
137    #[test]
138    fn uses_the_fixed_smoothing_constants() {
139        let prices: Vec<f64> = (0..80)
140            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
141            .collect();
142        // Reference: SMA-seeded EMAs with alpha 0.15 / 0.075.
143        let ema = |n: usize, a: f64| -> Vec<Option<f64>> {
144            let mut out = vec![None; prices.len()];
145            let mut v = prices[..n].iter().sum::<f64>() / n as f64;
146            out[n - 1] = Some(v);
147            for i in n..prices.len() {
148                v = a * prices[i] + (1.0 - a) * v;
149                out[i] = Some(v);
150            }
151            out
152        };
153        let (fast, slow) = (ema(12, 0.15), ema(26, 0.075));
154        let fix: Vec<Option<MacdOutput>> = MacdFix::new(9).unwrap().batch(&prices);
155        // The first MACD line value lands with the slow EMA; once the signal
156        // has seeded every output carries it.
157        for (i, out) in fix.iter().enumerate() {
158            if let Some(o) = out {
159                let expected = fast[i].unwrap() - slow[i].unwrap();
160                assert!((o.macd - expected).abs() < 1e-9, "at {i}");
161            }
162        }
163        assert!(fix.iter().any(Option::is_some));
164        // And it is not the period-derived MACD.
165        let classic: Vec<Option<MacdOutput>> =
166            MacdIndicator::new(12, 26, 9).unwrap().batch(&prices);
167        assert_ne!(fix, classic);
168    }
169
170    #[test]
171    fn flat_batch_matches_streaming() {
172        let prices: Vec<f64> = (0..80)
173            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
174            .collect();
175        let streamed: Vec<f64> = MacdFix::new(9)
176            .unwrap()
177            .batch(&prices)
178            .into_iter()
179            .flat_map(|o| o.map_or([f64::NAN; 3], |o| [o.macd, o.signal, o.histogram]))
180            .collect();
181        let flat = MacdFix::new(9).unwrap().batch_macd(&prices);
182        assert_eq!(flat.len(), streamed.len());
183        for (a, b) in flat.iter().zip(&streamed) {
184            assert!(a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()));
185        }
186    }
187
188    #[test]
189    fn reset_clears_state() {
190        let prices: Vec<f64> = (0..80).map(|i| 100.0 + f64::from(i)).collect();
191        let mut m = MacdFix::new(9).unwrap();
192        let _ = m.batch(&prices);
193        assert!(m.is_ready());
194        m.reset();
195        assert!(!m.is_ready());
196    }
197
198    fn prices(len: i32) -> Vec<f64> {
199        (0..len)
200            .map(|i| 100.0 + (f64::from(i) * 0.21).sin() * 7.0 + f64::from(i % 11) * 0.3)
201            .collect()
202    }
203
204    fn streamed_flat(signal: usize, inputs: &[f64]) -> Vec<f64> {
205        let mut m = MacdFix::new(signal).unwrap();
206        inputs
207            .iter()
208            .flat_map(|&x| {
209                m.update(x)
210                    .map_or([f64::NAN; 3], |o| [o.macd, o.signal, o.histogram])
211            })
212            .collect()
213    }
214
215    fn bits(v: &[f64]) -> Vec<u64> {
216        v.iter().map(|x| x.to_bits()).collect()
217    }
218
219    #[test]
220    fn rejects_signal_above_max() {
221        let too_big = crate::error::MAX_PERIOD + 1;
222        assert!(matches!(
223            MacdFix::new(too_big),
224            Err(crate::Error::InvalidPeriod { .. })
225        ));
226        assert!(matches!(MacdFix::new(0), Err(crate::Error::PeriodZero)));
227    }
228
229    #[test]
230    fn warmup_is_exact() {
231        let xs = prices(60);
232        let mut m = MacdFix::new(9).unwrap();
233        assert_eq!(m.warmup_period(), 34);
234        let out = m.batch(&xs);
235        assert!(out[..33].iter().all(Option::is_none));
236        assert!(out[33..].iter().all(Option::is_some));
237    }
238
239    #[test]
240    fn hand_computed_signal_two() {
241        // 26 bars at 100 seed fast and slow at 100, MACD 0 at idx25. Then 110, 110:
242        //   idx26 fast 101.5, slow 100.75 -> macd 0.75; signal(2) seeds on the
243        //         mean (0 + 0.75)/2 = 0.375, histogram 0.375
244        //   idx27 fast 0.15·110 + 0.85·101.5 = 102.775,
245        //         slow 0.075·110 + 0.925·100.75 = 101.44375 -> macd 1.33125
246        //         signal 2/3·1.33125 + 1/3·0.375 = 1.0125, histogram 0.31875
247        let mut xs = vec![100.0; 26];
248        xs.extend_from_slice(&[110.0, 110.0]);
249        let out = MacdFix::new(2).unwrap().batch(&xs);
250        assert!(out[..26].iter().all(Option::is_none));
251        let o26 = out[26].unwrap();
252        approx::assert_relative_eq!(o26.macd, 0.75, epsilon = 1e-12);
253        approx::assert_relative_eq!(o26.signal, 0.375, epsilon = 1e-12);
254        approx::assert_relative_eq!(o26.histogram, 0.375, epsilon = 1e-12);
255        let o27 = out[27].unwrap();
256        approx::assert_relative_eq!(o27.macd, 1.331_25, epsilon = 1e-12);
257        approx::assert_relative_eq!(o27.signal, 1.0125, epsilon = 1e-12);
258        approx::assert_relative_eq!(o27.histogram, 0.318_75, epsilon = 1e-12);
259    }
260
261    #[test]
262    fn batch_equals_streaming() {
263        let xs = prices(120);
264        let mut streaming = MacdFix::new(9).unwrap();
265        let expected: Vec<Option<MacdOutput>> = xs.iter().map(|&x| streaming.update(x)).collect();
266        assert_eq!(MacdFix::new(9).unwrap().batch(&xs), expected);
267    }
268
269    #[test]
270    fn batch_macd_into_is_bit_identical_to_streaming() {
271        let xs = prices(200);
272        let mut out = vec![-7.0; xs.len() * 3];
273        let mut m = MacdFix::new(9).unwrap();
274        m.batch_macd_into(&xs, &mut out);
275        assert_eq!(bits(&out), bits(&streamed_flat(9, &xs)));
276        // The indicator is left where a replay leaves it.
277        let mut replay = MacdFix::new(9).unwrap();
278        let _ = replay.batch(&xs);
279        assert_eq!(m.update(103.0), replay.update(103.0));
280        // batch_macd allocates the same result.
281        assert_eq!(bits(&MacdFix::new(9).unwrap().batch_macd(&xs)), bits(&out));
282    }
283
284    #[test]
285    fn batch_macd_into_short_input_falls_back_to_replay() {
286        let xs = prices(20);
287        let mut out = vec![0.0; xs.len() * 3];
288        MacdFix::new(9).unwrap().batch_macd_into(&xs, &mut out);
289        assert_eq!(bits(&out), bits(&streamed_flat(9, &xs)));
290    }
291
292    #[test]
293    fn batch_macd_fast_is_within_tolerance_with_identical_nans() {
294        let xs = prices(500);
295        let exact = streamed_flat(9, &xs);
296        let mut m = MacdFix::new(9).unwrap();
297        let fast = m.batch_macd_fast(&xs);
298        assert_eq!(fast.len(), exact.len());
299        assert!(fast
300            .iter()
301            .zip(&exact)
302            .all(|(f, e)| f.is_nan() == e.is_nan()));
303        let close = fast
304            .iter()
305            .zip(&exact)
306            .filter(|(f, _)| !f.is_nan())
307            .all(|(f, e)| (f - e).abs() <= 1e-12 * e.abs().max(1.0));
308        assert!(
309            close,
310            "fast batch must stay within 1e-12 relative of the exact batch"
311        );
312        // Streaming continues from the kernel's final state.
313        let mut replay = MacdFix::new(9).unwrap();
314        let _ = replay.batch(&xs);
315        let (a, b) = (m.update(104.0).unwrap(), replay.update(104.0).unwrap());
316        approx::assert_relative_eq!(a.macd, b.macd, max_relative = 1e-12);
317        approx::assert_relative_eq!(a.signal, b.signal, max_relative = 1e-12);
318    }
319
320    #[test]
321    fn batch_macd_fast_into_fills_a_caller_buffer() {
322        let xs = prices(300);
323        let mut out = vec![5.0; xs.len() * 3];
324        MacdFix::new(5).unwrap().batch_macd_fast_into(&xs, &mut out);
325        let exact = MacdFix::new(5).unwrap().batch_macd(&xs);
326        assert!(out
327            .iter()
328            .zip(&exact)
329            .all(|(f, e)| f.is_nan() == e.is_nan()));
330        let close = out
331            .iter()
332            .zip(&exact)
333            .filter(|(f, _)| !f.is_nan())
334            .all(|(f, e)| (f - e).abs() <= 1e-12 * e.abs().max(1.0));
335        assert!(
336            close,
337            "fast batch must stay within 1e-12 relative of the exact batch"
338        );
339    }
340
341    #[test]
342    #[should_panic(expected = "three values per input")]
343    fn batch_macd_into_rejects_length_mismatch() {
344        let xs = prices(40);
345        let mut out = vec![0.0; xs.len() * 3 - 1];
346        MacdFix::new(9).unwrap().batch_macd_into(&xs, &mut out);
347    }
348
349    #[test]
350    #[should_panic(expected = "three values per input")]
351    fn batch_macd_fast_into_rejects_length_mismatch() {
352        let xs = prices(40);
353        let mut out = vec![0.0; xs.len()];
354        MacdFix::new(9).unwrap().batch_macd_fast_into(&xs, &mut out);
355    }
356
357    #[test]
358    fn reset_reproduces_a_fresh_run() {
359        let xs = prices(90);
360        let mut m = MacdFix::new(9).unwrap();
361        let first = m.batch(&xs);
362        m.reset();
363        let second = m.batch(&xs);
364        assert_eq!(first, second);
365        assert_eq!(second, MacdFix::new(9).unwrap().batch(&xs));
366    }
367}