Skip to main content

df_hyperliquid_core/utils/
time.rs

1//! A UTC minute as the venue writes one in an outcome's description, `YYYYMMDD-HHMM`, counted
2//! from 1970 in integers on the proleptic Gregorian calendar. There is no clock here: the caller
3//! passes now, in milliseconds.
4
5use std::fmt;
6
7const MINUTE_MS: i64 = 60_000;
8
9/// One UTC minute: `20261001-0000` on the wire, `2026-10-01 00:00Z` to read.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct Stamp(i64);
12
13impl Stamp {
14    /// Reads `YYYYMMDD-HHMM` exactly, spaces around it aside. `None` for any other shape and for
15    /// a day or minute that does not exist.
16    #[must_use]
17    pub fn parse(text: &str) -> Option<Stamp> {
18        Stamp::exact(text.trim())
19    }
20
21    /// `YYYYMMDD-HHMM` and nothing else, not even a space: `20260231-1200`, `20261001-2400` and
22    /// `20261001-1260` are no minute.
23    #[must_use]
24    pub fn exact(text: &str) -> Option<Stamp> {
25        let raw = text.as_bytes();
26        if raw.len() != 13 || raw.get(8) != Some(&b'-') {
27            return None;
28        }
29        let (year, month, day) = (digits(raw, 0, 4)?, digits(raw, 4, 2)?, digits(raw, 6, 2)?);
30        minutes_since_1970(year, month, day, digits(raw, 9, 2)?, digits(raw, 11, 2)?).map(Stamp)
31    }
32
33    /// A day as `YYYYMMDD` and nothing else, at its midnight.
34    #[must_use]
35    pub fn day(text: &str) -> Option<Stamp> {
36        let raw = text.as_bytes();
37        if raw.len() != 8 {
38            return None;
39        }
40        minutes_since_1970(digits(raw, 0, 4)?, digits(raw, 4, 2)?, digits(raw, 6, 2)?, 0, 0).map(Stamp)
41    }
42
43    /// Minutes since 1970.
44    #[must_use]
45    pub const fn from_minutes(minutes: i64) -> Stamp {
46        Stamp(minutes)
47    }
48
49    #[must_use]
50    pub const fn minutes(self) -> i64 {
51        self.0
52    }
53
54    /// Milliseconds since 1970.
55    #[must_use]
56    pub const fn ms(self) -> i64 {
57        self.0.saturating_mul(MINUTE_MS)
58    }
59
60    /// Whether `now_ms` is at or past this minute.
61    #[must_use]
62    pub fn passed(self, now_ms: u64) -> bool {
63        i128::from(now_ms) >= i128::from(self.ms())
64    }
65
66    /// `YYYYMMDD-HHMM`, as a description writes it.
67    #[must_use]
68    pub fn wire(self) -> String {
69        let (year, month, day, hour, minute) = self.parts();
70        format!("{year:04}{month:02}{day:02}-{hour:02}{minute:02}")
71    }
72
73    fn parts(self) -> (i64, u32, u32, i64, i64) {
74        let (year, month, day) = civil_from_days(self.0.div_euclid(1_440));
75        let within = self.0.rem_euclid(1_440);
76        (year, month, day, within / 60, within % 60)
77    }
78}
79
80/// `2026-10-01 00:00Z`.
81impl fmt::Display for Stamp {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        let (year, month, day, hour, minute) = self.parts();
84        write!(f, "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}Z")
85    }
86}
87
88/// `count` ASCII digits from `from`, as a number; `None` where any is not a digit.
89fn digits(raw: &[u8], from: usize, count: usize) -> Option<i64> {
90    let field = raw.get(from..from.checked_add(count)?)?;
91    field.iter().try_fold(0i64, |sum, byte| byte.is_ascii_digit().then(|| sum * 10 + i64::from(byte - b'0')))
92}
93
94/// Minutes from 1970 to a minute of the calendar; `None` for one that does not exist.
95fn minutes_since_1970(year: i64, month: i64, day: i64, hour: i64, minute: i64) -> Option<i64> {
96    let month = u32::try_from(month).ok().filter(|month| (1..=12).contains(month))?;
97    let day = u32::try_from(day).ok().filter(|day| (1..=days_in(year, month)).contains(day))?;
98    if !(0..24).contains(&hour) || !(0..60).contains(&minute) {
99        return None;
100    }
101    Some((days_from_civil(year, month, day) * 24 + hour) * 60 + minute)
102}
103
104/// The day of the calendar `days` after 1970-01-01 (Howard Hinnant's `civil_from_days`).
105fn civil_from_days(days: i64) -> (i64, u32, u32) {
106    let shifted = days + 719_468;
107    let era = shifted.div_euclid(146_097);
108    let day_of_era = shifted.rem_euclid(146_097);
109    let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
110    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
111    let month_from_march = (5 * day_of_year + 2) / 153;
112    let day = day_of_year - (153 * month_from_march + 2) / 5 + 1;
113    let month = if month_from_march < 10 { month_from_march + 3 } else { month_from_march - 9 };
114    let year = year_of_era + era * 400;
115    let year = if month <= 2 { year + 1 } else { year };
116    (year, u32::try_from(month).unwrap_or(1), u32::try_from(day).unwrap_or(1))
117}
118
119/// Days from 1970-01-01 to a day of the calendar (Howard Hinnant's `days_from_civil`).
120fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
121    let (month, day) = (i64::from(month), i64::from(day));
122    let year = if month <= 2 { year - 1 } else { year };
123    let era = year.div_euclid(400);
124    let year_of_era = year - era * 400;
125    let month_from_march = if month > 2 { month - 3 } else { month + 9 };
126    let day_of_year = (153 * month_from_march + 2) / 5 + day - 1;
127    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
128    era * 146_097 + day_of_era - 719_468
129}
130
131const fn days_in(year: i64, month: u32) -> u32 {
132    match month {
133        2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
134        2 => 28,
135        4 | 6 | 9 | 11 => 30,
136        _ => 31,
137    }
138}
139
140#[cfg(test)]
141#[path = "tests/time.rs"]
142mod tests;