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