Skip to main content

nautilus_tardis/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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 anyhow::Context;
17use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_MICROSECOND};
18use nautilus_model::{
19    data::BarSpecification,
20    enums::{AggressorSide, BarAggregation, BookAction, OptionKind, OrderSide, PriceType},
21    identifiers::{InstrumentId, Symbol, TradeId},
22    types::{PRICE_MAX, PRICE_MIN, Price, fixed::check_fixed_precision},
23};
24use rust_decimal::Decimal;
25use serde::{Deserialize, Deserializer, de};
26use ustr::Ustr;
27
28use super::enums::{TardisExchange, TardisInstrumentType, TardisOptionType};
29
30pub(crate) fn validate_non_zero_amount(value: f64, precision: u8) -> anyhow::Result<()> {
31    anyhow::ensure!(value != 0.0, "value was zero");
32    check_fixed_precision(precision)?;
33    let rounded_value =
34        (value * 10.0_f64.powi(i32::from(precision))).round() / 10.0_f64.powi(i32::from(precision));
35    anyhow::ensure!(
36        rounded_value != 0.0,
37        "value {value} was zero after rounding to precision {precision}"
38    );
39    Ok(())
40}
41
42// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
43const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
44const FNV_PRIME: u64 = 0x0100_0000_01b3;
45
46/// Deserialize a string and convert to uppercase `Ustr`.
47///
48/// # Errors
49///
50/// Returns an error if the input is not a valid string.
51pub(crate) fn deserialize_uppercase<'de, D>(deserializer: D) -> Result<Ustr, D::Error>
52where
53    D: Deserializer<'de>,
54{
55    String::deserialize(deserializer).map(|s| Ustr::from(&s.to_uppercase()))
56}
57
58/// Deserializes an `f64` from a JSON number or numeric string.
59///
60/// # Errors
61///
62/// Returns an error if the input is not numeric or if the string cannot be parsed as `f64`.
63pub(crate) fn deserialize_f64_or_string<'de, D>(deserializer: D) -> Result<f64, D::Error>
64where
65    D: Deserializer<'de>,
66{
67    struct F64OrString;
68    impl<'de> de::Visitor<'de> for F64OrString {
69        type Value = f64;
70        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
71            f.write_str("f64 or string-encoded f64")
72        }
73        fn visit_f64<E: de::Error>(self, v: f64) -> Result<f64, E> {
74            Ok(v)
75        }
76        fn visit_i64<E: de::Error>(self, v: i64) -> Result<f64, E> {
77            Ok(v as f64)
78        }
79        fn visit_u64<E: de::Error>(self, v: u64) -> Result<f64, E> {
80            Ok(v as f64)
81        }
82        fn visit_str<E: de::Error>(self, v: &str) -> Result<f64, E> {
83            v.parse().map_err(de::Error::custom)
84        }
85        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<f64, M::Error> {
86            serde_json::Number::deserialize(de::value::MapAccessDeserializer::new(map))?
87                .as_f64()
88                .ok_or_else(|| de::Error::custom("number is outside f64 bounds"))
89        }
90    }
91    deserializer.deserialize_any(F64OrString)
92}
93
94/// Deserializes an optional `f64` from null, a JSON number, or a numeric string.
95///
96/// # Errors
97///
98/// Returns an error if a non-null input is not numeric or if the string cannot be parsed as `f64`.
99pub(crate) fn deserialize_opt_f64_or_string<'de, D>(
100    deserializer: D,
101) -> Result<Option<f64>, D::Error>
102where
103    D: Deserializer<'de>,
104{
105    struct OptF64OrString;
106    impl<'de> de::Visitor<'de> for OptF64OrString {
107        type Value = Option<f64>;
108        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
109            f.write_str("null, f64, or string-encoded f64")
110        }
111        fn visit_none<E: de::Error>(self) -> Result<Option<f64>, E> {
112            Ok(None)
113        }
114        fn visit_unit<E: de::Error>(self) -> Result<Option<f64>, E> {
115            Ok(None)
116        }
117        fn visit_f64<E: de::Error>(self, v: f64) -> Result<Option<f64>, E> {
118            Ok(Some(v))
119        }
120        fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<f64>, E> {
121            Ok(Some(v as f64))
122        }
123        fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<f64>, E> {
124            Ok(Some(v as f64))
125        }
126        fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<f64>, E> {
127            v.parse().map(Some).map_err(de::Error::custom)
128        }
129        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Option<f64>, M::Error> {
130            deserialize_f64_or_string(de::value::MapAccessDeserializer::new(map)).map(Some)
131        }
132    }
133    deserializer.deserialize_any(OptF64OrString)
134}
135
136/// Derives a deterministic [`TradeId`] from trade fields.
137///
138/// Tardis records do not always carry a venue-provided trade ID (some venues
139/// publish empty strings or omit the field entirely). This hash combines the
140/// symbol, timestamp, price, amount, and side so replayed data yields the same
141/// identifier across runs. FNV-1a is stable across architectures and crate
142/// versions; the 0x1f delimiter keeps variable-length fields from colliding.
143///
144/// `price` and `amount` are plain decimal text without trailing zeros, such as the `Display`
145/// output of a normalized `Decimal` or of an `f64`, so equal values derive the same identifier
146/// from machine messages and CSV records.
147#[must_use]
148pub fn derive_trade_id(
149    symbol: Ustr,
150    ts_event_ns: u64,
151    price: &str,
152    amount: &str,
153    side: &str,
154) -> TradeId {
155    let mut hash: u64 = FNV_OFFSET_BASIS;
156
157    for bytes in [
158        symbol.as_bytes(),
159        b"\x1f",
160        &ts_event_ns.to_le_bytes(),
161        b"\x1f",
162        price.as_bytes(),
163        b"\x1f",
164        amount.as_bytes(),
165        b"\x1f",
166        side.as_bytes(),
167    ] {
168        for &byte in bytes {
169            hash ^= u64::from(byte);
170            hash = hash.wrapping_mul(FNV_PRIME);
171        }
172    }
173    TradeId::new(format!("{hash:016x}"))
174}
175
176#[must_use]
177#[inline]
178pub fn normalize_symbol_str(
179    symbol: Ustr,
180    exchange: &TardisExchange,
181    instrument_type: &TardisInstrumentType,
182    is_inverse: Option<bool>,
183) -> Ustr {
184    match exchange {
185        TardisExchange::Binance
186        | TardisExchange::BinanceFutures
187        | TardisExchange::BinanceUs
188        | TardisExchange::BinanceDex
189        | TardisExchange::BinanceJersey
190            if instrument_type == &TardisInstrumentType::Perpetual =>
191        {
192            append_suffix(symbol, "-PERP")
193        }
194
195        TardisExchange::Bybit | TardisExchange::BybitSpot | TardisExchange::BybitOptions => {
196            match instrument_type {
197                TardisInstrumentType::Spot => append_suffix(symbol, "-SPOT"),
198                TardisInstrumentType::Perpetual if !is_inverse.unwrap_or(false) => {
199                    append_suffix(symbol, "-LINEAR")
200                }
201                TardisInstrumentType::Future if !is_inverse.unwrap_or(false) => {
202                    append_suffix(symbol, "-LINEAR")
203                }
204                TardisInstrumentType::Perpetual if is_inverse == Some(true) => {
205                    append_suffix(symbol, "-INVERSE")
206                }
207                TardisInstrumentType::Future if is_inverse == Some(true) => {
208                    append_suffix(symbol, "-INVERSE")
209                }
210                TardisInstrumentType::Option => append_suffix(symbol, "-OPTION"),
211                _ => symbol,
212            }
213        }
214
215        TardisExchange::Dydx if instrument_type == &TardisInstrumentType::Perpetual => {
216            append_suffix(symbol, "-PERP")
217        }
218
219        TardisExchange::GateIoFutures if instrument_type == &TardisInstrumentType::Perpetual => {
220            append_suffix(symbol, "-PERP")
221        }
222
223        TardisExchange::MexcFutures if instrument_type == &TardisInstrumentType::Perpetual => {
224            append_suffix(symbol, "-PERP")
225        }
226
227        _ => symbol,
228    }
229}
230
231fn append_suffix(symbol: Ustr, suffix: &str) -> Ustr {
232    let mut symbol = symbol.to_string();
233    symbol.push_str(suffix);
234    Ustr::from(&symbol)
235}
236
237/// Parses a Nautilus instrument ID from the given Tardis `exchange` and `symbol` values.
238#[must_use]
239pub fn parse_instrument_id(exchange: &TardisExchange, symbol: Ustr) -> InstrumentId {
240    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), exchange.as_venue())
241}
242
243/// Parses a Nautilus instrument ID with a normalized symbol from the given Tardis `exchange` and `symbol` values.
244#[must_use]
245pub fn normalize_instrument_id(
246    exchange: &TardisExchange,
247    symbol: Ustr,
248    instrument_type: &TardisInstrumentType,
249    is_inverse: Option<bool>,
250) -> InstrumentId {
251    let symbol = normalize_symbol_str(symbol, exchange, instrument_type, is_inverse);
252    parse_instrument_id(exchange, symbol)
253}
254
255/// Normalizes the given amount by truncating it to the specified decimal precision.
256///
257/// Amounts within 1e-9 of a step at `precision` (in units of that step) snap to the step
258/// instead, so upstream floating-point artifacts such as `2.9999999999999996` keep their
259/// intended size.
260///
261/// # Panics
262///
263/// Panics if `precision` exceeds 19, beyond which the tolerance is not representable.
264#[must_use]
265pub fn normalize_amount(amount: Decimal, precision: u8) -> Decimal {
266    let precision = u32::from(precision);
267    let rounded = amount.round_dp(precision);
268
269    if (rounded - amount).abs() < Decimal::new(1, 9 + precision) {
270        rounded
271    } else {
272        amount.trunc_with_scale(precision)
273    }
274}
275
276/// Parses a Nautilus price from the given `value`.
277///
278/// Values outside the representable range are capped to min/max price.
279#[must_use]
280pub fn parse_price(value: f64, precision: u8) -> Price {
281    match value {
282        v if (PRICE_MIN..=PRICE_MAX).contains(&v) => Price::new(value, precision),
283        v if v < PRICE_MIN => Price::min(precision),
284        _ => Price::max(precision),
285    }
286}
287
288/// Parses a Nautilus order side from the given Tardis string `value`.
289#[must_use]
290pub fn parse_order_side(value: &str) -> Option<OrderSide> {
291    match value {
292        "bid" => Some(OrderSide::Buy),
293        "ask" => Some(OrderSide::Sell),
294        _ => None,
295    }
296}
297
298/// Parses a Nautilus aggressor side from the given Tardis string `value`.
299#[must_use]
300pub fn parse_aggressor_side(value: &str) -> AggressorSide {
301    match value {
302        "buy" => AggressorSide::Buy,
303        "sell" => AggressorSide::Sell,
304        _ => AggressorSide::NoAggressor,
305    }
306}
307
308/// Parses a Nautilus option kind from the given Tardis enum `value`.
309#[must_use]
310pub const fn parse_option_kind(value: TardisOptionType) -> OptionKind {
311    match value {
312        TardisOptionType::Call => OptionKind::Call,
313        TardisOptionType::Put => OptionKind::Put,
314    }
315}
316
317/// Parses a UNIX nanoseconds timestamp from the given Tardis microseconds `value_us`.
318#[must_use]
319pub fn parse_timestamp(value_us: u64) -> UnixNanos {
320    value_us
321        .checked_mul(NANOSECONDS_IN_MICROSECOND)
322        .map_or_else(|| {
323            log::error!("Timestamp overflow: {value_us} microseconds exceeds maximum representable value");
324            UnixNanos::max()
325        }, UnixNanos::from)
326}
327
328/// Parses a Nautilus book action inferred from the given Tardis values.
329#[must_use]
330pub fn parse_book_action(is_snapshot: bool, amount: f64) -> BookAction {
331    if amount == 0.0 {
332        BookAction::Delete
333    } else if is_snapshot {
334        BookAction::Add
335    } else {
336        BookAction::Update
337    }
338}
339
340/// Parses a Nautilus bar specification from the given Tardis string `value`.
341///
342/// The [`PriceType`] is always `LAST` for Tardis trade bars.
343///
344/// # Errors
345///
346/// Returns an error if the specification format is invalid or if the aggregation suffix is unsupported.
347pub fn parse_bar_spec(value: &str) -> anyhow::Result<BarSpecification> {
348    let parts: Vec<&str> = value.split('_').collect();
349    let last_part = parts
350        .last()
351        .ok_or_else(|| anyhow::anyhow!("Invalid bar spec: empty string"))?;
352    let split_idx = last_part
353        .chars()
354        .position(|c| !c.is_ascii_digit())
355        .ok_or_else(|| anyhow::anyhow!("Invalid bar spec: no aggregation suffix in '{value}'"))?;
356
357    let (step_str, suffix) = last_part.split_at(split_idx);
358    let step: usize = step_str
359        .parse()
360        .map_err(|e| anyhow::anyhow!("Invalid step in bar spec '{value}': {e}"))?;
361
362    let aggregation = match suffix {
363        "ms" => BarAggregation::Millisecond,
364        "s" => BarAggregation::Second,
365        "m" => BarAggregation::Minute,
366        "ticks" => BarAggregation::Tick,
367        "vol" => BarAggregation::Volume,
368        _ => anyhow::bail!("Unsupported bar aggregation type: '{suffix}'"),
369    };
370
371    parse_canonical_bar_spec(step, aggregation)
372        .with_context(|| format!("Invalid bar spec '{value}'"))
373}
374
375fn parse_canonical_bar_spec(
376    step: usize,
377    aggregation: BarAggregation,
378) -> anyhow::Result<BarSpecification> {
379    match aggregation {
380        BarAggregation::Millisecond if step.is_multiple_of(1000) => {
381            parse_canonical_bar_spec(step / 1000, BarAggregation::Second)
382        }
383        BarAggregation::Second if step.is_multiple_of(60) => {
384            parse_canonical_bar_spec(step / 60, BarAggregation::Minute)
385        }
386        BarAggregation::Minute if step.is_multiple_of(60) => {
387            parse_canonical_bar_spec(step / 60, BarAggregation::Hour)
388        }
389        BarAggregation::Hour if step.is_multiple_of(24) => {
390            parse_canonical_bar_spec(step / 24, BarAggregation::Day)
391        }
392        _ => BarSpecification::new_checked(step, aggregation, PriceType::Last),
393    }
394}
395
396/// Converts a Nautilus `BarSpecification` to the Tardis trade bar string convention.
397///
398/// # Errors
399///
400/// Returns an error if the bar aggregation kind is unsupported.
401pub fn bar_spec_to_tardis_trade_bar_string(bar_spec: &BarSpecification) -> anyhow::Result<String> {
402    match bar_spec.aggregation {
403        BarAggregation::Hour => {
404            let minutes = bar_spec
405                .step
406                .get()
407                .checked_mul(60)
408                .context("bar specification step overflow")?;
409            return Ok(format!("trade_bar_{minutes}m"));
410        }
411        BarAggregation::Day => {
412            let minutes = bar_spec
413                .step
414                .get()
415                .checked_mul(1440)
416                .context("bar specification step overflow")?;
417            return Ok(format!("trade_bar_{minutes}m"));
418        }
419        _ => {}
420    }
421
422    let suffix = match bar_spec.aggregation {
423        BarAggregation::Millisecond => "ms",
424        BarAggregation::Second => "s",
425        BarAggregation::Minute => "m",
426        BarAggregation::Tick => "ticks",
427        BarAggregation::Volume => "vol",
428        _ => anyhow::bail!("Unsupported bar aggregation type: {}", bar_spec.aggregation),
429    };
430    Ok(format!("trade_bar_{}{}", bar_spec.step, suffix))
431}
432
433#[cfg(test)]
434mod tests {
435    use std::str::FromStr;
436
437    use rstest::rstest;
438    use rust_decimal_macros::dec;
439
440    use super::*;
441
442    #[rstest]
443    #[case("1e400")]
444    #[case("-1e400")]
445    fn test_deserialize_numeric_out_of_range(#[case] input: &str) {
446        let required = deserialize_f64_or_string(&mut serde_json::Deserializer::from_str(input));
447        let optional =
448            deserialize_opt_f64_or_string(&mut serde_json::Deserializer::from_str(input));
449
450        assert!(required.is_err());
451        assert!(optional.is_err());
452    }
453
454    #[rstest]
455    #[case(serde_json::json!(1.25), 1.25)]
456    #[case(serde_json::json!(25), 25.0)]
457    #[case(serde_json::json!(-3), -3.0)]
458    #[case(serde_json::json!("2.5e2"), 250.0)]
459    fn test_deserialize_numeric_fields(#[case] input: serde_json::Value, #[case] expected: f64) {
460        let json = input.to_string();
461        let required = deserialize_f64_or_string(&mut serde_json::Deserializer::from_str(&json));
462        let optional =
463            deserialize_opt_f64_or_string(&mut serde_json::Deserializer::from_str(&json));
464
465        assert_eq!(required.unwrap(), expected);
466        assert_eq!(optional.unwrap(), Some(expected));
467    }
468
469    #[rstest]
470    fn test_deserialize_numeric_null() {
471        let required = deserialize_f64_or_string(serde_json::Value::Null);
472        let optional = deserialize_opt_f64_or_string(serde_json::Value::Null);
473
474        assert!(required.is_err());
475        assert_eq!(optional.unwrap(), None);
476    }
477
478    #[rstest]
479    #[case(serde_json::json!({"number": "1.5"}))]
480    #[case(serde_json::json!(true))]
481    #[case(serde_json::json!("invalid"))]
482    fn test_deserialize_numeric_invalid(#[case] input: serde_json::Value) {
483        assert!(deserialize_f64_or_string(input.clone()).is_err());
484        assert!(deserialize_opt_f64_or_string(input).is_err());
485    }
486
487    #[rstest]
488    #[case(0.0, 0, false)]
489    #[case(0.0004, 3, false)]
490    #[case(0.0005, 3, true)]
491    #[case(123.456, 3, true)]
492    #[case(1.0, 255, false)]
493    fn test_validate_non_zero_amount(
494        #[case] amount: f64,
495        #[case] precision: u8,
496        #[case] expected: bool,
497    ) {
498        assert_eq!(
499            validate_non_zero_amount(amount, precision).is_ok(),
500            expected
501        );
502    }
503
504    #[rstest]
505    #[case(TardisExchange::Binance, "ETHUSDT", "ETHUSDT.BINANCE")]
506    #[case(TardisExchange::Bitmex, "XBTUSD", "XBTUSD.BITMEX")]
507    #[case(TardisExchange::Bybit, "BTCUSDT", "BTCUSDT.BYBIT")]
508    #[case(TardisExchange::OkexFutures, "BTC-USD-200313", "BTC-USD-200313.OKEX")]
509    #[case(TardisExchange::HuobiDmLinearSwap, "FOO-BAR", "FOO-BAR.HUOBI")]
510    #[case(TardisExchange::Mexc, "BTCUSDT", "BTCUSDT.MEXC")]
511    fn test_parse_instrument_id(
512        #[case] exchange: TardisExchange,
513        #[case] symbol: Ustr,
514        #[case] expected: &str,
515    ) {
516        let instrument_id = parse_instrument_id(&exchange, symbol);
517        let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
518        assert_eq!(instrument_id, expected_instrument_id);
519    }
520
521    #[rstest]
522    #[case(
523        TardisExchange::Binance,
524        "SOLUSDT",
525        TardisInstrumentType::Spot,
526        None,
527        "SOLUSDT.BINANCE"
528    )]
529    #[case(
530        TardisExchange::BinanceFutures,
531        "SOLUSDT",
532        TardisInstrumentType::Perpetual,
533        None,
534        "SOLUSDT-PERP.BINANCE"
535    )]
536    #[case(
537        TardisExchange::Bybit,
538        "BTCUSDT",
539        TardisInstrumentType::Spot,
540        None,
541        "BTCUSDT-SPOT.BYBIT"
542    )]
543    #[case(
544        TardisExchange::Bybit,
545        "BTCUSDT",
546        TardisInstrumentType::Perpetual,
547        None,
548        "BTCUSDT-LINEAR.BYBIT"
549    )]
550    #[case(
551        TardisExchange::Bybit,
552        "BTCUSDT",
553        TardisInstrumentType::Perpetual,
554        Some(true),
555        "BTCUSDT-INVERSE.BYBIT"
556    )]
557    #[case(
558        TardisExchange::Dydx,
559        "BTC-USD",
560        TardisInstrumentType::Perpetual,
561        None,
562        "BTC-USD-PERP.DYDX"
563    )]
564    #[case(
565        TardisExchange::MexcFutures,
566        "BTC_USDT",
567        TardisInstrumentType::Perpetual,
568        None,
569        "BTC_USDT-PERP.MEXC"
570    )]
571    fn test_normalize_instrument_id(
572        #[case] exchange: TardisExchange,
573        #[case] symbol: Ustr,
574        #[case] instrument_type: TardisInstrumentType,
575        #[case] is_inverse: Option<bool>,
576        #[case] expected: &str,
577    ) {
578        let instrument_id =
579            normalize_instrument_id(&exchange, symbol, &instrument_type, is_inverse);
580        let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
581        assert_eq!(instrument_id, expected_instrument_id);
582    }
583
584    #[rstest]
585    #[case(dec!(0.00001), 4, dec!(0))]
586    #[case(dec!(1.2345), 3, dec!(1.234))]
587    #[case(dec!(1.2345), 2, dec!(1.23))]
588    #[case(dec!(-1.2345), 3, dec!(-1.234))]
589    #[case(dec!(123.456), 0, dec!(123))]
590    #[case(dec!(0.1), 1, dec!(0.1))]
591    #[case(dec!(1.123456789), 9, dec!(1.123456789))]
592    #[case(dec!(0), 8, dec!(0))]
593    #[case(dec!(-0.1), 1, dec!(-0.1))]
594    #[case(dec!(2.9999999999999996), 0, dec!(3))]
595    #[case(dec!(0.29999999999999998), 1, dec!(0.3))]
596    #[case(dec!(2.999999998), 0, dec!(2))]
597    #[case(dec!(100000000.123456789), 8, dec!(100000000.12345678))]
598    fn test_normalize_amount(
599        #[case] amount: Decimal,
600        #[case] precision: u8,
601        #[case] expected: Decimal,
602    ) {
603        let result = normalize_amount(amount, precision);
604        assert_eq!(result, expected);
605    }
606
607    #[rstest]
608    #[case("bid", Some(OrderSide::Buy))]
609    #[case("ask", Some(OrderSide::Sell))]
610    #[case("unknown", None)]
611    #[case("", None)]
612    #[case("random", None)]
613    fn test_parse_order_side(#[case] input: &str, #[case] expected: Option<OrderSide>) {
614        assert_eq!(parse_order_side(input), expected);
615    }
616
617    #[rstest]
618    #[case("buy", AggressorSide::Buy)]
619    #[case("sell", AggressorSide::Sell)]
620    #[case("unknown", AggressorSide::NoAggressor)]
621    #[case("", AggressorSide::NoAggressor)]
622    #[case("random", AggressorSide::NoAggressor)]
623    fn test_parse_aggressor_side(#[case] input: &str, #[case] expected: AggressorSide) {
624        assert_eq!(parse_aggressor_side(input), expected);
625    }
626
627    #[rstest]
628    fn test_parse_timestamp() {
629        let input_timestamp: u64 = 1583020803145000;
630        let expected_nanos: UnixNanos =
631            UnixNanos::from(input_timestamp * NANOSECONDS_IN_MICROSECOND);
632
633        assert_eq!(parse_timestamp(input_timestamp), expected_nanos);
634    }
635
636    #[rstest]
637    #[case(true, 10.0, BookAction::Add)]
638    #[case(false, 0.0, BookAction::Delete)]
639    #[case(false, 10.0, BookAction::Update)]
640    fn test_parse_book_action(
641        #[case] is_snapshot: bool,
642        #[case] amount: f64,
643        #[case] expected: BookAction,
644    ) {
645        assert_eq!(parse_book_action(is_snapshot, amount), expected);
646    }
647
648    #[rstest]
649    #[case("trade_bar_10ms", 10, BarAggregation::Millisecond)]
650    #[case("trade_bar_10000ms", 10, BarAggregation::Second)]
651    #[case("trade_bar_5m", 5, BarAggregation::Minute)]
652    #[case("trade_bar_60m", 1, BarAggregation::Hour)]
653    #[case("trade_bar_100ticks", 100, BarAggregation::Tick)]
654    #[case("trade_bar_100000vol", 100000, BarAggregation::Volume)]
655    fn test_parse_bar_spec(
656        #[case] value: &str,
657        #[case] expected_step: usize,
658        #[case] expected_aggregation: BarAggregation,
659    ) {
660        let spec = parse_bar_spec(value).unwrap();
661        assert_eq!(spec.step.get(), expected_step);
662        assert_eq!(spec.aggregation, expected_aggregation);
663        assert_eq!(spec.price_type, PriceType::Last);
664    }
665
666    #[rstest]
667    #[case("trade_bar_10unknown", "Unsupported bar aggregation type")]
668    #[case("", "no aggregation suffix")]
669    #[case("trade_bar_notanumberms", "Invalid step")]
670    fn test_parse_bar_spec_errors(#[case] value: &str, #[case] expected_error: &str) {
671        let result = parse_bar_spec(value);
672        assert!(result.is_err());
673        assert!(
674            result.unwrap_err().to_string().contains(expected_error),
675            "Expected error containing '{expected_error}'"
676        );
677    }
678
679    #[rstest]
680    #[case(
681        BarSpecification::new(10, BarAggregation::Millisecond, PriceType::Last),
682        "trade_bar_10ms"
683    )]
684    #[case(
685        BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
686        "trade_bar_5m"
687    )]
688    #[case(
689        BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
690        "trade_bar_60m"
691    )]
692    #[case(
693        BarSpecification::new(2, BarAggregation::Day, PriceType::Last),
694        "trade_bar_2880m"
695    )]
696    #[case(
697        BarSpecification::new(100, BarAggregation::Tick, PriceType::Last),
698        "trade_bar_100ticks"
699    )]
700    #[case(
701        BarSpecification::new(100_000, BarAggregation::Volume, PriceType::Last),
702        "trade_bar_100000vol"
703    )]
704    fn test_to_tardis_string(#[case] bar_spec: BarSpecification, #[case] expected: &str) {
705        assert_eq!(
706            bar_spec_to_tardis_trade_bar_string(&bar_spec).unwrap(),
707            expected
708        );
709    }
710
711    #[rstest]
712    fn test_derive_trade_id_is_deterministic_and_16_hex_chars() {
713        let first = derive_trade_id(Ustr::from("XBTUSD"), 1_700_000_000, "7996", "50", "sell");
714        let second = derive_trade_id(Ustr::from("XBTUSD"), 1_700_000_000, "7996", "50", "sell");
715        assert_eq!(first, second);
716        assert_eq!(first.as_str().len(), 16);
717    }
718
719    #[rstest]
720    #[case::symbol_changed(derive_trade_id(Ustr::from("ETHUSD"), 1, "1", "1", "buy"))]
721    #[case::ts_changed(derive_trade_id(Ustr::from("XBTUSD"), 2, "1", "1", "buy"))]
722    #[case::price_changed(derive_trade_id(Ustr::from("XBTUSD"), 1, "2", "1", "buy"))]
723    #[case::amount_changed(derive_trade_id(Ustr::from("XBTUSD"), 1, "1", "2", "buy"))]
724    #[case::side_changed(derive_trade_id(Ustr::from("XBTUSD"), 1, "1", "1", "sell"))]
725    fn test_derive_trade_id_each_field_affects_output(#[case] altered: TradeId) {
726        let baseline = derive_trade_id(Ustr::from("XBTUSD"), 1, "1", "1", "buy");
727        assert_ne!(baseline, altered);
728    }
729
730    #[rstest]
731    fn test_derive_trade_id_field_delimiter_prevents_collision() {
732        // Without the 0x1f delimiter, concatenated bytes for these two inputs
733        // would collapse into the same stream.
734        let a = derive_trade_id(Ustr::from("A"), 1, "0", "0", "buy");
735        let b = derive_trade_id(Ustr::from("A\x00"), 256, "0", "0", "buy");
736        assert_ne!(a, b);
737    }
738
739    #[rstest]
740    fn test_derive_trade_id_matches_csv_and_machine_text() {
741        let price = dec!(7996.50).normalize().to_string();
742        let amount = dec!(0.000000150).normalize().to_string();
743
744        let machine = derive_trade_id(Ustr::from("XBTUSD"), 1, &price, &amount, "buy");
745        let csv = derive_trade_id(
746            Ustr::from("XBTUSD"),
747            1,
748            &7996.5_f64.to_string(),
749            &0.000_000_15_f64.to_string(),
750            "buy",
751        );
752
753        assert_eq!(machine, csv);
754    }
755}