Skip to main content

oanda_rs/models/
primitives.rs

1//! Shared primitive types: IDs, decimal values, and datetimes.
2//!
3//! OANDA encodes almost everything as JSON strings on the wire. This module
4//! provides thin newtypes so that values keep their meaning in Rust code
5//! while (de)serializing exactly as the API expects.
6
7use std::fmt;
8use std::str::FromStr;
9
10use rust_decimal::Decimal;
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13use super::serde_util::decimal_from_str_or_number;
14
15/// Declares a transparent string newtype with the usual conversions.
16macro_rules! string_newtype {
17    ($(#[$meta:meta])* $name:ident) => {
18        $(#[$meta])*
19        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
20        #[serde(transparent)]
21        pub struct $name(pub String);
22
23        impl $name {
24            /// Returns the underlying string slice.
25            pub fn as_str(&self) -> &str {
26                &self.0
27            }
28        }
29
30        impl fmt::Display for $name {
31            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32                f.write_str(&self.0)
33            }
34        }
35
36        impl From<String> for $name {
37            fn from(value: String) -> Self {
38                $name(value)
39            }
40        }
41
42        impl From<&str> for $name {
43            fn from(value: &str) -> Self {
44                $name(value.to_owned())
45            }
46        }
47
48        impl FromStr for $name {
49            type Err = std::convert::Infallible;
50
51            fn from_str(s: &str) -> Result<Self, Self::Err> {
52                Ok($name(s.to_owned()))
53            }
54        }
55    };
56}
57
58string_newtype! {
59    /// The string representation of an account identifier
60    /// (`{siteID}-{divisionID}-{userID}-{accountNumber}`, e.g. `101-004-1234567-001`).
61    AccountId
62}
63
64string_newtype! {
65    /// The unique identifier of a transaction within an account.
66    TransactionId
67}
68
69string_newtype! {
70    /// The unique identifier of an order within an account.
71    OrderId
72}
73
74string_newtype! {
75    /// The unique identifier of a trade within an account.
76    TradeId
77}
78
79string_newtype! {
80    /// A client-provided identifier, used to refer to orders and trades by
81    /// the id set through
82    /// [`ClientExtensions`](crate::models::ClientExtensions).
83    ClientId
84}
85
86string_newtype! {
87    /// A client-provided tag attached to orders and trades.
88    ClientTag
89}
90
91string_newtype! {
92    /// A client-provided comment attached to orders and trades.
93    ClientComment
94}
95
96string_newtype! {
97    /// The identifier OANDA assigned to an API request (the `RequestID`
98    /// response header).
99    RequestId
100}
101
102string_newtype! {
103    /// An ISO 4217 currency code (e.g. `EUR`, `USD`).
104    Currency
105}
106
107string_newtype! {
108    /// Identifies an order for lookup/cancel/replace operations: either the
109    /// order's id (e.g. `6789`) or `@` followed by its client id
110    /// (e.g. `@my-order`).
111    OrderSpecifier
112}
113
114string_newtype! {
115    /// Identifies a trade for lookup/close operations: either the trade's id
116    /// (e.g. `6789`) or `@` followed by its client id.
117    TradeSpecifier
118}
119
120impl OrderSpecifier {
121    /// A specifier addressing an order by the client id assigned through
122    /// [`ClientExtensions`](crate::models::ClientExtensions).
123    pub fn from_client_id(id: impl AsRef<str>) -> Self {
124        OrderSpecifier(format!("@{}", id.as_ref()))
125    }
126}
127
128impl From<&OrderId> for OrderSpecifier {
129    fn from(id: &OrderId) -> Self {
130        OrderSpecifier(id.0.clone())
131    }
132}
133
134impl From<OrderId> for OrderSpecifier {
135    fn from(id: OrderId) -> Self {
136        OrderSpecifier(id.0)
137    }
138}
139
140impl TradeSpecifier {
141    /// A specifier addressing a trade by the client id assigned through
142    /// [`ClientExtensions`](crate::models::ClientExtensions).
143    pub fn from_client_id(id: impl AsRef<str>) -> Self {
144        TradeSpecifier(format!("@{}", id.as_ref()))
145    }
146}
147
148impl From<&TradeId> for TradeSpecifier {
149    fn from(id: &TradeId) -> Self {
150        TradeSpecifier(id.0.clone())
151    }
152}
153
154impl From<TradeId> for TradeSpecifier {
155    fn from(id: TradeId) -> Self {
156        TradeSpecifier(id.0)
157    }
158}
159
160/// Declares a decimal newtype that serializes as a JSON string (OANDA's wire
161/// format) and tolerantly deserializes from either a string or a number.
162macro_rules! decimal_newtype {
163    ($(#[$meta:meta])* $name:ident) => {
164        $(#[$meta])*
165        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
166        pub struct $name(pub Decimal);
167
168        impl $name {
169            /// Returns the underlying [`Decimal`] value.
170            pub fn value(&self) -> Decimal {
171                self.0
172            }
173        }
174
175        impl Serialize for $name {
176            fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
177                serializer.collect_str(&self.0)
178            }
179        }
180
181        impl<'de> Deserialize<'de> for $name {
182            fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
183                decimal_from_str_or_number(deserializer).map($name)
184            }
185        }
186
187        impl fmt::Display for $name {
188            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189                fmt::Display::fmt(&self.0, f)
190            }
191        }
192
193        impl From<Decimal> for $name {
194            fn from(value: Decimal) -> Self {
195                $name(value)
196            }
197        }
198
199        impl From<$name> for Decimal {
200            fn from(value: $name) -> Self {
201                value.0
202            }
203        }
204
205        impl From<i64> for $name {
206            fn from(value: i64) -> Self {
207                $name(Decimal::from(value))
208            }
209        }
210
211        impl From<i32> for $name {
212            fn from(value: i32) -> Self {
213                $name(Decimal::from(value))
214            }
215        }
216
217        impl From<u32> for $name {
218            fn from(value: u32) -> Self {
219                $name(Decimal::from(value))
220            }
221        }
222
223        impl FromStr for $name {
224            type Err = rust_decimal::Error;
225
226            fn from_str(s: &str) -> Result<Self, Self::Err> {
227                s.parse::<Decimal>()
228                    .or_else(|_| Decimal::from_scientific(s))
229                    .map($name)
230            }
231        }
232
233        impl TryFrom<f64> for $name {
234            type Error = rust_decimal::Error;
235
236            fn try_from(value: f64) -> Result<Self, Self::Error> {
237                Decimal::try_from(value).map($name)
238            }
239        }
240    };
241}
242
243decimal_newtype! {
244    /// A decimal number encoded as a string on the wire (variable precision).
245    DecimalNumber
246}
247
248decimal_newtype! {
249    /// An amount in an account's home currency, encoded as a string on the
250    /// wire.
251    AccountUnits
252}
253
254decimal_newtype! {
255    /// An instrument price, encoded as a string on the wire.
256    PriceValue
257}
258
259/// A point in time as delivered by (and sent to) the OANDA API.
260///
261/// The wire representation depends on the `Accept-Datetime-Format` request
262/// header ([`AcceptDatetimeFormat`]): RFC 3339 (`2024-06-14T12:01:32.000000Z`)
263/// or a UNIX epoch value with fractional seconds (`1718366492.000000`).
264/// The value is kept verbatim as a string; use [`DateTime::to_utc`] to parse
265/// either representation into a [`chrono::DateTime`].
266#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
267#[serde(transparent)]
268pub struct DateTime(pub String);
269
270impl DateTime {
271    /// Returns the underlying string slice.
272    pub fn as_str(&self) -> &str {
273        &self.0
274    }
275
276    /// Parses the timestamp, accepting both the RFC 3339 and the UNIX
277    /// (`seconds[.fraction]`) representations. Returns `None` when the value
278    /// matches neither format.
279    pub fn to_utc(&self) -> Option<chrono::DateTime<chrono::Utc>> {
280        let s = self.0.as_str();
281        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
282            return Some(dt.with_timezone(&chrono::Utc));
283        }
284        // UNIX format: "1718366492" or "1718366492.000000000".
285        let (secs, frac) = match s.split_once('.') {
286            Some((secs, frac)) => (secs, frac),
287            None => (s, ""),
288        };
289        let secs: i64 = secs.parse().ok()?;
290        let nanos: u32 = if frac.is_empty() {
291            0
292        } else if frac.len() <= 9 && frac.chars().all(|c| c.is_ascii_digit()) {
293            frac.parse::<u32>().ok()? * 10u32.pow(9 - frac.len() as u32)
294        } else {
295            return None;
296        };
297        chrono::DateTime::from_timestamp(secs, nanos)
298    }
299}
300
301impl fmt::Display for DateTime {
302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303        f.write_str(&self.0)
304    }
305}
306
307impl From<String> for DateTime {
308    fn from(value: String) -> Self {
309        DateTime(value)
310    }
311}
312
313impl From<&str> for DateTime {
314    fn from(value: &str) -> Self {
315        DateTime(value.to_owned())
316    }
317}
318
319impl From<chrono::DateTime<chrono::Utc>> for DateTime {
320    fn from(value: chrono::DateTime<chrono::Utc>) -> Self {
321        DateTime(value.to_rfc3339_opts(chrono::SecondsFormat::Micros, true))
322    }
323}
324
325/// Wire format for datetime fields, selected via the `Accept-Datetime-Format`
326/// request header.
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
328pub enum AcceptDatetimeFormat {
329    /// UNIX epoch seconds with fractional part, e.g. `1718366492.000000`.
330    #[serde(rename = "UNIX")]
331    Unix,
332    /// RFC 3339 / ISO 8601, e.g. `2024-06-14T12:01:32.000000Z`. The default.
333    #[default]
334    #[serde(rename = "RFC3339")]
335    Rfc3339,
336}
337
338impl AcceptDatetimeFormat {
339    /// The value sent in the `Accept-Datetime-Format` header.
340    pub fn as_header_value(self) -> &'static str {
341        match self {
342            AcceptDatetimeFormat::Unix => "UNIX",
343            AcceptDatetimeFormat::Rfc3339 => "RFC3339",
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn decimal_newtype_serializes_as_string() {
354        let v = DecimalNumber::from_str("1.2345").unwrap();
355        assert_eq!(serde_json::to_string(&v).unwrap(), r#""1.2345""#);
356    }
357
358    #[test]
359    fn decimal_newtype_deserializes_from_number() {
360        let v: PriceValue = serde_json::from_str("1.1050").unwrap();
361        assert_eq!(v.to_string(), "1.105");
362    }
363
364    #[test]
365    fn decimal_newtype_roundtrips_string() {
366        let v: AccountUnits = serde_json::from_str(r#""-43.2100""#).unwrap();
367        assert_eq!(serde_json::to_string(&v).unwrap(), r#""-43.2100""#);
368    }
369
370    #[test]
371    fn datetime_parses_rfc3339() {
372        let dt = DateTime::from("2024-06-14T12:01:32.123456Z");
373        let parsed = dt.to_utc().unwrap();
374        assert_eq!(parsed.timestamp(), 1718366492);
375        assert_eq!(parsed.timestamp_subsec_micros(), 123456);
376    }
377
378    #[test]
379    fn datetime_parses_unix_with_fraction() {
380        let dt = DateTime::from("1718366492.123456000");
381        let parsed = dt.to_utc().unwrap();
382        assert_eq!(parsed.timestamp(), 1718366492);
383        assert_eq!(parsed.timestamp_subsec_micros(), 123456);
384    }
385
386    #[test]
387    fn datetime_parses_unix_without_fraction() {
388        let dt = DateTime::from("1718366492");
389        assert_eq!(dt.to_utc().unwrap().timestamp(), 1718366492);
390    }
391
392    #[test]
393    fn datetime_rejects_garbage() {
394        assert!(DateTime::from("not a date").to_utc().is_none());
395    }
396
397    #[test]
398    fn order_specifier_from_client_id() {
399        assert_eq!(OrderSpecifier::from_client_id("my-id").as_str(), "@my-id");
400        assert_eq!(OrderSpecifier::from(OrderId::from("42")).as_str(), "42");
401    }
402}