nautilus_dydx/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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//! Parsing utilities that convert dYdX payloads into Nautilus domain models.
17
18use std::str::FromStr;
19
20use nautilus_model::{
21    identifiers::{InstrumentId, Symbol},
22    types::{Currency, Price, Quantity},
23};
24use rust_decimal::Decimal;
25use ustr::Ustr;
26
27use super::consts::DYDX_VENUE;
28
29/// Returns a currency from the internal map or creates a new crypto currency.
30///
31/// If the code is empty, logs a warning with context and returns USDC as fallback.
32/// Uses [`Currency::get_or_create_crypto`] to handle unknown currency codes,
33/// which automatically registers newly listed dYdX assets.
34fn get_currency_with_context(code: &str, context: Option<&str>) -> Currency {
35    let trimmed = code.trim();
36    let ctx = context.unwrap_or("unknown");
37
38    if trimmed.is_empty() {
39        tracing::warn!("Empty currency code for context {ctx}, defaulting to USDC as fallback");
40        return Currency::USDC();
41    }
42
43    Currency::get_or_create_crypto(trimmed)
44}
45
46/// Returns a currency from the given code.
47///
48/// Uses [`Currency::get_or_create_crypto`] to handle unknown currency codes.
49#[must_use]
50pub fn get_currency(code: &str) -> Currency {
51    get_currency_with_context(code, None)
52}
53
54/// Parses a dYdX instrument ID from a ticker string.
55///
56/// dYdX v4 only lists perpetual markets, with tickers in the format
57/// "BASE-QUOTE" (e.g., "BTC-USD"). Nautilus standardizes perpetual
58/// instrument symbols by appending the product suffix "-PERP".
59///
60/// This function converts a dYdX ticker into a Nautilus `InstrumentId`
61/// by appending "-PERP" to the symbol and using the dYdX venue.
62///
63#[must_use]
64pub fn parse_instrument_id<S: AsRef<str>>(ticker: S) -> InstrumentId {
65    let mut base = ticker.as_ref().trim().to_uppercase();
66    // Ensure we don't double-append when given a symbol already suffixed.
67    if !base.ends_with("-PERP") {
68        base.push_str("-PERP");
69    }
70    let symbol = Ustr::from(base.as_str());
71    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *DYDX_VENUE)
72}
73
74/// Parses a decimal string into a [`Price`].
75///
76/// # Errors
77///
78/// Returns an error if the string cannot be parsed into a valid price.
79pub fn parse_price(value: &str, field_name: &str) -> anyhow::Result<Price> {
80    Price::from_str(value).map_err(|e| {
81        anyhow::anyhow!("Failed to parse '{field_name}' value '{value}' into Price: {e}")
82    })
83}
84
85/// Parses a decimal string into a [`Quantity`].
86///
87/// # Errors
88///
89/// Returns an error if the string cannot be parsed into a valid quantity.
90pub fn parse_quantity(value: &str, field_name: &str) -> anyhow::Result<Quantity> {
91    Quantity::from_str(value).map_err(|e| {
92        anyhow::anyhow!("Failed to parse '{field_name}' value '{value}' into Quantity: {e}")
93    })
94}
95
96/// Parses a decimal string into a [`Decimal`].
97///
98/// # Errors
99///
100/// Returns an error if the string cannot be parsed into a valid decimal.
101pub fn parse_decimal(value: &str, field_name: &str) -> anyhow::Result<Decimal> {
102    Decimal::from_str(value).map_err(|e| {
103        anyhow::anyhow!("Failed to parse '{field_name}' value '{value}' into Decimal: {e}")
104    })
105}
106
107////////////////////////////////////////////////////////////////////////////////
108// Tests
109////////////////////////////////////////////////////////////////////////////////
110
111#[cfg(test)]
112mod tests {
113    use rstest::rstest;
114
115    use super::*;
116
117    #[rstest]
118    fn test_get_currency() {
119        let btc = get_currency("BTC");
120        assert_eq!(btc.code.as_str(), "BTC");
121
122        let usdc = get_currency("USDC");
123        assert_eq!(usdc.code.as_str(), "USDC");
124    }
125
126    #[rstest]
127    fn test_parse_instrument_id() {
128        let instrument_id = parse_instrument_id("BTC-USD");
129        assert_eq!(instrument_id.symbol.as_str(), "BTC-USD-PERP");
130        assert_eq!(instrument_id.venue, *DYDX_VENUE);
131    }
132
133    #[rstest]
134    fn test_parse_price() {
135        let price = parse_price("0.01", "test_price").unwrap();
136        assert_eq!(price.to_string(), "0.01");
137
138        let err = parse_price("invalid", "invalid_price");
139        assert!(err.is_err());
140    }
141
142    #[rstest]
143    fn test_parse_quantity() {
144        let qty = parse_quantity("1.5", "test_qty").unwrap();
145        assert_eq!(qty.to_string(), "1.5");
146    }
147
148    #[rstest]
149    fn test_parse_decimal() {
150        let decimal = parse_decimal("0.001", "test_decimal").unwrap();
151        assert_eq!(decimal.to_string(), "0.001");
152    }
153}