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 _ => 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#[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#[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#[must_use]
233pub fn normalize_amount(amount: f64, precision: u8) -> f64 {
234 let factor = 10_f64.powi(i32::from(precision));
235 let scaled = amount * factor;
238 let rounded = scaled.round();
239 let result = if (rounded - scaled).abs() < 1e-9 {
242 rounded.trunc()
243 } else {
244 scaled.trunc()
245 };
246 result / factor
247}
248
249#[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#[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#[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#[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#[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#[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
313pub 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
369pub 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 let result = normalize_amount(0.1, 1);
502 assert_eq!(result, 0.1);
503
504 let result = normalize_amount(0.7, 1);
506 assert_eq!(result, 0.7);
507
508 let result = normalize_amount(1.123456789, 9);
510 assert_eq!(result, 1.123456789);
511
512 let result = normalize_amount(0.0, 8);
514 assert_eq!(result, 0.0);
515
516 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 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}