1use 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
42const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
44const FNV_PRIME: u64 = 0x0100_0000_01b3;
45
46pub(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
58pub(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
94pub(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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
340pub 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
396pub 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 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}