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 fn update(&mut self, input: f64) -> Option<ZeroLagMacdOutput> {
93 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 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 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 let z = ZeroLagMacd::new(12, 26, 9).unwrap();
220 assert_eq!(z.warmup_period(), 50);
221 let z = ZeroLagMacd::new(3, 5, 3).unwrap();
224 assert_eq!(z.warmup_period(), 10);
225 }
226}