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