nautilus_core/
datetime.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Posei Systems Pty Ltd. All rights reserved.
3//  https://poseitrader.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Common data and time functions.
17use std::convert::TryFrom;
18
19use chrono::{DateTime, Datelike, NaiveDate, SecondsFormat, TimeDelta, Utc, Weekday};
20
21use crate::UnixNanos;
22
23/// Number of milliseconds in one second.
24pub const MILLISECONDS_IN_SECOND: u64 = 1_000;
25
26/// Number of nanoseconds in one second.
27pub const NANOSECONDS_IN_SECOND: u64 = 1_000_000_000;
28
29/// Number of nanoseconds in one millisecond.
30pub const NANOSECONDS_IN_MILLISECOND: u64 = 1_000_000;
31
32/// Number of nanoseconds in one microsecond.
33pub const NANOSECONDS_IN_MICROSECOND: u64 = 1_000;
34
35/// List of weekdays (Monday to Friday).
36pub const WEEKDAYS: [Weekday; 5] = [
37    Weekday::Mon,
38    Weekday::Tue,
39    Weekday::Wed,
40    Weekday::Thu,
41    Weekday::Fri,
42];
43
44/// Converts seconds to nanoseconds (ns).
45///
46/// Casting f64 to u64 by truncating the fractional part is intentional for unit conversion,
47/// which may lose precision and drop negative values after clamping.
48#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
49#[must_use]
50pub fn secs_to_nanos(secs: f64) -> u64 {
51    let nanos = secs * NANOSECONDS_IN_SECOND as f64;
52    nanos.max(0.0).trunc() as u64
53}
54
55/// Converts seconds to milliseconds (ms).
56///
57/// Casting f64 to u64 by truncating the fractional part is intentional for unit conversion,
58/// which may lose precision and drop negative values after clamping.
59#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
60#[must_use]
61pub fn secs_to_millis(secs: f64) -> u64 {
62    let millis = secs * MILLISECONDS_IN_SECOND as f64;
63    millis.max(0.0).trunc() as u64
64}
65
66/// Converts milliseconds (ms) to nanoseconds (ns).
67///
68/// Casting f64 to u64 by truncating the fractional part is intentional for unit conversion,
69/// which may lose precision and drop negative values after clamping.
70#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
71#[must_use]
72pub fn millis_to_nanos(millis: f64) -> u64 {
73    let nanos = millis * NANOSECONDS_IN_MILLISECOND as f64;
74    nanos.max(0.0).trunc() as u64
75}
76
77/// Converts microseconds (μs) to nanoseconds (ns).
78///
79/// Casting f64 to u64 by truncating the fractional part is intentional for unit conversion,
80/// which may lose precision and drop negative values after clamping.
81#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
82#[must_use]
83pub fn micros_to_nanos(micros: f64) -> u64 {
84    let nanos = micros * NANOSECONDS_IN_MICROSECOND as f64;
85    nanos.max(0.0).trunc() as u64
86}
87
88/// Converts nanoseconds (ns) to seconds.
89///
90/// Casting u64 to f64 may lose precision for large values,
91/// but is acceptable when computing fractional seconds.
92#[allow(clippy::cast_precision_loss)]
93#[must_use]
94pub fn nanos_to_secs(nanos: u64) -> f64 {
95    let seconds = nanos / NANOSECONDS_IN_SECOND;
96    let rem_nanos = nanos % NANOSECONDS_IN_SECOND;
97    (seconds as f64) + (rem_nanos as f64) / (NANOSECONDS_IN_SECOND as f64)
98}
99
100/// Converts nanoseconds (ns) to milliseconds (ms).
101#[must_use]
102pub const fn nanos_to_millis(nanos: u64) -> u64 {
103    nanos / NANOSECONDS_IN_MILLISECOND
104}
105
106/// Converts nanoseconds (ns) to microseconds (μs).
107#[must_use]
108pub const fn nanos_to_micros(nanos: u64) -> u64 {
109    nanos / NANOSECONDS_IN_MICROSECOND
110}
111
112/// Converts a UNIX nanoseconds timestamp to an ISO 8601 (RFC 3339) format string.
113#[inline]
114#[must_use]
115pub fn unix_nanos_to_iso8601(unix_nanos: UnixNanos) -> String {
116    let datetime = unix_nanos.to_datetime_utc();
117    datetime.to_rfc3339_opts(SecondsFormat::Nanos, true)
118}
119
120/// Converts a UNIX nanoseconds timestamp to an ISO 8601 (RFC 3339) format string
121/// with millisecond precision.
122#[inline]
123#[must_use]
124pub fn unix_nanos_to_iso8601_millis(unix_nanos: UnixNanos) -> String {
125    let datetime = unix_nanos.to_datetime_utc();
126    datetime.to_rfc3339_opts(SecondsFormat::Millis, true)
127}
128
129/// Floor the given UNIX nanoseconds to the nearest microsecond.
130#[must_use]
131pub const fn floor_to_nearest_microsecond(unix_nanos: u64) -> u64 {
132    (unix_nanos / NANOSECONDS_IN_MICROSECOND) * NANOSECONDS_IN_MICROSECOND
133}
134
135/// Calculates the last weekday (Mon-Fri) from the given `year`, `month` and `day`.
136///
137/// # Errors
138///
139/// Returns an error if the date is invalid.
140pub fn last_weekday_nanos(year: i32, month: u32, day: u32) -> anyhow::Result<UnixNanos> {
141    let date =
142        NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| anyhow::anyhow!("Invalid date"))?;
143    let current_weekday = date.weekday().number_from_monday();
144
145    // Calculate the offset in days for closest weekday (Mon-Fri)
146    let offset = i64::from(match current_weekday {
147        1..=5 => 0, // Monday to Friday, no adjustment needed
148        6 => 1,     // Saturday, adjust to previous Friday
149        _ => 2,     // Sunday, adjust to previous Friday
150    });
151    // Calculate last closest weekday
152    let last_closest = date - TimeDelta::days(offset);
153
154    // Convert to UNIX nanoseconds
155    let unix_timestamp_ns = last_closest
156        .and_hms_nano_opt(0, 0, 0, 0)
157        .ok_or_else(|| anyhow::anyhow!("Failed `and_hms_nano_opt`"))?;
158
159    // Convert timestamp nanos safely from i64 to u64
160    let raw_ns = unix_timestamp_ns
161        .and_utc()
162        .timestamp_nanos_opt()
163        .ok_or_else(|| anyhow::anyhow!("Failed `timestamp_nanos_opt`"))?;
164    let ns_u64 =
165        u64::try_from(raw_ns).map_err(|_| anyhow::anyhow!("Negative timestamp: {raw_ns}"))?;
166    Ok(UnixNanos::from(ns_u64))
167}
168
169/// Check whether the given UNIX nanoseconds timestamp is within the last 24 hours.
170///
171/// # Errors
172///
173/// Returns an error if the timestamp is invalid.
174pub fn is_within_last_24_hours(timestamp_ns: UnixNanos) -> anyhow::Result<bool> {
175    let timestamp_ns = timestamp_ns.as_u64();
176    let seconds = timestamp_ns / NANOSECONDS_IN_SECOND;
177    let nanoseconds = (timestamp_ns % NANOSECONDS_IN_SECOND) as u32;
178    // Convert seconds to i64 safely
179    let secs_i64 = i64::try_from(seconds)
180        .map_err(|_| anyhow::anyhow!("Timestamp seconds overflow: {seconds}"))?;
181    let timestamp = DateTime::from_timestamp(secs_i64, nanoseconds)
182        .ok_or_else(|| anyhow::anyhow!("Invalid timestamp {timestamp_ns}"))?;
183    let now = Utc::now();
184
185    Ok(now.signed_duration_since(timestamp) <= TimeDelta::days(1))
186}
187
188/// Subtract `n` months from a chrono `DateTime<Utc>`.
189///
190/// # Errors
191///
192/// Returns an error if the resulting date would be invalid or out of range.
193pub fn subtract_n_months(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
194    match datetime.checked_sub_months(chrono::Months::new(n)) {
195        Some(result) => Ok(result),
196        None => anyhow::bail!("Failed to subtract {n} months from {datetime}"),
197    }
198}
199
200/// Add `n` months to a chrono `DateTime<Utc>`.
201///
202/// # Errors
203///
204/// Returns an error if the resulting date would be invalid or out of range.
205pub fn add_n_months(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
206    match datetime.checked_add_months(chrono::Months::new(n)) {
207        Some(result) => Ok(result),
208        None => anyhow::bail!("Failed to add {n} months to {datetime}"),
209    }
210}
211
212/// Subtract `n` months from a given UNIX nanoseconds timestamp.
213///
214/// # Errors
215///
216/// Returns an error if the resulting timestamp is out of range or invalid.
217pub fn subtract_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
218    let datetime = unix_nanos.to_datetime_utc();
219    let result = subtract_n_months(datetime, n)?;
220    let timestamp = match result.timestamp_nanos_opt() {
221        Some(ts) => ts,
222        None => anyhow::bail!("Timestamp out of range after subtracting {n} months"),
223    };
224
225    if timestamp < 0 {
226        anyhow::bail!("Negative timestamp not allowed");
227    }
228
229    Ok(UnixNanos::from(timestamp as u64))
230}
231
232/// Add `n` months to a given UNIX nanoseconds timestamp.
233///
234/// # Errors
235///
236/// Returns an error if the resulting timestamp is out of range or invalid.
237pub fn add_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
238    let datetime = unix_nanos.to_datetime_utc();
239    let result = add_n_months(datetime, n)?;
240    let timestamp = match result.timestamp_nanos_opt() {
241        Some(ts) => ts,
242        None => anyhow::bail!("Timestamp out of range after adding {n} months"),
243    };
244
245    if timestamp < 0 {
246        anyhow::bail!("Negative timestamp not allowed");
247    }
248
249    Ok(UnixNanos::from(timestamp as u64))
250}
251
252/// Returns the last valid day of `(year, month)`.
253#[must_use]
254pub const fn last_day_of_month(year: i32, month: u32) -> u32 {
255    // Validate month range 1-12
256    assert!(month >= 1 && month <= 12, "`month` must be in 1..=12");
257
258    // February leap-year logic
259    match month {
260        2 => {
261            if is_leap_year(year) {
262                29
263            } else {
264                28
265            }
266        }
267        4 | 6 | 9 | 11 => 30,
268        _ => 31, // January, March, May, July, August, October, December
269    }
270}
271
272/// Basic leap-year check
273#[must_use]
274pub const fn is_leap_year(year: i32) -> bool {
275    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
276}
277
278////////////////////////////////////////////////////////////////////////////////
279// Tests
280////////////////////////////////////////////////////////////////////////////////
281#[cfg(test)]
282#[allow(clippy::float_cmp)]
283mod tests {
284    use chrono::{DateTime, TimeDelta, TimeZone, Utc};
285    use rstest::rstest;
286
287    use super::*;
288
289    #[rstest]
290    #[case(0.0, 0)]
291    #[case(1.0, 1_000_000_000)]
292    #[case(1.1, 1_100_000_000)]
293    #[case(42.0, 42_000_000_000)]
294    #[case(0.000_123_5, 123_500)]
295    #[case(0.000_000_01, 10)]
296    #[case(0.000_000_001, 1)]
297    #[case(9.999_999_999, 9_999_999_999)]
298    fn test_secs_to_nanos(#[case] value: f64, #[case] expected: u64) {
299        let result = secs_to_nanos(value);
300        assert_eq!(result, expected);
301    }
302
303    #[rstest]
304    #[case(0.0, 0)]
305    #[case(1.0, 1_000)]
306    #[case(1.1, 1_100)]
307    #[case(42.0, 42_000)]
308    #[case(0.012_34, 12)]
309    #[case(0.001, 1)]
310    fn test_secs_to_millis(#[case] value: f64, #[case] expected: u64) {
311        let result = secs_to_millis(value);
312        assert_eq!(result, expected);
313    }
314
315    #[rstest]
316    #[should_panic(expected = "`month` must be in 1..=12")]
317    fn test_last_day_of_month_invalid_month() {
318        let _ = last_day_of_month(2024, 0);
319    }
320
321    #[rstest]
322    #[case(0.0, 0)]
323    #[case(1.0, 1_000_000)]
324    #[case(1.1, 1_100_000)]
325    #[case(42.0, 42_000_000)]
326    #[case(0.000_123_4, 123)]
327    #[case(0.000_01, 10)]
328    #[case(0.000_001, 1)]
329    #[case(9.999_999, 9_999_999)]
330    fn test_millis_to_nanos(#[case] value: f64, #[case] expected: u64) {
331        let result = millis_to_nanos(value);
332        assert_eq!(result, expected);
333    }
334
335    #[rstest]
336    #[case(0.0, 0)]
337    #[case(1.0, 1_000)]
338    #[case(1.1, 1_100)]
339    #[case(42.0, 42_000)]
340    #[case(0.1234, 123)]
341    #[case(0.01, 10)]
342    #[case(0.001, 1)]
343    #[case(9.999, 9_999)]
344    fn test_micros_to_nanos(#[case] value: f64, #[case] expected: u64) {
345        let result = micros_to_nanos(value);
346        assert_eq!(result, expected);
347    }
348
349    #[rstest]
350    #[case(0, 0.0)]
351    #[case(1, 1e-09)]
352    #[case(1_000_000_000, 1.0)]
353    #[case(42_897_123_111, 42.897_123_111)]
354    fn test_nanos_to_secs(#[case] value: u64, #[case] expected: f64) {
355        let result = nanos_to_secs(value);
356        assert_eq!(result, expected);
357    }
358
359    #[rstest]
360    #[case(0, 0)]
361    #[case(1_000_000, 1)]
362    #[case(1_000_000_000, 1000)]
363    #[case(42_897_123_111, 42897)]
364    fn test_nanos_to_millis(#[case] value: u64, #[case] expected: u64) {
365        let result = nanos_to_millis(value);
366        assert_eq!(result, expected);
367    }
368
369    #[rstest]
370    #[case(0, 0)]
371    #[case(1_000, 1)]
372    #[case(1_000_000_000, 1_000_000)]
373    #[case(42_897_123, 42_897)]
374    fn test_nanos_to_micros(#[case] value: u64, #[case] expected: u64) {
375        let result = nanos_to_micros(value);
376        assert_eq!(result, expected);
377    }
378
379    #[rstest]
380    #[case(0, "1970-01-01T00:00:00.000000000Z")] // Unix epoch
381    #[case(1, "1970-01-01T00:00:00.000000001Z")] // 1 nanosecond
382    #[case(1_000, "1970-01-01T00:00:00.000001000Z")] // 1 microsecond
383    #[case(1_000_000, "1970-01-01T00:00:00.001000000Z")] // 1 millisecond
384    #[case(1_000_000_000, "1970-01-01T00:00:01.000000000Z")] // 1 second
385    #[case(1_702_857_600_000_000_000, "2023-12-18T00:00:00.000000000Z")] // Specific date
386    fn test_unix_nanos_to_iso8601(#[case] nanos: u64, #[case] expected: &str) {
387        let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
388        assert_eq!(result, expected);
389    }
390
391    #[rstest]
392    #[case(0, "1970-01-01T00:00:00.000Z")] // Unix epoch
393    #[case(1_000_000, "1970-01-01T00:00:00.001Z")] // 1 millisecond
394    #[case(1_000_000_000, "1970-01-01T00:00:01.000Z")] // 1 second
395    #[case(1_702_857_600_123_456_789, "2023-12-18T00:00:00.123Z")] // With millisecond precision
396    fn test_unix_nanos_to_iso8601_millis(#[case] nanos: u64, #[case] expected: &str) {
397        let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
398        assert_eq!(result, expected);
399    }
400
401    #[rstest]
402    #[case(2023, 12, 15, 1_702_598_400_000_000_000)] // Fri
403    #[case(2023, 12, 16, 1_702_598_400_000_000_000)] // Sat
404    #[case(2023, 12, 17, 1_702_598_400_000_000_000)] // Sun
405    #[case(2023, 12, 18, 1_702_857_600_000_000_000)] // Mon
406    fn test_last_closest_weekday_nanos_with_valid_date(
407        #[case] year: i32,
408        #[case] month: u32,
409        #[case] day: u32,
410        #[case] expected: u64,
411    ) {
412        let result = last_weekday_nanos(year, month, day).unwrap().as_u64();
413        assert_eq!(result, expected);
414    }
415
416    #[rstest]
417    fn test_last_closest_weekday_nanos_with_invalid_date() {
418        let result = last_weekday_nanos(2023, 4, 31);
419        assert!(result.is_err());
420    }
421
422    #[rstest]
423    fn test_last_closest_weekday_nanos_with_nonexistent_date() {
424        let result = last_weekday_nanos(2023, 2, 30);
425        assert!(result.is_err());
426    }
427
428    #[rstest]
429    fn test_last_closest_weekday_nanos_with_invalid_conversion() {
430        let result = last_weekday_nanos(9999, 12, 31);
431        assert!(result.is_err());
432    }
433
434    #[rstest]
435    fn test_is_within_last_24_hours_when_now() {
436        let now_ns = Utc::now().timestamp_nanos_opt().unwrap();
437        assert!(is_within_last_24_hours(UnixNanos::from(now_ns as u64)).unwrap());
438    }
439
440    #[rstest]
441    fn test_is_within_last_24_hours_when_two_days_ago() {
442        let past_ns = (Utc::now() - TimeDelta::try_days(2).unwrap())
443            .timestamp_nanos_opt()
444            .unwrap();
445        assert!(!is_within_last_24_hours(UnixNanos::from(past_ns as u64)).unwrap());
446    }
447
448    #[rstest]
449    #[case(Utc.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap())] // Leap year February
450    #[case(Utc.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap(), 12, Utc.with_ymd_and_hms(2023, 3, 31, 12, 0, 0).unwrap())] // One year earlier
451    #[case(Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2023, 12, 31, 12, 0, 0).unwrap())] // Wrapping to previous year
452    #[case(Utc.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap(), 2, Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap())] // Multiple months back
453    fn test_subtract_n_months(
454        #[case] input: DateTime<Utc>,
455        #[case] months: u32,
456        #[case] expected: DateTime<Utc>,
457    ) {
458        let result = subtract_n_months(input, months).unwrap();
459        assert_eq!(result, expected);
460    }
461
462    #[rstest]
463    #[case(Utc.with_ymd_and_hms(2023, 2, 28, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2023, 3, 28, 12, 0, 0).unwrap())] // Simple month addition
464    #[case(Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap())] // Leap year February
465    #[case(Utc.with_ymd_and_hms(2023, 12, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap())] // Wrapping to next year
466    #[case(Utc.with_ymd_and_hms(2023, 1, 31, 12, 0, 0).unwrap(), 13, Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap())] // Crossing year boundary with multiple months
467    fn test_add_n_months(
468        #[case] input: DateTime<Utc>,
469        #[case] months: u32,
470        #[case] expected: DateTime<Utc>,
471    ) {
472        let result = add_n_months(input, months).unwrap();
473        assert_eq!(result, expected);
474    }
475
476    #[rstest]
477    #[case(2024, 2, 29)] // Leap year February
478    #[case(2023, 2, 28)] // Non-leap year February
479    #[case(2024, 12, 31)] // December
480    #[case(2023, 11, 30)] // November
481    fn test_last_day_of_month(#[case] year: i32, #[case] month: u32, #[case] expected: u32) {
482        let result = last_day_of_month(year, month);
483        assert_eq!(result, expected);
484    }
485
486    #[rstest]
487    #[case(2024, true)] // Leap year divisible by 4
488    #[case(1900, false)] // Not leap year, divisible by 100 but not 400
489    #[case(2000, true)] // Leap year, divisible by 400
490    #[case(2023, false)] // Non-leap year
491    fn test_is_leap_year(#[case] year: i32, #[case] expected: bool) {
492        let result = is_leap_year(year);
493        assert_eq!(result, expected);
494    }
495}