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::{CoinbaseGranularity, CoinbaseMarginType, CoinbaseProductType};
36
37pub fn deserialize_empty_string_to_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
39where
40 D: serde::Deserializer<'de>,
41 T: Deserialize<'de>,
42{
43 #[derive(Deserialize)]
44 #[serde(untagged)]
45 enum EmptyOrValue<T> {
46 Value(T),
47 Empty(String),
48 }
49
50 match Option::<EmptyOrValue<T>>::deserialize(deserializer)? {
51 None => Ok(None),
52 Some(EmptyOrValue::Value(value)) => Ok(Some(value)),
53 Some(EmptyOrValue::Empty(value)) if value.is_empty() => Ok(None),
54 Some(EmptyOrValue::Empty(value)) => Err(de::Error::invalid_value(
55 Unexpected::Str(&value),
56 &"an empty string or a valid value",
57 )),
58 }
59}
60
61pub fn deserialize_product_type_or_unknown<'de, D>(
63 deserializer: D,
64) -> Result<CoinbaseProductType, D::Error>
65where
66 D: serde::Deserializer<'de>,
67{
68 let value = String::deserialize(deserializer)?;
69 Ok(CoinbaseProductType::from_str(&value).unwrap_or(CoinbaseProductType::Unknown))
70}
71
72pub fn deserialize_margin_type_or_none<'de, D>(
80 deserializer: D,
81) -> Result<Option<CoinbaseMarginType>, D::Error>
82where
83 D: serde::Deserializer<'de>,
84{
85 let value = Option::<String>::deserialize(deserializer)?;
86 Ok(value
87 .filter(|s| !s.is_empty())
88 .and_then(|s| CoinbaseMarginType::from_str(&s).ok()))
89}
90
91pub fn format_rfc3339_from_nanos(ts: UnixNanos) -> anyhow::Result<String> {
98 let secs = (ts.as_u64() / 1_000_000_000) as i64;
99 let nanos = (ts.as_u64() % 1_000_000_000) as u32;
100 chrono::DateTime::<chrono::Utc>::from_timestamp(secs, nanos)
101 .map(|dt| dt.to_rfc3339())
102 .ok_or_else(|| anyhow::anyhow!("UnixNanos {ts} is out of range for chrono::DateTime"))
103}
104
105pub fn bar_type_to_granularity(bar_type: &BarType) -> anyhow::Result<CoinbaseGranularity> {
111 let spec = bar_type.spec();
112
113 anyhow::ensure!(
114 bar_type.aggregation_source() == AggregationSource::External,
115 "Only EXTERNAL aggregation is supported"
116 );
117
118 let step = spec.step.get();
119
120 match spec.aggregation {
121 BarAggregation::Minute => match step {
122 1 => Ok(CoinbaseGranularity::OneMinute),
123 5 => Ok(CoinbaseGranularity::FiveMinute),
124 15 => Ok(CoinbaseGranularity::FifteenMinute),
125 30 => Ok(CoinbaseGranularity::ThirtyMinute),
126 _ => anyhow::bail!("Unsupported minute step: {step}"),
127 },
128 BarAggregation::Hour => match step {
129 1 => Ok(CoinbaseGranularity::OneHour),
130 2 => Ok(CoinbaseGranularity::TwoHour),
131 6 => Ok(CoinbaseGranularity::SixHour),
132 _ => anyhow::bail!("Unsupported hour step: {step}"),
133 },
134 BarAggregation::Day => match step {
135 1 => Ok(CoinbaseGranularity::OneDay),
136 _ => anyhow::bail!("Unsupported day step: {step}"),
137 },
138 other => anyhow::bail!("Unsupported aggregation: {other}"),
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use rstest::rstest;
145
146 use super::*;
147
148 #[rstest]
149 #[case(
150 "BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL",
151 CoinbaseGranularity::OneMinute
152 )]
153 #[case(
154 "BTC-USD.COINBASE-5-MINUTE-LAST-EXTERNAL",
155 CoinbaseGranularity::FiveMinute
156 )]
157 #[case(
158 "BTC-USD.COINBASE-15-MINUTE-LAST-EXTERNAL",
159 CoinbaseGranularity::FifteenMinute
160 )]
161 #[case(
162 "BTC-USD.COINBASE-30-MINUTE-LAST-EXTERNAL",
163 CoinbaseGranularity::ThirtyMinute
164 )]
165 #[case("BTC-USD.COINBASE-1-HOUR-LAST-EXTERNAL", CoinbaseGranularity::OneHour)]
166 #[case("BTC-USD.COINBASE-2-HOUR-LAST-EXTERNAL", CoinbaseGranularity::TwoHour)]
167 #[case("BTC-USD.COINBASE-6-HOUR-LAST-EXTERNAL", CoinbaseGranularity::SixHour)]
168 #[case("BTC-USD.COINBASE-1-DAY-LAST-EXTERNAL", CoinbaseGranularity::OneDay)]
169 fn test_bar_type_to_granularity(
170 #[case] bar_type_str: &str,
171 #[case] expected: CoinbaseGranularity,
172 ) {
173 let bar_type = BarType::from(bar_type_str);
174 let result = bar_type_to_granularity(&bar_type).unwrap();
175 assert_eq!(result, expected);
176 }
177
178 #[rstest]
179 #[case("BTC-USD.COINBASE-3-MINUTE-LAST-EXTERNAL")]
180 #[case("BTC-USD.COINBASE-4-HOUR-LAST-EXTERNAL")]
181 #[case("BTC-USD.COINBASE-2-DAY-LAST-EXTERNAL")]
182 fn test_bar_type_to_granularity_unsupported(#[case] bar_type_str: &str) {
183 let bar_type = BarType::from(bar_type_str);
184 assert!(bar_type_to_granularity(&bar_type).is_err());
185 }
186
187 #[rstest]
188 fn test_format_rfc3339_from_nanos_round_trip() {
189 let ts = UnixNanos::from(1_705_314_600_000_000_000u64);
191 let s = format_rfc3339_from_nanos(ts).unwrap();
192 assert_eq!(s, "2024-01-15T10:30:00+00:00");
193 }
194
195 #[rstest]
196 fn test_format_rfc3339_from_nanos_preserves_subsecond_precision() {
197 let ts = UnixNanos::from(1_705_314_600_123_456_789u64);
199 let s = format_rfc3339_from_nanos(ts).unwrap();
200 assert_eq!(s, "2024-01-15T10:30:00.123456789+00:00");
201 }
202}