Skip to main content

egui_charts/studies/builtin/
seasonality.rs

1//! Seasonality
2//!
3//! Projects each month from how the same calendar month moved in earlier years: the month's
4//! historical average percentage change, applied to the previous month's close, marks where
5//! the month would close if it behaved like an average year.
6//!
7//! # Calculation
8//! The change of a month is `(close of its last bar / close of the last bar before it - 1) * 100`.
9//! For every bar of month `m` in year `y`, over the changes of month `m` in the complete years
10//! from the starting year to `y - 1`:
11//!
12//! - Average = mean of the changes
13//! - Standard deviation = population standard deviation of the changes
14//! - Months = number of changes
15//! - Expected = previous month's close * (1 + Average / 100)
16//!
17//! The values are constant within a month, so the Expected and Previous close lines draw a box
18//! over the month from the previous close to the projected close, filled in the positive
19//! colour when the average change is zero or more and in the negative colour otherwise. A month
20//! without a previous close or without a change in an earlier year has no value. Months are
21//! calendar months of the bar's UTC date, the date a feed gives a daily or longer bar.
22//!
23//! The heatmap includes each loaded year and monthly average, population standard deviation
24//! and percentage positive. Its current month uses the latest close; projections use only
25//! earlier years. The final projection extends through the remaining calendar-month slots
26//! of the time axis, without adding bars to the source history.
27//!
28//! # Example
29//! ```
30//! use chrono::{TimeZone, Utc};
31//! use egui_charts::model::Bar;
32//! use egui_charts::studies::{Indicator, Seasonality};
33//!
34//! // Month-end closes: January 2023 rises 10% from December 2022.
35//! let bars: Vec<Bar> = [(2022, 12, 100.0), (2023, 1, 110.0), (2023, 12, 200.0), (2024, 1, 201.0)]
36//!     .into_iter()
37//!     .map(|(y, m, c)| Bar::new(Utc.with_ymd_and_hms(y, m, 28, 0, 0, 0).unwrap(), c, c, c, c, 1.0))
38//!     .collect();
39//! let mut study = Seasonality::new();
40//! study.calculate(&bars);
41//! // January 2024 projects 10% over the December 2023 close.
42//! assert!((study.values().value(0, 3).unwrap() - 220.0).abs() < 1e-9);
43//! assert_eq!(study.values().value(1, 3), Some(200.0));
44//! assert_eq!(study.values().value(4, 3), Some(1.0));
45//! ```
46
47use crate::model::Bar;
48use crate::model::LineStyle;
49use crate::studies::palette;
50use crate::studies::stepper::Stepper;
51use crate::studies::{
52    Indicator, IndicatorValue, IndicatorValues, PlotStyle, PlotType, StudyCell, StudyProjection,
53    StudyTable,
54};
55use chrono::{Datelike, Months};
56use egui::Color32;
57use std::collections::BTreeMap;
58
59/// Monthly projection from the average change of the same month in earlier years, drawn
60/// on the price chart.
61#[derive(Clone)]
62pub struct Seasonality {
63    start_year: i32,
64    values: IndicatorValues,
65    stepper: Stepper<State>,
66    history: [Vec<(i32, f64)>; 12],
67    year_counts: BTreeMap<i32, usize>,
68    before_last_history: [usize; 12],
69    before_last_year: Option<i32>,
70    table: StudyTable,
71    projection: Vec<StudyProjection>,
72    /// One per line: Expected, Previous close, Average, Standard deviation, Months.
73    colors: [Color32; 5],
74    /// Expected line and box colour of a month whose average change is negative.
75    negative: Color32,
76    visible: bool,
77}
78
79#[derive(Clone)]
80struct State {
81    source: usize,
82    latest_source: Option<usize>,
83    month: Option<((i32, u32), f64)>,
84    previous_close: Option<f64>,
85    row: [f64; 5],
86}
87
88impl Default for State {
89    fn default() -> Self {
90        Self {
91            source: 0,
92            latest_source: None,
93            month: None,
94            previous_close: None,
95            row: [f64::NAN; 5],
96        }
97    }
98}
99
100impl Seasonality {
101    /// Seasonality over every loaded year.
102    pub fn new() -> Self {
103        let gray = palette::GRAY;
104        Self {
105            start_year: 1900,
106            values: IndicatorValues::new(),
107            stepper: Stepper::default(),
108            history: Default::default(),
109            year_counts: BTreeMap::new(),
110            before_last_history: [0; 12],
111            before_last_year: None,
112            table: StudyTable {
113                title: "Seasonality".into(),
114                columns: [
115                    "Year", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
116                    "Nov", "Dec",
117                ]
118                .map(String::from)
119                .to_vec(),
120                rows: Vec::new(),
121            },
122            projection: Vec::new(),
123            colors: [palette::UP, gray, gray, gray, gray],
124            negative: Color32::from_rgb(242, 55, 69),
125            visible: true,
126        }
127    }
128
129    /// Counts the changes of months from `year` on.
130    pub fn with_start_year(mut self, year: i32) -> Self {
131        self.start_year = year;
132        self
133    }
134
135    /// Colours of a month whose average change is positive or zero, and negative.
136    pub fn with_colors(mut self, positive: Color32, negative: Color32) -> Self {
137        self.colors[0] = positive;
138        self.negative = negative;
139        self
140    }
141}
142
143impl Default for Seasonality {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149impl Indicator for Seasonality {
150    fn source(&self) -> Option<crate::studies::StudySource> {
151        Some(crate::studies::StudySource::Price(
152            crate::model::PriceSource::Close,
153        ))
154    }
155
156    crate::studies::inputs::study_inputs!(self;
157        start_year: integer("Starting year", 0, 9999)
158    ; {});
159
160    fn name(&self) -> &str {
161        "Seasonality"
162    }
163
164    fn desc(&self) -> &str {
165        "Seasonality - Monthly projection from the average change of the month in past years"
166    }
167
168    fn calculate(&mut self, data: &[Bar]) {
169        self.update(data, 0);
170    }
171
172    fn update(&mut self, data: &[Bar], from: usize) {
173        let len = self.values.len();
174        if len > 0 && from == len && data.len() == len {
175            return;
176        }
177        let start = if len > 0 && from >= len - 1 && data.len() >= len {
178            from.min(len)
179        } else {
180            0
181        };
182        if start == 0 {
183            for history in &mut self.history {
184                history.clear();
185            }
186            self.year_counts.clear();
187        } else if start < len {
188            for (history, &len) in self.history.iter_mut().zip(&self.before_last_history) {
189                history.truncate(len);
190            }
191            if let Some(year) = self.before_last_year {
192                let count = self.year_counts.get_mut(&year).unwrap();
193                *count -= 1;
194                if *count == 0 {
195                    self.year_counts.remove(&year);
196                }
197            }
198        }
199        self.before_last_year = None;
200        self.stepper
201            .run(data, from, &mut self.values, |state, bar| {
202                let source = state.source;
203                state.source += 1;
204                let last = source + 1 == data.len();
205                if last {
206                    self.before_last_history = self.history.each_ref().map(Vec::len);
207                    self.before_last_year = None;
208                }
209                if !bar.close.is_finite() {
210                    return IndicatorValue::None;
211                }
212                state.latest_source = Some(source);
213                let date = bar.time.date_naive();
214                let key = (date.year(), date.month0());
215                if key.0 >= self.start_year {
216                    *self.year_counts.entry(key.0).or_default() += 1;
217                    if last {
218                        self.before_last_year = Some(key.0);
219                    }
220                }
221                let new_month = state.month.is_none_or(|(other, _)| other != key);
222                if let Some((done, last)) = state.month.filter(|(other, _)| *other != key) {
223                    let change = state.previous_close.map(|base| (last / base - 1.0) * 100.0);
224                    if let Some(change) =
225                        change.filter(|c| c.is_finite() && done.0 >= self.start_year)
226                    {
227                        self.history[done.1 as usize].push((done.0, change));
228                    }
229                    state.previous_close = Some(last);
230                }
231                state.month = Some((key, bar.close));
232                if new_month {
233                    state.row = [f64::NAN; 5];
234                    if let Some(base) = state.previous_close {
235                        let past: Vec<f64> = self.history[key.1 as usize]
236                            .iter()
237                            .filter(|(year, _)| *year < key.0)
238                            .map(|&(_, change)| change)
239                            .collect();
240                        if !past.is_empty() {
241                            let n = past.len() as f64;
242                            let average = past.iter().sum::<f64>() / n;
243                            let variance =
244                                past.iter().map(|c| (c - average).powi(2)).sum::<f64>() / n;
245                            state.row = [
246                                base * (1.0 + average / 100.0),
247                                base,
248                                average,
249                                variance.sqrt(),
250                                n,
251                            ];
252                        }
253                    }
254                }
255                IndicatorValue::Multiple(state.row.to_vec())
256            });
257        if !data.is_empty() {
258            self.values.set_value(4, 0, f64::NAN);
259        }
260        let state = self.stepper.state();
261        let developing = state
262            .month
263            .filter(|((year, _), _)| *year >= self.start_year)
264            .and_then(|((year, month), last)| {
265                state
266                    .previous_close
267                    .map(|base| (last / base - 1.0) * 100.0)
268                    .filter(|c| c.is_finite())
269                    .map(|change| (month as usize, year, change))
270            });
271        let mut years: BTreeMap<i32, [Option<f64>; 12]> = self
272            .year_counts
273            .keys()
274            .map(|&year| (year, [None; 12]))
275            .collect();
276        let mut summaries = [Vec::new(), Vec::new(), Vec::new()];
277        for (month, history) in self.history.iter().enumerate() {
278            let current = developing
279                .filter(|&(at, _, _)| at == month)
280                .map(|(_, year, change)| (year, change));
281            let changes = || history.iter().copied().chain(current);
282            for (year, change) in changes() {
283                years.entry(year).or_insert([None; 12])[month] = Some(change);
284            }
285            let n = changes().count() as f64;
286            let mean = changes().map(|(_, change)| change).sum::<f64>() / n;
287            let deviation = (changes()
288                .map(|(_, change)| (change - mean).powi(2))
289                .sum::<f64>()
290                / n)
291                .sqrt();
292            let positive = changes().filter(|(_, change)| *change > 0.0).count() as f64 / n * 100.0;
293            for (row, value) in summaries.iter_mut().zip([mean, deviation, positive]) {
294                row.push(StudyCell::Percent(value.is_finite().then_some(value)));
295            }
296        }
297        self.table.rows = years
298            .into_iter()
299            .map(|(year, changes)| {
300                std::iter::once(StudyCell::Text(year.to_string()))
301                    .chain(changes.map(StudyCell::Percent))
302                    .collect()
303            })
304            .collect();
305        for (label, cells) in ["Average", "Standard deviation", "Positive"]
306            .into_iter()
307            .zip(summaries)
308        {
309            self.table.rows.push(
310                std::iter::once(StudyCell::Text(label.into()))
311                    .chain(cells)
312                    .collect(),
313            );
314        }
315        self.projection.clear();
316        if let Some(i) = self.stepper.state().latest_source
317            && let (Some(expected), Some(base)) = (self.values.value(0, i), self.values.value(1, i))
318        {
319            let last = &data[i];
320            if let Some(end) = last
321                .time
322                .date_naive()
323                .with_day(1)
324                .and_then(|day| day.checked_add_months(Months::new(1)))
325                .and_then(|day| day.and_hms_opt(0, 0, 0))
326            {
327                // Match the time axis: interpolate inside history, extrapolate its last spacing.
328                let right = data
329                    .partition_point(|bar| bar.time < end.and_utc())
330                    .min(data.len() - 1);
331                let left = data[..right]
332                    .iter()
333                    .rposition(|bar| bar.time < data[right].time)
334                    .unwrap_or(right);
335                let seconds = |duration: chrono::TimeDelta| {
336                    duration.num_seconds() as f64 + duration.subsec_nanos() as f64 / 1e9
337                };
338                let step = if left == right {
339                    1.0
340                } else {
341                    seconds(data[right].time - data[left].time) / (right - left) as f64
342                };
343                let to = right as f64 + seconds(end.and_utc() - data[right].time) / step;
344                if to.is_finite() && to > i as f64 {
345                    self.projection.push(StudyProjection {
346                        bars: i as f64..to,
347                        top: expected.max(base),
348                        bottom: expected.min(base),
349                        plot: 0,
350                    });
351                }
352            }
353        }
354    }
355
356    fn is_incremental(&self) -> bool {
357        true
358    }
359
360    fn table(&self) -> Option<&StudyTable> {
361        Some(&self.table)
362    }
363
364    fn projections(&self) -> &[StudyProjection] {
365        &self.projection
366    }
367
368    fn projection_fill_color(&self, projection: &StudyProjection) -> Color32 {
369        let average = self
370            .values
371            .value(2, projection.bars.start as usize)
372            .unwrap_or(0.0);
373        let alpha = ((0.1 + 0.4 * (average.abs() / 10.0).min(1.0)) * 255.0).round() as u8;
374        (if average < 0.0 {
375            self.negative
376        } else {
377            self.colors[0]
378        })
379        .gamma_multiply_u8(alpha)
380    }
381
382    fn plot_style(&self, line: usize) -> PlotStyle {
383        PlotStyle {
384            color: self.colors.get(line).copied().unwrap_or(Color32::WHITE),
385            width: 1.0,
386            pattern: if line < 2 {
387                LineStyle::Dashed
388            } else {
389                LineStyle::Solid
390            },
391            plot_type: PlotType::StepLine,
392            visible: line < 2,
393        }
394    }
395
396    fn plot_color(&self, line: usize, bar: usize) -> Option<Color32> {
397        (line == 0 && self.values.value(2, bar)? < 0.0).then_some(self.negative)
398    }
399
400    fn fills(&self) -> Vec<crate::studies::StudyFill> {
401        let tint =
402            |color: Color32| Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 51);
403        vec![crate::studies::StudyFill {
404            first: crate::studies::FillBoundary::Plot(0),
405            second: crate::studies::FillBoundary::Plot(1),
406            color: tint(self.colors[0]),
407            inverted_color: Some(tint(self.negative)),
408        }]
409    }
410
411    fn values(&self) -> &IndicatorValues {
412        &self.values
413    }
414
415    fn colors(&self) -> Vec<Color32> {
416        self.colors.to_vec()
417    }
418
419    fn set_colors(&mut self, colors: Vec<Color32>) {
420        for (old, new) in self.colors.iter_mut().zip(colors) {
421            *old = new;
422        }
423    }
424
425    fn is_visible(&self) -> bool {
426        self.visible
427    }
428
429    fn set_visible(&mut self, visible: bool) {
430        self.visible = visible;
431    }
432
433    fn clone_box(&self) -> Box<dyn Indicator> {
434        Box::new(self.clone())
435    }
436
437    fn line_names(&self) -> Vec<String> {
438        [
439            "Expected",
440            "Previous close",
441            "Average",
442            "Standard deviation",
443            "Months",
444        ]
445        .map(String::from)
446        .to_vec()
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::model::Bar;
454    use crate::studies::*;
455    use chrono::{TimeZone, Utc};
456
457    /// Latest table metrics include the developing month; projections use earlier years only.
458    #[test]
459    fn seasonality_heatmap_and_remaining_month_use_calendar_changes() {
460        let bars: Vec<_> = [
461            (2022, 12, 28, 100.0),
462            (2023, 1, 28, 110.0),
463            (2023, 2, 28, 99.0),
464            (2023, 12, 28, 200.0),
465            (2024, 1, 15, 210.0),
466        ]
467        .into_iter()
468        .map(|(y, m, d, c)| {
469            Bar::new(
470                Utc.with_ymd_and_hms(y, m, d, 0, 0, 0).unwrap(),
471                c,
472                c,
473                c,
474                c,
475                1.0,
476            )
477        })
478        .collect();
479        let mut study = ConfiguredStudy::new(Box::new(Seasonality::new()));
480        study.set_bar_clock(&crate::model::BarClock::new(crate::model::Timeframe::Day1));
481        study.calculate(&bars);
482        let table = study.table().expect("Seasonality heatmap");
483        let percent = |label: &str, month| {
484            let row = table
485                .rows
486                .iter()
487                .find(|row| row.first() == Some(&StudyCell::Text(label.into())))
488                .unwrap();
489            let StudyCell::Percent(value) = row[month] else {
490                panic!("percentage cell")
491            };
492            value
493        };
494        for (label, month, expected) in [
495            ("2023", 1, 10.0),
496            ("2023", 2, -10.0),
497            ("2024", 1, 5.0),
498            ("Average", 1, 7.5),
499            ("Standard deviation", 1, 2.5),
500            ("Positive", 1, 100.0),
501        ] {
502            assert!(
503                (percent(label, month).unwrap() - expected).abs() < 1e-9,
504                "{label}"
505            );
506        }
507        assert_eq!(percent("2022", 12), None);
508        assert_eq!(percent("2024", 2), None);
509        let zone = study
510            .projections()
511            .last()
512            .expect("remaining month projection");
513        assert_eq!(zone.bars.start, 4.0);
514        // The last two bars are 18 days apart. February 1 is 17/18 of a slot after January 15.
515        assert!((zone.bars.end - (4.0 + 17.0 / 18.0)).abs() < 1e-10);
516        assert!((zone.top - 220.0).abs() < 1e-9);
517        assert_eq!(zone.bottom, 200.0);
518        #[cfg(feature = "widget")]
519        {
520            use crate::chart::{coords::ChartMapping, indicators::hit_test_indicator};
521            use crate::model::BarData;
522            use crate::studies::ChartState;
523            use egui::{Rect, pos2, vec2};
524
525            let mut bars = bars;
526            bars.extend(
527                (16..=31).map(|day| {
528                    Bar::whitespace(Utc.with_ymd_and_hms(2024, 1, day, 0, 0, 0).unwrap())
529                }),
530            );
531            study.calculate(&bars);
532            let mut state = ChartState::new(BarData::from_bars(bars));
533            state.time_scale_mut().set_width(80.0);
534            state.time_scale_mut().set_bar_spacing(8.0);
535            state.time_scale_mut().set_right_offset(0.0);
536            let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(80.0, 200.0));
537            for overlay in [true, false] {
538                study.set_overlay(overlay);
539                state.fit_price_scale([&study as &dyn Indicator]);
540                let price = state.price_scale().map(Default::default(), 0.0, 200.0);
541                if overlay {
542                    assert!(price.range().min <= 200.0 && price.range().max >= 220.0);
543                } else {
544                    use crate::widget::{IndicatorCoordParams, IndicatorPane, IndicatorPaneConfig};
545                    let mut pane =
546                        IndicatorPane::with_config(IndicatorPaneConfig::from_study(&study));
547                    egui::Context::default()
548                        .run_ui(Default::default(), |ui| {
549                            let interaction = pane
550                                .show_aligned_interactive(
551                                    ui,
552                                    &study,
553                                    &[],
554                                    12..21,
555                                    IndicatorCoordParams::new(8.0, 0.0, 20, 12),
556                                )
557                                .unwrap();
558                            assert!(interaction.y_min <= 200.0 && interaction.y_max >= 220.0);
559                        })
560                        .drop_without_applying_deltas();
561                    continue;
562                }
563                let coords = ChartMapping::new(rect, 8.0, 12, 20, 0.0, price);
564                assert!(
565                    hit_test_indicator(
566                        pos2(coords.idx_to_x(16), price.price_to_y(210.0)),
567                        &study,
568                        0,
569                        12..21,
570                        &coords,
571                        |value| price.price_to_y(value),
572                    )
573                    .is_some(),
574                    "the remaining-month band must be selectable in whitespace"
575                );
576            }
577        }
578    }
579}