nautilus_coinbase/common/
parse.rs1use std::str::FromStr;
19
20use nautilus_core::UnixNanos;
21pub use nautilus_core::serialization::{
22 deserialize_decimal_from_str, deserialize_decimal_or_zero,
23 deserialize_optional_decimal_from_str, deserialize_string_to_u64, serialize_decimal_as_str,
24 serialize_optional_decimal_as_str,
25};
26use nautilus_model::{
27 data::BarType,
28 enums::{AggregationSource, BarAggregation},
29};
30use serde::{
31 Deserialize,
32 de::{self, Unexpected},
33};
34
35use crate::common::enums::{
36 CoinbaseGranularity, CoinbaseMarginType, CoinbaseProductStatus, CoinbaseProductType,
37};
38
39pub fn deserialize_empty_string_to_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
41where
42 D: serde::Deserializer<'de>,
43 T: Deserialize<'de>,
44{
45 #[derive(Deserialize)]
46 #[serde(untagged)]
47 enum EmptyOrValue<T> {
48 Value(T),
49 Empty(String),
50 }
51
52 match Option::<EmptyOrValue<T>>::deserialize(deserializer)? {
53 None => Ok(None),
54 Some(EmptyOrValue::Value(value)) => Ok(Some(value)),
55 Some(EmptyOrValue::Empty(value)) if value.is_empty() => Ok(None),
56 Some(EmptyOrValue::Empty(value)) => Err(de::Error::invalid_value(
57 Unexpected::Str(&value),
58 &"an empty string or a valid value",
59 )),
60 }
61}
62
63pub fn deserialize_product_type_or_unknown<'de, D>(
65 deserializer: D,
66) -> Result<CoinbaseProductType, D::Error>
67where
68 D: serde::Deserializer<'de>,
69{
70 let value = String::deserialize(deserializer)?;
71 Ok(CoinbaseProductType::from_str(&value).unwrap_or(CoinbaseProductType::Unknown))
72}
73
74pub fn deserialize_product_status_or_unknown<'de, D>(
76 deserializer: D,
77) -> Result<CoinbaseProductStatus, D::Error>
78where
79 D: serde::Deserializer<'de>,
80{
81 let value = String::deserialize(deserializer)?;
82 Ok(CoinbaseProductStatus::from_str(&value).unwrap_or(CoinbaseProductStatus::Unknown))
83}
84
85pub fn deserialize_margin_type_or_none<'de, D>(
93 deserializer: D,
94) -> Result<Option<CoinbaseMarginType>, D::Error>
95where
96 D: serde::Deserializer<'de>,
97{
98 let value = Option::<String>::deserialize(deserializer)?;
99 Ok(value
100 .filter(|s| !s.is_empty())
101 .and_then(|s| CoinbaseMarginType::from_str(&s).ok()))
102}
103
104pub fn format_rfc3339_from_nanos(ts: UnixNanos) -> anyhow::Result<String> {
111 let secs = (ts.as_u64() / 1_000_000_000) as i64;
112 let nanos = (ts.as_u64() % 1_000_000_000) as u32;
113 chrono::DateTime::<chrono::Utc>::from_timestamp(secs, nanos)
114 .map(|dt| dt.to_rfc3339())
115 .ok_or_else(|| anyhow::anyhow!("UnixNanos {ts} is out of range for chrono::DateTime"))
116}
117
118pub fn bar_type_to_granularity(bar_type: &BarType) -> anyhow::Result<CoinbaseGranularity> {
124 let spec = bar_type.spec();
125
126 anyhow::ensure!(
127 bar_type.aggregation_source() == AggregationSource::External,
128 "Only EXTERNAL aggregation is supported"
129 );
130
131 let step = spec.step.get();
132
133 match spec.aggregation {
134 BarAggregation::Minute => match step {
135 1 => Ok(CoinbaseGranularity::OneMinute),
136 5 => Ok(CoinbaseGranularity::FiveMinute),
137 15 => Ok(CoinbaseGranularity::FifteenMinute),
138 30 => Ok(CoinbaseGranularity::ThirtyMinute),
139 _ => anyhow::bail!("Unsupported minute step: {step}"),
140 },
141 BarAggregation::Hour => match step {
142 1 => Ok(CoinbaseGranularity::OneHour),
143 2 => Ok(CoinbaseGranularity::TwoHour),
144 6 => Ok(CoinbaseGranularity::SixHour),
145 _ => anyhow::bail!("Unsupported hour step: {step}"),
146 },
147 BarAggregation::Day => match step {
148 1 => Ok(CoinbaseGranularity::OneDay),
149 _ => anyhow::bail!("Unsupported day step: {step}"),
150 },
151 other => anyhow::bail!("Unsupported aggregation: {other}"),
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use rstest::rstest;
158
159 use super::*;
160
161 #[rstest]
162 #[case(
163 "BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL",
164 CoinbaseGranularity::OneMinute
165 )]
166 #[case(
167 "BTC-USD.COINBASE-5-MINUTE-LAST-EXTERNAL",
168 CoinbaseGranularity::FiveMinute
169 )]
170 #[case(
171 "BTC-USD.COINBASE-15-MINUTE-LAST-EXTERNAL",
172 CoinbaseGranularity::FifteenMinute
173 )]
174 #[case(
175 "BTC-USD.COINBASE-30-MINUTE-LAST-EXTERNAL",
176 CoinbaseGranularity::ThirtyMinute
177 )]
178 #[case("BTC-USD.COINBASE-1-HOUR-LAST-EXTERNAL", CoinbaseGranularity::OneHour)]
179 #[case("BTC-USD.COINBASE-2-HOUR-LAST-EXTERNAL", CoinbaseGranularity::TwoHour)]
180 #[case("BTC-USD.COINBASE-6-HOUR-LAST-EXTERNAL", CoinbaseGranularity::SixHour)]
181 #[case("BTC-USD.COINBASE-1-DAY-LAST-EXTERNAL", CoinbaseGranularity::OneDay)]
182 fn test_bar_type_to_granularity(
183 #[case] bar_type_str: &str,
184 #[case] expected: CoinbaseGranularity,
185 ) {
186 let bar_type = BarType::from(bar_type_str);
187 let result = bar_type_to_granularity(&bar_type).unwrap();
188 assert_eq!(result, expected);
189 }
190
191 #[rstest]
192 #[case("BTC-USD.COINBASE-3-MINUTE-LAST-EXTERNAL")]
193 #[case("BTC-USD.COINBASE-4-HOUR-LAST-EXTERNAL")]
194 #[case("BTC-USD.COINBASE-2-DAY-LAST-EXTERNAL")]
195 fn test_bar_type_to_granularity_unsupported(#[case] bar_type_str: &str) {
196 let bar_type = BarType::from(bar_type_str);
197 assert!(bar_type_to_granularity(&bar_type).is_err());
198 }
199
200 #[rstest]
201 fn test_format_rfc3339_from_nanos_round_trip() {
202 let ts = UnixNanos::from(1_705_314_600_000_000_000u64);
204 let s = format_rfc3339_from_nanos(ts).unwrap();
205 assert_eq!(s, "2024-01-15T10:30:00+00:00");
206 }
207
208 #[rstest]
209 fn test_format_rfc3339_from_nanos_preserves_subsecond_precision() {
210 let ts = UnixNanos::from(1_705_314_600_123_456_789u64);
212 let s = format_rfc3339_from_nanos(ts).unwrap();
213 assert_eq!(s, "2024-01-15T10:30:00.123456789+00:00");
214 }
215}