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