1use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_MICROSECOND};
17use nautilus_model::{
18 data::BarSpecification,
19 enums::{AggressorSide, BarAggregation, BookAction, OptionKind, OrderSide, PriceType},
20 identifiers::{InstrumentId, Symbol, TradeId},
21 types::{PRICE_MAX, PRICE_MIN, Price},
22};
23use serde::{Deserialize, Deserializer};
24use ustr::Ustr;
25
26use super::enums::{TardisExchange, TardisInstrumentType, TardisOptionType};
27
28const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
30const FNV_PRIME: u64 = 0x0100_0000_01b3;
31
32pub fn deserialize_uppercase<'de, D>(deserializer: D) -> Result<Ustr, D::Error>
38where
39 D: Deserializer<'de>,
40{
41 String::deserialize(deserializer).map(|s| Ustr::from(&s.to_uppercase()))
42}
43
44#[must_use]
52pub fn derive_trade_id(
53 symbol: Ustr,
54 ts_event_ns: u64,
55 price: f64,
56 amount: f64,
57 side: &str,
58) -> TradeId {
59 let mut hash: u64 = FNV_OFFSET_BASIS;
60
61 for bytes in [
62 symbol.as_str().as_bytes(),
63 b"\x1f",
64 &ts_event_ns.to_le_bytes(),
65 b"\x1f",
66 &price.to_bits().to_le_bytes(),
67 b"\x1f",
68 &amount.to_bits().to_le_bytes(),
69 b"\x1f",
70 side.as_bytes(),
71 ] {
72 for &byte in bytes {
73 hash ^= u64::from(byte);
74 hash = hash.wrapping_mul(FNV_PRIME);
75 }
76 }
77 TradeId::new(format!("{hash:016x}"))
78}
79
80#[must_use]
81#[inline]
82pub fn normalize_symbol_str(
83 symbol: Ustr,
84 exchange: &TardisExchange,
85 instrument_type: &TardisInstrumentType,
86 is_inverse: Option<bool>,
87) -> Ustr {
88 match exchange {
89 TardisExchange::Binance
90 | TardisExchange::BinanceFutures
91 | TardisExchange::BinanceUs
92 | TardisExchange::BinanceDex
93 | TardisExchange::BinanceJersey
94 if instrument_type == &TardisInstrumentType::Perpetual =>
95 {
96 append_suffix(symbol, "-PERP")
97 }
98
99 TardisExchange::Bybit | TardisExchange::BybitSpot | TardisExchange::BybitOptions => {
100 match instrument_type {
101 TardisInstrumentType::Spot => append_suffix(symbol, "-SPOT"),
102 TardisInstrumentType::Perpetual if !is_inverse.unwrap_or(false) => {
103 append_suffix(symbol, "-LINEAR")
104 }
105 TardisInstrumentType::Future if !is_inverse.unwrap_or(false) => {
106 append_suffix(symbol, "-LINEAR")
107 }
108 TardisInstrumentType::Perpetual if is_inverse == Some(true) => {
109 append_suffix(symbol, "-INVERSE")
110 }
111 TardisInstrumentType::Future if is_inverse == Some(true) => {
112 append_suffix(symbol, "-INVERSE")
113 }
114 TardisInstrumentType::Option => append_suffix(symbol, "-OPTION"),
115 _ => symbol,
116 }
117 }
118
119 TardisExchange::Dydx if instrument_type == &TardisInstrumentType::Perpetual => {
120 append_suffix(symbol, "-PERP")
121 }
122
123 TardisExchange::GateIoFutures if instrument_type == &TardisInstrumentType::Perpetual => {
124 append_suffix(symbol, "-PERP")
125 }
126
127 _ => symbol,
128 }
129}
130
131fn append_suffix(symbol: Ustr, suffix: &str) -> Ustr {
132 let mut symbol = symbol.to_string();
133 symbol.push_str(suffix);
134 Ustr::from(&symbol)
135}
136
137#[must_use]
139pub fn parse_instrument_id(exchange: &TardisExchange, symbol: Ustr) -> InstrumentId {
140 InstrumentId::new(Symbol::from_ustr_unchecked(symbol), exchange.as_venue())
141}
142
143#[must_use]
145pub fn normalize_instrument_id(
146 exchange: &TardisExchange,
147 symbol: Ustr,
148 instrument_type: &TardisInstrumentType,
149 is_inverse: Option<bool>,
150) -> InstrumentId {
151 let symbol = normalize_symbol_str(symbol, exchange, instrument_type, is_inverse);
152 parse_instrument_id(exchange, symbol)
153}
154
155#[must_use]
160pub fn normalize_amount(amount: f64, precision: u8) -> f64 {
161 let factor = 10_f64.powi(i32::from(precision));
162 let scaled = amount * factor;
165 let rounded = scaled.round();
166 let result = if (rounded - scaled).abs() < 1e-9 {
169 rounded.trunc()
170 } else {
171 scaled.trunc()
172 };
173 result / factor
174}
175
176#[must_use]
180pub fn parse_price(value: f64, precision: u8) -> Price {
181 match value {
182 v if (PRICE_MIN..=PRICE_MAX).contains(&v) => Price::new(value, precision),
183 v if v < PRICE_MIN => Price::min(precision),
184 _ => Price::max(precision),
185 }
186}
187
188#[must_use]
190pub fn parse_order_side(value: &str) -> OrderSide {
191 match value {
192 "bid" => OrderSide::Buy,
193 "ask" => OrderSide::Sell,
194 _ => OrderSide::NoOrderSide,
195 }
196}
197
198#[must_use]
200pub fn parse_aggressor_side(value: &str) -> AggressorSide {
201 match value {
202 "buy" => AggressorSide::Buyer,
203 "sell" => AggressorSide::Seller,
204 _ => AggressorSide::NoAggressor,
205 }
206}
207
208#[must_use]
210pub const fn parse_option_kind(value: TardisOptionType) -> OptionKind {
211 match value {
212 TardisOptionType::Call => OptionKind::Call,
213 TardisOptionType::Put => OptionKind::Put,
214 }
215}
216
217#[must_use]
219pub fn parse_timestamp(value_us: u64) -> UnixNanos {
220 value_us
221 .checked_mul(NANOSECONDS_IN_MICROSECOND)
222 .map_or_else(|| {
223 log::error!("Timestamp overflow: {value_us} microseconds exceeds maximum representable value");
224 UnixNanos::max()
225 }, UnixNanos::from)
226}
227
228#[must_use]
230pub fn parse_book_action(is_snapshot: bool, amount: f64) -> BookAction {
231 if amount == 0.0 {
232 BookAction::Delete
233 } else if is_snapshot {
234 BookAction::Add
235 } else {
236 BookAction::Update
237 }
238}
239
240pub fn parse_bar_spec(value: &str) -> anyhow::Result<BarSpecification> {
248 let parts: Vec<&str> = value.split('_').collect();
249 let last_part = parts
250 .last()
251 .ok_or_else(|| anyhow::anyhow!("Invalid bar spec: empty string"))?;
252 let split_idx = last_part
253 .chars()
254 .position(|c| !c.is_ascii_digit())
255 .ok_or_else(|| anyhow::anyhow!("Invalid bar spec: no aggregation suffix in '{value}'"))?;
256
257 let (step_str, suffix) = last_part.split_at(split_idx);
258 let step: usize = step_str
259 .parse()
260 .map_err(|e| anyhow::anyhow!("Invalid step in bar spec '{value}': {e}"))?;
261
262 let aggregation = match suffix {
263 "ms" => BarAggregation::Millisecond,
264 "s" => BarAggregation::Second,
265 "m" => BarAggregation::Minute,
266 "ticks" => BarAggregation::Tick,
267 "vol" => BarAggregation::Volume,
268 _ => anyhow::bail!("Unsupported bar aggregation type: '{suffix}'"),
269 };
270
271 Ok(BarSpecification::new(step, aggregation, PriceType::Last))
272}
273
274pub fn bar_spec_to_tardis_trade_bar_string(bar_spec: &BarSpecification) -> anyhow::Result<String> {
280 let suffix = match bar_spec.aggregation {
281 BarAggregation::Millisecond => "ms",
282 BarAggregation::Second => "s",
283 BarAggregation::Minute => "m",
284 BarAggregation::Tick => "ticks",
285 BarAggregation::Volume => "vol",
286 _ => anyhow::bail!("Unsupported bar aggregation type: {}", bar_spec.aggregation),
287 };
288 Ok(format!("trade_bar_{}{}", bar_spec.step, suffix))
289}
290
291#[cfg(test)]
292mod tests {
293 use std::str::FromStr;
294
295 use rstest::rstest;
296
297 use super::*;
298
299 #[rstest]
300 #[case(TardisExchange::Binance, "ETHUSDT", "ETHUSDT.BINANCE")]
301 #[case(TardisExchange::Bitmex, "XBTUSD", "XBTUSD.BITMEX")]
302 #[case(TardisExchange::Bybit, "BTCUSDT", "BTCUSDT.BYBIT")]
303 #[case(TardisExchange::OkexFutures, "BTC-USD-200313", "BTC-USD-200313.OKEX")]
304 #[case(TardisExchange::HuobiDmLinearSwap, "FOO-BAR", "FOO-BAR.HUOBI")]
305 fn test_parse_instrument_id(
306 #[case] exchange: TardisExchange,
307 #[case] symbol: Ustr,
308 #[case] expected: &str,
309 ) {
310 let instrument_id = parse_instrument_id(&exchange, symbol);
311 let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
312 assert_eq!(instrument_id, expected_instrument_id);
313 }
314
315 #[rstest]
316 #[case(
317 TardisExchange::Binance,
318 "SOLUSDT",
319 TardisInstrumentType::Spot,
320 None,
321 "SOLUSDT.BINANCE"
322 )]
323 #[case(
324 TardisExchange::BinanceFutures,
325 "SOLUSDT",
326 TardisInstrumentType::Perpetual,
327 None,
328 "SOLUSDT-PERP.BINANCE"
329 )]
330 #[case(
331 TardisExchange::Bybit,
332 "BTCUSDT",
333 TardisInstrumentType::Spot,
334 None,
335 "BTCUSDT-SPOT.BYBIT"
336 )]
337 #[case(
338 TardisExchange::Bybit,
339 "BTCUSDT",
340 TardisInstrumentType::Perpetual,
341 None,
342 "BTCUSDT-LINEAR.BYBIT"
343 )]
344 #[case(
345 TardisExchange::Bybit,
346 "BTCUSDT",
347 TardisInstrumentType::Perpetual,
348 Some(true),
349 "BTCUSDT-INVERSE.BYBIT"
350 )]
351 #[case(
352 TardisExchange::Dydx,
353 "BTC-USD",
354 TardisInstrumentType::Perpetual,
355 None,
356 "BTC-USD-PERP.DYDX"
357 )]
358 fn test_normalize_instrument_id(
359 #[case] exchange: TardisExchange,
360 #[case] symbol: Ustr,
361 #[case] instrument_type: TardisInstrumentType,
362 #[case] is_inverse: Option<bool>,
363 #[case] expected: &str,
364 ) {
365 let instrument_id =
366 normalize_instrument_id(&exchange, symbol, &instrument_type, is_inverse);
367 let expected_instrument_id = InstrumentId::from_str(expected).unwrap();
368 assert_eq!(instrument_id, expected_instrument_id);
369 }
370
371 #[rstest]
372 #[case(0.00001, 4, 0.0)]
373 #[case(1.2345, 3, 1.234)]
374 #[case(1.2345, 2, 1.23)]
375 #[case(-1.2345, 3, -1.234)]
376 #[case(123.456, 0, 123.0)]
377 fn test_normalize_amount(#[case] amount: f64, #[case] precision: u8, #[case] expected: f64) {
378 let result = normalize_amount(amount, precision);
379 assert_eq!(result, expected);
380 }
381
382 #[rstest]
383 fn test_normalize_amount_floating_point_edge_cases() {
384 let result = normalize_amount(0.1, 1);
387 assert_eq!(result, 0.1);
388
389 let result = normalize_amount(0.7, 1);
391 assert_eq!(result, 0.7);
392
393 let result = normalize_amount(1.123456789, 9);
395 assert_eq!(result, 1.123456789);
396
397 let result = normalize_amount(0.0, 8);
399 assert_eq!(result, 0.0);
400
401 let result = normalize_amount(-0.1, 1);
403 assert_eq!(result, -0.1);
404 }
405
406 #[rstest]
407 #[case("bid", OrderSide::Buy)]
408 #[case("ask", OrderSide::Sell)]
409 #[case("unknown", OrderSide::NoOrderSide)]
410 #[case("", OrderSide::NoOrderSide)]
411 #[case("random", OrderSide::NoOrderSide)]
412 fn test_parse_order_side(#[case] input: &str, #[case] expected: OrderSide) {
413 assert_eq!(parse_order_side(input), expected);
414 }
415
416 #[rstest]
417 #[case("buy", AggressorSide::Buyer)]
418 #[case("sell", AggressorSide::Seller)]
419 #[case("unknown", AggressorSide::NoAggressor)]
420 #[case("", AggressorSide::NoAggressor)]
421 #[case("random", AggressorSide::NoAggressor)]
422 fn test_parse_aggressor_side(#[case] input: &str, #[case] expected: AggressorSide) {
423 assert_eq!(parse_aggressor_side(input), expected);
424 }
425
426 #[rstest]
427 fn test_parse_timestamp() {
428 let input_timestamp: u64 = 1583020803145000;
429 let expected_nanos: UnixNanos =
430 UnixNanos::from(input_timestamp * NANOSECONDS_IN_MICROSECOND);
431
432 assert_eq!(parse_timestamp(input_timestamp), expected_nanos);
433 }
434
435 #[rstest]
436 #[case(true, 10.0, BookAction::Add)]
437 #[case(false, 0.0, BookAction::Delete)]
438 #[case(false, 10.0, BookAction::Update)]
439 fn test_parse_book_action(
440 #[case] is_snapshot: bool,
441 #[case] amount: f64,
442 #[case] expected: BookAction,
443 ) {
444 assert_eq!(parse_book_action(is_snapshot, amount), expected);
445 }
446
447 #[rstest]
448 #[case("trade_bar_10ms", 10, BarAggregation::Millisecond)]
449 #[case("trade_bar_5m", 5, BarAggregation::Minute)]
450 #[case("trade_bar_100ticks", 100, BarAggregation::Tick)]
451 #[case("trade_bar_100000vol", 100000, BarAggregation::Volume)]
452 fn test_parse_bar_spec(
453 #[case] value: &str,
454 #[case] expected_step: usize,
455 #[case] expected_aggregation: BarAggregation,
456 ) {
457 let spec = parse_bar_spec(value).unwrap();
458 assert_eq!(spec.step.get(), expected_step);
459 assert_eq!(spec.aggregation, expected_aggregation);
460 assert_eq!(spec.price_type, PriceType::Last);
461 }
462
463 #[rstest]
464 #[case("trade_bar_10unknown", "Unsupported bar aggregation type")]
465 #[case("", "no aggregation suffix")]
466 #[case("trade_bar_notanumberms", "Invalid step")]
467 fn test_parse_bar_spec_errors(#[case] value: &str, #[case] expected_error: &str) {
468 let result = parse_bar_spec(value);
469 assert!(result.is_err());
470 assert!(
471 result.unwrap_err().to_string().contains(expected_error),
472 "Expected error containing '{expected_error}'"
473 );
474 }
475
476 #[rstest]
477 #[case(
478 BarSpecification::new(10, BarAggregation::Millisecond, PriceType::Last),
479 "trade_bar_10ms"
480 )]
481 #[case(
482 BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
483 "trade_bar_5m"
484 )]
485 #[case(
486 BarSpecification::new(100, BarAggregation::Tick, PriceType::Last),
487 "trade_bar_100ticks"
488 )]
489 #[case(
490 BarSpecification::new(100_000, BarAggregation::Volume, PriceType::Last),
491 "trade_bar_100000vol"
492 )]
493 fn test_to_tardis_string(#[case] bar_spec: BarSpecification, #[case] expected: &str) {
494 assert_eq!(
495 bar_spec_to_tardis_trade_bar_string(&bar_spec).unwrap(),
496 expected
497 );
498 }
499
500 #[rstest]
501 fn test_derive_trade_id_is_deterministic_and_16_hex_chars() {
502 let first = derive_trade_id(Ustr::from("XBTUSD"), 1_700_000_000, 7996.0, 50.0, "sell");
503 let second = derive_trade_id(Ustr::from("XBTUSD"), 1_700_000_000, 7996.0, 50.0, "sell");
504 assert_eq!(first, second);
505 assert_eq!(first.as_str().len(), 16);
506 }
507
508 #[rstest]
509 #[case::symbol_changed(derive_trade_id(Ustr::from("ETHUSD"), 1, 1.0, 1.0, "buy"))]
510 #[case::ts_changed(derive_trade_id(Ustr::from("XBTUSD"), 2, 1.0, 1.0, "buy"))]
511 #[case::price_changed(derive_trade_id(Ustr::from("XBTUSD"), 1, 2.0, 1.0, "buy"))]
512 #[case::amount_changed(derive_trade_id(Ustr::from("XBTUSD"), 1, 1.0, 2.0, "buy"))]
513 #[case::side_changed(derive_trade_id(Ustr::from("XBTUSD"), 1, 1.0, 1.0, "sell"))]
514 fn test_derive_trade_id_each_field_affects_output(#[case] altered: TradeId) {
515 let baseline = derive_trade_id(Ustr::from("XBTUSD"), 1, 1.0, 1.0, "buy");
516 assert_ne!(baseline, altered);
517 }
518
519 #[rstest]
520 fn test_derive_trade_id_field_delimiter_prevents_collision() {
521 let a = derive_trade_id(Ustr::from("A"), 1, 0.0, 0.0, "buy");
524 let b = derive_trade_id(Ustr::from("A\x00"), 256, 0.0, 0.0, "buy");
525 assert_ne!(a, b);
526 }
527}