Skip to main content

wickra_core/indicators/
smoothed_heikin_ashi.rs

1//! Smoothed Heikin-Ashi — Heikin-Ashi computed on EMA-smoothed OHLC.
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// One smoothed Heikin-Ashi candle.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct SmoothedHeikinAshiOutput {
11    /// Smoothed Heikin-Ashi open.
12    pub open: f64,
13    /// Smoothed Heikin-Ashi high.
14    pub high: f64,
15    /// Smoothed Heikin-Ashi low.
16    pub low: f64,
17    /// Smoothed Heikin-Ashi close.
18    pub close: f64,
19}
20
21/// Smoothed Heikin-Ashi — the [`HeikinAshi`](crate::HeikinAshi) transform applied
22/// to **EMA-smoothed** OHLC, for an even cleaner trend view.
23///
24/// ```text
25/// eo, eh, el, ec = EMA(open|high|low|close, period)
26/// ha_close = (eo + eh + el + ec) / 4
27/// ha_open  = (prev_ha_open + prev_ha_close) / 2     (seeded with (eo + ec)/2)
28/// ha_high  = max(eh, ha_open, ha_close)
29/// ha_low   = min(el, ha_open, ha_close)
30/// ```
31///
32/// Standard Heikin-Ashi already averages the OHLC; smoothing each input series
33/// with an EMA *before* the transform removes still more noise, producing long,
34/// uninterrupted runs of same-colour candles in a trend and crisp colour flips at
35/// turns. The trade-off is added lag proportional to `period`. The output uses the
36/// same OHLC field layout as a candle so it can be charted directly.
37///
38/// The first value lands once the EMAs are seeded (`period` inputs). Each `update`
39/// is O(1).
40///
41/// # Example
42///
43/// ```
44/// use wickra_core::{Candle, Indicator, SmoothedHeikinAshi};
45///
46/// let mut indicator = SmoothedHeikinAshi::new(10).unwrap();
47/// let mut last = None;
48/// for i in 0..40 {
49///     let base = 100.0 + f64::from(i);
50///     let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
51///     last = indicator.update(c);
52/// }
53/// assert!(last.is_some());
54/// ```
55#[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    /// Construct a smoothed Heikin-Ashi with the given EMA `period`.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`Error::PeriodZero`] if `period == 0`.
72    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    /// Configured smoothing period.
93    pub const fn period(&self) -> usize {
94        self.period
95    }
96
97    /// Current value if available.
98    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}