feat(family-13): add Ichimoku + Heikin-Ashi (#50)

Two new indicators in a brand-new "Ichimoku & alternative charts"
family:

- `Ichimoku` (Ichimoku Kinko Hyo): the full five-line cloud system
  (Tenkan-sen, Kijun-sen, Senkou Span A/B, Chikou Span). Classic
  (9, 26, 52, 26) defaults; configurable. Forward displacement is
  handled in an O(1) ring buffer so the visible Senkou A/B at bar n
  are the values computed at bar n-displacement.
- `HeikinAshi`: recursive candle smoothing transform emitting a
  four-field synthetic candle. Seeds ha_open from (open+close)/2 on
  the first bar.

Touchpoints: core + unit tests, mod.rs/lib.rs re-exports, Python +
Node + WASM bindings (multi-output via PyArray2 / interleaved Vec<f64>
/ Object+Float64Array), Python tests across smoke/new-indicators/
input-validation, Node parity tests, fuzz target (Candle), benches,
README family table + counter (71 -> 73, 8 -> 9 families), CHANGELOG.

Note: Renko, Kagi, and Point & Figure from the family-13 ideas list
are intentionally skipped. They are bar generators (the bar boundary
is defined by price moves, not by a fixed time interval) rather than
indicators that consume a candle stream, and belong in wickra-data
as candle/tick transforms alongside the existing tick-to-candle
aggregator and resampler.
This commit is contained in:
kingchenc
2026-05-25 23:02:29 +02:00
committed by GitHub
parent b971e671b4
commit 5aa0949bce
17 changed files with 1423 additions and 27 deletions
@@ -0,0 +1,198 @@
//! Heikin-Ashi candle transform.
#![allow(clippy::manual_midpoint)]
//!
//! Heikin-Ashi ("average bar" in Japanese) smooths an OHLC candle stream so
//! trends are easier to read at a glance. The transform is purely local except
//! that `ha_open` depends on the *previous* Heikin-Ashi candle, so it remains
//! a streaming O(1) state machine.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// One Heikin-Ashi candle.
///
/// Fields use the same names as the source `Candle` but represent the
/// transformed OHLC.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HeikinAshiOutput {
/// Heikin-Ashi open: midpoint of the previous Heikin-Ashi open and close.
pub open: f64,
/// Heikin-Ashi high: `max(real high, ha_open, ha_close)`.
pub high: f64,
/// Heikin-Ashi low: `min(real low, ha_open, ha_close)`.
pub low: f64,
/// Heikin-Ashi close: average of the real open, high, low, close.
pub close: f64,
}
/// Streaming Heikin-Ashi transform.
///
/// Emits a [`HeikinAshiOutput`] for every input bar starting with the very
/// first, so `warmup_period` is 1 and `batch` returns `n` outputs for `n`
/// inputs.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, HeikinAshi, Indicator};
///
/// let mut ha = HeikinAshi::new();
/// let c = Candle::new(10.0, 11.0, 9.0, 10.5, 0.0, 0).unwrap();
/// let out = ha.update(c).unwrap();
/// // First bar: ha_open = (open + close) / 2 = 10.25.
/// assert!((out.open - 10.25).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct HeikinAshi {
prev: Option<HeikinAshiOutput>,
}
impl HeikinAshi {
/// Construct a fresh transform with no prior state.
#[must_use]
pub const fn new() -> Self {
Self { prev: None }
}
/// Most recently emitted Heikin-Ashi candle, if any.
pub const fn value(&self) -> Option<HeikinAshiOutput> {
self.prev
}
}
impl Indicator for HeikinAshi {
type Input = Candle;
type Output = HeikinAshiOutput;
fn update(&mut self, candle: Candle) -> Option<HeikinAshiOutput> {
let ha_close = (candle.open + candle.high + candle.low + candle.close) / 4.0;
let ha_open = match self.prev {
Some(p) => f64::midpoint(p.open, p.close),
// Seed: average of the real open and close.
None => f64::midpoint(candle.open, candle.close),
};
let ha_high = candle.high.max(ha_open).max(ha_close);
let ha_low = candle.low.min(ha_open).min(ha_close);
let out = HeikinAshiOutput {
open: ha_open,
high: ha_high,
low: ha_low,
close: ha_close,
};
self.prev = Some(out);
Some(out)
}
fn reset(&mut self) {
self.prev = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.prev.is_some()
}
fn name(&self) -> &'static str {
"HeikinAshi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn cnd(o: f64, h: f64, l: f64, c: f64) -> Candle {
Candle::new(o, h, l, c, 0.0, 0).unwrap()
}
#[test]
fn first_bar_seeds_open_from_real_open_close() {
let mut ha = HeikinAshi::new();
let out = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap();
assert_relative_eq!(out.open, (10.0 + 11.0) / 2.0, epsilon = 1e-12);
assert_relative_eq!(out.close, (10.0 + 12.0 + 9.0 + 11.0) / 4.0, epsilon = 1e-12);
// high/low must envelope ha_open & ha_close along with the real H/L.
assert!(out.high >= out.open);
assert!(out.high >= out.close);
assert!(out.low <= out.open);
assert!(out.low <= out.close);
}
#[test]
fn second_bar_uses_previous_ha_midpoint_as_open() {
let mut ha = HeikinAshi::new();
let first = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap();
let second = ha.update(cnd(11.5, 13.0, 10.5, 12.0)).unwrap();
assert_relative_eq!(
second.open,
(first.open + first.close) / 2.0,
epsilon = 1e-12
);
assert_relative_eq!(
second.close,
(11.5 + 13.0 + 10.5 + 12.0) / 4.0,
epsilon = 1e-12
);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let p = 100.0 + f64::from(i);
cnd(p, p + 1.5, p - 1.5, p + 0.5)
})
.collect();
let mut a = HeikinAshi::new();
let mut b = HeikinAshi::new();
let batched = a.batch(&candles);
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batched, streamed);
}
#[test]
fn ready_after_first_update() {
let mut ha = HeikinAshi::new();
assert!(!ha.is_ready());
ha.update(cnd(10.0, 11.0, 9.0, 10.5));
assert!(ha.is_ready());
}
#[test]
fn reset_clears_state() {
let mut ha = HeikinAshi::new();
ha.update(cnd(10.0, 11.0, 9.0, 10.5));
assert!(ha.is_ready());
ha.reset();
assert!(!ha.is_ready());
assert!(ha.value().is_none());
// After reset, the next bar re-seeds from real open/close.
let out = ha.update(cnd(20.0, 22.0, 18.0, 21.0)).unwrap();
assert_relative_eq!(out.open, (20.0 + 21.0) / 2.0, epsilon = 1e-12);
}
#[test]
fn metadata() {
let ha = HeikinAshi::new();
assert_eq!(ha.warmup_period(), 1);
assert_eq!(ha.name(), "HeikinAshi");
}
#[test]
fn high_envelopes_open_and_close() {
// Real high below the synthetic ha_open/close still inflates ha_high.
let mut ha = HeikinAshi::new();
// Bar 1 sets a baseline.
ha.update(cnd(100.0, 101.0, 99.0, 100.5));
// Bar 2 with an extreme close — ha_close = (50+50+50+200)/4 = 87.5,
// ha_open = midpoint of prev open/close — and a real high of 200.
let out = ha.update(cnd(50.0, 200.0, 50.0, 200.0)).unwrap();
assert_eq!(out.high, 200.0);
assert!(out.low <= out.open.min(out.close));
}
}
@@ -0,0 +1,434 @@
//! Ichimoku Kinko Hyo — the five-line cloud chart.
//!
//! The Ichimoku system bundles five distinct lines computed from highs, lows
//! and closes:
//!
//! - **Tenkan-sen** (Conversion Line): midpoint of the last `tenkan_period`
//! highs and lows (default 9).
//! - **Kijun-sen** (Base Line): midpoint over `kijun_period` (default 26).
//! - **Senkou Span A** (Leading A): `(tenkan + kijun) / 2`, shifted *forward*
//! `displacement` bars.
//! - **Senkou Span B** (Leading B): midpoint over `senkou_b_period` (default
//! 52), also shifted forward `displacement` bars.
//! - **Chikou Span** (Lagging Span): the current close, displayed `displacement`
//! bars *backwards*.
//!
//! The two Senkou Spans form the **Kumo** (cloud). At step *n* the visible
//! Senkou A/B are computed from data at step *n displacement*; the visible
//! Chikou is the close from step *n + displacement* in a chart, but in a
//! streaming setting the only Chikou we can emit at step *n* is the close from
//! *n displacement*. That convention matches every TA library that processes
//! candles in chronological order.
#![allow(clippy::too_many_arguments)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// All five Ichimoku lines at one step.
///
/// `tenkan` and `kijun` reflect data up to and including the current bar.
/// `senkou_a` / `senkou_b` are the leading-span values *visible at the current
/// bar*, computed from `displacement` bars ago. `chikou` is the close from
/// `displacement` bars ago (its "lagging" placement on charts).
///
/// Any field that is not yet defined (insufficient history) is `None`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct IchimokuOutput {
/// Tenkan-sen — midpoint of the last `tenkan_period` highs/lows.
pub tenkan: Option<f64>,
/// Kijun-sen — midpoint of the last `kijun_period` highs/lows.
pub kijun: Option<f64>,
/// Senkou Span A as visible at the current bar (computed from
/// `(tenkan + kijun) / 2` at step `n - displacement`).
pub senkou_a: Option<f64>,
/// Senkou Span B as visible at the current bar (computed from the
/// `senkou_b_period` midpoint at step `n - displacement`).
pub senkou_b: Option<f64>,
/// Chikou Span — the close from `displacement` bars ago.
pub chikou: Option<f64>,
}
/// Ichimoku Kinko Hyo indicator.
///
/// Standard parameters are `(9, 26, 52, 26)`. The first fully-populated output
/// (every field `Some`) appears after `senkou_b_period + displacement - 1`
/// candles — 77 bars at the defaults — because Senkou B needs its own 52-bar
/// midpoint *and* a 26-bar history of those midpoints to displace from.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Ichimoku, Indicator};
///
/// let mut ichi = Ichimoku::classic();
/// for i in 0..120 {
/// let p = 100.0 + f64::from(i);
/// let candle = Candle::new(p, p + 2.0, p - 2.0, p + 1.0, 0.0, i64::from(i)).unwrap();
/// ichi.update(candle);
/// }
/// let out = ichi.value().unwrap();
/// assert!(out.tenkan.is_some() && out.kijun.is_some());
/// assert!(out.senkou_a.is_some() && out.senkou_b.is_some());
/// assert!(out.chikou.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Ichimoku {
tenkan_period: usize,
kijun_period: usize,
senkou_b_period: usize,
displacement: usize,
// Rolling window of recent highs/lows for the longest lookback we need.
highs: VecDeque<f64>,
lows: VecDeque<f64>,
// Past (tenkan+kijun)/2 values used to emit the displaced Senkou A.
senkou_a_history: VecDeque<f64>,
// Past Senkou B midpoint values used to emit the displaced Senkou B.
senkou_b_history: VecDeque<f64>,
// Past closes for the lagging Chikou span.
close_history: VecDeque<f64>,
last: Option<IchimokuOutput>,
}
impl Ichimoku {
/// Construct an Ichimoku indicator with custom periods.
///
/// `tenkan_period` is the short midpoint window (default 9), `kijun_period`
/// the medium (default 26), `senkou_b_period` the long (default 52), and
/// `displacement` the forward/backward shift in bars (default 26).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any of `tenkan_period`, `kijun_period`,
/// `senkou_b_period`, or `displacement` is zero, and [`Error::InvalidPeriod`]
/// if the periods are not in strictly increasing order
/// (`tenkan < kijun < senkou_b`).
pub fn new(
tenkan_period: usize,
kijun_period: usize,
senkou_b_period: usize,
displacement: usize,
) -> Result<Self> {
if tenkan_period == 0 || kijun_period == 0 || senkou_b_period == 0 || displacement == 0 {
return Err(Error::PeriodZero);
}
if tenkan_period >= kijun_period || kijun_period >= senkou_b_period {
return Err(Error::InvalidPeriod {
message: "Ichimoku periods must satisfy tenkan < kijun < senkou_b",
});
}
let cap = senkou_b_period;
Ok(Self {
tenkan_period,
kijun_period,
senkou_b_period,
displacement,
highs: VecDeque::with_capacity(cap),
lows: VecDeque::with_capacity(cap),
senkou_a_history: VecDeque::with_capacity(displacement),
senkou_b_history: VecDeque::with_capacity(displacement),
close_history: VecDeque::with_capacity(displacement),
last: None,
})
}
/// Classical `(9, 26, 52, 26)` configuration.
pub fn classic() -> Self {
Self::new(9, 26, 52, 26).expect("classic Ichimoku periods are valid")
}
/// Configured periods as `(tenkan, kijun, senkou_b, displacement)`.
pub const fn periods(&self) -> (usize, usize, usize, usize) {
(
self.tenkan_period,
self.kijun_period,
self.senkou_b_period,
self.displacement,
)
}
/// Most recent output if at least one bar has been consumed.
pub const fn value(&self) -> Option<IchimokuOutput> {
self.last
}
/// Midpoint of the last `n` highs/lows. Assumes `self.highs.len() >= n`
/// (the caller checks).
fn midpoint(&self, n: usize) -> f64 {
let len = self.highs.len();
let start = len - n;
let mut hi = f64::NEG_INFINITY;
let mut lo = f64::INFINITY;
for i in start..len {
hi = hi.max(self.highs[i]);
lo = lo.min(self.lows[i]);
}
f64::midpoint(hi, lo)
}
}
impl Indicator for Ichimoku {
type Input = Candle;
type Output = IchimokuOutput;
fn update(&mut self, candle: Candle) -> Option<IchimokuOutput> {
// Ring-buffer the new bar; cap at the longest lookback.
if self.highs.len() == self.senkou_b_period {
self.highs.pop_front();
self.lows.pop_front();
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
let tenkan =
(self.highs.len() >= self.tenkan_period).then(|| self.midpoint(self.tenkan_period));
let kijun =
(self.highs.len() >= self.kijun_period).then(|| self.midpoint(self.kijun_period));
let senkou_b_now =
(self.highs.len() >= self.senkou_b_period).then(|| self.midpoint(self.senkou_b_period));
// Today's contribution to the leading spans (will become visible after
// `displacement` more bars).
let senkou_a_now = match (tenkan, kijun) {
(Some(t), Some(k)) => Some(f64::midpoint(t, k)),
_ => None,
};
// The currently-visible Senkou A/B at this bar are the values that were
// computed `displacement` bars ago. We always push the freshly-computed
// `senkou_a_now` / `senkou_b_now` to keep the history aligned 1:1 with
// bars; NaN encodes "no value yet" so the buffer indices stay simple.
let push_or_nan = |q: &mut VecDeque<f64>, v: Option<f64>, cap: usize| {
if q.len() == cap {
q.pop_front();
}
q.push_back(v.unwrap_or(f64::NAN));
};
push_or_nan(&mut self.senkou_a_history, senkou_a_now, self.displacement);
push_or_nan(&mut self.senkou_b_history, senkou_b_now, self.displacement);
// The visible Senkou A/B at the current bar were buffered exactly
// `displacement` updates ago, which is `self.senkou_*_history.front()`
// once the buffer is full.
let take_front = |q: &VecDeque<f64>, cap: usize| -> Option<f64> {
if q.len() == cap {
let v = q[0];
if v.is_nan() {
None
} else {
Some(v)
}
} else {
None
}
};
let senkou_a = take_front(&self.senkou_a_history, self.displacement);
let senkou_b = take_front(&self.senkou_b_history, self.displacement);
// Chikou: close from `displacement` bars ago.
if self.close_history.len() == self.displacement {
self.close_history.pop_front();
}
self.close_history.push_back(candle.close);
let chikou = (self.close_history.len() == self.displacement).then(|| self.close_history[0]);
let out = IchimokuOutput {
tenkan,
kijun,
senkou_a,
senkou_b,
chikou,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.highs.clear();
self.lows.clear();
self.senkou_a_history.clear();
self.senkou_b_history.clear();
self.close_history.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
// First fully-populated row needs senkou_b's midpoint to have travelled
// `displacement` bars forward.
self.senkou_b_period + self.displacement - 1
}
fn is_ready(&self) -> bool {
self.last.is_some_and(|o| {
o.tenkan.is_some()
&& o.kijun.is_some()
&& o.senkou_a.is_some()
&& o.senkou_b.is_some()
&& o.chikou.is_some()
})
}
fn name(&self) -> &'static str {
"Ichimoku"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64, i: i64) -> Candle {
Candle::new(cl, h, l, cl, 0.0, i).unwrap()
}
fn ramp(n: i64) -> Vec<Candle> {
(0..n)
.map(|i| {
let p = 100.0 + f64::from(i32::try_from(i).unwrap());
c(p + 2.0, p - 2.0, p + 1.0, i)
})
.collect()
}
#[test]
fn rejects_zero_periods() {
assert!(matches!(
Ichimoku::new(0, 26, 52, 26),
Err(Error::PeriodZero)
));
assert!(matches!(
Ichimoku::new(9, 0, 52, 26),
Err(Error::PeriodZero)
));
assert!(matches!(
Ichimoku::new(9, 26, 0, 26),
Err(Error::PeriodZero)
));
assert!(matches!(
Ichimoku::new(9, 26, 52, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_non_increasing_periods() {
assert!(matches!(
Ichimoku::new(26, 26, 52, 26),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Ichimoku::new(9, 52, 52, 26),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Ichimoku::new(52, 26, 9, 26),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let ichi = Ichimoku::classic();
assert_eq!(ichi.periods(), (9, 26, 52, 26));
assert_eq!(ichi.warmup_period(), 77);
assert_eq!(ichi.name(), "Ichimoku");
assert!(ichi.value().is_none());
}
#[test]
fn tenkan_emits_at_period() {
let mut ichi = Ichimoku::classic();
let candles = ramp(10);
let out = ichi.batch(&candles);
// The 9th update is the first time tenkan has 9 highs/lows.
for (i, o) in out.iter().enumerate() {
let v = o.unwrap();
if i < 8 {
assert!(v.tenkan.is_none(), "tenkan must be None until 9 bars");
} else {
assert!(v.tenkan.is_some(), "tenkan must be Some from bar 9 on");
}
}
}
#[test]
fn fully_populated_after_warmup() {
let mut ichi = Ichimoku::classic();
let candles = ramp(120);
let out = ichi.batch(&candles);
let last = out.last().unwrap().unwrap();
assert!(last.tenkan.is_some());
assert!(last.kijun.is_some());
assert!(last.senkou_a.is_some());
assert!(last.senkou_b.is_some());
assert!(last.chikou.is_some());
assert!(ichi.is_ready());
}
#[test]
fn ramp_tenkan_equals_window_midpoint() {
// On a strict ramp the midpoint of the last 9 (high, low) candles is
// the midpoint of the first and last bar in that window.
let mut ichi = Ichimoku::classic();
let candles = ramp(20);
let out = ichi.batch(&candles);
// At index 8 (9th bar), the window is bars 0..=8 with highs 102..110
// and lows 98..106. Midpoint = (110 + 98) / 2 = 104.
let v = out[8].unwrap();
assert_relative_eq!(v.tenkan.unwrap(), 104.0, epsilon = 1e-12);
}
#[test]
fn chikou_is_close_displacement_bars_back() {
let mut ichi = Ichimoku::classic();
let candles = ramp(60);
let out = ichi.batch(&candles);
// Displacement = 26; at bar index 25, chikou is the close from bar 0.
let v = out[25].unwrap();
assert_relative_eq!(v.chikou.unwrap(), candles[0].close, epsilon = 1e-12);
let v = out[50].unwrap();
assert_relative_eq!(v.chikou.unwrap(), candles[25].close, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles = ramp(120);
let mut a = Ichimoku::classic();
let mut b = Ichimoku::classic();
let batched = a.batch(&candles);
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batched.len(), streamed.len());
for (lhs, rhs) in batched.iter().zip(streamed.iter()) {
let (l, r) = (lhs.unwrap(), rhs.unwrap());
assert_eq!(l.tenkan, r.tenkan);
assert_eq!(l.kijun, r.kijun);
assert_eq!(l.senkou_a, r.senkou_a);
assert_eq!(l.senkou_b, r.senkou_b);
assert_eq!(l.chikou, r.chikou);
}
}
#[test]
fn reset_clears_state() {
let mut ichi = Ichimoku::classic();
ichi.batch(&ramp(100));
assert!(ichi.is_ready());
ichi.reset();
assert!(!ichi.is_ready());
assert!(ichi.value().is_none());
}
#[test]
fn custom_periods_accepted() {
let mut ichi = Ichimoku::new(5, 10, 20, 10).unwrap();
let out = ichi.batch(&ramp(40));
let last = out.last().unwrap().unwrap();
assert!(last.tenkan.is_some());
assert!(last.senkou_a.is_some());
}
}
+4
View File
@@ -62,11 +62,13 @@ mod force_index;
mod fractal_chaos_bands;
mod frama;
mod garman_klass;
mod heikin_ashi;
mod hilbert_dominant_cycle;
mod hilo_activator;
mod historical_volatility;
mod hma;
mod hurst_channel;
mod ichimoku;
mod inertia;
mod instantaneous_trendline;
mod inverse_fisher_transform;
@@ -226,11 +228,13 @@ pub use force_index::ForceIndex;
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
pub use frama::Frama;
pub use garman_klass::GarmanKlassVolatility;
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
pub use hilbert_dominant_cycle::HilbertDominantCycle;
pub use hilo_activator::HiLoActivator;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use ichimoku::{Ichimoku, IchimokuOutput};
pub use inertia::Inertia;
pub use instantaneous_trendline::InstantaneousTrendline;
pub use inverse_fisher_transform::InverseFisherTransform;