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    /// Whether a value has been emitted since the last reset. The trait
95    /// defines `is_ready` as exactly that, and the state this used to key
96    /// off changed at a different moment.
97    has_emitted: bool,
98}
99
100impl Ichimoku {
101    /// Construct an Ichimoku indicator with custom periods.
102    ///
103    /// `tenkan_period` is the short midpoint window (default 9), `kijun_period`
104    /// the medium (default 26), `senkou_b_period` the long (default 52), and
105    /// `displacement` the forward/backward shift in bars (default 26).
106    ///
107    /// # Errors
108    ///
109    /// Returns [`Error::PeriodZero`] if any of `tenkan_period`, `kijun_period`,
110    /// `senkou_b_period`, or `displacement` is zero, and [`Error::InvalidPeriod`]
111    /// if the periods are not in strictly increasing order
112    /// (`tenkan < kijun < senkou_b`).
113    pub fn new(
114        tenkan_period: usize,
115        kijun_period: usize,
116        senkou_b_period: usize,
117        displacement: usize,
118    ) -> Result<Self> {
119        if tenkan_period == 0 || kijun_period == 0 || senkou_b_period == 0 || displacement == 0 {
120            return Err(Error::PeriodZero);
121        }
122        if tenkan_period >= kijun_period || kijun_period >= senkou_b_period {
123            return Err(Error::InvalidPeriod {
124                message: "Ichimoku periods must satisfy tenkan < kijun < senkou_b",
125            });
126        }
127        let cap = senkou_b_period;
128        Ok(Self {
129            tenkan_period,
130            kijun_period,
131            senkou_b_period,
132            displacement,
133            highs: VecDeque::with_capacity(cap),
134            lows: VecDeque::with_capacity(cap),
135            senkou_a_history: VecDeque::with_capacity(displacement),
136            senkou_b_history: VecDeque::with_capacity(displacement),
137            close_history: VecDeque::with_capacity(displacement),
138            last: None,
139            has_emitted: false,
140        })
141    }
142
143    /// Classical `(9, 26, 52, 26)` configuration.
144    pub fn classic() -> Self {
145        Self::new(9, 26, 52, 26).expect("classic Ichimoku periods are valid")
146    }
147
148    /// Configured periods as `(tenkan, kijun, senkou_b, displacement)`.
149    pub const fn periods(&self) -> (usize, usize, usize, usize) {
150        (
151            self.tenkan_period,
152            self.kijun_period,
153            self.senkou_b_period,
154            self.displacement,
155        )
156    }
157
158    /// Most recent output if at least one bar has been consumed.
159    pub const fn value(&self) -> Option<IchimokuOutput> {
160        self.last
161    }
162
163    /// Midpoint of the last `n` highs/lows. Assumes `self.highs.len() >= n`
164    /// (the caller checks).
165    fn midpoint(&self, n: usize) -> f64 {
166        let len = self.highs.len();
167        let start = len - n;
168        let mut hi = f64::NEG_INFINITY;
169        let mut lo = f64::INFINITY;
170        for i in start..len {
171            hi = hi.max(self.highs[i]);
172            lo = lo.min(self.lows[i]);
173        }
174        f64::midpoint(hi, lo)
175    }
176}
177
178impl Indicator for Ichimoku {
179    type Input = Candle;
180    type Output = IchimokuOutput;
181
182    fn update(&mut self, candle: Candle) -> Option<IchimokuOutput> {
183        // Ring-buffer the new bar; cap at the longest lookback.
184        if self.highs.len() == self.senkou_b_period {
185            self.highs.pop_front();
186            self.lows.pop_front();
187        }
188        self.highs.push_back(candle.high);
189        self.lows.push_back(candle.low);
190
191        let tenkan =
192            (self.highs.len() >= self.tenkan_period).then(|| self.midpoint(self.tenkan_period));
193        let kijun =
194            (self.highs.len() >= self.kijun_period).then(|| self.midpoint(self.kijun_period));
195        let senkou_b_now =
196            (self.highs.len() >= self.senkou_b_period).then(|| self.midpoint(self.senkou_b_period));
197
198        // Today's contribution to the leading spans (will become visible after
199        // `displacement` more bars).
200        let senkou_a_now = match (tenkan, kijun) {
201            (Some(t), Some(k)) => Some(f64::midpoint(t, k)),
202            _ => None,
203        };
204
205        // The currently-visible Senkou A/B at this bar are the values that were
206        // computed `displacement` bars ago. We always push the freshly-computed
207        // `senkou_a_now` / `senkou_b_now` to keep the history aligned 1:1 with
208        // bars; NaN encodes "no value yet" so the buffer indices stay simple.
209        let push_or_nan = |q: &mut VecDeque<f64>, v: Option<f64>, cap: usize| {
210            if q.len() == cap {
211                q.pop_front();
212            }
213            q.push_back(v.unwrap_or(f64::NAN));
214        };
215        push_or_nan(&mut self.senkou_a_history, senkou_a_now, self.displacement);
216        push_or_nan(&mut self.senkou_b_history, senkou_b_now, self.displacement);
217
218        // The visible Senkou A/B at the current bar were buffered exactly
219        // `displacement` updates ago, which is `self.senkou_*_history.front()`
220        // once the buffer is full.
221        let take_front = |q: &VecDeque<f64>, cap: usize| -> Option<f64> {
222            if q.len() == cap {
223                let v = q[0];
224                if v.is_nan() {
225                    None
226                } else {
227                    Some(v)
228                }
229            } else {
230                None
231            }
232        };
233        let senkou_a = take_front(&self.senkou_a_history, self.displacement);
234        let senkou_b = take_front(&self.senkou_b_history, self.displacement);
235
236        // Chikou: close from `displacement` bars ago.
237        if self.close_history.len() == self.displacement {
238            self.close_history.pop_front();
239        }
240        self.close_history.push_back(candle.close);
241        let chikou = (self.close_history.len() == self.displacement).then(|| self.close_history[0]);
242
243        let out = IchimokuOutput {
244            tenkan,
245            kijun,
246            senkou_a,
247            senkou_b,
248            chikou,
249        };
250        self.last = Some(out);
251        self.has_emitted = true;
252        Some(out)
253    }
254
255    fn reset(&mut self) {
256        self.has_emitted = false;
257        self.highs.clear();
258        self.lows.clear();
259        self.senkou_a_history.clear();
260        self.senkou_b_history.clear();
261        self.close_history.clear();
262        self.last = None;
263    }
264
265    #[inline]
266    fn warmup_period(&self) -> usize {
267        // A row is emitted from the first bar: every component is an
268        // `Option`, and they fill in as the history allows. The last of them,
269        // senkou_b displaced forward, needs
270        // `senkou_b_period + displacement - 1` bars -- but that is when the row
271        // becomes *complete*, not when a value first appears, and this method
272        // promises the latter.
273        1
274    }
275
276    #[inline]
277    fn is_ready(&self) -> bool {
278        self.has_emitted
279    }
280
281    #[inline]
282    fn name(&self) -> &'static str {
283        "Ichimoku"
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::traits::BatchExt;
291    use approx::assert_relative_eq;
292
293    fn c(h: f64, l: f64, cl: f64, i: i64) -> Candle {
294        Candle::new(cl, h, l, cl, 0.0, i).unwrap()
295    }
296
297    fn ramp(n: i64) -> Vec<Candle> {
298        (0..n)
299            .map(|i| {
300                let p = 100.0 + f64::from(i32::try_from(i).unwrap());
301                c(p + 2.0, p - 2.0, p + 1.0, i)
302            })
303            .collect()
304    }
305
306    #[test]
307    fn rejects_zero_periods() {
308        assert!(matches!(
309            Ichimoku::new(0, 26, 52, 26),
310            Err(Error::PeriodZero)
311        ));
312        assert!(matches!(
313            Ichimoku::new(9, 0, 52, 26),
314            Err(Error::PeriodZero)
315        ));
316        assert!(matches!(
317            Ichimoku::new(9, 26, 0, 26),
318            Err(Error::PeriodZero)
319        ));
320        assert!(matches!(
321            Ichimoku::new(9, 26, 52, 0),
322            Err(Error::PeriodZero)
323        ));
324    }
325
326    #[test]
327    fn rejects_non_increasing_periods() {
328        assert!(matches!(
329            Ichimoku::new(26, 26, 52, 26),
330            Err(Error::InvalidPeriod { .. })
331        ));
332        assert!(matches!(
333            Ichimoku::new(9, 52, 52, 26),
334            Err(Error::InvalidPeriod { .. })
335        ));
336        assert!(matches!(
337            Ichimoku::new(52, 26, 9, 26),
338            Err(Error::InvalidPeriod { .. })
339        ));
340    }
341
342    #[test]
343    fn accessors_and_metadata() {
344        let ichi = Ichimoku::classic();
345        assert_eq!(ichi.periods(), (9, 26, 52, 26));
346        assert_eq!(ichi.warmup_period(), 1);
347        assert_eq!(ichi.name(), "Ichimoku");
348        assert!(ichi.value().is_none());
349    }
350
351    #[test]
352    fn tenkan_emits_at_period() {
353        let mut ichi = Ichimoku::classic();
354        let candles = ramp(10);
355        let out = ichi.batch(&candles);
356        // The 9th update is the first time tenkan has 9 highs/lows.
357        for (i, o) in out.iter().enumerate() {
358            let v = o.unwrap();
359            if i < 8 {
360                assert!(v.tenkan.is_none(), "tenkan must be None until 9 bars");
361            } else {
362                assert!(v.tenkan.is_some(), "tenkan must be Some from bar 9 on");
363            }
364        }
365    }
366
367    #[test]
368    fn fully_populated_after_warmup() {
369        let mut ichi = Ichimoku::classic();
370        let candles = ramp(120);
371        let out = ichi.batch(&candles);
372        let last = out.last().unwrap().unwrap();
373        assert!(last.tenkan.is_some());
374        assert!(last.kijun.is_some());
375        assert!(last.senkou_a.is_some());
376        assert!(last.senkou_b.is_some());
377        assert!(last.chikou.is_some());
378        assert!(ichi.is_ready());
379    }
380
381    #[test]
382    fn ramp_tenkan_equals_window_midpoint() {
383        // On a strict ramp the midpoint of the last 9 (high, low) candles is
384        // the midpoint of the first and last bar in that window.
385        let mut ichi = Ichimoku::classic();
386        let candles = ramp(20);
387        let out = ichi.batch(&candles);
388        // At index 8 (9th bar), the window is bars 0..=8 with highs 102..110
389        // and lows 98..106. Midpoint = (110 + 98) / 2 = 104.
390        let v = out[8].unwrap();
391        assert_relative_eq!(v.tenkan.unwrap(), 104.0, epsilon = 1e-12);
392    }
393
394    #[test]
395    fn chikou_is_close_displacement_bars_back() {
396        let mut ichi = Ichimoku::classic();
397        let candles = ramp(60);
398        let out = ichi.batch(&candles);
399        // Displacement = 26; at bar index 25, chikou is the close from bar 0.
400        let v = out[25].unwrap();
401        assert_relative_eq!(v.chikou.unwrap(), candles[0].close, epsilon = 1e-12);
402        let v = out[50].unwrap();
403        assert_relative_eq!(v.chikou.unwrap(), candles[25].close, epsilon = 1e-12);
404    }
405
406    #[test]
407    fn batch_equals_streaming() {
408        let candles = ramp(120);
409        let mut a = Ichimoku::classic();
410        let mut b = Ichimoku::classic();
411        let batched = a.batch(&candles);
412        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
413        assert_eq!(batched.len(), streamed.len());
414        for (lhs, rhs) in batched.iter().zip(streamed.iter()) {
415            let (l, r) = (lhs.unwrap(), rhs.unwrap());
416            assert_eq!(l.tenkan, r.tenkan);
417            assert_eq!(l.kijun, r.kijun);
418            assert_eq!(l.senkou_a, r.senkou_a);
419            assert_eq!(l.senkou_b, r.senkou_b);
420            assert_eq!(l.chikou, r.chikou);
421        }
422    }
423
424    #[test]
425    fn reset_clears_state() {
426        let mut ichi = Ichimoku::classic();
427        ichi.batch(&ramp(100));
428        assert!(ichi.is_ready());
429        ichi.reset();
430        assert!(!ichi.is_ready());
431        assert!(ichi.value().is_none());
432    }
433
434    #[test]
435    fn custom_periods_accepted() {
436        let mut ichi = Ichimoku::new(5, 10, 20, 10).unwrap();
437        let out = ichi.batch(&ramp(40));
438        let last = out.last().unwrap().unwrap();
439        assert!(last.tenkan.is_some());
440        assert!(last.senkou_a.is_some());
441    }
442}