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