Skip to main content

nautilus_dydx/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//! Parsing utilities that convert dYdX payloads into Nautilus domain models.
17
18use std::str::FromStr;
19
20use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_SECOND};
21use nautilus_model::{
22    enums::{OrderSide, TimeInForce},
23    identifiers::{InstrumentId, Symbol},
24    types::{Price, Quantity, fixed::FIXED_PRECISION},
25};
26use rust_decimal::Decimal;
27
28use super::consts::DYDX_VENUE;
29use crate::proto::dydxprotocol::clob::order::{
30    Side as ProtoOrderSide, TimeInForce as ProtoTimeInForce,
31};
32
33/// Extracts the raw dYdX ticker from a Nautilus symbol.
34///
35/// Removes both the venue suffix (`.DYDX`) and the perpetual suffix (`-PERP`).
36/// This produces the base ticker format required by dYdX WebSocket subscriptions.
37#[must_use]
38pub fn extract_raw_symbol(symbol: &str) -> &str {
39    let without_venue = symbol.split('.').next().unwrap_or(symbol);
40    without_venue.strip_suffix("-PERP").unwrap_or(without_venue)
41}
42
43/// Converts Nautilus `OrderSide` to dYdX proto `OrderSide`.
44#[must_use]
45pub fn order_side_to_proto(side: OrderSide) -> ProtoOrderSide {
46    match side {
47        OrderSide::Buy => ProtoOrderSide::Buy,
48        OrderSide::Sell => ProtoOrderSide::Sell,
49    }
50}
51
52/// Converts Nautilus `TimeInForce` to dYdX proto `TimeInForce`.
53///
54/// dYdX v4 protocol mappings:
55/// - `IOC` → `ProtoTimeInForce::Ioc` (Immediate or Cancel)
56/// - `FOK` → `ProtoTimeInForce::FillOrKill` (Fill or Kill)
57/// - `GTC` → `ProtoTimeInForce::Unspecified` (Good Till Cancel - protocol default)
58/// - `GTD` → `ProtoTimeInForce::Unspecified` (Good Till Date - uses `good_til_block_time` or `good_til_block`)
59/// - Others → `ProtoTimeInForce::Unspecified` (protocol default)
60///
61/// Note: `Unspecified` (proto enum value 0) is the protocol default and represents GTC behavior.
62/// GTD orders specify expiration separately via `good_til_block` or `good_til_block_time` fields.
63/// For post-only orders, use `time_in_force_to_proto_with_post_only()` which returns `ProtoTimeInForce::PostOnly`.
64#[must_use]
65pub fn time_in_force_to_proto(tif: TimeInForce) -> ProtoTimeInForce {
66    match tif {
67        TimeInForce::Ioc => ProtoTimeInForce::Ioc,
68        TimeInForce::Fok => ProtoTimeInForce::FillOrKill,
69        TimeInForce::Gtc => ProtoTimeInForce::Unspecified,
70        TimeInForce::Gtd => ProtoTimeInForce::Unspecified,
71        _ => ProtoTimeInForce::Unspecified,
72    }
73}
74
75/// Converts Nautilus `TimeInForce` to dYdX proto `TimeInForce` with post_only flag support.
76///
77/// When `post_only` is true, returns `ProtoTimeInForce::PostOnly` regardless of the input TIF.
78/// Otherwise, delegates to `time_in_force_to_proto()`.
79#[must_use]
80pub fn time_in_force_to_proto_with_post_only(
81    tif: TimeInForce,
82    post_only: bool,
83) -> ProtoTimeInForce {
84    if post_only {
85        ProtoTimeInForce::PostOnly
86    } else {
87        time_in_force_to_proto(tif)
88    }
89}
90
91/// Parses a dYdX instrument ID from a ticker string.
92///
93/// dYdX v4 only lists perpetual markets, with tickers in the format
94/// "BASE-QUOTE" (e.g., "BTC-USD"). Nautilus standardizes perpetual
95/// instrument symbols by appending the product suffix "-PERP".
96///
97/// This function converts a dYdX ticker into a Nautilus `InstrumentId`
98/// by appending "-PERP" to the symbol and using the dYdX venue.
99#[must_use]
100pub fn parse_instrument_id<S: AsRef<str>>(ticker: S) -> InstrumentId {
101    let mut base = ticker.as_ref().trim().to_uppercase();
102    // Ensure we don't double-append when given a symbol already suffixed.
103    if !base.ends_with("-PERP") {
104        base.push_str("-PERP");
105    }
106    InstrumentId::new(Symbol::from_str_unchecked(&base), *DYDX_VENUE)
107}
108
109/// Parses a decimal string into a [`Price`].
110///
111/// Normalizes the decimal to strip trailing zeros and clamps precision to
112/// [`FIXED_PRECISION`] to prevent panics from venue values with excessive
113/// decimal places.
114///
115/// # Errors
116///
117/// Returns an error if the string cannot be parsed into a valid price.
118pub fn parse_price(value: &str, field_name: &str) -> anyhow::Result<Price> {
119    let decimal = Decimal::from_str(value).map_err(|e| {
120        anyhow::anyhow!("Failed to parse '{field_name}' value '{value}' into Decimal: {e}")
121    })?;
122    let normalized = decimal.normalize();
123    let precision = (normalized.scale() as u8).min(FIXED_PRECISION);
124    Price::from_decimal_dp(normalized, precision).map_err(|e| {
125        anyhow::anyhow!("Failed to parse '{field_name}' value '{value}' into Price: {e}")
126    })
127}
128
129/// Parses a decimal string into a [`Quantity`].
130///
131/// Normalizes the decimal to strip trailing zeros and clamps precision to
132/// [`FIXED_PRECISION`] to prevent panics from venue values with excessive
133/// decimal places.
134///
135/// # Errors
136///
137/// Returns an error if the string cannot be parsed into a valid quantity.
138pub fn parse_quantity(value: &str, field_name: &str) -> anyhow::Result<Quantity> {
139    let decimal = Decimal::from_str(value).map_err(|e| {
140        anyhow::anyhow!("Failed to parse '{field_name}' value '{value}' into Decimal: {e}")
141    })?;
142    let normalized = decimal.normalize();
143    let precision = (normalized.scale() as u8).min(FIXED_PRECISION);
144    Quantity::from_decimal_dp(normalized, precision).map_err(|e| {
145        anyhow::anyhow!("Failed to parse '{field_name}' value '{value}' into Quantity: {e}")
146    })
147}
148
149/// Parses a decimal string into a [`Decimal`].
150///
151/// # Errors
152///
153/// Returns an error if the string cannot be parsed into a valid decimal.
154pub fn parse_decimal(value: &str, field_name: &str) -> anyhow::Result<Decimal> {
155    Decimal::from_str(value).map_err(|e| {
156        anyhow::anyhow!("Failed to parse '{field_name}' value '{value}' into Decimal: {e}")
157    })
158}
159
160/// Converts [`UnixNanos`] to seconds as `i64` using integer division.
161///
162/// Uses pure integer arithmetic to avoid floating-point precision loss that can
163/// occur when converting large nanosecond timestamps (e.g., order expiry times).
164#[must_use]
165pub fn nanos_to_secs_i64(nanos: UnixNanos) -> i64 {
166    (nanos.as_u64() / NANOSECONDS_IN_SECOND) as i64
167}
168
169#[cfg(test)]
170mod tests {
171    use nautilus_model::types::Currency;
172    use rstest::rstest;
173
174    use super::*;
175
176    #[rstest]
177    fn test_extract_raw_symbol() {
178        assert_eq!(extract_raw_symbol("BTC-USD-PERP.DYDX"), "BTC-USD");
179        assert_eq!(extract_raw_symbol("BTC-USD-PERP"), "BTC-USD");
180        assert_eq!(extract_raw_symbol("ETH-USD.DYDX"), "ETH-USD");
181        assert_eq!(extract_raw_symbol("SOL-USD"), "SOL-USD");
182    }
183
184    #[rstest]
185    #[case(OrderSide::Buy, ProtoOrderSide::Buy)]
186    #[case(OrderSide::Sell, ProtoOrderSide::Sell)]
187    fn test_order_side_to_proto(#[case] side: OrderSide, #[case] expected: ProtoOrderSide) {
188        assert_eq!(order_side_to_proto(side), expected);
189    }
190
191    #[rstest]
192    #[case(TimeInForce::Ioc, ProtoTimeInForce::Ioc)]
193    #[case(TimeInForce::Fok, ProtoTimeInForce::FillOrKill)]
194    #[case(TimeInForce::Gtc, ProtoTimeInForce::Unspecified)]
195    #[case(TimeInForce::Gtd, ProtoTimeInForce::Unspecified)]
196    #[case(TimeInForce::Day, ProtoTimeInForce::Unspecified)]
197    fn test_time_in_force_to_proto(#[case] tif: TimeInForce, #[case] expected: ProtoTimeInForce) {
198        assert_eq!(time_in_force_to_proto(tif), expected);
199    }
200
201    #[rstest]
202    #[case(TimeInForce::Gtc, false, ProtoTimeInForce::Unspecified)]
203    #[case(TimeInForce::Gtc, true, ProtoTimeInForce::PostOnly)]
204    #[case(TimeInForce::Ioc, false, ProtoTimeInForce::Ioc)]
205    #[case(TimeInForce::Ioc, true, ProtoTimeInForce::PostOnly)]
206    #[case(TimeInForce::Fok, false, ProtoTimeInForce::FillOrKill)]
207    #[case(TimeInForce::Fok, true, ProtoTimeInForce::PostOnly)]
208    #[case(TimeInForce::Gtd, false, ProtoTimeInForce::Unspecified)]
209    #[case(TimeInForce::Gtd, true, ProtoTimeInForce::PostOnly)]
210    fn test_time_in_force_to_proto_with_post_only(
211        #[case] tif: TimeInForce,
212        #[case] post_only: bool,
213        #[case] expected: ProtoTimeInForce,
214    ) {
215        assert_eq!(
216            time_in_force_to_proto_with_post_only(tif, post_only),
217            expected
218        );
219    }
220
221    #[rstest]
222    fn test_get_currency() {
223        let btc = Currency::get_or_create_crypto("BTC");
224        assert_eq!(btc.code.as_str(), "BTC");
225
226        let usdc = Currency::get_or_create_crypto("USDC");
227        assert_eq!(usdc.code.as_str(), "USDC");
228    }
229
230    #[rstest]
231    fn test_parse_instrument_id() {
232        let instrument_id = parse_instrument_id("BTC-USD");
233        assert_eq!(instrument_id.symbol.as_str(), "BTC-USD-PERP");
234        assert_eq!(instrument_id.venue, *DYDX_VENUE);
235    }
236
237    #[rstest]
238    fn test_parse_price() {
239        let price = parse_price("0.01", "test_price").unwrap();
240        assert_eq!(price.to_string(), "0.01");
241
242        let err = parse_price("invalid", "invalid_price");
243        assert!(err.is_err());
244    }
245
246    #[rstest]
247    fn test_parse_price_normalizes_trailing_zeros() {
248        let price = parse_price("0.0100", "test_price").unwrap();
249        assert_eq!(price.precision, 2);
250        assert_eq!(price.to_string(), "0.01");
251    }
252
253    #[rstest]
254    fn test_parse_price_clamps_precision_to_fixed_max() {
255        // 18 decimal places exceeds FIXED_PRECISION (16 with high-precision)
256        let price = parse_price("0.000000000000000001", "test_price").unwrap();
257        assert!(price.precision <= FIXED_PRECISION);
258    }
259
260    #[rstest]
261    fn test_parse_price_high_precision_no_panic() {
262        // 20 decimal places should not panic, just clamp
263        let result = parse_price("0.00000000000000000001", "test_price");
264        assert!(result.is_ok());
265        assert!(result.unwrap().precision <= FIXED_PRECISION);
266    }
267
268    #[rstest]
269    fn test_parse_quantity() {
270        let qty = parse_quantity("1.5", "test_qty").unwrap();
271        assert_eq!(qty.to_string(), "1.5");
272    }
273
274    #[rstest]
275    fn test_parse_quantity_clamps_precision_to_fixed_max() {
276        let qty = parse_quantity("0.000000000000000001", "test_qty").unwrap();
277        assert!(qty.precision <= FIXED_PRECISION);
278    }
279
280    #[rstest]
281    fn test_parse_decimal() {
282        let decimal = parse_decimal("0.001", "test_decimal").unwrap();
283        assert_eq!(decimal.to_string(), "0.001");
284    }
285
286    #[rstest]
287    fn test_nanos_to_secs_i64() {
288        assert_eq!(nanos_to_secs_i64(UnixNanos::from(0)), 0);
289        assert_eq!(nanos_to_secs_i64(UnixNanos::from(1_000_000_000)), 1);
290        assert_eq!(nanos_to_secs_i64(UnixNanos::from(1_500_000_000)), 1);
291        assert_eq!(nanos_to_secs_i64(UnixNanos::from(1_999_999_999)), 1);
292        assert_eq!(nanos_to_secs_i64(UnixNanos::from(2_000_000_000)), 2);
293        // Test with a realistic order expiry timestamp (2024-01-01 00:00:00 UTC)
294        assert_eq!(
295            nanos_to_secs_i64(UnixNanos::from(1_704_067_200_000_000_000)),
296            1_704_067_200
297        );
298    }
299}