Skip to main content

wickra_core/indicators/
macd_ext.rs

1//! MACD with selectable moving-average types (MACDEXT).
2
3use crate::error::{Error, Result};
4use crate::indicators::dema::Dema;
5use crate::indicators::ema::Ema;
6use crate::indicators::macd::MacdOutput;
7use crate::indicators::sma::Sma;
8use crate::indicators::tema::Tema;
9use crate::indicators::trima::Trima;
10use crate::indicators::wma::Wma;
11use crate::traits::Indicator;
12
13/// Moving-average type selector for [`MacdExt`] and other multi-MA indicators.
14///
15/// The variants map to TA-Lib's `MA_Type` codes `0..=5` — the period-only
16/// moving averages. (TA-Lib's KAMA / MAMA / T3 take additional shape parameters
17/// and are not selectable here.)
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum MaType {
20    /// Simple moving average (TA-Lib code `0`).
21    Sma,
22    /// Exponential moving average (TA-Lib code `1`).
23    Ema,
24    /// Weighted moving average (TA-Lib code `2`).
25    Wma,
26    /// Double exponential moving average (TA-Lib code `3`).
27    Dema,
28    /// Triple exponential moving average (TA-Lib code `4`).
29    Tema,
30    /// Triangular moving average (TA-Lib code `5`).
31    Trima,
32}
33
34impl MaType {
35    /// Map a TA-Lib `MA_Type` integer code (`0..=5`) to a [`MaType`].
36    ///
37    /// # Errors
38    /// Returns [`Error::InvalidPeriod`] for codes outside `0..=5` (the period-only
39    /// moving averages); codes `6..=8` (KAMA / MAMA / T3) are not supported.
40    pub fn from_code(code: u32) -> Result<Self> {
41        match code {
42            0 => Ok(Self::Sma),
43            1 => Ok(Self::Ema),
44            2 => Ok(Self::Wma),
45            3 => Ok(Self::Dema),
46            4 => Ok(Self::Tema),
47            5 => Ok(Self::Trima),
48            _ => Err(Error::InvalidPeriod {
49                message: "unsupported moving-average type code (expected 0..=5)",
50            }),
51        }
52    }
53}
54
55/// A concrete period-only moving average instance, dispatched by [`MaType`].
56#[derive(Debug, Clone)]
57enum Ma {
58    Sma(Sma),
59    Ema(Ema),
60    Wma(Wma),
61    Dema(Dema),
62    Tema(Tema),
63    Trima(Trima),
64}
65
66impl Ma {
67    fn new(kind: MaType, period: usize) -> Result<Self> {
68        Ok(match kind {
69            MaType::Sma => Self::Sma(Sma::new(period)?),
70            MaType::Ema => Self::Ema(Ema::new(period)?),
71            MaType::Wma => Self::Wma(Wma::new(period)?),
72            MaType::Dema => Self::Dema(Dema::new(period)?),
73            MaType::Tema => Self::Tema(Tema::new(period)?),
74            MaType::Trima => Self::Trima(Trima::new(period)?),
75        })
76    }
77
78    #[inline]
79    fn update(&mut self, value: f64) -> Option<f64> {
80        match self {
81            Self::Sma(m) => m.update(value),
82            Self::Ema(m) => m.update(value),
83            Self::Wma(m) => m.update(value),
84            Self::Dema(m) => m.update(value),
85            Self::Tema(m) => m.update(value),
86            Self::Trima(m) => m.update(value),
87        }
88    }
89
90    fn reset(&mut self) {
91        match self {
92            Self::Sma(m) => m.reset(),
93            Self::Ema(m) => m.reset(),
94            Self::Wma(m) => m.reset(),
95            Self::Dema(m) => m.reset(),
96            Self::Tema(m) => m.reset(),
97            Self::Trima(m) => m.reset(),
98        }
99    }
100
101    #[inline]
102    fn warmup_period(&self) -> usize {
103        match self {
104            Self::Sma(m) => m.warmup_period(),
105            Self::Ema(m) => m.warmup_period(),
106            Self::Wma(m) => m.warmup_period(),
107            Self::Dema(m) => m.warmup_period(),
108            Self::Tema(m) => m.warmup_period(),
109            Self::Trima(m) => m.warmup_period(),
110        }
111    }
112}
113
114/// MACD Extended (`MACDEXT`): MACD with an independently selectable
115/// [`MaType`] for each of the fast, slow and signal lines.
116///
117/// Classic [`MacdIndicator`](crate::MacdIndicator) hard-wires the exponential
118/// moving average everywhere; `MACDEXT` lets each line use any period-only
119/// moving average. The MACD line is `fast_ma(price) − slow_ma(price)`, the signal
120/// line is `signal_ma(macd)`, and the histogram is `macd − signal`. The first
121/// full [`MacdOutput`] is emitted once the slow and signal averages are both warm.
122///
123/// # Example
124///
125/// ```
126/// use wickra_core::{Indicator, MacdExt, MaType};
127///
128/// let mut indicator =
129///     MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma).unwrap();
130/// let mut last = None;
131/// for i in 0..120 {
132///     last = indicator.update(100.0 + f64::from(i));
133/// }
134/// assert!(last.is_some());
135/// ```
136#[derive(Debug, Clone)]
137pub struct MacdExt {
138    fast: Ma,
139    slow: Ma,
140    signal: Ma,
141    has_emitted: bool,
142}
143
144impl MacdExt {
145    /// Construct a MACDEXT with per-line periods and moving-average types.
146    ///
147    /// # Errors
148    /// Returns [`Error::PeriodZero`] if any period is zero and
149    /// [`Error::InvalidPeriod`] if `fast >= slow`, propagating any moving-average
150    /// construction error.
151    pub fn new(
152        fast: usize,
153        fast_type: MaType,
154        slow: usize,
155        slow_type: MaType,
156        signal: usize,
157        signal_type: MaType,
158    ) -> Result<Self> {
159        if fast == 0 || slow == 0 || signal == 0 {
160            return Err(Error::PeriodZero);
161        }
162        if fast >= slow {
163            return Err(Error::InvalidPeriod {
164                message: "fast period must be < slow period",
165            });
166        }
167        Ok(Self {
168            fast: Ma::new(fast_type, fast)?,
169            slow: Ma::new(slow_type, slow)?,
170            signal: Ma::new(signal_type, signal)?,
171            has_emitted: false,
172        })
173    }
174}
175
176impl Indicator for MacdExt {
177    type Input = f64;
178    type Output = MacdOutput;
179
180    #[inline]
181    fn update(&mut self, value: f64) -> Option<MacdOutput> {
182        let fast_v = self.fast.update(value);
183        let slow_v = self.slow.update(value);
184        let (Some(fast_v), Some(slow_v)) = (fast_v, slow_v) else {
185            return None;
186        };
187        let macd = fast_v - slow_v;
188        let signal = self.signal.update(macd)?;
189        self.has_emitted = true;
190        Some(MacdOutput {
191            macd,
192            signal,
193            histogram: macd - signal,
194        })
195    }
196
197    fn reset(&mut self) {
198        self.fast.reset();
199        self.slow.reset();
200        self.signal.reset();
201        self.has_emitted = false;
202    }
203
204    #[inline]
205    fn warmup_period(&self) -> usize {
206        // The signal line is fed the MACD series, so it receives its first
207        // input on the bar the slow average emits: the warmups overlap by one.
208        self.slow.warmup_period() + self.signal.warmup_period() - 1
209    }
210
211    #[inline]
212    fn is_ready(&self) -> bool {
213        self.has_emitted
214    }
215
216    #[inline]
217    fn name(&self) -> &'static str {
218        "MACDEXT"
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::traits::BatchExt;
226
227    const TYPES: [MaType; 6] = [
228        MaType::Sma,
229        MaType::Ema,
230        MaType::Wma,
231        MaType::Dema,
232        MaType::Tema,
233        MaType::Trima,
234    ];
235
236    #[test]
237    fn from_code_maps_all_supported_types() {
238        assert_eq!(MaType::from_code(0).unwrap(), MaType::Sma);
239        assert_eq!(MaType::from_code(1).unwrap(), MaType::Ema);
240        assert_eq!(MaType::from_code(2).unwrap(), MaType::Wma);
241        assert_eq!(MaType::from_code(3).unwrap(), MaType::Dema);
242        assert_eq!(MaType::from_code(4).unwrap(), MaType::Tema);
243        assert_eq!(MaType::from_code(5).unwrap(), MaType::Trima);
244        assert!(MaType::from_code(6).is_err());
245    }
246
247    #[test]
248    fn rejects_invalid_periods() {
249        assert!(matches!(
250            MacdExt::new(0, MaType::Ema, 26, MaType::Ema, 9, MaType::Ema),
251            Err(Error::PeriodZero)
252        ));
253        assert!(matches!(
254            MacdExt::new(26, MaType::Ema, 12, MaType::Ema, 9, MaType::Ema),
255            Err(Error::InvalidPeriod { .. })
256        ));
257    }
258
259    #[test]
260    fn accessors_and_metadata() {
261        let m = MacdExt::new(12, MaType::Ema, 26, MaType::Sma, 9, MaType::Sma).unwrap();
262        assert_eq!(m.name(), "MACDEXT");
263        assert!(!m.is_ready());
264        assert!(m.warmup_period() >= 26);
265    }
266
267    #[test]
268    fn every_ma_type_produces_a_consistent_histogram() {
269        let prices: Vec<f64> = (0..120)
270            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0)
271            .collect();
272        for &t in &TYPES {
273            let mut m = MacdExt::new(5, t, 10, t, 4, t).unwrap();
274            let out: Vec<Option<MacdOutput>> = m.batch(&prices);
275            assert!(out.iter().any(Option::is_some), "{t:?} never emitted");
276            for o in out.into_iter().flatten() {
277                assert!((o.histogram - (o.macd - o.signal)).abs() < 1e-9);
278            }
279            // Exercise the warmup accessor for this variant's inner averages.
280            assert!(m.warmup_period() >= 10);
281            assert!(m.is_ready());
282            m.reset();
283            assert!(!m.is_ready());
284        }
285    }
286
287    #[test]
288    fn mixed_ma_types_per_line() {
289        let prices: Vec<f64> = (0..120).map(|i| 100.0 + f64::from(i)).collect();
290        let mut m = MacdExt::new(12, MaType::Wma, 26, MaType::Dema, 9, MaType::Trima).unwrap();
291        let last = m.batch(&prices).into_iter().flatten().last();
292        assert!(last.is_some());
293    }
294}