Skip to main content

indicators/trend/
macd.rs

1//! Moving Average Convergence Divergence (MACD).
2//!
3//! Python source: `indicators/trend/macd.py :: class MACD`
4//!
5//! # Python algorithm (to port)
6//! ```python
7//! fast_ema = data[self.column].ewm(span=self.fast_period, adjust=False).mean()
8//! slow_ema = data[self.column].ewm(span=self.slow_period, adjust=False).mean()
9//! macd_line = fast_ema - slow_ema
10//! signal_line = macd_line.ewm(span=self.signal_period, adjust=False).mean()
11//! histogram = macd_line - signal_line
12//! ```
13//!
14//! Output columns: `"MACD_line"`, `"MACD_signal"`, `"MACD_histogram"`.
15//!
16//! See also: `crate::functions::macd()` — already implemented for batch use.
17
18use std::collections::HashMap;
19
20use crate::error::IndicatorError;
21use crate::functions::{self};
22use crate::indicator::{Indicator, IndicatorOutput, PriceColumn};
23use crate::types::Candle;
24
25// ── Params ────────────────────────────────────────────────────────────────────
26
27#[derive(Debug, Clone)]
28pub struct MacdParams {
29    /// Fast EMA period.  Python default: 12.
30    pub fast_period: usize,
31    /// Slow EMA period.  Python default: 26.
32    pub slow_period: usize,
33    /// Signal line period.  Python default: 9.
34    pub signal_period: usize,
35    /// Price field.  Python default: `"close"`.
36    pub column: PriceColumn,
37}
38
39impl Default for MacdParams {
40    fn default() -> Self {
41        Self {
42            fast_period: 12,
43            slow_period: 26,
44            signal_period: 9,
45            column: PriceColumn::Close,
46        }
47    }
48}
49
50// ── Indicator struct ──────────────────────────────────────────────────────────
51
52#[derive(Debug, Clone)]
53pub struct Macd {
54    pub params: MacdParams,
55}
56
57impl Macd {
58    pub fn new(params: MacdParams) -> Self {
59        Self { params }
60    }
61}
62
63impl Default for Macd {
64    fn default() -> Self {
65        Self::new(MacdParams::default())
66    }
67}
68
69impl Indicator for Macd {
70    fn name(&self) -> &'static str {
71        "MACD"
72    }
73
74    fn required_len(&self) -> usize {
75        // Only need enough bars for the slow EMA to produce at least one value.
76        // The signal line uses ema_nan_aware, which seeds from the first valid
77        // MACD value, so it doesn't add to the minimum requirement.
78        self.params.slow_period
79    }
80
81    fn required_columns(&self) -> &[&'static str] {
82        &["close"]
83    }
84
85    /// Delegates to the existing `crate::functions::macd()`.
86    ///
87    /// Output key names `"MACD_line"`, `"MACD_signal"`, `"MACD_histogram"` match
88    /// the Python pattern `f"{self.name}_{suffix}"` where `self.name = "MACD"`.
89    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
90        self.check_len(candles)?;
91
92        let prices = self.params.column.extract(candles);
93        let (macd_line, signal_line, histogram) = functions::macd(
94            &prices,
95            self.params.fast_period,
96            self.params.slow_period,
97            self.params.signal_period,
98        )?;
99
100        Ok(IndicatorOutput::from_pairs([
101            ("MACD_line".to_string(), macd_line),
102            ("MACD_signal".to_string(), signal_line),
103            ("MACD_histogram".to_string(), histogram),
104        ]))
105    }
106}
107
108// ── Registry factory ──────────────────────────────────────────────────────────
109
110pub fn factory<S: ::std::hash::BuildHasher>(
111    params: &HashMap<String, String, S>,
112) -> Result<Box<dyn Indicator>, IndicatorError> {
113    Ok(Box::new(Macd::new(MacdParams {
114        fast_period: crate::registry::param_usize(params, "fast_period", 12)?,
115        slow_period: crate::registry::param_usize(params, "slow_period", 26)?,
116        signal_period: crate::registry::param_usize(params, "signal_period", 9)?,
117        column: PriceColumn::Close,
118    })))
119}
120
121// ── Tests ─────────────────────────────────────────────────────────────────────
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn candles(closes: &[f64]) -> Vec<Candle> {
128        closes
129            .iter()
130            .enumerate()
131            .map(|(i, &c)| Candle {
132                time: i64::try_from(i).expect("time index fits i64"),
133                open: c,
134                high: c,
135                low: c,
136                close: c,
137                volume: 1.0,
138            })
139            .collect()
140    }
141
142    #[test]
143    fn macd_insufficient_data() {
144        let macd = Macd::default();
145        assert!(macd.calculate(&candles(&[1.0; 10])).is_err());
146    }
147
148    #[test]
149    fn macd_output_has_three_columns() {
150        let macd = Macd::default();
151        let closes: Vec<f64> = (1..=50).map(|x| x as f64).collect();
152        let out = macd.calculate(&candles(&closes)).unwrap();
153        assert!(out.get("MACD_line").is_some(), "missing MACD_line");
154        assert!(out.get("MACD_signal").is_some(), "missing MACD_signal");
155        assert!(
156            out.get("MACD_histogram").is_some(),
157            "missing MACD_histogram"
158        );
159    }
160
161    #[test]
162    fn macd_histogram_is_line_minus_signal() {
163        let macd = Macd::default();
164        let closes: Vec<f64> = (1..=50).map(|x| x as f64).collect();
165        let out = macd.calculate(&candles(&closes)).unwrap();
166        let line = out.get("MACD_line").unwrap();
167        let signal = out.get("MACD_signal").unwrap();
168        let hist = out.get("MACD_histogram").unwrap();
169        for i in 0..line.len() {
170            if !line[i].is_nan() && !signal[i].is_nan() {
171                assert!((hist[i] - (line[i] - signal[i])).abs() < 1e-9);
172            }
173        }
174    }
175
176    #[test]
177    fn factory_creates_macd() {
178        let params = HashMap::new();
179        let ind = factory(&params).unwrap();
180        assert_eq!(ind.name(), "MACD");
181    }
182}