1use std::str::FromStr;
22
23use anyhow::Context;
24use nautilus_core::nanos::UnixNanos;
25use nautilus_model::{
26 data::{
27 Bar, BarSpecification, BarType, BookOrder, OrderBookDelta, OrderBookDeltas, QuoteTick,
28 TradeTick,
29 },
30 enums::{
31 AggressorSide, AssetClass, BarAggregation, BookAction, LiquiditySide, OrderSide,
32 OrderStatus, OrderType, RecordFlag, TimeInForce, TriggerType,
33 },
34 identifiers::{AccountId, InstrumentId, OrderListId, Symbol, TradeId, Venue, VenueOrderId},
35 instruments::{
36 Instrument, any::InstrumentAny, crypto_future::CryptoFuture,
37 crypto_perpetual::CryptoPerpetual, currency_pair::CurrencyPair,
38 perpetual_contract::PerpetualContract,
39 },
40 reports::{FillReport, OrderStatusReport},
41 types::{Currency, Money, Price, Quantity},
42};
43use rust_decimal::Decimal;
44use serde_json::Value;
45
46use crate::{
47 common::{
48 consts::BINANCE,
49 encoder::decode_client_order_id,
50 enums::{
51 BinanceContractStatus, BinanceKlineInterval, BinanceProductType, BinanceTradingStatus,
52 },
53 symbol::format_instrument_id,
54 },
55 futures::http::models::{BinanceFuturesCoinSymbol, BinanceFuturesUsdSymbol},
56 spot::{
57 http::models::{
58 BinanceAccountTrade, BinanceKlines, BinanceLotSizeFilterSbe, BinanceNewOrderResponse,
59 BinanceNotionalFilter, BinanceOrderResponse, BinancePriceFilterSbe, BinanceSymbolJson,
60 BinanceSymbolSbe, BinanceTrades,
61 },
62 sbe::spot::{
63 order_side::OrderSide as SbeOrderSide, order_status::OrderStatus as SbeOrderStatus,
64 order_type::OrderType as SbeOrderType, time_in_force::TimeInForce as SbeTimeInForce,
65 },
66 },
67};
68const CONTRACT_TYPE_PERPETUAL: &str = "PERPETUAL";
69const CONTRACT_TYPE_TRADIFI_PERPETUAL: &str = "TRADIFI_PERPETUAL";
70const CONTRACT_TYPE_CURRENT_WEEK: &str = "CURRENT_WEEK";
71const CONTRACT_TYPE_NEXT_WEEK: &str = "NEXT_WEEK";
72const CONTRACT_TYPE_CURRENT_MONTH: &str = "CURRENT_MONTH";
73const CONTRACT_TYPE_NEXT_MONTH: &str = "NEXT_MONTH";
74const CONTRACT_TYPE_CURRENT_QUARTER: &str = "CURRENT_QUARTER";
75const CONTRACT_TYPE_NEXT_QUARTER: &str = "NEXT_QUARTER";
76
77pub(crate) fn parse_millis(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
78 parse_timestamp(value, UnixNanos::from_millis_checked(value), field)
79}
80
81pub(crate) fn parse_micros(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
82 parse_timestamp(value, UnixNanos::from_micros_checked(value), field)
83}
84
85fn parse_timestamp(
86 value: i64,
87 timestamp: Option<UnixNanos>,
88 field: &str,
89) -> anyhow::Result<UnixNanos> {
90 timestamp.ok_or_else(|| {
91 if value < 0 {
92 anyhow::anyhow!("invalid negative Binance {field} timestamp: {value}")
93 } else {
94 anyhow::anyhow!("Binance {field} timestamp is outside the UnixNanos range: {value}")
95 }
96 })
97}
98
99pub(crate) fn parse_millis_or_init(value: i64, field: &str, ts_init: UnixNanos) -> UnixNanos {
100 timestamp_or_init(parse_millis(value, field), ts_init)
101}
102
103pub(crate) fn parse_micros_or_init(value: i64, field: &str, ts_init: UnixNanos) -> UnixNanos {
104 timestamp_or_init(parse_micros(value, field), ts_init)
105}
106
107fn timestamp_or_init(timestamp: anyhow::Result<UnixNanos>, ts_init: UnixNanos) -> UnixNanos {
108 match timestamp {
109 Ok(timestamp) => timestamp,
110 Err(e) => {
111 log::warn!("{e}; using initialization timestamp");
112 ts_init
113 }
114 }
115}
116
117fn parse_tradifi_asset_class(symbol: &BinanceFuturesUsdSymbol) -> anyhow::Result<AssetClass> {
118 let underlying_type = symbol.underlying_type.as_deref().with_context(|| {
119 format!(
120 "Missing underlying type for TRADIFI_PERPETUAL symbol '{}'",
121 symbol.symbol
122 )
123 })?;
124
125 match underlying_type {
129 "EQUITY" | "CN_EQUITY" | "KR_EQUITY" | "HK_EQUITY" | "PREMARKET" => Ok(AssetClass::Equity),
130 "COMMODITY" => Ok(AssetClass::Commodity),
131 _ => anyhow::bail!(
132 "Unsupported underlying type '{underlying_type}' for TRADIFI_PERPETUAL symbol '{}'",
133 symbol.symbol
134 ),
135 }
136}
137
138pub fn get_currency(code: &str) -> Currency {
140 Currency::get_or_create_crypto(code)
141}
142
143fn get_filter<'a>(filters: &'a [Value], filter_type: &str) -> Option<&'a Value> {
145 filters.iter().find(|f| {
146 f.get("filterType")
147 .and_then(|v| v.as_str())
148 .is_some_and(|t| t == filter_type)
149 })
150}
151
152fn parse_filter_string(filter: &Value, field: &str) -> anyhow::Result<String> {
154 filter
155 .get(field)
156 .and_then(|v| v.as_str())
157 .map(String::from)
158 .ok_or_else(|| anyhow::anyhow!("Missing field '{field}' in filter"))
159}
160
161fn parse_filter_price(filter: &Value, field: &str) -> anyhow::Result<Price> {
163 let value = parse_filter_string(filter, field)?;
164 Price::from_str(&value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
165}
166
167fn parse_filter_quantity(filter: &Value, field: &str) -> anyhow::Result<Quantity> {
169 let value = parse_filter_string(filter, field)?;
170 Quantity::from_str(&value)
171 .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
172}
173
174fn parse_futures_min_notional(filters: &[Value], currency: Currency) -> Option<Money> {
179 let filter = get_filter(filters, "MIN_NOTIONAL")?;
180 let raw = filter.get("notional").and_then(|v| v.as_str())?;
181 let amount = f64::from_str(raw).ok()?;
182 if amount <= 0.0 {
183 return None;
184 }
185 Some(Money::new(amount, currency))
186}
187
188#[must_use]
194pub(crate) fn parse_quantity_at_precision(raw: &str, precision: u8) -> Option<Quantity> {
195 let decimal = Decimal::from_str(raw).ok()?;
196 if !decimal.is_sign_positive() || decimal.is_zero() {
197 return None;
198 }
199
200 Quantity::from_decimal_dp(decimal, precision).ok()
201}
202
203#[must_use]
208pub(crate) fn parse_price_at_precision(raw: &str, precision: u8) -> Option<Price> {
209 let decimal = Decimal::from_str(raw).ok()?;
210 if !decimal.is_sign_positive() || decimal.is_zero() {
211 return None;
212 }
213
214 Price::from_decimal_dp(decimal, precision).ok()
215}
216
217pub(crate) fn parse_required_decimal(raw: &str, field: &str) -> anyhow::Result<Decimal> {
219 Decimal::from_str(raw).map_err(|e| anyhow::anyhow!("invalid {field}='{raw}': {e}"))
220}
221
222pub(crate) fn parse_required_quantity_at_precision(
224 raw: &str,
225 precision: u8,
226 field: &str,
227) -> anyhow::Result<Quantity> {
228 let decimal = parse_required_decimal(raw, field)?;
229 Quantity::from_decimal_dp(decimal, precision)
230 .map_err(|e| anyhow::anyhow!("invalid {field}='{raw}' at precision {precision}: {e}"))
231}
232
233pub(crate) fn parse_required_price_at_precision(
235 raw: &str,
236 precision: u8,
237 field: &str,
238) -> anyhow::Result<Price> {
239 let decimal = parse_required_decimal(raw, field)?;
240 Price::from_decimal_dp(decimal, precision)
241 .map_err(|e| anyhow::anyhow!("invalid {field}='{raw}' at precision {precision}: {e}"))
242}
243
244#[must_use]
246pub(crate) fn quantity_at_precision(quantity: Quantity, precision: u8) -> Option<Quantity> {
247 Quantity::from_decimal_dp(quantity.as_decimal(), precision).ok()
248}
249
250#[must_use]
252pub(crate) fn price_at_precision(price: Price, precision: u8) -> Option<Price> {
253 Price::from_decimal_dp(price.as_decimal(), precision).ok()
254}
255
256#[must_use]
261pub(crate) fn is_not_trading_error(error: &anyhow::Error) -> bool {
262 error
263 .chain()
264 .any(|cause| cause.to_string().contains("is not trading"))
265}
266
267#[must_use]
272pub(crate) fn should_warn_on_instrument_parse_error(
273 log_warnings: bool,
274 explicit: bool,
275 error: &anyhow::Error,
276) -> bool {
277 if !explicit && is_not_trading_error(error) {
278 return false;
279 }
280 log_warnings
281}
282
283pub fn parse_usdm_instrument(
293 symbol: &BinanceFuturesUsdSymbol,
294 ts_event: UnixNanos,
295 ts_init: UnixNanos,
296) -> anyhow::Result<InstrumentAny> {
297 parse_usdm_instrument_with_fees(symbol, ts_event, ts_init)
298}
299
300pub(crate) fn parse_usdm_instrument_with_fees(
301 symbol: &BinanceFuturesUsdSymbol,
302 ts_event: UnixNanos,
303 ts_init: UnixNanos,
304) -> anyhow::Result<InstrumentAny> {
305 enum ContractKind {
306 CryptoPerpetual,
307 TradFi(AssetClass),
308 Delivery,
309 }
310
311 let contract_kind = match symbol.contract_type.as_str() {
312 CONTRACT_TYPE_PERPETUAL => ContractKind::CryptoPerpetual,
313 CONTRACT_TYPE_TRADIFI_PERPETUAL => ContractKind::TradFi(parse_tradifi_asset_class(symbol)?),
314 CONTRACT_TYPE_CURRENT_WEEK
315 | CONTRACT_TYPE_NEXT_WEEK
316 | CONTRACT_TYPE_CURRENT_MONTH
317 | CONTRACT_TYPE_NEXT_MONTH
318 | CONTRACT_TYPE_CURRENT_QUARTER
319 | CONTRACT_TYPE_NEXT_QUARTER => ContractKind::Delivery,
320 _ => anyhow::bail!(
321 "Unsupported USD-M contract type '{}' for symbol '{}'",
322 symbol.contract_type,
323 symbol.symbol,
324 ),
325 };
326
327 if symbol.status != BinanceTradingStatus::Trading {
328 anyhow::bail!(
329 "Symbol '{}' is not trading (status: {:?})",
330 symbol.symbol,
331 symbol.status
332 );
333 }
334
335 let quote_currency = get_currency(symbol.quote_asset.as_str());
336 let settlement_currency = get_currency(symbol.margin_asset.as_str());
337
338 let instrument_id = format_instrument_id(&symbol.symbol, BinanceProductType::UsdM);
339 let raw_symbol = Symbol::new(symbol.symbol.as_str());
340
341 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
342 .context("Missing PRICE_FILTER in symbol filters")?;
343
344 let tick_size = parse_filter_price(price_filter, "tickSize")?;
345 if tick_size.is_zero() {
346 anyhow::bail!(
347 "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
348 symbol.symbol,
349 );
350 }
351 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
352 let min_price = parse_filter_price(price_filter, "minPrice").ok();
353
354 let lot_filter =
355 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
356
357 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
358 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
359 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
360
361 let min_notional = parse_futures_min_notional(&symbol.filters, quote_currency);
362
363 let default_margin = Decimal::new(1, 1);
365
366 match contract_kind {
367 ContractKind::TradFi(asset_class) => {
368 let instrument = PerpetualContract::builder()
369 .instrument_id(instrument_id)
370 .raw_symbol(raw_symbol)
371 .underlying(symbol.base_asset)
372 .asset_class(asset_class)
373 .quote_currency(quote_currency)
374 .settlement_currency(settlement_currency)
375 .is_inverse(false)
376 .price_precision(tick_size.precision)
377 .size_precision(step_size.precision)
378 .price_increment(tick_size)
379 .size_increment(step_size)
380 .lot_size(step_size)
381 .maybe_max_quantity(max_quantity)
382 .maybe_min_quantity(min_quantity)
383 .maybe_min_notional(min_notional)
384 .maybe_max_price(max_price)
385 .maybe_min_price(min_price)
386 .margin_init(default_margin)
387 .margin_maint(default_margin)
388 .ts_event(ts_event)
389 .ts_init(ts_init)
390 .build()?;
391 Ok(InstrumentAny::PerpetualContract(instrument))
392 }
393 ContractKind::CryptoPerpetual => {
394 let instrument = CryptoPerpetual::builder()
395 .instrument_id(instrument_id)
396 .raw_symbol(raw_symbol)
397 .base_currency(get_currency(symbol.base_asset.as_str()))
398 .quote_currency(quote_currency)
399 .settlement_currency(settlement_currency)
400 .is_inverse(false)
401 .price_precision(tick_size.precision)
402 .size_precision(step_size.precision)
403 .price_increment(tick_size)
404 .size_increment(step_size)
405 .lot_size(step_size)
406 .maybe_max_quantity(max_quantity)
407 .maybe_min_quantity(min_quantity)
408 .maybe_min_notional(min_notional)
409 .maybe_max_price(max_price)
410 .maybe_min_price(min_price)
411 .margin_init(default_margin)
412 .margin_maint(default_margin)
413 .ts_event(ts_event)
414 .ts_init(ts_init)
415 .build()
416 .unwrap();
417 Ok(InstrumentAny::CryptoPerpetual(instrument))
418 }
419 ContractKind::Delivery => {
420 let activation_ns = parse_millis(symbol.onboard_date, "Futures onboardDate")?;
421 let expiration_ns = parse_millis(symbol.delivery_date, "Futures deliveryDate")?;
422 let instrument = CryptoFuture::builder()
423 .instrument_id(instrument_id)
424 .raw_symbol(raw_symbol)
425 .underlying(get_currency(symbol.base_asset.as_str()))
426 .quote_currency(quote_currency)
427 .settlement_currency(settlement_currency)
428 .is_inverse(false)
429 .activation_ns(activation_ns)
430 .expiration_ns(expiration_ns)
431 .price_precision(tick_size.precision)
432 .size_precision(step_size.precision)
433 .price_increment(tick_size)
434 .size_increment(step_size)
435 .lot_size(step_size)
436 .maybe_max_quantity(max_quantity)
437 .maybe_min_quantity(min_quantity)
438 .maybe_min_notional(min_notional)
439 .maybe_max_price(max_price)
440 .maybe_min_price(min_price)
441 .margin_init(default_margin)
442 .margin_maint(default_margin)
443 .ts_event(ts_event)
444 .ts_init(ts_init)
445 .build()
446 .unwrap();
447 Ok(InstrumentAny::CryptoFuture(instrument))
448 }
449 }
450}
451
452pub fn parse_coinm_instrument(
464 symbol: &BinanceFuturesCoinSymbol,
465 ts_event: UnixNanos,
466 ts_init: UnixNanos,
467) -> anyhow::Result<InstrumentAny> {
468 parse_coinm_instrument_with_fees(symbol, ts_event, ts_init)
469}
470
471pub(crate) fn parse_coinm_instrument_with_fees(
472 symbol: &BinanceFuturesCoinSymbol,
473 ts_event: UnixNanos,
474 ts_init: UnixNanos,
475) -> anyhow::Result<InstrumentAny> {
476 let is_perpetual = symbol.contract_type == CONTRACT_TYPE_PERPETUAL;
477 let is_delivery = matches!(
478 symbol.contract_type.as_str(),
479 CONTRACT_TYPE_CURRENT_QUARTER | CONTRACT_TYPE_NEXT_QUARTER
480 );
481
482 if !is_perpetual && !is_delivery {
483 anyhow::bail!(
484 "Unsupported COIN-M contract type '{}' for symbol '{}'",
485 symbol.contract_type,
486 symbol.symbol,
487 );
488 }
489
490 if symbol.contract_status != Some(BinanceContractStatus::Trading) {
491 anyhow::bail!(
492 "Symbol '{}' is not trading (status: {:?})",
493 symbol.symbol,
494 symbol.contract_status
495 );
496 }
497
498 let base_currency = get_currency(symbol.base_asset.as_str());
499 let quote_currency = get_currency(symbol.quote_asset.as_str());
500
501 let settlement_currency = get_currency(symbol.margin_asset.as_str());
503
504 let instrument_id = format_instrument_id(&symbol.symbol, BinanceProductType::CoinM);
505 let raw_symbol = Symbol::new(symbol.symbol.as_str());
506
507 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
508 .context("Missing PRICE_FILTER in symbol filters")?;
509
510 let tick_size = parse_filter_price(price_filter, "tickSize")?;
511 if tick_size.is_zero() {
512 anyhow::bail!(
513 "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
514 symbol.symbol,
515 );
516 }
517 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
518 let min_price = parse_filter_price(price_filter, "minPrice").ok();
519
520 let lot_filter =
521 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
522
523 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
524 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
525 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
526
527 let multiplier = Quantity::from(symbol.contract_size);
529
530 let min_notional = parse_futures_min_notional(&symbol.filters, quote_currency);
531
532 let default_margin = Decimal::new(1, 1);
534
535 if is_perpetual {
536 let instrument = CryptoPerpetual::builder()
537 .instrument_id(instrument_id)
538 .raw_symbol(raw_symbol)
539 .base_currency(base_currency)
540 .quote_currency(quote_currency)
541 .settlement_currency(settlement_currency)
542 .is_inverse(true)
543 .price_precision(tick_size.precision)
544 .size_precision(step_size.precision)
545 .price_increment(tick_size)
546 .size_increment(step_size)
547 .multiplier(multiplier)
548 .lot_size(step_size)
549 .maybe_max_quantity(max_quantity)
550 .maybe_min_quantity(min_quantity)
551 .maybe_min_notional(min_notional)
552 .maybe_max_price(max_price)
553 .maybe_min_price(min_price)
554 .margin_init(default_margin)
555 .margin_maint(default_margin)
556 .ts_event(ts_event)
557 .ts_init(ts_init)
558 .build()
559 .unwrap();
560 Ok(InstrumentAny::CryptoPerpetual(instrument))
561 } else {
562 let activation_ns = parse_millis(symbol.onboard_date, "Futures onboardDate")?;
563 let expiration_ns = parse_millis(symbol.delivery_date, "Futures deliveryDate")?;
564 let instrument = CryptoFuture::builder()
565 .instrument_id(instrument_id)
566 .raw_symbol(raw_symbol)
567 .underlying(base_currency)
568 .quote_currency(quote_currency)
569 .settlement_currency(settlement_currency)
570 .is_inverse(true)
571 .activation_ns(activation_ns)
572 .expiration_ns(expiration_ns)
573 .price_precision(tick_size.precision)
574 .size_precision(step_size.precision)
575 .price_increment(tick_size)
576 .size_increment(step_size)
577 .multiplier(multiplier)
578 .lot_size(step_size)
579 .maybe_max_quantity(max_quantity)
580 .maybe_min_quantity(min_quantity)
581 .maybe_min_notional(min_notional)
582 .maybe_max_price(max_price)
583 .maybe_min_price(min_price)
584 .margin_init(default_margin)
585 .margin_maint(default_margin)
586 .ts_event(ts_event)
587 .ts_init(ts_init)
588 .build()
589 .unwrap();
590 Ok(InstrumentAny::CryptoFuture(instrument))
591 }
592}
593
594const SBE_STATUS_TRADING: u8 = 0;
596
597fn sbe_mantissa_precision(mantissa: i64, exponent: i8) -> u8 {
610 if mantissa == 0 {
611 return 0;
612 }
613 let mut m = mantissa.abs();
614 let mut trailing_zeros: i8 = 0;
615
616 while m > 0 && m % 10 == 0 {
617 m /= 10;
618 trailing_zeros += 1;
619 }
620 (-exponent - trailing_zeros).max(0) as u8
621}
622
623fn parse_sbe_price_filter(
625 filter: &BinancePriceFilterSbe,
626) -> anyhow::Result<(Price, Option<Price>, Option<Price>)> {
627 let precision = sbe_mantissa_precision(filter.tick_size, filter.price_exponent);
628
629 let tick_size =
630 Price::from_mantissa_exponent_checked(filter.tick_size, filter.price_exponent, precision)?;
631
632 let max_price = if filter.max_price != 0 {
633 Some(Price::from_mantissa_exponent_checked(
634 filter.max_price,
635 filter.price_exponent,
636 precision,
637 )?)
638 } else {
639 None
640 };
641
642 let min_price = if filter.min_price != 0 {
643 Some(Price::from_mantissa_exponent_checked(
644 filter.min_price,
645 filter.price_exponent,
646 precision,
647 )?)
648 } else {
649 None
650 };
651
652 Ok((tick_size, max_price, min_price))
653}
654
655fn parse_sbe_lot_size_filter(
657 filter: &BinanceLotSizeFilterSbe,
658) -> anyhow::Result<(Quantity, Option<Quantity>, Option<Quantity>)> {
659 let precision = sbe_mantissa_precision(filter.step_size, filter.qty_exponent);
660
661 let step_size = Quantity::from_mantissa_exponent_checked(
662 filter.step_size as u64,
663 filter.qty_exponent,
664 precision,
665 )?;
666
667 let max_qty = if filter.max_qty != 0 {
668 Some(Quantity::from_mantissa_exponent_checked(
669 filter.max_qty as u64,
670 filter.qty_exponent,
671 precision,
672 )?)
673 } else {
674 None
675 };
676
677 let min_qty = if filter.min_qty != 0 {
678 Some(Quantity::from_mantissa_exponent_checked(
679 filter.min_qty as u64,
680 filter.qty_exponent,
681 precision,
682 )?)
683 } else {
684 None
685 };
686
687 Ok((step_size, max_qty, min_qty))
688}
689
690pub fn parse_spot_instrument_sbe(
699 symbol: &BinanceSymbolSbe,
700 ts_event: UnixNanos,
701 ts_init: UnixNanos,
702) -> anyhow::Result<InstrumentAny> {
703 parse_spot_instrument_sbe_with_fees(symbol, ts_event, ts_init)
704}
705
706pub(crate) fn parse_spot_instrument_sbe_with_fees(
707 symbol: &BinanceSymbolSbe,
708 ts_event: UnixNanos,
709 ts_init: UnixNanos,
710) -> anyhow::Result<InstrumentAny> {
711 if symbol.status != SBE_STATUS_TRADING {
712 anyhow::bail!(
713 "Symbol '{}' is not trading (status: {})",
714 symbol.symbol,
715 symbol.status
716 );
717 }
718
719 let base_currency = get_currency(&symbol.base_asset);
720 let quote_currency = get_currency(&symbol.quote_asset);
721
722 let instrument_id = InstrumentId::new(
723 Symbol::from_str_unchecked(&symbol.symbol),
724 Venue::new(BINANCE),
725 );
726 let raw_symbol = Symbol::new(&symbol.symbol);
727
728 let price_filter = symbol
729 .filters
730 .price_filter
731 .as_ref()
732 .context("Missing PRICE_FILTER in symbol filters")?;
733
734 let (tick_size, max_price, min_price) = parse_sbe_price_filter(price_filter)?;
735
736 let lot_filter = symbol
737 .filters
738 .lot_size_filter
739 .as_ref()
740 .context("Missing LOT_SIZE in symbol filters")?;
741
742 let (step_size, max_quantity, min_quantity) = parse_sbe_lot_size_filter(lot_filter)?;
743
744 let (min_notional, max_notional) =
745 parse_spot_notional_rules(&symbol.filters.notional_filters, quote_currency)?;
746
747 let default_margin = Decimal::new(1, 0);
749
750 let instrument = CurrencyPair::builder()
751 .instrument_id(instrument_id)
752 .raw_symbol(raw_symbol)
753 .base_currency(base_currency)
754 .quote_currency(quote_currency)
755 .price_precision(tick_size.precision)
756 .size_precision(step_size.precision)
757 .price_increment(tick_size)
758 .size_increment(step_size)
759 .lot_size(step_size)
760 .maybe_max_quantity(max_quantity)
761 .maybe_min_quantity(min_quantity)
762 .maybe_min_notional(min_notional)
763 .maybe_max_notional(max_notional)
764 .maybe_max_price(max_price)
765 .maybe_min_price(min_price)
766 .margin_init(default_margin)
767 .margin_maint(default_margin)
768 .ts_event(ts_event)
769 .ts_init(ts_init)
770 .build()
771 .unwrap();
772
773 Ok(InstrumentAny::CurrencyPair(instrument))
774}
775
776pub(crate) fn parse_spot_instrument_json_with_fees(
777 symbol: &BinanceSymbolJson,
778 ts_event: UnixNanos,
779 ts_init: UnixNanos,
780) -> anyhow::Result<InstrumentAny> {
781 anyhow::ensure!(
782 symbol.status == "TRADING",
783 "Symbol '{}' is not trading (status: {})",
784 symbol.symbol,
785 symbol.status,
786 );
787
788 let price_filter = symbol
789 .filters
790 .iter()
791 .find(|filter| filter.filter_type == "PRICE_FILTER")
792 .context("Missing PRICE_FILTER in symbol filters")?;
793 let lot_filter = symbol
794 .filters
795 .iter()
796 .find(|filter| filter.filter_type == "LOT_SIZE")
797 .context("Missing LOT_SIZE in symbol filters")?;
798
799 let tick_size = decimal_price(
800 price_filter
801 .tick_size
802 .as_deref()
803 .context("Missing PRICE_FILTER tickSize")?,
804 )?;
805 anyhow::ensure!(!tick_size.is_zero(), "Invalid tickSize of 0");
806 let step_size = decimal_quantity(
807 lot_filter
808 .step_size
809 .as_deref()
810 .context("Missing LOT_SIZE stepSize")?,
811 )?;
812 anyhow::ensure!(!step_size.is_zero(), "Invalid stepSize of 0");
813
814 let quote_currency = get_currency(&symbol.quote_asset);
815 let mut rules = Vec::new();
816
817 for filter in &symbol.filters {
818 if !matches!(filter.filter_type.as_str(), "MIN_NOTIONAL" | "NOTIONAL") {
819 continue;
820 }
821
822 let range = filter.filter_type == "NOTIONAL";
823
824 rules.push(BinanceNotionalFilter {
825 min: Decimal::from_str_exact(
826 filter
827 .min_notional
828 .as_deref()
829 .context("missing minNotional")?,
830 )?,
831
832 max: if range {
833 Some(Decimal::from_str_exact(
834 filter
835 .max_notional
836 .as_deref()
837 .context("missing maxNotional")?,
838 )?)
839 } else {
840 None
841 },
842
843 apply_min_to_market: if range {
844 filter.apply_min_to_market
845 } else {
846 filter.apply_to_market
847 }
848 .context("missing minimum notional market flag")?,
849
850 apply_max_to_market: if range {
851 filter
852 .apply_max_to_market
853 .context("missing applyMaxToMarket")?
854 } else {
855 false
856 },
857
858 avg_price_mins: filter.avg_price_mins.context("missing avgPriceMins")?,
859 });
860 }
861
862 let (min_notional, max_notional) = parse_spot_notional_rules(&rules, quote_currency)?;
863
864 let instrument = CurrencyPair::builder()
865 .instrument_id(InstrumentId::new(
866 Symbol::from_str_unchecked(&symbol.symbol),
867 Venue::new(BINANCE),
868 ))
869 .raw_symbol(Symbol::new(&symbol.symbol))
870 .base_currency(get_currency(&symbol.base_asset))
871 .quote_currency(quote_currency)
872 .maybe_min_notional(min_notional)
873 .maybe_max_notional(max_notional)
874 .price_precision(tick_size.precision)
875 .size_precision(step_size.precision)
876 .price_increment(tick_size)
877 .size_increment(step_size)
878 .lot_size(step_size)
879 .maybe_max_quantity(optional_decimal_quantity(
880 lot_filter.max_qty.as_deref(),
881 step_size.precision,
882 )?)
883 .maybe_min_quantity(optional_decimal_quantity(
884 lot_filter.min_qty.as_deref(),
885 step_size.precision,
886 )?)
887 .maybe_max_price(optional_decimal_price(
888 price_filter.max_price.as_deref(),
889 tick_size.precision,
890 )?)
891 .maybe_min_price(optional_decimal_price(
892 price_filter.min_price.as_deref(),
893 tick_size.precision,
894 )?)
895 .margin_init(Decimal::ONE)
896 .margin_maint(Decimal::ONE)
897 .ts_event(ts_event)
898 .ts_init(ts_init)
899 .build()
900 .unwrap();
901
902 Ok(InstrumentAny::CurrencyPair(instrument))
903}
904
905fn parse_spot_notional_rules(
906 rules: &[BinanceNotionalFilter],
907 currency: Currency,
908) -> anyhow::Result<(Option<Money>, Option<Money>)> {
909 let mut minimum: Option<Decimal> = None;
910 let mut maximum: Option<Decimal> = None;
911 for rule in rules {
912 anyhow::ensure!(rule.min >= Decimal::ZERO, "negative minimum notional");
913 minimum = Some(minimum.map_or(rule.min, |min| min.max(rule.min)));
914
915 if let Some(max) = rule.max {
916 anyhow::ensure!(max >= rule.min, "maximum notional is below minimum");
917 maximum = Some(maximum.map_or(max, |current| current.min(max)));
918 }
919 }
920
921 if let (Some(min), Some(max)) = (minimum, maximum) {
922 anyhow::ensure!(min <= max, "conflicting notional bounds");
923 }
924 Ok((
925 minimum
926 .map(|value| Money::from_decimal(value, currency))
927 .transpose()?,
928 maximum
929 .map(|value| Money::from_decimal(value, currency))
930 .transpose()?,
931 ))
932}
933
934fn decimal_price(value: &str) -> anyhow::Result<Price> {
935 let decimal = Decimal::from_str_exact(value)?.normalize();
936 let precision = u8::try_from(decimal.scale()).context("price precision exceeds u8")?;
937 Ok(Price::from_decimal_dp(decimal, precision)?)
938}
939
940fn decimal_quantity(value: &str) -> anyhow::Result<Quantity> {
941 let decimal = Decimal::from_str_exact(value)?.normalize();
942 let precision = u8::try_from(decimal.scale()).context("quantity precision exceeds u8")?;
943 Ok(Quantity::from_decimal_dp(decimal, precision)?)
944}
945
946fn optional_decimal_price(value: Option<&str>, precision: u8) -> anyhow::Result<Option<Price>> {
947 let Some(value) = value else {
948 return Ok(None);
949 };
950 let decimal = Decimal::from_str_exact(value)?;
951 if decimal.is_zero() {
952 return Ok(None);
953 }
954 Ok(Some(Price::from_decimal_dp(decimal, precision)?))
955}
956
957fn optional_decimal_quantity(
958 value: Option<&str>,
959 precision: u8,
960) -> anyhow::Result<Option<Quantity>> {
961 let Some(value) = value else {
962 return Ok(None);
963 };
964 let decimal = Decimal::from_str_exact(value)?;
965 if decimal.is_zero() {
966 return Ok(None);
967 }
968 Ok(Some(Quantity::from_decimal_dp(decimal, precision)?))
969}
970
971pub fn parse_spot_trades_sbe(
979 trades: &BinanceTrades,
980 instrument: &InstrumentAny,
981 ts_init: UnixNanos,
982) -> anyhow::Result<Vec<TradeTick>> {
983 let instrument_id = instrument.id();
984 let price_precision = instrument.price_precision();
985 let size_precision = instrument.size_precision();
986
987 let mut result = Vec::with_capacity(trades.trades.len());
988
989 for trade in &trades.trades {
990 let price = Price::from_mantissa_exponent(
991 trade.price_mantissa,
992 trades.price_exponent,
993 price_precision,
994 );
995 let size = Quantity::from_mantissa_exponent(
996 trade.qty_mantissa as u64,
997 trades.qty_exponent,
998 size_precision,
999 );
1000
1001 let aggressor_side = if trade.is_buyer_maker {
1003 AggressorSide::Sell
1004 } else {
1005 AggressorSide::Buy
1006 };
1007
1008 let ts_event = parse_micros(trade.time, "Spot SBE trade time")?;
1010
1011 let tick = TradeTick::new(
1012 instrument_id,
1013 price,
1014 size,
1015 aggressor_side,
1016 TradeId::new(trade.id.to_string()),
1017 ts_event,
1018 ts_init,
1019 );
1020
1021 result.push(tick);
1022 }
1023
1024 Ok(result)
1025}
1026
1027pub fn map_order_status_sbe(status: SbeOrderStatus) -> anyhow::Result<OrderStatus> {
1033 Ok(match status {
1034 SbeOrderStatus::New => OrderStatus::Accepted,
1035 SbeOrderStatus::PendingNew => OrderStatus::Submitted,
1036 SbeOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1037 SbeOrderStatus::Filled => OrderStatus::Filled,
1038 SbeOrderStatus::Canceled => OrderStatus::Canceled,
1039 SbeOrderStatus::PendingCancel => OrderStatus::PendingCancel,
1040 SbeOrderStatus::Rejected => OrderStatus::Rejected,
1041 SbeOrderStatus::Expired | SbeOrderStatus::ExpiredInMatch => OrderStatus::Expired,
1042 SbeOrderStatus::Unknown | SbeOrderStatus::NonRepresentable | SbeOrderStatus::NullVal => {
1043 anyhow::bail!("unknown Binance SBE order status")
1044 }
1045 })
1046}
1047
1048pub fn map_order_type_sbe(order_type: SbeOrderType) -> anyhow::Result<OrderType> {
1054 Ok(match order_type {
1055 SbeOrderType::Market => OrderType::Market,
1056 SbeOrderType::Limit | SbeOrderType::LimitMaker => OrderType::Limit,
1057 SbeOrderType::StopLoss | SbeOrderType::TakeProfit => OrderType::StopMarket,
1058 SbeOrderType::StopLossLimit | SbeOrderType::TakeProfitLimit => OrderType::StopLimit,
1059 SbeOrderType::NonRepresentable | SbeOrderType::NullVal => {
1060 anyhow::bail!("unknown Binance SBE order type")
1061 }
1062 })
1063}
1064
1065#[must_use]
1067pub const fn map_order_side_sbe(side: SbeOrderSide) -> Option<OrderSide> {
1068 match side {
1069 SbeOrderSide::Buy => Some(OrderSide::Buy),
1070 SbeOrderSide::Sell => Some(OrderSide::Sell),
1071 SbeOrderSide::NonRepresentable | SbeOrderSide::NullVal => None,
1072 }
1073}
1074
1075pub fn map_time_in_force_sbe(tif: SbeTimeInForce) -> anyhow::Result<TimeInForce> {
1081 Ok(match tif {
1082 SbeTimeInForce::Gtc => TimeInForce::Gtc,
1083 SbeTimeInForce::Ioc => TimeInForce::Ioc,
1084 SbeTimeInForce::Fok => TimeInForce::Fok,
1085 SbeTimeInForce::NonRepresentable | SbeTimeInForce::NullVal => {
1086 anyhow::bail!("unknown Binance SBE time in force")
1087 }
1088 })
1089}
1090
1091pub fn parse_order_status_report_sbe(
1097 order: &BinanceOrderResponse,
1098 account_id: AccountId,
1099 instrument: &InstrumentAny,
1100 broker_id: &str,
1101 ts_init: UnixNanos,
1102) -> anyhow::Result<OrderStatusReport> {
1103 let instrument_id = instrument.id();
1104 let price_precision = instrument.price_precision();
1105 let size_precision = instrument.size_precision();
1106
1107 let price = if order.price_mantissa != 0 {
1108 Some(Price::from_mantissa_exponent(
1109 order.price_mantissa,
1110 order.price_exponent,
1111 price_precision,
1112 ))
1113 } else {
1114 None
1115 };
1116
1117 let quantity = Quantity::from_mantissa_exponent(
1118 order.orig_qty_mantissa as u64,
1119 order.qty_exponent,
1120 size_precision,
1121 );
1122 let filled_qty = Quantity::from_mantissa_exponent(
1123 order.executed_qty_mantissa as u64,
1124 order.qty_exponent,
1125 size_precision,
1126 );
1127
1128 let avg_px = if order.executed_qty_mantissa > 0 {
1131 let quote_exp = (order.price_exponent as i32) + (order.qty_exponent as i32);
1132 let cum_quote_dec = Decimal::new(order.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
1133 let filled_dec = Decimal::new(
1134 order.executed_qty_mantissa,
1135 (-order.qty_exponent as i32) as u32,
1136 );
1137 let avg_dec = cum_quote_dec / filled_dec;
1138 Some(
1139 Price::from_decimal_dp(avg_dec, price_precision)
1140 .unwrap_or(Price::zero(price_precision)),
1141 )
1142 } else {
1143 None
1144 };
1145
1146 let trigger_price = order.stop_price_mantissa.and_then(|mantissa| {
1148 if mantissa != 0 {
1149 Some(Price::from_mantissa_exponent(
1150 mantissa,
1151 order.price_exponent,
1152 price_precision,
1153 ))
1154 } else {
1155 None
1156 }
1157 });
1158
1159 let order_status = map_order_status_sbe(order.status)?;
1161 let order_type = map_order_type_sbe(order.order_type)?;
1162 let order_side = map_order_side_sbe(order.side);
1163 let time_in_force = map_time_in_force_sbe(order.time_in_force)?;
1164
1165 let trigger_type = if trigger_price.is_some() {
1167 Some(TriggerType::LastPrice)
1168 } else {
1169 None
1170 };
1171
1172 let ts_event = parse_micros(order.update_time, "Spot SBE order update time")?;
1174
1175 let order_list_id = order.order_list_id.and_then(|id| {
1177 if id > 0 {
1178 Some(OrderListId::new(id.to_string()))
1179 } else {
1180 None
1181 }
1182 });
1183
1184 let post_only = order.order_type == SbeOrderType::LimitMaker;
1186
1187 let ts_accepted = parse_micros(order.time, "Spot SBE order time")?;
1189
1190 let mut report = OrderStatusReport::new(
1191 account_id,
1192 instrument_id,
1193 Some(decode_client_order_id(&order.client_order_id, broker_id)?),
1194 VenueOrderId::new(order.order_id.to_string()),
1195 order_side,
1196 order_type,
1197 time_in_force,
1198 order_status,
1199 quantity,
1200 filled_qty,
1201 ts_accepted,
1202 ts_event,
1203 ts_init,
1204 None, );
1206
1207 if let Some(p) = price {
1209 report = report.with_price(p);
1210 }
1211
1212 if let Some(ap) = avg_px {
1213 report = report.with_avg_px(ap.as_decimal());
1214 }
1215
1216 if let Some(tp) = trigger_price {
1217 report = report.with_trigger_price(tp);
1218 }
1219
1220 if let Some(tt) = trigger_type {
1221 report = report.with_trigger_type(tt);
1222 }
1223
1224 if let Some(oli) = order_list_id {
1225 report = report.with_order_list_id(oli);
1226 }
1227
1228 if post_only {
1229 report = report.with_post_only(true);
1230 }
1231
1232 Ok(report)
1233}
1234
1235pub fn parse_new_order_response_sbe(
1241 response: &BinanceNewOrderResponse,
1242 account_id: AccountId,
1243 instrument: &InstrumentAny,
1244 broker_id: &str,
1245 ts_init: UnixNanos,
1246) -> anyhow::Result<OrderStatusReport> {
1247 let instrument_id = instrument.id();
1248 let price_precision = instrument.price_precision();
1249 let size_precision = instrument.size_precision();
1250
1251 let price = if response.price_mantissa != 0 {
1252 Some(Price::from_mantissa_exponent(
1253 response.price_mantissa,
1254 response.price_exponent,
1255 price_precision,
1256 ))
1257 } else {
1258 None
1259 };
1260
1261 let quantity = Quantity::from_mantissa_exponent(
1262 response.orig_qty_mantissa as u64,
1263 response.qty_exponent,
1264 size_precision,
1265 );
1266 let filled_qty = Quantity::from_mantissa_exponent(
1267 response.executed_qty_mantissa as u64,
1268 response.qty_exponent,
1269 size_precision,
1270 );
1271
1272 let avg_px = if response.executed_qty_mantissa > 0 {
1275 let quote_exp = (response.price_exponent as i32) + (response.qty_exponent as i32);
1276 let cum_quote_dec =
1277 Decimal::new(response.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
1278 let filled_dec = Decimal::new(
1279 response.executed_qty_mantissa,
1280 (-response.qty_exponent as i32) as u32,
1281 );
1282 let avg_dec = cum_quote_dec / filled_dec;
1283 Some(
1284 Price::from_decimal_dp(avg_dec, price_precision)
1285 .unwrap_or(Price::zero(price_precision)),
1286 )
1287 } else {
1288 None
1289 };
1290
1291 let trigger_price = response.stop_price_mantissa.and_then(|mantissa| {
1292 if mantissa != 0 {
1293 Some(Price::from_mantissa_exponent(
1294 mantissa,
1295 response.price_exponent,
1296 price_precision,
1297 ))
1298 } else {
1299 None
1300 }
1301 });
1302
1303 let order_status = map_order_status_sbe(response.status)?;
1304 let order_type = map_order_type_sbe(response.order_type)?;
1305 let order_side = map_order_side_sbe(response.side);
1306 let time_in_force = map_time_in_force_sbe(response.time_in_force)?;
1307
1308 let trigger_type = if trigger_price.is_some() {
1309 Some(TriggerType::LastPrice)
1310 } else {
1311 None
1312 };
1313
1314 let ts_event = parse_micros(response.transact_time, "Spot SBE transaction time")?;
1316 let ts_accepted = ts_event;
1317
1318 let order_list_id = response.order_list_id.and_then(|id| {
1319 if id > 0 {
1320 Some(OrderListId::new(id.to_string()))
1321 } else {
1322 None
1323 }
1324 });
1325
1326 let post_only = response.order_type == SbeOrderType::LimitMaker;
1328
1329 let mut report = OrderStatusReport::new(
1330 account_id,
1331 instrument_id,
1332 Some(decode_client_order_id(
1333 &response.client_order_id,
1334 broker_id,
1335 )?),
1336 VenueOrderId::new(response.order_id.to_string()),
1337 order_side,
1338 order_type,
1339 time_in_force,
1340 order_status,
1341 quantity,
1342 filled_qty,
1343 ts_accepted,
1344 ts_event,
1345 ts_init,
1346 None,
1347 );
1348
1349 if let Some(p) = price {
1350 report = report.with_price(p);
1351 }
1352
1353 if let Some(ap) = avg_px {
1354 report = report.with_avg_px(ap.as_decimal());
1355 }
1356
1357 if let Some(tp) = trigger_price {
1358 report = report.with_trigger_price(tp);
1359 }
1360
1361 if let Some(tt) = trigger_type {
1362 report = report.with_trigger_type(tt);
1363 }
1364
1365 if let Some(oli) = order_list_id {
1366 report = report.with_order_list_id(oli);
1367 }
1368
1369 if post_only {
1370 report = report.with_post_only(true);
1371 }
1372
1373 Ok(report)
1374}
1375
1376pub fn parse_fill_report_sbe(
1382 trade: &BinanceAccountTrade,
1383 account_id: AccountId,
1384 instrument: &InstrumentAny,
1385 commission_currency: Currency,
1386 ts_init: UnixNanos,
1387) -> anyhow::Result<FillReport> {
1388 let instrument_id = instrument.id();
1389 let price_precision = instrument.price_precision();
1390 let size_precision = instrument.size_precision();
1391
1392 let last_px =
1393 Price::from_mantissa_exponent(trade.price_mantissa, trade.price_exponent, price_precision);
1394 let last_qty = Quantity::from_mantissa_exponent(
1395 trade.qty_mantissa as u64,
1396 trade.qty_exponent,
1397 size_precision,
1398 );
1399
1400 let comm_exp = trade.commission_exponent as i32;
1401 let comm_dec = Decimal::new(trade.commission_mantissa, (-comm_exp) as u32);
1402 let commission = Money::from_decimal(comm_dec, commission_currency)?;
1403
1404 let order_side = if trade.is_buyer {
1406 OrderSide::Buy
1407 } else {
1408 OrderSide::Sell
1409 };
1410
1411 let liquidity_side = if trade.is_maker {
1413 LiquiditySide::Maker
1414 } else {
1415 LiquiditySide::Taker
1416 };
1417
1418 let ts_event = parse_micros(trade.time, "Spot SBE account trade time")?;
1420
1421 Ok(FillReport::new(
1422 account_id,
1423 instrument_id,
1424 VenueOrderId::new(trade.order_id.to_string()),
1425 TradeId::new(trade.id.to_string()),
1426 order_side,
1427 last_qty,
1428 last_px,
1429 commission,
1430 liquidity_side,
1431 None, None, ts_event,
1434 ts_init,
1435 None, ))
1437}
1438
1439pub fn parse_klines_to_binance_bars(
1445 klines: &BinanceKlines,
1446 bar_type: BarType,
1447 instrument: &InstrumentAny,
1448 ts_init: UnixNanos,
1449) -> anyhow::Result<Vec<crate::common::bar::BinanceBar>> {
1450 let price_precision = instrument.price_precision();
1451 let size_precision = instrument.size_precision();
1452
1453 let mut bars = Vec::with_capacity(klines.klines.len());
1454
1455 for kline in &klines.klines {
1456 let open =
1457 Price::from_mantissa_exponent(kline.open_price, klines.price_exponent, price_precision);
1458 let high =
1459 Price::from_mantissa_exponent(kline.high_price, klines.price_exponent, price_precision);
1460 let low =
1461 Price::from_mantissa_exponent(kline.low_price, klines.price_exponent, price_precision);
1462 let close = Price::from_mantissa_exponent(
1463 kline.close_price,
1464 klines.price_exponent,
1465 price_precision,
1466 );
1467
1468 let volume_mantissa = i128::from_le_bytes(kline.volume);
1469 let volume_dec =
1470 Decimal::from_i128_with_scale(volume_mantissa, (-klines.qty_exponent as i32) as u32);
1471 let volume = Quantity::from_decimal_dp(volume_dec, size_precision)?;
1472
1473 let quote_volume = Decimal::from_i128_with_scale(
1474 i128::from_le_bytes(kline.quote_volume),
1475 (-klines.price_exponent as i32) as u32,
1476 );
1477 let taker_buy_base_volume = Decimal::from_i128_with_scale(
1478 i128::from_le_bytes(kline.taker_buy_base_volume),
1479 (-klines.qty_exponent as i32) as u32,
1480 );
1481 let taker_buy_quote_volume = Decimal::from_i128_with_scale(
1482 i128::from_le_bytes(kline.taker_buy_quote_volume),
1483 (-klines.price_exponent as i32) as u32,
1484 );
1485 let count = u64::try_from(kline.num_trades).map_err(|_| {
1486 anyhow::anyhow!("invalid negative kline trade count {}", kline.num_trades)
1487 })?;
1488 let ts_event = parse_micros(kline.close_time, "Spot SBE kline close time")?;
1489
1490 let bar = crate::common::bar::BinanceBar::new(
1491 bar_type,
1492 open,
1493 high,
1494 low,
1495 close,
1496 volume,
1497 quote_volume,
1498 count,
1499 taker_buy_base_volume,
1500 taker_buy_quote_volume,
1501 ts_event,
1502 ts_init,
1503 );
1504 bars.push(bar);
1505 }
1506
1507 Ok(bars)
1508}
1509
1510pub fn parse_klines_to_bars(
1516 klines: &BinanceKlines,
1517 bar_type: BarType,
1518 instrument: &InstrumentAny,
1519 ts_init: UnixNanos,
1520) -> anyhow::Result<Vec<Bar>> {
1521 Ok(
1522 parse_klines_to_binance_bars(klines, bar_type, instrument, ts_init)?
1523 .into_iter()
1524 .map(|bar| bar.bar())
1525 .collect(),
1526 )
1527}
1528
1529pub fn bar_spec_to_binance_interval(
1536 bar_spec: BarSpecification,
1537) -> anyhow::Result<BinanceKlineInterval> {
1538 let step = bar_spec.step.get();
1539 let interval = match bar_spec.aggregation {
1540 BarAggregation::Second => match step {
1541 1 => BinanceKlineInterval::Second1,
1542 _ => anyhow::bail!("Unsupported second interval: {step}s"),
1543 },
1544 BarAggregation::Minute => match step {
1545 1 => BinanceKlineInterval::Minute1,
1546 3 => BinanceKlineInterval::Minute3,
1547 5 => BinanceKlineInterval::Minute5,
1548 15 => BinanceKlineInterval::Minute15,
1549 30 => BinanceKlineInterval::Minute30,
1550 _ => anyhow::bail!("Unsupported minute interval: {step}m"),
1551 },
1552 BarAggregation::Hour => match step {
1553 1 => BinanceKlineInterval::Hour1,
1554 2 => BinanceKlineInterval::Hour2,
1555 4 => BinanceKlineInterval::Hour4,
1556 6 => BinanceKlineInterval::Hour6,
1557 8 => BinanceKlineInterval::Hour8,
1558 12 => BinanceKlineInterval::Hour12,
1559 _ => anyhow::bail!("Unsupported hour interval: {step}h"),
1560 },
1561 BarAggregation::Day => match step {
1562 1 => BinanceKlineInterval::Day1,
1563 3 => BinanceKlineInterval::Day3,
1564 _ => anyhow::bail!("Unsupported day interval: {step}d"),
1565 },
1566 BarAggregation::Week => match step {
1567 1 => BinanceKlineInterval::Week1,
1568 _ => anyhow::bail!("Unsupported week interval: {step}w"),
1569 },
1570 BarAggregation::Month => match step {
1571 1 => BinanceKlineInterval::Month1,
1572 _ => anyhow::bail!("Unsupported month interval: {step}M"),
1573 },
1574 agg => anyhow::bail!("Unsupported bar aggregation for Binance: {agg:?}"),
1575 };
1576
1577 Ok(interval)
1578}
1579
1580pub(crate) fn quote_to_l1_deltas(quote: QuoteTick, sequence: u64) -> OrderBookDeltas {
1581 let bid_action = if quote.bid_size.is_zero() {
1582 BookAction::Delete
1583 } else {
1584 BookAction::Update
1585 };
1586 let ask_action = if quote.ask_size.is_zero() {
1587 BookAction::Delete
1588 } else {
1589 BookAction::Update
1590 };
1591 let bid = OrderBookDelta::new(
1592 quote.instrument_id,
1593 bid_action,
1594 BookOrder::new(OrderSide::Buy, quote.bid_price, quote.bid_size, 0),
1595 RecordFlag::F_MBP as u8,
1596 sequence,
1597 quote.ts_event,
1598 quote.ts_init,
1599 );
1600 let ask = OrderBookDelta::new(
1601 quote.instrument_id,
1602 ask_action,
1603 BookOrder::new(OrderSide::Sell, quote.ask_price, quote.ask_size, 0),
1604 RecordFlag::F_MBP as u8 | RecordFlag::F_LAST as u8,
1605 sequence,
1606 quote.ts_event,
1607 quote.ts_init,
1608 );
1609
1610 OrderBookDeltas::new(quote.instrument_id, vec![bid, ask])
1611}
1612
1613#[cfg(test)]
1614mod tests {
1615 use nautilus_model::identifiers::ClientOrderId;
1616 use rstest::rstest;
1617 use rust_decimal_macros::dec;
1618 use serde_json::json;
1619 use ustr::Ustr;
1620
1621 use super::*;
1622 use crate::common::{
1623 consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
1624 enums::{BinanceContractStatus, BinanceTradingStatus},
1625 };
1626
1627 #[rstest]
1628 fn test_sbe_order_mappings_reject_unknown_values() {
1629 for status in [
1630 SbeOrderStatus::Unknown,
1631 SbeOrderStatus::NonRepresentable,
1632 SbeOrderStatus::NullVal,
1633 ] {
1634 assert_eq!(
1635 map_order_status_sbe(status).unwrap_err().to_string(),
1636 "unknown Binance SBE order status"
1637 );
1638 }
1639
1640 for order_type in [SbeOrderType::NonRepresentable, SbeOrderType::NullVal] {
1641 assert_eq!(
1642 map_order_type_sbe(order_type).unwrap_err().to_string(),
1643 "unknown Binance SBE order type"
1644 );
1645 }
1646
1647 for tif in [SbeTimeInForce::NonRepresentable, SbeTimeInForce::NullVal] {
1648 assert_eq!(
1649 map_time_in_force_sbe(tif).unwrap_err().to_string(),
1650 "unknown Binance SBE time in force"
1651 );
1652 }
1653 }
1654
1655 #[rstest]
1656 fn test_quote_to_l1_deltas_maps_all_fields() {
1657 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
1658 let ts_event = UnixNanos::from(1_700_000_000_000_000_001u64);
1659 let ts_init = UnixNanos::from(1_700_000_000_000_000_002u64);
1660 let quote = QuoteTick::new(
1661 instrument_id,
1662 Price::from("42000.01"),
1663 Price::from("42000.02"),
1664 Quantity::from("1.23456"),
1665 Quantity::from("2.34567"),
1666 ts_event,
1667 ts_init,
1668 );
1669 let expected = OrderBookDeltas::new(
1670 instrument_id,
1671 vec![
1672 OrderBookDelta::new(
1673 instrument_id,
1674 BookAction::Update,
1675 BookOrder::new(
1676 OrderSide::Buy,
1677 Price::from("42000.01"),
1678 Quantity::from("1.23456"),
1679 0,
1680 ),
1681 RecordFlag::F_MBP as u8,
1682 12345,
1683 ts_event,
1684 ts_init,
1685 ),
1686 OrderBookDelta::new(
1687 instrument_id,
1688 BookAction::Update,
1689 BookOrder::new(
1690 OrderSide::Sell,
1691 Price::from("42000.02"),
1692 Quantity::from("2.34567"),
1693 0,
1694 ),
1695 RecordFlag::F_MBP as u8 | RecordFlag::F_LAST as u8,
1696 12345,
1697 ts_event,
1698 ts_init,
1699 ),
1700 ],
1701 );
1702
1703 let actual = quote_to_l1_deltas(quote, 12345);
1704
1705 assert_eq!(actual, expected);
1706 }
1707
1708 #[rstest]
1709 fn test_quote_to_l1_deltas_deletes_empty_sides() {
1710 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
1711 let quote = QuoteTick::new(
1712 instrument_id,
1713 Price::from("42000.01"),
1714 Price::from("42000.02"),
1715 Quantity::from("0.00000"),
1716 Quantity::from("0.00000"),
1717 UnixNanos::from(1_700_000_000_000_000_001u64),
1718 UnixNanos::from(1_700_000_000_000_000_002u64),
1719 );
1720
1721 let actual = quote_to_l1_deltas(quote, 12345);
1722
1723 assert_eq!(actual.deltas[0].action, BookAction::Delete);
1724 assert_eq!(actual.deltas[1].action, BookAction::Delete);
1725 }
1726
1727 #[rstest]
1728 #[case::positive("0.001", 8, Some(Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap()))]
1729 #[case::trailing_zero("0.00100000", 8, Some(Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap()))]
1730 #[case::zero("0", 8, None)]
1731 #[case::negative("-1", 8, None)]
1732 #[case::empty("", 8, None)]
1733 #[case::garbage("abc", 8, None)]
1734 fn test_parse_quantity_at_precision(
1735 #[case] raw: &str,
1736 #[case] precision: u8,
1737 #[case] expected: Option<Quantity>,
1738 ) {
1739 assert_eq!(parse_quantity_at_precision(raw, precision), expected);
1740 }
1741
1742 #[rstest]
1743 #[case::positive("7100.50", 2, Some(Price::from_decimal_dp(Decimal::from_str("7100.50").unwrap(), 2).unwrap()))]
1744 #[case::high_precision("0.000000001", 9, Some(Price::from_decimal_dp(Decimal::from_str("0.000000001").unwrap(), 9).unwrap()))]
1745 #[case::zero("0", 2, None)]
1746 #[case::negative("-100", 2, None)]
1747 #[case::empty("", 2, None)]
1748 fn test_parse_price_at_precision(
1749 #[case] raw: &str,
1750 #[case] precision: u8,
1751 #[case] expected: Option<Price>,
1752 ) {
1753 assert_eq!(parse_price_at_precision(raw, precision), expected);
1754 }
1755
1756 #[rstest]
1757 fn test_quantity_at_precision_re_precisions_via_decimal() {
1758 let original = Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 3).unwrap();
1759 let widened = quantity_at_precision(original, 8).unwrap();
1760 let expected = Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap();
1761 assert_eq!(widened, expected);
1762 }
1763
1764 #[rstest]
1765 fn test_price_at_precision_re_precisions_via_decimal() {
1766 let original = Price::from_decimal_dp(Decimal::from_str("7100.5").unwrap(), 1).unwrap();
1767 let widened = price_at_precision(original, 8).unwrap();
1768 let expected = Price::from_decimal_dp(Decimal::from_str("7100.5").unwrap(), 8).unwrap();
1769 assert_eq!(widened, expected);
1770 }
1771
1772 fn sample_usdm_symbol() -> BinanceFuturesUsdSymbol {
1773 BinanceFuturesUsdSymbol {
1774 symbol: Ustr::from("BTCUSDT"),
1775 pair: Ustr::from("BTCUSDT"),
1776 contract_type: "PERPETUAL".to_string(),
1777 delivery_date: 4133404800000,
1778 onboard_date: 1569398400000,
1779 status: BinanceTradingStatus::Trading,
1780 maint_margin_percent: "2.5000".to_string(),
1781 required_margin_percent: "5.0000".to_string(),
1782 base_asset: Ustr::from("BTC"),
1783 quote_asset: Ustr::from("USDT"),
1784 margin_asset: Ustr::from("USDT"),
1785 price_precision: 2,
1786 quantity_precision: 3,
1787 base_asset_precision: 8,
1788 quote_precision: 8,
1789 underlying_type: Some("COIN".to_string()),
1790 underlying_sub_type: vec!["PoW".to_string()],
1791 settle_plan: None,
1792 trigger_protect: Some("0.0500".to_string()),
1793 liquidation_fee: Some("0.012500".to_string()),
1794 market_take_bound: Some("0.05".to_string()),
1795 order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
1796 time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
1797 filters: vec![
1798 json!({
1799 "filterType": "PRICE_FILTER",
1800 "tickSize": "0.10",
1801 "maxPrice": "4529764",
1802 "minPrice": "556.80"
1803 }),
1804 json!({
1805 "filterType": "LOT_SIZE",
1806 "stepSize": "0.001",
1807 "maxQty": "1000",
1808 "minQty": "0.001"
1809 }),
1810 json!({
1811 "filterType": "MIN_NOTIONAL",
1812 "notional": "5"
1813 }),
1814 ],
1815 }
1816 }
1817
1818 fn sample_tradifi_usdm_symbol(
1819 symbol: &str,
1820 underlying: &str,
1821 underlying_type: Option<&str>,
1822 ) -> BinanceFuturesUsdSymbol {
1823 let mut definition = sample_usdm_symbol();
1824 definition.symbol = Ustr::from(symbol);
1825 definition.pair = Ustr::from(symbol);
1826 definition.contract_type = CONTRACT_TYPE_TRADIFI_PERPETUAL.to_string();
1827 definition.base_asset = Ustr::from(underlying);
1828 definition.underlying_type = underlying_type.map(str::to_string);
1829 definition
1830 }
1831
1832 fn sample_coinm_symbol() -> BinanceFuturesCoinSymbol {
1833 BinanceFuturesCoinSymbol {
1834 symbol: Ustr::from("BTCUSD_PERP"),
1835 pair: Ustr::from("BTCUSD"),
1836 contract_type: "PERPETUAL".to_string(),
1837 delivery_date: 4_133_404_800_000,
1838 onboard_date: 1_569_398_400_000,
1839 contract_status: Some(BinanceContractStatus::Trading),
1840 contract_size: 100,
1841 maint_margin_percent: "2.5000".to_string(),
1842 required_margin_percent: "5.0000".to_string(),
1843 base_asset: Ustr::from("BTC"),
1844 quote_asset: Ustr::from("USD"),
1845 margin_asset: Ustr::from("BTC"),
1846 price_precision: 1,
1847 quantity_precision: 0,
1848 base_asset_precision: 8,
1849 quote_precision: 8,
1850 equal_qty_precision: None,
1851 trigger_protect: Some("0.0500".to_string()),
1852 liquidation_fee: Some("0.012500".to_string()),
1853 market_take_bound: Some("0.05".to_string()),
1854 order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
1855 time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
1856 filters: vec![
1857 json!({
1858 "filterType": "PRICE_FILTER",
1859 "tickSize": "0.10",
1860 "maxPrice": "1000000",
1861 "minPrice": "0.10"
1862 }),
1863 json!({
1864 "filterType": "LOT_SIZE",
1865 "stepSize": "1",
1866 "maxQty": "1000",
1867 "minQty": "1"
1868 }),
1869 json!({
1870 "filterType": "MIN_NOTIONAL",
1871 "notional": "1"
1872 }),
1873 ],
1874 }
1875 }
1876
1877 fn sample_spot_symbol_sbe() -> BinanceSymbolSbe {
1878 BinanceSymbolSbe {
1879 symbol: "ETHUSDT".to_string(),
1880 base_asset: "ETH".to_string(),
1881 quote_asset: "USDT".to_string(),
1882 base_asset_precision: 8,
1883 quote_asset_precision: 8,
1884 status: SBE_STATUS_TRADING,
1885 order_types: 0,
1886 iceberg_allowed: true,
1887 oco_allowed: true,
1888 oto_allowed: false,
1889 quote_order_qty_market_allowed: true,
1890 allow_trailing_stop: true,
1891 cancel_replace_allowed: true,
1892 amend_allowed: true,
1893 is_spot_trading_allowed: true,
1894 is_margin_trading_allowed: false,
1895 filters: crate::spot::http::models::BinanceSymbolFiltersSbe {
1896 notional_filters: Vec::new(),
1897 price_filter: Some(BinancePriceFilterSbe {
1898 price_exponent: -8,
1899 min_price: 1_000_000,
1900 max_price: 100_000_000_000_000,
1901 tick_size: 1_000_000,
1902 }),
1903 lot_size_filter: Some(BinanceLotSizeFilterSbe {
1904 qty_exponent: -8,
1905 min_qty: 10_000,
1906 max_qty: 900_000_000_000,
1907 step_size: 10_000,
1908 }),
1909 },
1910 permissions: vec![vec!["SPOT".to_string()]],
1911 }
1912 }
1913
1914 #[rstest]
1915 #[case::min(
1916 include_bytes!("../../test_data/spot/http_sbe/notional_min.sbe").as_slice(),
1917 include_str!("../../test_data/spot/http_json/notional_min.json"),
1918 dec!(12.34567891), None,
1919 )]
1920 #[case::range(
1921 include_bytes!("../../test_data/spot/http_sbe/notional_range.sbe").as_slice(),
1922 include_str!("../../test_data/spot/http_json/notional_range.json"),
1923 dec!(23.45678912), Some(dec!(98.76543219)),
1924 )]
1925 #[case::both(
1926 include_bytes!("../../test_data/spot/http_sbe/notional_both.sbe").as_slice(),
1927 include_str!("../../test_data/spot/http_json/notional_both.json"),
1928 dec!(23.45678912), Some(dec!(98.76543219)),
1929 )]
1930 fn test_spot_notional_fixtures(
1931 #[case] wire: &[u8],
1932 #[case] json: &str,
1933 #[case] minimum: Decimal,
1934 #[case] maximum: Option<Decimal>,
1935 ) {
1936 let decoded = crate::spot::http::parse::decode_exchange_info(wire).unwrap();
1937 let symbol: BinanceSymbolJson = serde_json::from_str(json).unwrap();
1938 let ts_event = UnixNanos::from(123u64);
1939 let ts_init = UnixNanos::from(456u64);
1940 let sbe =
1941 parse_spot_instrument_sbe_with_fees(&decoded.symbols[0], ts_event, ts_init).unwrap();
1942 let json = parse_spot_instrument_json_with_fees(&symbol, ts_event, ts_init).unwrap();
1943 let InstrumentAny::CurrencyPair(mut expected) =
1944 parse_spot_instrument_sbe_with_fees(&sample_spot_symbol_sbe(), ts_event, ts_init)
1945 .unwrap()
1946 else {
1947 panic!("Expected CurrencyPair")
1948 };
1949 let rules = decoded.symbols[0].filters.notional_filters.clone();
1950 expected.min_notional =
1951 Some(Money::from_decimal(minimum, expected.quote_currency).unwrap());
1952 expected.max_notional =
1953 maximum.map(|value| Money::from_decimal(value, expected.quote_currency).unwrap());
1954 let expected = serde_json::to_value(InstrumentAny::CurrencyPair(expected)).unwrap();
1955
1956 assert_eq!(serde_json::to_value(sbe).unwrap(), expected);
1957 assert_eq!(serde_json::to_value(json).unwrap(), expected);
1958 assert_eq!(
1959 rules[0].min,
1960 if rules.len() == 2 || maximum.is_none() {
1961 dec!(12.34567891)
1962 } else {
1963 minimum
1964 }
1965 );
1966
1967 for rule in &rules {
1968 if rule.max.is_none() {
1969 assert_eq!(
1970 rule,
1971 &BinanceNotionalFilter {
1972 min: dec!(12.34567891),
1973 max: None,
1974 apply_min_to_market: true,
1975 apply_max_to_market: false,
1976 avg_price_mins: 7
1977 }
1978 );
1979 } else {
1980 assert_eq!(
1981 rule,
1982 &BinanceNotionalFilter {
1983 min: dec!(23.45678912),
1984 max: Some(dec!(98.76543219)),
1985 apply_min_to_market: false,
1986 apply_max_to_market: true,
1987 avg_price_mins: 3
1988 }
1989 );
1990 }
1991 }
1992 }
1993
1994 #[rstest]
1995 #[case("minNotional", "missing minNotional")]
1996 #[case("maxNotional", "missing maxNotional")]
1997 #[case("applyMinToMarket", "missing minimum notional market flag")]
1998 #[case("applyMaxToMarket", "missing applyMaxToMarket")]
1999 #[case("avgPriceMins", "missing avgPriceMins")]
2000 fn test_spot_notional_json_missing_fields(#[case] field: &str, #[case] error: &str) {
2001 let mut value: Value = serde_json::from_str(include_str!(
2002 "../../test_data/spot/http_json/notional_range.json"
2003 ))
2004 .unwrap();
2005 value["filters"][2].as_object_mut().unwrap().remove(field);
2006 let symbol = serde_json::from_value(value).unwrap();
2007 let result = parse_spot_instrument_json_with_fees(
2008 &symbol,
2009 UnixNanos::default(),
2010 UnixNanos::default(),
2011 );
2012 assert_eq!(result.unwrap_err().to_string(), error);
2013 }
2014
2015 #[rstest]
2016 #[case(vec![(dec!(-1), None)], "negative minimum notional")]
2017 #[case(vec![(dec!(2), Some(dec!(1)))], "maximum notional is below minimum")]
2018 #[case(vec![(dec!(2), None), (dec!(0), Some(dec!(1)))], "conflicting notional bounds")]
2019 fn test_spot_notional_invalid_bounds(
2020 #[case] bounds: Vec<(Decimal, Option<Decimal>)>,
2021 #[case] error: &str,
2022 ) {
2023 let rules = bounds
2024 .into_iter()
2025 .map(|(min, max)| BinanceNotionalFilter {
2026 min,
2027 max,
2028 apply_min_to_market: false,
2029 apply_max_to_market: true,
2030 avg_price_mins: 7,
2031 })
2032 .collect::<Vec<_>>();
2033
2034 let result = parse_spot_notional_rules(&rules, Currency::USD());
2035
2036 assert_eq!(result.unwrap_err().to_string(), error);
2037 }
2038
2039 fn sample_spot_instrument() -> InstrumentAny {
2040 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2041 parse_spot_instrument_sbe(&sample_spot_symbol_sbe(), ts, ts).unwrap()
2042 }
2043
2044 fn sample_account_id() -> AccountId {
2045 AccountId::from("BINANCE-SPOT-001")
2046 }
2047
2048 #[rstest]
2049 fn test_parse_usdm_perpetual() {
2050 let symbol = sample_usdm_symbol();
2051 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2052
2053 let result = parse_usdm_instrument(&symbol, ts, ts);
2054 assert!(result.is_ok(), "Failed: {:?}", result.err());
2055
2056 let instrument = result.unwrap();
2057 match instrument {
2058 InstrumentAny::CryptoPerpetual(perp) => {
2059 assert_eq!(perp.id.to_string(), "BTCUSDT-PERP.BINANCE");
2060 assert_eq!(perp.raw_symbol.to_string(), "BTCUSDT");
2061 assert_eq!(perp.base_currency.code, "BTC");
2062 assert_eq!(perp.quote_currency.code, "USDT");
2063 assert_eq!(perp.settlement_currency.code, "USDT");
2064 assert!(!perp.is_inverse);
2065 assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
2066 assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
2067 assert_eq!(
2068 perp.min_notional,
2069 Some(Money::new(5.0, perp.quote_currency)),
2070 );
2071 }
2072 other => panic!("Expected CryptoPerpetual, was {other:?}"),
2073 }
2074 }
2075
2076 #[rstest]
2077 #[case::equity("SNDKUSDT", "SNDK", "EQUITY", AssetClass::Equity)]
2078 #[case::chinese_equity("UNITREEUSDT", "UNITREE", "CN_EQUITY", AssetClass::Equity)]
2079 #[case::korean_equity("005930USDT", "005930", "KR_EQUITY", AssetClass::Equity)]
2080 #[case::hong_kong_equity("0700USDT", "0700", "HK_EQUITY", AssetClass::Equity)]
2081 #[case::premarket("SPCXUSDT", "SPCX", "PREMARKET", AssetClass::Equity)]
2082 #[case::commodity("XAUUSDT", "XAU", "COMMODITY", AssetClass::Commodity)]
2083 fn test_parse_usdm_tradifi_perpetual(
2084 #[case] raw_symbol: &str,
2085 #[case] underlying: &str,
2086 #[case] underlying_type: &str,
2087 #[case] expected_asset_class: AssetClass,
2088 ) {
2089 let symbol = sample_tradifi_usdm_symbol(raw_symbol, underlying, Some(underlying_type));
2090 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2091
2092 let instrument = parse_usdm_instrument(&symbol, ts, ts).unwrap();
2093 match instrument {
2094 InstrumentAny::PerpetualContract(perp) => {
2095 assert_eq!(perp.id.to_string(), format!("{raw_symbol}-PERP.BINANCE"));
2096 assert_eq!(perp.raw_symbol.to_string(), raw_symbol);
2097 assert_eq!(perp.underlying, Ustr::from(underlying));
2098 assert_eq!(perp.asset_class, expected_asset_class);
2099 assert_eq!(perp.base_currency, None);
2100 assert_eq!(perp.quote_currency.code, "USDT");
2101 assert_eq!(perp.settlement_currency.code, "USDT");
2102 assert!(!perp.is_inverse);
2103 assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
2104 assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
2105 assert_eq!(
2106 perp.min_notional,
2107 Some(Money::new(5.0, perp.quote_currency)),
2108 );
2109 }
2110 other => panic!("Expected PerpetualContract, was {other:?}"),
2111 }
2112 }
2113
2114 #[rstest]
2115 #[case::missing(
2116 None,
2117 "Missing underlying type for TRADIFI_PERPETUAL symbol 'SNDKUSDT'"
2118 )]
2119 #[case::unknown(
2120 Some("INDEX"),
2121 "Unsupported underlying type 'INDEX' for TRADIFI_PERPETUAL symbol 'SNDKUSDT'"
2122 )]
2123 fn test_parse_usdm_tradifi_perpetual_rejects_invalid_underlying_type(
2124 #[case] underlying_type: Option<&str>,
2125 #[case] expected_error: &str,
2126 ) {
2127 let symbol = sample_tradifi_usdm_symbol("SNDKUSDT", "SNDK", underlying_type);
2128 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2129
2130 let error = parse_usdm_instrument(&symbol, ts, ts).unwrap_err();
2131 assert_eq!(error.to_string(), expected_error);
2132 }
2133
2134 #[rstest]
2135 #[case::current_week(CONTRACT_TYPE_CURRENT_WEEK)]
2136 #[case::next_week(CONTRACT_TYPE_NEXT_WEEK)]
2137 #[case::current_month(CONTRACT_TYPE_CURRENT_MONTH)]
2138 #[case::next_month(CONTRACT_TYPE_NEXT_MONTH)]
2139 #[case::current_quarter(CONTRACT_TYPE_CURRENT_QUARTER)]
2140 #[case::next_quarter(CONTRACT_TYPE_NEXT_QUARTER)]
2141 fn test_parse_usdm_delivery(#[case] contract_type: &str) {
2142 let mut symbol = sample_usdm_symbol();
2143 symbol.symbol = Ustr::from("BTCUSDT_260925");
2144 symbol.contract_type = contract_type.to_string();
2145 symbol.onboard_date = 1_774_598_400_000;
2146 symbol.delivery_date = 1_790_323_200_000;
2147 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2148
2149 let result = parse_usdm_instrument(&symbol, ts, ts).unwrap();
2150 let InstrumentAny::CryptoFuture(future) = result else {
2151 panic!("Expected CryptoFuture, was {result:?}");
2152 };
2153
2154 assert_eq!(future.id.to_string(), "BTCUSDT_260925.BINANCE");
2155 assert_eq!(future.raw_symbol.to_string(), "BTCUSDT_260925");
2156 assert_eq!(future.underlying.code, "BTC");
2157 assert_eq!(future.quote_currency.code, "USDT");
2158 assert_eq!(future.settlement_currency.code, "USDT");
2159 assert!(!future.is_inverse);
2160 assert_eq!(
2161 future.activation_ns,
2162 UnixNanos::from_millis(1_774_598_400_000)
2163 );
2164 assert_eq!(
2165 future.expiration_ns,
2166 UnixNanos::from_millis(1_790_323_200_000)
2167 );
2168 assert_eq!(future.price_increment, Price::from_str("0.10").unwrap());
2169 assert_eq!(future.size_increment, Quantity::from_str("0.001").unwrap());
2170 assert_eq!(future.multiplier, Quantity::from(1));
2171 assert_eq!(
2172 future.max_quantity,
2173 Some(Quantity::from_str("1000").unwrap())
2174 );
2175 assert_eq!(
2176 future.min_quantity,
2177 Some(Quantity::from_str("0.001").unwrap())
2178 );
2179 assert_eq!(
2180 future.min_notional,
2181 Some(Money::new(5.0, future.quote_currency)),
2182 );
2183 assert_eq!(future.max_price, Some(Price::from_str("4529764").unwrap()));
2184 assert_eq!(future.min_price, Some(Price::from_str("556.80").unwrap()));
2185 }
2186
2187 #[rstest]
2188 fn test_parse_usdm_unsupported_contract_type_fails() {
2189 let mut symbol = sample_usdm_symbol();
2190 symbol.contract_type = "UNKNOWN".to_string();
2191 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2192
2193 let error = parse_usdm_instrument(&symbol, ts, ts).unwrap_err();
2194
2195 assert!(
2196 error
2197 .to_string()
2198 .contains("Unsupported USD-M contract type")
2199 );
2200 }
2201
2202 #[rstest]
2203 fn test_parse_missing_price_filter_fails() {
2204 let mut symbol = sample_usdm_symbol();
2205 symbol.filters = vec![json!({
2206 "filterType": "LOT_SIZE",
2207 "stepSize": "0.001",
2208 "maxQty": "1000",
2209 "minQty": "0.001"
2210 })];
2211 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2212
2213 let result = parse_usdm_instrument(&symbol, ts, ts);
2214 assert!(result.is_err());
2215 assert!(
2216 result
2217 .unwrap_err()
2218 .to_string()
2219 .contains("Missing PRICE_FILTER")
2220 );
2221 }
2222
2223 #[rstest]
2224 fn test_parse_coinm_perpetual() {
2225 let symbol = sample_coinm_symbol();
2226 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2227
2228 let result = parse_coinm_instrument(&symbol, ts, ts).unwrap();
2229
2230 match result {
2231 InstrumentAny::CryptoPerpetual(perp) => {
2232 assert_eq!(perp.id.to_string(), "BTCUSD_PERP.BINANCE");
2233 assert_eq!(perp.raw_symbol.to_string(), "BTCUSD_PERP");
2234 assert_eq!(perp.base_currency.code, "BTC");
2235 assert_eq!(perp.quote_currency.code, "USD");
2236 assert_eq!(perp.settlement_currency.code, "BTC");
2237 assert!(perp.is_inverse);
2238 assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
2239 assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
2240 assert_eq!(
2241 perp.min_notional,
2242 Some(Money::new(1.0, perp.quote_currency)),
2243 );
2244 }
2245 other => panic!("Expected CryptoPerpetual, was {other:?}"),
2246 }
2247 }
2248
2249 #[rstest]
2250 #[case::current_quarter(CONTRACT_TYPE_CURRENT_QUARTER)]
2251 #[case::next_quarter(CONTRACT_TYPE_NEXT_QUARTER)]
2252 fn test_parse_coinm_delivery(#[case] contract_type: &str) {
2253 let mut symbol = sample_coinm_symbol();
2254 symbol.symbol = Ustr::from("BTCUSD_260925");
2255 symbol.contract_type = contract_type.to_string();
2256 symbol.onboard_date = 1_774_598_400_000;
2257 symbol.delivery_date = 1_790_323_200_000;
2258 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2259
2260 let result = parse_coinm_instrument(&symbol, ts, ts).unwrap();
2261 let InstrumentAny::CryptoFuture(future) = result else {
2262 panic!("Expected CryptoFuture, was {result:?}");
2263 };
2264
2265 assert_eq!(future.id.to_string(), "BTCUSD_260925.BINANCE");
2266 assert_eq!(future.raw_symbol.to_string(), "BTCUSD_260925");
2267 assert_eq!(future.underlying.code, "BTC");
2268 assert_eq!(future.quote_currency.code, "USD");
2269 assert_eq!(future.settlement_currency.code, "BTC");
2270 assert!(future.is_inverse);
2271 assert_eq!(
2272 future.activation_ns,
2273 UnixNanos::from_millis(1_774_598_400_000)
2274 );
2275 assert_eq!(
2276 future.expiration_ns,
2277 UnixNanos::from_millis(1_790_323_200_000)
2278 );
2279 assert_eq!(future.price_increment, Price::from_str("0.10").unwrap());
2280 assert_eq!(future.size_increment, Quantity::from_str("1").unwrap());
2281 assert_eq!(future.multiplier, Quantity::from(100));
2282 assert_eq!(
2283 future.max_quantity,
2284 Some(Quantity::from_str("1000").unwrap())
2285 );
2286 assert_eq!(future.min_quantity, Some(Quantity::from_str("1").unwrap()));
2287 assert_eq!(future.max_price, Some(Price::from_str("1000000").unwrap()));
2288 assert_eq!(future.min_price, Some(Price::from_str("0.10").unwrap()));
2289 }
2290
2291 #[rstest]
2292 fn test_parse_coinm_month_contract_fails() {
2293 let mut symbol = sample_coinm_symbol();
2294 symbol.contract_type = CONTRACT_TYPE_CURRENT_MONTH.to_string();
2295 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2296
2297 let error = parse_coinm_instrument(&symbol, ts, ts).unwrap_err();
2298
2299 assert!(
2300 error
2301 .to_string()
2302 .contains("Unsupported COIN-M contract type")
2303 );
2304 }
2305
2306 #[rstest]
2307 fn test_parse_spot_instrument_sbe() {
2308 let symbol = sample_spot_symbol_sbe();
2309 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2310
2311 let result = parse_spot_instrument_sbe(&symbol, ts, ts).unwrap();
2312
2313 match result {
2314 InstrumentAny::CurrencyPair(pair) => {
2315 assert_eq!(pair.id.to_string(), "ETHUSDT.BINANCE");
2316 assert_eq!(pair.raw_symbol.to_string(), "ETHUSDT");
2317 assert_eq!(pair.base_currency.code, "ETH");
2318 assert_eq!(pair.quote_currency.code, "USDT");
2319 assert_eq!(pair.price_increment, Price::from_str("0.01").unwrap());
2320 assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
2321 }
2322 other => panic!("Expected CurrencyPair, was {other:?}"),
2323 }
2324 }
2325
2326 #[rstest]
2327 fn test_not_trading_parse_errors_stay_debug_for_bulk_loads() {
2328 let mut symbol = sample_spot_symbol_sbe();
2329 symbol.status = 3;
2330 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2331 let error = parse_spot_instrument_sbe(&symbol, ts, ts).unwrap_err();
2332
2333 assert!(is_not_trading_error(&error));
2334 assert!(!should_warn_on_instrument_parse_error(true, false, &error));
2335 assert!(should_warn_on_instrument_parse_error(true, true, &error));
2336 }
2337
2338 #[rstest]
2339 fn test_all_producers_emit_detectable_not_trading_errors() {
2340 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2341
2342 let mut sbe = sample_spot_symbol_sbe();
2343 sbe.status = 3;
2344 let sbe_err = parse_spot_instrument_sbe(&sbe, ts, ts).unwrap_err();
2345
2346 let json = BinanceSymbolJson {
2347 symbol: "ETHUSDT".to_string(),
2348 status: "BREAK".to_string(),
2349 base_asset: "ETH".to_string(),
2350 quote_asset: "USDT".to_string(),
2351 base_asset_precision: 8,
2352 quote_asset_precision: 8,
2353 filters: Vec::new(),
2354 };
2355 let json_err = parse_spot_instrument_json_with_fees(&json, ts, ts).unwrap_err();
2356
2357 let mut usdm = sample_usdm_symbol();
2358 usdm.status = BinanceTradingStatus::Halt;
2359 let usdm_err = parse_usdm_instrument(&usdm, ts, ts).unwrap_err();
2360
2361 let mut coinm = sample_coinm_symbol();
2362 coinm.contract_status = Some(BinanceContractStatus::TradingHalt);
2363 let coinm_err = parse_coinm_instrument(&coinm, ts, ts).unwrap_err();
2364
2365 for error in [&sbe_err, &json_err, &usdm_err, &coinm_err] {
2366 assert!(is_not_trading_error(error), "undetected: {error}");
2367 }
2368 }
2369
2370 #[rstest]
2371 fn test_unexpected_parse_errors_honor_log_warnings() {
2372 let error = anyhow::anyhow!("Missing PRICE_FILTER in symbol filters");
2373
2374 assert!(!is_not_trading_error(&error));
2375 assert!(should_warn_on_instrument_parse_error(true, false, &error));
2376 assert!(!should_warn_on_instrument_parse_error(false, true, &error));
2377 }
2378
2379 #[rstest]
2380 fn test_parse_spot_trades_sbe() {
2381 let instrument = sample_spot_instrument();
2382 let trades = BinanceTrades {
2383 price_exponent: -2,
2384 qty_exponent: -4,
2385 trades: vec![
2386 crate::spot::http::models::BinanceTrade {
2387 id: 1,
2388 price_mantissa: 12_345,
2389 qty_mantissa: 25_000,
2390 quote_qty_mantissa: 0,
2391 time: 1_700_000_000_000_000,
2392 is_buyer_maker: false,
2393 is_best_match: true,
2394 },
2395 crate::spot::http::models::BinanceTrade {
2396 id: 2,
2397 price_mantissa: 12_340,
2398 qty_mantissa: 10_000,
2399 quote_qty_mantissa: 0,
2400 time: 1_700_000_000_500_000,
2401 is_buyer_maker: true,
2402 is_best_match: true,
2403 },
2404 ],
2405 };
2406 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2407
2408 let result = parse_spot_trades_sbe(&trades, &instrument, ts_init).unwrap();
2409
2410 assert_eq!(result.len(), 2);
2411 assert_eq!(result[0].instrument_id, instrument.id());
2412 assert_eq!(result[0].price.as_f64(), 123.45);
2413 assert_eq!(result[0].size.as_f64(), 2.5);
2414 assert_eq!(result[0].aggressor_side, AggressorSide::Buy);
2415 assert_eq!(result[0].trade_id, TradeId::new("1"));
2416 assert_eq!(
2417 result[0].ts_event,
2418 UnixNanos::from(1_700_000_000_000_000_000u64)
2419 );
2420 assert_eq!(result[0].ts_init, ts_init);
2421 assert_eq!(result[1].aggressor_side, AggressorSide::Sell);
2422 }
2423
2424 #[rstest]
2425 fn test_parse_order_status_report_sbe() {
2426 let instrument = sample_spot_instrument();
2427 let order = BinanceOrderResponse {
2428 price_exponent: -2,
2429 qty_exponent: -4,
2430 order_id: 42,
2431 order_list_id: Some(77),
2432 price_mantissa: 12_345,
2433 orig_qty_mantissa: 25_000,
2434 executed_qty_mantissa: 10_000,
2435 cummulative_quote_qty_mantissa: 123_450_000,
2436 status: SbeOrderStatus::PartiallyFilled,
2437 time_in_force: SbeTimeInForce::Gtc,
2438 order_type: SbeOrderType::LimitMaker,
2439 side: SbeOrderSide::Buy,
2440 stop_price_mantissa: None,
2441 iceberg_qty_mantissa: None,
2442 time: 1_700_000_000_000_000,
2443 update_time: 1_700_000_000_100_000,
2444 is_working: true,
2445 working_time: Some(1_700_000_000_050_000),
2446 orig_quote_order_qty_mantissa: 0,
2447 self_trade_prevention_mode:
2448 crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
2449 client_order_id: "client-123".to_string(),
2450 symbol: "ETHUSDT".to_string(),
2451 expiry_reason: None,
2452 };
2453 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2454
2455 let report = parse_order_status_report_sbe(
2456 &order,
2457 sample_account_id(),
2458 &instrument,
2459 BINANCE_NAUTILUS_SPOT_BROKER_ID,
2460 ts_init,
2461 )
2462 .unwrap();
2463
2464 assert_eq!(report.account_id, sample_account_id());
2465 assert_eq!(report.instrument_id, instrument.id());
2466 assert_eq!(
2467 report.client_order_id,
2468 Some(ClientOrderId::new("client-123"))
2469 );
2470 assert_eq!(report.venue_order_id, VenueOrderId::new("42"));
2471 assert_eq!(report.order_side, OrderSide::Buy.into());
2472 assert_eq!(report.order_type, OrderType::Limit);
2473 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2474 assert_eq!(report.quantity.as_f64(), 2.5);
2475 assert_eq!(report.filled_qty.as_f64(), 1.0);
2476 assert_eq!(report.order_list_id, Some(OrderListId::new("77")));
2477 assert_eq!(report.price, Some(Price::new(123.45, 2)));
2478 assert_eq!(report.avg_px.unwrap().to_string(), "123.45");
2479 assert!(report.post_only);
2480 assert_eq!(
2481 report.ts_accepted,
2482 UnixNanos::from(1_700_000_000_000_000_000u64)
2483 );
2484 assert_eq!(
2485 report.ts_last,
2486 UnixNanos::from(1_700_000_000_100_000_000u64)
2487 );
2488 assert_eq!(report.ts_init, ts_init);
2489 }
2490
2491 #[rstest]
2492 fn test_parse_new_order_response_sbe() {
2493 let instrument = sample_spot_instrument();
2494 let response = BinanceNewOrderResponse {
2495 price_exponent: -2,
2496 qty_exponent: -4,
2497 order_id: 99,
2498 order_list_id: Some(7),
2499 transact_time: 1_700_000_000_000_000,
2500 price_mantissa: 12_100,
2501 orig_qty_mantissa: 20_000,
2502 executed_qty_mantissa: 5_000,
2503 cummulative_quote_qty_mantissa: 60_500_000,
2504 status: SbeOrderStatus::New,
2505 time_in_force: SbeTimeInForce::Gtc,
2506 order_type: SbeOrderType::StopLossLimit,
2507 side: SbeOrderSide::Sell,
2508 stop_price_mantissa: Some(12_000),
2509 working_time: Some(1_700_000_000_000_000),
2510 self_trade_prevention_mode:
2511 crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
2512 client_order_id: "client-456".to_string(),
2513 symbol: "ETHUSDT".to_string(),
2514 fills: vec![],
2515 expiry_reason: None,
2516 };
2517 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2518
2519 let report = parse_new_order_response_sbe(
2520 &response,
2521 sample_account_id(),
2522 &instrument,
2523 BINANCE_NAUTILUS_SPOT_BROKER_ID,
2524 ts_init,
2525 )
2526 .unwrap();
2527
2528 assert_eq!(report.account_id, sample_account_id());
2529 assert_eq!(report.instrument_id, instrument.id());
2530 assert_eq!(
2531 report.client_order_id,
2532 Some(ClientOrderId::new("client-456"))
2533 );
2534 assert_eq!(report.venue_order_id, VenueOrderId::new("99"));
2535 assert_eq!(report.order_side, OrderSide::Sell.into());
2536 assert_eq!(report.order_type, OrderType::StopLimit);
2537 assert_eq!(report.order_status, OrderStatus::Accepted);
2538 assert_eq!(report.quantity.as_f64(), 2.0);
2539 assert_eq!(report.filled_qty.as_f64(), 0.5);
2540 assert_eq!(report.order_list_id, Some(OrderListId::new("7")));
2541 assert_eq!(report.price, Some(Price::new(121.0, 2)));
2542 assert_eq!(report.trigger_price, Some(Price::new(120.0, 2)));
2543 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2544 assert_eq!(report.avg_px, Some(dec!(121.00)));
2546 assert_eq!(report.avg_px.unwrap().to_string(), "121.00");
2547 assert!(!report.post_only);
2548 assert_eq!(
2549 report.ts_accepted,
2550 UnixNanos::from(1_700_000_000_000_000_000u64)
2551 );
2552 assert_eq!(
2553 report.ts_last,
2554 UnixNanos::from(1_700_000_000_000_000_000u64)
2555 );
2556 }
2557
2558 #[rstest]
2559 fn test_parse_fill_report_sbe() {
2560 let instrument = sample_spot_instrument();
2561 let trade = BinanceAccountTrade {
2562 price_exponent: -2,
2563 qty_exponent: -4,
2564 commission_exponent: -8,
2565 id: 123,
2566 order_id: 456,
2567 order_list_id: None,
2568 price_mantissa: 12_345,
2569 qty_mantissa: 25_000,
2570 quote_qty_mantissa: 0,
2571 commission_mantissa: 10_000,
2572 time: 1_700_000_000_000_000,
2573 is_buyer: false,
2574 is_maker: true,
2575 is_best_match: true,
2576 symbol: "ETHUSDT".to_string(),
2577 commission_asset: "USDT".to_string(),
2578 };
2579 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2580
2581 let report = parse_fill_report_sbe(
2582 &trade,
2583 sample_account_id(),
2584 &instrument,
2585 Currency::from("USDT"),
2586 ts_init,
2587 )
2588 .unwrap();
2589
2590 assert_eq!(report.account_id, sample_account_id());
2591 assert_eq!(report.instrument_id, instrument.id());
2592 assert_eq!(report.venue_order_id, VenueOrderId::new("456"));
2593 assert_eq!(report.trade_id, TradeId::new("123"));
2594 assert_eq!(report.order_side, OrderSide::Sell);
2595 assert_eq!(report.last_qty.as_f64(), 2.5);
2596 assert_eq!(report.last_px.as_f64(), 123.45);
2597 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
2598 assert_eq!(report.commission.as_f64(), 0.0001);
2599 assert_eq!(
2600 report.ts_event,
2601 UnixNanos::from(1_700_000_000_000_000_000u64)
2602 );
2603 assert_eq!(report.ts_init, ts_init);
2604 assert!(report.client_order_id.is_none());
2605 }
2606
2607 #[rstest]
2608 fn test_parse_klines_to_bars() {
2609 use nautilus_model::enums::{AggregationSource, PriceType};
2610
2611 let instrument = sample_spot_instrument();
2612 let bar_type = BarType::new(
2613 instrument.id(),
2614 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
2615 AggregationSource::External,
2616 );
2617 let klines = BinanceKlines {
2618 price_exponent: -2,
2619 qty_exponent: -4,
2620 klines: vec![crate::spot::http::models::BinanceKline {
2621 open_time: 1_700_000_000_000_000,
2622 open_price: 12_000,
2623 high_price: 12_500,
2624 low_price: 11_900,
2625 close_price: 12_345,
2626 volume: 1_234_500_i128.to_le_bytes(),
2627 close_time: 1_700_000_059_999_000,
2628 quote_volume: 777_788_i128.to_le_bytes(),
2629 num_trades: 100,
2630 taker_buy_base_volume: 56_789_i128.to_le_bytes(),
2631 taker_buy_quote_volume: 9_901_i128.to_le_bytes(),
2632 }],
2633 };
2634 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2635
2636 let bars = parse_klines_to_binance_bars(&klines, bar_type, &instrument, ts_init).unwrap();
2637
2638 assert_eq!(bars.len(), 1);
2639 assert_eq!(bars[0].bar_type, bar_type);
2640 assert_eq!(bars[0].open, Price::new(120.0, 2));
2641 assert_eq!(bars[0].high, Price::new(125.0, 2));
2642 assert_eq!(bars[0].low, Price::new(119.0, 2));
2643 assert_eq!(bars[0].close, Price::new(123.45, 2));
2644 assert_eq!(bars[0].volume, Quantity::new(123.45, 4));
2645 assert_eq!(bars[0].quote_volume, dec!(7777.88));
2646 assert_eq!(bars[0].count, 100);
2647 assert_eq!(bars[0].taker_buy_base_volume, dec!(5.6789));
2648 assert_eq!(bars[0].taker_buy_quote_volume, dec!(99.01));
2649 assert_eq!(
2650 bars[0].ts_event,
2651 UnixNanos::from(1_700_000_059_999_000_000u64)
2652 );
2653 assert_eq!(bars[0].ts_init, ts_init);
2654 }
2655
2656 mod bar_spec_tests {
2657 use std::num::NonZeroUsize;
2658
2659 use nautilus_model::{
2660 data::BarSpecification,
2661 enums::{BarAggregation, PriceType},
2662 };
2663
2664 use super::*;
2665 use crate::common::enums::BinanceKlineInterval;
2666
2667 fn make_bar_spec(step: usize, aggregation: BarAggregation) -> BarSpecification {
2668 BarSpecification {
2669 step: NonZeroUsize::new(step).unwrap(),
2670 aggregation,
2671 price_type: PriceType::Last,
2672 }
2673 }
2674
2675 #[rstest]
2676 #[case(1, BarAggregation::Second, BinanceKlineInterval::Second1)]
2677 #[case(1, BarAggregation::Minute, BinanceKlineInterval::Minute1)]
2678 #[case(3, BarAggregation::Minute, BinanceKlineInterval::Minute3)]
2679 #[case(5, BarAggregation::Minute, BinanceKlineInterval::Minute5)]
2680 #[case(15, BarAggregation::Minute, BinanceKlineInterval::Minute15)]
2681 #[case(30, BarAggregation::Minute, BinanceKlineInterval::Minute30)]
2682 #[case(1, BarAggregation::Hour, BinanceKlineInterval::Hour1)]
2683 #[case(2, BarAggregation::Hour, BinanceKlineInterval::Hour2)]
2684 #[case(4, BarAggregation::Hour, BinanceKlineInterval::Hour4)]
2685 #[case(6, BarAggregation::Hour, BinanceKlineInterval::Hour6)]
2686 #[case(8, BarAggregation::Hour, BinanceKlineInterval::Hour8)]
2687 #[case(12, BarAggregation::Hour, BinanceKlineInterval::Hour12)]
2688 #[case(1, BarAggregation::Day, BinanceKlineInterval::Day1)]
2689 #[case(3, BarAggregation::Day, BinanceKlineInterval::Day3)]
2690 #[case(1, BarAggregation::Week, BinanceKlineInterval::Week1)]
2691 #[case(1, BarAggregation::Month, BinanceKlineInterval::Month1)]
2692 fn test_bar_spec_to_binance_interval(
2693 #[case] step: usize,
2694 #[case] aggregation: BarAggregation,
2695 #[case] expected: BinanceKlineInterval,
2696 ) {
2697 let bar_spec = make_bar_spec(step, aggregation);
2698 let result = bar_spec_to_binance_interval(bar_spec).unwrap();
2699 assert_eq!(result, expected);
2700 }
2701
2702 #[rstest]
2703 fn test_unsupported_second_interval() {
2704 let bar_spec = make_bar_spec(2, BarAggregation::Second);
2705 let result = bar_spec_to_binance_interval(bar_spec);
2706 assert!(result.is_err());
2707 assert!(
2708 result
2709 .unwrap_err()
2710 .to_string()
2711 .contains("Unsupported second interval")
2712 );
2713 }
2714
2715 #[rstest]
2716 fn test_unsupported_minute_interval() {
2717 let bar_spec = make_bar_spec(7, BarAggregation::Minute);
2718 let result = bar_spec_to_binance_interval(bar_spec);
2719 assert!(result.is_err());
2720 assert!(
2721 result
2722 .unwrap_err()
2723 .to_string()
2724 .contains("Unsupported minute interval")
2725 );
2726 }
2727
2728 #[rstest]
2729 fn test_unsupported_aggregation() {
2730 let bar_spec = make_bar_spec(100, BarAggregation::Tick);
2731 let result = bar_spec_to_binance_interval(bar_spec);
2732 assert!(result.is_err());
2733 assert!(
2734 result
2735 .unwrap_err()
2736 .to_string()
2737 .contains("Unsupported bar aggregation")
2738 );
2739 }
2740 }
2741
2742 mod sbe_precision_tests {
2743 use super::*;
2744 use crate::spot::http::models::{BinanceLotSizeFilterSbe, BinancePriceFilterSbe};
2745
2746 #[rstest]
2747 #[case::precision_0(100_000_000, -8, 0)]
2748 #[case::precision_1(10_000_000, -8, 1)]
2749 #[case::precision_2(1_000_000, -8, 2)]
2750 #[case::precision_3(100_000, -8, 3)]
2751 #[case::precision_4(10_000, -8, 4)]
2752 #[case::precision_5(1_000, -8, 5)]
2753 #[case::precision_6(100, -8, 6)]
2754 #[case::precision_7(10, -8, 7)]
2755 #[case::precision_8(1, -8, 8)]
2756 fn test_sbe_mantissa_precision(
2757 #[case] mantissa: i64,
2758 #[case] exponent: i8,
2759 #[case] expected: u8,
2760 ) {
2761 let result = sbe_mantissa_precision(mantissa, exponent);
2762 assert_eq!(
2763 result, expected,
2764 "mantissa={mantissa}, exponent={exponent}: expected {expected}, was {result}"
2765 );
2766 }
2767
2768 #[rstest]
2769 fn test_sbe_mantissa_precision_zero_mantissa() {
2770 assert_eq!(sbe_mantissa_precision(0, -8), 0);
2771 }
2772
2773 #[rstest]
2774 fn test_sbe_mantissa_precision_positive_exponent() {
2775 assert_eq!(sbe_mantissa_precision(1, 0), 0);
2776 assert_eq!(sbe_mantissa_precision(5, 2), 0);
2777 }
2778
2779 #[rstest]
2780 fn test_parse_sbe_price_filter_ethusdc() {
2781 let filter = BinancePriceFilterSbe {
2782 price_exponent: -8,
2783 min_price: 1_000_000,
2784 max_price: 100_000_000_000_000,
2785 tick_size: 1_000_000,
2786 };
2787
2788 let (tick_size, max_price, min_price) = parse_sbe_price_filter(&filter).unwrap();
2789 let max_price = max_price.unwrap();
2790 let min_price = min_price.unwrap();
2791
2792 assert_eq!(tick_size.precision, 2, "tick_size precision");
2793 assert_eq!(tick_size.as_decimal(), dec!(0.01));
2794 assert_eq!(max_price.precision, 2);
2795 assert_eq!(max_price.as_decimal(), dec!(1000000.00));
2796 assert_eq!(min_price.precision, 2);
2797 assert_eq!(min_price.as_decimal(), dec!(0.01));
2798 }
2799
2800 #[rstest]
2801 fn test_parse_sbe_price_filter_shibusdt() {
2802 let filter = BinancePriceFilterSbe {
2803 price_exponent: -8,
2804 min_price: 1,
2805 max_price: 100_000_000,
2806 tick_size: 1,
2807 };
2808
2809 let (tick_size, _, _) = parse_sbe_price_filter(&filter).unwrap();
2810
2811 assert_eq!(tick_size.precision, 8);
2812 assert_eq!(tick_size.as_decimal(), dec!(0.00000001));
2813 }
2814
2815 #[rstest]
2816 fn test_parse_sbe_lot_size_filter_ethusdc() {
2817 let filter = BinanceLotSizeFilterSbe {
2818 qty_exponent: -8,
2819 min_qty: 10_000,
2820 max_qty: 900_000_000_000,
2821 step_size: 10_000,
2822 };
2823
2824 let (step_size, max_qty, min_qty) = parse_sbe_lot_size_filter(&filter).unwrap();
2825 let max_qty = max_qty.unwrap();
2826 let min_qty = min_qty.unwrap();
2827
2828 assert_eq!(step_size.precision, 4, "step_size precision");
2829 assert_eq!(step_size.as_decimal(), dec!(0.0001));
2830 assert_eq!(min_qty.precision, 4);
2831 assert_eq!(min_qty.as_decimal(), dec!(0.0001));
2832 assert_eq!(max_qty.precision, 4);
2833 assert_eq!(max_qty.as_decimal(), dec!(9000.0000));
2834 }
2835 }
2836}