nautilus_tardis/
parse.rs

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