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