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 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
41const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
43const FNV_PRIME: u64 = 0x0100_0000_01b3;
44
45pub(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
57pub(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
89pub(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#[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#[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#[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#[must_use]
249pub fn normalize_amount(amount: f64, precision: u8) -> f64 {
250 let factor = 10_f64.powi(i32::from(precision));
251 let scaled = amount * factor;
254 let rounded = scaled.round();
255 let result = if (rounded - scaled).abs() < 1e-9 {
258 rounded.trunc()
259 } else {
260 scaled.trunc()
261 };
262 result / factor
263}
264
265#[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#[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#[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#[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#[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#[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
329pub 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
385pub 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 let result = normalize_amount(0.1, 1);
543 assert_eq!(result, 0.1);
544
545 let result = normalize_amount(0.7, 1);
547 assert_eq!(result, 0.7);
548
549 let result = normalize_amount(1.123456789, 9);
551 assert_eq!(result, 1.123456789);
552
553 let result = normalize_amount(0.0, 8);
555 assert_eq!(result, 0.0);
556
557 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 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}