Skip to main content

mantis_ta/indicators/trend/
ichimoku.rs

1use crate::indicators::Indicator;
2use crate::types::{Candle, IchimokuOutput};
3use crate::utils::ringbuf::RingBuf;
4
5/// Ichimoku Cloud (Ichimoku Kinko Hyo) — a multi-line trend and
6/// support/resistance system built from rolling high/low midpoints.
7///
8/// Components (all computed from data up to and including the current bar):
9/// - `tenkan_sen` (conversion line): midpoint of the `tenkan_period` high/low
10/// - `kijun_sen` (base line): midpoint of the `kijun_period` high/low
11/// - `senkou_span_a` (leading span A): midpoint of `tenkan_sen`/`kijun_sen`
12/// - `senkou_span_b` (leading span B): midpoint of the `senkou_b_period` high/low
13/// - `chikou_span` (lagging span): the current close
14///
15/// On a classic Ichimoku chart, Senkou Span A/B are plotted `kijun_period`
16/// bars *ahead* and Chikou Span is plotted `kijun_period` bars *behind* —
17/// that plotting displacement is a presentation concern, so this streaming
18/// indicator returns the undisplaced values and leaves the offset to the
19/// caller (chart renderer or strategy).
20///
21/// # Examples
22/// ```rust
23/// use mantis_ta::indicators::{Ichimoku, Indicator};
24/// use mantis_ta::types::Candle;
25///
26/// let candles: Vec<Candle> = (0..60)
27///     .map(|i| {
28///         let price = 100.0 + i as f64;
29///         Candle {
30///             timestamp: i as i64,
31///             open: price,
32///             high: price + 1.0,
33///             low: price - 1.0,
34///             close: price,
35///             volume: 0.0,
36///         }
37///     })
38///     .collect();
39///
40/// // Standard 9/26/52 periods; full output needs 52 candles.
41/// let out = Ichimoku::new(9, 26, 52).calculate(&candles);
42/// assert!(out.iter().take(51).all(|v| v.is_none()));
43/// assert!(out[51].is_some());
44/// ```
45#[derive(Debug, Clone)]
46pub struct Ichimoku {
47    tenkan_period: usize,
48    kijun_period: usize,
49    senkou_b_period: usize,
50    tenkan_window: RingBuf<(f64, f64)>,
51    kijun_window: RingBuf<(f64, f64)>,
52    senkou_b_window: RingBuf<(f64, f64)>,
53}
54
55impl Ichimoku {
56    pub fn new(tenkan_period: usize, kijun_period: usize, senkou_b_period: usize) -> Self {
57        assert!(
58            tenkan_period > 0 && kijun_period > 0 && senkou_b_period > 0,
59            "periods must be > 0"
60        );
61        Self {
62            tenkan_period,
63            kijun_period,
64            senkou_b_period,
65            tenkan_window: RingBuf::new(tenkan_period, (0.0, 0.0)),
66            kijun_window: RingBuf::new(kijun_period, (0.0, 0.0)),
67            senkou_b_window: RingBuf::new(senkou_b_period, (0.0, 0.0)),
68        }
69    }
70
71    #[inline]
72    fn midpoint(window: &RingBuf<(f64, f64)>, period: usize) -> Option<f64> {
73        if window.len() < period {
74            return None;
75        }
76        let mut highest = f64::MIN;
77        let mut lowest = f64::MAX;
78        for &(high, low) in window.iter() {
79            if high > highest {
80                highest = high;
81            }
82            if low < lowest {
83                lowest = low;
84            }
85        }
86        Some((highest + lowest) / 2.0)
87    }
88}
89
90impl Indicator for Ichimoku {
91    type Output = IchimokuOutput;
92
93    fn next(&mut self, candle: &Candle) -> Option<Self::Output> {
94        self.tenkan_window.push((candle.high, candle.low));
95        self.kijun_window.push((candle.high, candle.low));
96        self.senkou_b_window.push((candle.high, candle.low));
97
98        let tenkan_sen = Self::midpoint(&self.tenkan_window, self.tenkan_period)?;
99        let kijun_sen = Self::midpoint(&self.kijun_window, self.kijun_period)?;
100        let senkou_span_b = Self::midpoint(&self.senkou_b_window, self.senkou_b_period)?;
101        let senkou_span_a = (tenkan_sen + kijun_sen) / 2.0;
102
103        Some(IchimokuOutput {
104            tenkan_sen,
105            kijun_sen,
106            senkou_span_a,
107            senkou_span_b,
108            chikou_span: candle.close,
109        })
110    }
111
112    fn reset(&mut self) {
113        self.tenkan_window = RingBuf::new(self.tenkan_period, (0.0, 0.0));
114        self.kijun_window = RingBuf::new(self.kijun_period, (0.0, 0.0));
115        self.senkou_b_window = RingBuf::new(self.senkou_b_period, (0.0, 0.0));
116    }
117
118    fn warmup_period(&self) -> usize {
119        self.tenkan_period
120            .max(self.kijun_period)
121            .max(self.senkou_b_period)
122    }
123
124    fn clone_boxed(&self) -> Box<dyn Indicator<Output = Self::Output>> {
125        Box::new(self.clone())
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn make_candles(n: usize) -> Vec<Candle> {
134        (0..n)
135            .map(|i| {
136                let price = 100.0 + i as f64;
137                Candle {
138                    timestamp: i as i64,
139                    open: price,
140                    high: price + 1.0,
141                    low: price - 1.0,
142                    close: price,
143                    volume: 0.0,
144                }
145            })
146            .collect()
147    }
148
149    #[test]
150    fn ichimoku_emits_after_warmup() {
151        let candles = make_candles(60);
152        let ichimoku = Ichimoku::new(9, 26, 52);
153        let out = ichimoku.calculate(&candles);
154
155        assert!(out.iter().take(51).all(|v| v.is_none()));
156        assert!(out[51].is_some());
157    }
158
159    #[test]
160    fn ichimoku_warmup_period_is_max_of_periods() {
161        assert_eq!(Ichimoku::new(9, 26, 52).warmup_period(), 52);
162        assert_eq!(Ichimoku::new(9, 52, 26).warmup_period(), 52);
163    }
164
165    #[test]
166    fn ichimoku_reset_restores_fresh_state() {
167        let candles = make_candles(60);
168        let mut ichimoku = Ichimoku::new(9, 26, 52);
169        for candle in &candles {
170            ichimoku.next(candle);
171        }
172        assert!(ichimoku.next(&candles[0]).is_some());
173
174        ichimoku.reset();
175        assert_eq!(ichimoku.next(&candles[0]), None);
176    }
177
178    #[test]
179    #[should_panic(expected = "periods must be > 0")]
180    fn ichimoku_rejects_zero_period() {
181        Ichimoku::new(0, 26, 52);
182    }
183}