Files
wickra/crates/wickra-core/src/indicators/ichimoku.rs
T
kingchencandGitHub 5aa0949bce 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.
2026-05-25 23:02:29 +02:00

435 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}