wickra_core/indicators/
ehma.rs1use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone)]
37pub struct Ehma {
38 period: usize,
39 half_ema: Ema,
40 full_ema: Ema,
41 smooth_ema: Ema,
42}
43
44impl Ehma {
45 pub fn new(period: usize) -> Result<Self> {
48 if period == 0 {
49 return Err(Error::PeriodZero);
50 }
51 if period > crate::error::MAX_PERIOD {
52 return Err(Error::InvalidPeriod {
53 message: crate::error::PERIOD_ABOVE_MAX,
54 });
55 }
56 let half = (period / 2).max(1);
57 let smooth = (period as f64).sqrt().round() as usize;
58 let smooth = smooth.max(1);
59 Ok(Self {
60 period,
61 half_ema: Ema::new(half)?,
62 full_ema: Ema::new(period)?,
63 smooth_ema: Ema::new(smooth)?,
64 })
65 }
66
67 pub const fn period(&self) -> usize {
69 self.period
70 }
71}
72
73impl Indicator for Ehma {
74 type Input = f64;
75 type Output = f64;
76
77 #[inline]
78 fn update(&mut self, input: f64) -> Option<f64> {
79 let h = self.half_ema.update(input);
83 let f = self.full_ema.update(input);
84 let (h, f) = (h?, f?);
85 let diff = 2.0 * h - f;
86 self.smooth_ema.update(diff)
87 }
88
89 fn reset(&mut self) {
90 self.half_ema.reset();
91 self.full_ema.reset();
92 self.smooth_ema.reset();
93 }
94
95 #[inline]
96 fn warmup_period(&self) -> usize {
97 let sm = (self.period as f64).sqrt().round() as usize;
100 self.period + sm.max(1) - 1
101 }
102
103 #[inline]
104 fn is_ready(&self) -> bool {
105 self.smooth_ema.is_ready()
106 }
107
108 #[inline]
109 fn name(&self) -> &'static str {
110 "EHMA"
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::traits::BatchExt;
118 use approx::assert_relative_eq;
119
120 #[test]
121 fn constant_series_yields_constant_ehma() {
122 let mut ehma = Ehma::new(9).unwrap();
123 let out = ehma.batch(&[10.0_f64; 80]);
124 let last = out.iter().rev().flatten().next().unwrap();
125 assert_relative_eq!(*last, 10.0, epsilon = 1e-9);
126 }
127
128 #[test]
129 fn batch_equals_streaming() {
130 let prices: Vec<f64> = (1..=100).map(|i| f64::from(i) * 0.7).collect();
131 let mut a = Ehma::new(9).unwrap();
132 let mut b = Ehma::new(9).unwrap();
133 assert_eq!(
134 a.batch(&prices),
135 prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
136 );
137 }
138
139 #[test]
140 fn reset_clears_state() {
141 let mut ehma = Ehma::new(9).unwrap();
142 ehma.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
143 assert!(ehma.is_ready());
144 ehma.reset();
145 assert!(!ehma.is_ready());
146 }
147
148 #[test]
149 fn rejects_zero_period() {
150 assert!(Ehma::new(0).is_err());
151 }
152
153 #[test]
156 fn accessors_and_metadata() {
157 let ehma = Ehma::new(9).unwrap();
158 assert_eq!(ehma.period(), 9);
159 assert_eq!(ehma.name(), "EHMA");
160 }
161
162 #[test]
163 fn first_emission_matches_warmup_period() {
164 let prices: Vec<f64> = (1..=40).map(f64::from).collect();
165 let mut ehma = Ehma::new(9).unwrap();
166 let out = ehma.batch(&prices);
167 let warmup = ehma.warmup_period();
168 assert_eq!(warmup, 11);
170 for (i, v) in out.iter().enumerate().take(warmup - 1) {
171 assert!(v.is_none(), "index {i} must be None during warmup");
172 }
173 assert!(
174 out[warmup - 1].is_some(),
175 "first EHMA value must land at warmup_period - 1"
176 );
177 }
178
179 #[test]
180 fn matches_independent_emas() {
181 let prices: Vec<f64> = (1..=50)
184 .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
185 .collect();
186 let mut ehma = Ehma::new(9).unwrap();
187 let mut half = Ema::new(4).unwrap(); let mut full = Ema::new(9).unwrap();
189 let mut smooth = Ema::new(3).unwrap(); for (i, &p) in prices.iter().enumerate() {
191 let got = ehma.update(p);
192 let want = match (half.update(p), full.update(p)) {
193 (Some(h), Some(f)) => smooth.update(2.0 * h - f),
194 _ => None,
195 };
196 assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
197 if let (Some(a), Some(b)) = (got, want) {
198 assert_relative_eq!(a, b, epsilon = 1e-9);
199 }
200 }
201 }
202
203 #[test]
204 fn period_one_collapses_to_pass_through() {
205 let mut ehma = Ehma::new(1).unwrap();
208 assert_relative_eq!(ehma.update(5.0).unwrap(), 5.0, epsilon = 1e-12);
209 assert_relative_eq!(ehma.update(8.0).unwrap(), 8.0, epsilon = 1e-12);
210 }
211}