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        _ => symbol,
201    }
202}
203
204fn append_suffix(symbol: Ustr, suffix: &str) -> Ustr {
205    let mut symbol = symbol.to_string();
206    symbol.push_str(suffix);
207    Ustr::from(&symbol)
208}
209
210/// Parses a Nautilus instrument ID from the given Tardis `exchange` and `symbol` values.
211#[must_use]
212pub fn parse_instrument_id(exchange: &TardisExchange, symbol: Ustr) -> InstrumentId {
213    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), exchange.as_venue())
214}
215
216/// Parses a Nautilus instrument ID with a normalized symbol from the given Tardis `exchange` and `symbol` values.
217#[must_use]
218pub fn normalize_instrument_id(
219    exchange: &TardisExchange,
220    symbol: Ustr,
221    instrument_type: &TardisInstrumentType,
222    is_inverse: Option<bool>,
223) -> InstrumentId {
224    let symbol = normalize_symbol_str(symbol, exchange, instrument_type, is_inverse);
225    parse_instrument_id(exchange, symbol)
226}
227
228/// Normalizes the given amount by truncating it to the specified decimal precision.
229///
230/// Uses rounding to the nearest integer before truncation to avoid floating-point
231/// precision issues (e.g., `0.1 * 10` becoming `0.9999999999`).
232#[must_use]
233pub fn normalize_amount(amount: f64, precision: u8) -> f64 {
234    let factor = 10_f64.powi(i32::from(precision));
235    // Round to nearest integer first to handle floating-point precision issues,
236    // then truncate toward zero to maintain the original truncation semantics
237    let scaled = amount * factor;
238    let rounded = scaled.round();
239    // If the rounded value is very close to scaled, use it; otherwise use trunc
240    // This handles edge cases like 0.1 * 10 = 0.9999999999... -> 1.0
241    let result = if (rounded - scaled).abs() < 1e-9 {
242        rounded.trunc()
243    } else {
244        scaled.trunc()
245    };
246    result / factor
247}
248
249/// Parses a Nautilus price from the given `value`.
250///
251/// Values outside the representable range are capped to min/max price.
252#[must_use]
253pub fn parse_price(value: f64, precision: u8) -> Price {
254    match value {
255        v if (PRICE_MIN..=PRICE_MAX).contains(&v) => Price::new(value, precision),
256        v if v < PRICE_MIN => Price::min(precision),
257        _ => Price::max(precision),
258    }
259}
260
261/// Parses a Nautilus order side from the given Tardis string `value`.
262#[must_use]
263pub fn parse_order_side(value: &str) -> OrderSide {
264    match value {
265        "bid" => OrderSide::Buy,
266        "ask" => OrderSide::Sell,
267        _ => OrderSide::NoOrderSide,
268    }
269}
270
271/// Parses a Nautilus aggressor side from the given Tardis string `value`.
272#[must_use]
273pub fn parse_aggressor_side(value: &str) -> AggressorSide {
274    match value {
275        "buy" => AggressorSide::Buyer,
276        "sell" => AggressorSide::Seller,
277        _ => AggressorSide::NoAggressor,
278    }
279}
280
281/// Parses a Nautilus option kind from the given Tardis enum `value`.
282#[must_use]
283pub const fn parse_option_kind(value: TardisOptionType) -> OptionKind {
284    match value {
285        TardisOptionType::Call => OptionKind::Call,
286        TardisOptionType::Put => OptionKind::Put,
287    }
288}
289
290/// Parses a UNIX nanoseconds timestamp from the given Tardis microseconds `value_us`.
291#[must_use]
292pub fn parse_timestamp(value_us: u64) -> UnixNanos {
293    value_us
294        .checked_mul(NANOSECONDS_IN_MICROSECOND)
295        .map_or_else(|| {
296            log::error!("Timestamp overflow: {value_us} microseconds exceeds maximum representable value");
297            UnixNanos::max()
298        }, UnixNanos::from)
299}
300
301/// Parses a Nautilus book action inferred from the given Tardis values.
302#[must_use]
303pub fn parse_book_action(is_snapshot: bool, amount: f64) -> BookAction {
304    if amount == 0.0 {
305        BookAction::Delete
306    } else if is_snapshot {
307        BookAction::Add
308    } else {
309        BookAction::Update
310    }
311}
312
313/// Parses a Nautilus bar specification from the given Tardis string `value`.
314///
315/// The [`PriceType`] is always `LAST` for Tardis trade bars.
316///
317/// # Errors
318///
319/// Returns an error if the specification format is invalid or if the aggregation suffix is unsupported.
320pub fn parse_bar_spec(value: &str) -> anyhow::Result<BarSpecification> {
321    let parts: Vec<&str> = value.split('_').collect();
322    let last_part = parts
323        .last()
324        .ok_or_else(|| anyhow::anyhow!("Invalid bar spec: empty string"))?;
325    let split_idx = last_part
326        .chars()
327        .position(|c| !c.is_ascii_digit())
328        .ok_or_else(|| anyhow::anyhow!("Invalid bar spec: no aggregation suffix in '{value}'"))?;
329
330    let (step_str, suffix) = last_part.split_at(split_idx);
331    let step: usize = step_str
332        .parse()
333        .map_err(|e| anyhow::anyhow!("Invalid step in bar spec '{value}': {e}"))?;
334
335    let aggregation = match suffix {
336        "ms" => BarAggregation::Millisecond,
337        "s" => BarAggregation::Second,
338        "m" => BarAggregation::Minute,
339        "ticks" => BarAggregation::Tick,
340        "vol" => BarAggregation::Volume,
341        _ => anyhow::bail!("Unsupported bar aggregation type: '{suffix}'"),
342    };
343
344    parse_canonical_bar_spec(step, aggregation)
345        .with_context(|| format!("Invalid bar spec '{value}'"))
346}
347
348fn parse_canonical_bar_spec(
349    step: usize,
350    aggregation: BarAggregation,
351) -> anyhow::Result<BarSpecification> {
352    match aggregation {
353        BarAggregation::Millisecond if step.is_multiple_of(1000) => {
354            parse_canonical_bar_spec(step / 1000, BarAggregation::Second)
355        }
356        BarAggregation::Second if step.is_multiple_of(60) => {
357            parse_canonical_bar_spec(step / 60, BarAggregation::Minute)
358        }
359        BarAggregation::Minute if step.is_multiple_of(60) => {
360            parse_canonical_bar_spec(step / 60, BarAggregation::Hour)
361        }
362        BarAggregation::Hour if step.is_multiple_of(24) => {
363            parse_canonical_bar_spec(step / 24, BarAggregation::Day)
364        }
365        _ => BarSpecification::new_checked(step, aggregation, PriceType::Last),
366    }
367}
368
369/// Converts a Nautilus `BarSpecification` to the Tardis trade bar string convention.
370///
371/// # Errors
372///
373/// Returns an error if the bar aggregation kind is unsupported.
374pub fn bar_spec_to_tardis_trade_bar_string(bar_spec: &BarSpecification) -> anyhow::Result<String> {
375    match bar_spec.aggregation {
376        BarAggregation::Hour => {
377            let minutes = bar_spec
378                .step
379                .get()
380                .checked_mul(60)
381                .context("bar specification step overflow")?;
382            return Ok(format!("trade_bar_{minutes}m"));
383        }
384        BarAggregation::Day => {
385            let minutes = bar_spec
386                .step
387                .get()
388                .checked_mul(1440)
389                .context("bar specification step overflow")?;
390            return Ok(format!("trade_bar_{minutes}m"));
391        }
392        _ => {}
393    }
394
395    let suffix = match bar_spec.aggregation {
396        BarAggregation::Millisecond => "ms",
397        BarAggregation::Second => "s",
398        BarAggregation::Minute => "m",
399        BarAggregation::Tick => "ticks",
400        BarAggregation::Volume => "vol",
401        _ => anyhow::bail!("Unsupported bar aggregation type: {}", bar_spec.aggregation),
402    };
403    Ok(format!("trade_bar_{}{}", bar_spec.step, suffix))
404}
405
406#[cfg(test)]
407mod tests {
408    use std::str::FromStr;
409
410    use rstest::rstest;
411
412    use super::*;
413
414    #[rstest]
415    #[case(TardisExchange::Binance, "ETHUSDT", "ETHUSDT.BINANCE")]
416    #[case(TardisExchange::Bitmex, "XBTUSD", "XBTUSD.BITMEX")]
417    #[case(TardisExchange::Bybit, "BTCUSDT", "BTCUSDT.BYBIT")]
418    #[case(TardisExchange::OkexFutures, "BTC-USD-200313", "BTC-USD-200313.OKEX")]
419    #[case(TardisExchange::HuobiDmLinearSwap, "FOO-BAR", "FOO-BAR.HUOBI")]
420    fn test_parse_instrument_id(
421        #[case] exchange: TardisExchange,
422        #[case] symbol: Ustr,
423        #[case] expected: &str,
424    ) {
425        let instrument_id = parse_instrument_id(&exchange, symbol);
426        let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
427        assert_eq!(instrument_id, expected_instrument_id);
428    }
429
430    #[rstest]
431    #[case(
432        TardisExchange::Binance,
433        "SOLUSDT",
434        TardisInstrumentType::Spot,
435        None,
436        "SOLUSDT.BINANCE"
437    )]
438    #[case(
439        TardisExchange::BinanceFutures,
440        "SOLUSDT",
441        TardisInstrumentType::Perpetual,
442        None,
443        "SOLUSDT-PERP.BINANCE"
444    )]
445    #[case(
446        TardisExchange::Bybit,
447        "BTCUSDT",
448        TardisInstrumentType::Spot,
449        None,
450        "BTCUSDT-SPOT.BYBIT"
451    )]
452    #[case(
453        TardisExchange::Bybit,
454        "BTCUSDT",
455        TardisInstrumentType::Perpetual,
456        None,
457        "BTCUSDT-LINEAR.BYBIT"
458    )]
459    #[case(
460        TardisExchange::Bybit,
461        "BTCUSDT",
462        TardisInstrumentType::Perpetual,
463        Some(true),
464        "BTCUSDT-INVERSE.BYBIT"
465    )]
466    #[case(
467        TardisExchange::Dydx,
468        "BTC-USD",
469        TardisInstrumentType::Perpetual,
470        None,
471        "BTC-USD-PERP.DYDX"
472    )]
473    fn test_normalize_instrument_id(
474        #[case] exchange: TardisExchange,
475        #[case] symbol: Ustr,
476        #[case] instrument_type: TardisInstrumentType,
477        #[case] is_inverse: Option<bool>,
478        #[case] expected: &str,
479    ) {
480        let instrument_id =
481            normalize_instrument_id(&exchange, symbol, &instrument_type, is_inverse);
482        let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
483        assert_eq!(instrument_id, expected_instrument_id);
484    }
485
486    #[rstest]
487    #[case(0.00001, 4, 0.0)]
488    #[case(1.2345, 3, 1.234)]
489    #[case(1.2345, 2, 1.23)]
490    #[case(-1.2345, 3, -1.234)]
491    #[case(123.456, 0, 123.0)]
492    fn test_normalize_amount(#[case] amount: f64, #[case] precision: u8, #[case] expected: f64) {
493        let result = normalize_amount(amount, precision);
494        assert_eq!(result, expected);
495    }
496
497    #[rstest]
498    fn test_normalize_amount_floating_point_edge_cases() {
499        // Test that floating-point edge cases are handled correctly
500        // 0.1 * 10 can become 0.9999999... due to IEEE 754
501        let result = normalize_amount(0.1, 1);
502        assert_eq!(result, 0.1);
503
504        // Test with values that could have precision issues
505        let result = normalize_amount(0.7, 1);
506        assert_eq!(result, 0.7);
507
508        // Test large precision
509        let result = normalize_amount(1.123456789, 9);
510        assert_eq!(result, 1.123456789);
511
512        // Test zero
513        let result = normalize_amount(0.0, 8);
514        assert_eq!(result, 0.0);
515
516        // Test negative values
517        let result = normalize_amount(-0.1, 1);
518        assert_eq!(result, -0.1);
519    }
520
521    #[rstest]
522    #[case("bid", OrderSide::Buy)]
523    #[case("ask", OrderSide::Sell)]
524    #[case("unknown", OrderSide::NoOrderSide)]
525    #[case("", OrderSide::NoOrderSide)]
526    #[case("random", OrderSide::NoOrderSide)]
527    fn test_parse_order_side(#[case] input: &str, #[case] expected: OrderSide) {
528        assert_eq!(parse_order_side(input), expected);
529    }
530
531    #[rstest]
532    #[case("buy", AggressorSide::Buyer)]
533    #[case("sell", AggressorSide::Seller)]
534    #[case("unknown", AggressorSide::NoAggressor)]
535    #[case("", AggressorSide::NoAggressor)]
536    #[case("random", AggressorSide::NoAggressor)]
537    fn test_parse_aggressor_side(#[case] input: &str, #[case] expected: AggressorSide) {
538        assert_eq!(parse_aggressor_side(input), expected);
539    }
540
541    #[rstest]
542    fn test_parse_timestamp() {
543        let input_timestamp: u64 = 1583020803145000;
544        let expected_nanos: UnixNanos =
545            UnixNanos::from(input_timestamp * NANOSECONDS_IN_MICROSECOND);
546
547        assert_eq!(parse_timestamp(input_timestamp), expected_nanos);
548    }
549
550    #[rstest]
551    #[case(true, 10.0, BookAction::Add)]
552    #[case(false, 0.0, BookAction::Delete)]
553    #[case(false, 10.0, BookAction::Update)]
554    fn test_parse_book_action(
555        #[case] is_snapshot: bool,
556        #[case] amount: f64,
557        #[case] expected: BookAction,
558    ) {
559        assert_eq!(parse_book_action(is_snapshot, amount), expected);
560    }
561
562    #[rstest]
563    #[case("trade_bar_10ms", 10, BarAggregation::Millisecond)]
564    #[case("trade_bar_10000ms", 10, BarAggregation::Second)]
565    #[case("trade_bar_5m", 5, BarAggregation::Minute)]
566    #[case("trade_bar_60m", 1, BarAggregation::Hour)]
567    #[case("trade_bar_100ticks", 100, BarAggregation::Tick)]
568    #[case("trade_bar_100000vol", 100000, BarAggregation::Volume)]
569    fn test_parse_bar_spec(
570        #[case] value: &str,
571        #[case] expected_step: usize,
572        #[case] expected_aggregation: BarAggregation,
573    ) {
574        let spec = parse_bar_spec(value).unwrap();
575        assert_eq!(spec.step.get(), expected_step);
576        assert_eq!(spec.aggregation, expected_aggregation);
577        assert_eq!(spec.price_type, PriceType::Last);
578    }
579
580    #[rstest]
581    #[case("trade_bar_10unknown", "Unsupported bar aggregation type")]
582    #[case("", "no aggregation suffix")]
583    #[case("trade_bar_notanumberms", "Invalid step")]
584    fn test_parse_bar_spec_errors(#[case] value: &str, #[case] expected_error: &str) {
585        let result = parse_bar_spec(value);
586        assert!(result.is_err());
587        assert!(
588            result.unwrap_err().to_string().contains(expected_error),
589            "Expected error containing '{expected_error}'"
590        );
591    }
592
593    #[rstest]
594    #[case(
595        BarSpecification::new(10, BarAggregation::Millisecond, PriceType::Last),
596        "trade_bar_10ms"
597    )]
598    #[case(
599        BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
600        "trade_bar_5m"
601    )]
602    #[case(
603        BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
604        "trade_bar_60m"
605    )]
606    #[case(
607        BarSpecification::new(2, BarAggregation::Day, PriceType::Last),
608        "trade_bar_2880m"
609    )]
610    #[case(
611        BarSpecification::new(100, BarAggregation::Tick, PriceType::Last),
612        "trade_bar_100ticks"
613    )]
614    #[case(
615        BarSpecification::new(100_000, BarAggregation::Volume, PriceType::Last),
616        "trade_bar_100000vol"
617    )]
618    fn test_to_tardis_string(#[case] bar_spec: BarSpecification, #[case] expected: &str) {
619        assert_eq!(
620            bar_spec_to_tardis_trade_bar_string(&bar_spec).unwrap(),
621            expected
622        );
623    }
624
625    #[rstest]
626    fn test_derive_trade_id_is_deterministic_and_16_hex_chars() {
627        let first = derive_trade_id(Ustr::from("XBTUSD"), 1_700_000_000, 7996.0, 50.0, "sell");
628        let second = derive_trade_id(Ustr::from("XBTUSD"), 1_700_000_000, 7996.0, 50.0, "sell");
629        assert_eq!(first, second);
630        assert_eq!(first.as_str().len(), 16);
631    }
632
633    #[rstest]
634    #[case::symbol_changed(derive_trade_id(Ustr::from("ETHUSD"), 1, 1.0, 1.0, "buy"))]
635    #[case::ts_changed(derive_trade_id(Ustr::from("XBTUSD"), 2, 1.0, 1.0, "buy"))]
636    #[case::price_changed(derive_trade_id(Ustr::from("XBTUSD"), 1, 2.0, 1.0, "buy"))]
637    #[case::amount_changed(derive_trade_id(Ustr::from("XBTUSD"), 1, 1.0, 2.0, "buy"))]
638    #[case::side_changed(derive_trade_id(Ustr::from("XBTUSD"), 1, 1.0, 1.0, "sell"))]
639    fn test_derive_trade_id_each_field_affects_output(#[case] altered: TradeId) {
640        let baseline = derive_trade_id(Ustr::from("XBTUSD"), 1, 1.0, 1.0, "buy");
641        assert_ne!(baseline, altered);
642    }
643
644    #[rstest]
645    fn test_derive_trade_id_field_delimiter_prevents_collision() {
646        // Without the 0x1f delimiter, concatenated bytes for these two inputs
647        // would collapse into the same stream.
648        let a = derive_trade_id(Ustr::from("A"), 1, 0.0, 0.0, "buy");
649        let b = derive_trade_id(Ustr::from("A\x00"), 256, 0.0, 0.0, "buy");
650        assert_ne!(a, b);
651    }
652}