Skip to main content

wickra_core/indicators/
macd_histogram.rs

1//! MACD Histogram (standalone).
2
3use crate::error::Result;
4use crate::indicators::macd::MacdIndicator;
5use crate::traits::Indicator;
6
7/// MACD Histogram — the `macd − signal` bar of [`MacdIndicator`] as a
8/// standalone scalar indicator.
9///
10/// ```text
11/// macd      = EMA(fast) − EMA(slow)
12/// signal    = EMA(macd, signal)
13/// histogram = macd − signal
14/// ```
15///
16/// The histogram is the most actively traded part of MACD: it crosses zero
17/// exactly when the MACD line crosses its signal, and its slope measures
18/// whether that momentum is accelerating or fading. This wrapper exposes just
19/// that series for pipelines that want a plain `f64` stream rather than the
20/// full [`MacdOutput`](crate::MacdOutput); for the line and signal alongside
21/// it, use [`MacdIndicator`](crate::MacdIndicator) directly.
22///
23/// Standard parameters are `fast = 12`, `slow = 26`, `signal = 9`, so the
24/// first value lands after `slow + signal − 1` inputs — exactly when
25/// [`MacdIndicator`] emits its first full output.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Indicator, MacdHistogram};
31///
32/// let mut indicator = MacdHistogram::new(12, 26, 9).unwrap();
33/// let mut last = None;
34/// for i in 0..80 {
35///     last = indicator.update(100.0 + f64::from(i));
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct MacdHistogram {
41    macd: MacdIndicator,
42}
43
44impl MacdHistogram {
45    /// Construct a MACD histogram with the given periods.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`Error::PeriodZero`] if any period is zero, and
50    /// [`Error::InvalidPeriod`] if `fast >= slow`.
51    pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
52        Ok(Self {
53            macd: MacdIndicator::new(fast, slow, signal)?,
54        })
55    }
56
57    /// Default `(12, 26, 9)` configuration, matching every classical chart package.
58    pub fn classic() -> Self {
59        Self::new(12, 26, 9).expect("classic MACD periods are valid")
60    }
61
62    /// Configured periods as `(fast, slow, signal)`.
63    pub const fn periods(&self) -> (usize, usize, usize) {
64        self.macd.periods()
65    }
66}
67
68impl Indicator for MacdHistogram {
69    type Input = f64;
70    type Output = f64;
71
72    fn update(&mut self, input: f64) -> Option<f64> {
73        self.macd.update(input).map(|out| out.histogram)
74    }
75
76    fn reset(&mut self) {
77        self.macd.reset();
78    }
79
80    fn warmup_period(&self) -> usize {
81        self.macd.warmup_period()
82    }
83
84    fn is_ready(&self) -> bool {
85        self.macd.is_ready()
86    }
87
88    fn name(&self) -> &'static str {
89        "MacdHistogram"
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::error::Error;
97    use crate::traits::BatchExt;
98    use approx::assert_relative_eq;
99
100    #[test]
101    fn rejects_invalid_periods() {
102        assert!(matches!(
103            MacdHistogram::new(0, 26, 9),
104            Err(Error::PeriodZero)
105        ));
106        assert!(matches!(
107            MacdHistogram::new(12, 26, 0),
108            Err(Error::PeriodZero)
109        ));
110        assert!(matches!(
111            MacdHistogram::new(26, 12, 9),
112            Err(Error::InvalidPeriod { .. })
113        ));
114    }
115
116    #[test]
117    fn accessors_and_metadata() {
118        let osc = MacdHistogram::classic();
119        assert_eq!(osc.periods(), (12, 26, 9));
120        assert_eq!(osc.name(), "MacdHistogram");
121        assert_eq!(osc.warmup_period(), 26 + 9 - 1);
122        assert!(!osc.is_ready());
123    }
124
125    #[test]
126    fn equals_macd_histogram_field() {
127        // The standalone series must be exactly MacdIndicator's histogram bar.
128        let prices: Vec<f64> = (1..=120)
129            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 8.0)
130            .collect();
131        let hist = MacdHistogram::classic().batch(&prices);
132        let full = MacdIndicator::classic().batch(&prices);
133        assert_eq!(hist.len(), full.len());
134        for (h, m) in hist.iter().zip(full.iter()) {
135            assert_eq!(h.is_some(), m.is_some());
136            if let (Some(h), Some(m)) = (h, m) {
137                assert_relative_eq!(*h, m.histogram, epsilon = 1e-12);
138            }
139        }
140    }
141
142    #[test]
143    fn warmup_emits_first_value_at_warmup_period() {
144        let mut osc = MacdHistogram::new(3, 6, 3).unwrap();
145        let warmup = osc.warmup_period();
146        assert_eq!(warmup, 6 + 3 - 1);
147        for i in 1..warmup {
148            assert!(osc.update(100.0 + i as f64).is_none());
149        }
150        assert!(osc.update(100.0 + warmup as f64).is_some());
151        assert!(osc.is_ready());
152    }
153
154    #[test]
155    fn constant_series_converges_to_zero() {
156        let mut osc = MacdHistogram::classic();
157        let out = osc.batch(&[100.0_f64; 200]);
158        let last = out.iter().rev().flatten().next().expect("emits a value");
159        assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
160    }
161
162    #[test]
163    fn batch_equals_streaming() {
164        let prices: Vec<f64> = (1..=100)
165            .map(|i| (f64::from(i) * 0.4).cos() * 10.0)
166            .collect();
167        let mut a = MacdHistogram::classic();
168        let mut b = MacdHistogram::classic();
169        assert_eq!(
170            a.batch(&prices),
171            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
172        );
173    }
174
175    #[test]
176    fn reset_clears_state() {
177        let mut osc = MacdHistogram::classic();
178        osc.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
179        assert!(osc.is_ready());
180        osc.reset();
181        assert!(!osc.is_ready());
182        assert_eq!(osc.update(1.0), None);
183    }
184}