Skip to main content

wickra_core/indicators/
macd.rs

1//! Moving Average Convergence Divergence (MACD).
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::traits::Indicator;
6
7/// MACD output: the three classic series at a given step.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct MacdOutput {
10    /// Fast EMA − slow EMA.
11    pub macd: f64,
12    /// EMA of `macd` over the signal period.
13    pub signal: f64,
14    /// `macd − signal`.
15    pub histogram: f64,
16}
17
18/// MACD = EMA(fast) − EMA(slow), with a signal EMA on top.
19///
20/// Standard parameters are `fast = 12`, `slow = 26`, `signal = 9`. The signal EMA
21/// is seeded from the first `signal` raw MACD values, so the first full
22/// [`MacdOutput`] is emitted after `slow + signal − 1` inputs (assuming the
23/// slow EMA seeded by then).
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{Indicator, MacdIndicator};
29///
30/// let mut indicator = MacdIndicator::new(3, 6, 3).unwrap();
31/// let mut last = None;
32/// for i in 0..80 {
33///     last = indicator.update(100.0 + f64::from(i));
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct MacdIndicator {
39    fast: Ema,
40    slow: Ema,
41    signal_ema: Ema,
42    fast_period: usize,
43    slow_period: usize,
44    signal_period: usize,
45    last: Option<MacdOutput>,
46}
47
48impl MacdIndicator {
49    /// Construct a MACD with the given periods.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`Error::PeriodZero`] if any period is zero, and
54    /// [`Error::InvalidPeriod`] if `fast >= slow`.
55    pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
56        if fast == 0 || slow == 0 || signal == 0 {
57            return Err(Error::PeriodZero);
58        }
59        if fast >= slow {
60            return Err(Error::InvalidPeriod {
61                message: "fast period must be strictly less than slow period",
62            });
63        }
64        Ok(Self {
65            fast: Ema::new(fast)?,
66            slow: Ema::new(slow)?,
67            signal_ema: Ema::new(signal)?,
68            fast_period: fast,
69            slow_period: slow,
70            signal_period: signal,
71            last: None,
72        })
73    }
74
75    /// Default `(12, 26, 9)` configuration, matching every classical chart package.
76    pub fn classic() -> Self {
77        Self::new(12, 26, 9).expect("classic MACD periods are valid")
78    }
79
80    /// Configured periods as `(fast, slow, signal)`.
81    pub const fn periods(&self) -> (usize, usize, usize) {
82        (self.fast_period, self.slow_period, self.signal_period)
83    }
84
85    /// Most recent fully-computed output if available.
86    pub const fn value(&self) -> Option<MacdOutput> {
87        self.last
88    }
89
90    /// Vectorized flat batch for bindings: `n * 3` values laid out as
91    /// `[macd, signal, histogram]` per input row, warmup rows all `NaN`.
92    ///
93    /// For a fresh, all-finite slice long enough for a full output it runs the
94    /// fast EMA, slow EMA and signal EMA as three recurrences fused into a single
95    /// pass with one allocation — no `Option` per tick, no per-EMA intermediate
96    /// buffers, identical SMA-mean seeds (division) and `mul_add` recurrences. The
97    /// result is *bit-for-bit* equal to replaying `update`. Anything else (not
98    /// fresh, non-finite, or too short to emit) defers to the exact `update`
99    /// replay.
100    ///
101    /// Separate from the trait [`batch`](crate::BatchExt::batch), which stays a
102    /// bit-identical `update` replay; only the bindings call this.
103    pub fn batch_macd(&mut self, inputs: &[f64]) -> Vec<f64> {
104        let n = inputs.len();
105        let (fp, sp, gp) = (self.fast_period, self.slow_period, self.signal_period);
106        // First full output needs the slow EMA seeded (index sp-1) plus gp signal
107        // values: index sp + gp - 2. Below that, or non-fresh/non-finite, replay.
108        if self.last.is_some()
109            || !self.fast.is_fresh()
110            || !self.slow.is_fresh()
111            || !self.signal_ema.is_fresh()
112            || n < sp + gp - 1
113            || !inputs.iter().all(|x| x.is_finite())
114        {
115            let mut out = vec![f64::NAN; n * 3];
116            for (i, &x) in inputs.iter().enumerate() {
117                if let Some(o) = self.update(x) {
118                    out[i * 3] = o.macd;
119                    out[i * 3 + 1] = o.signal;
120                    out[i * 3 + 2] = o.histogram;
121                }
122            }
123            return out;
124        }
125
126        // Pre-sized output: warmup rows stay NaN, full-output rows are written in
127        // place by index — no per-row `push` length/capacity check.
128        let mut out = vec![f64::NAN; n * 3];
129        let (fa, fo) = (self.fast.alpha(), 1.0 - self.fast.alpha());
130        let (sa, so) = (self.slow.alpha(), 1.0 - self.slow.alpha());
131        let (ga, go) = (self.signal_ema.alpha(), 1.0 - self.signal_ema.alpha());
132        let (fp_f, sp_f, gp_f) = (fp as f64, sp as f64, gp as f64);
133
134        let (mut fast_val, mut slow_val, mut sig) = (0.0_f64, 0.0_f64, 0.0_f64);
135        let (mut fsum, mut ssum, mut gsum) = (0.0_f64, 0.0_f64, 0.0_f64);
136        let mut sig_count = 0usize; // signal-EMA seed progress (raw MACD values seen)
137        let mut sig_seeded = false;
138        let mut last = MacdOutput {
139            macd: 0.0,
140            signal: 0.0,
141            histogram: 0.0,
142        };
143
144        for (i, &x) in inputs.iter().enumerate() {
145            // Fast EMA: SMA-seeded at index fp-1, then recurrence.
146            if i < fp {
147                fsum += x;
148                if i == fp - 1 {
149                    fast_val = fsum / fp_f;
150                }
151            } else {
152                fast_val = fa.mul_add(x, fo * fast_val);
153            }
154            // Slow EMA: SMA-seeded at index sp-1, then recurrence.
155            if i < sp {
156                ssum += x;
157                if i == sp - 1 {
158                    slow_val = ssum / sp_f;
159                }
160            } else {
161                slow_val = sa.mul_add(x, so * slow_val);
162            }
163            if i + 1 < sp {
164                continue; // slow EMA not seeded yet → no raw MACD line
165            }
166            let macd = fast_val - slow_val;
167            // Signal EMA over the MACD line: SMA-seeded over its first gp values.
168            let signal = if sig_seeded {
169                sig = ga.mul_add(macd, go * sig);
170                sig
171            } else {
172                gsum += macd;
173                sig_count += 1;
174                if sig_count < gp {
175                    continue; // signal EMA still seeding → no full output
176                }
177                sig = gsum / gp_f;
178                sig_seeded = true;
179                sig
180            };
181            let histogram = macd - signal;
182            out[i * 3] = macd;
183            out[i * 3 + 1] = signal;
184            out[i * 3 + 2] = histogram;
185            last = MacdOutput {
186                macd,
187                signal,
188                histogram,
189            };
190        }
191
192        // Leave every sub-EMA and `last` where a full `update` replay would.
193        self.fast.seed_to(fast_val);
194        self.slow.seed_to(slow_val);
195        self.signal_ema.seed_to(sig);
196        self.last = Some(last);
197        out
198    }
199}
200
201impl Indicator for MacdIndicator {
202    type Input = f64;
203    type Output = MacdOutput;
204
205    #[inline]
206    fn update(&mut self, input: f64) -> Option<MacdOutput> {
207        if !input.is_finite() {
208            return None;
209        }
210
211        let fast = self.fast.update(input);
212        let slow = self.slow.update(input);
213
214        match (fast, slow) {
215            (Some(f), Some(s)) => {
216                let macd = f - s;
217                let signal = self.signal_ema.update(macd)?;
218                let out = MacdOutput {
219                    macd,
220                    signal,
221                    histogram: macd - signal,
222                };
223                self.last = Some(out);
224                Some(out)
225            }
226            _ => None,
227        }
228    }
229
230    fn reset(&mut self) {
231        self.fast.reset();
232        self.slow.reset();
233        self.signal_ema.reset();
234        self.last = None;
235    }
236
237    #[inline]
238    fn warmup_period(&self) -> usize {
239        // Slow EMA needs `slow` inputs to seed; signal EMA needs another `signal - 1`.
240        self.slow_period + self.signal_period - 1
241    }
242
243    #[inline]
244    fn is_ready(&self) -> bool {
245        self.last.is_some()
246    }
247
248    #[inline]
249    fn name(&self) -> &'static str {
250        "MACD"
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::traits::BatchExt;
258    use approx::assert_relative_eq;
259
260    #[test]
261    fn rejects_fast_geq_slow() {
262        assert!(matches!(
263            MacdIndicator::new(26, 12, 9),
264            Err(Error::InvalidPeriod { .. })
265        ));
266        assert!(matches!(
267            MacdIndicator::new(12, 12, 9),
268            Err(Error::InvalidPeriod { .. })
269        ));
270    }
271
272    /// Cover the const accessors `periods` / `value` (81-88) and the
273    /// Indicator-impl `name` body (135-137). `warmup_period` is exercised
274    /// elsewhere.
275    #[test]
276    fn accessors_and_metadata() {
277        let mut m = MacdIndicator::new(12, 26, 9).unwrap();
278        assert_eq!(m.periods(), (12, 26, 9));
279        assert_eq!(m.name(), "MACD");
280        assert!(m.value().is_none());
281        for i in 1..=m.warmup_period() {
282            m.update(100.0 + f64::from(u32::try_from(i).unwrap()));
283        }
284        assert!(m.value().is_some());
285    }
286
287    #[test]
288    fn rejects_zero_periods() {
289        assert!(matches!(
290            MacdIndicator::new(0, 26, 9),
291            Err(Error::PeriodZero)
292        ));
293        assert!(matches!(
294            MacdIndicator::new(12, 0, 9),
295            Err(Error::PeriodZero)
296        ));
297        assert!(matches!(
298            MacdIndicator::new(12, 26, 0),
299            Err(Error::PeriodZero)
300        ));
301    }
302
303    #[test]
304    fn first_emission_matches_warmup_period() {
305        let prices: Vec<f64> = (1..=60).map(f64::from).collect();
306        let mut macd = MacdIndicator::classic();
307        let out = macd.batch(&prices);
308        let warmup = macd.warmup_period();
309        // Indices 0..warmup-1 are None, index warmup-1 might be Some or might still need
310        // the signal EMA's seeding. Our warmup_period is the index at which the first
311        // signal value appears: slow + signal - 1.
312        for x in out.iter().take(warmup - 1) {
313            assert!(x.is_none(), "expected None within warmup");
314        }
315        assert!(
316            out[warmup - 1].is_some(),
317            "expected first emission at warmup_period - 1 ({warmup} idx)"
318        );
319    }
320
321    #[test]
322    fn histogram_equals_macd_minus_signal() {
323        let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 0.5).collect();
324        let mut macd = MacdIndicator::classic();
325        for v in macd.batch(&prices).into_iter().flatten() {
326            assert_relative_eq!(v.histogram, v.macd - v.signal, epsilon = 1e-12);
327        }
328    }
329
330    #[test]
331    fn constant_series_yields_zero_macd_eventually() {
332        let mut macd = MacdIndicator::classic();
333        let out = macd.batch(&[100.0_f64; 200]);
334        // Both EMAs converge to 100, so MACD must approach 0.
335        let last = out.iter().rev().flatten().next().expect("emits a value");
336        assert_relative_eq!(last.macd, 0.0, epsilon = 1e-9);
337        assert_relative_eq!(last.signal, 0.0, epsilon = 1e-9);
338        assert_relative_eq!(last.histogram, 0.0, epsilon = 1e-9);
339    }
340
341    #[test]
342    fn rising_series_macd_positive_then_signal_catches_up() {
343        let prices: Vec<f64> = (1..=200).map(f64::from).collect();
344        let mut macd = MacdIndicator::classic();
345        let out = macd.batch(&prices);
346        let last = out.iter().rev().flatten().next().unwrap();
347        assert!(last.macd > 0.0, "rising series must yield positive MACD");
348    }
349
350    #[test]
351    fn batch_equals_streaming() {
352        let prices: Vec<f64> = (1..=100)
353            .map(|i| (f64::from(i) * 0.4).cos() * 10.0)
354            .collect();
355        let mut a = MacdIndicator::classic();
356        let mut b = MacdIndicator::classic();
357        assert_eq!(
358            a.batch(&prices),
359            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
360        );
361    }
362
363    #[test]
364    fn reset_clears_state() {
365        let mut macd = MacdIndicator::classic();
366        macd.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
367        assert!(macd.is_ready());
368        macd.reset();
369        assert!(!macd.is_ready());
370        assert_eq!(macd.update(1.0), None);
371    }
372
373    fn bits_eq(a: &[f64], b: &[f64]) -> bool {
374        a.len() == b.len()
375            && a.iter()
376                .zip(b)
377                .all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
378    }
379
380    /// Flat `n*3` `[macd, signal, histogram]` replay of `update`.
381    fn macd_replay(series: &[f64]) -> Vec<f64> {
382        let mut m = MacdIndicator::classic();
383        let mut out = Vec::with_capacity(series.len() * 3);
384        for &x in series {
385            match m.update(x) {
386                Some(o) => out.extend_from_slice(&[o.macd, o.signal, o.histogram]),
387                None => out.extend_from_slice(&[f64::NAN; 3]),
388            }
389        }
390        out
391    }
392
393    #[test]
394    fn batch_macd_fast_path_is_bit_identical() {
395        let series: Vec<f64> = (0..300)
396            .map(|i| (f64::from(i) * 0.4).cos() * 10.0 + 100.0)
397            .collect();
398        let mut macd = MacdIndicator::classic();
399        let got = macd.batch_macd(&series);
400        assert!(bits_eq(&got, &macd_replay(&series)));
401        // Sub-EMA + last state left where the replay would: continued update agrees.
402        let mut ref_macd = MacdIndicator::classic();
403        for &x in &series {
404            ref_macd.update(x);
405        }
406        let (a, b) = (macd.update(101.0), ref_macd.update(101.0));
407        assert_eq!(a.is_some(), b.is_some());
408        assert_relative_eq!(a.unwrap().macd, b.unwrap().macd, epsilon = 1e-12);
409    }
410
411    #[test]
412    fn batch_macd_falls_back_on_non_finite() {
413        let mut series: Vec<f64> = (0..60).map(|i| f64::from(i) + 100.0).collect();
414        series[40] = f64::NAN;
415        let mut macd = MacdIndicator::classic();
416        assert!(bits_eq(&macd.batch_macd(&series), &macd_replay(&series)));
417    }
418
419    #[test]
420    fn batch_macd_falls_back_when_not_fresh() {
421        let series: Vec<f64> = (0..60).map(|i| f64::from(i) + 100.0).collect();
422        let mut macd = MacdIndicator::classic();
423        macd.update(50.0);
424        let mut ref_macd = MacdIndicator::classic();
425        ref_macd.update(50.0);
426        let mut want = Vec::new();
427        for &x in &series {
428            match ref_macd.update(x) {
429                Some(o) => want.extend_from_slice(&[o.macd, o.signal, o.histogram]),
430                None => want.extend_from_slice(&[f64::NAN; 3]),
431            }
432        }
433        assert!(bits_eq(&macd.batch_macd(&series), &want));
434    }
435
436    #[test]
437    fn batch_macd_too_short_for_output_falls_back() {
438        // n < slow + signal - 1 (= 34): no full output, routed to the replay.
439        let series: Vec<f64> = (0..20).map(|i| f64::from(i) + 100.0).collect();
440        let mut macd = MacdIndicator::classic();
441        let got = macd.batch_macd(&series);
442        assert!(bits_eq(&got, &macd_replay(&series)));
443        assert!(got.iter().all(|x| x.is_nan()));
444    }
445
446    #[test]
447    fn ignores_non_finite_input() {
448        let mut macd = MacdIndicator::classic();
449        macd.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
450        let before = macd.value();
451        assert!(before.is_some());
452        // Non-finite inputs return the last value without advancing any EMA.
453        assert_eq!(macd.update(f64::NAN), None);
454        assert_eq!(macd.update(f64::INFINITY), None);
455        assert_eq!(macd.value(), before);
456    }
457}