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::{CoinbaseGranularity, CoinbaseMarginType, CoinbaseProductType};
36
37/// Deserializes an optional value where Coinbase uses an empty string for `None`.
38pub 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
61/// Deserializes a Coinbase product type and falls back to `Unknown`.
62pub 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
72/// Deserializes the optional `margin_type` field on historical orders.
73///
74/// Coinbase returns one of `""`, `"UNKNOWN_MARGIN_TYPE"`, `"CROSS"`, or
75/// `"ISOLATED"` here. The first two carry no information (spot orders, or
76/// futures orders the venue declines to classify), so they map to `None`.
77/// Unrecognized values also map to `None` so a future enum variant cannot
78/// fail an entire historical-orders batch.
79pub 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
91/// Converts a [`UnixNanos`] timestamp to an RFC 3339 string in UTC.
92///
93/// # Errors
94///
95/// Returns an error when the nanosecond value is outside the range
96/// representable by [`chrono::DateTime::<chrono::Utc>::from_timestamp`].
97pub 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
105/// Converts a Nautilus [`BarType`] to a [`CoinbaseGranularity`].
106///
107/// # Errors
108///
109/// Returns an error if the bar type uses an unsupported aggregation or step value.
110pub 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        // 2024-01-15T10:30:00.000000000Z
190        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        // 2024-01-15T10:30:00.123456789Z
198        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}