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    fn update(&mut self, input: f64) -> Option<ZeroLagMacdOutput> {
93        // Feed both inner ZLEMAs on every input so the slow one warms in
94        // parallel with the fast one.
95        let f = self.fast.update(input);
96        let s = self.slow.update(input);
97        let (f, s) = (f?, s?);
98        let macd = f - s;
99        let signal = self.signal.update(macd)?;
100        Some(ZeroLagMacdOutput {
101            macd,
102            signal,
103            histogram: macd - signal,
104        })
105    }
106
107    fn reset(&mut self) {
108        self.fast.reset();
109        self.slow.reset();
110        self.signal.reset();
111    }
112
113    fn warmup_period(&self) -> usize {
114        // ZLEMA(period) warmup is `(period − 1) / 2 + period` = `lag + period`.
115        // Both fast and slow run in parallel; the slow one dominates. The
116        // signal ZLEMA then needs its own `lag + period` MACD values on top.
117        let zlema_warmup = |period: usize| ((period - 1) / 2).saturating_add(period);
118        zlema_warmup(self.slow_period) + zlema_warmup(self.signal_period) - 1
119    }
120
121    fn is_ready(&self) -> bool {
122        self.signal.is_ready()
123    }
124
125    fn name(&self) -> &'static str {
126        "ZeroLagMACD"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::traits::BatchExt;
134    use approx::assert_relative_eq;
135
136    #[test]
137    fn rejects_zero_period() {
138        assert!(matches!(ZeroLagMacd::new(0, 26, 9), Err(Error::PeriodZero)));
139        assert!(matches!(ZeroLagMacd::new(12, 0, 9), Err(Error::PeriodZero)));
140        assert!(matches!(
141            ZeroLagMacd::new(12, 26, 0),
142            Err(Error::PeriodZero)
143        ));
144    }
145
146    #[test]
147    fn rejects_fast_geq_slow() {
148        assert!(matches!(
149            ZeroLagMacd::new(26, 12, 9),
150            Err(Error::InvalidPeriod { .. })
151        ));
152    }
153
154    #[test]
155    fn accessors_and_metadata() {
156        let z = ZeroLagMacd::classic();
157        assert_eq!(z.periods(), (12, 26, 9));
158        assert_eq!(z.name(), "ZeroLagMACD");
159    }
160
161    #[test]
162    fn classic_factory() {
163        assert_eq!(ZeroLagMacd::classic().periods(), (12, 26, 9));
164    }
165
166    #[test]
167    fn constant_series_converges_to_zero() {
168        // Each ZLEMA reproduces a constant, so macd, signal and histogram
169        // are all 0 after the slowest branch warms.
170        let mut z = ZeroLagMacd::new(3, 5, 3).unwrap();
171        let out = z.batch(&[42.0_f64; 60]);
172        for v in out.iter().rev().take(5).flatten() {
173            assert_relative_eq!(v.macd, 0.0, epsilon = 1e-12);
174            assert_relative_eq!(v.signal, 0.0, epsilon = 1e-12);
175            assert_relative_eq!(v.histogram, 0.0, epsilon = 1e-12);
176        }
177    }
178
179    #[test]
180    fn histogram_is_macd_minus_signal() {
181        let mut z = ZeroLagMacd::classic();
182        let prices: Vec<f64> = (1..=120)
183            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
184            .collect();
185        for v in z.batch(&prices).iter().flatten() {
186            assert_relative_eq!(v.histogram, v.macd - v.signal, epsilon = 1e-12);
187        }
188    }
189
190    #[test]
191    fn batch_equals_streaming() {
192        let prices: Vec<f64> = (1..=120)
193            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
194            .collect();
195        let mut a = ZeroLagMacd::classic();
196        let mut b = ZeroLagMacd::classic();
197        assert_eq!(
198            a.batch(&prices),
199            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
200        );
201    }
202
203    #[test]
204    fn reset_clears_state() {
205        let mut z = ZeroLagMacd::classic();
206        z.batch(&(1..=120).map(f64::from).collect::<Vec<_>>());
207        assert!(z.is_ready());
208        z.reset();
209        assert!(!z.is_ready());
210    }
211
212    #[test]
213    fn warmup_period_matches_zlema_chain() {
214        // warmup = zlema_warmup(slow) + zlema_warmup(signal) - 1
215        // zlema_warmup(p) = (p - 1) / 2 + p
216        // (12, 26, 9): zlema_warmup(26) = 12 + 26 = 38;
217        //              zlema_warmup(9)  = 4 + 9 = 13.
218        //              warmup = 38 + 13 - 1 = 50.
219        let z = ZeroLagMacd::new(12, 26, 9).unwrap();
220        assert_eq!(z.warmup_period(), 50);
221        // (3, 5, 3): zlema_warmup(5) = 2 + 5 = 7; zlema_warmup(3) = 1 + 3 = 4.
222        //            warmup = 7 + 4 - 1 = 10.
223        let z = ZeroLagMacd::new(3, 5, 3).unwrap();
224        assert_eq!(z.warmup_period(), 10);
225    }
226}