Skip to main content

kestrel_chartkit/
transform.rs

1//! Bar-series transformations: alternative candles derived from observed ones.
2//!
3//! What separates these from indicators is what they produce. An indicator answers with a number
4//! about a bar; a transformation answers with another bar. The prices in that bar were never
5//! traded, which is the whole reason this lives in its own type rather than in [`crate::Bar`]:
6//! a synthetic price must not reach fill, slippage or risk arithmetic by accident.
7
8use crate::model::{Bar, Provenance, SeriesIdentity};
9
10/// One Heikin-Ashi candle together with the observed bar it was derived from.
11///
12/// The OHLC values here are computed, not traded. `volume` is the *source bar's* volume, passed
13/// through unchanged — Heikin-Ashi averages prices, it does not redistribute turnover.
14///
15/// The link back to `source` is kept so that anything needing a real price (a fill, a stop
16/// distance, a risk figure) can reach it without a second lookup, and so a renderer can draw the
17/// transformed candle against the bar it belongs to.
18#[derive(Debug, Clone, PartialEq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub struct HeikinAshiBar {
21    /// Timestamp of the source bar, unchanged: one input bar yields exactly one output candle.
22    pub timestamp: i64,
23    /// `(previous ha_open + previous ha_close) / 2`; on the first bar of a series `(open + close) / 2`.
24    pub open: f64,
25    /// `max(source high, ha_open, ha_close)`.
26    pub high: f64,
27    /// `min(source low, ha_open, ha_close)`.
28    pub low: f64,
29    /// `(open + high + low + close) / 4` of the source bar.
30    pub close: f64,
31    /// The source bar's volume, unchanged.
32    pub volume: f64,
33    /// The observed bar this candle was derived from.
34    pub source: Bar,
35}
36
37impl HeikinAshiBar {
38    /// Converts to a plain [`Bar`], for the deliberate case of running an indicator over the
39    /// transformed series.
40    ///
41    /// This drops the distinction between computed and observed prices — the resulting bar looks
42    /// like any other. Pair it with [`HeikinAshiBar::synthetic_identity`] so the series it
43    /// belongs to still says where its prices came from, and keep fill, slippage and risk
44    /// arithmetic on [`HeikinAshiBar::source`].
45    pub fn to_bar(&self) -> Bar {
46        Bar::new(
47            self.timestamp,
48            self.open,
49            self.high,
50            self.low,
51            self.close,
52            self.volume,
53        )
54    }
55
56    /// The identity a transformed series carries: `base` with its provenance set to
57    /// [`Provenance::Synthetic`], since the prices are derived rather than observed.
58    pub fn synthetic_identity(base: &SeriesIdentity) -> SeriesIdentity {
59        let mut identity = base.clone();
60        identity.provenance = Provenance::Synthetic;
61        identity
62    }
63}
64
65/// Streaming Heikin-Ashi transformation.
66///
67/// From the second candle on:
68///
69/// ```text
70/// ha_close = (open + high + low + close) / 4
71/// ha_open  = (previous ha_open + previous ha_close) / 2
72/// ha_high  = max(high, ha_open, ha_close)
73/// ha_low   = min(low,  ha_open, ha_close)
74/// ```
75///
76/// The first candle has no predecessor to average, so its `ha_open` is `(open + close) / 2` of
77/// that bar. That is a decision, not a derivation: other implementations seed with `(O+H+L+C)/4`
78/// instead and produce a different opening candle, after which both converge.
79///
80/// The recursion is causal — a candle is final when its bar is — and it carries state, so a
81/// series switch must go through [`HeikinAshi::reset`] rather than continuing across the
82/// boundary; see [`SeriesIdentity`] for what makes two series different.
83#[derive(Debug, Clone, Default)]
84pub struct HeikinAshi {
85    prev: Option<(f64, f64)>,
86}
87
88impl HeikinAshi {
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Transforms one bar. Every input bar yields exactly one candle — there is no warmup.
94    pub fn update(&mut self, bar: &Bar) -> HeikinAshiBar {
95        let close = (bar.open + bar.high + bar.low + bar.close) / 4.0;
96        let open = match self.prev {
97            None => (bar.open + bar.close) / 2.0,
98            Some((prev_open, prev_close)) => (prev_open + prev_close) / 2.0,
99        };
100        self.prev = Some((open, close));
101
102        HeikinAshiBar {
103            timestamp: bar.timestamp,
104            open,
105            high: bar.high.max(open).max(close),
106            low: bar.low.min(open).min(close),
107            close,
108            volume: bar.volume,
109            source: bar.clone(),
110        }
111    }
112
113    pub fn reset(&mut self) {
114        self.prev = None;
115    }
116}