wickra_core/indicators/
zero_lag_macd.rs1use crate::error::{Error, Result};
4use crate::indicators::zlema::Zlema;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct ZeroLagMacdOutput {
11 pub macd: f64,
13 pub signal: f64,
15 pub histogram: f64,
17}
18
19#[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 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 pub fn classic() -> Self {
79 Self::new(12, 26, 9).expect("classic Zero-Lag MACD parameters are valid")
80 }
81
82 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 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 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 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 let z = ZeroLagMacd::new(12, 26, 9).unwrap();
224 assert_eq!(z.warmup_period(), 50);
225 let z = ZeroLagMacd::new(3, 5, 3).unwrap();
228 assert_eq!(z.warmup_period(), 10);
229 }
230}