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},
23};
24use serde::{Deserialize, Deserializer, de};
25use ustr::Ustr;
26
27use super::enums::{TardisExchange, TardisInstrumentType, TardisOptionType};
28
29const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
31const FNV_PRIME: u64 = 0x0100_0000_01b3;
32
33pub(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
45pub(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
77pub(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#[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#[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#[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#[must_use]
237pub fn normalize_amount(amount: f64, precision: u8) -> f64 {
238 let factor = 10_f64.powi(i32::from(precision));
239 let scaled = amount * factor;
242 let rounded = scaled.round();
243 let result = if (rounded - scaled).abs() < 1e-9 {
246 rounded.trunc()
247 } else {
248 scaled.trunc()
249 };
250 result / factor
251}
252
253#[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#[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#[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#[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#[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#[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
317pub 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
373pub 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 let result = normalize_amount(0.1, 1);
514 assert_eq!(result, 0.1);
515
516 let result = normalize_amount(0.7, 1);
518 assert_eq!(result, 0.7);
519
520 let result = normalize_amount(1.123456789, 9);
522 assert_eq!(result, 1.123456789);
523
524 let result = normalize_amount(0.0, 8);
526 assert_eq!(result, 0.0);
527
528 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 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}