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    /// Allocates the result and fills it through
94    /// [`batch_macd_into`](Self::batch_macd_into). Separate from the trait
95    /// [`batch`](crate::BatchExt::batch), which stays a bit-identical `update`
96    /// replay.
97    pub fn batch_macd(&mut self, inputs: &[f64]) -> Vec<f64> {
98        let mut out = vec![0.0; inputs.len() * 3];
99        self.batch_macd_into(inputs, &mut out);
100        out
101    }
102
103    /// Opt-in fast variant of [`batch_macd_into`](Self::batch_macd_into): after
104    /// the exact warmup (those rows are identical to the exact batch), the
105    /// fast, slow and signal EMAs run as SIMD linear-recurrence scans over
106    /// blocks. Every value agrees with the exact batch to within a few units in
107    /// the last place; warmup rows and length are identical, and the result is
108    /// the same on every platform. Only a fresh indicator over finite values
109    /// within `1e100`, long enough for a full output, takes the kernel;
110    /// anything else is the exact batch. Afterwards the three EMAs continue
111    /// streaming from the kernel's last values.
112    ///
113    /// # Panics
114    ///
115    /// Panics if `out.len() != inputs.len() * 3`.
116    pub fn batch_macd_fast_into(&mut self, inputs: &[f64], out: &mut [f64]) {
117        let n = inputs.len();
118        assert_eq!(
119            out.len(),
120            n * 3,
121            "batch_macd output must hold three values per input"
122        );
123        let (fp, sp, gp) = (self.fast_period, self.slow_period, self.signal_period);
124        if self.last.is_some()
125            || !self.fast.is_fresh()
126            || !self.slow.is_fresh()
127            || !self.signal_ema.is_fresh()
128            || n < sp + gp - 1
129            || !crate::fast::in_range(inputs)
130        {
131            self.batch_macd_into(inputs, out);
132            return;
133        }
134        let (fast, slow, signal) = wickra_simd::dispatch(crate::fast::MacdFast {
135            x: inputs,
136            periods: (fp, sp, gp),
137            alphas: (
138                self.fast.alpha(),
139                self.slow.alpha(),
140                self.signal_ema.alpha(),
141            ),
142            out,
143            _borrow: std::marker::PhantomData,
144        });
145        self.fast.seed_to(fast);
146        self.slow.seed_to(slow);
147        self.signal_ema.seed_to(signal);
148        let tail = &out[(n - 1) * 3..];
149        self.last = Some(MacdOutput {
150            macd: tail[0],
151            signal: tail[1],
152            histogram: tail[2],
153        });
154    }
155
156    /// [`batch_macd_fast_into`](Self::batch_macd_fast_into) into a fresh
157    /// vector.
158    pub fn batch_macd_fast(&mut self, inputs: &[f64]) -> Vec<f64> {
159        let mut out = vec![0.0; inputs.len() * 3];
160        self.batch_macd_fast_into(inputs, &mut out);
161        out
162    }
163
164    /// [`batch_macd`](Self::batch_macd) into a caller-owned buffer of
165    /// `inputs.len() * 3` values, overwriting every cell.
166    ///
167    /// For a fresh slice long enough for a full output, whose values are all
168    /// finite with magnitude at most `1e300`, it runs the fast EMA, slow EMA
169    /// and signal EMA as three recurrences in one pass: the warmup phases run
170    /// on their own so the steady-state loop has no per-row branch, and the
171    /// three independent chains overlap in the pipeline. The seeds are the same
172    /// running means `Ema` keeps (summed from `-0.0` in input order) and the
173    /// recurrences the same `mul_add`, so every value is *bit-for-bit* equal to
174    /// replaying `update`. The magnitude bound keeps every EMA, sum and MACD
175    /// difference finite, which is what lets the signal EMA take every MACD
176    /// value (streaming skips a non-finite one). Anything else (not fresh, a
177    /// value out of range, or too short to emit) replays `update`.
178    ///
179    /// # Panics
180    ///
181    /// Panics if `out.len() != inputs.len() * 3`.
182    pub fn batch_macd_into(&mut self, inputs: &[f64], out: &mut [f64]) {
183        let n = inputs.len();
184        assert_eq!(
185            out.len(),
186            n * 3,
187            "batch_macd output must hold three values per input"
188        );
189        let (fp, sp, gp) = (self.fast_period, self.slow_period, self.signal_period);
190        // First full output needs the slow EMA seeded (index sp-1) plus gp signal
191        // values: index sp + gp - 2. Below that, or non-fresh/out-of-range, replay.
192        if self.last.is_some()
193            || !self.fast.is_fresh()
194            || !self.slow.is_fresh()
195            || !self.signal_ema.is_fresh()
196            || n < sp + gp - 1
197            || !inputs.iter().all(|x| x.abs() <= 1e300)
198        {
199            for (row, &x) in out.chunks_exact_mut(3).zip(inputs) {
200                match self.update(x) {
201                    Some(o) => row.copy_from_slice(&[o.macd, o.signal, o.histogram]),
202                    None => row.fill(f64::NAN),
203                }
204            }
205            return;
206        }
207
208        let (fast_val, slow_val, sig) = wickra_simd::dispatch(FusedMacd {
209            inputs,
210            out,
211            periods: (fp, sp, gp),
212            alphas: (
213                self.fast.alpha(),
214                self.slow.alpha(),
215                self.signal_ema.alpha(),
216            ),
217        });
218
219        // Leave every sub-EMA and `last` where a full `update` replay would.
220        self.fast.seed_to(fast_val);
221        self.slow.seed_to(slow_val);
222        self.signal_ema.seed_to(sig);
223        let tail = &out[(n - 1) * 3..];
224        self.last = Some(MacdOutput {
225            macd: tail[0],
226            signal: tail[1],
227            histogram: tail[2],
228        });
229    }
230}
231
232/// The fused MACD fast path as a [`wickra_simd::Kernel`], so its three
233/// `mul_add` chains compile to hardware FMA where the CPU has it (the baseline
234/// build calls the C library's `fma`, which serialises the chains). Returns the
235/// final fast EMA, slow EMA and signal EMA.
236struct FusedMacd<'a> {
237    inputs: &'a [f64],
238    out: &'a mut [f64],
239    periods: (usize, usize, usize),
240    alphas: (f64, f64, f64),
241}
242
243// Inlining into the dispatching function is what compiles the body with its
244// features; see `wickra_simd::Kernel`.
245#[allow(clippy::inline_always)]
246impl wickra_simd::Kernel for FusedMacd<'_> {
247    type Output = (f64, f64, f64);
248
249    #[inline(always)]
250    fn run<S: wickra_simd::Simd>(self, _simd: S) -> (f64, f64, f64) {
251        let Self {
252            inputs,
253            out,
254            periods: (fp, sp, gp),
255            alphas: (fa, sa, ga),
256        } = self;
257        let (fo, so, go) = (1.0 - fa, 1.0 - sa, 1.0 - ga);
258        let first_full = sp + gp - 2;
259
260        // Warmup rows carry no full output.
261        out[..first_full * 3].fill(f64::NAN);
262
263        // Fast EMA seed (the mean of the first fp inputs), then its recurrence
264        // up to the slow seed; the slow EMA only accumulates until index sp-1.
265        let mut fsum = -0.0_f64;
266        for &x in &inputs[..fp] {
267            fsum += x;
268        }
269        let mut fast_val = fsum / fp as f64;
270        let mut ssum = -0.0_f64;
271        for &x in &inputs[..sp] {
272            ssum += x;
273        }
274        for &x in &inputs[fp..sp] {
275            fast_val = fa.mul_add(x, fo * fast_val);
276        }
277        let mut slow_val = ssum / sp as f64;
278
279        // The signal EMA seeds on the mean of the first gp MACD values
280        // (indices sp-1 ..= sp+gp-2).
281        let mut gsum = -0.0_f64 + (fast_val - slow_val);
282        for &x in &inputs[sp..=first_full] {
283            fast_val = fa.mul_add(x, fo * fast_val);
284            slow_val = sa.mul_add(x, so * slow_val);
285            gsum += fast_val - slow_val;
286        }
287        let mut sig = gsum / gp as f64;
288        let macd = fast_val - slow_val;
289        out[first_full * 3..first_full * 3 + 3].copy_from_slice(&[macd, sig, macd - sig]);
290
291        // Steady state: three independent recurrences per row, no branch.
292        for (row, &x) in out[(first_full + 1) * 3..]
293            .chunks_exact_mut(3)
294            .zip(&inputs[first_full + 1..])
295        {
296            fast_val = fa.mul_add(x, fo * fast_val);
297            slow_val = sa.mul_add(x, so * slow_val);
298            let macd = fast_val - slow_val;
299            sig = ga.mul_add(macd, go * sig);
300            row.copy_from_slice(&[macd, sig, macd - sig]);
301        }
302        (fast_val, slow_val, sig)
303    }
304}
305
306impl Indicator for MacdIndicator {
307    type Input = f64;
308    type Output = MacdOutput;
309
310    #[inline]
311    fn update(&mut self, input: f64) -> Option<MacdOutput> {
312        if !input.is_finite() {
313            return None;
314        }
315
316        let fast = self.fast.update(input);
317        let slow = self.slow.update(input);
318
319        match (fast, slow) {
320            (Some(f), Some(s)) => {
321                let macd = f - s;
322                let signal = self.signal_ema.update(macd)?;
323                let out = MacdOutput {
324                    macd,
325                    signal,
326                    histogram: macd - signal,
327                };
328                self.last = Some(out);
329                Some(out)
330            }
331            _ => None,
332        }
333    }
334
335    fn reset(&mut self) {
336        self.fast.reset();
337        self.slow.reset();
338        self.signal_ema.reset();
339        self.last = None;
340    }
341
342    #[inline]
343    fn warmup_period(&self) -> usize {
344        // Slow EMA needs `slow` inputs to seed; signal EMA needs another `signal - 1`.
345        self.slow_period + self.signal_period - 1
346    }
347
348    #[inline]
349    fn is_ready(&self) -> bool {
350        self.last.is_some()
351    }
352
353    #[inline]
354    fn name(&self) -> &'static str {
355        "MACD"
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::traits::BatchExt;
363    use approx::assert_relative_eq;
364
365    #[test]
366    fn rejects_fast_geq_slow() {
367        assert!(matches!(
368            MacdIndicator::new(26, 12, 9),
369            Err(Error::InvalidPeriod { .. })
370        ));
371        assert!(matches!(
372            MacdIndicator::new(12, 12, 9),
373            Err(Error::InvalidPeriod { .. })
374        ));
375    }
376
377    /// Cover the const accessors `periods` / `value` (81-88) and the
378    /// Indicator-impl `name` body (135-137). `warmup_period` is exercised
379    /// elsewhere.
380    #[test]
381    fn accessors_and_metadata() {
382        let mut m = MacdIndicator::new(12, 26, 9).unwrap();
383        assert_eq!(m.periods(), (12, 26, 9));
384        assert_eq!(m.name(), "MACD");
385        assert!(m.value().is_none());
386        for i in 1..=m.warmup_period() {
387            m.update(100.0 + f64::from(u32::try_from(i).unwrap()));
388        }
389        assert!(m.value().is_some());
390    }
391
392    #[test]
393    fn rejects_zero_periods() {
394        assert!(matches!(
395            MacdIndicator::new(0, 26, 9),
396            Err(Error::PeriodZero)
397        ));
398        assert!(matches!(
399            MacdIndicator::new(12, 0, 9),
400            Err(Error::PeriodZero)
401        ));
402        assert!(matches!(
403            MacdIndicator::new(12, 26, 0),
404            Err(Error::PeriodZero)
405        ));
406    }
407
408    #[test]
409    fn first_emission_matches_warmup_period() {
410        let prices: Vec<f64> = (1..=60).map(f64::from).collect();
411        let mut macd = MacdIndicator::classic();
412        let out = macd.batch(&prices);
413        let warmup = macd.warmup_period();
414        // Indices 0..warmup-1 are None, index warmup-1 might be Some or might still need
415        // the signal EMA's seeding. Our warmup_period is the index at which the first
416        // signal value appears: slow + signal - 1.
417        for x in out.iter().take(warmup - 1) {
418            assert!(x.is_none(), "expected None within warmup");
419        }
420        assert!(
421            out[warmup - 1].is_some(),
422            "expected first emission at warmup_period - 1 ({warmup} idx)"
423        );
424    }
425
426    #[test]
427    fn histogram_equals_macd_minus_signal() {
428        let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 0.5).collect();
429        let mut macd = MacdIndicator::classic();
430        for v in macd.batch(&prices).into_iter().flatten() {
431            assert_relative_eq!(v.histogram, v.macd - v.signal, epsilon = 1e-12);
432        }
433    }
434
435    #[test]
436    fn constant_series_yields_zero_macd_eventually() {
437        let mut macd = MacdIndicator::classic();
438        let out = macd.batch(&[100.0_f64; 200]);
439        // Both EMAs converge to 100, so MACD must approach 0.
440        let last = out.iter().rev().flatten().next().expect("emits a value");
441        assert_relative_eq!(last.macd, 0.0, epsilon = 1e-9);
442        assert_relative_eq!(last.signal, 0.0, epsilon = 1e-9);
443        assert_relative_eq!(last.histogram, 0.0, epsilon = 1e-9);
444    }
445
446    #[test]
447    fn rising_series_macd_positive_then_signal_catches_up() {
448        let prices: Vec<f64> = (1..=200).map(f64::from).collect();
449        let mut macd = MacdIndicator::classic();
450        let out = macd.batch(&prices);
451        let last = out.iter().rev().flatten().next().unwrap();
452        assert!(last.macd > 0.0, "rising series must yield positive MACD");
453    }
454
455    #[test]
456    fn batch_equals_streaming() {
457        let prices: Vec<f64> = (1..=100)
458            .map(|i| (f64::from(i) * 0.4).cos() * 10.0)
459            .collect();
460        let mut a = MacdIndicator::classic();
461        let mut b = MacdIndicator::classic();
462        assert_eq!(
463            a.batch(&prices),
464            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
465        );
466    }
467
468    #[test]
469    fn reset_clears_state() {
470        let mut macd = MacdIndicator::classic();
471        macd.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
472        assert!(macd.is_ready());
473        macd.reset();
474        assert!(!macd.is_ready());
475        assert_eq!(macd.update(1.0), None);
476    }
477
478    fn bits_eq(a: &[f64], b: &[f64]) -> bool {
479        a.len() == b.len()
480            && a.iter()
481                .zip(b)
482                .all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
483    }
484
485    /// Flat `n*3` `[macd, signal, histogram]` replay of `update`.
486    fn macd_replay(series: &[f64]) -> Vec<f64> {
487        let mut m = MacdIndicator::classic();
488        let mut out = Vec::with_capacity(series.len() * 3);
489        for &x in series {
490            match m.update(x) {
491                Some(o) => out.extend_from_slice(&[o.macd, o.signal, o.histogram]),
492                None => out.extend_from_slice(&[f64::NAN; 3]),
493            }
494        }
495        out
496    }
497
498    #[test]
499    fn batch_macd_fast_path_is_bit_identical() {
500        let series: Vec<f64> = (0..300)
501            .map(|i| (f64::from(i) * 0.4).cos() * 10.0 + 100.0)
502            .collect();
503        let mut macd = MacdIndicator::classic();
504        let got = macd.batch_macd(&series);
505        assert!(bits_eq(&got, &macd_replay(&series)));
506        // Sub-EMA + last state left where the replay would: continued update agrees.
507        let mut ref_macd = MacdIndicator::classic();
508        for &x in &series {
509            ref_macd.update(x);
510        }
511        let (a, b) = (macd.update(101.0), ref_macd.update(101.0));
512        assert_eq!(a.is_some(), b.is_some());
513        assert_relative_eq!(a.unwrap().macd, b.unwrap().macd, epsilon = 1e-12);
514    }
515
516    /// Strict bit equality (`-0.0` differs from `0.0`; the only `NaN`s here are
517    /// the warmup `f64::NAN`s both sides write).
518    fn to_bits(v: &[f64]) -> Vec<u64> {
519        v.iter().map(|x| x.to_bits()).collect()
520    }
521
522    /// The fused kernel run through the dispatcher (AVX2 + FMA where available)
523    /// and in the baseline build must write the same bits and end in the same
524    /// state.
525    #[test]
526    fn fused_kernel_is_identical_on_every_dispatch_path() {
527        let series: Vec<f64> = (0..3000)
528            .map(|i| (f64::from(i) * 0.093).sin() * 7.0 + f64::from(i % 13) * 0.4 + 80.0)
529            .collect();
530        let alphas = (2.0 / 13.0, 2.0 / 27.0, 2.0 / 10.0);
531        let mut dispatched = vec![0.0; series.len() * 3];
532        let mut baseline = vec![0.0; series.len() * 3];
533        let a = wickra_simd::dispatch(FusedMacd {
534            inputs: &series,
535            out: &mut dispatched,
536            periods: (12, 26, 9),
537            alphas,
538        });
539        let b = wickra_simd::run_baseline(FusedMacd {
540            inputs: &series,
541            out: &mut baseline,
542            periods: (12, 26, 9),
543            alphas,
544        });
545        assert_eq!(to_bits(&dispatched), to_bits(&baseline));
546        assert_eq!(
547            [a.0.to_bits(), a.1.to_bits(), a.2.to_bits()],
548            [b.0.to_bits(), b.1.to_bits(), b.2.to_bits()]
549        );
550    }
551
552    /// An all-negative-zero window must seed exactly like the streaming EMAs,
553    /// which sum from `-0.0`: every output keeps its sign bit.
554    #[test]
555    fn batch_macd_negative_zero_series_matches_to_the_bit() {
556        let series = vec![-0.0_f64; 60];
557        let got = MacdIndicator::classic().batch_macd(&series);
558        assert_eq!(to_bits(&got), to_bits(&macd_replay(&series)));
559    }
560
561    /// Into a buffer that already holds values, every cell is overwritten —
562    /// warmup rows with `NaN`, the rest with the replay's values.
563    #[test]
564    fn batch_macd_into_overwrites_a_dirty_buffer() {
565        let series: Vec<f64> = (0..200)
566            .map(|i| (f64::from(i) * 0.21).sin() * 3.0 + 50.0)
567            .collect();
568        let mut out = vec![9.0; series.len() * 3];
569        MacdIndicator::classic().batch_macd_into(&series, &mut out);
570        assert_eq!(to_bits(&out), to_bits(&macd_replay(&series)));
571    }
572
573    /// A signal period of one seeds the signal on the first MACD value, so the
574    /// signal-warmup phase is empty.
575    #[test]
576    fn batch_macd_with_signal_period_one_matches_replay() {
577        let series: Vec<f64> = (0..80).map(|i| f64::from(i % 9) * 1.25 + 30.0).collect();
578        let mut fused = MacdIndicator::new(3, 7, 1).unwrap();
579        let mut replay = MacdIndicator::new(3, 7, 1).unwrap();
580        let want: Vec<f64> = series
581            .iter()
582            .flat_map(|&x| match replay.update(x) {
583                Some(o) => [o.macd, o.signal, o.histogram],
584                None => [f64::NAN; 3],
585            })
586            .collect();
587        assert_eq!(to_bits(&fused.batch_macd(&series)), to_bits(&want));
588    }
589
590    /// Values beyond `1e300` could overflow a MACD difference, which the
591    /// streaming signal EMA would skip; the fused path must hand those to the
592    /// exact replay instead.
593    #[test]
594    fn batch_macd_hands_huge_values_to_the_replay() {
595        let mut series: Vec<f64> = (0..60).map(|i| f64::from(i) + 100.0).collect();
596        series[45] = 1.7e308;
597        series[46] = -1.7e308;
598        let got = MacdIndicator::classic().batch_macd(&series);
599        assert!(bits_eq(&got, &macd_replay(&series)));
600    }
601
602    #[test]
603    #[should_panic(expected = "batch_macd output must hold three values per input")]
604    fn batch_macd_into_rejects_a_short_buffer() {
605        let mut out = vec![0.0; 5];
606        MacdIndicator::classic().batch_macd_into(&[1.0, 2.0], &mut out);
607    }
608
609    #[test]
610    fn batch_macd_falls_back_on_non_finite() {
611        let mut series: Vec<f64> = (0..60).map(|i| f64::from(i) + 100.0).collect();
612        series[40] = f64::NAN;
613        let mut macd = MacdIndicator::classic();
614        assert!(bits_eq(&macd.batch_macd(&series), &macd_replay(&series)));
615    }
616
617    #[test]
618    fn batch_macd_falls_back_when_not_fresh() {
619        let series: Vec<f64> = (0..60).map(|i| f64::from(i) + 100.0).collect();
620        let mut macd = MacdIndicator::classic();
621        macd.update(50.0);
622        let mut ref_macd = MacdIndicator::classic();
623        ref_macd.update(50.0);
624        let mut want = Vec::new();
625        for &x in &series {
626            match ref_macd.update(x) {
627                Some(o) => want.extend_from_slice(&[o.macd, o.signal, o.histogram]),
628                None => want.extend_from_slice(&[f64::NAN; 3]),
629            }
630        }
631        assert!(bits_eq(&macd.batch_macd(&series), &want));
632    }
633
634    #[test]
635    fn batch_macd_too_short_for_output_falls_back() {
636        // n < slow + signal - 1 (= 34): no full output, routed to the replay.
637        let series: Vec<f64> = (0..20).map(|i| f64::from(i) + 100.0).collect();
638        let mut macd = MacdIndicator::classic();
639        let got = macd.batch_macd(&series);
640        assert!(bits_eq(&got, &macd_replay(&series)));
641        assert!(got.iter().all(|x| x.is_nan()));
642    }
643
644    #[test]
645    fn ignores_non_finite_input() {
646        let mut macd = MacdIndicator::classic();
647        macd.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
648        let before = macd.value();
649        assert!(before.is_some());
650        // Non-finite inputs return the last value without advancing any EMA.
651        assert_eq!(macd.update(f64::NAN), None);
652        assert_eq!(macd.update(f64::INFINITY), None);
653        assert_eq!(macd.value(), before);
654    }
655}