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::{Exchange, InstrumentType, OptionType};
28
29pub fn deserialize_uppercase<'de, D>(deserializer: D) -> Result<Ustr, D::Error>
30where
31    D: Deserializer<'de>,
32{
33    String::deserialize(deserializer).map(|s| Ustr::from(&s.to_uppercase()))
34}
35
36pub fn deserialize_trade_id<'de, D>(deserializer: D) -> Result<String, D::Error>
37where
38    D: serde::Deserializer<'de>,
39{
40    let s = String::deserialize(deserializer)?;
41
42    if s.is_empty() {
43        return Ok(Uuid::new_v4().to_string());
44    }
45
46    Ok(s)
47}
48
49#[must_use]
50#[inline]
51pub fn normalize_symbol_str(
52    symbol: Ustr,
53    exchange: &Exchange,
54    instrument_type: &InstrumentType,
55    is_inverse: Option<bool>,
56) -> Ustr {
57    match exchange {
58        Exchange::Binance
59        | Exchange::BinanceFutures
60        | Exchange::BinanceUs
61        | Exchange::BinanceDex
62        | Exchange::BinanceJersey
63            if instrument_type == &InstrumentType::Perpetual =>
64        {
65            append_suffix(symbol, "-PERP")
66        }
67
68        Exchange::Bybit | Exchange::BybitSpot | Exchange::BybitOptions => match instrument_type {
69            InstrumentType::Spot => append_suffix(symbol, "-SPOT"),
70            InstrumentType::Perpetual if !is_inverse.unwrap_or(false) => {
71                append_suffix(symbol, "-LINEAR")
72            }
73            InstrumentType::Future if !is_inverse.unwrap_or(false) => {
74                append_suffix(symbol, "-LINEAR")
75            }
76            InstrumentType::Perpetual if is_inverse == Some(true) => {
77                append_suffix(symbol, "-INVERSE")
78            }
79            InstrumentType::Future if is_inverse == Some(true) => append_suffix(symbol, "-INVERSE"),
80            InstrumentType::Option => append_suffix(symbol, "-OPTION"),
81            _ => symbol,
82        },
83
84        Exchange::Dydx if instrument_type == &InstrumentType::Perpetual => {
85            append_suffix(symbol, "-PERP")
86        }
87
88        Exchange::GateIoFutures if instrument_type == &InstrumentType::Perpetual => {
89            append_suffix(symbol, "-PERP")
90        }
91
92        _ => symbol,
93    }
94}
95
96fn append_suffix(symbol: Ustr, suffix: &str) -> Ustr {
97    let mut symbol = symbol.to_string();
98    symbol.push_str(suffix);
99    Ustr::from(&symbol)
100}
101
102/// Parses a Nautilus instrument ID from the given Tardis `exchange` and `symbol` values.
103#[must_use]
104pub fn parse_instrument_id(exchange: &Exchange, symbol: Ustr) -> InstrumentId {
105    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), exchange.as_venue())
106}
107
108/// Parses a Nautilus instrument ID with a normalized symbol from the given Tardis `exchange` and `symbol` values.
109#[must_use]
110pub fn normalize_instrument_id(
111    exchange: &Exchange,
112    symbol: Ustr,
113    instrument_type: &InstrumentType,
114    is_inverse: Option<bool>,
115) -> InstrumentId {
116    let symbol = normalize_symbol_str(symbol, exchange, instrument_type, is_inverse);
117    parse_instrument_id(exchange, symbol)
118}
119
120/// Normalizes the given amount by truncating it to the specified decimal precision.
121#[must_use]
122pub fn normalize_amount(amount: f64, precision: u8) -> f64 {
123    let factor = 10_f64.powi(i32::from(precision));
124    (amount * factor).trunc() / factor
125}
126
127/// Parses a Nautilus price from the given `value`.
128///
129/// Values outside the representable range are capped to min/max price.
130#[must_use]
131pub fn parse_price(value: f64, precision: u8) -> Price {
132    match value {
133        v if (PRICE_MIN..=PRICE_MAX).contains(&v) => Price::new(value, precision),
134        v if v < PRICE_MIN => Price::min(precision),
135        _ => Price::max(precision),
136    }
137}
138
139/// Parses a Nautilus order side from the given Tardis string `value`.
140#[must_use]
141pub fn parse_order_side(value: &str) -> OrderSide {
142    match value {
143        "bid" => OrderSide::Buy,
144        "ask" => OrderSide::Sell,
145        _ => OrderSide::NoOrderSide,
146    }
147}
148
149/// Parses a Nautilus aggressor side from the given Tardis string `value`.
150#[must_use]
151pub fn parse_aggressor_side(value: &str) -> AggressorSide {
152    match value {
153        "buy" => AggressorSide::Buyer,
154        "sell" => AggressorSide::Seller,
155        _ => AggressorSide::NoAggressor,
156    }
157}
158
159/// Parses a Nautilus option kind from the given Tardis enum `value`.
160#[must_use]
161pub const fn parse_option_kind(value: OptionType) -> OptionKind {
162    match value {
163        OptionType::Call => OptionKind::Call,
164        OptionType::Put => OptionKind::Put,
165    }
166}
167
168/// Parses a UNIX nanoseconds timestamp from the given Tardis microseconds `value_us`.
169#[must_use]
170pub fn parse_timestamp(value_us: u64) -> UnixNanos {
171    UnixNanos::from(value_us * NANOSECONDS_IN_MICROSECOND)
172}
173
174/// Parses a Nautilus book action inferred from the given Tardis values.
175#[must_use]
176pub fn parse_book_action(is_snapshot: bool, amount: f64) -> BookAction {
177    if amount == 0.0 {
178        BookAction::Delete
179    } else if is_snapshot {
180        BookAction::Add
181    } else {
182        BookAction::Update
183    }
184}
185
186/// Parses a Nautilus bar specification from the given Tardis string `value`.
187///
188/// The [`PriceType`] is always `LAST` for Tardis trade bars.
189#[must_use]
190pub fn parse_bar_spec(value: &str) -> BarSpecification {
191    let parts: Vec<&str> = value.split('_').collect();
192    let last_part = parts.last().expect("Invalid bar spec");
193    let split_idx = last_part
194        .chars()
195        .position(|c| !c.is_ascii_digit())
196        .expect("Invalid bar spec");
197
198    let (step_str, suffix) = last_part.split_at(split_idx);
199    let step: usize = step_str.parse().expect("Invalid step");
200
201    let aggregation = match suffix {
202        "ms" => BarAggregation::Millisecond,
203        "s" => BarAggregation::Second,
204        "m" => BarAggregation::Minute,
205        "ticks" => BarAggregation::Tick,
206        "vol" => BarAggregation::Volume,
207        _ => panic!("Unsupported bar aggregation type"),
208    };
209
210    BarSpecification::new(step, aggregation, PriceType::Last)
211}
212
213/// Converts a Nautilus `BarSpecification` to the Tardis trade bar string convention.
214#[must_use]
215pub fn bar_spec_to_tardis_trade_bar_string(bar_spec: &BarSpecification) -> String {
216    let suffix = match bar_spec.aggregation {
217        BarAggregation::Millisecond => "ms",
218        BarAggregation::Second => "s",
219        BarAggregation::Minute => "m",
220        BarAggregation::Tick => "ticks",
221        BarAggregation::Volume => "vol",
222        _ => panic!("Unsupported bar aggregation type {}", bar_spec.aggregation),
223    };
224    format!("trade_bar_{}{}", bar_spec.step, suffix)
225}
226
227////////////////////////////////////////////////////////////////////////////////
228// Tests
229////////////////////////////////////////////////////////////////////////////////
230#[cfg(test)]
231mod tests {
232    use std::str::FromStr;
233
234    use nautilus_model::enums::AggressorSide;
235    use rstest::rstest;
236
237    use super::*;
238
239    #[rstest]
240    #[case(Exchange::Binance, "ETHUSDT", "ETHUSDT.BINANCE")]
241    #[case(Exchange::Bitmex, "XBTUSD", "XBTUSD.BITMEX")]
242    #[case(Exchange::Bybit, "BTCUSDT", "BTCUSDT.BYBIT")]
243    #[case(Exchange::OkexFutures, "BTC-USD-200313", "BTC-USD-200313.OKEX")]
244    #[case(Exchange::HuobiDmLinearSwap, "FOO-BAR", "FOO-BAR.HUOBI")]
245    fn test_parse_instrument_id(
246        #[case] exchange: Exchange,
247        #[case] symbol: Ustr,
248        #[case] expected: &str,
249    ) {
250        let instrument_id = parse_instrument_id(&exchange, symbol);
251        let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
252        assert_eq!(instrument_id, expected_instrument_id);
253    }
254
255    #[rstest]
256    #[case(
257        Exchange::Binance,
258        "SOLUSDT",
259        InstrumentType::Spot,
260        None,
261        "SOLUSDT.BINANCE"
262    )]
263    #[case(
264        Exchange::BinanceFutures,
265        "SOLUSDT",
266        InstrumentType::Perpetual,
267        None,
268        "SOLUSDT-PERP.BINANCE"
269    )]
270    #[case(
271        Exchange::Bybit,
272        "BTCUSDT",
273        InstrumentType::Spot,
274        None,
275        "BTCUSDT-SPOT.BYBIT"
276    )]
277    #[case(
278        Exchange::Bybit,
279        "BTCUSDT",
280        InstrumentType::Perpetual,
281        None,
282        "BTCUSDT-LINEAR.BYBIT"
283    )]
284    #[case(
285        Exchange::Bybit,
286        "BTCUSDT",
287        InstrumentType::Perpetual,
288        Some(true),
289        "BTCUSDT-INVERSE.BYBIT"
290    )]
291    #[case(
292        Exchange::Dydx,
293        "BTC-USD",
294        InstrumentType::Perpetual,
295        None,
296        "BTC-USD-PERP.DYDX"
297    )]
298    fn test_normalize_instrument_id(
299        #[case] exchange: Exchange,
300        #[case] symbol: Ustr,
301        #[case] instrument_type: InstrumentType,
302        #[case] is_inverse: Option<bool>,
303        #[case] expected: &str,
304    ) {
305        let instrument_id =
306            normalize_instrument_id(&exchange, symbol, &instrument_type, is_inverse);
307        let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
308        assert_eq!(instrument_id, expected_instrument_id);
309    }
310
311    #[rstest]
312    #[case(0.00001, 4, 0.0)]
313    #[case(1.2345, 3, 1.234)]
314    #[case(1.2345, 2, 1.23)]
315    #[case(-1.2345, 3, -1.234)]
316    #[case(123.456, 0, 123.0)]
317    fn test_normalize_amount(#[case] amount: f64, #[case] precision: u8, #[case] expected: f64) {
318        let result = normalize_amount(amount, precision);
319        assert_eq!(result, expected);
320    }
321
322    #[rstest]
323    #[case("bid", OrderSide::Buy)]
324    #[case("ask", OrderSide::Sell)]
325    #[case("unknown", OrderSide::NoOrderSide)]
326    #[case("", OrderSide::NoOrderSide)]
327    #[case("random", OrderSide::NoOrderSide)]
328    fn test_parse_order_side(#[case] input: &str, #[case] expected: OrderSide) {
329        assert_eq!(parse_order_side(input), expected);
330    }
331
332    #[rstest]
333    #[case("buy", AggressorSide::Buyer)]
334    #[case("sell", AggressorSide::Seller)]
335    #[case("unknown", AggressorSide::NoAggressor)]
336    #[case("", AggressorSide::NoAggressor)]
337    #[case("random", AggressorSide::NoAggressor)]
338    fn test_parse_aggressor_side(#[case] input: &str, #[case] expected: AggressorSide) {
339        assert_eq!(parse_aggressor_side(input), expected);
340    }
341
342    #[rstest]
343    fn test_parse_timestamp() {
344        let input_timestamp: u64 = 1583020803145000;
345        let expected_nanos: UnixNanos =
346            UnixNanos::from(input_timestamp * NANOSECONDS_IN_MICROSECOND);
347
348        assert_eq!(parse_timestamp(input_timestamp), expected_nanos);
349    }
350
351    #[rstest]
352    #[case(true, 10.0, BookAction::Add)]
353    #[case(false, 0.0, BookAction::Delete)]
354    #[case(false, 10.0, BookAction::Update)]
355    fn test_parse_book_action(
356        #[case] is_snapshot: bool,
357        #[case] amount: f64,
358        #[case] expected: BookAction,
359    ) {
360        assert_eq!(parse_book_action(is_snapshot, amount), expected);
361    }
362
363    #[rstest]
364    #[case("trade_bar_10ms", 10, BarAggregation::Millisecond)]
365    #[case("trade_bar_5m", 5, BarAggregation::Minute)]
366    #[case("trade_bar_100ticks", 100, BarAggregation::Tick)]
367    #[case("trade_bar_100000vol", 100000, BarAggregation::Volume)]
368    fn test_parse_bar_spec(
369        #[case] value: &str,
370        #[case] expected_step: usize,
371        #[case] expected_aggregation: BarAggregation,
372    ) {
373        let spec = parse_bar_spec(value);
374        assert_eq!(spec.step.get(), expected_step);
375        assert_eq!(spec.aggregation, expected_aggregation);
376        assert_eq!(spec.price_type, PriceType::Last);
377    }
378
379    #[rstest]
380    #[case("trade_bar_10unknown")]
381    #[should_panic(expected = "Unsupported bar aggregation type")]
382    fn test_parse_bar_spec_invalid_suffix(#[case] value: &str) {
383        let _ = parse_bar_spec(value);
384    }
385
386    #[rstest]
387    #[case("")]
388    #[should_panic(expected = "Invalid bar spec")]
389    fn test_parse_bar_spec_empty(#[case] value: &str) {
390        let _ = parse_bar_spec(value);
391    }
392
393    #[rstest]
394    #[case("trade_bar_notanumberms")]
395    #[should_panic(expected = "Invalid step")]
396    fn test_parse_bar_spec_invalid_step(#[case] value: &str) {
397        let _ = parse_bar_spec(value);
398    }
399
400    #[rstest]
401    #[case(
402        BarSpecification::new(10, BarAggregation::Millisecond, PriceType::Last),
403        "trade_bar_10ms"
404    )]
405    #[case(
406        BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
407        "trade_bar_5m"
408    )]
409    #[case(
410        BarSpecification::new(100, BarAggregation::Tick, PriceType::Last),
411        "trade_bar_100ticks"
412    )]
413    #[case(
414        BarSpecification::new(100_000, BarAggregation::Volume, PriceType::Last),
415        "trade_bar_100000vol"
416    )]
417    fn test_to_tardis_string(#[case] bar_spec: BarSpecification, #[case] expected: &str) {
418        assert_eq!(bar_spec_to_tardis_trade_bar_string(&bar_spec), expected);
419    }
420}