//! 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, /// Kijun-sen — midpoint of the last `kijun_period` highs/lows. pub kijun: Option, /// Senkou Span A as visible at the current bar (computed from /// `(tenkan + kijun) / 2` at step `n - displacement`). pub senkou_a: Option, /// Senkou Span B as visible at the current bar (computed from the /// `senkou_b_period` midpoint at step `n - displacement`). pub senkou_b: Option, /// Chikou Span — the close from `displacement` bars ago. pub chikou: Option, } /// 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, lows: VecDeque, // Past (tenkan+kijun)/2 values used to emit the displaced Senkou A. senkou_a_history: VecDeque, // Past Senkou B midpoint values used to emit the displaced Senkou B. senkou_b_history: VecDeque, // Past closes for the lagging Chikou span. close_history: VecDeque, last: Option, } 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 { 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 { 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 { // 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, v: Option, 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, cap: usize| -> Option { 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 { (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()); } }