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    fn update(&mut self, value: f64) -> Option<f64> {
79        match self {
80            Self::Sma(m) => m.update(value),
81            Self::Ema(m) => m.update(value),
82            Self::Wma(m) => m.update(value),
83            Self::Dema(m) => m.update(value),
84            Self::Tema(m) => m.update(value),
85            Self::Trima(m) => m.update(value),
86        }
87    }
88
89    fn reset(&mut self) {
90        match self {
91            Self::Sma(m) => m.reset(),
92            Self::Ema(m) => m.reset(),
93            Self::Wma(m) => m.reset(),
94            Self::Dema(m) => m.reset(),
95            Self::Tema(m) => m.reset(),
96            Self::Trima(m) => m.reset(),
97        }
98    }
99
100    fn warmup_period(&self) -> usize {
101        match self {
102            Self::Sma(m) => m.warmup_period(),
103            Self::Ema(m) => m.warmup_period(),
104            Self::Wma(m) => m.warmup_period(),
105            Self::Dema(m) => m.warmup_period(),
106            Self::Tema(m) => m.warmup_period(),
107            Self::Trima(m) => m.warmup_period(),
108        }
109    }
110}
111
112/// MACD Extended (`MACDEXT`): MACD with an independently selectable
113/// [`MaType`] for each of the fast, slow and signal lines.
114///
115/// Classic [`MacdIndicator`](crate::MacdIndicator) hard-wires the exponential
116/// moving average everywhere; `MACDEXT` lets each line use any period-only
117/// moving average. The MACD line is `fast_ma(price) − slow_ma(price)`, the signal
118/// line is `signal_ma(macd)`, and the histogram is `macd − signal`. The first
119/// full [`MacdOutput`] is emitted once the slow and signal averages are both warm.
120///
121/// # Example
122///
123/// ```
124/// use wickra_core::{Indicator, MacdExt, MaType};
125///
126/// let mut indicator =
127///     MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma).unwrap();
128/// let mut last = None;
129/// for i in 0..120 {
130///     last = indicator.update(100.0 + f64::from(i));
131/// }
132/// assert!(last.is_some());
133/// ```
134#[derive(Debug, Clone)]
135pub struct MacdExt {
136    fast: Ma,
137    slow: Ma,
138    signal: Ma,
139    has_emitted: bool,
140}
141
142impl MacdExt {
143    /// Construct a MACDEXT with per-line periods and moving-average types.
144    ///
145    /// # Errors
146    /// Returns [`Error::PeriodZero`] if any period is zero and
147    /// [`Error::InvalidPeriod`] if `fast >= slow`, propagating any moving-average
148    /// construction error.
149    pub fn new(
150        fast: usize,
151        fast_type: MaType,
152        slow: usize,
153        slow_type: MaType,
154        signal: usize,
155        signal_type: MaType,
156    ) -> Result<Self> {
157        if fast == 0 || slow == 0 || signal == 0 {
158            return Err(Error::PeriodZero);
159        }
160        if fast >= slow {
161            return Err(Error::InvalidPeriod {
162                message: "fast period must be < slow period",
163            });
164        }
165        Ok(Self {
166            fast: Ma::new(fast_type, fast)?,
167            slow: Ma::new(slow_type, slow)?,
168            signal: Ma::new(signal_type, signal)?,
169            has_emitted: false,
170        })
171    }
172}
173
174impl Indicator for MacdExt {
175    type Input = f64;
176    type Output = MacdOutput;
177
178    fn update(&mut self, value: f64) -> Option<MacdOutput> {
179        let fast_v = self.fast.update(value);
180        let slow_v = self.slow.update(value);
181        let (Some(fast_v), Some(slow_v)) = (fast_v, slow_v) else {
182            return None;
183        };
184        let macd = fast_v - slow_v;
185        let signal = self.signal.update(macd)?;
186        self.has_emitted = true;
187        Some(MacdOutput {
188            macd,
189            signal,
190            histogram: macd - signal,
191        })
192    }
193
194    fn reset(&mut self) {
195        self.fast.reset();
196        self.slow.reset();
197        self.signal.reset();
198        self.has_emitted = false;
199    }
200
201    fn warmup_period(&self) -> usize {
202        self.slow.warmup_period() + self.signal.warmup_period()
203    }
204
205    fn is_ready(&self) -> bool {
206        self.has_emitted
207    }
208
209    fn name(&self) -> &'static str {
210        "MACDEXT"
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::traits::BatchExt;
218
219    const TYPES: [MaType; 6] = [
220        MaType::Sma,
221        MaType::Ema,
222        MaType::Wma,
223        MaType::Dema,
224        MaType::Tema,
225        MaType::Trima,
226    ];
227
228    #[test]
229    fn from_code_maps_all_supported_types() {
230        assert_eq!(MaType::from_code(0).unwrap(), MaType::Sma);
231        assert_eq!(MaType::from_code(1).unwrap(), MaType::Ema);
232        assert_eq!(MaType::from_code(2).unwrap(), MaType::Wma);
233        assert_eq!(MaType::from_code(3).unwrap(), MaType::Dema);
234        assert_eq!(MaType::from_code(4).unwrap(), MaType::Tema);
235        assert_eq!(MaType::from_code(5).unwrap(), MaType::Trima);
236        assert!(MaType::from_code(6).is_err());
237    }
238
239    #[test]
240    fn rejects_invalid_periods() {
241        assert!(matches!(
242            MacdExt::new(0, MaType::Ema, 26, MaType::Ema, 9, MaType::Ema),
243            Err(Error::PeriodZero)
244        ));
245        assert!(matches!(
246            MacdExt::new(26, MaType::Ema, 12, MaType::Ema, 9, MaType::Ema),
247            Err(Error::InvalidPeriod { .. })
248        ));
249    }
250
251    #[test]
252    fn accessors_and_metadata() {
253        let m = MacdExt::new(12, MaType::Ema, 26, MaType::Sma, 9, MaType::Sma).unwrap();
254        assert_eq!(m.name(), "MACDEXT");
255        assert!(!m.is_ready());
256        assert!(m.warmup_period() >= 26);
257    }
258
259    #[test]
260    fn every_ma_type_produces_a_consistent_histogram() {
261        let prices: Vec<f64> = (0..120)
262            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0)
263            .collect();
264        for &t in &TYPES {
265            let mut m = MacdExt::new(5, t, 10, t, 4, t).unwrap();
266            let out: Vec<Option<MacdOutput>> = m.batch(&prices);
267            assert!(out.iter().any(Option::is_some), "{t:?} never emitted");
268            for o in out.into_iter().flatten() {
269                assert!((o.histogram - (o.macd - o.signal)).abs() < 1e-9);
270            }
271            // Exercise the warmup accessor for this variant's inner averages.
272            assert!(m.warmup_period() >= 10);
273            assert!(m.is_ready());
274            m.reset();
275            assert!(!m.is_ready());
276        }
277    }
278
279    #[test]
280    fn mixed_ma_types_per_line() {
281        let prices: Vec<f64> = (0..120).map(|i| 100.0 + f64::from(i)).collect();
282        let mut m = MacdExt::new(12, MaType::Wma, 26, MaType::Dema, 9, MaType::Trima).unwrap();
283        let last = m.batch(&prices).into_iter().flatten().last();
284        assert!(last.is_some());
285    }
286}