wickra_core/indicators/
macd.rs1use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct MacdOutput {
10 pub macd: f64,
12 pub signal: f64,
14 pub histogram: f64,
16}
17
18#[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 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 pub fn classic() -> Self {
77 Self::new(12, 26, 9).expect("classic MACD periods are valid")
78 }
79
80 pub const fn periods(&self) -> (usize, usize, usize) {
82 (self.fast_period, self.slow_period, self.signal_period)
83 }
84
85 pub const fn value(&self) -> Option<MacdOutput> {
87 self.last
88 }
89}
90
91impl Indicator for MacdIndicator {
92 type Input = f64;
93 type Output = MacdOutput;
94
95 fn update(&mut self, input: f64) -> Option<MacdOutput> {
96 if !input.is_finite() {
97 return self.last;
98 }
99
100 let fast = self.fast.update(input);
101 let slow = self.slow.update(input);
102
103 match (fast, slow) {
104 (Some(f), Some(s)) => {
105 let macd = f - s;
106 let signal = self.signal_ema.update(macd)?;
107 let out = MacdOutput {
108 macd,
109 signal,
110 histogram: macd - signal,
111 };
112 self.last = Some(out);
113 Some(out)
114 }
115 _ => None,
116 }
117 }
118
119 fn reset(&mut self) {
120 self.fast.reset();
121 self.slow.reset();
122 self.signal_ema.reset();
123 self.last = None;
124 }
125
126 fn warmup_period(&self) -> usize {
127 self.slow_period + self.signal_period - 1
129 }
130
131 fn is_ready(&self) -> bool {
132 self.last.is_some()
133 }
134
135 fn name(&self) -> &'static str {
136 "MACD"
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::traits::BatchExt;
144 use approx::assert_relative_eq;
145
146 #[test]
147 fn rejects_fast_geq_slow() {
148 assert!(matches!(
149 MacdIndicator::new(26, 12, 9),
150 Err(Error::InvalidPeriod { .. })
151 ));
152 assert!(matches!(
153 MacdIndicator::new(12, 12, 9),
154 Err(Error::InvalidPeriod { .. })
155 ));
156 }
157
158 #[test]
162 fn accessors_and_metadata() {
163 let mut m = MacdIndicator::new(12, 26, 9).unwrap();
164 assert_eq!(m.periods(), (12, 26, 9));
165 assert_eq!(m.name(), "MACD");
166 assert!(m.value().is_none());
167 for i in 1..=m.warmup_period() {
168 m.update(100.0 + f64::from(u32::try_from(i).unwrap()));
169 }
170 assert!(m.value().is_some());
171 }
172
173 #[test]
174 fn rejects_zero_periods() {
175 assert!(matches!(
176 MacdIndicator::new(0, 26, 9),
177 Err(Error::PeriodZero)
178 ));
179 assert!(matches!(
180 MacdIndicator::new(12, 0, 9),
181 Err(Error::PeriodZero)
182 ));
183 assert!(matches!(
184 MacdIndicator::new(12, 26, 0),
185 Err(Error::PeriodZero)
186 ));
187 }
188
189 #[test]
190 fn first_emission_matches_warmup_period() {
191 let prices: Vec<f64> = (1..=60).map(f64::from).collect();
192 let mut macd = MacdIndicator::classic();
193 let out = macd.batch(&prices);
194 let warmup = macd.warmup_period();
195 for x in out.iter().take(warmup - 1) {
199 assert!(x.is_none(), "expected None within warmup");
200 }
201 assert!(
202 out[warmup - 1].is_some(),
203 "expected first emission at warmup_period - 1 ({warmup} idx)"
204 );
205 }
206
207 #[test]
208 fn histogram_equals_macd_minus_signal() {
209 let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 0.5).collect();
210 let mut macd = MacdIndicator::classic();
211 for v in macd.batch(&prices).into_iter().flatten() {
212 assert_relative_eq!(v.histogram, v.macd - v.signal, epsilon = 1e-12);
213 }
214 }
215
216 #[test]
217 fn constant_series_yields_zero_macd_eventually() {
218 let mut macd = MacdIndicator::classic();
219 let out = macd.batch(&[100.0_f64; 200]);
220 let last = out.iter().rev().flatten().next().expect("emits a value");
222 assert_relative_eq!(last.macd, 0.0, epsilon = 1e-9);
223 assert_relative_eq!(last.signal, 0.0, epsilon = 1e-9);
224 assert_relative_eq!(last.histogram, 0.0, epsilon = 1e-9);
225 }
226
227 #[test]
228 fn rising_series_macd_positive_then_signal_catches_up() {
229 let prices: Vec<f64> = (1..=200).map(f64::from).collect();
230 let mut macd = MacdIndicator::classic();
231 let out = macd.batch(&prices);
232 let last = out.iter().rev().flatten().next().unwrap();
233 assert!(last.macd > 0.0, "rising series must yield positive MACD");
234 }
235
236 #[test]
237 fn batch_equals_streaming() {
238 let prices: Vec<f64> = (1..=100)
239 .map(|i| (f64::from(i) * 0.4).cos() * 10.0)
240 .collect();
241 let mut a = MacdIndicator::classic();
242 let mut b = MacdIndicator::classic();
243 assert_eq!(
244 a.batch(&prices),
245 prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
246 );
247 }
248
249 #[test]
250 fn reset_clears_state() {
251 let mut macd = MacdIndicator::classic();
252 macd.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
253 assert!(macd.is_ready());
254 macd.reset();
255 assert!(!macd.is_ready());
256 assert_eq!(macd.update(1.0), None);
257 }
258
259 #[test]
260 fn ignores_non_finite_input() {
261 let mut macd = MacdIndicator::classic();
262 macd.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
263 let before = macd.value();
264 assert!(before.is_some());
265 assert_eq!(macd.update(f64::NAN), before);
267 assert_eq!(macd.update(f64::INFINITY), before);
268 assert_eq!(macd.value(), before);
269 }
270}