Skip to main content

kestrel_chartkit/analytics/
seasonality.rs

1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use crate::finance::Date;
5use crate::model::Bar;
6
7/// One calendar month's return, measured close to close across the month boundary.
8#[derive(Debug, Clone, Copy, PartialEq)]
9#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
10pub struct MonthlyReturn {
11    pub year: i32,
12    /// 1 through 12.
13    pub month: u32,
14    /// Percentage change from the previous month's last close to this month's last close.
15    pub return_pct: f64,
16    /// Whether this month's last observed bar is known to be followed by another month.
17    ///
18    /// The final month of a series is `false`: its last bar may or may not be the month's last
19    /// trading day, and this cannot be told from the data alone. Such a month is excluded from
20    /// the statistics rather than counted as if it had closed.
21    pub complete: bool,
22}
23
24/// What the completed observations of one calendar month say.
25#[derive(Debug, Clone, Copy, PartialEq)]
26#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
27pub struct MonthStatistics {
28    /// 1 through 12.
29    pub month: u32,
30    /// How many completed years contributed. Published because a mean over three years and one
31    /// over thirty are not the same claim.
32    pub samples: usize,
33    pub mean_return_pct: f64,
34    /// Sample standard deviation over the years, `None` with fewer than two of them — a spread
35    /// needs at least two observations, and zero would suggest certainty.
36    pub stdev_return_pct: Option<f64>,
37    /// Share of contributing years in which the month closed higher, in `0..=1`.
38    ///
39    /// A frequency, not a calibrated probability: it says what happened in these `samples` years,
40    /// not what is likely to happen next.
41    pub positive_share: f64,
42}
43
44/// Monthly seasonality of a price series: what each calendar month did, historically.
45#[derive(Debug, Clone, PartialEq)]
46#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
47pub struct SeasonalityReport {
48    /// Every month found in the series, oldest first, including the incomplete final one.
49    pub monthly_returns: Vec<MonthlyReturn>,
50    /// One entry per calendar month that has at least one completed observation, ascending by
51    /// month number.
52    pub months: Vec<MonthStatistics>,
53    /// Timestamp of the last bar the report was computed from — the data cut-off it belongs to.
54    pub as_of: i64,
55    /// How many completed monthly returns went into the statistics.
56    pub completed_months: usize,
57}
58
59/// Computes the monthly seasonality of a bar series.
60///
61/// A month's return runs from the last close of the previous month to the last close of that
62/// month. The first month of a series therefore has no return — there is nothing before it to
63/// measure against — and the last month is marked incomplete, because whether its final bar is
64/// the month's final bar cannot be known from the series.
65///
66/// This is descriptive statistics and stops there. There is no projection, no expected return for
67/// a coming month, and `positive_share` is a frequency over the years present, not a probability
68/// that the next one will be positive. Turning a frequency into a forecast needs assumptions this
69/// function is not in a position to make.
70///
71/// Bars must be in ascending time order; a price adjustment convention (raw, back-adjusted,
72/// total return) changes the answer and belongs to the caller's series, not to this computation —
73/// see [`crate::model::SeriesIdentity`].
74pub fn monthly_seasonality(bars: &[Bar]) -> SeasonalityReport {
75    let mut monthly_returns = Vec::new();
76    let as_of = bars.last().map(|bar| bar.timestamp).unwrap_or(0);
77
78    // Last close of each calendar month, in order of appearance.
79    let mut month_closes: Vec<((i32, u32), f64)> = Vec::new();
80    for bar in bars {
81        if !bar.close.is_finite() || bar.close <= 0.0 {
82            continue;
83        }
84        let date = date_of(bar.timestamp);
85        let key = (date.year, date.month);
86        match month_closes.last_mut() {
87            Some((last_key, close)) if *last_key == key => *close = bar.close,
88            _ => month_closes.push((key, bar.close)),
89        }
90    }
91
92    for index in 1..month_closes.len() {
93        let ((year, month), close) = month_closes[index];
94        let previous_close = month_closes[index - 1].1;
95        if previous_close <= 0.0 {
96            continue;
97        }
98        monthly_returns.push(MonthlyReturn {
99            year,
100            month,
101            return_pct: 100.0 * (close / previous_close - 1.0),
102            complete: index + 1 < month_closes.len(),
103        });
104    }
105
106    let mut months = Vec::new();
107    for month in 1..=12u32 {
108        let returns: Vec<f64> = monthly_returns
109            .iter()
110            .filter(|entry| entry.month == month && entry.complete)
111            .map(|entry| entry.return_pct)
112            .collect();
113        if returns.is_empty() {
114            continue;
115        }
116
117        let samples = returns.len();
118        let mean = returns.iter().sum::<f64>() / samples as f64;
119        let stdev = (samples >= 2).then(|| {
120            let variance = returns
121                .iter()
122                .map(|value| {
123                    let diff = value - mean;
124                    diff * diff
125                })
126                .sum::<f64>()
127                / (samples - 1) as f64;
128            variance.sqrt()
129        });
130        let positive = returns.iter().filter(|value| **value > 0.0).count();
131
132        months.push(MonthStatistics {
133            month,
134            samples,
135            mean_return_pct: mean,
136            stdev_return_pct: stdev,
137            positive_share: positive as f64 / samples as f64,
138        });
139    }
140
141    let completed_months = monthly_returns
142        .iter()
143        .filter(|entry| entry.complete)
144        .count();
145    SeasonalityReport {
146        monthly_returns,
147        months,
148        as_of,
149        completed_months,
150    }
151}
152
153/// Calendar date of a Unix timestamp, in UTC.
154fn date_of(timestamp: i64) -> Date {
155    let epoch = Date::new(1970, 1, 1).expect("the epoch is a valid date");
156    epoch.add_days(timestamp.div_euclid(86_400))
157}