Skip to main content

oanda_client/
timestamp.rs

1//! Exact OANDA timestamp boundary.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::{fmt, str::FromStr};
6
7/// Why a timestamp cannot be represented at OANDA's nanosecond precision.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
9pub enum InvalidTimestamp {
10    /// The input is neither RFC3339 nor fractional Unix seconds.
11    #[error("invalid OANDA timestamp")]
12    Malformed,
13    /// More than nine fractional digits would require rounding.
14    #[error("OANDA timestamp has more than nine fractional digits")]
15    TooManyFractionalDigits,
16    /// The instant falls outside the representable UTC range.
17    #[error("OANDA timestamp out of range")]
18    OutOfRange,
19}
20
21/// UTC instant accepted from RFC3339 or OANDA's fractional Unix-seconds form.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub struct Timestamp(DateTime<Utc>);
24
25impl Timestamp {
26    /// Wrap an already represented UTC instant without formatting and reparsing.
27    #[must_use]
28    pub const fn new(value: DateTime<Utc>) -> Self {
29        Self(value)
30    }
31
32    /// Return the parsed UTC instant.
33    #[must_use]
34    pub fn into_inner(self) -> DateTime<Utc> {
35        self.0
36    }
37}
38
39impl From<DateTime<Utc>> for Timestamp {
40    fn from(value: DateTime<Utc>) -> Self {
41        Self::new(value)
42    }
43}
44
45impl fmt::Display for Timestamp {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        write!(f, "{}", self.0.format("%Y-%m-%dT%H:%M:%S%.9fZ"))
48    }
49}
50
51impl FromStr for Timestamp {
52    type Err = InvalidTimestamp;
53
54    fn from_str(value: &str) -> Result<Self, Self::Err> {
55        if let Some((_, fraction)) = value.split_once('.')
56            && fraction.bytes().take_while(u8::is_ascii_digit).count() > 9
57        {
58            return Err(InvalidTimestamp::TooManyFractionalDigits);
59        }
60        if let Ok(parsed) = DateTime::parse_from_rfc3339(value) {
61            return Ok(Self(parsed.with_timezone(&Utc)));
62        }
63        let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
64        let seconds = whole.parse::<i64>().map_err(|error| match error.kind() {
65            std::num::IntErrorKind::PosOverflow | std::num::IntErrorKind::NegOverflow => {
66                InvalidTimestamp::OutOfRange
67            }
68            _ => InvalidTimestamp::Malformed,
69        })?;
70        if fraction.len() > 9 {
71            return Err(InvalidTimestamp::TooManyFractionalDigits);
72        }
73        if !fraction.bytes().all(|byte| byte.is_ascii_digit())
74            || (value.contains('.') && fraction.is_empty())
75        {
76            return Err(InvalidTimestamp::Malformed);
77        }
78        let digits = fraction
79            .bytes()
80            .fold(0_u32, |nanos, byte| nanos * 10 + u32::from(byte - b'0'));
81        let exponent =
82            u32::try_from(9 - fraction.len()).map_err(|_| InvalidTimestamp::Malformed)?;
83        let nanos = digits * 10_u32.pow(exponent);
84        // Unix decimals are signed quantities, while chrono's fractional part is positive.
85        let (seconds, nanos) = if value.starts_with('-') && nanos != 0 {
86            (
87                seconds.checked_sub(1).ok_or(InvalidTimestamp::OutOfRange)?,
88                1_000_000_000 - nanos,
89            )
90        } else {
91            (seconds, nanos)
92        };
93        DateTime::from_timestamp(seconds, nanos)
94            .map(Self)
95            .ok_or(InvalidTimestamp::OutOfRange)
96    }
97}
98
99impl Serialize for Timestamp {
100    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
101        serializer.collect_str(self)
102    }
103}
104
105impl<'de> Deserialize<'de> for Timestamp {
106    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
107        let value = String::deserialize(deserializer)?;
108        value.parse().map_err(serde::de::Error::custom)
109    }
110}
111
112#[cfg(test)]
113// Test setup fails fast; production timestamp parsing returns typed errors.
114#[allow(clippy::expect_used)]
115mod tests {
116    use super::{InvalidTimestamp, Timestamp};
117
118    #[test]
119    fn fractions_are_exact_and_signed_before_the_epoch() {
120        for (unix, rfc) in [
121            ("0.000000001", "1970-01-01T00:00:00.000000001Z"),
122            ("-0.000000001", "1969-12-31T23:59:59.999999999Z"),
123            ("-1.25", "1969-12-31T23:59:58.750000000Z"),
124        ] {
125            let parsed: Timestamp = unix.parse().expect("valid timestamp");
126            assert_eq!(parsed.to_string(), rfc);
127            assert_eq!(Timestamp::from(parsed.into_inner()), parsed);
128            assert_eq!(
129                serde_json::to_string(&parsed).expect("serialize"),
130                format!("\"{rfc}\"")
131            );
132        }
133    }
134
135    #[test]
136    fn unrepresentable_precision_is_an_error_in_both_formats() {
137        for value in ["1.1234567890", "2024-01-02T03:04:05.1234567890Z"] {
138            assert_eq!(
139                value.parse::<Timestamp>(),
140                Err(InvalidTimestamp::TooManyFractionalDigits)
141            );
142        }
143        assert_eq!(
144            "garbage".parse::<Timestamp>(),
145            Err(InvalidTimestamp::Malformed)
146        );
147        assert_eq!(
148            "9223372036854775808".parse::<Timestamp>(),
149            Err(InvalidTimestamp::OutOfRange)
150        );
151    }
152}