wickra_core/indicators/
smoothed_heikin_ashi.rs1use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct SmoothedHeikinAshiOutput {
11 pub open: f64,
13 pub high: f64,
15 pub low: f64,
17 pub close: f64,
19}
20
21#[derive(Debug, Clone)]
56pub struct SmoothedHeikinAshi {
57 period: usize,
58 ema_open: Ema,
59 ema_high: Ema,
60 ema_low: Ema,
61 ema_close: Ema,
62 prev: Option<SmoothedHeikinAshiOutput>,
63 last: Option<SmoothedHeikinAshiOutput>,
64}
65
66impl SmoothedHeikinAshi {
67 pub fn new(period: usize) -> Result<Self> {
73 if period == 0 {
74 return Err(Error::PeriodZero);
75 }
76 if period > crate::error::MAX_PERIOD {
77 return Err(Error::InvalidPeriod {
78 message: crate::error::PERIOD_ABOVE_MAX,
79 });
80 }
81 Ok(Self {
82 period,
83 ema_open: Ema::new(period)?,
84 ema_high: Ema::new(period)?,
85 ema_low: Ema::new(period)?,
86 ema_close: Ema::new(period)?,
87 prev: None,
88 last: None,
89 })
90 }
91
92 pub const fn period(&self) -> usize {
94 self.period
95 }
96
97 pub const fn value(&self) -> Option<SmoothedHeikinAshiOutput> {
99 self.last
100 }
101}
102
103impl Indicator for SmoothedHeikinAshi {
104 type Input = Candle;
105 type Output = SmoothedHeikinAshiOutput;
106
107 #[inline]
108 fn update(&mut self, candle: Candle) -> Option<SmoothedHeikinAshiOutput> {
109 let eo = self.ema_open.update(candle.open);
110 let eh = self.ema_high.update(candle.high);
111 let el = self.ema_low.update(candle.low);
112 let ec = self.ema_close.update(candle.close);
113 let (Some(eo), Some(eh), Some(el), Some(ec)) = (eo, eh, el, ec) else {
114 return None;
115 };
116 let ha_close = (eo + eh + el + ec) / 4.0;
117 let ha_open = match self.prev {
118 Some(p) => f64::midpoint(p.open, p.close),
119 None => f64::midpoint(eo, ec),
120 };
121 let ha_high = eh.max(ha_open).max(ha_close);
122 let ha_low = el.min(ha_open).min(ha_close);
123 let out = SmoothedHeikinAshiOutput {
124 open: ha_open,
125 high: ha_high,
126 low: ha_low,
127 close: ha_close,
128 };
129 self.prev = Some(out);
130 self.last = Some(out);
131 Some(out)
132 }
133
134 fn reset(&mut self) {
135 self.ema_open.reset();
136 self.ema_high.reset();
137 self.ema_low.reset();
138 self.ema_close.reset();
139 self.prev = None;
140 self.last = None;
141 }
142
143 #[inline]
144 fn warmup_period(&self) -> usize {
145 self.period
146 }
147
148 #[inline]
149 fn is_ready(&self) -> bool {
150 self.last.is_some()
151 }
152
153 #[inline]
154 fn name(&self) -> &'static str {
155 "SmoothedHeikinAshi"
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::traits::BatchExt;
163
164 fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
165 Candle::new_unchecked(open, high, low, close, 1_000.0, 0)
166 }
167
168 #[test]
169 fn rejects_zero_period() {
170 assert!(matches!(SmoothedHeikinAshi::new(0), Err(Error::PeriodZero)));
171 }
172
173 #[test]
174 fn accessors_and_metadata() {
175 let s = SmoothedHeikinAshi::new(10).unwrap();
176 assert_eq!(s.period(), 10);
177 assert_eq!(s.warmup_period(), 10);
178 assert_eq!(s.name(), "SmoothedHeikinAshi");
179 assert!(!s.is_ready());
180 assert_eq!(s.value(), None);
181 }
182
183 #[test]
184 fn first_emission_at_warmup_period() {
185 let mut s = SmoothedHeikinAshi::new(3).unwrap();
186 let candles: Vec<Candle> = (0..6)
187 .map(|i| {
188 let b = 100.0 + f64::from(i);
189 c(b, b + 1.0, b - 1.0, b + 0.5)
190 })
191 .collect();
192 let out = s.batch(&candles);
193 for v in out.iter().take(2) {
194 assert!(v.is_none());
195 }
196 assert!(out[2].is_some());
197 }
198
199 #[test]
200 fn high_brackets_open_close() {
201 let mut s = SmoothedHeikinAshi::new(3).unwrap();
202 let candles: Vec<Candle> = (0..30)
203 .map(|i| {
204 let b = 100.0 + f64::from(i);
205 c(b, b + 2.0, b - 2.0, b + 0.5)
206 })
207 .collect();
208 for o in s.batch(&candles).into_iter().flatten() {
209 assert!(o.high >= o.open && o.high >= o.close);
210 assert!(o.low <= o.open && o.low <= o.close);
211 }
212 }
213
214 #[test]
215 fn uptrend_close_above_open() {
216 let mut s = SmoothedHeikinAshi::new(3).unwrap();
217 let candles: Vec<Candle> = (0..30)
218 .map(|i| {
219 let b = 100.0 + 2.0 * f64::from(i);
220 c(b, b + 1.0, b - 1.0, b + 0.5)
221 })
222 .collect();
223 let o = s.batch(&candles).into_iter().flatten().last().unwrap();
224 assert!(
225 o.close > o.open,
226 "an uptrend should print a bullish smoothed HA candle"
227 );
228 }
229
230 #[test]
231 fn reset_clears_state() {
232 let mut s = SmoothedHeikinAshi::new(3).unwrap();
233 s.batch(
234 &(0..10)
235 .map(|i| {
236 let b = 100.0 + f64::from(i);
237 c(b, b + 1.0, b - 1.0, b)
238 })
239 .collect::<Vec<_>>(),
240 );
241 assert!(s.is_ready());
242 s.reset();
243 assert!(!s.is_ready());
244 assert_eq!(s.value(), None);
245 assert_eq!(s.update(c(100.0, 101.0, 99.0, 100.0)), None);
246 }
247
248 #[test]
249 fn batch_equals_streaming() {
250 let candles: Vec<Candle> = (0..80)
251 .map(|i| {
252 let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
253 c(b, b + 1.0, b - 1.0, b + 0.3)
254 })
255 .collect();
256 let batch = SmoothedHeikinAshi::new(10).unwrap().batch(&candles);
257 let mut b = SmoothedHeikinAshi::new(10).unwrap();
258 let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
259 assert_eq!(batch, streamed);
260 }
261}