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 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
40/// Deserializes an optional value where Coinbase uses an empty string for `None`.
41pub 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
64/// Deserializes a Coinbase product type and falls back to `Unknown`.
65pub 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
75/// Deserializes a Coinbase product status and falls back to `Unknown`.
76pub 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
86/// Deserializes the optional `margin_type` field on historical orders.
87///
88/// Coinbase returns one of `""`, `"UNKNOWN_MARGIN_TYPE"`, `"CROSS"`, or
89/// `"ISOLATED"` here. The first two carry no information (spot orders, or
90/// futures orders the venue declines to classify), so they map to `None`.
91/// Unrecognized values also map to `None` so a future enum variant cannot
92/// fail an entire historical-orders batch.
93pub 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
105/// Converts a [`UnixNanos`] timestamp to an RFC 3339 string in UTC.
106///
107/// # Errors
108///
109/// Returns an error when the nanosecond value is outside the Jiff timestamp range.
110pub 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
117/// Converts a Nautilus [`BarType`] to a [`CoinbaseGranularity`].
118///
119/// # Errors
120///
121/// Returns an error if the bar type uses an unsupported aggregation or step value.
122pub 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        // 2024-01-15T10:30:00.000000000Z
202        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        // 2024-01-15T10:30:00.123456789Z
210        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}