Skip to main content

nautilus_coinbase/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Common parsing utilities for the Coinbase adapter.
17
18use 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
39/// Deserializes an optional value where Coinbase uses an empty string for `None`.
40pub 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
63/// Deserializes a Coinbase product type and falls back to `Unknown`.
64pub 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
74/// Deserializes a Coinbase product status and falls back to `Unknown`.
75pub 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
85/// Deserializes the optional `margin_type` field on historical orders.
86///
87/// Coinbase returns one of `""`, `"UNKNOWN_MARGIN_TYPE"`, `"CROSS"`, or
88/// `"ISOLATED"` here. The first two carry no information (spot orders, or
89/// futures orders the venue declines to classify), so they map to `None`.
90/// Unrecognized values also map to `None` so a future enum variant cannot
91/// fail an entire historical-orders batch.
92pub 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
104/// Converts a [`UnixNanos`] timestamp to an RFC 3339 string in UTC.
105///
106/// # Errors
107///
108/// Returns an error when the nanosecond value is outside the range
109/// representable by [`chrono::DateTime::<chrono::Utc>::from_timestamp`].
110pub 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
118/// Converts a Nautilus [`BarType`] to a [`CoinbaseGranularity`].
119///
120/// # Errors
121///
122/// Returns an error if the bar type uses an unsupported aggregation or step value.
123pub 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        // 2024-01-15T10:30:00.000000000Z
203        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        // 2024-01-15T10:30:00.123456789Z
211        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}