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