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