Skip to main content

wickra_core/indicators/
ichimoku.rs

1//! Ichimoku Kinko Hyo — the five-line cloud chart.
2//!
3//! The Ichimoku system bundles five distinct lines computed from highs, lows
4//! and closes:
5//!
6//! - **Tenkan-sen** (Conversion Line): midpoint of the last `tenkan_period`
7//!   highs and lows (default 9).
8//! - **Kijun-sen** (Base Line): midpoint over `kijun_period` (default 26).
9//! - **Senkou Span A** (Leading A): `(tenkan + kijun) / 2`, shifted *forward*
10//!   `displacement` bars.
11//! - **Senkou Span B** (Leading B): midpoint over `senkou_b_period` (default
12//!   52), also shifted forward `displacement` bars.
13//! - **Chikou Span** (Lagging Span): the current close, displayed `displacement`
14//!   bars *backwards*.
15//!
16//! The two Senkou Spans form the **Kumo** (cloud). At step *n* the visible
17//! Senkou A/B are computed from data at step *n − displacement*; the visible
18//! Chikou is the close from step *n + displacement* in a chart, but in a
19//! streaming setting the only Chikou we can emit at step *n* is the close from
20//! *n − displacement*. That convention matches every TA library that processes
21//! candles in chronological order.
22
23#![allow(clippy::too_many_arguments)]
24
25use std::collections::VecDeque;
26
27use crate::error::{Error, Result};
28use crate::ohlcv::Candle;
29use crate::traits::Indicator;
30
31/// All five Ichimoku lines at one step.
32///
33/// `tenkan` and `kijun` reflect data up to and including the current bar.
34/// `senkou_a` / `senkou_b` are the leading-span values *visible at the current
35/// bar*, computed from `displacement` bars ago. `chikou` is the close from
36/// `displacement` bars ago (its "lagging" placement on charts).
37///
38/// Any field that is not yet defined (insufficient history) is `None`.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct IchimokuOutput {
41    /// Tenkan-sen — midpoint of the last `tenkan_period` highs/lows.
42    pub tenkan: Option<f64>,
43    /// Kijun-sen — midpoint of the last `kijun_period` highs/lows.
44    pub kijun: Option<f64>,
45    /// Senkou Span A as visible at the current bar (computed from
46    /// `(tenkan + kijun) / 2` at step `n - displacement`).
47    pub senkou_a: Option<f64>,
48    /// Senkou Span B as visible at the current bar (computed from the
49    /// `senkou_b_period` midpoint at step `n - displacement`).
50    pub senkou_b: Option<f64>,
51    /// Chikou Span — the close from `displacement` bars ago.
52    pub chikou: Option<f64>,
53}
54
55/// Ichimoku Kinko Hyo indicator.
56///
57/// Standard parameters are `(9, 26, 52, 26)`. The first fully-populated output
58/// (every field `Some`) appears after `senkou_b_period + displacement - 1`
59/// candles — 77 bars at the defaults — because Senkou B needs its own 52-bar
60/// midpoint *and* a 26-bar history of those midpoints to displace from.
61///
62/// # Example
63///
64/// ```
65/// use wickra_core::{Candle, Ichimoku, Indicator};
66///
67/// let mut ichi = Ichimoku::classic();
68/// for i in 0..120 {
69///     let p = 100.0 + f64::from(i);
70///     let candle = Candle::new(p, p + 2.0, p - 2.0, p + 1.0, 0.0, i64::from(i)).unwrap();
71///     ichi.update(candle);
72/// }
73/// let out = ichi.value().unwrap();
74/// assert!(out.tenkan.is_some() && out.kijun.is_some());
75/// assert!(out.senkou_a.is_some() && out.senkou_b.is_some());
76/// assert!(out.chikou.is_some());
77/// ```
78#[derive(Debug, Clone)]
79pub struct Ichimoku {
80    tenkan_period: usize,
81    kijun_period: usize,
82    senkou_b_period: usize,
83    displacement: usize,
84    // Rolling window of recent highs/lows for the longest lookback we need.
85    highs: VecDeque<f64>,
86    lows: VecDeque<f64>,
87    // Past (tenkan+kijun)/2 values used to emit the displaced Senkou A.
88    senkou_a_history: VecDeque<f64>,
89    // Past Senkou B midpoint values used to emit the displaced Senkou B.
90    senkou_b_history: VecDeque<f64>,
91    // Past closes for the lagging Chikou span.
92    close_history: VecDeque<f64>,
93    last: Option<IchimokuOutput>,
94}
95
96impl Ichimoku {
97    /// Construct an Ichimoku indicator with custom periods.
98    ///
99    /// `tenkan_period` is the short midpoint window (default 9), `kijun_period`
100    /// the medium (default 26), `senkou_b_period` the long (default 52), and
101    /// `displacement` the forward/backward shift in bars (default 26).
102    ///
103    /// # Errors
104    ///
105    /// Returns [`Error::PeriodZero`] if any of `tenkan_period`, `kijun_period`,
106    /// `senkou_b_period`, or `displacement` is zero, and [`Error::InvalidPeriod`]
107    /// if the periods are not in strictly increasing order
108    /// (`tenkan < kijun < senkou_b`).
109    pub fn new(
110        tenkan_period: usize,
111        kijun_period: usize,
112        senkou_b_period: usize,
113        displacement: usize,
114    ) -> Result<Self> {
115        if tenkan_period == 0 || kijun_period == 0 || senkou_b_period == 0 || displacement == 0 {
116            return Err(Error::PeriodZero);
117        }
118        if tenkan_period >= kijun_period || kijun_period >= senkou_b_period {
119            return Err(Error::InvalidPeriod {
120                message: "Ichimoku periods must satisfy tenkan < kijun < senkou_b",
121            });
122        }
123        let cap = senkou_b_period;
124        Ok(Self {
125            tenkan_period,
126            kijun_period,
127            senkou_b_period,
128            displacement,
129            highs: VecDeque::with_capacity(cap),
130            lows: VecDeque::with_capacity(cap),
131            senkou_a_history: VecDeque::with_capacity(displacement),
132            senkou_b_history: VecDeque::with_capacity(displacement),
133            close_history: VecDeque::with_capacity(displacement),
134            last: None,
135        })
136    }
137
138    /// Classical `(9, 26, 52, 26)` configuration.
139    pub fn classic() -> Self {
140        Self::new(9, 26, 52, 26).expect("classic Ichimoku periods are valid")
141    }
142
143    /// Configured periods as `(tenkan, kijun, senkou_b, displacement)`.
144    pub const fn periods(&self) -> (usize, usize, usize, usize) {
145        (
146            self.tenkan_period,
147            self.kijun_period,
148            self.senkou_b_period,
149            self.displacement,
150        )
151    }
152
153    /// Most recent output if at least one bar has been consumed.
154    pub const fn value(&self) -> Option<IchimokuOutput> {
155        self.last
156    }
157
158    /// Midpoint of the last `n` highs/lows. Assumes `self.highs.len() >= n`
159    /// (the caller checks).
160    fn midpoint(&self, n: usize) -> f64 {
161        let len = self.highs.len();
162        let start = len - n;
163        let mut hi = f64::NEG_INFINITY;
164        let mut lo = f64::INFINITY;
165        for i in start..len {
166            hi = hi.max(self.highs[i]);
167            lo = lo.min(self.lows[i]);
168        }
169        f64::midpoint(hi, lo)
170    }
171}
172
173impl Indicator for Ichimoku {
174    type Input = Candle;
175    type Output = IchimokuOutput;
176
177    fn update(&mut self, candle: Candle) -> Option<IchimokuOutput> {
178        // Ring-buffer the new bar; cap at the longest lookback.
179        if self.highs.len() == self.senkou_b_period {
180            self.highs.pop_front();
181            self.lows.pop_front();
182        }
183        self.highs.push_back(candle.high);
184        self.lows.push_back(candle.low);
185
186        let tenkan =
187            (self.highs.len() >= self.tenkan_period).then(|| self.midpoint(self.tenkan_period));
188        let kijun =
189            (self.highs.len() >= self.kijun_period).then(|| self.midpoint(self.kijun_period));
190        let senkou_b_now =
191            (self.highs.len() >= self.senkou_b_period).then(|| self.midpoint(self.senkou_b_period));
192
193        // Today's contribution to the leading spans (will become visible after
194        // `displacement` more bars).
195        let senkou_a_now = match (tenkan, kijun) {
196            (Some(t), Some(k)) => Some(f64::midpoint(t, k)),
197            _ => None,
198        };
199
200        // The currently-visible Senkou A/B at this bar are the values that were
201        // computed `displacement` bars ago. We always push the freshly-computed
202        // `senkou_a_now` / `senkou_b_now` to keep the history aligned 1:1 with
203        // bars; NaN encodes "no value yet" so the buffer indices stay simple.
204        let push_or_nan = |q: &mut VecDeque<f64>, v: Option<f64>, cap: usize| {
205            if q.len() == cap {
206                q.pop_front();
207            }
208            q.push_back(v.unwrap_or(f64::NAN));
209        };
210        push_or_nan(&mut self.senkou_a_history, senkou_a_now, self.displacement);
211        push_or_nan(&mut self.senkou_b_history, senkou_b_now, self.displacement);
212
213        // The visible Senkou A/B at the current bar were buffered exactly
214        // `displacement` updates ago, which is `self.senkou_*_history.front()`
215        // once the buffer is full.
216        let take_front = |q: &VecDeque<f64>, cap: usize| -> Option<f64> {
217            if q.len() == cap {
218                let v = q[0];
219                if v.is_nan() {
220                    None
221                } else {
222                    Some(v)
223                }
224            } else {
225                None
226            }
227        };
228        let senkou_a = take_front(&self.senkou_a_history, self.displacement);
229        let senkou_b = take_front(&self.senkou_b_history, self.displacement);
230
231        // Chikou: close from `displacement` bars ago.
232        if self.close_history.len() == self.displacement {
233            self.close_history.pop_front();
234        }
235        self.close_history.push_back(candle.close);
236        let chikou = (self.close_history.len() == self.displacement).then(|| self.close_history[0]);
237
238        let out = IchimokuOutput {
239            tenkan,
240            kijun,
241            senkou_a,
242            senkou_b,
243            chikou,
244        };
245        self.last = Some(out);
246        Some(out)
247    }
248
249    fn reset(&mut self) {
250        self.highs.clear();
251        self.lows.clear();
252        self.senkou_a_history.clear();
253        self.senkou_b_history.clear();
254        self.close_history.clear();
255        self.last = None;
256    }
257
258    fn warmup_period(&self) -> usize {
259        // First fully-populated row needs senkou_b's midpoint to have travelled
260        // `displacement` bars forward.
261        self.senkou_b_period + self.displacement - 1
262    }
263
264    fn is_ready(&self) -> bool {
265        self.last.is_some_and(|o| {
266            o.tenkan.is_some()
267                && o.kijun.is_some()
268                && o.senkou_a.is_some()
269                && o.senkou_b.is_some()
270                && o.chikou.is_some()
271        })
272    }
273
274    fn name(&self) -> &'static str {
275        "Ichimoku"
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::traits::BatchExt;
283    use approx::assert_relative_eq;
284
285    fn c(h: f64, l: f64, cl: f64, i: i64) -> Candle {
286        Candle::new(cl, h, l, cl, 0.0, i).unwrap()
287    }
288
289    fn ramp(n: i64) -> Vec<Candle> {
290        (0..n)
291            .map(|i| {
292                let p = 100.0 + f64::from(i32::try_from(i).unwrap());
293                c(p + 2.0, p - 2.0, p + 1.0, i)
294            })
295            .collect()
296    }
297
298    #[test]
299    fn rejects_zero_periods() {
300        assert!(matches!(
301            Ichimoku::new(0, 26, 52, 26),
302            Err(Error::PeriodZero)
303        ));
304        assert!(matches!(
305            Ichimoku::new(9, 0, 52, 26),
306            Err(Error::PeriodZero)
307        ));
308        assert!(matches!(
309            Ichimoku::new(9, 26, 0, 26),
310            Err(Error::PeriodZero)
311        ));
312        assert!(matches!(
313            Ichimoku::new(9, 26, 52, 0),
314            Err(Error::PeriodZero)
315        ));
316    }
317
318    #[test]
319    fn rejects_non_increasing_periods() {
320        assert!(matches!(
321            Ichimoku::new(26, 26, 52, 26),
322            Err(Error::InvalidPeriod { .. })
323        ));
324        assert!(matches!(
325            Ichimoku::new(9, 52, 52, 26),
326            Err(Error::InvalidPeriod { .. })
327        ));
328        assert!(matches!(
329            Ichimoku::new(52, 26, 9, 26),
330            Err(Error::InvalidPeriod { .. })
331        ));
332    }
333
334    #[test]
335    fn accessors_and_metadata() {
336        let ichi = Ichimoku::classic();
337        assert_eq!(ichi.periods(), (9, 26, 52, 26));
338        assert_eq!(ichi.warmup_period(), 77);
339        assert_eq!(ichi.name(), "Ichimoku");
340        assert!(ichi.value().is_none());
341    }
342
343    #[test]
344    fn tenkan_emits_at_period() {
345        let mut ichi = Ichimoku::classic();
346        let candles = ramp(10);
347        let out = ichi.batch(&candles);
348        // The 9th update is the first time tenkan has 9 highs/lows.
349        for (i, o) in out.iter().enumerate() {
350            let v = o.unwrap();
351            if i < 8 {
352                assert!(v.tenkan.is_none(), "tenkan must be None until 9 bars");
353            } else {
354                assert!(v.tenkan.is_some(), "tenkan must be Some from bar 9 on");
355            }
356        }
357    }
358
359    #[test]
360    fn fully_populated_after_warmup() {
361        let mut ichi = Ichimoku::classic();
362        let candles = ramp(120);
363        let out = ichi.batch(&candles);
364        let last = out.last().unwrap().unwrap();
365        assert!(last.tenkan.is_some());
366        assert!(last.kijun.is_some());
367        assert!(last.senkou_a.is_some());
368        assert!(last.senkou_b.is_some());
369        assert!(last.chikou.is_some());
370        assert!(ichi.is_ready());
371    }
372
373    #[test]
374    fn ramp_tenkan_equals_window_midpoint() {
375        // On a strict ramp the midpoint of the last 9 (high, low) candles is
376        // the midpoint of the first and last bar in that window.
377        let mut ichi = Ichimoku::classic();
378        let candles = ramp(20);
379        let out = ichi.batch(&candles);
380        // At index 8 (9th bar), the window is bars 0..=8 with highs 102..110
381        // and lows 98..106. Midpoint = (110 + 98) / 2 = 104.
382        let v = out[8].unwrap();
383        assert_relative_eq!(v.tenkan.unwrap(), 104.0, epsilon = 1e-12);
384    }
385
386    #[test]
387    fn chikou_is_close_displacement_bars_back() {
388        let mut ichi = Ichimoku::classic();
389        let candles = ramp(60);
390        let out = ichi.batch(&candles);
391        // Displacement = 26; at bar index 25, chikou is the close from bar 0.
392        let v = out[25].unwrap();
393        assert_relative_eq!(v.chikou.unwrap(), candles[0].close, epsilon = 1e-12);
394        let v = out[50].unwrap();
395        assert_relative_eq!(v.chikou.unwrap(), candles[25].close, epsilon = 1e-12);
396    }
397
398    #[test]
399    fn batch_equals_streaming() {
400        let candles = ramp(120);
401        let mut a = Ichimoku::classic();
402        let mut b = Ichimoku::classic();
403        let batched = a.batch(&candles);
404        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
405        assert_eq!(batched.len(), streamed.len());
406        for (lhs, rhs) in batched.iter().zip(streamed.iter()) {
407            let (l, r) = (lhs.unwrap(), rhs.unwrap());
408            assert_eq!(l.tenkan, r.tenkan);
409            assert_eq!(l.kijun, r.kijun);
410            assert_eq!(l.senkou_a, r.senkou_a);
411            assert_eq!(l.senkou_b, r.senkou_b);
412            assert_eq!(l.chikou, r.chikou);
413        }
414    }
415
416    #[test]
417    fn reset_clears_state() {
418        let mut ichi = Ichimoku::classic();
419        ichi.batch(&ramp(100));
420        assert!(ichi.is_ready());
421        ichi.reset();
422        assert!(!ichi.is_ready());
423        assert!(ichi.value().is_none());
424    }
425
426    #[test]
427    fn custom_periods_accepted() {
428        let mut ichi = Ichimoku::new(5, 10, 20, 10).unwrap();
429        let out = ichi.batch(&ramp(40));
430        let last = out.last().unwrap().unwrap();
431        assert!(last.tenkan.is_some());
432        assert!(last.senkou_a.is_some());
433    }
434}