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    /// Construct a Turn-of-Month indicator.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`Error::PeriodZero`] if both `n_first` and `n_last` are zero (the
61    /// window would never include a day).
62    pub fn new(n_first: u32, n_last: u32, utc_offset_minutes: i32) -> Result<Self> {
63        if n_first == 0 && n_last == 0 {
64            return Err(Error::PeriodZero);
65        }
66        Ok(Self {
67            n_first,
68            n_last,
69            utc_offset_minutes,
70            day: None,
71            cur_close: 0.0,
72            prev_day_close: None,
73            sum: 0.0,
74            count: 0,
75        })
76    }
77
78    /// Classic turn-of-month window: first 3 and last 1 day of the month.
79    pub fn classic() -> Self {
80        Self::new(3, 1, 0).expect("classic turn-of-month window is valid")
81    }
82
83    /// Configured `(n_first, n_last, utc_offset_minutes)`.
84    pub const fn params(&self) -> (u32, u32, i32) {
85        (self.n_first, self.n_last, self.utc_offset_minutes)
86    }
87
88    /// Most recent mean turn-of-month return if any in-window day has completed.
89    pub fn value(&self) -> Option<f64> {
90        if self.count == 0 {
91            None
92        } else {
93            Some(self.sum / self.count as f64)
94        }
95    }
96
97    /// Settle the just-finished day `(year, month, dom)` whose last close is
98    /// `self.cur_close`, then start `next_key`.
99    fn roll_into(
100        &mut self,
101        year: i64,
102        month: u32,
103        dom: u32,
104        next_key: (i64, u32, u32),
105        close: f64,
106    ) {
107        if let Some(prev) = self.prev_day_close {
108            let ret = if prev == 0.0 {
109                0.0
110            } else {
111                self.cur_close / prev - 1.0
112            };
113            if in_turn_window(dom, days_in_month(year, month), self.n_first, self.n_last) {
114                self.sum += ret;
115                self.count += 1;
116            }
117        }
118        self.prev_day_close = Some(self.cur_close);
119        self.day = Some(next_key);
120        self.cur_close = close;
121    }
122}
123
124impl Indicator for TurnOfMonth {
125    type Input = Candle;
126    type Output = f64;
127
128    fn update(&mut self, candle: Candle) -> Option<f64> {
129        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
130        let key = (civil.year, civil.month, civil.day);
131        match self.day {
132            Some(prev) if prev == key => {
133                self.cur_close = candle.close;
134            }
135            Some((year, month, dom)) => {
136                self.roll_into(year, month, dom, key, candle.close);
137            }
138            None => {
139                self.day = Some(key);
140                self.cur_close = candle.close;
141            }
142        }
143        self.value()
144    }
145
146    fn reset(&mut self) {
147        self.day = None;
148        self.cur_close = 0.0;
149        self.prev_day_close = None;
150        self.sum = 0.0;
151        self.count = 0;
152    }
153
154    fn warmup_period(&self) -> usize {
155        2
156    }
157
158    fn is_ready(&self) -> bool {
159        self.count > 0
160    }
161
162    fn name(&self) -> &'static str {
163        "TurnOfMonth"
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::traits::BatchExt;
171    use approx::assert_relative_eq;
172
173    const DAY: i64 = 24 * 3_600_000;
174    // 2021-01-28 00:00 UTC.
175    const JAN28_2021: i64 = 1_611_792_000_000;
176
177    fn c(close: f64, ts: i64) -> Candle {
178        Candle::new(close, close, close, close, 1.0, ts).unwrap()
179    }
180
181    #[test]
182    fn window_predicate_branches() {
183        // First-days branch.
184        assert!(in_turn_window(1, 31, 3, 1));
185        assert!(in_turn_window(3, 31, 3, 1));
186        assert!(!in_turn_window(4, 31, 3, 1));
187        // Last-days branch.
188        assert!(in_turn_window(31, 31, 3, 1));
189        assert!(!in_turn_window(30, 31, 3, 1));
190        // Saturating subtraction when n_last exceeds the month length.
191        assert!(in_turn_window(1, 28, 0, 40));
192    }
193
194    #[test]
195    fn rejects_empty_window() {
196        assert!(matches!(TurnOfMonth::new(0, 0, 0), Err(Error::PeriodZero)));
197    }
198
199    #[test]
200    fn metadata_and_accessors() {
201        let tom = TurnOfMonth::classic();
202        assert_eq!(tom.params(), (3, 1, 0));
203        assert_eq!(tom.name(), "TurnOfMonth");
204        assert_eq!(tom.warmup_period(), 2);
205        assert!(!tom.is_ready());
206        assert!(tom.value().is_none());
207    }
208
209    #[test]
210    fn averages_in_window_returns_only() {
211        let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
212        // 2021-01-28 (out of window, no prior close): close 100.
213        assert!(tom.update(c(100.0, JAN28_2021)).is_none());
214        // 2021-01-29 (out of window: dom 29, dim 31 -> 29 <= 30): return ignored.
215        assert!(tom.update(c(110.0, JAN28_2021 + DAY)).is_none());
216        // 2021-01-30 (out of window): completes 01-29; still none.
217        assert!(tom.update(c(120.0, JAN28_2021 + 2 * DAY)).is_none());
218        // 2021-01-31 (last day, in window): completes 01-30 (out). Still none.
219        assert!(tom.update(c(121.0, JAN28_2021 + 3 * DAY)).is_none());
220        // 2021-02-01 (first day, in window): completes 01-31 (in window).
221        // return = 121 / 120 - 1.
222        let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap();
223        assert_relative_eq!(v, 121.0 / 120.0 - 1.0);
224        assert!(tom.is_ready());
225    }
226
227    #[test]
228    fn zero_prev_close_contributes_zero() {
229        let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
230        // 2021-01-30 closes at 0 — becomes the prior close for 01-31.
231        tom.update(c(0.0, JAN28_2021 + 2 * DAY));
232        // 2021-01-31 (last day, in window): finalizes 01-30 with no prior -> no
233        // contribution, but records prev_day_close = 0.
234        tom.update(c(5.0, JAN28_2021 + 3 * DAY));
235        // 2021-02-01 (in window): finalizes 01-31 with prev_close 0 -> ret 0.
236        let v = tom.update(c(50.0, JAN28_2021 + 4 * DAY)).unwrap();
237        assert_relative_eq!(v, 0.0);
238    }
239
240    #[test]
241    fn same_day_bars_use_latest_close() {
242        let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
243        // 2021-01-30 closes at 100 (prior day, sets prev_day_close).
244        tom.update(c(100.0, JAN28_2021 + 2 * DAY));
245        // 2021-01-31 two bars on the same day; the later close (120) wins.
246        tom.update(c(110.0, JAN28_2021 + 3 * DAY));
247        tom.update(c(120.0, JAN28_2021 + 3 * DAY + 3_600_000));
248        // 2021-02-01 (in window) finalizes 01-31: return = 120 / 100 - 1 = 0.20.
249        let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap();
250        assert_relative_eq!(v, 0.20);
251    }
252
253    #[test]
254    fn reset_clears_state() {
255        let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
256        tom.update(c(121.0, JAN28_2021 + 3 * DAY));
257        tom.update(c(130.0, JAN28_2021 + 4 * DAY));
258        tom.reset();
259        assert!(!tom.is_ready());
260        assert!(tom.value().is_none());
261    }
262
263    #[test]
264    fn batch_equals_streaming() {
265        let candles: Vec<Candle> = (0..40)
266            .map(|i| c(100.0 + f64::from(i), JAN28_2021 + i64::from(i) * DAY))
267            .collect();
268        let mut a = TurnOfMonth::new(3, 2, 0).unwrap();
269        let mut b = TurnOfMonth::new(3, 2, 0).unwrap();
270        assert_eq!(
271            a.batch(&candles),
272            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
273        );
274    }
275}