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