Skip to main content

wickra_core/indicators/
macd.rs

1//! Moving Average Convergence Divergence (MACD).
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::traits::Indicator;
6
7/// MACD output: the three classic series at a given step.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct MacdOutput {
10    /// Fast EMA − slow EMA.
11    pub macd: f64,
12    /// EMA of `macd` over the signal period.
13    pub signal: f64,
14    /// `macd − signal`.
15    pub histogram: f64,
16}
17
18/// MACD = EMA(fast) − EMA(slow), with a signal EMA on top.
19///
20/// Standard parameters are `fast = 12`, `slow = 26`, `signal = 9`. The signal EMA
21/// is seeded from the first `signal` raw MACD values, so the first full
22/// [`MacdOutput`] is emitted after `slow + signal − 1` inputs (assuming the
23/// slow EMA seeded by then).
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{Indicator, MacdIndicator};
29///
30/// let mut indicator = MacdIndicator::new(3, 6, 3).unwrap();
31/// let mut last = None;
32/// for i in 0..80 {
33///     last = indicator.update(100.0 + f64::from(i));
34/// }
35/// assert!(last.is_some());
36/// ```
37#[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    /// Construct a MACD with the given periods.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`Error::PeriodZero`] if any period is zero, and
54    /// [`Error::InvalidPeriod`] if `fast >= slow`.
55    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    /// Default `(12, 26, 9)` configuration, matching every classical chart package.
76    pub fn classic() -> Self {
77        Self::new(12, 26, 9).expect("classic MACD periods are valid")
78    }
79
80    /// Configured periods as `(fast, slow, signal)`.
81    pub const fn periods(&self) -> (usize, usize, usize) {
82        (self.fast_period, self.slow_period, self.signal_period)
83    }
84
85    /// Most recent fully-computed output if available.
86    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        // Slow EMA needs `slow` inputs to seed; signal EMA needs another `signal - 1`.
128        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    /// Cover the const accessors `periods` / `value` (81-88) and the
159    /// Indicator-impl `name` body (135-137). `warmup_period` is exercised
160    /// elsewhere.
161    #[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        // Indices 0..warmup-1 are None, index warmup-1 might be Some or might still need
196        // the signal EMA's seeding. Our warmup_period is the index at which the first
197        // signal value appears: slow + signal - 1.
198        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        // Both EMAs converge to 100, so MACD must approach 0.
221        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        // Non-finite inputs return the last value without advancing any EMA.
266        assert_eq!(macd.update(f64::NAN), before);
267        assert_eq!(macd.update(f64::INFINITY), before);
268        assert_eq!(macd.value(), before);
269    }
270}