Skip to main content

wickra_core/indicators/
zero_lag_macd.rs

1//! Zero-Lag MACD — MACD computed on `ZLEMA` instead of `EMA`.
2
3use crate::error::{Error, Result};
4use crate::indicators::zlema::Zlema;
5use crate::traits::Indicator;
6
7/// Multi-output for Zero-Lag MACD: the MACD line, its signal line, and the
8/// histogram (line − signal).
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct ZeroLagMacdOutput {
11    /// Fast `ZLEMA` minus slow `ZLEMA`.
12    pub macd: f64,
13    /// `ZLEMA(macd, signal_period)`.
14    pub signal: f64,
15    /// `macd − signal`.
16    pub histogram: f64,
17}
18
19/// Zero-Lag MACD — the standard `MACD` topology with `ZLEMA` substituted for
20/// `EMA` everywhere. `ZLEMA`'s de-lagged construction makes the MACD line
21/// react faster to trend changes at the cost of slightly noisier readings.
22///
23/// ```text
24/// macd_t      = ZLEMA(close, fast)_t − ZLEMA(close, slow)_t
25/// signal_t    = ZLEMA(macd, signal_period)_t
26/// histogram_t = macd_t − signal_t
27/// ```
28///
29/// Default parameters mirror MACD: `(fast = 12, slow = 26, signal = 9)`.
30/// `fast` must be strictly less than `slow`.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Indicator, ZeroLagMacd};
36///
37/// let mut zmacd = ZeroLagMacd::classic();
38/// let mut last = None;
39/// for i in 0..120 {
40///     last = zmacd.update(100.0 + f64::from(i));
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct ZeroLagMacd {
46    fast_period: usize,
47    slow_period: usize,
48    signal_period: usize,
49    fast: Zlema,
50    slow: Zlema,
51    signal: Zlema,
52}
53
54impl ZeroLagMacd {
55    /// # Errors
56    /// - [`Error::PeriodZero`] if any period is zero.
57    /// - [`Error::InvalidPeriod`] if `fast >= slow`.
58    pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
59        if fast == 0 || slow == 0 || signal == 0 {
60            return Err(Error::PeriodZero);
61        }
62        if fast >= slow {
63            return Err(Error::InvalidPeriod {
64                message: "ZeroLagMACD fast period must be strictly less than slow",
65            });
66        }
67        Ok(Self {
68            fast_period: fast,
69            slow_period: slow,
70            signal_period: signal,
71            fast: Zlema::new(fast)?,
72            slow: Zlema::new(slow)?,
73            signal: Zlema::new(signal)?,
74        })
75    }
76
77    /// MACD-style defaults: `(fast = 12, slow = 26, signal = 9)`.
78    pub fn classic() -> Self {
79        Self::new(12, 26, 9).expect("classic Zero-Lag MACD parameters are valid")
80    }
81
82    /// Configured `(fast, slow, signal)`.
83    pub const fn periods(&self) -> (usize, usize, usize) {
84        (self.fast_period, self.slow_period, self.signal_period)
85    }
86}
87
88impl Indicator for ZeroLagMacd {
89    type Input = f64;
90    type Output = ZeroLagMacdOutput;
91
92    #[inline]
93    fn update(&mut self, input: f64) -> Option<ZeroLagMacdOutput> {
94        // Feed both inner ZLEMAs on every input so the slow one warms in
95        // parallel with the fast one.
96        let f = self.fast.update(input);
97        let s = self.slow.update(input);
98        let (f, s) = (f?, s?);
99        let macd = f - s;
100        let signal = self.signal.update(macd)?;
101        Some(ZeroLagMacdOutput {
102            macd,
103            signal,
104            histogram: macd - signal,
105        })
106    }
107
108    fn reset(&mut self) {
109        self.fast.reset();
110        self.slow.reset();
111        self.signal.reset();
112    }
113
114    #[inline]
115    fn warmup_period(&self) -> usize {
116        // ZLEMA(period) warmup is `(period − 1) / 2 + period` = `lag + period`.
117        // Both fast and slow run in parallel; the slow one dominates. The
118        // signal ZLEMA then needs its own `lag + period` MACD values on top.
119        let zlema_warmup = |period: usize| ((period - 1) / 2).saturating_add(period);
120        zlema_warmup(self.slow_period) + zlema_warmup(self.signal_period) - 1
121    }
122
123    #[inline]
124    fn is_ready(&self) -> bool {
125        self.signal.is_ready()
126    }
127
128    #[inline]
129    fn name(&self) -> &'static str {
130        "ZeroLagMACD"
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::traits::BatchExt;
138    use approx::assert_relative_eq;
139
140    #[test]
141    fn rejects_zero_period() {
142        assert!(matches!(ZeroLagMacd::new(0, 26, 9), Err(Error::PeriodZero)));
143        assert!(matches!(ZeroLagMacd::new(12, 0, 9), Err(Error::PeriodZero)));
144        assert!(matches!(
145            ZeroLagMacd::new(12, 26, 0),
146            Err(Error::PeriodZero)
147        ));
148    }
149
150    #[test]
151    fn rejects_fast_geq_slow() {
152        assert!(matches!(
153            ZeroLagMacd::new(26, 12, 9),
154            Err(Error::InvalidPeriod { .. })
155        ));
156    }
157
158    #[test]
159    fn accessors_and_metadata() {
160        let z = ZeroLagMacd::classic();
161        assert_eq!(z.periods(), (12, 26, 9));
162        assert_eq!(z.name(), "ZeroLagMACD");
163    }
164
165    #[test]
166    fn classic_factory() {
167        assert_eq!(ZeroLagMacd::classic().periods(), (12, 26, 9));
168    }
169
170    #[test]
171    fn constant_series_converges_to_zero() {
172        // Each ZLEMA reproduces a constant, so macd, signal and histogram
173        // are all 0 after the slowest branch warms.
174        let mut z = ZeroLagMacd::new(3, 5, 3).unwrap();
175        let out = z.batch(&[42.0_f64; 60]);
176        for v in out.iter().rev().take(5).flatten() {
177            assert_relative_eq!(v.macd, 0.0, epsilon = 1e-12);
178            assert_relative_eq!(v.signal, 0.0, epsilon = 1e-12);
179            assert_relative_eq!(v.histogram, 0.0, epsilon = 1e-12);
180        }
181    }
182
183    #[test]
184    fn histogram_is_macd_minus_signal() {
185        let mut z = ZeroLagMacd::classic();
186        let prices: Vec<f64> = (1..=120)
187            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
188            .collect();
189        for v in z.batch(&prices).iter().flatten() {
190            assert_relative_eq!(v.histogram, v.macd - v.signal, epsilon = 1e-12);
191        }
192    }
193
194    #[test]
195    fn batch_equals_streaming() {
196        let prices: Vec<f64> = (1..=120)
197            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
198            .collect();
199        let mut a = ZeroLagMacd::classic();
200        let mut b = ZeroLagMacd::classic();
201        assert_eq!(
202            a.batch(&prices),
203            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
204        );
205    }
206
207    #[test]
208    fn reset_clears_state() {
209        let mut z = ZeroLagMacd::classic();
210        z.batch(&(1..=120).map(f64::from).collect::<Vec<_>>());
211        assert!(z.is_ready());
212        z.reset();
213        assert!(!z.is_ready());
214    }
215
216    #[test]
217    fn warmup_period_matches_zlema_chain() {
218        // warmup = zlema_warmup(slow) + zlema_warmup(signal) - 1
219        // zlema_warmup(p) = (p - 1) / 2 + p
220        // (12, 26, 9): zlema_warmup(26) = 12 + 26 = 38;
221        //              zlema_warmup(9)  = 4 + 9 = 13.
222        //              warmup = 38 + 13 - 1 = 50.
223        let z = ZeroLagMacd::new(12, 26, 9).unwrap();
224        assert_eq!(z.warmup_period(), 50);
225        // (3, 5, 3): zlema_warmup(5) = 2 + 5 = 7; zlema_warmup(3) = 1 + 3 = 4.
226        //            warmup = 7 + 4 - 1 = 10.
227        let z = ZeroLagMacd::new(3, 5, 3).unwrap();
228        assert_eq!(z.warmup_period(), 10);
229    }
230}