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#[must_use]
139pub fn normalize_amount(amount: f64, precision: u8) -> f64 {
140    let factor = 10_f64.powi(i32::from(precision));
141    (amount * factor).trunc() / factor
142}
143
144/// Parses a Nautilus price from the given `value`.
145///
146/// Values outside the representable range are capped to min/max price.
147#[must_use]
148pub fn parse_price(value: f64, precision: u8) -> Price {
149    match value {
150        v if (PRICE_MIN..=PRICE_MAX).contains(&v) => Price::new(value, precision),
151        v if v < PRICE_MIN => Price::min(precision),
152        _ => Price::max(precision),
153    }
154}
155
156/// Parses a Nautilus order side from the given Tardis string `value`.
157#[must_use]
158pub fn parse_order_side(value: &str) -> OrderSide {
159    match value {
160        "bid" => OrderSide::Buy,
161        "ask" => OrderSide::Sell,
162        _ => OrderSide::NoOrderSide,
163    }
164}
165
166/// Parses a Nautilus aggressor side from the given Tardis string `value`.
167#[must_use]
168pub fn parse_aggressor_side(value: &str) -> AggressorSide {
169    match value {
170        "buy" => AggressorSide::Buyer,
171        "sell" => AggressorSide::Seller,
172        _ => AggressorSide::NoAggressor,
173    }
174}
175
176/// Parses a Nautilus option kind from the given Tardis enum `value`.
177#[must_use]
178pub const fn parse_option_kind(value: TardisOptionType) -> OptionKind {
179    match value {
180        TardisOptionType::Call => OptionKind::Call,
181        TardisOptionType::Put => OptionKind::Put,
182    }
183}
184
185/// Parses a UNIX nanoseconds timestamp from the given Tardis microseconds `value_us`.
186#[must_use]
187pub fn parse_timestamp(value_us: u64) -> UnixNanos {
188    value_us
189        .checked_mul(NANOSECONDS_IN_MICROSECOND)
190        .map_or_else(|| {
191            tracing::error!("Timestamp overflow: {value_us} microseconds exceeds maximum representable value");
192            UnixNanos::max()
193        }, UnixNanos::from)
194}
195
196/// Parses a Nautilus book action inferred from the given Tardis values.
197#[must_use]
198pub fn parse_book_action(is_snapshot: bool, amount: f64) -> BookAction {
199    if amount == 0.0 {
200        BookAction::Delete
201    } else if is_snapshot {
202        BookAction::Add
203    } else {
204        BookAction::Update
205    }
206}
207
208/// Parses a Nautilus bar specification from the given Tardis string `value`.
209///
210/// The [`PriceType`] is always `LAST` for Tardis trade bars.
211///
212/// # Panics
213///
214/// Panics if the specification format is invalid or if the aggregation suffix is unsupported.
215#[must_use]
216pub fn parse_bar_spec(value: &str) -> BarSpecification {
217    let parts: Vec<&str> = value.split('_').collect();
218    let last_part = parts.last().expect("Invalid bar spec");
219    let split_idx = last_part
220        .chars()
221        .position(|c| !c.is_ascii_digit())
222        .expect("Invalid bar spec");
223
224    let (step_str, suffix) = last_part.split_at(split_idx);
225    let step: usize = step_str.parse().expect("Invalid step");
226
227    let aggregation = match suffix {
228        "ms" => BarAggregation::Millisecond,
229        "s" => BarAggregation::Second,
230        "m" => BarAggregation::Minute,
231        "ticks" => BarAggregation::Tick,
232        "vol" => BarAggregation::Volume,
233        _ => panic!("Unsupported bar aggregation type"),
234    };
235
236    BarSpecification::new(step, aggregation, PriceType::Last)
237}
238
239/// Converts a Nautilus `BarSpecification` to the Tardis trade bar string convention.
240///
241/// # Panics
242///
243/// Panics if the bar aggregation kind is unsupported.
244#[must_use]
245pub fn bar_spec_to_tardis_trade_bar_string(bar_spec: &BarSpecification) -> String {
246    let suffix = match bar_spec.aggregation {
247        BarAggregation::Millisecond => "ms",
248        BarAggregation::Second => "s",
249        BarAggregation::Minute => "m",
250        BarAggregation::Tick => "ticks",
251        BarAggregation::Volume => "vol",
252        _ => panic!("Unsupported bar aggregation type {}", bar_spec.aggregation),
253    };
254    format!("trade_bar_{}{}", bar_spec.step, suffix)
255}
256
257////////////////////////////////////////////////////////////////////////////////
258// Tests
259////////////////////////////////////////////////////////////////////////////////
260#[cfg(test)]
261mod tests {
262    use std::str::FromStr;
263
264    use rstest::rstest;
265
266    use super::*;
267
268    #[rstest]
269    #[case(TardisExchange::Binance, "ETHUSDT", "ETHUSDT.BINANCE")]
270    #[case(TardisExchange::Bitmex, "XBTUSD", "XBTUSD.BITMEX")]
271    #[case(TardisExchange::Bybit, "BTCUSDT", "BTCUSDT.BYBIT")]
272    #[case(TardisExchange::OkexFutures, "BTC-USD-200313", "BTC-USD-200313.OKEX")]
273    #[case(TardisExchange::HuobiDmLinearSwap, "FOO-BAR", "FOO-BAR.HUOBI")]
274    fn test_parse_instrument_id(
275        #[case] exchange: TardisExchange,
276        #[case] symbol: Ustr,
277        #[case] expected: &str,
278    ) {
279        let instrument_id = parse_instrument_id(&exchange, symbol);
280        let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
281        assert_eq!(instrument_id, expected_instrument_id);
282    }
283
284    #[rstest]
285    #[case(
286        TardisExchange::Binance,
287        "SOLUSDT",
288        TardisInstrumentType::Spot,
289        None,
290        "SOLUSDT.BINANCE"
291    )]
292    #[case(
293        TardisExchange::BinanceFutures,
294        "SOLUSDT",
295        TardisInstrumentType::Perpetual,
296        None,
297        "SOLUSDT-PERP.BINANCE"
298    )]
299    #[case(
300        TardisExchange::Bybit,
301        "BTCUSDT",
302        TardisInstrumentType::Spot,
303        None,
304        "BTCUSDT-SPOT.BYBIT"
305    )]
306    #[case(
307        TardisExchange::Bybit,
308        "BTCUSDT",
309        TardisInstrumentType::Perpetual,
310        None,
311        "BTCUSDT-LINEAR.BYBIT"
312    )]
313    #[case(
314        TardisExchange::Bybit,
315        "BTCUSDT",
316        TardisInstrumentType::Perpetual,
317        Some(true),
318        "BTCUSDT-INVERSE.BYBIT"
319    )]
320    #[case(
321        TardisExchange::Dydx,
322        "BTC-USD",
323        TardisInstrumentType::Perpetual,
324        None,
325        "BTC-USD-PERP.DYDX"
326    )]
327    fn test_normalize_instrument_id(
328        #[case] exchange: TardisExchange,
329        #[case] symbol: Ustr,
330        #[case] instrument_type: TardisInstrumentType,
331        #[case] is_inverse: Option<bool>,
332        #[case] expected: &str,
333    ) {
334        let instrument_id =
335            normalize_instrument_id(&exchange, symbol, &instrument_type, is_inverse);
336        let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
337        assert_eq!(instrument_id, expected_instrument_id);
338    }
339
340    #[rstest]
341    #[case(0.00001, 4, 0.0)]
342    #[case(1.2345, 3, 1.234)]
343    #[case(1.2345, 2, 1.23)]
344    #[case(-1.2345, 3, -1.234)]
345    #[case(123.456, 0, 123.0)]
346    fn test_normalize_amount(#[case] amount: f64, #[case] precision: u8, #[case] expected: f64) {
347        let result = normalize_amount(amount, precision);
348        assert_eq!(result, expected);
349    }
350
351    #[rstest]
352    #[case("bid", OrderSide::Buy)]
353    #[case("ask", OrderSide::Sell)]
354    #[case("unknown", OrderSide::NoOrderSide)]
355    #[case("", OrderSide::NoOrderSide)]
356    #[case("random", OrderSide::NoOrderSide)]
357    fn test_parse_order_side(#[case] input: &str, #[case] expected: OrderSide) {
358        assert_eq!(parse_order_side(input), expected);
359    }
360
361    #[rstest]
362    #[case("buy", AggressorSide::Buyer)]
363    #[case("sell", AggressorSide::Seller)]
364    #[case("unknown", AggressorSide::NoAggressor)]
365    #[case("", AggressorSide::NoAggressor)]
366    #[case("random", AggressorSide::NoAggressor)]
367    fn test_parse_aggressor_side(#[case] input: &str, #[case] expected: AggressorSide) {
368        assert_eq!(parse_aggressor_side(input), expected);
369    }
370
371    #[rstest]
372    fn test_parse_timestamp() {
373        let input_timestamp: u64 = 1583020803145000;
374        let expected_nanos: UnixNanos =
375            UnixNanos::from(input_timestamp * NANOSECONDS_IN_MICROSECOND);
376
377        assert_eq!(parse_timestamp(input_timestamp), expected_nanos);
378    }
379
380    #[rstest]
381    #[case(true, 10.0, BookAction::Add)]
382    #[case(false, 0.0, BookAction::Delete)]
383    #[case(false, 10.0, BookAction::Update)]
384    fn test_parse_book_action(
385        #[case] is_snapshot: bool,
386        #[case] amount: f64,
387        #[case] expected: BookAction,
388    ) {
389        assert_eq!(parse_book_action(is_snapshot, amount), expected);
390    }
391
392    #[rstest]
393    #[case("trade_bar_10ms", 10, BarAggregation::Millisecond)]
394    #[case("trade_bar_5m", 5, BarAggregation::Minute)]
395    #[case("trade_bar_100ticks", 100, BarAggregation::Tick)]
396    #[case("trade_bar_100000vol", 100000, BarAggregation::Volume)]
397    fn test_parse_bar_spec(
398        #[case] value: &str,
399        #[case] expected_step: usize,
400        #[case] expected_aggregation: BarAggregation,
401    ) {
402        let spec = parse_bar_spec(value);
403        assert_eq!(spec.step.get(), expected_step);
404        assert_eq!(spec.aggregation, expected_aggregation);
405        assert_eq!(spec.price_type, PriceType::Last);
406    }
407
408    #[rstest]
409    #[case("trade_bar_10unknown")]
410    #[should_panic(expected = "Unsupported bar aggregation type")]
411    fn test_parse_bar_spec_invalid_suffix(#[case] value: &str) {
412        let _ = parse_bar_spec(value);
413    }
414
415    #[rstest]
416    #[case("")]
417    #[should_panic(expected = "Invalid bar spec")]
418    fn test_parse_bar_spec_empty(#[case] value: &str) {
419        let _ = parse_bar_spec(value);
420    }
421
422    #[rstest]
423    #[case("trade_bar_notanumberms")]
424    #[should_panic(expected = "Invalid step")]
425    fn test_parse_bar_spec_invalid_step(#[case] value: &str) {
426        let _ = parse_bar_spec(value);
427    }
428
429    #[rstest]
430    #[case(
431        BarSpecification::new(10, BarAggregation::Millisecond, PriceType::Last),
432        "trade_bar_10ms"
433    )]
434    #[case(
435        BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
436        "trade_bar_5m"
437    )]
438    #[case(
439        BarSpecification::new(100, BarAggregation::Tick, PriceType::Last),
440        "trade_bar_100ticks"
441    )]
442    #[case(
443        BarSpecification::new(100_000, BarAggregation::Volume, PriceType::Last),
444        "trade_bar_100000vol"
445    )]
446    fn test_to_tardis_string(#[case] bar_spec: BarSpecification, #[case] expected: &str) {
447        assert_eq!(bar_spec_to_tardis_trade_bar_string(&bar_spec), expected);
448    }
449}