Skip to main content

nautilus_indicators/momentum/
ichimoku.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{collections::VecDeque, fmt::Display};
17
18use nautilus_core::correctness::FAILED;
19use nautilus_model::data::Bar;
20
21use crate::{
22    indicator::Indicator,
23    support::{MAX_PERIOD, is_valid_hlc},
24};
25
26/// Ichimoku Kinko Hyo: the five-line cloud chart.
27///
28/// ```text
29/// tenkan_sen    = midpoint(high, low over tenkan_period)
30/// kijun_sen     = midpoint(high, low over kijun_period)
31/// senkou_span_a = (tenkan_sen + kijun_sen) / 2      as computed `displacement - 1` bars ago
32/// senkou_span_b = midpoint(high, low over senkou_period) as computed `displacement - 1` bars ago
33/// chikou_span   = close from `displacement - 1` bars ago
34/// ```
35///
36/// The two Senkou spans form the Kumo (cloud). Charts draw them `displacement`
37/// bars ahead and the Chikou span `displacement` bars behind; streaming in
38/// chronological order, the values visible at bar `n` are the ones buffered
39/// `displacement` updates ago, that is bar `n - displacement + 1`.
40///
41/// Each line becomes available at its own bar: `tenkan_sen` after `tenkan_period`
42/// bars, `kijun_sen` after `kijun_period`, `chikou_span` after `displacement`,
43/// `senkou_span_a` after `kijun_period + displacement - 1`, and `senkou_span_b`
44/// after `senkou_period + displacement - 1` (77 bars at the classic
45/// `(9, 26, 52, 26)`). The matching `has_*` flag reports whether each field
46/// holds a value, and `initialized` gates on all five.
47#[repr(C)]
48#[derive(Debug)]
49#[cfg_attr(
50    feature = "python",
51    pyo3::pyclass(module = "nautilus_trader.indicators")
52)]
53#[cfg_attr(
54    feature = "python",
55    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
56)]
57pub struct IchimokuCloud {
58    pub tenkan_period: usize,
59    pub kijun_period: usize,
60    pub senkou_period: usize,
61    pub displacement: usize,
62    pub tenkan_sen: f64,
63    pub kijun_sen: f64,
64    pub senkou_span_a: f64,
65    pub senkou_span_b: f64,
66    pub chikou_span: f64,
67    pub has_tenkan: bool,
68    pub has_kijun: bool,
69    pub has_senkou_a: bool,
70    pub has_senkou_b: bool,
71    pub has_chikou: bool,
72    pub count: usize,
73    pub initialized: bool,
74    has_inputs: bool,
75    highs: VecDeque<f64>,
76    lows: VecDeque<f64>,
77    senkou_a_history: VecDeque<f64>,
78    senkou_b_history: VecDeque<f64>,
79    close_history: VecDeque<f64>,
80}
81
82impl Display for IchimokuCloud {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(
85            f,
86            "{}({},{},{},{})",
87            self.name(),
88            self.tenkan_period,
89            self.kijun_period,
90            self.senkou_period,
91            self.displacement,
92        )
93    }
94}
95
96impl Indicator for IchimokuCloud {
97    fn name(&self) -> String {
98        stringify!(IchimokuCloud).to_string()
99    }
100
101    fn has_inputs(&self) -> bool {
102        self.has_inputs
103    }
104
105    fn initialized(&self) -> bool {
106        self.initialized
107    }
108
109    fn handle_bar(&mut self, bar: &Bar) {
110        self.update_raw((&bar.high).into(), (&bar.low).into(), (&bar.close).into());
111    }
112
113    fn reset(&mut self) {
114        self.highs.clear();
115        self.lows.clear();
116        self.senkou_a_history.clear();
117        self.senkou_b_history.clear();
118        self.close_history.clear();
119        self.tenkan_sen = 0.0;
120        self.kijun_sen = 0.0;
121        self.senkou_span_a = 0.0;
122        self.senkou_span_b = 0.0;
123        self.chikou_span = 0.0;
124        self.has_tenkan = false;
125        self.has_kijun = false;
126        self.has_senkou_a = false;
127        self.has_senkou_b = false;
128        self.has_chikou = false;
129        self.count = 0;
130        self.has_inputs = false;
131        self.initialized = false;
132    }
133}
134
135impl IchimokuCloud {
136    /// Creates a new [`IchimokuCloud`] instance.
137    ///
138    /// # Panics
139    ///
140    /// Panics if:
141    /// - any of `tenkan_period`, `kijun_period`, `senkou_period` or
142    ///   `displacement` is zero or exceeds 16,777,216.
143    /// - the periods are not non-decreasing
144    ///   (`tenkan_period <= kijun_period <= senkou_period`).
145    #[must_use]
146    pub fn new(
147        tenkan_period: usize,
148        kijun_period: usize,
149        senkou_period: usize,
150        displacement: usize,
151    ) -> Self {
152        Self::new_checked(tenkan_period, kijun_period, senkou_period, displacement).expect(FAILED)
153    }
154
155    pub(crate) fn new_checked(
156        tenkan_period: usize,
157        kijun_period: usize,
158        senkou_period: usize,
159        displacement: usize,
160    ) -> anyhow::Result<Self> {
161        anyhow::ensure!(
162            tenkan_period <= MAX_PERIOD,
163            "tenkan_period must not exceed {MAX_PERIOD}"
164        );
165        anyhow::ensure!(
166            kijun_period <= MAX_PERIOD,
167            "kijun_period must not exceed {MAX_PERIOD}"
168        );
169        anyhow::ensure!(
170            senkou_period <= MAX_PERIOD,
171            "senkou_period must not exceed {MAX_PERIOD}"
172        );
173        anyhow::ensure!(
174            displacement <= MAX_PERIOD,
175            "displacement must not exceed {MAX_PERIOD}"
176        );
177
178        anyhow::ensure!(
179            tenkan_period > 0,
180            "IchimokuCloud: tenkan_period must be > 0 (received {tenkan_period})"
181        );
182        anyhow::ensure!(
183            kijun_period > 0,
184            "IchimokuCloud: kijun_period must be > 0 (received {kijun_period})"
185        );
186        anyhow::ensure!(
187            senkou_period > 0,
188            "IchimokuCloud: senkou_period must be > 0 (received {senkou_period})"
189        );
190        anyhow::ensure!(
191            displacement > 0,
192            "IchimokuCloud: displacement must be > 0 (received {displacement})"
193        );
194        anyhow::ensure!(
195            kijun_period >= tenkan_period,
196            "IchimokuCloud: kijun_period must be >= tenkan_period"
197        );
198        anyhow::ensure!(
199            senkou_period >= kijun_period,
200            "IchimokuCloud: senkou_period must be >= kijun_period"
201        );
202        Ok(Self {
203            tenkan_period,
204            kijun_period,
205            senkou_period,
206            displacement,
207            tenkan_sen: 0.0,
208            kijun_sen: 0.0,
209            senkou_span_a: 0.0,
210            senkou_span_b: 0.0,
211            chikou_span: 0.0,
212            has_tenkan: false,
213            has_kijun: false,
214            has_senkou_a: false,
215            has_senkou_b: false,
216            has_chikou: false,
217            count: 0,
218            initialized: false,
219            has_inputs: false,
220            highs: VecDeque::with_capacity(senkou_period),
221            lows: VecDeque::with_capacity(senkou_period),
222            senkou_a_history: VecDeque::with_capacity(displacement),
223            senkou_b_history: VecDeque::with_capacity(displacement),
224            close_history: VecDeque::with_capacity(displacement),
225        })
226    }
227
228    /// Updates the indicator with the given high, low and close.
229    pub fn update_raw(&mut self, high: f64, low: f64, close: f64) {
230        if !is_valid_hlc(high, low, close) {
231            return;
232        }
233
234        self.count += 1;
235        self.has_inputs = true;
236
237        if self.highs.len() == self.senkou_period {
238            self.highs.pop_front();
239            self.lows.pop_front();
240        }
241        self.highs.push_back(high);
242        self.lows.push_back(low);
243
244        if self.highs.len() >= self.tenkan_period {
245            self.tenkan_sen = self.midpoint(self.tenkan_period);
246            self.has_tenkan = true;
247        }
248
249        if self.highs.len() >= self.kijun_period {
250            self.kijun_sen = self.midpoint(self.kijun_period);
251            self.has_kijun = true;
252        }
253        let senkou_b_now = if self.highs.len() >= self.senkou_period {
254            self.midpoint(self.senkou_period)
255        } else {
256            f64::NAN
257        };
258        let senkou_a_now = if self.has_tenkan && self.has_kijun {
259            f64::midpoint(self.tenkan_sen, self.kijun_sen)
260        } else {
261            f64::NAN
262        };
263
264        // Push every bar (NaN encodes "no value yet") so the buffers stay
265        // aligned 1:1 with bars and the displaced read is a plain front peek.
266        Self::push_capped(&mut self.senkou_a_history, senkou_a_now, self.displacement);
267        Self::push_capped(&mut self.senkou_b_history, senkou_b_now, self.displacement);
268        Self::push_capped(&mut self.close_history, close, self.displacement);
269
270        if let Some(value) = Self::displaced(&self.senkou_a_history, self.displacement) {
271            self.senkou_span_a = value;
272            self.has_senkou_a = true;
273        }
274
275        if let Some(value) = Self::displaced(&self.senkou_b_history, self.displacement) {
276            self.senkou_span_b = value;
277            self.has_senkou_b = true;
278        }
279
280        if let Some(value) = Self::displaced(&self.close_history, self.displacement) {
281            self.chikou_span = value;
282            self.has_chikou = true;
283        }
284
285        self.initialized = self.has_tenkan
286            && self.has_kijun
287            && self.has_senkou_a
288            && self.has_senkou_b
289            && self.has_chikou;
290    }
291
292    // Midpoint of the last `n` highs and lows; the caller guarantees `n` bars exist.
293    fn midpoint(&self, n: usize) -> f64 {
294        let len = self.highs.len();
295        let start = len - n;
296        let mut hi = f64::NEG_INFINITY;
297        let mut lo = f64::INFINITY;
298
299        for i in start..len {
300            hi = hi.max(self.highs[i]);
301            lo = lo.min(self.lows[i]);
302        }
303        f64::midpoint(hi, lo)
304    }
305
306    fn push_capped(queue: &mut VecDeque<f64>, value: f64, cap: usize) {
307        if queue.len() == cap {
308            queue.pop_front();
309        }
310        queue.push_back(value);
311    }
312
313    // The value buffered `displacement` updates ago, once the buffer is full.
314    fn displaced(queue: &VecDeque<f64>, cap: usize) -> Option<f64> {
315        if queue.len() == cap && !queue[0].is_nan() {
316            Some(queue[0])
317        } else {
318            None
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use rstest::rstest;
326
327    use super::*;
328    use crate::indicator::Indicator;
329
330    fn classic() -> IchimokuCloud {
331        IchimokuCloud::new(9, 26, 52, 26)
332    }
333
334    fn ramp(n: i32) -> Vec<(f64, f64, f64)> {
335        (0..n)
336            .map(|i| {
337                let p = 100.0 + f64::from(i);
338                (p + 2.0, p - 2.0, p + 1.0)
339            })
340            .collect()
341    }
342
343    #[rstest]
344    fn test_name_and_display() {
345        let ichi = classic();
346        assert_eq!(ichi.name(), "IchimokuCloud");
347        assert_eq!(format!("{ichi}"), "IchimokuCloud(9,26,52,26)");
348        assert_eq!(ichi.tenkan_period, 9);
349        assert_eq!(ichi.kijun_period, 26);
350        assert_eq!(ichi.senkou_period, 52);
351        assert_eq!(ichi.displacement, 26);
352        assert!(!ichi.initialized());
353        assert!(!ichi.has_inputs());
354    }
355
356    #[rstest]
357    #[should_panic(expected = "tenkan_period must be > 0")]
358    fn test_zero_tenkan_period_panics() {
359        let _ = IchimokuCloud::new(0, 26, 52, 26);
360    }
361
362    #[rstest]
363    #[should_panic(expected = "kijun_period must be > 0")]
364    fn test_zero_kijun_period_panics() {
365        let _ = IchimokuCloud::new(9, 0, 52, 26);
366    }
367
368    #[rstest]
369    #[should_panic(expected = "senkou_period must be > 0")]
370    fn test_zero_senkou_period_panics() {
371        let _ = IchimokuCloud::new(9, 26, 0, 26);
372    }
373
374    #[rstest]
375    #[should_panic(expected = "displacement must be > 0")]
376    fn test_zero_displacement_panics() {
377        let _ = IchimokuCloud::new(9, 26, 52, 0);
378    }
379
380    #[rstest]
381    #[should_panic(expected = "kijun_period must be >= tenkan_period")]
382    fn test_kijun_below_tenkan_panics() {
383        let _ = IchimokuCloud::new(27, 26, 52, 26);
384    }
385
386    #[rstest]
387    #[should_panic(expected = "senkou_period must be >= kijun_period")]
388    fn test_senkou_below_kijun_panics() {
389        let _ = IchimokuCloud::new(9, 26, 25, 26);
390    }
391
392    #[rstest]
393    fn test_warmup_boundaries_per_line() {
394        let mut ichi = classic();
395        for (i, &(h, l, c)) in ramp(80).iter().enumerate() {
396            ichi.update_raw(h, l, c);
397            let bars = i + 1;
398            assert_eq!(ichi.has_tenkan, bars >= 9, "tenkan at bar {bars}");
399            assert_eq!(ichi.has_kijun, bars >= 26, "kijun at bar {bars}");
400            assert_eq!(ichi.has_chikou, bars >= 26, "chikou at bar {bars}");
401            // senkou_a first reads a real value at kijun_period + displacement - 1.
402            assert_eq!(ichi.has_senkou_a, bars >= 51, "senkou_a at bar {bars}");
403            // senkou_b first reads a real value at senkou_period + displacement - 1.
404            assert_eq!(ichi.has_senkou_b, bars >= 77, "senkou_b at bar {bars}");
405            assert_eq!(ichi.initialized(), bars >= 77, "initialized at bar {bars}");
406        }
407    }
408
409    #[rstest]
410    fn test_ramp_tenkan_equals_window_midpoint() {
411        // On a strict ramp the 9-bar window at bar 9 spans highs 102..110 and
412        // lows 98..106, so the midpoint is (110 + 98) / 2 = 104.
413        let mut ichi = classic();
414        for &(h, l, c) in &ramp(9) {
415            ichi.update_raw(h, l, c);
416        }
417        assert!(ichi.has_tenkan);
418        assert_eq!(ichi.tenkan_sen, 104.0);
419    }
420
421    #[rstest]
422    fn test_chikou_is_close_displacement_bars_back() {
423        let candles = ramp(60);
424        let mut ichi = classic();
425        for (i, &(h, l, c)) in candles.iter().enumerate() {
426            ichi.update_raw(h, l, c);
427
428            if i + 1 >= 26 {
429                // At bar n the visible chikou is the close from bar n - 25.
430                assert_eq!(ichi.chikou_span, candles[i + 1 - 26].2, "bar {}", i + 1);
431            }
432        }
433    }
434
435    #[rstest]
436    fn test_senkou_a_is_midpoint_of_lines_displacement_bars_back() {
437        let candles = ramp(80);
438        let mut reference = classic();
439        let mut history = Vec::new();
440
441        for &(h, l, c) in &candles {
442            reference.update_raw(h, l, c);
443            history.push(if reference.has_tenkan && reference.has_kijun {
444                Some(f64::midpoint(reference.tenkan_sen, reference.kijun_sen))
445            } else {
446                None
447            });
448        }
449        let mut ichi = classic();
450        for (i, &(h, l, c)) in candles.iter().enumerate() {
451            ichi.update_raw(h, l, c);
452
453            if i + 1 >= 26
454                && let Some(want) = history[i + 1 - 26]
455            {
456                assert_eq!(ichi.senkou_span_a, want, "bar {}", i + 1);
457            }
458        }
459    }
460
461    #[rstest]
462    fn test_reset() {
463        let mut ichi = classic();
464        for &(h, l, c) in &ramp(100) {
465            ichi.update_raw(h, l, c);
466        }
467        assert!(ichi.initialized());
468        ichi.reset();
469        assert!(!ichi.initialized());
470        assert!(!ichi.has_inputs());
471        assert_eq!(ichi.tenkan_sen, 0.0);
472        assert_eq!(ichi.kijun_sen, 0.0);
473        assert_eq!(ichi.senkou_span_a, 0.0);
474        assert_eq!(ichi.senkou_span_b, 0.0);
475        assert_eq!(ichi.chikou_span, 0.0);
476        assert!(!ichi.has_tenkan);
477        assert!(!ichi.has_kijun);
478        assert!(!ichi.has_senkou_a);
479        assert!(!ichi.has_senkou_b);
480        assert!(!ichi.has_chikou);
481        assert_eq!(ichi.count, 0);
482
483        // A fresh instance and the reset instance must agree thereafter.
484        let mut fresh = classic();
485
486        for &(h, l, c) in &ramp(90) {
487            ichi.update_raw(h, l, c);
488            fresh.update_raw(h, l, c);
489        }
490        assert_eq!(ichi.tenkan_sen, fresh.tenkan_sen);
491        assert_eq!(ichi.kijun_sen, fresh.kijun_sen);
492        assert_eq!(ichi.senkou_span_a, fresh.senkou_span_a);
493        assert_eq!(ichi.senkou_span_b, fresh.senkou_span_b);
494        assert_eq!(ichi.chikou_span, fresh.chikou_span);
495    }
496
497    #[rstest]
498    fn test_custom_periods_accepted() {
499        let mut ichi = IchimokuCloud::new(5, 10, 20, 10);
500        for &(h, l, c) in &ramp(40) {
501            ichi.update_raw(h, l, c);
502        }
503        assert!(ichi.initialized());
504    }
505}