1use volas_core::{Result, Tz, VolasError};
4
5const 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#[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 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 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 pub fn unify(&self, ns: i64) -> i64 {
94 self.unify_tz(ns, Tz::Utc)
95 }
96
97 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 => volas_core::datetime::days_from_civil(y, mo, d).div_euclid(3),
123 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 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; 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 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 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 #[test]
249 fn period_start_floors_to_the_grid() {
250 let ns = datetime::parse_ns("2024-05-22 13:47:23").unwrap(); 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 assert_eq!(start(TimeFrame::Day3), at("2024-05-20 00:00:00"));
268 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 #[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(); assert_eq!(
282 TimeFrame::Day1.period_start_ns(ns, sh),
283 datetime::parse_ns("2024-05-21 16:00:00").unwrap() );
285 let ny = Tz::parse("America/New_York").unwrap();
288 let in_fold = datetime::parse_ns("2024-11-03 05:30:00").unwrap(); assert_eq!(
290 TimeFrame::Hour1.period_start_ns(in_fold, ny),
291 datetime::parse_ns("2024-11-03 05:00:00").unwrap()
292 );
293 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() );
301 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 #[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 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 assert_eq!(TimeFrame::Week1.unify(mon), TimeFrame::Week1.unify(sun));
356 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 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), (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), (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 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 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 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}