1use 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#[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 colors: [Color32; 5],
74 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 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 pub fn with_start_year(mut self, year: i32) -> Self {
131 self.start_year = year;
132 self
133 }
134
135 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 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 #[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 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}