Skip to main content

df_hyperliquid_core/utils/
time.rs

1//! The calendar: instants and days in UTC, counted from 1970 in integers on the proleptic
2//! Gregorian calendar. There is no clock here: the caller passes now, in milliseconds.
3//!
4//! - [`At`]: an instant, read and written as RFC 3339.
5//! - [`Stamp`]: a minute as the venue writes one in an outcome's description, `YYYYMMDD-HHMM`.
6//! - [`civil`]: the year, month and day of an instant.
7
8use std::{borrow::Cow, fmt, str::FromStr};
9
10use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
11
12/// Milliseconds in a day.
13pub const DAY_MS: i64 = 86_400_000;
14
15const MINUTE_MS: i64 = 60_000;
16
17const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
18
19/// An instant: milliseconds since 1970 in UTC. Read from RFC 3339 and written as RFC 3339 with
20/// milliseconds and a `Z` (`2026-09-01T10:00:00.000Z`), a fixed width that sorts as text.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
22pub struct At(i64);
23
24impl At {
25    #[must_use]
26    pub const fn from_ms(ms: i64) -> At {
27        At(ms)
28    }
29
30    #[must_use]
31    pub const fn ms(self) -> i64 {
32        self.0
33    }
34
35    /// `YYYY-MM-DDTHH:MM:SS`, a fraction of a second if any (cut to the millisecond), and `Z` or
36    /// an offset `+HH:MM`. A space or a lowercase `t` may stand for the `T`, as RFC 3339
37    /// allows. `None` for anything else, a day that does not exist included.
38    #[must_use]
39    pub fn parse(text: &str) -> Option<At> {
40        let raw = text.as_bytes();
41        let at = |place: usize, byte: u8| raw.get(place) == Some(&byte);
42        let shaped = at(4, b'-') && at(7, b'-') && at(13, b':') && at(16, b':');
43        if !shaped || !matches!(raw.get(10), Some(b'T' | b't' | b' ')) {
44            return None;
45        }
46        let (year, month, day) = (digits(raw, 0, 4)?, digits(raw, 5, 2)?, digits(raw, 8, 2)?);
47        let (hour, minute, second) = (digits(raw, 11, 2)?, digits(raw, 14, 2)?, digits(raw, 17, 2)?);
48        if second > 59 {
49            return None;
50        }
51        let minutes = minutes_since_1970(year, month, day, hour, minute)?;
52        let mut place = 19;
53        let mut millis = 0i64;
54        if at(place, b'.') {
55            place += 1;
56            let start = place;
57            while let Some(digit) = raw.get(place).filter(|byte| byte.is_ascii_digit()) {
58                if place - start < 3 {
59                    millis = millis * 10 + i64::from(digit - b'0');
60                }
61                place += 1;
62            }
63            let read = place - start;
64            if read == 0 {
65                return None;
66            }
67            for _ in read..3 {
68                millis *= 10;
69            }
70        }
71        let offset = match raw.get(place..)? {
72            b"Z" | b"z" => 0,
73            [sign @ (b'+' | b'-'), _, _, b':', _, _] => {
74                let (hours, minutes) = (digits(raw, place + 1, 2)?, digits(raw, place + 4, 2)?);
75                if hours > 23 || minutes > 59 {
76                    return None;
77                }
78                let minutes = hours * 60 + minutes;
79                if *sign == b'-' { -minutes } else { minutes }
80            }
81            _ => return None,
82        };
83        Some(At(((minutes - offset) * 60 + second) * 1_000 + millis))
84    }
85
86    /// `days` whole days on, or back when negative.
87    #[must_use]
88    pub const fn plus_days(self, days: i64) -> At {
89        At(self.0.saturating_add(days.saturating_mul(DAY_MS)))
90    }
91
92    /// `months` calendar months on, or back, at the same time of day. A day the month does not
93    /// have becomes its last: the 31st of January a month on is the 28th of February, or the
94    /// 29th in a leap year.
95    #[must_use]
96    pub fn plus_months(self, months: i64) -> At {
97        let (year, month, day) = civil(self);
98        let counted = year * 12 + i64::from(month) - 1 + months;
99        let (year, month) = (counted.div_euclid(12), counted.rem_euclid(12) + 1);
100        let month = u32::try_from(month).unwrap_or(1);
101        let day = day.min(days_in(year, month));
102        At(days_from_civil(year, month, day) * DAY_MS + self.0.rem_euclid(DAY_MS))
103    }
104
105    /// The day, `YYYY-MM-DD`.
106    #[must_use]
107    pub fn date(self) -> String {
108        let (year, month, day) = civil(self);
109        format!("{year:04}-{month:02}-{day:02}")
110    }
111
112    /// The day, `YYYYMMDD`, as Hyperliquid names a builder's day file.
113    #[must_use]
114    pub fn compact(self) -> String {
115        let (year, month, day) = civil(self);
116        format!("{year:04}{month:02}{day:02}")
117    }
118}
119
120impl fmt::Display for At {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        let (year, month, day) = civil(*self);
123        let within = self.0.rem_euclid(DAY_MS);
124        let (hour, minute) = (within / 3_600_000, within / MINUTE_MS % 60);
125        let (second, millis) = (within / 1_000 % 60, within % 1_000);
126        write!(f, "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z")
127    }
128}
129
130impl FromStr for At {
131    type Err = BadAt;
132
133    fn from_str(text: &str) -> Result<Self, Self::Err> {
134        At::parse(text).ok_or_else(|| BadAt(text.to_owned()))
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
139#[error("`{0}` is not an RFC 3339 instant")]
140pub struct BadAt(pub String);
141
142impl Serialize for At {
143    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
144        serializer.collect_str(self)
145    }
146}
147
148impl<'de> Deserialize<'de> for At {
149    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
150        let text = <Cow<'de, str>>::deserialize(deserializer)?;
151        text.parse().map_err(de::Error::custom)
152    }
153}
154
155/// One UTC minute: `20261001-0000` on the wire, `2026-10-01 00:00Z` to read.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
157pub struct Stamp(i64);
158
159impl Stamp {
160    /// Reads `YYYYMMDD-HHMM` exactly, spaces around it aside. `None` for any other shape and for
161    /// a day or minute that does not exist.
162    #[must_use]
163    pub fn parse(text: &str) -> Option<Stamp> {
164        Stamp::exact(text.trim())
165    }
166
167    /// `YYYYMMDD-HHMM` and nothing else, not even a space: `20260231-1200`, `20261001-2400` and
168    /// `20261001-1260` are no minute.
169    #[must_use]
170    pub fn exact(text: &str) -> Option<Stamp> {
171        let raw = text.as_bytes();
172        if raw.len() != 13 || raw.get(8) != Some(&b'-') {
173            return None;
174        }
175        let (year, month, day) = (digits(raw, 0, 4)?, digits(raw, 4, 2)?, digits(raw, 6, 2)?);
176        minutes_since_1970(year, month, day, digits(raw, 9, 2)?, digits(raw, 11, 2)?).map(Stamp)
177    }
178
179    /// A day as `YYYYMMDD` and nothing else, at its midnight.
180    #[must_use]
181    pub fn day(text: &str) -> Option<Stamp> {
182        let raw = text.as_bytes();
183        if raw.len() != 8 {
184            return None;
185        }
186        minutes_since_1970(digits(raw, 0, 4)?, digits(raw, 4, 2)?, digits(raw, 6, 2)?, 0, 0).map(Stamp)
187    }
188
189    /// Minutes since 1970.
190    #[must_use]
191    pub const fn from_minutes(minutes: i64) -> Stamp {
192        Stamp(minutes)
193    }
194
195    #[must_use]
196    pub const fn minutes(self) -> i64 {
197        self.0
198    }
199
200    /// Milliseconds since 1970.
201    #[must_use]
202    pub const fn ms(self) -> i64 {
203        self.0.saturating_mul(MINUTE_MS)
204    }
205
206    /// Whether `now_ms` is at or past this minute.
207    #[must_use]
208    pub fn passed(self, now_ms: u64) -> bool {
209        i128::from(now_ms) >= i128::from(self.ms())
210    }
211
212    /// `YYYYMMDD-HHMM`, as a description writes it.
213    #[must_use]
214    pub fn wire(self) -> String {
215        let (year, month, day, hour, minute) = self.parts();
216        format!("{year:04}{month:02}{day:02}-{hour:02}{minute:02}")
217    }
218
219    fn parts(self) -> (i64, u32, u32, i64, i64) {
220        let (year, month, day) = civil_from_days(self.0.div_euclid(1_440));
221        let within = self.0.rem_euclid(1_440);
222        (year, month, day, within / 60, within % 60)
223    }
224}
225
226/// `2026-10-01 00:00Z`.
227impl fmt::Display for Stamp {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        let (year, month, day, hour, minute) = self.parts();
230        write!(f, "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}Z")
231    }
232}
233
234/// The year, month and day of an instant.
235#[must_use]
236pub fn civil(at: At) -> (i64, u32, u32) {
237    civil_from_days(at.0.div_euclid(DAY_MS))
238}
239
240/// A month's short English name, `Jan` for 1.
241#[must_use]
242pub fn month_name(month: u32) -> &'static str {
243    month.checked_sub(1).and_then(|at| MONTHS.get(usize::try_from(at).ok()?)).copied().unwrap_or("")
244}
245
246/// `count` ASCII digits from `from`, as a number; `None` where any is not a digit.
247fn digits(raw: &[u8], from: usize, count: usize) -> Option<i64> {
248    let field = raw.get(from..from.checked_add(count)?)?;
249    field.iter().try_fold(0i64, |sum, byte| byte.is_ascii_digit().then(|| sum * 10 + i64::from(byte - b'0')))
250}
251
252/// Minutes from 1970 to a minute of the calendar; `None` for one that does not exist.
253fn minutes_since_1970(year: i64, month: i64, day: i64, hour: i64, minute: i64) -> Option<i64> {
254    let month = u32::try_from(month).ok().filter(|month| (1..=12).contains(month))?;
255    let day = u32::try_from(day).ok().filter(|day| (1..=days_in(year, month)).contains(day))?;
256    if !(0..24).contains(&hour) || !(0..60).contains(&minute) {
257        return None;
258    }
259    Some((days_from_civil(year, month, day) * 24 + hour) * 60 + minute)
260}
261
262/// The day of the calendar `days` after 1970-01-01 (Howard Hinnant's `civil_from_days`).
263fn civil_from_days(days: i64) -> (i64, u32, u32) {
264    let shifted = days + 719_468;
265    let era = shifted.div_euclid(146_097);
266    let day_of_era = shifted.rem_euclid(146_097);
267    let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
268    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
269    let month_from_march = (5 * day_of_year + 2) / 153;
270    let day = day_of_year - (153 * month_from_march + 2) / 5 + 1;
271    let month = if month_from_march < 10 { month_from_march + 3 } else { month_from_march - 9 };
272    let year = year_of_era + era * 400;
273    let year = if month <= 2 { year + 1 } else { year };
274    (year, u32::try_from(month).unwrap_or(1), u32::try_from(day).unwrap_or(1))
275}
276
277/// Days from 1970-01-01 to a day of the calendar (Howard Hinnant's `days_from_civil`).
278fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
279    let (month, day) = (i64::from(month), i64::from(day));
280    let year = if month <= 2 { year - 1 } else { year };
281    let era = year.div_euclid(400);
282    let year_of_era = year - era * 400;
283    let month_from_march = if month > 2 { month - 3 } else { month + 9 };
284    let day_of_year = (153 * month_from_march + 2) / 5 + day - 1;
285    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
286    era * 146_097 + day_of_era - 719_468
287}
288
289const fn days_in(year: i64, month: u32) -> u32 {
290    match month {
291        2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
292        2 => 28,
293        4 | 6 | 9 | 11 => 30,
294        _ => 31,
295    }
296}
297
298#[cfg(test)]
299#[path = "tests/time.rs"]
300mod tests;