Skip to main content

volas_time/
time_frame.rs

1//! Time frames and their period-key unification.
2
3use volas_core::{Result, Tz, VolasError};
4
5// Magnitude-positional encoding of a truncated civil datetime (matches
6// stock-pandas, so `unify("2020-01-02 03:04:05")` for seconds == 20200102030405).
7const SEC: i64 = 1;
8const MIN: i64 = 100;
9const HOUR: i64 = 10_000;
10const DAY: i64 = 1_000_000;
11const MONTH: i64 = 100_000_000;
12const YEAR: i64 = 10_000_000_000;
13
14/// An OHLCV sampling period.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum TimeFrame {
17    Sec1,
18    Min1,
19    Min3,
20    Min5,
21    Min15,
22    Min30,
23    Hour1,
24    Hour2,
25    Hour4,
26    Hour6,
27    Hour8,
28    Hour12,
29    Day1,
30    Day3,
31    Week1,
32    Month1,
33    Year1,
34}
35
36impl TimeFrame {
37    /// Parse a label (`"5m"`, `"1h"`, `"1d"`, `"1M"`, `"1w"`, `"1y"`, `"1s"`, plus
38    /// upper-case aliases).
39    pub fn from_label(s: &str) -> Result<TimeFrame> {
40        use TimeFrame::*;
41        Ok(match s {
42            "1s" | "1S" => Sec1,
43            "1m" => Min1,
44            "3m" => Min3,
45            "5m" => Min5,
46            "15m" => Min15,
47            "30m" => Min30,
48            "1h" | "1H" => Hour1,
49            "2h" | "2H" => Hour2,
50            "4h" | "4H" => Hour4,
51            "6h" | "6H" => Hour6,
52            "8h" | "8H" => Hour8,
53            "12h" | "12H" => Hour12,
54            "1d" | "1D" => Day1,
55            "3d" | "3D" => Day3,
56            "1w" | "1W" => Week1,
57            "1M" => Month1,
58            "1y" | "1Y" => Year1,
59            _ => {
60                return Err(VolasError::Value(format!(
61                    "\"{s}\" is an invalid time frame"
62                )))
63            }
64        })
65    }
66
67    /// The canonical label, e.g. `"5m"`.
68    pub fn label(&self) -> &'static str {
69        use TimeFrame::*;
70        match self {
71            Sec1 => "1s",
72            Min1 => "1m",
73            Min3 => "3m",
74            Min5 => "5m",
75            Min15 => "15m",
76            Min30 => "30m",
77            Hour1 => "1h",
78            Hour2 => "2h",
79            Hour4 => "4h",
80            Hour6 => "6h",
81            Hour8 => "8h",
82            Hour12 => "12h",
83            Day1 => "1d",
84            Day3 => "3d",
85            Week1 => "1w",
86            Month1 => "1M",
87            Year1 => "1y",
88        }
89    }
90
91    /// Unify an epoch-ns timestamp to its period key (in **UTC** wall-clock). Two
92    /// timestamps are in the same period iff their keys are equal.
93    pub fn unify(&self, ns: i64) -> i64 {
94        self.unify_tz(ns, Tz::Utc)
95    }
96
97    /// Unify an epoch-ns timestamp to its period key in `tz`'s wall-clock, so that
98    /// hour+ buckets (e.g. daily bars) align to the local trading day — DST-aware
99    /// for a named zone. Storage stays UTC; only the bucketing uses `tz`.
100    pub fn unify_tz(&self, ns: i64, tz: Tz) -> i64 {
101        let (y, mo, d, h, mi, s) = tz.civil_parts(ns);
102        use TimeFrame::*;
103        match self {
104            Sec1 => s * SEC + mi * MIN + h * HOUR + d * DAY + mo * MONTH + y * YEAR,
105            Min1 => mi * MIN + h * HOUR + d * DAY + mo * MONTH + y * YEAR,
106            Min3 => (mi / 3) * MIN + h * HOUR + d * DAY + mo * MONTH + y * YEAR,
107            Min5 => (mi / 5) * MIN + h * HOUR + d * DAY + mo * MONTH + y * YEAR,
108            Min15 => (mi / 15) * MIN + h * HOUR + d * DAY + mo * MONTH + y * YEAR,
109            Min30 => (mi / 30) * MIN + h * HOUR + d * DAY + mo * MONTH + y * YEAR,
110            Hour1 => h * HOUR + d * DAY + mo * MONTH + y * YEAR,
111            Hour2 => (h / 2) * HOUR + d * DAY + mo * MONTH + y * YEAR,
112            Hour4 => (h / 4) * HOUR + d * DAY + mo * MONTH + y * YEAR,
113            Hour6 => (h / 6) * HOUR + d * DAY + mo * MONTH + y * YEAR,
114            Hour8 => (h / 8) * HOUR + d * DAY + mo * MONTH + y * YEAR,
115            Hour12 => (h / 12) * HOUR + d * DAY + mo * MONTH + y * YEAR,
116            Day1 => d * DAY + mo * MONTH + y * YEAR,
117            // Day3 and Week1 are CONTINUOUS, epoch-anchored buckets — never reset
118            // at a month boundary (the old `(d/3)`/`(d/7)` civil-field scheme split
119            // a real 3-day/week run at the month edge). They key off the continuous
120            // day count since the Unix epoch; the key is monotonic and unique per
121            // bucket, which is all `group_runs` needs.
122            Day3 => volas_core::datetime::days_from_civil(y, mo, d).div_euclid(3),
123            // 1970-01-01 is a Thursday, so `+3` anchors week boundaries on Monday.
124            Week1 => (volas_core::datetime::days_from_civil(y, mo, d) + 3).div_euclid(7),
125            Month1 => mo * MONTH + y * YEAR,
126            Year1 => y * YEAR,
127        }
128    }
129
130    /// The UTC epoch-ns instant at which `ns`'s period **starts** in `tz`'s
131    /// wall-clock — the bar label of a cumulated period (owner decision
132    /// 2026-06-12: bar labels are the grid period start, origin + n·delta, not
133    /// the first raw timestamp). The grid mirrors [`TimeFrame::unify_tz`]
134    /// field-for-field: intraday frames anchor at local midnight (a 15-minute
135    /// bar starts at :00/:15/:30/:45), multi-hour frames at hours 0/k/2k…,
136    /// Day3 at the Unix epoch, Week1 on Monday, Month1/Year1 on the calendar.
137    /// A DST anomaly at the boundary resolves to the bucket's earliest real
138    /// instant (see [`Tz::wall_to_utc_ns_earliest`]).
139    pub fn period_start_ns(&self, ns: i64, tz: Tz) -> i64 {
140        let (y, mo, d, h, mi, s) = tz.civil_parts(ns);
141        use TimeFrame::*;
142        let (y, mo, d, h, mi, s) = match self {
143            Sec1 => (y, mo, d, h, mi, s),
144            Min1 => (y, mo, d, h, mi, 0),
145            Min3 => (y, mo, d, h, (mi / 3) * 3, 0),
146            Min5 => (y, mo, d, h, (mi / 5) * 5, 0),
147            Min15 => (y, mo, d, h, (mi / 15) * 15, 0),
148            Min30 => (y, mo, d, h, (mi / 30) * 30, 0),
149            Hour1 => (y, mo, d, h, 0, 0),
150            Hour2 => (y, mo, d, (h / 2) * 2, 0, 0),
151            Hour4 => (y, mo, d, (h / 4) * 4, 0, 0),
152            Hour6 => (y, mo, d, (h / 6) * 6, 0, 0),
153            Hour8 => (y, mo, d, (h / 8) * 8, 0, 0),
154            Hour12 => (y, mo, d, (h / 12) * 12, 0, 0),
155            Day1 => (y, mo, d, 0, 0, 0),
156            Day3 => {
157                let start = volas_core::datetime::days_from_civil(y, mo, d).div_euclid(3) * 3;
158                let (y, mo, d) = volas_core::datetime::civil_from_days(start);
159                (y, mo, d, 0, 0, 0)
160            }
161            Week1 => {
162                let days = volas_core::datetime::days_from_civil(y, mo, d);
163                let start = (days + 3).div_euclid(7) * 7 - 3; // Monday anchor
164                let (y, mo, d) = volas_core::datetime::civil_from_days(start);
165                (y, mo, d, 0, 0, 0)
166            }
167            Month1 => (y, mo, 1, 0, 0, 0),
168            Year1 => (y, 1, 1, 0, 0, 0),
169        };
170        tz.wall_to_utc_ns_earliest(y as i32, mo as u32, d as u32, h as u32, mi as u32, s as u32)
171            .expect("a floored civil instant derived from a valid timestamp is valid")
172    }
173
174    /// Whether this frame can be cleanly coarsened (aggregated) up to `dst` —
175    /// i.e. every `dst` bucket boundary is also a boundary of `self`, so each
176    /// `dst` bar is a whole number of `self` bars with none straddling.
177    ///
178    /// Fixed-duration frames (≤ `Week1`) nest by duration divisibility on the
179    /// epoch grid. The calendar frames need care: a sub-day / 1-day frame nests
180    /// into a week / month / year (its boundaries fall on every UTC midnight,
181    /// hence on Monday week-starts and on the 1st), and a month nests into a year
182    /// — but a **week or a 3-day bar does NOT nest into a month or year** (ISO
183    /// weeks and epoch-anchored 3-day bars straddle calendar boundaries).
184    pub fn can_coarsen(self, dst: TimeFrame) -> bool {
185        use TimeFrame::*;
186        if self == dst {
187            return true;
188        }
189        let day_aligned = matches!(
190            self,
191            Sec1 | Min1
192                | Min3
193                | Min5
194                | Min15
195                | Min30
196                | Hour1
197                | Hour2
198                | Hour4
199                | Hour6
200                | Hour8
201                | Hour12
202                | Day1
203        );
204        match dst {
205            Year1 => self == Month1 || day_aligned,
206            Month1 => day_aligned,
207            Week1 => day_aligned,
208            _ => match (self.duration_secs(), dst.duration_secs()) {
209                (Some(s), Some(d)) => d > s && d % s == 0,
210                _ => false,
211            },
212        }
213    }
214
215    /// The fixed duration in seconds for the fixed-length frames; `None` for the
216    /// variable-length calendar frames (`Month1` / `Year1`). Used only by
217    /// [`can_coarsen`](Self::can_coarsen).
218    fn duration_secs(self) -> Option<i64> {
219        use TimeFrame::*;
220        Some(match self {
221            Sec1 => 1,
222            Min1 => 60,
223            Min3 => 180,
224            Min5 => 300,
225            Min15 => 900,
226            Min30 => 1800,
227            Hour1 => 3600,
228            Hour2 => 7200,
229            Hour4 => 14400,
230            Hour6 => 21600,
231            Hour8 => 28800,
232            Hour12 => 43200,
233            Day1 => 86400,
234            Day3 => 259200,
235            Week1 => 604800,
236            Month1 | Year1 => return None,
237        })
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use volas_core::datetime;
245
246    /// `period_start_ns` floors an off-grid instant to its grid origin + n·delta
247    /// for every frame kind — the bar-label contract (owner decision 2026-06-12).
248    #[test]
249    fn period_start_floors_to_the_grid() {
250        let ns = datetime::parse_ns("2024-05-22 13:47:23").unwrap(); // a Wednesday
251        let start = |tf: TimeFrame| tf.period_start_ns(ns, Tz::Utc);
252        let at = |s: &str| datetime::parse_ns(s).unwrap();
253        assert_eq!(start(TimeFrame::Sec1), at("2024-05-22 13:47:23"));
254        assert_eq!(start(TimeFrame::Min1), at("2024-05-22 13:47:00"));
255        assert_eq!(start(TimeFrame::Min3), at("2024-05-22 13:45:00"));
256        assert_eq!(start(TimeFrame::Min5), at("2024-05-22 13:45:00"));
257        assert_eq!(start(TimeFrame::Min15), at("2024-05-22 13:45:00"));
258        assert_eq!(start(TimeFrame::Min30), at("2024-05-22 13:30:00"));
259        assert_eq!(start(TimeFrame::Hour1), at("2024-05-22 13:00:00"));
260        assert_eq!(start(TimeFrame::Hour2), at("2024-05-22 12:00:00"));
261        assert_eq!(start(TimeFrame::Hour4), at("2024-05-22 12:00:00"));
262        assert_eq!(start(TimeFrame::Hour6), at("2024-05-22 12:00:00"));
263        assert_eq!(start(TimeFrame::Hour8), at("2024-05-22 08:00:00"));
264        assert_eq!(start(TimeFrame::Hour12), at("2024-05-22 12:00:00"));
265        assert_eq!(start(TimeFrame::Day1), at("2024-05-22 00:00:00"));
266        // Day3 is epoch-anchored: day 19865 (2024-05-22) floors to 19863 (05-20).
267        assert_eq!(start(TimeFrame::Day3), at("2024-05-20 00:00:00"));
268        // Week1 anchors on Monday: 2024-05-22 is Wednesday -> Monday 05-20.
269        assert_eq!(start(TimeFrame::Week1), at("2024-05-20 00:00:00"));
270        assert_eq!(start(TimeFrame::Month1), at("2024-05-01 00:00:00"));
271        assert_eq!(start(TimeFrame::Year1), at("2024-01-01 00:00:00"));
272    }
273
274    /// In a named zone the grid is the LOCAL wall-clock (a Shanghai daily bar
275    /// starts at 00:00 +08:00 = 16:00 UTC of the prior day), and a DST fold at
276    /// the boundary resolves to the earliest occurrence.
277    #[test]
278    fn period_start_uses_local_wall_clock_and_resolves_dst() {
279        let sh = Tz::parse("Asia/Shanghai").unwrap();
280        let ns = datetime::parse_ns("2024-05-22 03:30:00").unwrap(); // 11:30 in Shanghai
281        assert_eq!(
282            TimeFrame::Day1.period_start_ns(ns, sh),
283            datetime::parse_ns("2024-05-21 16:00:00").unwrap() // 05-22 00:00 +08
284        );
285        // America/New_York 2024-11-03: 01:00-02:00 EDT repeats (fold). An Hour1
286        // bar inside the fold labels the EARLIEST 01:00 (05:00 UTC, not 06:00).
287        let ny = Tz::parse("America/New_York").unwrap();
288        let in_fold = datetime::parse_ns("2024-11-03 05:30:00").unwrap(); // first 01:30 EDT
289        assert_eq!(
290            TimeFrame::Hour1.period_start_ns(in_fold, ny),
291            datetime::parse_ns("2024-11-03 05:00:00").unwrap()
292        );
293        // 2024-03-10: 02:00-03:00 EST does not exist (gap). A 02:xx-grid start is
294        // unreachable from real timestamps, but a Day1 bar over the gap day still
295        // starts at the (existing) local midnight.
296        let gap_day = datetime::parse_ns("2024-03-10 12:00:00").unwrap();
297        assert_eq!(
298            TimeFrame::Day1.period_start_ns(gap_day, ny),
299            datetime::parse_ns("2024-03-10 05:00:00").unwrap() // 00:00 EST
300        );
301        // América/São_Paulo 2018-11-04: midnight itself did not exist (the clock
302        // sprang 00:00 -> 01:00). The daily bar's start resolves to the first
303        // real instant of the day: 01:00 -02:00 = 03:00 UTC (the gap-probe path).
304        let sp = Tz::parse("America/Sao_Paulo").unwrap();
305        let mid_day = datetime::parse_ns("2018-11-04 15:00:00").unwrap();
306        assert_eq!(
307            TimeFrame::Day1.period_start_ns(mid_day, sp),
308            datetime::parse_ns("2018-11-04 03:00:00").unwrap()
309        );
310    }
311
312    /// `civil_from_days` is the exact inverse of `days_from_civil` across a wide
313    /// sweep, including the negative (pre-epoch) range.
314    #[test]
315    fn civil_from_days_roundtrips() {
316        for days in (-30000..40000).step_by(97) {
317            let (y, mo, d) = datetime::civil_from_days(days);
318            assert_eq!(datetime::days_from_civil(y, mo, d), days);
319        }
320    }
321
322    #[test]
323    fn labels_and_parse() {
324        assert_eq!(TimeFrame::Min5.label(), "5m");
325        assert_eq!(TimeFrame::Month1.label(), "1M");
326        assert_eq!(TimeFrame::from_label("5m").unwrap(), TimeFrame::Min5);
327        assert_eq!(TimeFrame::from_label("1M").unwrap(), TimeFrame::Month1);
328        assert_eq!(TimeFrame::from_label("1H").unwrap(), TimeFrame::Hour1);
329        assert!(TimeFrame::from_label("1").is_err());
330    }
331
332    #[test]
333    fn unify_second_contract() {
334        let ns = datetime::parse_ns("2020-01-02 03:04:05").unwrap();
335        assert_eq!(TimeFrame::Sec1.unify(ns), 20_200_102_030_405);
336    }
337
338    #[test]
339    fn unify_groups_same_5min_block() {
340        let a = datetime::parse_ns("2020-01-01 00:00:00").unwrap();
341        let b = datetime::parse_ns("2020-01-01 00:04:59").unwrap();
342        let c = datetime::parse_ns("2020-01-01 00:05:00").unwrap();
343        assert_eq!(TimeFrame::Min5.unify(a), TimeFrame::Min5.unify(b));
344        assert_ne!(TimeFrame::Min5.unify(a), TimeFrame::Min5.unify(c));
345    }
346
347    #[test]
348    fn week_is_continuous_and_monday_anchored() {
349        // 2024-01-29 is a Monday; the run Mon..Sun (2024-02-04) crosses Feb 1.
350        let mon = datetime::parse_ns("2024-01-29 00:00:00").unwrap();
351        let sun = datetime::parse_ns("2024-02-04 23:59:59").unwrap();
352        let prev_sun = datetime::parse_ns("2024-01-28 23:59:59").unwrap();
353        let next_mon = datetime::parse_ns("2024-02-05 00:00:00").unwrap();
354        // one continuous week, even across the month boundary (the old (d/7) split it)
355        assert_eq!(TimeFrame::Week1.unify(mon), TimeFrame::Week1.unify(sun));
356        // boundaries land on Monday
357        assert_ne!(
358            TimeFrame::Week1.unify(mon),
359            TimeFrame::Week1.unify(prev_sun)
360        );
361        assert_ne!(
362            TimeFrame::Week1.unify(mon),
363            TimeFrame::Week1.unify(next_mon)
364        );
365    }
366
367    #[test]
368    fn day3_is_continuous_across_month_boundary() {
369        // Each calendar day advances the epoch-anchored 3-day bucket by 0 or 1 —
370        // never the month-reset jump the old (d/3) scheme produced at Feb 1.
371        let mut prev = TimeFrame::Day3.unify(datetime::parse_ns("2024-01-28 00:00:00").unwrap());
372        for day in [
373            "2024-01-29",
374            "2024-01-30",
375            "2024-01-31",
376            "2024-02-01",
377            "2024-02-02",
378        ] {
379            let k = TimeFrame::Day3.unify(datetime::parse_ns(&format!("{day} 00:00:00")).unwrap());
380            assert!(k == prev || k == prev + 1, "{day}: bucket {k}, prev {prev}");
381            prev = k;
382        }
383    }
384
385    #[test]
386    fn can_coarsen_truth_table() {
387        use TimeFrame::*;
388        for (s, d) in [
389            (Min5, Min5), // identity (= copy)
390            // every fixed-duration source frame tiles a coarser one on the epoch grid
391            (Sec1, Min1),
392            (Min1, Min5),
393            (Min30, Hour1),
394            (Hour2, Hour6),
395            (Hour8, Day1),
396            (Min5, Min15),
397            (Min15, Hour1),
398            (Min5, Hour1),
399            (Hour1, Hour4),
400            (Hour4, Hour12),
401            (Hour1, Day1),
402            (Day1, Day3),
403            (Hour12, Day3),
404            (Day1, Week1),
405            (Min5, Week1),
406            (Day1, Month1),
407            (Hour1, Month1),
408            (Month1, Year1),
409            (Day1, Year1),
410        ] {
411            assert!(s.can_coarsen(d), "{s:?} -> {d:?} should be valid");
412        }
413        for (s, d) in [
414            (Min3, Min5),
415            (Hour4, Hour6),
416            (Day3, Week1),
417            (Week1, Month1),
418            (Week1, Year1),
419            (Day3, Month1),
420            (Day3, Year1),
421            (Day1, Hour1), // refining, not coarsening
422            (Week1, Day1),
423            (Month1, Day1),
424        ] {
425            assert!(!s.can_coarsen(d), "{s:?} -> {d:?} should be invalid");
426        }
427    }
428
429    const ALL: [TimeFrame; 17] = [
430        TimeFrame::Sec1,
431        TimeFrame::Min1,
432        TimeFrame::Min3,
433        TimeFrame::Min5,
434        TimeFrame::Min15,
435        TimeFrame::Min30,
436        TimeFrame::Hour1,
437        TimeFrame::Hour2,
438        TimeFrame::Hour4,
439        TimeFrame::Hour6,
440        TimeFrame::Hour8,
441        TimeFrame::Hour12,
442        TimeFrame::Day1,
443        TimeFrame::Day3,
444        TimeFrame::Week1,
445        TimeFrame::Month1,
446        TimeFrame::Year1,
447    ];
448
449    #[test]
450    fn label_roundtrips_for_every_frame() {
451        for tf in ALL {
452            assert_eq!(TimeFrame::from_label(tf.label()).unwrap(), tf);
453        }
454        // Upper-case aliases also parse.
455        for alias in ["1S", "2H", "4H", "6H", "8H", "12H", "3D", "1W", "1Y"] {
456            assert!(TimeFrame::from_label(alias).is_ok());
457        }
458    }
459
460    #[test]
461    fn unify_covers_every_branch() {
462        // A timestamp whose every civil field is non-trivial, so each frame's
463        // truncation arm is exercised and yields a well-formed key.
464        let ns = datetime::parse_ns("2021-07-19 22:47:53").unwrap();
465        for tf in ALL {
466            assert!(tf.unify(ns) > 0);
467        }
468        // Same-period vs next-period contract for each granularity boundary.
469        let mid = datetime::parse_ns("2021-07-19 22:47:53").unwrap();
470        let same_hour = datetime::parse_ns("2021-07-19 22:00:00").unwrap();
471        assert_eq!(
472            TimeFrame::Hour1.unify(mid),
473            TimeFrame::Hour1.unify(same_hour)
474        );
475        let same_day = datetime::parse_ns("2021-07-19 00:00:00").unwrap();
476        assert_eq!(TimeFrame::Day1.unify(mid), TimeFrame::Day1.unify(same_day));
477        let same_month = datetime::parse_ns("2021-07-01 00:00:00").unwrap();
478        assert_eq!(
479            TimeFrame::Month1.unify(mid),
480            TimeFrame::Month1.unify(same_month)
481        );
482    }
483}