1use std::str::FromStr;
22
23use anyhow::Context;
24use nautilus_core::nanos::UnixNanos;
25use nautilus_model::{
26 data::{Bar, BarSpecification, BarType, TradeTick},
27 enums::{
28 AggressorSide, BarAggregation, LiquiditySide, OrderSide, OrderStatus, OrderType,
29 TimeInForce, TriggerType,
30 },
31 identifiers::{
32 AccountId, ClientOrderId, InstrumentId, OrderListId, Symbol, TradeId, Venue, VenueOrderId,
33 },
34 instruments::{
35 Instrument, any::InstrumentAny, crypto_perpetual::CryptoPerpetual,
36 currency_pair::CurrencyPair,
37 },
38 reports::{FillReport, OrderStatusReport},
39 types::{Currency, Money, Price, Quantity},
40};
41use rust_decimal::{Decimal, prelude::ToPrimitive};
42use serde_json::Value;
43
44use crate::{
45 common::{
46 consts::BINANCE,
47 encoder::decode_broker_id,
48 enums::{BinanceContractStatus, BinanceKlineInterval, BinanceTradingStatus},
49 },
50 futures::http::models::{BinanceFuturesCoinSymbol, BinanceFuturesUsdSymbol},
51 spot::{
52 http::models::{
53 BinanceAccountTrade, BinanceKlines, BinanceLotSizeFilterSbe, BinanceNewOrderResponse,
54 BinanceOrderResponse, BinancePriceFilterSbe, BinanceSymbolSbe, BinanceTrades,
55 },
56 sbe::spot::{
57 order_side::OrderSide as SbeOrderSide, order_status::OrderStatus as SbeOrderStatus,
58 order_type::OrderType as SbeOrderType, time_in_force::TimeInForce as SbeTimeInForce,
59 },
60 },
61};
62const CONTRACT_TYPE_PERPETUAL: &str = "PERPETUAL";
63
64pub fn get_currency(code: &str) -> Currency {
66 Currency::get_or_create_crypto(code)
67}
68
69fn get_filter<'a>(filters: &'a [Value], filter_type: &str) -> Option<&'a Value> {
71 filters.iter().find(|f| {
72 f.get("filterType")
73 .and_then(|v| v.as_str())
74 .is_some_and(|t| t == filter_type)
75 })
76}
77
78fn parse_filter_string(filter: &Value, field: &str) -> anyhow::Result<String> {
80 filter
81 .get(field)
82 .and_then(|v| v.as_str())
83 .map(String::from)
84 .ok_or_else(|| anyhow::anyhow!("Missing field '{field}' in filter"))
85}
86
87fn parse_filter_price(filter: &Value, field: &str) -> anyhow::Result<Price> {
89 let value = parse_filter_string(filter, field)?;
90 Price::from_str(&value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
91}
92
93fn parse_filter_quantity(filter: &Value, field: &str) -> anyhow::Result<Quantity> {
95 let value = parse_filter_string(filter, field)?;
96 Quantity::from_str(&value)
97 .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
98}
99
100pub fn parse_usdm_instrument(
109 symbol: &BinanceFuturesUsdSymbol,
110 ts_event: UnixNanos,
111 ts_init: UnixNanos,
112) -> anyhow::Result<InstrumentAny> {
113 if symbol.contract_type != CONTRACT_TYPE_PERPETUAL {
115 anyhow::bail!(
116 "Unsupported contract type '{}' for symbol '{}', expected '{}'",
117 symbol.contract_type,
118 symbol.symbol,
119 CONTRACT_TYPE_PERPETUAL
120 );
121 }
122
123 if symbol.status != BinanceTradingStatus::Trading {
124 anyhow::bail!(
125 "Symbol '{}' is not trading (status: {:?})",
126 symbol.symbol,
127 symbol.status
128 );
129 }
130
131 let base_currency = get_currency(symbol.base_asset.as_str());
132 let quote_currency = get_currency(symbol.quote_asset.as_str());
133 let settlement_currency = get_currency(symbol.margin_asset.as_str());
134
135 let instrument_id = InstrumentId::new(
136 Symbol::from_str_unchecked(format!("{}-PERP", symbol.symbol)),
137 Venue::new(BINANCE),
138 );
139 let raw_symbol = Symbol::new(symbol.symbol.as_str());
140
141 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
142 .context("Missing PRICE_FILTER in symbol filters")?;
143
144 let tick_size = parse_filter_price(price_filter, "tickSize")?;
145 if tick_size.is_zero() {
146 anyhow::bail!(
147 "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
148 symbol.symbol,
149 );
150 }
151 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
152 let min_price = parse_filter_price(price_filter, "minPrice").ok();
153
154 let lot_filter =
155 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
156
157 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
158 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
159 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
160
161 let default_margin = Decimal::new(1, 1);
163
164 let instrument = CryptoPerpetual::new(
165 instrument_id,
166 raw_symbol,
167 base_currency,
168 quote_currency,
169 settlement_currency,
170 false, tick_size.precision,
172 step_size.precision,
173 tick_size,
174 step_size,
175 None, Some(step_size),
177 max_quantity,
178 min_quantity,
179 None, None, max_price,
182 min_price,
183 Some(default_margin),
184 Some(default_margin),
185 None, None, None, ts_event,
189 ts_init,
190 );
191
192 Ok(InstrumentAny::CryptoPerpetual(instrument))
193}
194
195pub fn parse_coinm_instrument(
207 symbol: &BinanceFuturesCoinSymbol,
208 ts_event: UnixNanos,
209 ts_init: UnixNanos,
210) -> anyhow::Result<InstrumentAny> {
211 if symbol.contract_type != CONTRACT_TYPE_PERPETUAL {
212 anyhow::bail!(
213 "Unsupported contract type '{}' for symbol '{}', expected '{}'",
214 symbol.contract_type,
215 symbol.symbol,
216 CONTRACT_TYPE_PERPETUAL
217 );
218 }
219
220 if symbol.contract_status != Some(BinanceContractStatus::Trading) {
221 anyhow::bail!(
222 "Symbol '{}' is not trading (status: {:?})",
223 symbol.symbol,
224 symbol.contract_status
225 );
226 }
227
228 let base_currency = get_currency(symbol.base_asset.as_str());
229 let quote_currency = get_currency(symbol.quote_asset.as_str());
230
231 let settlement_currency = get_currency(symbol.margin_asset.as_str());
233
234 let instrument_id = InstrumentId::new(
235 Symbol::from_str_unchecked(format!("{}-PERP", symbol.symbol)),
236 Venue::new(BINANCE),
237 );
238 let raw_symbol = Symbol::new(symbol.symbol.as_str());
239
240 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
241 .context("Missing PRICE_FILTER in symbol filters")?;
242
243 let tick_size = parse_filter_price(price_filter, "tickSize")?;
244 if tick_size.is_zero() {
245 anyhow::bail!(
246 "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
247 symbol.symbol,
248 );
249 }
250 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
251 let min_price = parse_filter_price(price_filter, "minPrice").ok();
252
253 let lot_filter =
254 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
255
256 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
257 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
258 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
259
260 let multiplier = Quantity::new(symbol.contract_size as f64, 0);
262
263 let default_margin = Decimal::new(1, 1);
265
266 let instrument = CryptoPerpetual::new(
267 instrument_id,
268 raw_symbol,
269 base_currency,
270 quote_currency,
271 settlement_currency,
272 true, tick_size.precision,
274 step_size.precision,
275 tick_size,
276 step_size,
277 Some(multiplier),
278 Some(step_size),
279 max_quantity,
280 min_quantity,
281 None, None, max_price,
284 min_price,
285 Some(default_margin),
286 Some(default_margin),
287 None, None, None, ts_event,
291 ts_init,
292 );
293
294 Ok(InstrumentAny::CryptoPerpetual(instrument))
295}
296
297const SBE_STATUS_TRADING: u8 = 0;
299
300fn sbe_mantissa_precision(mantissa: i64, exponent: i8) -> u8 {
313 if mantissa == 0 {
314 return 0;
315 }
316 let mut m = mantissa.abs();
317 let mut trailing_zeros: i8 = 0;
318
319 while m > 0 && m % 10 == 0 {
320 m /= 10;
321 trailing_zeros += 1;
322 }
323 (-exponent - trailing_zeros).max(0) as u8
324}
325
326fn parse_sbe_price_filter(filter: &BinancePriceFilterSbe) -> (Price, Option<Price>, Option<Price>) {
328 let precision = sbe_mantissa_precision(filter.tick_size, filter.price_exponent);
329
330 let tick_size =
331 Price::from_mantissa_exponent(filter.tick_size, filter.price_exponent, precision);
332
333 let max_price = if filter.max_price != 0 {
334 Some(Price::from_mantissa_exponent(
335 filter.max_price,
336 filter.price_exponent,
337 precision,
338 ))
339 } else {
340 None
341 };
342
343 let min_price = if filter.min_price != 0 {
344 Some(Price::from_mantissa_exponent(
345 filter.min_price,
346 filter.price_exponent,
347 precision,
348 ))
349 } else {
350 None
351 };
352
353 (tick_size, max_price, min_price)
354}
355
356fn parse_sbe_lot_size_filter(
358 filter: &BinanceLotSizeFilterSbe,
359) -> (Quantity, Option<Quantity>, Option<Quantity>) {
360 let precision = sbe_mantissa_precision(filter.step_size, filter.qty_exponent);
361
362 let step_size =
363 Quantity::from_mantissa_exponent(filter.step_size as u64, filter.qty_exponent, precision);
364
365 let max_qty = if filter.max_qty != 0 {
366 Some(Quantity::from_mantissa_exponent(
367 filter.max_qty as u64,
368 filter.qty_exponent,
369 precision,
370 ))
371 } else {
372 None
373 };
374
375 let min_qty = if filter.min_qty != 0 {
376 Some(Quantity::from_mantissa_exponent(
377 filter.min_qty as u64,
378 filter.qty_exponent,
379 precision,
380 ))
381 } else {
382 None
383 };
384
385 (step_size, max_qty, min_qty)
386}
387
388pub fn parse_spot_instrument_sbe(
397 symbol: &BinanceSymbolSbe,
398 ts_event: UnixNanos,
399 ts_init: UnixNanos,
400) -> anyhow::Result<InstrumentAny> {
401 if symbol.status != SBE_STATUS_TRADING {
402 anyhow::bail!(
403 "Symbol '{}' is not trading (status: {})",
404 symbol.symbol,
405 symbol.status
406 );
407 }
408
409 let base_currency = get_currency(&symbol.base_asset);
410 let quote_currency = get_currency(&symbol.quote_asset);
411
412 let instrument_id = InstrumentId::new(
413 Symbol::from_str_unchecked(&symbol.symbol),
414 Venue::new(BINANCE),
415 );
416 let raw_symbol = Symbol::new(&symbol.symbol);
417
418 let price_filter = symbol
419 .filters
420 .price_filter
421 .as_ref()
422 .context("Missing PRICE_FILTER in symbol filters")?;
423
424 let (tick_size, max_price, min_price) = parse_sbe_price_filter(price_filter);
425
426 let lot_filter = symbol
427 .filters
428 .lot_size_filter
429 .as_ref()
430 .context("Missing LOT_SIZE in symbol filters")?;
431
432 let (step_size, max_quantity, min_quantity) = parse_sbe_lot_size_filter(lot_filter);
433
434 let default_margin = Decimal::new(1, 0);
436
437 let instrument = CurrencyPair::new(
438 instrument_id,
439 raw_symbol,
440 base_currency,
441 quote_currency,
442 tick_size.precision,
443 step_size.precision,
444 tick_size,
445 step_size,
446 None, Some(step_size),
448 max_quantity,
449 min_quantity,
450 None, None, max_price,
453 min_price,
454 Some(default_margin),
455 Some(default_margin),
456 None, None, None, ts_event,
460 ts_init,
461 );
462
463 Ok(InstrumentAny::CurrencyPair(instrument))
464}
465
466pub fn parse_spot_trades_sbe(
474 trades: &BinanceTrades,
475 instrument: &InstrumentAny,
476 ts_init: UnixNanos,
477) -> anyhow::Result<Vec<TradeTick>> {
478 let instrument_id = instrument.id();
479 let price_precision = instrument.price_precision();
480 let size_precision = instrument.size_precision();
481
482 let mut result = Vec::with_capacity(trades.trades.len());
483
484 for trade in &trades.trades {
485 let price = Price::from_mantissa_exponent(
486 trade.price_mantissa,
487 trades.price_exponent,
488 price_precision,
489 );
490 let size = Quantity::from_mantissa_exponent(
491 trade.qty_mantissa as u64,
492 trades.qty_exponent,
493 size_precision,
494 );
495
496 let aggressor_side = if trade.is_buyer_maker {
498 AggressorSide::Seller
499 } else {
500 AggressorSide::Buyer
501 };
502
503 let ts_event = UnixNanos::from(trade.time as u64 * 1_000);
505
506 let tick = TradeTick::new(
507 instrument_id,
508 price,
509 size,
510 aggressor_side,
511 TradeId::new(trade.id.to_string()),
512 ts_event,
513 ts_init,
514 );
515
516 result.push(tick);
517 }
518
519 Ok(result)
520}
521
522#[must_use]
524pub const fn map_order_status_sbe(status: SbeOrderStatus) -> OrderStatus {
525 match status {
526 SbeOrderStatus::New => OrderStatus::Accepted,
527 SbeOrderStatus::PendingNew => OrderStatus::Submitted,
528 SbeOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
529 SbeOrderStatus::Filled => OrderStatus::Filled,
530 SbeOrderStatus::Canceled => OrderStatus::Canceled,
531 SbeOrderStatus::PendingCancel => OrderStatus::PendingCancel,
532 SbeOrderStatus::Rejected => OrderStatus::Rejected,
533 SbeOrderStatus::Expired | SbeOrderStatus::ExpiredInMatch => OrderStatus::Expired,
534 SbeOrderStatus::Unknown | SbeOrderStatus::NonRepresentable | SbeOrderStatus::NullVal => {
535 OrderStatus::Initialized
536 }
537 }
538}
539
540#[must_use]
542pub const fn map_order_type_sbe(order_type: SbeOrderType) -> OrderType {
543 match order_type {
544 SbeOrderType::Market => OrderType::Market,
545 SbeOrderType::Limit | SbeOrderType::LimitMaker => OrderType::Limit,
546 SbeOrderType::StopLoss | SbeOrderType::TakeProfit => OrderType::StopMarket,
547 SbeOrderType::StopLossLimit | SbeOrderType::TakeProfitLimit => OrderType::StopLimit,
548 SbeOrderType::NonRepresentable | SbeOrderType::NullVal => OrderType::Market,
549 }
550}
551
552#[must_use]
554pub const fn map_order_side_sbe(side: SbeOrderSide) -> OrderSide {
555 match side {
556 SbeOrderSide::Buy => OrderSide::Buy,
557 SbeOrderSide::Sell => OrderSide::Sell,
558 SbeOrderSide::NonRepresentable | SbeOrderSide::NullVal => OrderSide::NoOrderSide,
559 }
560}
561
562#[must_use]
564pub const fn map_time_in_force_sbe(tif: SbeTimeInForce) -> TimeInForce {
565 match tif {
566 SbeTimeInForce::Gtc => TimeInForce::Gtc,
567 SbeTimeInForce::Ioc => TimeInForce::Ioc,
568 SbeTimeInForce::Fok => TimeInForce::Fok,
569 SbeTimeInForce::NonRepresentable | SbeTimeInForce::NullVal => TimeInForce::Gtc,
570 }
571}
572
573pub fn parse_order_status_report_sbe(
579 order: &BinanceOrderResponse,
580 account_id: AccountId,
581 instrument: &InstrumentAny,
582 broker_id: &str,
583 ts_init: UnixNanos,
584) -> anyhow::Result<OrderStatusReport> {
585 let instrument_id = instrument.id();
586 let price_precision = instrument.price_precision();
587 let size_precision = instrument.size_precision();
588
589 let price = if order.price_mantissa != 0 {
590 Some(Price::from_mantissa_exponent(
591 order.price_mantissa,
592 order.price_exponent,
593 price_precision,
594 ))
595 } else {
596 None
597 };
598
599 let quantity = Quantity::from_mantissa_exponent(
600 order.orig_qty_mantissa as u64,
601 order.qty_exponent,
602 size_precision,
603 );
604 let filled_qty = Quantity::from_mantissa_exponent(
605 order.executed_qty_mantissa as u64,
606 order.qty_exponent,
607 size_precision,
608 );
609
610 let avg_px = if order.executed_qty_mantissa > 0 {
613 let quote_exp = (order.price_exponent as i32) + (order.qty_exponent as i32);
614 let cum_quote_dec = Decimal::new(order.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
615 let filled_dec = Decimal::new(
616 order.executed_qty_mantissa,
617 (-order.qty_exponent as i32) as u32,
618 );
619 let avg_dec = cum_quote_dec / filled_dec;
620 Some(
621 Price::from_decimal_dp(avg_dec, price_precision)
622 .unwrap_or(Price::zero(price_precision)),
623 )
624 } else {
625 None
626 };
627
628 let trigger_price = order.stop_price_mantissa.and_then(|mantissa| {
630 if mantissa != 0 {
631 Some(Price::from_mantissa_exponent(
632 mantissa,
633 order.price_exponent,
634 price_precision,
635 ))
636 } else {
637 None
638 }
639 });
640
641 let order_status = map_order_status_sbe(order.status);
643 let order_type = map_order_type_sbe(order.order_type);
644 let order_side = map_order_side_sbe(order.side);
645 let time_in_force = map_time_in_force_sbe(order.time_in_force);
646
647 let trigger_type = if trigger_price.is_some() {
649 Some(TriggerType::LastPrice)
650 } else {
651 None
652 };
653
654 let ts_event = UnixNanos::from_micros(order.update_time as u64);
656
657 let order_list_id = order.order_list_id.and_then(|id| {
659 if id > 0 {
660 Some(OrderListId::new(id.to_string()))
661 } else {
662 None
663 }
664 });
665
666 let post_only = order.order_type == SbeOrderType::LimitMaker;
668
669 let ts_accepted = UnixNanos::from_micros(order.time as u64);
671
672 let mut report = OrderStatusReport::new(
673 account_id,
674 instrument_id,
675 Some(ClientOrderId::new(decode_broker_id(
676 &order.client_order_id,
677 broker_id,
678 ))),
679 VenueOrderId::new(order.order_id.to_string()),
680 order_side,
681 order_type,
682 time_in_force,
683 order_status,
684 quantity,
685 filled_qty,
686 ts_accepted,
687 ts_event,
688 ts_init,
689 None, );
691
692 if let Some(p) = price {
694 report = report.with_price(p);
695 }
696
697 if let Some(ap) = avg_px {
698 report = report.with_avg_px(ap.as_f64())?;
699 }
700
701 if let Some(tp) = trigger_price {
702 report = report.with_trigger_price(tp);
703 }
704
705 if let Some(tt) = trigger_type {
706 report = report.with_trigger_type(tt);
707 }
708
709 if let Some(oli) = order_list_id {
710 report = report.with_order_list_id(oli);
711 }
712
713 if post_only {
714 report = report.with_post_only(true);
715 }
716
717 Ok(report)
718}
719
720pub fn parse_new_order_response_sbe(
726 response: &BinanceNewOrderResponse,
727 account_id: AccountId,
728 instrument: &InstrumentAny,
729 broker_id: &str,
730 ts_init: UnixNanos,
731) -> anyhow::Result<OrderStatusReport> {
732 let instrument_id = instrument.id();
733 let price_precision = instrument.price_precision();
734 let size_precision = instrument.size_precision();
735
736 let price = if response.price_mantissa != 0 {
737 Some(Price::from_mantissa_exponent(
738 response.price_mantissa,
739 response.price_exponent,
740 price_precision,
741 ))
742 } else {
743 None
744 };
745
746 let quantity = Quantity::from_mantissa_exponent(
747 response.orig_qty_mantissa as u64,
748 response.qty_exponent,
749 size_precision,
750 );
751 let filled_qty = Quantity::from_mantissa_exponent(
752 response.executed_qty_mantissa as u64,
753 response.qty_exponent,
754 size_precision,
755 );
756
757 let avg_px = if response.executed_qty_mantissa > 0 {
760 let quote_exp = (response.price_exponent as i32) + (response.qty_exponent as i32);
761 let cum_quote_dec =
762 Decimal::new(response.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
763 let filled_dec = Decimal::new(
764 response.executed_qty_mantissa,
765 (-response.qty_exponent as i32) as u32,
766 );
767 let avg_dec = cum_quote_dec / filled_dec;
768 Some(
769 Price::from_decimal_dp(avg_dec, price_precision)
770 .unwrap_or(Price::zero(price_precision)),
771 )
772 } else {
773 None
774 };
775
776 let trigger_price = response.stop_price_mantissa.and_then(|mantissa| {
777 if mantissa != 0 {
778 Some(Price::from_mantissa_exponent(
779 mantissa,
780 response.price_exponent,
781 price_precision,
782 ))
783 } else {
784 None
785 }
786 });
787
788 let order_status = map_order_status_sbe(response.status);
789 let order_type = map_order_type_sbe(response.order_type);
790 let order_side = map_order_side_sbe(response.side);
791 let time_in_force = map_time_in_force_sbe(response.time_in_force);
792
793 let trigger_type = if trigger_price.is_some() {
794 Some(TriggerType::LastPrice)
795 } else {
796 None
797 };
798
799 let ts_event = UnixNanos::from_micros(response.transact_time as u64);
801 let ts_accepted = ts_event;
802
803 let order_list_id = response.order_list_id.and_then(|id| {
804 if id > 0 {
805 Some(OrderListId::new(id.to_string()))
806 } else {
807 None
808 }
809 });
810
811 let post_only = response.order_type == SbeOrderType::LimitMaker;
813
814 let mut report = OrderStatusReport::new(
815 account_id,
816 instrument_id,
817 Some(ClientOrderId::new(decode_broker_id(
818 &response.client_order_id,
819 broker_id,
820 ))),
821 VenueOrderId::new(response.order_id.to_string()),
822 order_side,
823 order_type,
824 time_in_force,
825 order_status,
826 quantity,
827 filled_qty,
828 ts_accepted,
829 ts_event,
830 ts_init,
831 None,
832 );
833
834 if let Some(p) = price {
835 report = report.with_price(p);
836 }
837
838 if let Some(ap) = avg_px {
839 report = report.with_avg_px(ap.as_f64())?;
840 }
841
842 if let Some(tp) = trigger_price {
843 report = report.with_trigger_price(tp);
844 }
845
846 if let Some(tt) = trigger_type {
847 report = report.with_trigger_type(tt);
848 }
849
850 if let Some(oli) = order_list_id {
851 report = report.with_order_list_id(oli);
852 }
853
854 if post_only {
855 report = report.with_post_only(true);
856 }
857
858 Ok(report)
859}
860
861pub fn parse_fill_report_sbe(
867 trade: &BinanceAccountTrade,
868 account_id: AccountId,
869 instrument: &InstrumentAny,
870 commission_currency: Currency,
871 ts_init: UnixNanos,
872) -> anyhow::Result<FillReport> {
873 let instrument_id = instrument.id();
874 let price_precision = instrument.price_precision();
875 let size_precision = instrument.size_precision();
876
877 let last_px =
878 Price::from_mantissa_exponent(trade.price_mantissa, trade.price_exponent, price_precision);
879 let last_qty = Quantity::from_mantissa_exponent(
880 trade.qty_mantissa as u64,
881 trade.qty_exponent,
882 size_precision,
883 );
884
885 let comm_exp = trade.commission_exponent as i32;
887 let comm_dec = Decimal::new(trade.commission_mantissa, (-comm_exp) as u32);
888 let commission = Money::new(comm_dec.to_f64().unwrap_or(0.0), commission_currency);
889
890 let order_side = if trade.is_buyer {
892 OrderSide::Buy
893 } else {
894 OrderSide::Sell
895 };
896
897 let liquidity_side = if trade.is_maker {
899 LiquiditySide::Maker
900 } else {
901 LiquiditySide::Taker
902 };
903
904 let ts_event = UnixNanos::from_micros(trade.time as u64);
906
907 Ok(FillReport::new(
908 account_id,
909 instrument_id,
910 VenueOrderId::new(trade.order_id.to_string()),
911 TradeId::new(trade.id.to_string()),
912 order_side,
913 last_qty,
914 last_px,
915 commission,
916 liquidity_side,
917 None, None, ts_event,
920 ts_init,
921 None, ))
923}
924
925pub fn parse_klines_to_bars(
931 klines: &BinanceKlines,
932 bar_type: BarType,
933 instrument: &InstrumentAny,
934 ts_init: UnixNanos,
935) -> anyhow::Result<Vec<Bar>> {
936 let price_precision = instrument.price_precision();
937 let size_precision = instrument.size_precision();
938
939 let mut bars = Vec::with_capacity(klines.klines.len());
940
941 for kline in &klines.klines {
942 let open =
943 Price::from_mantissa_exponent(kline.open_price, klines.price_exponent, price_precision);
944 let high =
945 Price::from_mantissa_exponent(kline.high_price, klines.price_exponent, price_precision);
946 let low =
947 Price::from_mantissa_exponent(kline.low_price, klines.price_exponent, price_precision);
948 let close = Price::from_mantissa_exponent(
949 kline.close_price,
950 klines.price_exponent,
951 price_precision,
952 );
953
954 let volume_mantissa = i128::from_le_bytes(kline.volume);
956 let volume_dec =
957 Decimal::from_i128_with_scale(volume_mantissa, (-klines.qty_exponent as i32) as u32);
958 let volume = Quantity::new(volume_dec.to_f64().unwrap_or(0.0), size_precision);
959
960 let ts_event = UnixNanos::from_micros(kline.open_time as u64);
961
962 let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
963 bars.push(bar);
964 }
965
966 Ok(bars)
967}
968
969pub fn bar_spec_to_binance_interval(
976 bar_spec: BarSpecification,
977) -> anyhow::Result<BinanceKlineInterval> {
978 let step = bar_spec.step.get();
979 let interval = match bar_spec.aggregation {
980 BarAggregation::Second => {
981 anyhow::bail!("Binance Spot does not support second-level kline intervals")
982 }
983 BarAggregation::Minute => match step {
984 1 => BinanceKlineInterval::Minute1,
985 3 => BinanceKlineInterval::Minute3,
986 5 => BinanceKlineInterval::Minute5,
987 15 => BinanceKlineInterval::Minute15,
988 30 => BinanceKlineInterval::Minute30,
989 _ => anyhow::bail!("Unsupported minute interval: {step}m"),
990 },
991 BarAggregation::Hour => match step {
992 1 => BinanceKlineInterval::Hour1,
993 2 => BinanceKlineInterval::Hour2,
994 4 => BinanceKlineInterval::Hour4,
995 6 => BinanceKlineInterval::Hour6,
996 8 => BinanceKlineInterval::Hour8,
997 12 => BinanceKlineInterval::Hour12,
998 _ => anyhow::bail!("Unsupported hour interval: {step}h"),
999 },
1000 BarAggregation::Day => match step {
1001 1 => BinanceKlineInterval::Day1,
1002 3 => BinanceKlineInterval::Day3,
1003 _ => anyhow::bail!("Unsupported day interval: {step}d"),
1004 },
1005 BarAggregation::Week => match step {
1006 1 => BinanceKlineInterval::Week1,
1007 _ => anyhow::bail!("Unsupported week interval: {step}w"),
1008 },
1009 BarAggregation::Month => match step {
1010 1 => BinanceKlineInterval::Month1,
1011 _ => anyhow::bail!("Unsupported month interval: {step}M"),
1012 },
1013 agg => anyhow::bail!("Unsupported bar aggregation for Binance: {agg:?}"),
1014 };
1015
1016 Ok(interval)
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use rstest::rstest;
1022 use serde_json::json;
1023 use ustr::Ustr;
1024
1025 use super::*;
1026 use crate::common::{
1027 consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
1028 enums::{BinanceContractStatus, BinanceTradingStatus},
1029 };
1030
1031 fn sample_usdm_symbol() -> BinanceFuturesUsdSymbol {
1032 BinanceFuturesUsdSymbol {
1033 symbol: Ustr::from("BTCUSDT"),
1034 pair: Ustr::from("BTCUSDT"),
1035 contract_type: "PERPETUAL".to_string(),
1036 delivery_date: 4133404800000,
1037 onboard_date: 1569398400000,
1038 status: BinanceTradingStatus::Trading,
1039 maint_margin_percent: "2.5000".to_string(),
1040 required_margin_percent: "5.0000".to_string(),
1041 base_asset: Ustr::from("BTC"),
1042 quote_asset: Ustr::from("USDT"),
1043 margin_asset: Ustr::from("USDT"),
1044 price_precision: 2,
1045 quantity_precision: 3,
1046 base_asset_precision: 8,
1047 quote_precision: 8,
1048 underlying_type: Some("COIN".to_string()),
1049 underlying_sub_type: vec!["PoW".to_string()],
1050 settle_plan: None,
1051 trigger_protect: Some("0.0500".to_string()),
1052 liquidation_fee: Some("0.012500".to_string()),
1053 market_take_bound: Some("0.05".to_string()),
1054 order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
1055 time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
1056 filters: vec![
1057 json!({
1058 "filterType": "PRICE_FILTER",
1059 "tickSize": "0.10",
1060 "maxPrice": "4529764",
1061 "minPrice": "556.80"
1062 }),
1063 json!({
1064 "filterType": "LOT_SIZE",
1065 "stepSize": "0.001",
1066 "maxQty": "1000",
1067 "minQty": "0.001"
1068 }),
1069 ],
1070 }
1071 }
1072
1073 fn sample_coinm_symbol() -> BinanceFuturesCoinSymbol {
1074 BinanceFuturesCoinSymbol {
1075 symbol: Ustr::from("BTCUSD_PERP"),
1076 pair: Ustr::from("BTCUSD"),
1077 contract_type: "PERPETUAL".to_string(),
1078 delivery_date: 4_133_404_800_000,
1079 onboard_date: 1_569_398_400_000,
1080 contract_status: Some(BinanceContractStatus::Trading),
1081 contract_size: 100,
1082 maint_margin_percent: "2.5000".to_string(),
1083 required_margin_percent: "5.0000".to_string(),
1084 base_asset: Ustr::from("BTC"),
1085 quote_asset: Ustr::from("USD"),
1086 margin_asset: Ustr::from("BTC"),
1087 price_precision: 1,
1088 quantity_precision: 0,
1089 base_asset_precision: 8,
1090 quote_precision: 8,
1091 equal_qty_precision: None,
1092 trigger_protect: Some("0.0500".to_string()),
1093 liquidation_fee: Some("0.012500".to_string()),
1094 market_take_bound: Some("0.05".to_string()),
1095 order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
1096 time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
1097 filters: vec![
1098 json!({
1099 "filterType": "PRICE_FILTER",
1100 "tickSize": "0.10",
1101 "maxPrice": "1000000",
1102 "minPrice": "0.10"
1103 }),
1104 json!({
1105 "filterType": "LOT_SIZE",
1106 "stepSize": "1",
1107 "maxQty": "1000",
1108 "minQty": "1"
1109 }),
1110 ],
1111 }
1112 }
1113
1114 fn sample_spot_symbol_sbe() -> BinanceSymbolSbe {
1115 BinanceSymbolSbe {
1116 symbol: "ETHUSDT".to_string(),
1117 base_asset: "ETH".to_string(),
1118 quote_asset: "USDT".to_string(),
1119 base_asset_precision: 8,
1120 quote_asset_precision: 8,
1121 status: SBE_STATUS_TRADING,
1122 order_types: 0,
1123 iceberg_allowed: true,
1124 oco_allowed: true,
1125 oto_allowed: false,
1126 quote_order_qty_market_allowed: true,
1127 allow_trailing_stop: true,
1128 cancel_replace_allowed: true,
1129 amend_allowed: true,
1130 is_spot_trading_allowed: true,
1131 is_margin_trading_allowed: false,
1132 filters: crate::spot::http::models::BinanceSymbolFiltersSbe {
1133 price_filter: Some(BinancePriceFilterSbe {
1134 price_exponent: -8,
1135 min_price: 1_000_000,
1136 max_price: 100_000_000_000_000,
1137 tick_size: 1_000_000,
1138 }),
1139 lot_size_filter: Some(BinanceLotSizeFilterSbe {
1140 qty_exponent: -8,
1141 min_qty: 10_000,
1142 max_qty: 900_000_000_000,
1143 step_size: 10_000,
1144 }),
1145 },
1146 permissions: vec![vec!["SPOT".to_string()]],
1147 }
1148 }
1149
1150 fn sample_spot_instrument() -> InstrumentAny {
1151 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1152 parse_spot_instrument_sbe(&sample_spot_symbol_sbe(), ts, ts).unwrap()
1153 }
1154
1155 fn sample_account_id() -> AccountId {
1156 AccountId::from("BINANCE-SPOT-001")
1157 }
1158
1159 #[rstest]
1160 fn test_parse_usdm_perpetual() {
1161 let symbol = sample_usdm_symbol();
1162 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1163
1164 let result = parse_usdm_instrument(&symbol, ts, ts);
1165 assert!(result.is_ok(), "Failed: {:?}", result.err());
1166
1167 let instrument = result.unwrap();
1168 match instrument {
1169 InstrumentAny::CryptoPerpetual(perp) => {
1170 assert_eq!(perp.id.to_string(), "BTCUSDT-PERP.BINANCE");
1171 assert_eq!(perp.raw_symbol.to_string(), "BTCUSDT");
1172 assert_eq!(perp.base_currency.code.as_str(), "BTC");
1173 assert_eq!(perp.quote_currency.code.as_str(), "USDT");
1174 assert_eq!(perp.settlement_currency.code.as_str(), "USDT");
1175 assert!(!perp.is_inverse);
1176 assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
1177 assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1178 }
1179 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1180 }
1181 }
1182
1183 #[rstest]
1184 fn test_parse_non_perpetual_fails() {
1185 let mut symbol = sample_usdm_symbol();
1186 symbol.contract_type = "CURRENT_QUARTER".to_string();
1187 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1188
1189 let result = parse_usdm_instrument(&symbol, ts, ts);
1190 assert!(result.is_err());
1191 assert!(
1192 result
1193 .unwrap_err()
1194 .to_string()
1195 .contains("Unsupported contract type")
1196 );
1197 }
1198
1199 #[rstest]
1200 fn test_parse_missing_price_filter_fails() {
1201 let mut symbol = sample_usdm_symbol();
1202 symbol.filters = vec![json!({
1203 "filterType": "LOT_SIZE",
1204 "stepSize": "0.001",
1205 "maxQty": "1000",
1206 "minQty": "0.001"
1207 })];
1208 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1209
1210 let result = parse_usdm_instrument(&symbol, ts, ts);
1211 assert!(result.is_err());
1212 assert!(
1213 result
1214 .unwrap_err()
1215 .to_string()
1216 .contains("Missing PRICE_FILTER")
1217 );
1218 }
1219
1220 #[rstest]
1221 fn test_parse_coinm_perpetual() {
1222 let symbol = sample_coinm_symbol();
1223 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1224
1225 let result = parse_coinm_instrument(&symbol, ts, ts).unwrap();
1226
1227 match result {
1228 InstrumentAny::CryptoPerpetual(perp) => {
1229 assert_eq!(perp.id.to_string(), "BTCUSD_PERP-PERP.BINANCE");
1230 assert_eq!(perp.raw_symbol.to_string(), "BTCUSD_PERP");
1231 assert_eq!(perp.base_currency.code.as_str(), "BTC");
1232 assert_eq!(perp.quote_currency.code.as_str(), "USD");
1233 assert_eq!(perp.settlement_currency.code.as_str(), "BTC");
1234 assert!(perp.is_inverse);
1235 assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
1236 assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
1237 }
1238 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1239 }
1240 }
1241
1242 #[rstest]
1243 fn test_parse_spot_instrument_sbe() {
1244 let symbol = sample_spot_symbol_sbe();
1245 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1246
1247 let result = parse_spot_instrument_sbe(&symbol, ts, ts).unwrap();
1248
1249 match result {
1250 InstrumentAny::CurrencyPair(pair) => {
1251 assert_eq!(pair.id.to_string(), "ETHUSDT.BINANCE");
1252 assert_eq!(pair.raw_symbol.to_string(), "ETHUSDT");
1253 assert_eq!(pair.base_currency.code.as_str(), "ETH");
1254 assert_eq!(pair.quote_currency.code.as_str(), "USDT");
1255 assert_eq!(pair.price_increment, Price::from_str("0.01").unwrap());
1256 assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
1257 }
1258 other => panic!("Expected CurrencyPair, was {other:?}"),
1259 }
1260 }
1261
1262 #[rstest]
1263 fn test_parse_spot_trades_sbe() {
1264 let instrument = sample_spot_instrument();
1265 let trades = BinanceTrades {
1266 price_exponent: -2,
1267 qty_exponent: -4,
1268 trades: vec![
1269 crate::spot::http::models::BinanceTrade {
1270 id: 1,
1271 price_mantissa: 12_345,
1272 qty_mantissa: 25_000,
1273 quote_qty_mantissa: 0,
1274 time: 1_700_000_000_000_000,
1275 is_buyer_maker: false,
1276 is_best_match: true,
1277 },
1278 crate::spot::http::models::BinanceTrade {
1279 id: 2,
1280 price_mantissa: 12_340,
1281 qty_mantissa: 10_000,
1282 quote_qty_mantissa: 0,
1283 time: 1_700_000_000_500_000,
1284 is_buyer_maker: true,
1285 is_best_match: true,
1286 },
1287 ],
1288 };
1289 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
1290
1291 let result = parse_spot_trades_sbe(&trades, &instrument, ts_init).unwrap();
1292
1293 assert_eq!(result.len(), 2);
1294 assert_eq!(result[0].instrument_id, instrument.id());
1295 assert_eq!(result[0].price.as_f64(), 123.45);
1296 assert_eq!(result[0].size.as_f64(), 2.5);
1297 assert_eq!(result[0].aggressor_side, AggressorSide::Buyer);
1298 assert_eq!(result[0].trade_id, TradeId::new("1"));
1299 assert_eq!(
1300 result[0].ts_event,
1301 UnixNanos::from(1_700_000_000_000_000_000u64)
1302 );
1303 assert_eq!(result[0].ts_init, ts_init);
1304 assert_eq!(result[1].aggressor_side, AggressorSide::Seller);
1305 }
1306
1307 #[rstest]
1308 fn test_parse_order_status_report_sbe() {
1309 let instrument = sample_spot_instrument();
1310 let order = BinanceOrderResponse {
1311 price_exponent: -2,
1312 qty_exponent: -4,
1313 order_id: 42,
1314 order_list_id: Some(77),
1315 price_mantissa: 12_345,
1316 orig_qty_mantissa: 25_000,
1317 executed_qty_mantissa: 10_000,
1318 cummulative_quote_qty_mantissa: 123_450_000,
1319 status: SbeOrderStatus::PartiallyFilled,
1320 time_in_force: SbeTimeInForce::Gtc,
1321 order_type: SbeOrderType::LimitMaker,
1322 side: SbeOrderSide::Buy,
1323 stop_price_mantissa: None,
1324 iceberg_qty_mantissa: None,
1325 time: 1_700_000_000_000_000,
1326 update_time: 1_700_000_000_100_000,
1327 is_working: true,
1328 working_time: Some(1_700_000_000_050_000),
1329 orig_quote_order_qty_mantissa: 0,
1330 self_trade_prevention_mode:
1331 crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
1332 client_order_id: "client-123".to_string(),
1333 symbol: "ETHUSDT".to_string(),
1334 };
1335 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
1336
1337 let report = parse_order_status_report_sbe(
1338 &order,
1339 sample_account_id(),
1340 &instrument,
1341 BINANCE_NAUTILUS_SPOT_BROKER_ID,
1342 ts_init,
1343 )
1344 .unwrap();
1345
1346 assert_eq!(report.account_id, sample_account_id());
1347 assert_eq!(report.instrument_id, instrument.id());
1348 assert_eq!(
1349 report.client_order_id,
1350 Some(ClientOrderId::new("client-123"))
1351 );
1352 assert_eq!(report.venue_order_id, VenueOrderId::new("42"));
1353 assert_eq!(report.order_side, OrderSide::Buy);
1354 assert_eq!(report.order_type, OrderType::Limit);
1355 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1356 assert_eq!(report.quantity.as_f64(), 2.5);
1357 assert_eq!(report.filled_qty.as_f64(), 1.0);
1358 assert_eq!(report.order_list_id, Some(OrderListId::new("77")));
1359 assert_eq!(report.price, Some(Price::new(123.45, 2)));
1360 assert_eq!(report.avg_px.unwrap().to_string(), "123.45");
1361 assert!(report.post_only);
1362 assert_eq!(
1363 report.ts_accepted,
1364 UnixNanos::from(1_700_000_000_000_000_000u64)
1365 );
1366 assert_eq!(
1367 report.ts_last,
1368 UnixNanos::from(1_700_000_000_100_000_000u64)
1369 );
1370 assert_eq!(report.ts_init, ts_init);
1371 }
1372
1373 #[rstest]
1374 fn test_parse_new_order_response_sbe() {
1375 let instrument = sample_spot_instrument();
1376 let response = BinanceNewOrderResponse {
1377 price_exponent: -2,
1378 qty_exponent: -4,
1379 order_id: 99,
1380 order_list_id: Some(7),
1381 transact_time: 1_700_000_000_000_000,
1382 price_mantissa: 12_100,
1383 orig_qty_mantissa: 20_000,
1384 executed_qty_mantissa: 5_000,
1385 cummulative_quote_qty_mantissa: 60_500_000,
1386 status: SbeOrderStatus::New,
1387 time_in_force: SbeTimeInForce::Gtc,
1388 order_type: SbeOrderType::StopLossLimit,
1389 side: SbeOrderSide::Sell,
1390 stop_price_mantissa: Some(12_000),
1391 working_time: Some(1_700_000_000_000_000),
1392 self_trade_prevention_mode:
1393 crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
1394 client_order_id: "client-456".to_string(),
1395 symbol: "ETHUSDT".to_string(),
1396 fills: vec![],
1397 };
1398 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
1399
1400 let report = parse_new_order_response_sbe(
1401 &response,
1402 sample_account_id(),
1403 &instrument,
1404 BINANCE_NAUTILUS_SPOT_BROKER_ID,
1405 ts_init,
1406 )
1407 .unwrap();
1408
1409 assert_eq!(report.account_id, sample_account_id());
1410 assert_eq!(report.instrument_id, instrument.id());
1411 assert_eq!(
1412 report.client_order_id,
1413 Some(ClientOrderId::new("client-456"))
1414 );
1415 assert_eq!(report.venue_order_id, VenueOrderId::new("99"));
1416 assert_eq!(report.order_side, OrderSide::Sell);
1417 assert_eq!(report.order_type, OrderType::StopLimit);
1418 assert_eq!(report.order_status, OrderStatus::Accepted);
1419 assert_eq!(report.quantity.as_f64(), 2.0);
1420 assert_eq!(report.filled_qty.as_f64(), 0.5);
1421 assert_eq!(report.order_list_id, Some(OrderListId::new("7")));
1422 assert_eq!(report.price, Some(Price::new(121.0, 2)));
1423 assert_eq!(report.trigger_price, Some(Price::new(120.0, 2)));
1424 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1425 assert_eq!(report.avg_px.unwrap().to_string(), "121");
1426 assert!(!report.post_only);
1427 assert_eq!(
1428 report.ts_accepted,
1429 UnixNanos::from(1_700_000_000_000_000_000u64)
1430 );
1431 assert_eq!(
1432 report.ts_last,
1433 UnixNanos::from(1_700_000_000_000_000_000u64)
1434 );
1435 }
1436
1437 #[rstest]
1438 fn test_parse_fill_report_sbe() {
1439 let instrument = sample_spot_instrument();
1440 let trade = BinanceAccountTrade {
1441 price_exponent: -2,
1442 qty_exponent: -4,
1443 commission_exponent: -8,
1444 id: 123,
1445 order_id: 456,
1446 order_list_id: None,
1447 price_mantissa: 12_345,
1448 qty_mantissa: 25_000,
1449 quote_qty_mantissa: 0,
1450 commission_mantissa: 10_000,
1451 time: 1_700_000_000_000_000,
1452 is_buyer: false,
1453 is_maker: true,
1454 is_best_match: true,
1455 symbol: "ETHUSDT".to_string(),
1456 commission_asset: "USDT".to_string(),
1457 };
1458 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
1459
1460 let report = parse_fill_report_sbe(
1461 &trade,
1462 sample_account_id(),
1463 &instrument,
1464 Currency::from("USDT"),
1465 ts_init,
1466 )
1467 .unwrap();
1468
1469 assert_eq!(report.account_id, sample_account_id());
1470 assert_eq!(report.instrument_id, instrument.id());
1471 assert_eq!(report.venue_order_id, VenueOrderId::new("456"));
1472 assert_eq!(report.trade_id, TradeId::new("123"));
1473 assert_eq!(report.order_side, OrderSide::Sell);
1474 assert_eq!(report.last_qty.as_f64(), 2.5);
1475 assert_eq!(report.last_px.as_f64(), 123.45);
1476 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1477 assert_eq!(report.commission.as_f64(), 0.0001);
1478 assert_eq!(
1479 report.ts_event,
1480 UnixNanos::from(1_700_000_000_000_000_000u64)
1481 );
1482 assert_eq!(report.ts_init, ts_init);
1483 assert!(report.client_order_id.is_none());
1484 }
1485
1486 #[rstest]
1487 fn test_parse_klines_to_bars() {
1488 use nautilus_model::enums::{AggregationSource, PriceType};
1489
1490 let instrument = sample_spot_instrument();
1491 let bar_type = BarType::new(
1492 instrument.id(),
1493 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1494 AggregationSource::External,
1495 );
1496 let klines = BinanceKlines {
1497 price_exponent: -2,
1498 qty_exponent: -4,
1499 klines: vec![crate::spot::http::models::BinanceKline {
1500 open_time: 1_700_000_000_000_000,
1501 open_price: 12_000,
1502 high_price: 12_500,
1503 low_price: 11_900,
1504 close_price: 12_345,
1505 volume: 1_234_500_i128.to_le_bytes(),
1506 close_time: 1_700_000_059_999_000,
1507 quote_volume: 0_i128.to_le_bytes(),
1508 num_trades: 100,
1509 taker_buy_base_volume: 0_i128.to_le_bytes(),
1510 taker_buy_quote_volume: 0_i128.to_le_bytes(),
1511 }],
1512 };
1513 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
1514
1515 let bars = parse_klines_to_bars(&klines, bar_type, &instrument, ts_init).unwrap();
1516
1517 assert_eq!(bars.len(), 1);
1518 assert_eq!(bars[0].bar_type, bar_type);
1519 assert_eq!(bars[0].open, Price::new(120.0, 2));
1520 assert_eq!(bars[0].high, Price::new(125.0, 2));
1521 assert_eq!(bars[0].low, Price::new(119.0, 2));
1522 assert_eq!(bars[0].close, Price::new(123.45, 2));
1523 assert_eq!(bars[0].volume, Quantity::new(123.45, 4));
1524 assert_eq!(
1525 bars[0].ts_event,
1526 UnixNanos::from(1_700_000_000_000_000_000u64)
1527 );
1528 assert_eq!(bars[0].ts_init, ts_init);
1529 }
1530
1531 mod bar_spec_tests {
1532 use std::num::NonZeroUsize;
1533
1534 use nautilus_model::{
1535 data::BarSpecification,
1536 enums::{BarAggregation, PriceType},
1537 };
1538
1539 use super::*;
1540 use crate::common::enums::BinanceKlineInterval;
1541
1542 fn make_bar_spec(step: usize, aggregation: BarAggregation) -> BarSpecification {
1543 BarSpecification {
1544 step: NonZeroUsize::new(step).unwrap(),
1545 aggregation,
1546 price_type: PriceType::Last,
1547 }
1548 }
1549
1550 #[rstest]
1551 #[case(1, BarAggregation::Minute, BinanceKlineInterval::Minute1)]
1552 #[case(3, BarAggregation::Minute, BinanceKlineInterval::Minute3)]
1553 #[case(5, BarAggregation::Minute, BinanceKlineInterval::Minute5)]
1554 #[case(15, BarAggregation::Minute, BinanceKlineInterval::Minute15)]
1555 #[case(30, BarAggregation::Minute, BinanceKlineInterval::Minute30)]
1556 #[case(1, BarAggregation::Hour, BinanceKlineInterval::Hour1)]
1557 #[case(2, BarAggregation::Hour, BinanceKlineInterval::Hour2)]
1558 #[case(4, BarAggregation::Hour, BinanceKlineInterval::Hour4)]
1559 #[case(6, BarAggregation::Hour, BinanceKlineInterval::Hour6)]
1560 #[case(8, BarAggregation::Hour, BinanceKlineInterval::Hour8)]
1561 #[case(12, BarAggregation::Hour, BinanceKlineInterval::Hour12)]
1562 #[case(1, BarAggregation::Day, BinanceKlineInterval::Day1)]
1563 #[case(3, BarAggregation::Day, BinanceKlineInterval::Day3)]
1564 #[case(1, BarAggregation::Week, BinanceKlineInterval::Week1)]
1565 #[case(1, BarAggregation::Month, BinanceKlineInterval::Month1)]
1566 fn test_bar_spec_to_binance_interval(
1567 #[case] step: usize,
1568 #[case] aggregation: BarAggregation,
1569 #[case] expected: BinanceKlineInterval,
1570 ) {
1571 let bar_spec = make_bar_spec(step, aggregation);
1572 let result = bar_spec_to_binance_interval(bar_spec).unwrap();
1573 assert_eq!(result, expected);
1574 }
1575
1576 #[rstest]
1577 fn test_unsupported_second_interval() {
1578 let bar_spec = make_bar_spec(1, BarAggregation::Second);
1579 let result = bar_spec_to_binance_interval(bar_spec);
1580 assert!(result.is_err());
1581 assert!(
1582 result
1583 .unwrap_err()
1584 .to_string()
1585 .contains("does not support second-level")
1586 );
1587 }
1588
1589 #[rstest]
1590 fn test_unsupported_minute_interval() {
1591 let bar_spec = make_bar_spec(7, BarAggregation::Minute);
1592 let result = bar_spec_to_binance_interval(bar_spec);
1593 assert!(result.is_err());
1594 assert!(
1595 result
1596 .unwrap_err()
1597 .to_string()
1598 .contains("Unsupported minute interval")
1599 );
1600 }
1601
1602 #[rstest]
1603 fn test_unsupported_aggregation() {
1604 let bar_spec = make_bar_spec(100, BarAggregation::Tick);
1605 let result = bar_spec_to_binance_interval(bar_spec);
1606 assert!(result.is_err());
1607 assert!(
1608 result
1609 .unwrap_err()
1610 .to_string()
1611 .contains("Unsupported bar aggregation")
1612 );
1613 }
1614 }
1615
1616 mod sbe_precision_tests {
1617 use super::*;
1618 use crate::spot::http::models::{BinanceLotSizeFilterSbe, BinancePriceFilterSbe};
1619
1620 #[rstest]
1621 #[case::precision_0(100_000_000, -8, 0)]
1622 #[case::precision_1(10_000_000, -8, 1)]
1623 #[case::precision_2(1_000_000, -8, 2)]
1624 #[case::precision_3(100_000, -8, 3)]
1625 #[case::precision_4(10_000, -8, 4)]
1626 #[case::precision_5(1_000, -8, 5)]
1627 #[case::precision_6(100, -8, 6)]
1628 #[case::precision_7(10, -8, 7)]
1629 #[case::precision_8(1, -8, 8)]
1630 fn test_sbe_mantissa_precision(
1631 #[case] mantissa: i64,
1632 #[case] exponent: i8,
1633 #[case] expected: u8,
1634 ) {
1635 let result = sbe_mantissa_precision(mantissa, exponent);
1636 assert_eq!(
1637 result, expected,
1638 "mantissa={mantissa}, exponent={exponent}: expected {expected}, was {result}"
1639 );
1640 }
1641
1642 #[rstest]
1643 fn test_sbe_mantissa_precision_zero_mantissa() {
1644 assert_eq!(sbe_mantissa_precision(0, -8), 0);
1645 }
1646
1647 #[rstest]
1648 fn test_sbe_mantissa_precision_positive_exponent() {
1649 assert_eq!(sbe_mantissa_precision(1, 0), 0);
1650 assert_eq!(sbe_mantissa_precision(5, 2), 0);
1651 }
1652
1653 #[rstest]
1654 fn test_parse_sbe_price_filter_ethusdc() {
1655 let filter = BinancePriceFilterSbe {
1656 price_exponent: -8,
1657 min_price: 1_000_000,
1658 max_price: 100_000_000_000_000,
1659 tick_size: 1_000_000,
1660 };
1661
1662 let (tick_size, max_price, min_price) = parse_sbe_price_filter(&filter);
1663
1664 assert_eq!(tick_size.precision, 2, "tick_size precision");
1665 assert_eq!(tick_size.as_f64(), 0.01);
1666 assert_eq!(max_price.unwrap().precision, 2);
1667 assert_eq!(min_price.unwrap().precision, 2);
1668 }
1669
1670 #[rstest]
1671 fn test_parse_sbe_price_filter_shibusdt() {
1672 let filter = BinancePriceFilterSbe {
1673 price_exponent: -8,
1674 min_price: 1,
1675 max_price: 100_000_000,
1676 tick_size: 1,
1677 };
1678
1679 let (tick_size, _, _) = parse_sbe_price_filter(&filter);
1680
1681 assert_eq!(tick_size.precision, 8);
1682 assert_eq!(tick_size.as_f64(), 0.00000001);
1683 }
1684
1685 #[rstest]
1686 fn test_parse_sbe_lot_size_filter_ethusdc() {
1687 let filter = BinanceLotSizeFilterSbe {
1688 qty_exponent: -8,
1689 min_qty: 10_000,
1690 max_qty: 900_000_000_000,
1691 step_size: 10_000,
1692 };
1693
1694 let (step_size, max_qty, min_qty) = parse_sbe_lot_size_filter(&filter);
1695
1696 assert_eq!(step_size.precision, 4, "step_size precision");
1697 assert_eq!(min_qty.unwrap().precision, 4);
1698 assert_eq!(max_qty.unwrap().precision, 4);
1699 }
1700 }
1701}