Skip to main content

wickra_core/indicators/
heikin_ashi.rs

1//! Heikin-Ashi candle transform.
2#![allow(clippy::manual_midpoint)]
3//!
4//! Heikin-Ashi ("average bar" in Japanese) smooths an OHLC candle stream so
5//! trends are easier to read at a glance. The transform is purely local except
6//! that `ha_open` depends on the *previous* Heikin-Ashi candle, so it remains
7//! a streaming O(1) state machine.
8
9use crate::ohlcv::Candle;
10use crate::traits::Indicator;
11
12/// One Heikin-Ashi candle.
13///
14/// Fields use the same names as the source `Candle` but represent the
15/// transformed OHLC.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct HeikinAshiOutput {
18    /// Heikin-Ashi open: midpoint of the previous Heikin-Ashi open and close.
19    pub open: f64,
20    /// Heikin-Ashi high: `max(real high, ha_open, ha_close)`.
21    pub high: f64,
22    /// Heikin-Ashi low: `min(real low, ha_open, ha_close)`.
23    pub low: f64,
24    /// Heikin-Ashi close: average of the real open, high, low, close.
25    pub close: f64,
26}
27
28/// Streaming Heikin-Ashi transform.
29///
30/// Emits a [`HeikinAshiOutput`] for every input bar starting with the very
31/// first, so `warmup_period` is 1 and `batch` returns `n` outputs for `n`
32/// inputs.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Candle, HeikinAshi, Indicator};
38///
39/// let mut ha = HeikinAshi::new();
40/// let c = Candle::new(10.0, 11.0, 9.0, 10.5, 0.0, 0).unwrap();
41/// let out = ha.update(c).unwrap();
42/// // First bar: ha_open = (open + close) / 2 = 10.25.
43/// assert!((out.open - 10.25).abs() < 1e-12);
44/// ```
45#[derive(Debug, Clone, Default)]
46pub struct HeikinAshi {
47    prev: Option<HeikinAshiOutput>,
48}
49
50impl HeikinAshi {
51    /// Construct a fresh transform with no prior state.
52    #[must_use]
53    pub const fn new() -> Self {
54        Self { prev: None }
55    }
56
57    /// Most recently emitted Heikin-Ashi candle, if any.
58    pub const fn value(&self) -> Option<HeikinAshiOutput> {
59        self.prev
60    }
61}
62
63impl Indicator for HeikinAshi {
64    type Input = Candle;
65    type Output = HeikinAshiOutput;
66
67    fn update(&mut self, candle: Candle) -> Option<HeikinAshiOutput> {
68        let ha_close = (candle.open + candle.high + candle.low + candle.close) / 4.0;
69        let ha_open = match self.prev {
70            Some(p) => f64::midpoint(p.open, p.close),
71            // Seed: average of the real open and close.
72            None => f64::midpoint(candle.open, candle.close),
73        };
74        let ha_high = candle.high.max(ha_open).max(ha_close);
75        let ha_low = candle.low.min(ha_open).min(ha_close);
76        let out = HeikinAshiOutput {
77            open: ha_open,
78            high: ha_high,
79            low: ha_low,
80            close: ha_close,
81        };
82        self.prev = Some(out);
83        Some(out)
84    }
85
86    fn reset(&mut self) {
87        self.prev = None;
88    }
89
90    fn warmup_period(&self) -> usize {
91        1
92    }
93
94    fn is_ready(&self) -> bool {
95        self.prev.is_some()
96    }
97
98    fn name(&self) -> &'static str {
99        "HeikinAshi"
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::traits::BatchExt;
107    use approx::assert_relative_eq;
108
109    fn cnd(o: f64, h: f64, l: f64, c: f64) -> Candle {
110        Candle::new(o, h, l, c, 0.0, 0).unwrap()
111    }
112
113    #[test]
114    fn first_bar_seeds_open_from_real_open_close() {
115        let mut ha = HeikinAshi::new();
116        let out = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap();
117        assert_relative_eq!(out.open, (10.0 + 11.0) / 2.0, epsilon = 1e-12);
118        assert_relative_eq!(out.close, (10.0 + 12.0 + 9.0 + 11.0) / 4.0, epsilon = 1e-12);
119        // high/low must envelope ha_open & ha_close along with the real H/L.
120        assert!(out.high >= out.open);
121        assert!(out.high >= out.close);
122        assert!(out.low <= out.open);
123        assert!(out.low <= out.close);
124    }
125
126    #[test]
127    fn second_bar_uses_previous_ha_midpoint_as_open() {
128        let mut ha = HeikinAshi::new();
129        let first = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap();
130        let second = ha.update(cnd(11.5, 13.0, 10.5, 12.0)).unwrap();
131        assert_relative_eq!(
132            second.open,
133            (first.open + first.close) / 2.0,
134            epsilon = 1e-12
135        );
136        assert_relative_eq!(
137            second.close,
138            (11.5 + 13.0 + 10.5 + 12.0) / 4.0,
139            epsilon = 1e-12
140        );
141    }
142
143    #[test]
144    fn batch_equals_streaming() {
145        let candles: Vec<Candle> = (0..50)
146            .map(|i| {
147                let p = 100.0 + f64::from(i);
148                cnd(p, p + 1.5, p - 1.5, p + 0.5)
149            })
150            .collect();
151        let mut a = HeikinAshi::new();
152        let mut b = HeikinAshi::new();
153        let batched = a.batch(&candles);
154        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
155        assert_eq!(batched, streamed);
156    }
157
158    #[test]
159    fn ready_after_first_update() {
160        let mut ha = HeikinAshi::new();
161        assert!(!ha.is_ready());
162        ha.update(cnd(10.0, 11.0, 9.0, 10.5));
163        assert!(ha.is_ready());
164    }
165
166    #[test]
167    fn reset_clears_state() {
168        let mut ha = HeikinAshi::new();
169        ha.update(cnd(10.0, 11.0, 9.0, 10.5));
170        assert!(ha.is_ready());
171        ha.reset();
172        assert!(!ha.is_ready());
173        assert!(ha.value().is_none());
174        // After reset, the next bar re-seeds from real open/close.
175        let out = ha.update(cnd(20.0, 22.0, 18.0, 21.0)).unwrap();
176        assert_relative_eq!(out.open, (20.0 + 21.0) / 2.0, epsilon = 1e-12);
177    }
178
179    #[test]
180    fn metadata() {
181        let ha = HeikinAshi::new();
182        assert_eq!(ha.warmup_period(), 1);
183        assert_eq!(ha.name(), "HeikinAshi");
184    }
185
186    #[test]
187    fn high_envelopes_open_and_close() {
188        // Real high below the synthetic ha_open/close still inflates ha_high.
189        let mut ha = HeikinAshi::new();
190        // Bar 1 sets a baseline.
191        ha.update(cnd(100.0, 101.0, 99.0, 100.5));
192        // Bar 2 with an extreme close — ha_close = (50+50+50+200)/4 = 87.5,
193        // ha_open = midpoint of prev open/close — and a real high of 200.
194        let out = ha.update(cnd(50.0, 200.0, 50.0, 200.0)).unwrap();
195        assert_eq!(out.high, 200.0);
196        assert!(out.low <= out.open.min(out.close));
197    }
198}