Skip to main content

wickra_core/indicators/
turn_of_month.rs

1//! Turn-of-Month Effect — the mean daily return of sessions that fall inside the
2//! turn-of-month window (the last `n_last` and first `n_first` days of a month).
3
4use crate::calendar::{civil_from_timestamp, days_in_month};
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Whether a day-of-month lies in the turn-of-month window.
10///
11/// The window is the first `n_first` calendar days plus the last `n_last` days of
12/// the month (`days_in_month - n_last < dom`).
13fn in_turn_window(dom: u32, dim: u32, n_first: u32, n_last: u32) -> bool {
14    dom <= n_first || dom > dim.saturating_sub(n_last)
15}
16
17/// Turn-of-Month effect: the running mean of daily close-to-close returns for the
18/// sessions that fall in the turn-of-month window.
19///
20/// Each completed session (the wall-clock day of
21/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`)
22/// contributes its return `close / previous_close - 1`. Only sessions whose
23/// day-of-month is within the first `n_first` or last `n_last` days of their month
24/// are averaged; the rest are ignored. The classic effect uses `n_first = 3`,
25/// `n_last = 1`.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Candle, Indicator, TurnOfMonth};
31///
32/// let day = 24 * 3_600_000;
33/// // 2021-01-29 .. 02-02 — all turn-of-month days with n_first=3, n_last=1.
34/// let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
35/// let start = 1_611_878_400_000; // 2021-01-29 00:00 UTC
36/// let mut last = None;
37/// for (i, close) in [100.0, 101.0, 102.0, 103.0].iter().enumerate() {
38///     let ts = start + i as i64 * day;
39///     last = tom.update(Candle::new(*close, *close, *close, *close, 1.0, ts).unwrap());
40/// }
41/// assert!(last.is_some());
42/// ```
43#[derive(Debug, Clone)]
44pub struct TurnOfMonth {
45    n_first: u32,
46    n_last: u32,
47    utc_offset_minutes: i32,
48    day: Option<(i64, u32, u32)>,
49    cur_close: f64,
50    prev_day_close: Option<f64>,
51    sum: f64,
52    count: u64,
53}
54
55impl TurnOfMonth {
56    ///
57    /// The offset is a constant and does not follow daylight saving: for a
58    /// venue that observes it, one value is correct for part of the year and an
59    /// hour out for the rest, which shifts every session boundary by an hour.
60    /// Either pass the offset in force for the span being analysed and keep
61    /// spans that cross a transition apart, or convert the timestamps to the
62    /// venue's wall clock upstream and pass `0`.
63    /// Construct a Turn-of-Month indicator.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`Error::PeriodZero`] if both `n_first` and `n_last` are zero (the
68    /// window would never include a day).
69    pub fn new(n_first: u32, n_last: u32, utc_offset_minutes: i32) -> Result<Self> {
70        if n_first == 0 && n_last == 0 {
71            return Err(Error::PeriodZero);
72        }
73        Ok(Self {
74            n_first,
75            n_last,
76            utc_offset_minutes,
77            day: None,
78            cur_close: 0.0,
79            prev_day_close: None,
80            sum: 0.0,
81            count: 0,
82        })
83    }
84
85    /// Classic turn-of-month window: first 3 and last 1 day of the month.
86    pub fn classic() -> Self {
87        Self::new(3, 1, 0).expect("classic turn-of-month window is valid")
88    }
89
90    /// Configured `(n_first, n_last, utc_offset_minutes)`.
91    pub const fn params(&self) -> (u32, u32, i32) {
92        (self.n_first, self.n_last, self.utc_offset_minutes)
93    }
94
95    /// Most recent mean turn-of-month return if any in-window day has completed.
96    pub fn value(&self) -> Option<f64> {
97        if self.count == 0 {
98            None
99        } else {
100            Some(self.sum / self.count as f64)
101        }
102    }
103
104    /// Settle the just-finished day `(year, month, dom)` whose last close is
105    /// `self.cur_close`, then start `next_key`.
106    fn roll_into(
107        &mut self,
108        year: i64,
109        month: u32,
110        dom: u32,
111        next_key: (i64, u32, u32),
112        close: f64,
113    ) {
114        if let Some(prev) = self.prev_day_close {
115            let ret = if prev == 0.0 {
116                0.0
117            } else {
118                self.cur_close / prev - 1.0
119            };
120            if in_turn_window(dom, days_in_month(year, month), self.n_first, self.n_last) {
121                self.sum += ret;
122                self.count += 1;
123            }
124        }
125        self.prev_day_close = Some(self.cur_close);
126        self.day = Some(next_key);
127        self.cur_close = close;
128    }
129}
130
131impl Indicator for TurnOfMonth {
132    type Input = Candle;
133    type Output = f64;
134
135    #[inline]
136    fn update(&mut self, candle: Candle) -> Option<f64> {
137        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
138        let key = (civil.year, civil.month, civil.day);
139        match self.day {
140            Some(prev) if prev == key => {
141                self.cur_close = candle.close;
142            }
143            Some((year, month, dom)) => {
144                self.roll_into(year, month, dom, key, candle.close);
145            }
146            None => {
147                self.day = Some(key);
148                self.cur_close = candle.close;
149            }
150        }
151        self.value()
152    }
153
154    fn reset(&mut self) {
155        self.day = None;
156        self.cur_close = 0.0;
157        self.prev_day_close = None;
158        self.sum = 0.0;
159        self.count = 0;
160    }
161
162    #[inline]
163    fn warmup_period(&self) -> usize {
164        2
165    }
166
167    #[inline]
168    fn is_ready(&self) -> bool {
169        self.count > 0
170    }
171
172    #[inline]
173    fn name(&self) -> &'static str {
174        "TurnOfMonth"
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::traits::BatchExt;
182    use approx::assert_relative_eq;
183
184    const DAY: i64 = 24 * 3_600_000;
185    // 2021-01-28 00:00 UTC.
186    const JAN28_2021: i64 = 1_611_792_000_000;
187
188    fn c(close: f64, ts: i64) -> Candle {
189        Candle::new(close, close, close, close, 1.0, ts).unwrap()
190    }
191
192    #[test]
193    fn window_predicate_branches() {
194        // First-days branch.
195        assert!(in_turn_window(1, 31, 3, 1));
196        assert!(in_turn_window(3, 31, 3, 1));
197        assert!(!in_turn_window(4, 31, 3, 1));
198        // Last-days branch.
199        assert!(in_turn_window(31, 31, 3, 1));
200        assert!(!in_turn_window(30, 31, 3, 1));
201        // Saturating subtraction when n_last exceeds the month length.
202        assert!(in_turn_window(1, 28, 0, 40));
203    }
204
205    #[test]
206    fn rejects_empty_window() {
207        assert!(matches!(TurnOfMonth::new(0, 0, 0), Err(Error::PeriodZero)));
208    }
209
210    #[test]
211    fn metadata_and_accessors() {
212        let tom = TurnOfMonth::classic();
213        assert_eq!(tom.params(), (3, 1, 0));
214        assert_eq!(tom.name(), "TurnOfMonth");
215        assert_eq!(tom.warmup_period(), 2);
216        assert!(!tom.is_ready());
217        assert!(tom.value().is_none());
218    }
219
220    #[test]
221    fn averages_in_window_returns_only() {
222        let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
223        // 2021-01-28 (out of window, no prior close): close 100.
224        assert!(tom.update(c(100.0, JAN28_2021)).is_none());
225        // 2021-01-29 (out of window: dom 29, dim 31 -> 29 <= 30): return ignored.
226        assert!(tom.update(c(110.0, JAN28_2021 + DAY)).is_none());
227        // 2021-01-30 (out of window): completes 01-29; still none.
228        assert!(tom.update(c(120.0, JAN28_2021 + 2 * DAY)).is_none());
229        // 2021-01-31 (last day, in window): completes 01-30 (out). Still none.
230        assert!(tom.update(c(121.0, JAN28_2021 + 3 * DAY)).is_none());
231        // 2021-02-01 (first day, in window): completes 01-31 (in window).
232        // return = 121 / 120 - 1.
233        let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap();
234        assert_relative_eq!(v, 121.0 / 120.0 - 1.0);
235        assert!(tom.is_ready());
236    }
237
238    #[test]
239    fn zero_prev_close_contributes_zero() {
240        let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
241        // 2021-01-30 closes at 0 — becomes the prior close for 01-31.
242        tom.update(c(0.0, JAN28_2021 + 2 * DAY));
243        // 2021-01-31 (last day, in window): finalizes 01-30 with no prior -> no
244        // contribution, but records prev_day_close = 0.
245        tom.update(c(5.0, JAN28_2021 + 3 * DAY));
246        // 2021-02-01 (in window): finalizes 01-31 with prev_close 0 -> ret 0.
247        let v = tom.update(c(50.0, JAN28_2021 + 4 * DAY)).unwrap();
248        assert_relative_eq!(v, 0.0);
249    }
250
251    #[test]
252    fn same_day_bars_use_latest_close() {
253        let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
254        // 2021-01-30 closes at 100 (prior day, sets prev_day_close).
255        tom.update(c(100.0, JAN28_2021 + 2 * DAY));
256        // 2021-01-31 two bars on the same day; the later close (120) wins.
257        tom.update(c(110.0, JAN28_2021 + 3 * DAY));
258        tom.update(c(120.0, JAN28_2021 + 3 * DAY + 3_600_000));
259        // 2021-02-01 (in window) finalizes 01-31: return = 120 / 100 - 1 = 0.20.
260        let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap();
261        assert_relative_eq!(v, 0.20);
262    }
263
264    #[test]
265    fn reset_clears_state() {
266        let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
267        tom.update(c(121.0, JAN28_2021 + 3 * DAY));
268        tom.update(c(130.0, JAN28_2021 + 4 * DAY));
269        tom.reset();
270        assert!(!tom.is_ready());
271        assert!(tom.value().is_none());
272    }
273
274    #[test]
275    fn batch_equals_streaming() {
276        let candles: Vec<Candle> = (0..40)
277            .map(|i| c(100.0 + f64::from(i), JAN28_2021 + i64::from(i) * DAY))
278            .collect();
279        let mut a = TurnOfMonth::new(3, 2, 0).unwrap();
280        let mut b = TurnOfMonth::new(3, 2, 0).unwrap();
281        assert_eq!(
282            a.batch(&candles),
283            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
284        );
285    }
286}