nautilus_tardis/
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
16use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_MICROSECOND};
17use nautilus_model::{
18    data::BarSpecification,
19    enums::{AggressorSide, BarAggregation, BookAction, OptionKind, OrderSide, PriceType},
20    identifiers::{InstrumentId, Symbol},
21    types::{PRICE_MAX, PRICE_MIN, Price},
22};
23use serde::{Deserialize, Deserializer};
24use ustr::Ustr;
25use uuid::Uuid;
26
27use super::enums::{TardisExchange, TardisInstrumentType, TardisOptionType};
28
29/// Deserialize a string and convert to uppercase `Ustr`.
30///
31/// # Errors
32///
33/// Returns a deserialization error if the input is not a valid string.
34pub fn deserialize_uppercase<'de, D>(deserializer: D) -> Result<Ustr, D::Error>
35where
36    D: Deserializer<'de>,
37{
38    String::deserialize(deserializer).map(|s| Ustr::from(&s.to_uppercase()))
39}
40// Errors
41//
42// Returns a deserialization error if the input is not a valid string.
43
44/// Deserialize a trade ID or generate a new UUID if empty.
45///
46/// # Errors
47///
48/// Returns a deserialization error if the input cannot be deserialized as a string.
49pub fn deserialize_trade_id<'de, D>(deserializer: D) -> Result<String, D::Error>
50where
51    D: serde::Deserializer<'de>,
52{
53    let s = String::deserialize(deserializer)?;
54
55    if s.is_empty() {
56        return Ok(Uuid::new_v4().to_string());
57    }
58
59    Ok(s)
60}
61
62#[must_use]
63#[inline]
64pub fn normalize_symbol_str(
65    symbol: Ustr,
66    exchange: &TardisExchange,
67    instrument_type: &TardisInstrumentType,
68    is_inverse: Option<bool>,
69) -> Ustr {
70    match exchange {
71        TardisExchange::Binance
72        | TardisExchange::BinanceFutures
73        | TardisExchange::BinanceUs
74        | TardisExchange::BinanceDex
75        | TardisExchange::BinanceJersey
76            if instrument_type == &TardisInstrumentType::Perpetual =>
77        {
78            append_suffix(symbol, "-PERP")
79        }
80
81        TardisExchange::Bybit | TardisExchange::BybitSpot | TardisExchange::BybitOptions => {
82            match instrument_type {
83                TardisInstrumentType::Spot => append_suffix(symbol, "-SPOT"),
84                TardisInstrumentType::Perpetual if !is_inverse.unwrap_or(false) => {
85                    append_suffix(symbol, "-LINEAR")
86                }
87                TardisInstrumentType::Future if !is_inverse.unwrap_or(false) => {
88                    append_suffix(symbol, "-LINEAR")
89                }
90                TardisInstrumentType::Perpetual if is_inverse == Some(true) => {
91                    append_suffix(symbol, "-INVERSE")
92                }
93                TardisInstrumentType::Future if is_inverse == Some(true) => {
94                    append_suffix(symbol, "-INVERSE")
95                }
96                TardisInstrumentType::Option => append_suffix(symbol, "-OPTION"),
97                _ => symbol,
98            }
99        }
100
101        TardisExchange::Dydx if instrument_type == &TardisInstrumentType::Perpetual => {
102            append_suffix(symbol, "-PERP")
103        }
104
105        TardisExchange::GateIoFutures if instrument_type == &TardisInstrumentType::Perpetual => {
106            append_suffix(symbol, "-PERP")
107        }
108
109        _ => symbol,
110    }
111}
112
113fn append_suffix(symbol: Ustr, suffix: &str) -> Ustr {
114    let mut symbol = symbol.to_string();
115    symbol.push_str(suffix);
116    Ustr::from(&symbol)
117}
118
119/// Parses a Nautilus instrument ID from the given Tardis `exchange` and `symbol` values.
120#[must_use]
121pub fn parse_instrument_id(exchange: &TardisExchange, symbol: Ustr) -> InstrumentId {
122    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), exchange.as_venue())
123}
124
125/// Parses a Nautilus instrument ID with a normalized symbol from the given Tardis `exchange` and `symbol` values.
126#[must_use]
127pub fn normalize_instrument_id(
128    exchange: &TardisExchange,
129    symbol: Ustr,
130    instrument_type: &TardisInstrumentType,
131    is_inverse: Option<bool>,
132) -> InstrumentId {
133    let symbol = normalize_symbol_str(symbol, exchange, instrument_type, is_inverse);
134    parse_instrument_id(exchange, symbol)
135}
136
137/// Normalizes the given amount by truncating it to the specified decimal precision.
138///
139/// Uses rounding to the nearest integer before truncation to avoid floating-point
140/// precision issues (e.g., `0.1 * 10` becoming `0.9999999999`).
141#[must_use]
142pub fn normalize_amount(amount: f64, precision: u8) -> f64 {
143    let factor = 10_f64.powi(i32::from(precision));
144    // Round to nearest integer first to handle floating-point precision issues,
145    // then truncate toward zero to maintain the original truncation semantics
146    let scaled = amount * factor;
147    let rounded = scaled.round();
148    // If the rounded value is very close to scaled, use it; otherwise use trunc
149    // This handles edge cases like 0.1 * 10 = 0.9999999999... -> 1.0
150    let result = if (rounded - scaled).abs() < 1e-9 {
151        rounded.trunc()
152    } else {
153        scaled.trunc()
154    };
155    result / factor
156}
157
158/// Parses a Nautilus price from the given `value`.
159///
160/// Values outside the representable range are capped to min/max price.
161#[must_use]
162pub fn parse_price(value: f64, precision: u8) -> Price {
163    match value {
164        v if (PRICE_MIN..=PRICE_MAX).contains(&v) => Price::new(value, precision),
165        v if v < PRICE_MIN => Price::min(precision),
166        _ => Price::max(precision),
167    }
168}
169
170/// Parses a Nautilus order side from the given Tardis string `value`.
171#[must_use]
172pub fn parse_order_side(value: &str) -> OrderSide {
173    match value {
174        "bid" => OrderSide::Buy,
175        "ask" => OrderSide::Sell,
176        _ => OrderSide::NoOrderSide,
177    }
178}
179
180/// Parses a Nautilus aggressor side from the given Tardis string `value`.
181#[must_use]
182pub fn parse_aggressor_side(value: &str) -> AggressorSide {
183    match value {
184        "buy" => AggressorSide::Buyer,
185        "sell" => AggressorSide::Seller,
186        _ => AggressorSide::NoAggressor,
187    }
188}
189
190/// Parses a Nautilus option kind from the given Tardis enum `value`.
191#[must_use]
192pub const fn parse_option_kind(value: TardisOptionType) -> OptionKind {
193    match value {
194        TardisOptionType::Call => OptionKind::Call,
195        TardisOptionType::Put => OptionKind::Put,
196    }
197}
198
199/// Parses a UNIX nanoseconds timestamp from the given Tardis microseconds `value_us`.
200#[must_use]
201pub fn parse_timestamp(value_us: u64) -> UnixNanos {
202    value_us
203        .checked_mul(NANOSECONDS_IN_MICROSECOND)
204        .map_or_else(|| {
205            tracing::error!("Timestamp overflow: {value_us} microseconds exceeds maximum representable value");
206            UnixNanos::max()
207        }, UnixNanos::from)
208}
209
210/// Parses a Nautilus book action inferred from the given Tardis values.
211#[must_use]
212pub fn parse_book_action(is_snapshot: bool, amount: f64) -> BookAction {
213    if amount == 0.0 {
214        BookAction::Delete
215    } else if is_snapshot {
216        BookAction::Add
217    } else {
218        BookAction::Update
219    }
220}
221
222/// Parses a Nautilus bar specification from the given Tardis string `value`.
223///
224/// The [`PriceType`] is always `LAST` for Tardis trade bars.
225///
226/// # Errors
227///
228/// Returns an error if the specification format is invalid or if the aggregation suffix is unsupported.
229pub fn parse_bar_spec(value: &str) -> anyhow::Result<BarSpecification> {
230    let parts: Vec<&str> = value.split('_').collect();
231    let last_part = parts
232        .last()
233        .ok_or_else(|| anyhow::anyhow!("Invalid bar spec: empty string"))?;
234    let split_idx = last_part
235        .chars()
236        .position(|c| !c.is_ascii_digit())
237        .ok_or_else(|| anyhow::anyhow!("Invalid bar spec: no aggregation suffix in '{value}'"))?;
238
239    let (step_str, suffix) = last_part.split_at(split_idx);
240    let step: usize = step_str
241        .parse()
242        .map_err(|e| anyhow::anyhow!("Invalid step in bar spec '{value}': {e}"))?;
243
244    let aggregation = match suffix {
245        "ms" => BarAggregation::Millisecond,
246        "s" => BarAggregation::Second,
247        "m" => BarAggregation::Minute,
248        "ticks" => BarAggregation::Tick,
249        "vol" => BarAggregation::Volume,
250        _ => anyhow::bail!("Unsupported bar aggregation type: '{suffix}'"),
251    };
252
253    Ok(BarSpecification::new(step, aggregation, PriceType::Last))
254}
255
256/// Converts a Nautilus `BarSpecification` to the Tardis trade bar string convention.
257///
258/// # Errors
259///
260/// Returns an error if the bar aggregation kind is unsupported.
261pub fn bar_spec_to_tardis_trade_bar_string(bar_spec: &BarSpecification) -> anyhow::Result<String> {
262    let suffix = match bar_spec.aggregation {
263        BarAggregation::Millisecond => "ms",
264        BarAggregation::Second => "s",
265        BarAggregation::Minute => "m",
266        BarAggregation::Tick => "ticks",
267        BarAggregation::Volume => "vol",
268        _ => anyhow::bail!("Unsupported bar aggregation type: {}", bar_spec.aggregation),
269    };
270    Ok(format!("trade_bar_{}{}", bar_spec.step, suffix))
271}
272
273#[cfg(test)]
274mod tests {
275    use std::str::FromStr;
276
277    use rstest::rstest;
278
279    use super::*;
280
281    #[rstest]
282    #[case(TardisExchange::Binance, "ETHUSDT", "ETHUSDT.BINANCE")]
283    #[case(TardisExchange::Bitmex, "XBTUSD", "XBTUSD.BITMEX")]
284    #[case(TardisExchange::Bybit, "BTCUSDT", "BTCUSDT.BYBIT")]
285    #[case(TardisExchange::OkexFutures, "BTC-USD-200313", "BTC-USD-200313.OKEX")]
286    #[case(TardisExchange::HuobiDmLinearSwap, "FOO-BAR", "FOO-BAR.HUOBI")]
287    fn test_parse_instrument_id(
288        #[case] exchange: TardisExchange,
289        #[case] symbol: Ustr,
290        #[case] expected: &str,
291    ) {
292        let instrument_id = parse_instrument_id(&exchange, symbol);
293        let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
294        assert_eq!(instrument_id, expected_instrument_id);
295    }
296
297    #[rstest]
298    #[case(
299        TardisExchange::Binance,
300        "SOLUSDT",
301        TardisInstrumentType::Spot,
302        None,
303        "SOLUSDT.BINANCE"
304    )]
305    #[case(
306        TardisExchange::BinanceFutures,
307        "SOLUSDT",
308        TardisInstrumentType::Perpetual,
309        None,
310        "SOLUSDT-PERP.BINANCE"
311    )]
312    #[case(
313        TardisExchange::Bybit,
314        "BTCUSDT",
315        TardisInstrumentType::Spot,
316        None,
317        "BTCUSDT-SPOT.BYBIT"
318    )]
319    #[case(
320        TardisExchange::Bybit,
321        "BTCUSDT",
322        TardisInstrumentType::Perpetual,
323        None,
324        "BTCUSDT-LINEAR.BYBIT"
325    )]
326    #[case(
327        TardisExchange::Bybit,
328        "BTCUSDT",
329        TardisInstrumentType::Perpetual,
330        Some(true),
331        "BTCUSDT-INVERSE.BYBIT"
332    )]
333    #[case(
334        TardisExchange::Dydx,
335        "BTC-USD",
336        TardisInstrumentType::Perpetual,
337        None,
338        "BTC-USD-PERP.DYDX"
339    )]
340    fn test_normalize_instrument_id(
341        #[case] exchange: TardisExchange,
342        #[case] symbol: Ustr,
343        #[case] instrument_type: TardisInstrumentType,
344        #[case] is_inverse: Option<bool>,
345        #[case] expected: &str,
346    ) {
347        let instrument_id =
348            normalize_instrument_id(&exchange, symbol, &instrument_type, is_inverse);
349        let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
350        assert_eq!(instrument_id, expected_instrument_id);
351    }
352
353    #[rstest]
354    #[case(0.00001, 4, 0.0)]
355    #[case(1.2345, 3, 1.234)]
356    #[case(1.2345, 2, 1.23)]
357    #[case(-1.2345, 3, -1.234)]
358    #[case(123.456, 0, 123.0)]
359    fn test_normalize_amount(#[case] amount: f64, #[case] precision: u8, #[case] expected: f64) {
360        let result = normalize_amount(amount, precision);
361        assert_eq!(result, expected);
362    }
363
364    #[rstest]
365    fn test_normalize_amount_floating_point_edge_cases() {
366        // Test that floating-point edge cases are handled correctly
367        // 0.1 * 10 can become 0.9999999... due to IEEE 754
368        let result = normalize_amount(0.1, 1);
369        assert_eq!(result, 0.1);
370
371        // Test with values that could have precision issues
372        let result = normalize_amount(0.7, 1);
373        assert_eq!(result, 0.7);
374
375        // Test large precision
376        let result = normalize_amount(1.123456789, 9);
377        assert_eq!(result, 1.123456789);
378
379        // Test zero
380        let result = normalize_amount(0.0, 8);
381        assert_eq!(result, 0.0);
382
383        // Test negative values
384        let result = normalize_amount(-0.1, 1);
385        assert_eq!(result, -0.1);
386    }
387
388    #[rstest]
389    #[case("bid", OrderSide::Buy)]
390    #[case("ask", OrderSide::Sell)]
391    #[case("unknown", OrderSide::NoOrderSide)]
392    #[case("", OrderSide::NoOrderSide)]
393    #[case("random", OrderSide::NoOrderSide)]
394    fn test_parse_order_side(#[case] input: &str, #[case] expected: OrderSide) {
395        assert_eq!(parse_order_side(input), expected);
396    }
397
398    #[rstest]
399    #[case("buy", AggressorSide::Buyer)]
400    #[case("sell", AggressorSide::Seller)]
401    #[case("unknown", AggressorSide::NoAggressor)]
402    #[case("", AggressorSide::NoAggressor)]
403    #[case("random", AggressorSide::NoAggressor)]
404    fn test_parse_aggressor_side(#[case] input: &str, #[case] expected: AggressorSide) {
405        assert_eq!(parse_aggressor_side(input), expected);
406    }
407
408    #[rstest]
409    fn test_parse_timestamp() {
410        let input_timestamp: u64 = 1583020803145000;
411        let expected_nanos: UnixNanos =
412            UnixNanos::from(input_timestamp * NANOSECONDS_IN_MICROSECOND);
413
414        assert_eq!(parse_timestamp(input_timestamp), expected_nanos);
415    }
416
417    #[rstest]
418    #[case(true, 10.0, BookAction::Add)]
419    #[case(false, 0.0, BookAction::Delete)]
420    #[case(false, 10.0, BookAction::Update)]
421    fn test_parse_book_action(
422        #[case] is_snapshot: bool,
423        #[case] amount: f64,
424        #[case] expected: BookAction,
425    ) {
426        assert_eq!(parse_book_action(is_snapshot, amount), expected);
427    }
428
429    #[rstest]
430    #[case("trade_bar_10ms", 10, BarAggregation::Millisecond)]
431    #[case("trade_bar_5m", 5, BarAggregation::Minute)]
432    #[case("trade_bar_100ticks", 100, BarAggregation::Tick)]
433    #[case("trade_bar_100000vol", 100000, BarAggregation::Volume)]
434    fn test_parse_bar_spec(
435        #[case] value: &str,
436        #[case] expected_step: usize,
437        #[case] expected_aggregation: BarAggregation,
438    ) {
439        let spec = parse_bar_spec(value).unwrap();
440        assert_eq!(spec.step.get(), expected_step);
441        assert_eq!(spec.aggregation, expected_aggregation);
442        assert_eq!(spec.price_type, PriceType::Last);
443    }
444
445    #[rstest]
446    #[case("trade_bar_10unknown", "Unsupported bar aggregation type")]
447    #[case("", "no aggregation suffix")]
448    #[case("trade_bar_notanumberms", "Invalid step")]
449    fn test_parse_bar_spec_errors(#[case] value: &str, #[case] expected_error: &str) {
450        let result = parse_bar_spec(value);
451        assert!(result.is_err());
452        assert!(
453            result.unwrap_err().to_string().contains(expected_error),
454            "Expected error containing '{expected_error}'"
455        );
456    }
457
458    #[rstest]
459    #[case(
460        BarSpecification::new(10, BarAggregation::Millisecond, PriceType::Last),
461        "trade_bar_10ms"
462    )]
463    #[case(
464        BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
465        "trade_bar_5m"
466    )]
467    #[case(
468        BarSpecification::new(100, BarAggregation::Tick, PriceType::Last),
469        "trade_bar_100ticks"
470    )]
471    #[case(
472        BarSpecification::new(100_000, BarAggregation::Volume, PriceType::Last),
473        "trade_bar_100000vol"
474    )]
475    fn test_to_tardis_string(#[case] bar_spec: BarSpecification, #[case] expected: &str) {
476        assert_eq!(
477            bar_spec_to_tardis_trade_bar_string(&bar_spec).unwrap(),
478            expected
479        );
480    }
481}