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