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}