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