wickra_core/indicators/
macd_histogram.rs1use crate::error::Result;
4use crate::indicators::macd::MacdIndicator;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone)]
40pub struct MacdHistogram {
41 macd: MacdIndicator,
42}
43
44impl MacdHistogram {
45 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 pub fn classic() -> Self {
59 Self::new(12, 26, 9).expect("classic MACD periods are valid")
60 }
61
62 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 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}