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 [`crate::Error::PeriodZero`] if any period is zero, and
50    /// [`crate::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    #[inline]
73    fn update(&mut self, input: f64) -> Option<f64> {
74        self.macd.update(input).map(|out| out.histogram)
75    }
76
77    fn reset(&mut self) {
78        self.macd.reset();
79    }
80
81    #[inline]
82    fn warmup_period(&self) -> usize {
83        self.macd.warmup_period()
84    }
85
86    #[inline]
87    fn is_ready(&self) -> bool {
88        self.macd.is_ready()
89    }
90
91    #[inline]
92    fn name(&self) -> &'static str {
93        "MacdHistogram"
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::error::Error;
101    use crate::traits::BatchExt;
102    use approx::assert_relative_eq;
103
104    #[test]
105    fn rejects_invalid_periods() {
106        assert!(matches!(
107            MacdHistogram::new(0, 26, 9),
108            Err(Error::PeriodZero)
109        ));
110        assert!(matches!(
111            MacdHistogram::new(12, 26, 0),
112            Err(Error::PeriodZero)
113        ));
114        assert!(matches!(
115            MacdHistogram::new(26, 12, 9),
116            Err(Error::InvalidPeriod { .. })
117        ));
118    }
119
120    #[test]
121    fn accessors_and_metadata() {
122        let osc = MacdHistogram::classic();
123        assert_eq!(osc.periods(), (12, 26, 9));
124        assert_eq!(osc.name(), "MacdHistogram");
125        assert_eq!(osc.warmup_period(), 26 + 9 - 1);
126        assert!(!osc.is_ready());
127    }
128
129    #[test]
130    fn equals_macd_histogram_field() {
131        // The standalone series must be exactly MacdIndicator's histogram bar.
132        let prices: Vec<f64> = (1..=120)
133            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 8.0)
134            .collect();
135        let hist = MacdHistogram::classic().batch(&prices);
136        let full = MacdIndicator::classic().batch(&prices);
137        assert_eq!(hist.len(), full.len());
138        for (h, m) in hist.iter().zip(full.iter()) {
139            assert_eq!(h.is_some(), m.is_some());
140            if let (Some(h), Some(m)) = (h, m) {
141                assert_relative_eq!(*h, m.histogram, epsilon = 1e-12);
142            }
143        }
144    }
145
146    #[test]
147    fn warmup_emits_first_value_at_warmup_period() {
148        let mut osc = MacdHistogram::new(3, 6, 3).unwrap();
149        let warmup = osc.warmup_period();
150        assert_eq!(warmup, 6 + 3 - 1);
151        for i in 1..warmup {
152            assert!(osc.update(100.0 + i as f64).is_none());
153        }
154        assert!(osc.update(100.0 + warmup as f64).is_some());
155        assert!(osc.is_ready());
156    }
157
158    #[test]
159    fn constant_series_converges_to_zero() {
160        let mut osc = MacdHistogram::classic();
161        let out = osc.batch(&[100.0_f64; 200]);
162        let last = out.iter().rev().flatten().next().expect("emits a value");
163        assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
164    }
165
166    #[test]
167    fn batch_equals_streaming() {
168        let prices: Vec<f64> = (1..=100)
169            .map(|i| (f64::from(i) * 0.4).cos() * 10.0)
170            .collect();
171        let mut a = MacdHistogram::classic();
172        let mut b = MacdHistogram::classic();
173        assert_eq!(
174            a.batch(&prices),
175            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
176        );
177    }
178
179    #[test]
180    fn reset_clears_state() {
181        let mut osc = MacdHistogram::classic();
182        osc.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
183        assert!(osc.is_ready());
184        osc.reset();
185        assert!(!osc.is_ready());
186        assert_eq!(osc.update(1.0), None);
187    }
188}