Skip to main content

egui_charts/studies/builtin/
ichimoku.rs

1//! Ichimoku Cloud (Ichimoku Kinko Hyo) Indicator
2//!
3//! Ichimoku Cloud is a comprehensive indicator that shows support/resistance,
4//! trend direction, momentum, and trading signals at a glance.
5//!
6//! # Components
7//! - Tenkan-sen (Conversion Line): (9-period high + 9-period low) / 2
8//! - Kijun-sen (Base Line): (26-period high + 26-period low) / 2
9//! - Senkou Span A (Leading Span A): (Tenkan + Kijun) / 2, plotted 25 bars ahead with displacement 26
10//! - Senkou Span B (Leading Span B): (52-period high + 52-period low) / 2, plotted 25 bars ahead with displacement 26
11//! - Chikou Span (Lagging Span): Close plotted 25 bars back with displacement 26
12//!
13//! # Interpretation
14//! - Price above cloud: Bullish trend
15//! - Price below cloud: Bearish trend
16//! - Cloud color: Green (Span A > Span B), Red (Span A < Span B)
17//! - Tenkan crossing Kijun: Potential entry signal
18//!
19//! # Example
20//! ```
21//! # let bars: Vec<egui_charts::model::Bar> = (0..60).map(|i| { let p = 100.0 + (i as f64 * 0.3).sin() * 5.0; egui_charts::model::Bar::new(chrono::Utc::now(), p, p + 1.0, p - 1.0, p + 0.5, 1_000.0) }).collect();
22//! use egui_charts::studies::{IchimokuCloud, Indicator};
23//!
24//! let mut ichimoku = IchimokuCloud::new(9, 26, 52);
25//! ichimoku.calculate(&bars);
26//! ```
27
28use super::over_finite_bars;
29use crate::model::Bar;
30use crate::studies::palette;
31use crate::studies::stepper::{rolling_max, rolling_min};
32use crate::studies::stepper::{update_window, usable_behind};
33use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
34use egui::Color32;
35
36/// Ichimoku Cloud indicator
37///
38/// Each row holds `[tenkan, kijun, senkou_a, senkou_b, close]` computed at its
39/// source bar, with NaN during warmup. Leading spans use a positive plot offset
40/// and Chikou a negative one. Displacement counts the current bar as period one:
41/// 26 shifts by 25 bars. Zero and one leave the spans unshifted. All leading
42/// values are retained, including those drawn beyond the last price bar.
43#[derive(Clone)]
44pub struct IchimokuCloud {
45    tenkan_period: usize,
46    kijun_period: usize,
47    senkou_b_period: usize,
48    displacement: usize,
49    values: IndicatorValues,
50    tenkan_color: Color32,
51    kijun_color: Color32,
52    span_a_color: Color32,
53    span_b_color: Color32,
54    chikou_color: Color32,
55    cloud_colors: [Color32; 2],
56    visible: bool,
57}
58
59impl IchimokuCloud {
60    /// Create a new Ichimoku Cloud indicator
61    ///
62    /// # Arguments
63    /// * `tenkan_period` - Tenkan-sen period (typically 9)
64    /// * `kijun_period` - Kijun-sen period (typically 26)
65    /// * `senkou_b_period` - Senkou Span B period (typically 52)
66    pub fn new(tenkan_period: usize, kijun_period: usize, senkou_b_period: usize) -> Self {
67        let kijun_period = kijun_period.max(1);
68        Self {
69            tenkan_period: tenkan_period.max(1),
70            kijun_period,
71            senkou_b_period: senkou_b_period.max(1),
72            displacement: kijun_period,
73            values: IndicatorValues::new(),
74            tenkan_color: palette::BLUE,
75            kijun_color: palette::DARK_RED,
76            span_a_color: palette::LIGHT_GREEN,
77            span_b_color: Color32::from_rgb(239, 154, 154),
78            chikou_color: palette::DARK_GREEN,
79            cloud_colors: [
80                palette::DARK_GREEN.gamma_multiply_u8(26),
81                Color32::from_rgba_unmultiplied(244, 67, 54, 26),
82            ],
83            visible: true,
84        }
85    }
86
87    /// Create with default params (9, 26, 52)
88    pub fn default_params() -> Self {
89        Self::new(9, 26, 52)
90    }
91
92    /// Set displacement periods, counting the current bar as the first.
93    /// The Kijun period is the default; 0 and 1 both leave spans unshifted.
94    pub fn with_displacement(mut self, displacement: usize) -> Self {
95        self.displacement = displacement;
96        self
97    }
98}
99
100/// Construct with the conventional default parameters.
101impl Default for IchimokuCloud {
102    fn default() -> Self {
103        Self::new(9, 26, 52)
104    }
105}
106
107impl IchimokuCloud {
108    /// The values over `data`, every bar of which has finite inputs.
109    fn compute(&self, data: &[Bar]) -> IndicatorValues {
110        let mut values = IndicatorValues::new();
111        // Midpoint of the highest high and lowest low of each full window
112        let mid = |period: usize| -> Vec<Option<f64>> {
113            let highest = rolling_max(data.iter().map(|b| b.high), period);
114            let lowest = rolling_min(data.iter().map(|b| b.low), period);
115            highest
116                .into_iter()
117                .zip(lowest)
118                .enumerate()
119                .map(|(i, (hh, ll))| (i + 1 >= period).then_some((hh + ll) / 2.0))
120                .collect()
121        };
122        let tenkan_line = mid(self.tenkan_period);
123        let kijun_line = mid(self.kijun_period);
124        let senkou_b_line = mid(self.senkou_b_period);
125        for i in 0..data.len() {
126            let tenkan = tenkan_line[i];
127            let kijun = kijun_line[i];
128            let span_a = tenkan
129                .zip(kijun)
130                .map(|(tenkan, kijun)| (tenkan + kijun) / 2.0);
131            let span_b = senkou_b_line[i];
132            let chikou = Some(data[i].close);
133
134            let row = [tenkan, kijun, span_a, span_b, chikou];
135            values.push(if row.iter().all(Option::is_none) {
136                IndicatorValue::None
137            } else {
138                IndicatorValue::Multiple(row.map(|v| v.unwrap_or(f64::NAN)).to_vec())
139            });
140        }
141
142        values
143    }
144}
145
146impl Indicator for IchimokuCloud {
147    crate::studies::inputs::study_inputs!(self;
148        tenkan_period: length("Tenkan period", 1, usize::MAX),
149        kijun_period: length("Kijun period", 1, usize::MAX),
150        senkou_b_period: length("Senkou b period", 1, usize::MAX),
151        displacement: length("Displacement", 0, usize::MAX)
152    ; {});
153
154    fn plot_offset(&self, line: usize) -> isize {
155        let shift = self.displacement.saturating_sub(1).min(isize::MAX as usize) as isize;
156        match line {
157            2 | 3 => shift,
158            4 => -shift,
159            _ => 0,
160        }
161    }
162
163    fn fills(&self) -> Vec<crate::studies::StudyFill> {
164        vec![crate::studies::StudyFill {
165            first: crate::studies::FillBoundary::Plot(2),
166            second: crate::studies::FillBoundary::Plot(3),
167            color: self.cloud_colors[0],
168            inverted_color: Some(self.cloud_colors[1]),
169        }]
170    }
171
172    fn name(&self) -> &str {
173        "Ichimoku"
174    }
175
176    fn desc(&self) -> &str {
177        "Ichimoku Cloud - Comprehensive trend indicator"
178    }
179
180    fn calculate(&mut self, data: &[Bar]) {
181        self.update(data, 0);
182    }
183
184    fn update(&mut self, data: &[Bar], from: usize) {
185        let lookback = self
186            .tenkan_period
187            .max(self.kijun_period)
188            .max(self.senkou_b_period)
189            - 1;
190        let mut values = std::mem::take(&mut self.values);
191        update_window(
192            &mut values,
193            data,
194            from,
195            usable_behind(lookback, |b| {
196                b.high.is_finite() && b.low.is_finite() && b.close.is_finite()
197            }),
198            |data| {
199                over_finite_bars(
200                    data,
201                    |b| b.high.is_finite() && b.low.is_finite() && b.close.is_finite(),
202                    |data| self.compute(data),
203                )
204            },
205        );
206        self.values = values;
207    }
208
209    fn is_incremental(&self) -> bool {
210        true
211    }
212
213    fn values(&self) -> &IndicatorValues {
214        &self.values
215    }
216
217    fn colors(&self) -> Vec<Color32> {
218        vec![
219            self.tenkan_color,
220            self.kijun_color,
221            self.span_a_color,
222            self.span_b_color,
223            self.chikou_color,
224            self.cloud_colors[0],
225            self.cloud_colors[1],
226        ]
227    }
228
229    fn set_colors(&mut self, colors: Vec<Color32>) {
230        if !colors.is_empty() {
231            self.tenkan_color = colors[0];
232        }
233        if colors.len() > 1 {
234            self.kijun_color = colors[1];
235        }
236        if colors.len() > 2 {
237            self.span_a_color = colors[2];
238        }
239        if colors.len() > 3 {
240            self.span_b_color = colors[3];
241        }
242        if colors.len() > 4 {
243            self.chikou_color = colors[4];
244        }
245        if colors.len() > 2 {
246            self.cloud_colors[0] = colors.get(5).copied().unwrap_or(self.span_a_color);
247        }
248        if colors.len() > 3 {
249            self.cloud_colors[1] = colors.get(6).copied().unwrap_or(self.span_b_color);
250        }
251    }
252
253    fn is_overlay(&self) -> bool {
254        true // Ichimoku is drawn on main chart
255    }
256
257    fn line_count(&self) -> usize {
258        5 // Tenkan, Kijun, Senkou A, Senkou B, Chikou
259    }
260
261    fn is_visible(&self) -> bool {
262        self.visible
263    }
264
265    fn set_visible(&mut self, visible: bool) {
266        self.visible = visible;
267    }
268
269    fn clone_box(&self) -> Box<dyn Indicator> {
270        Box::new(self.clone())
271    }
272
273    fn line_names(&self) -> Vec<String> {
274        vec![
275            format!("Tenkan({})", self.tenkan_period),
276            format!("Kijun({})", self.kijun_period),
277            "Senkou A".to_string(),
278            format!("Senkou B({})", self.senkou_b_period),
279            "Chikou".to_string(),
280        ]
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use chrono::{Duration, Utc};
288
289    /// Source values calculated by hand; displacement includes the current period.
290    #[test]
291    fn spans_are_displaced() {
292        let highs = [
293            12., 14., 13., 15., 17., 16., 18., 20., 19., 21., 23., 22., 20., 19., 21., 24., 25.,
294            23., 22., 26.,
295        ];
296        let lows = [
297            10., 11., 11., 12., 14., 14., 15., 17., 17., 18., 20., 19., 18., 17., 18., 21., 22.,
298            21., 20., 23.,
299        ];
300        let closes = [
301            11., 13., 12., 14., 16., 15., 17., 19., 18., 20., 22., 20., 19., 18., 20., 23., 24.,
302            22., 21., 25.,
303        ];
304        let start = Utc::now();
305        let bars: Vec<Bar> = (0..20)
306            .map(|i| Bar {
307                time: start + Duration::minutes(i as i64),
308                open: closes[i],
309                high: highs[i],
310                low: lows[i],
311                close: closes[i],
312                volume: 1.0,
313                colors: None,
314                payload: None,
315            })
316            .collect();
317        let n = f64::NAN;
318        // [tenkan, kijun, senkou A, senkou B, chikou] per bar
319        let expected = [
320            [n, n, n, n, 11.0],
321            [n, n, n, n, 13.0],
322            [12.0, n, n, n, 12.0],
323            [13.0, n, n, n, 14.0],
324            [14.0, 13.5, 13.75, n, 16.0],
325            [14.5, 14.0, 14.25, n, 15.0],
326            [16.0, 14.5, 15.25, n, 17.0],
327            [17.0, 16.0, 16.5, 15.0, 19.0],
328            [17.5, 17.0, 17.25, 15.5, 18.0],
329            [19.0, 17.5, 18.25, 16.0, 20.0],
330            [20.0, 19.0, 19.5, 17.5, 22.0],
331            [20.5, 20.0, 20.25, 18.5, 20.0],
332            [20.5, 20.0, 20.25, 18.5, 19.0],
333            [19.5, 20.0, 19.75, 19.0, 18.0],
334            [19.0, 20.0, 19.5, 20.0, 20.0],
335            [20.5, 20.5, 20.5, 20.5, 23.0],
336            [21.5, 21.0, 21.25, 21.0, 24.0],
337            [23.0, 21.0, 22.0, 21.0, 22.0],
338            [22.5, 21.5, 22.0, 21.0, 21.0],
339            [23.0, 23.0, 23.0, 21.5, 25.0],
340        ];
341
342        let mut ichimoku = IchimokuCloud::new(3, 5, 8);
343        ichimoku.calculate(&bars);
344
345        assert_eq!(ichimoku.values().len(), bars.len());
346        assert_eq!(ichimoku.plot_offset(2), 4);
347        assert_eq!(ichimoku.plot_offset(4), -4);
348        assert_eq!(ichimoku.plot_value(2, 8), Some(13.75));
349        assert_eq!(ichimoku.plot_value(2, 23), Some(23.0));
350        assert_eq!(ichimoku.plot_value(4, 0), Some(16.0));
351        for (periods, offset) in [(0, 0), (1, 0), (26, 25), (usize::MAX, isize::MAX)] {
352            let study = IchimokuCloud::default().with_displacement(periods);
353            assert_eq!(study.plot_offset(2), offset);
354            assert_eq!(study.plot_offset(4), -offset);
355        }
356        for (i, (value, row)) in ichimoku.values().to_rows().iter().zip(expected).enumerate() {
357            let IndicatorValue::Multiple(got) = value else {
358                panic!("bar {i}: {value:?}");
359            };
360            let same = got.len() == 5
361                && got
362                    .iter()
363                    .zip(row)
364                    .all(|(g, e)| (g.is_nan() && e.is_nan()) || *g == e);
365            assert!(same, "bar {i}: {got:?} != {row:?}");
366        }
367    }
368}