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;
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
100fn parse_futures_min_notional(filters: &[Value], currency: Currency) -> Option<Money> {
105 let filter = get_filter(filters, "MIN_NOTIONAL")?;
106 let raw = filter.get("notional").and_then(|v| v.as_str())?;
107 let amount = f64::from_str(raw).ok()?;
108 if amount <= 0.0 {
109 return None;
110 }
111 Some(Money::new(amount, currency))
112}
113
114#[must_use]
120pub(crate) fn parse_quantity_at_precision(raw: &str, precision: u8) -> Option<Quantity> {
121 let decimal = Decimal::from_str(raw).ok()?;
122 if !decimal.is_sign_positive() || decimal.is_zero() {
123 return None;
124 }
125
126 Quantity::from_decimal_dp(decimal, precision).ok()
127}
128
129#[must_use]
134pub(crate) fn parse_price_at_precision(raw: &str, precision: u8) -> Option<Price> {
135 let decimal = Decimal::from_str(raw).ok()?;
136 if !decimal.is_sign_positive() || decimal.is_zero() {
137 return None;
138 }
139
140 Price::from_decimal_dp(decimal, precision).ok()
141}
142
143pub(crate) fn parse_required_decimal(raw: &str, field: &str) -> anyhow::Result<Decimal> {
145 Decimal::from_str(raw).map_err(|e| anyhow::anyhow!("invalid {field}='{raw}': {e}"))
146}
147
148pub(crate) fn parse_required_quantity_at_precision(
150 raw: &str,
151 precision: u8,
152 field: &str,
153) -> anyhow::Result<Quantity> {
154 let decimal = parse_required_decimal(raw, field)?;
155 Quantity::from_decimal_dp(decimal, precision)
156 .map_err(|e| anyhow::anyhow!("invalid {field}='{raw}' at precision {precision}: {e}"))
157}
158
159pub(crate) fn parse_required_price_at_precision(
161 raw: &str,
162 precision: u8,
163 field: &str,
164) -> anyhow::Result<Price> {
165 let decimal = parse_required_decimal(raw, field)?;
166 Price::from_decimal_dp(decimal, precision)
167 .map_err(|e| anyhow::anyhow!("invalid {field}='{raw}' at precision {precision}: {e}"))
168}
169
170#[must_use]
172pub(crate) fn quantity_at_precision(quantity: Quantity, precision: u8) -> Option<Quantity> {
173 Quantity::from_decimal_dp(quantity.as_decimal(), precision).ok()
174}
175
176#[must_use]
178pub(crate) fn price_at_precision(price: Price, precision: u8) -> Option<Price> {
179 Price::from_decimal_dp(price.as_decimal(), precision).ok()
180}
181
182pub fn parse_usdm_instrument(
191 symbol: &BinanceFuturesUsdSymbol,
192 ts_event: UnixNanos,
193 ts_init: UnixNanos,
194) -> anyhow::Result<InstrumentAny> {
195 if symbol.contract_type != CONTRACT_TYPE_PERPETUAL {
197 anyhow::bail!(
198 "Unsupported contract type '{}' for symbol '{}', expected '{}'",
199 symbol.contract_type,
200 symbol.symbol,
201 CONTRACT_TYPE_PERPETUAL
202 );
203 }
204
205 if symbol.status != BinanceTradingStatus::Trading {
206 anyhow::bail!(
207 "Symbol '{}' is not trading (status: {:?})",
208 symbol.symbol,
209 symbol.status
210 );
211 }
212
213 let base_currency = get_currency(symbol.base_asset.as_str());
214 let quote_currency = get_currency(symbol.quote_asset.as_str());
215 let settlement_currency = get_currency(symbol.margin_asset.as_str());
216
217 let instrument_id = InstrumentId::new(
218 Symbol::from_str_unchecked(format!("{}-PERP", symbol.symbol)),
219 Venue::new(BINANCE),
220 );
221 let raw_symbol = Symbol::new(symbol.symbol.as_str());
222
223 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
224 .context("Missing PRICE_FILTER in symbol filters")?;
225
226 let tick_size = parse_filter_price(price_filter, "tickSize")?;
227 if tick_size.is_zero() {
228 anyhow::bail!(
229 "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
230 symbol.symbol,
231 );
232 }
233 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
234 let min_price = parse_filter_price(price_filter, "minPrice").ok();
235
236 let lot_filter =
237 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
238
239 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
240 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
241 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
242
243 let min_notional = parse_futures_min_notional(&symbol.filters, quote_currency);
244
245 let default_margin = Decimal::new(1, 1);
247
248 let instrument = CryptoPerpetual::new(
249 instrument_id,
250 raw_symbol,
251 base_currency,
252 quote_currency,
253 settlement_currency,
254 false, tick_size.precision,
256 step_size.precision,
257 tick_size,
258 step_size,
259 None, Some(step_size),
261 max_quantity,
262 min_quantity,
263 None, min_notional,
265 max_price,
266 min_price,
267 Some(default_margin),
268 Some(default_margin),
269 None, None, None, None, ts_event,
274 ts_init,
275 );
276
277 Ok(InstrumentAny::CryptoPerpetual(instrument))
278}
279
280pub fn parse_coinm_instrument(
292 symbol: &BinanceFuturesCoinSymbol,
293 ts_event: UnixNanos,
294 ts_init: UnixNanos,
295) -> anyhow::Result<InstrumentAny> {
296 if symbol.contract_type != CONTRACT_TYPE_PERPETUAL {
297 anyhow::bail!(
298 "Unsupported contract type '{}' for symbol '{}', expected '{}'",
299 symbol.contract_type,
300 symbol.symbol,
301 CONTRACT_TYPE_PERPETUAL
302 );
303 }
304
305 if symbol.contract_status != Some(BinanceContractStatus::Trading) {
306 anyhow::bail!(
307 "Symbol '{}' is not trading (status: {:?})",
308 symbol.symbol,
309 symbol.contract_status
310 );
311 }
312
313 let base_currency = get_currency(symbol.base_asset.as_str());
314 let quote_currency = get_currency(symbol.quote_asset.as_str());
315
316 let settlement_currency = get_currency(symbol.margin_asset.as_str());
318
319 let instrument_id = InstrumentId::new(
320 Symbol::from_str_unchecked(format!("{}-PERP", symbol.symbol)),
321 Venue::new(BINANCE),
322 );
323 let raw_symbol = Symbol::new(symbol.symbol.as_str());
324
325 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
326 .context("Missing PRICE_FILTER in symbol filters")?;
327
328 let tick_size = parse_filter_price(price_filter, "tickSize")?;
329 if tick_size.is_zero() {
330 anyhow::bail!(
331 "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
332 symbol.symbol,
333 );
334 }
335 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
336 let min_price = parse_filter_price(price_filter, "minPrice").ok();
337
338 let lot_filter =
339 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
340
341 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
342 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
343 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
344
345 let multiplier = Quantity::new(symbol.contract_size as f64, 0);
347
348 let min_notional = parse_futures_min_notional(&symbol.filters, quote_currency);
349
350 let default_margin = Decimal::new(1, 1);
352
353 let instrument = CryptoPerpetual::new(
354 instrument_id,
355 raw_symbol,
356 base_currency,
357 quote_currency,
358 settlement_currency,
359 true, tick_size.precision,
361 step_size.precision,
362 tick_size,
363 step_size,
364 Some(multiplier),
365 Some(step_size),
366 max_quantity,
367 min_quantity,
368 None, min_notional,
370 max_price,
371 min_price,
372 Some(default_margin),
373 Some(default_margin),
374 None, None, None, None, ts_event,
379 ts_init,
380 );
381
382 Ok(InstrumentAny::CryptoPerpetual(instrument))
383}
384
385const SBE_STATUS_TRADING: u8 = 0;
387
388fn sbe_mantissa_precision(mantissa: i64, exponent: i8) -> u8 {
401 if mantissa == 0 {
402 return 0;
403 }
404 let mut m = mantissa.abs();
405 let mut trailing_zeros: i8 = 0;
406
407 while m > 0 && m % 10 == 0 {
408 m /= 10;
409 trailing_zeros += 1;
410 }
411 (-exponent - trailing_zeros).max(0) as u8
412}
413
414fn parse_sbe_price_filter(
416 filter: &BinancePriceFilterSbe,
417) -> anyhow::Result<(Price, Option<Price>, Option<Price>)> {
418 let precision = sbe_mantissa_precision(filter.tick_size, filter.price_exponent);
419
420 let tick_size =
421 Price::from_mantissa_exponent_checked(filter.tick_size, filter.price_exponent, precision)?;
422
423 let max_price = if filter.max_price != 0 {
424 Some(Price::from_mantissa_exponent_checked(
425 filter.max_price,
426 filter.price_exponent,
427 precision,
428 )?)
429 } else {
430 None
431 };
432
433 let min_price = if filter.min_price != 0 {
434 Some(Price::from_mantissa_exponent_checked(
435 filter.min_price,
436 filter.price_exponent,
437 precision,
438 )?)
439 } else {
440 None
441 };
442
443 Ok((tick_size, max_price, min_price))
444}
445
446fn parse_sbe_lot_size_filter(
448 filter: &BinanceLotSizeFilterSbe,
449) -> anyhow::Result<(Quantity, Option<Quantity>, Option<Quantity>)> {
450 let precision = sbe_mantissa_precision(filter.step_size, filter.qty_exponent);
451
452 let step_size = Quantity::from_mantissa_exponent_checked(
453 filter.step_size as u64,
454 filter.qty_exponent,
455 precision,
456 )?;
457
458 let max_qty = if filter.max_qty != 0 {
459 Some(Quantity::from_mantissa_exponent_checked(
460 filter.max_qty as u64,
461 filter.qty_exponent,
462 precision,
463 )?)
464 } else {
465 None
466 };
467
468 let min_qty = if filter.min_qty != 0 {
469 Some(Quantity::from_mantissa_exponent_checked(
470 filter.min_qty as u64,
471 filter.qty_exponent,
472 precision,
473 )?)
474 } else {
475 None
476 };
477
478 Ok((step_size, max_qty, min_qty))
479}
480
481pub fn parse_spot_instrument_sbe(
490 symbol: &BinanceSymbolSbe,
491 ts_event: UnixNanos,
492 ts_init: UnixNanos,
493) -> anyhow::Result<InstrumentAny> {
494 if symbol.status != SBE_STATUS_TRADING {
495 anyhow::bail!(
496 "Symbol '{}' is not trading (status: {})",
497 symbol.symbol,
498 symbol.status
499 );
500 }
501
502 let base_currency = get_currency(&symbol.base_asset);
503 let quote_currency = get_currency(&symbol.quote_asset);
504
505 let instrument_id = InstrumentId::new(
506 Symbol::from_str_unchecked(&symbol.symbol),
507 Venue::new(BINANCE),
508 );
509 let raw_symbol = Symbol::new(&symbol.symbol);
510
511 let price_filter = symbol
512 .filters
513 .price_filter
514 .as_ref()
515 .context("Missing PRICE_FILTER in symbol filters")?;
516
517 let (tick_size, max_price, min_price) = parse_sbe_price_filter(price_filter)?;
518
519 let lot_filter = symbol
520 .filters
521 .lot_size_filter
522 .as_ref()
523 .context("Missing LOT_SIZE in symbol filters")?;
524
525 let (step_size, max_quantity, min_quantity) = parse_sbe_lot_size_filter(lot_filter)?;
526
527 let default_margin = Decimal::new(1, 0);
529
530 let instrument = CurrencyPair::new(
531 instrument_id,
532 raw_symbol,
533 base_currency,
534 quote_currency,
535 tick_size.precision,
536 step_size.precision,
537 tick_size,
538 step_size,
539 None, Some(step_size),
541 max_quantity,
542 min_quantity,
543 None, None, max_price,
546 min_price,
547 Some(default_margin),
548 Some(default_margin),
549 None, None, None, None, ts_event,
554 ts_init,
555 );
556
557 Ok(InstrumentAny::CurrencyPair(instrument))
558}
559
560pub fn parse_spot_trades_sbe(
568 trades: &BinanceTrades,
569 instrument: &InstrumentAny,
570 ts_init: UnixNanos,
571) -> anyhow::Result<Vec<TradeTick>> {
572 let instrument_id = instrument.id();
573 let price_precision = instrument.price_precision();
574 let size_precision = instrument.size_precision();
575
576 let mut result = Vec::with_capacity(trades.trades.len());
577
578 for trade in &trades.trades {
579 let price = Price::from_mantissa_exponent(
580 trade.price_mantissa,
581 trades.price_exponent,
582 price_precision,
583 );
584 let size = Quantity::from_mantissa_exponent(
585 trade.qty_mantissa as u64,
586 trades.qty_exponent,
587 size_precision,
588 );
589
590 let aggressor_side = if trade.is_buyer_maker {
592 AggressorSide::Seller
593 } else {
594 AggressorSide::Buyer
595 };
596
597 let ts_event = UnixNanos::from(trade.time as u64 * 1_000);
599
600 let tick = TradeTick::new(
601 instrument_id,
602 price,
603 size,
604 aggressor_side,
605 TradeId::new(trade.id.to_string()),
606 ts_event,
607 ts_init,
608 );
609
610 result.push(tick);
611 }
612
613 Ok(result)
614}
615
616#[must_use]
618pub const fn map_order_status_sbe(status: SbeOrderStatus) -> OrderStatus {
619 match status {
620 SbeOrderStatus::New => OrderStatus::Accepted,
621 SbeOrderStatus::PendingNew => OrderStatus::Submitted,
622 SbeOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
623 SbeOrderStatus::Filled => OrderStatus::Filled,
624 SbeOrderStatus::Canceled => OrderStatus::Canceled,
625 SbeOrderStatus::PendingCancel => OrderStatus::PendingCancel,
626 SbeOrderStatus::Rejected => OrderStatus::Rejected,
627 SbeOrderStatus::Expired | SbeOrderStatus::ExpiredInMatch => OrderStatus::Expired,
628 SbeOrderStatus::Unknown | SbeOrderStatus::NonRepresentable | SbeOrderStatus::NullVal => {
629 OrderStatus::Initialized
630 }
631 }
632}
633
634#[must_use]
636pub const fn map_order_type_sbe(order_type: SbeOrderType) -> OrderType {
637 match order_type {
638 SbeOrderType::Market => OrderType::Market,
639 SbeOrderType::Limit | SbeOrderType::LimitMaker => OrderType::Limit,
640 SbeOrderType::StopLoss | SbeOrderType::TakeProfit => OrderType::StopMarket,
641 SbeOrderType::StopLossLimit | SbeOrderType::TakeProfitLimit => OrderType::StopLimit,
642 SbeOrderType::NonRepresentable | SbeOrderType::NullVal => OrderType::Market,
643 }
644}
645
646#[must_use]
648pub const fn map_order_side_sbe(side: SbeOrderSide) -> OrderSide {
649 match side {
650 SbeOrderSide::Buy => OrderSide::Buy,
651 SbeOrderSide::Sell => OrderSide::Sell,
652 SbeOrderSide::NonRepresentable | SbeOrderSide::NullVal => OrderSide::NoOrderSide,
653 }
654}
655
656#[must_use]
658pub const fn map_time_in_force_sbe(tif: SbeTimeInForce) -> TimeInForce {
659 match tif {
660 SbeTimeInForce::Gtc => TimeInForce::Gtc,
661 SbeTimeInForce::Ioc => TimeInForce::Ioc,
662 SbeTimeInForce::Fok => TimeInForce::Fok,
663 SbeTimeInForce::NonRepresentable | SbeTimeInForce::NullVal => TimeInForce::Gtc,
664 }
665}
666
667pub fn parse_order_status_report_sbe(
673 order: &BinanceOrderResponse,
674 account_id: AccountId,
675 instrument: &InstrumentAny,
676 broker_id: &str,
677 ts_init: UnixNanos,
678) -> anyhow::Result<OrderStatusReport> {
679 let instrument_id = instrument.id();
680 let price_precision = instrument.price_precision();
681 let size_precision = instrument.size_precision();
682
683 let price = if order.price_mantissa != 0 {
684 Some(Price::from_mantissa_exponent(
685 order.price_mantissa,
686 order.price_exponent,
687 price_precision,
688 ))
689 } else {
690 None
691 };
692
693 let quantity = Quantity::from_mantissa_exponent(
694 order.orig_qty_mantissa as u64,
695 order.qty_exponent,
696 size_precision,
697 );
698 let filled_qty = Quantity::from_mantissa_exponent(
699 order.executed_qty_mantissa as u64,
700 order.qty_exponent,
701 size_precision,
702 );
703
704 let avg_px = if order.executed_qty_mantissa > 0 {
707 let quote_exp = (order.price_exponent as i32) + (order.qty_exponent as i32);
708 let cum_quote_dec = Decimal::new(order.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
709 let filled_dec = Decimal::new(
710 order.executed_qty_mantissa,
711 (-order.qty_exponent as i32) as u32,
712 );
713 let avg_dec = cum_quote_dec / filled_dec;
714 Some(
715 Price::from_decimal_dp(avg_dec, price_precision)
716 .unwrap_or(Price::zero(price_precision)),
717 )
718 } else {
719 None
720 };
721
722 let trigger_price = order.stop_price_mantissa.and_then(|mantissa| {
724 if mantissa != 0 {
725 Some(Price::from_mantissa_exponent(
726 mantissa,
727 order.price_exponent,
728 price_precision,
729 ))
730 } else {
731 None
732 }
733 });
734
735 let order_status = map_order_status_sbe(order.status);
737 let order_type = map_order_type_sbe(order.order_type);
738 let order_side = map_order_side_sbe(order.side);
739 let time_in_force = map_time_in_force_sbe(order.time_in_force);
740
741 let trigger_type = if trigger_price.is_some() {
743 Some(TriggerType::LastPrice)
744 } else {
745 None
746 };
747
748 let ts_event = UnixNanos::from_micros(order.update_time as u64);
750
751 let order_list_id = order.order_list_id.and_then(|id| {
753 if id > 0 {
754 Some(OrderListId::new(id.to_string()))
755 } else {
756 None
757 }
758 });
759
760 let post_only = order.order_type == SbeOrderType::LimitMaker;
762
763 let ts_accepted = UnixNanos::from_micros(order.time as u64);
765
766 let mut report = OrderStatusReport::new(
767 account_id,
768 instrument_id,
769 Some(ClientOrderId::new(decode_broker_id(
770 &order.client_order_id,
771 broker_id,
772 ))),
773 VenueOrderId::new(order.order_id.to_string()),
774 order_side,
775 order_type,
776 time_in_force,
777 order_status,
778 quantity,
779 filled_qty,
780 ts_accepted,
781 ts_event,
782 ts_init,
783 None, );
785
786 if let Some(p) = price {
788 report = report.with_price(p);
789 }
790
791 if let Some(ap) = avg_px {
792 report = report.with_avg_px(ap.as_f64())?;
793 }
794
795 if let Some(tp) = trigger_price {
796 report = report.with_trigger_price(tp);
797 }
798
799 if let Some(tt) = trigger_type {
800 report = report.with_trigger_type(tt);
801 }
802
803 if let Some(oli) = order_list_id {
804 report = report.with_order_list_id(oli);
805 }
806
807 if post_only {
808 report = report.with_post_only(true);
809 }
810
811 Ok(report)
812}
813
814pub fn parse_new_order_response_sbe(
820 response: &BinanceNewOrderResponse,
821 account_id: AccountId,
822 instrument: &InstrumentAny,
823 broker_id: &str,
824 ts_init: UnixNanos,
825) -> anyhow::Result<OrderStatusReport> {
826 let instrument_id = instrument.id();
827 let price_precision = instrument.price_precision();
828 let size_precision = instrument.size_precision();
829
830 let price = if response.price_mantissa != 0 {
831 Some(Price::from_mantissa_exponent(
832 response.price_mantissa,
833 response.price_exponent,
834 price_precision,
835 ))
836 } else {
837 None
838 };
839
840 let quantity = Quantity::from_mantissa_exponent(
841 response.orig_qty_mantissa as u64,
842 response.qty_exponent,
843 size_precision,
844 );
845 let filled_qty = Quantity::from_mantissa_exponent(
846 response.executed_qty_mantissa as u64,
847 response.qty_exponent,
848 size_precision,
849 );
850
851 let avg_px = if response.executed_qty_mantissa > 0 {
854 let quote_exp = (response.price_exponent as i32) + (response.qty_exponent as i32);
855 let cum_quote_dec =
856 Decimal::new(response.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
857 let filled_dec = Decimal::new(
858 response.executed_qty_mantissa,
859 (-response.qty_exponent as i32) as u32,
860 );
861 let avg_dec = cum_quote_dec / filled_dec;
862 Some(
863 Price::from_decimal_dp(avg_dec, price_precision)
864 .unwrap_or(Price::zero(price_precision)),
865 )
866 } else {
867 None
868 };
869
870 let trigger_price = response.stop_price_mantissa.and_then(|mantissa| {
871 if mantissa != 0 {
872 Some(Price::from_mantissa_exponent(
873 mantissa,
874 response.price_exponent,
875 price_precision,
876 ))
877 } else {
878 None
879 }
880 });
881
882 let order_status = map_order_status_sbe(response.status);
883 let order_type = map_order_type_sbe(response.order_type);
884 let order_side = map_order_side_sbe(response.side);
885 let time_in_force = map_time_in_force_sbe(response.time_in_force);
886
887 let trigger_type = if trigger_price.is_some() {
888 Some(TriggerType::LastPrice)
889 } else {
890 None
891 };
892
893 let ts_event = UnixNanos::from_micros(response.transact_time as u64);
895 let ts_accepted = ts_event;
896
897 let order_list_id = response.order_list_id.and_then(|id| {
898 if id > 0 {
899 Some(OrderListId::new(id.to_string()))
900 } else {
901 None
902 }
903 });
904
905 let post_only = response.order_type == SbeOrderType::LimitMaker;
907
908 let mut report = OrderStatusReport::new(
909 account_id,
910 instrument_id,
911 Some(ClientOrderId::new(decode_broker_id(
912 &response.client_order_id,
913 broker_id,
914 ))),
915 VenueOrderId::new(response.order_id.to_string()),
916 order_side,
917 order_type,
918 time_in_force,
919 order_status,
920 quantity,
921 filled_qty,
922 ts_accepted,
923 ts_event,
924 ts_init,
925 None,
926 );
927
928 if let Some(p) = price {
929 report = report.with_price(p);
930 }
931
932 if let Some(ap) = avg_px {
933 report = report.with_avg_px(ap.as_f64())?;
934 }
935
936 if let Some(tp) = trigger_price {
937 report = report.with_trigger_price(tp);
938 }
939
940 if let Some(tt) = trigger_type {
941 report = report.with_trigger_type(tt);
942 }
943
944 if let Some(oli) = order_list_id {
945 report = report.with_order_list_id(oli);
946 }
947
948 if post_only {
949 report = report.with_post_only(true);
950 }
951
952 Ok(report)
953}
954
955pub fn parse_fill_report_sbe(
961 trade: &BinanceAccountTrade,
962 account_id: AccountId,
963 instrument: &InstrumentAny,
964 commission_currency: Currency,
965 ts_init: UnixNanos,
966) -> anyhow::Result<FillReport> {
967 let instrument_id = instrument.id();
968 let price_precision = instrument.price_precision();
969 let size_precision = instrument.size_precision();
970
971 let last_px =
972 Price::from_mantissa_exponent(trade.price_mantissa, trade.price_exponent, price_precision);
973 let last_qty = Quantity::from_mantissa_exponent(
974 trade.qty_mantissa as u64,
975 trade.qty_exponent,
976 size_precision,
977 );
978
979 let comm_exp = trade.commission_exponent as i32;
980 let comm_dec = Decimal::new(trade.commission_mantissa, (-comm_exp) as u32);
981 let commission = Money::from_decimal(comm_dec, commission_currency)?;
982
983 let order_side = if trade.is_buyer {
985 OrderSide::Buy
986 } else {
987 OrderSide::Sell
988 };
989
990 let liquidity_side = if trade.is_maker {
992 LiquiditySide::Maker
993 } else {
994 LiquiditySide::Taker
995 };
996
997 let ts_event = UnixNanos::from_micros(trade.time as u64);
999
1000 Ok(FillReport::new(
1001 account_id,
1002 instrument_id,
1003 VenueOrderId::new(trade.order_id.to_string()),
1004 TradeId::new(trade.id.to_string()),
1005 order_side,
1006 last_qty,
1007 last_px,
1008 commission,
1009 liquidity_side,
1010 None, None, ts_event,
1013 ts_init,
1014 None, ))
1016}
1017
1018pub fn parse_klines_to_bars(
1024 klines: &BinanceKlines,
1025 bar_type: BarType,
1026 instrument: &InstrumentAny,
1027 ts_init: UnixNanos,
1028) -> anyhow::Result<Vec<Bar>> {
1029 let price_precision = instrument.price_precision();
1030 let size_precision = instrument.size_precision();
1031
1032 let mut bars = Vec::with_capacity(klines.klines.len());
1033
1034 for kline in &klines.klines {
1035 let open =
1036 Price::from_mantissa_exponent(kline.open_price, klines.price_exponent, price_precision);
1037 let high =
1038 Price::from_mantissa_exponent(kline.high_price, klines.price_exponent, price_precision);
1039 let low =
1040 Price::from_mantissa_exponent(kline.low_price, klines.price_exponent, price_precision);
1041 let close = Price::from_mantissa_exponent(
1042 kline.close_price,
1043 klines.price_exponent,
1044 price_precision,
1045 );
1046
1047 let volume_mantissa = i128::from_le_bytes(kline.volume);
1048 let volume_dec =
1049 Decimal::from_i128_with_scale(volume_mantissa, (-klines.qty_exponent as i32) as u32);
1050 let volume = Quantity::from_decimal_dp(volume_dec, size_precision)?;
1051
1052 let ts_event = UnixNanos::from_micros(kline.open_time as u64);
1053
1054 let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
1055 bars.push(bar);
1056 }
1057
1058 Ok(bars)
1059}
1060
1061pub fn bar_spec_to_binance_interval(
1068 bar_spec: BarSpecification,
1069) -> anyhow::Result<BinanceKlineInterval> {
1070 let step = bar_spec.step.get();
1071 let interval = match bar_spec.aggregation {
1072 BarAggregation::Second => {
1073 anyhow::bail!("Binance Spot does not support second-level kline intervals")
1074 }
1075 BarAggregation::Minute => match step {
1076 1 => BinanceKlineInterval::Minute1,
1077 3 => BinanceKlineInterval::Minute3,
1078 5 => BinanceKlineInterval::Minute5,
1079 15 => BinanceKlineInterval::Minute15,
1080 30 => BinanceKlineInterval::Minute30,
1081 _ => anyhow::bail!("Unsupported minute interval: {step}m"),
1082 },
1083 BarAggregation::Hour => match step {
1084 1 => BinanceKlineInterval::Hour1,
1085 2 => BinanceKlineInterval::Hour2,
1086 4 => BinanceKlineInterval::Hour4,
1087 6 => BinanceKlineInterval::Hour6,
1088 8 => BinanceKlineInterval::Hour8,
1089 12 => BinanceKlineInterval::Hour12,
1090 _ => anyhow::bail!("Unsupported hour interval: {step}h"),
1091 },
1092 BarAggregation::Day => match step {
1093 1 => BinanceKlineInterval::Day1,
1094 3 => BinanceKlineInterval::Day3,
1095 _ => anyhow::bail!("Unsupported day interval: {step}d"),
1096 },
1097 BarAggregation::Week => match step {
1098 1 => BinanceKlineInterval::Week1,
1099 _ => anyhow::bail!("Unsupported week interval: {step}w"),
1100 },
1101 BarAggregation::Month => match step {
1102 1 => BinanceKlineInterval::Month1,
1103 _ => anyhow::bail!("Unsupported month interval: {step}M"),
1104 },
1105 agg => anyhow::bail!("Unsupported bar aggregation for Binance: {agg:?}"),
1106 };
1107
1108 Ok(interval)
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use rstest::rstest;
1114 use rust_decimal_macros::dec;
1115 use serde_json::json;
1116 use ustr::Ustr;
1117
1118 use super::*;
1119 use crate::common::{
1120 consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
1121 enums::{BinanceContractStatus, BinanceTradingStatus},
1122 };
1123
1124 #[rstest]
1125 #[case::positive("0.001", 8, Some(Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap()))]
1126 #[case::trailing_zero("0.00100000", 8, Some(Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap()))]
1127 #[case::zero("0", 8, None)]
1128 #[case::negative("-1", 8, None)]
1129 #[case::empty("", 8, None)]
1130 #[case::garbage("abc", 8, None)]
1131 fn test_parse_quantity_at_precision(
1132 #[case] raw: &str,
1133 #[case] precision: u8,
1134 #[case] expected: Option<Quantity>,
1135 ) {
1136 assert_eq!(parse_quantity_at_precision(raw, precision), expected);
1137 }
1138
1139 #[rstest]
1140 #[case::positive("7100.50", 2, Some(Price::from_decimal_dp(Decimal::from_str("7100.50").unwrap(), 2).unwrap()))]
1141 #[case::high_precision("0.000000001", 9, Some(Price::from_decimal_dp(Decimal::from_str("0.000000001").unwrap(), 9).unwrap()))]
1142 #[case::zero("0", 2, None)]
1143 #[case::negative("-100", 2, None)]
1144 #[case::empty("", 2, None)]
1145 fn test_parse_price_at_precision(
1146 #[case] raw: &str,
1147 #[case] precision: u8,
1148 #[case] expected: Option<Price>,
1149 ) {
1150 assert_eq!(parse_price_at_precision(raw, precision), expected);
1151 }
1152
1153 #[rstest]
1154 fn test_quantity_at_precision_re_precisions_via_decimal() {
1155 let original = Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 3).unwrap();
1156 let widened = quantity_at_precision(original, 8).unwrap();
1157 let expected = Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap();
1158 assert_eq!(widened, expected);
1159 }
1160
1161 #[rstest]
1162 fn test_price_at_precision_re_precisions_via_decimal() {
1163 let original = Price::from_decimal_dp(Decimal::from_str("7100.5").unwrap(), 1).unwrap();
1164 let widened = price_at_precision(original, 8).unwrap();
1165 let expected = Price::from_decimal_dp(Decimal::from_str("7100.5").unwrap(), 8).unwrap();
1166 assert_eq!(widened, expected);
1167 }
1168
1169 fn sample_usdm_symbol() -> BinanceFuturesUsdSymbol {
1170 BinanceFuturesUsdSymbol {
1171 symbol: Ustr::from("BTCUSDT"),
1172 pair: Ustr::from("BTCUSDT"),
1173 contract_type: "PERPETUAL".to_string(),
1174 delivery_date: 4133404800000,
1175 onboard_date: 1569398400000,
1176 status: BinanceTradingStatus::Trading,
1177 maint_margin_percent: "2.5000".to_string(),
1178 required_margin_percent: "5.0000".to_string(),
1179 base_asset: Ustr::from("BTC"),
1180 quote_asset: Ustr::from("USDT"),
1181 margin_asset: Ustr::from("USDT"),
1182 price_precision: 2,
1183 quantity_precision: 3,
1184 base_asset_precision: 8,
1185 quote_precision: 8,
1186 underlying_type: Some("COIN".to_string()),
1187 underlying_sub_type: vec!["PoW".to_string()],
1188 settle_plan: None,
1189 trigger_protect: Some("0.0500".to_string()),
1190 liquidation_fee: Some("0.012500".to_string()),
1191 market_take_bound: Some("0.05".to_string()),
1192 order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
1193 time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
1194 filters: vec![
1195 json!({
1196 "filterType": "PRICE_FILTER",
1197 "tickSize": "0.10",
1198 "maxPrice": "4529764",
1199 "minPrice": "556.80"
1200 }),
1201 json!({
1202 "filterType": "LOT_SIZE",
1203 "stepSize": "0.001",
1204 "maxQty": "1000",
1205 "minQty": "0.001"
1206 }),
1207 json!({
1208 "filterType": "MIN_NOTIONAL",
1209 "notional": "5"
1210 }),
1211 ],
1212 }
1213 }
1214
1215 fn sample_coinm_symbol() -> BinanceFuturesCoinSymbol {
1216 BinanceFuturesCoinSymbol {
1217 symbol: Ustr::from("BTCUSD_PERP"),
1218 pair: Ustr::from("BTCUSD"),
1219 contract_type: "PERPETUAL".to_string(),
1220 delivery_date: 4_133_404_800_000,
1221 onboard_date: 1_569_398_400_000,
1222 contract_status: Some(BinanceContractStatus::Trading),
1223 contract_size: 100,
1224 maint_margin_percent: "2.5000".to_string(),
1225 required_margin_percent: "5.0000".to_string(),
1226 base_asset: Ustr::from("BTC"),
1227 quote_asset: Ustr::from("USD"),
1228 margin_asset: Ustr::from("BTC"),
1229 price_precision: 1,
1230 quantity_precision: 0,
1231 base_asset_precision: 8,
1232 quote_precision: 8,
1233 equal_qty_precision: None,
1234 trigger_protect: Some("0.0500".to_string()),
1235 liquidation_fee: Some("0.012500".to_string()),
1236 market_take_bound: Some("0.05".to_string()),
1237 order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
1238 time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
1239 filters: vec![
1240 json!({
1241 "filterType": "PRICE_FILTER",
1242 "tickSize": "0.10",
1243 "maxPrice": "1000000",
1244 "minPrice": "0.10"
1245 }),
1246 json!({
1247 "filterType": "LOT_SIZE",
1248 "stepSize": "1",
1249 "maxQty": "1000",
1250 "minQty": "1"
1251 }),
1252 json!({
1253 "filterType": "MIN_NOTIONAL",
1254 "notional": "1"
1255 }),
1256 ],
1257 }
1258 }
1259
1260 fn sample_spot_symbol_sbe() -> BinanceSymbolSbe {
1261 BinanceSymbolSbe {
1262 symbol: "ETHUSDT".to_string(),
1263 base_asset: "ETH".to_string(),
1264 quote_asset: "USDT".to_string(),
1265 base_asset_precision: 8,
1266 quote_asset_precision: 8,
1267 status: SBE_STATUS_TRADING,
1268 order_types: 0,
1269 iceberg_allowed: true,
1270 oco_allowed: true,
1271 oto_allowed: false,
1272 quote_order_qty_market_allowed: true,
1273 allow_trailing_stop: true,
1274 cancel_replace_allowed: true,
1275 amend_allowed: true,
1276 is_spot_trading_allowed: true,
1277 is_margin_trading_allowed: false,
1278 filters: crate::spot::http::models::BinanceSymbolFiltersSbe {
1279 price_filter: Some(BinancePriceFilterSbe {
1280 price_exponent: -8,
1281 min_price: 1_000_000,
1282 max_price: 100_000_000_000_000,
1283 tick_size: 1_000_000,
1284 }),
1285 lot_size_filter: Some(BinanceLotSizeFilterSbe {
1286 qty_exponent: -8,
1287 min_qty: 10_000,
1288 max_qty: 900_000_000_000,
1289 step_size: 10_000,
1290 }),
1291 },
1292 permissions: vec![vec!["SPOT".to_string()]],
1293 }
1294 }
1295
1296 fn sample_spot_instrument() -> InstrumentAny {
1297 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1298 parse_spot_instrument_sbe(&sample_spot_symbol_sbe(), ts, ts).unwrap()
1299 }
1300
1301 fn sample_account_id() -> AccountId {
1302 AccountId::from("BINANCE-SPOT-001")
1303 }
1304
1305 #[rstest]
1306 fn test_parse_usdm_perpetual() {
1307 let symbol = sample_usdm_symbol();
1308 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1309
1310 let result = parse_usdm_instrument(&symbol, ts, ts);
1311 assert!(result.is_ok(), "Failed: {:?}", result.err());
1312
1313 let instrument = result.unwrap();
1314 match instrument {
1315 InstrumentAny::CryptoPerpetual(perp) => {
1316 assert_eq!(perp.id.to_string(), "BTCUSDT-PERP.BINANCE");
1317 assert_eq!(perp.raw_symbol.to_string(), "BTCUSDT");
1318 assert_eq!(perp.base_currency.code.as_str(), "BTC");
1319 assert_eq!(perp.quote_currency.code.as_str(), "USDT");
1320 assert_eq!(perp.settlement_currency.code.as_str(), "USDT");
1321 assert!(!perp.is_inverse);
1322 assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
1323 assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1324 assert_eq!(
1325 perp.min_notional,
1326 Some(Money::new(5.0, perp.quote_currency)),
1327 );
1328 }
1329 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1330 }
1331 }
1332
1333 #[rstest]
1334 fn test_parse_non_perpetual_fails() {
1335 let mut symbol = sample_usdm_symbol();
1336 symbol.contract_type = "CURRENT_QUARTER".to_string();
1337 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1338
1339 let result = parse_usdm_instrument(&symbol, ts, ts);
1340 assert!(result.is_err());
1341 assert!(
1342 result
1343 .unwrap_err()
1344 .to_string()
1345 .contains("Unsupported contract type")
1346 );
1347 }
1348
1349 #[rstest]
1350 fn test_parse_missing_price_filter_fails() {
1351 let mut symbol = sample_usdm_symbol();
1352 symbol.filters = vec![json!({
1353 "filterType": "LOT_SIZE",
1354 "stepSize": "0.001",
1355 "maxQty": "1000",
1356 "minQty": "0.001"
1357 })];
1358 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1359
1360 let result = parse_usdm_instrument(&symbol, ts, ts);
1361 assert!(result.is_err());
1362 assert!(
1363 result
1364 .unwrap_err()
1365 .to_string()
1366 .contains("Missing PRICE_FILTER")
1367 );
1368 }
1369
1370 #[rstest]
1371 fn test_parse_coinm_perpetual() {
1372 let symbol = sample_coinm_symbol();
1373 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1374
1375 let result = parse_coinm_instrument(&symbol, ts, ts).unwrap();
1376
1377 match result {
1378 InstrumentAny::CryptoPerpetual(perp) => {
1379 assert_eq!(perp.id.to_string(), "BTCUSD_PERP-PERP.BINANCE");
1380 assert_eq!(perp.raw_symbol.to_string(), "BTCUSD_PERP");
1381 assert_eq!(perp.base_currency.code.as_str(), "BTC");
1382 assert_eq!(perp.quote_currency.code.as_str(), "USD");
1383 assert_eq!(perp.settlement_currency.code.as_str(), "BTC");
1384 assert!(perp.is_inverse);
1385 assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
1386 assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
1387 assert_eq!(
1388 perp.min_notional,
1389 Some(Money::new(1.0, perp.quote_currency)),
1390 );
1391 }
1392 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1393 }
1394 }
1395
1396 #[rstest]
1397 fn test_parse_spot_instrument_sbe() {
1398 let symbol = sample_spot_symbol_sbe();
1399 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1400
1401 let result = parse_spot_instrument_sbe(&symbol, ts, ts).unwrap();
1402
1403 match result {
1404 InstrumentAny::CurrencyPair(pair) => {
1405 assert_eq!(pair.id.to_string(), "ETHUSDT.BINANCE");
1406 assert_eq!(pair.raw_symbol.to_string(), "ETHUSDT");
1407 assert_eq!(pair.base_currency.code.as_str(), "ETH");
1408 assert_eq!(pair.quote_currency.code.as_str(), "USDT");
1409 assert_eq!(pair.price_increment, Price::from_str("0.01").unwrap());
1410 assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
1411 }
1412 other => panic!("Expected CurrencyPair, was {other:?}"),
1413 }
1414 }
1415
1416 #[rstest]
1417 fn test_parse_spot_trades_sbe() {
1418 let instrument = sample_spot_instrument();
1419 let trades = BinanceTrades {
1420 price_exponent: -2,
1421 qty_exponent: -4,
1422 trades: vec![
1423 crate::spot::http::models::BinanceTrade {
1424 id: 1,
1425 price_mantissa: 12_345,
1426 qty_mantissa: 25_000,
1427 quote_qty_mantissa: 0,
1428 time: 1_700_000_000_000_000,
1429 is_buyer_maker: false,
1430 is_best_match: true,
1431 },
1432 crate::spot::http::models::BinanceTrade {
1433 id: 2,
1434 price_mantissa: 12_340,
1435 qty_mantissa: 10_000,
1436 quote_qty_mantissa: 0,
1437 time: 1_700_000_000_500_000,
1438 is_buyer_maker: true,
1439 is_best_match: true,
1440 },
1441 ],
1442 };
1443 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
1444
1445 let result = parse_spot_trades_sbe(&trades, &instrument, ts_init).unwrap();
1446
1447 assert_eq!(result.len(), 2);
1448 assert_eq!(result[0].instrument_id, instrument.id());
1449 assert_eq!(result[0].price.as_f64(), 123.45);
1450 assert_eq!(result[0].size.as_f64(), 2.5);
1451 assert_eq!(result[0].aggressor_side, AggressorSide::Buyer);
1452 assert_eq!(result[0].trade_id, TradeId::new("1"));
1453 assert_eq!(
1454 result[0].ts_event,
1455 UnixNanos::from(1_700_000_000_000_000_000u64)
1456 );
1457 assert_eq!(result[0].ts_init, ts_init);
1458 assert_eq!(result[1].aggressor_side, AggressorSide::Seller);
1459 }
1460
1461 #[rstest]
1462 fn test_parse_order_status_report_sbe() {
1463 let instrument = sample_spot_instrument();
1464 let order = BinanceOrderResponse {
1465 price_exponent: -2,
1466 qty_exponent: -4,
1467 order_id: 42,
1468 order_list_id: Some(77),
1469 price_mantissa: 12_345,
1470 orig_qty_mantissa: 25_000,
1471 executed_qty_mantissa: 10_000,
1472 cummulative_quote_qty_mantissa: 123_450_000,
1473 status: SbeOrderStatus::PartiallyFilled,
1474 time_in_force: SbeTimeInForce::Gtc,
1475 order_type: SbeOrderType::LimitMaker,
1476 side: SbeOrderSide::Buy,
1477 stop_price_mantissa: None,
1478 iceberg_qty_mantissa: None,
1479 time: 1_700_000_000_000_000,
1480 update_time: 1_700_000_000_100_000,
1481 is_working: true,
1482 working_time: Some(1_700_000_000_050_000),
1483 orig_quote_order_qty_mantissa: 0,
1484 self_trade_prevention_mode:
1485 crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
1486 client_order_id: "client-123".to_string(),
1487 symbol: "ETHUSDT".to_string(),
1488 expiry_reason: None,
1489 };
1490 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
1491
1492 let report = parse_order_status_report_sbe(
1493 &order,
1494 sample_account_id(),
1495 &instrument,
1496 BINANCE_NAUTILUS_SPOT_BROKER_ID,
1497 ts_init,
1498 )
1499 .unwrap();
1500
1501 assert_eq!(report.account_id, sample_account_id());
1502 assert_eq!(report.instrument_id, instrument.id());
1503 assert_eq!(
1504 report.client_order_id,
1505 Some(ClientOrderId::new("client-123"))
1506 );
1507 assert_eq!(report.venue_order_id, VenueOrderId::new("42"));
1508 assert_eq!(report.order_side, OrderSide::Buy);
1509 assert_eq!(report.order_type, OrderType::Limit);
1510 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1511 assert_eq!(report.quantity.as_f64(), 2.5);
1512 assert_eq!(report.filled_qty.as_f64(), 1.0);
1513 assert_eq!(report.order_list_id, Some(OrderListId::new("77")));
1514 assert_eq!(report.price, Some(Price::new(123.45, 2)));
1515 assert_eq!(report.avg_px.unwrap().to_string(), "123.45");
1516 assert!(report.post_only);
1517 assert_eq!(
1518 report.ts_accepted,
1519 UnixNanos::from(1_700_000_000_000_000_000u64)
1520 );
1521 assert_eq!(
1522 report.ts_last,
1523 UnixNanos::from(1_700_000_000_100_000_000u64)
1524 );
1525 assert_eq!(report.ts_init, ts_init);
1526 }
1527
1528 #[rstest]
1529 fn test_parse_new_order_response_sbe() {
1530 let instrument = sample_spot_instrument();
1531 let response = BinanceNewOrderResponse {
1532 price_exponent: -2,
1533 qty_exponent: -4,
1534 order_id: 99,
1535 order_list_id: Some(7),
1536 transact_time: 1_700_000_000_000_000,
1537 price_mantissa: 12_100,
1538 orig_qty_mantissa: 20_000,
1539 executed_qty_mantissa: 5_000,
1540 cummulative_quote_qty_mantissa: 60_500_000,
1541 status: SbeOrderStatus::New,
1542 time_in_force: SbeTimeInForce::Gtc,
1543 order_type: SbeOrderType::StopLossLimit,
1544 side: SbeOrderSide::Sell,
1545 stop_price_mantissa: Some(12_000),
1546 working_time: Some(1_700_000_000_000_000),
1547 self_trade_prevention_mode:
1548 crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
1549 client_order_id: "client-456".to_string(),
1550 symbol: "ETHUSDT".to_string(),
1551 fills: vec![],
1552 expiry_reason: None,
1553 };
1554 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
1555
1556 let report = parse_new_order_response_sbe(
1557 &response,
1558 sample_account_id(),
1559 &instrument,
1560 BINANCE_NAUTILUS_SPOT_BROKER_ID,
1561 ts_init,
1562 )
1563 .unwrap();
1564
1565 assert_eq!(report.account_id, sample_account_id());
1566 assert_eq!(report.instrument_id, instrument.id());
1567 assert_eq!(
1568 report.client_order_id,
1569 Some(ClientOrderId::new("client-456"))
1570 );
1571 assert_eq!(report.venue_order_id, VenueOrderId::new("99"));
1572 assert_eq!(report.order_side, OrderSide::Sell);
1573 assert_eq!(report.order_type, OrderType::StopLimit);
1574 assert_eq!(report.order_status, OrderStatus::Accepted);
1575 assert_eq!(report.quantity.as_f64(), 2.0);
1576 assert_eq!(report.filled_qty.as_f64(), 0.5);
1577 assert_eq!(report.order_list_id, Some(OrderListId::new("7")));
1578 assert_eq!(report.price, Some(Price::new(121.0, 2)));
1579 assert_eq!(report.trigger_price, Some(Price::new(120.0, 2)));
1580 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1581 assert_eq!(report.avg_px.unwrap().to_string(), "121");
1582 assert!(!report.post_only);
1583 assert_eq!(
1584 report.ts_accepted,
1585 UnixNanos::from(1_700_000_000_000_000_000u64)
1586 );
1587 assert_eq!(
1588 report.ts_last,
1589 UnixNanos::from(1_700_000_000_000_000_000u64)
1590 );
1591 }
1592
1593 #[rstest]
1594 fn test_parse_fill_report_sbe() {
1595 let instrument = sample_spot_instrument();
1596 let trade = BinanceAccountTrade {
1597 price_exponent: -2,
1598 qty_exponent: -4,
1599 commission_exponent: -8,
1600 id: 123,
1601 order_id: 456,
1602 order_list_id: None,
1603 price_mantissa: 12_345,
1604 qty_mantissa: 25_000,
1605 quote_qty_mantissa: 0,
1606 commission_mantissa: 10_000,
1607 time: 1_700_000_000_000_000,
1608 is_buyer: false,
1609 is_maker: true,
1610 is_best_match: true,
1611 symbol: "ETHUSDT".to_string(),
1612 commission_asset: "USDT".to_string(),
1613 };
1614 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
1615
1616 let report = parse_fill_report_sbe(
1617 &trade,
1618 sample_account_id(),
1619 &instrument,
1620 Currency::from("USDT"),
1621 ts_init,
1622 )
1623 .unwrap();
1624
1625 assert_eq!(report.account_id, sample_account_id());
1626 assert_eq!(report.instrument_id, instrument.id());
1627 assert_eq!(report.venue_order_id, VenueOrderId::new("456"));
1628 assert_eq!(report.trade_id, TradeId::new("123"));
1629 assert_eq!(report.order_side, OrderSide::Sell);
1630 assert_eq!(report.last_qty.as_f64(), 2.5);
1631 assert_eq!(report.last_px.as_f64(), 123.45);
1632 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1633 assert_eq!(report.commission.as_f64(), 0.0001);
1634 assert_eq!(
1635 report.ts_event,
1636 UnixNanos::from(1_700_000_000_000_000_000u64)
1637 );
1638 assert_eq!(report.ts_init, ts_init);
1639 assert!(report.client_order_id.is_none());
1640 }
1641
1642 #[rstest]
1643 fn test_parse_klines_to_bars() {
1644 use nautilus_model::enums::{AggregationSource, PriceType};
1645
1646 let instrument = sample_spot_instrument();
1647 let bar_type = BarType::new(
1648 instrument.id(),
1649 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1650 AggregationSource::External,
1651 );
1652 let klines = BinanceKlines {
1653 price_exponent: -2,
1654 qty_exponent: -4,
1655 klines: vec![crate::spot::http::models::BinanceKline {
1656 open_time: 1_700_000_000_000_000,
1657 open_price: 12_000,
1658 high_price: 12_500,
1659 low_price: 11_900,
1660 close_price: 12_345,
1661 volume: 1_234_500_i128.to_le_bytes(),
1662 close_time: 1_700_000_059_999_000,
1663 quote_volume: 0_i128.to_le_bytes(),
1664 num_trades: 100,
1665 taker_buy_base_volume: 0_i128.to_le_bytes(),
1666 taker_buy_quote_volume: 0_i128.to_le_bytes(),
1667 }],
1668 };
1669 let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
1670
1671 let bars = parse_klines_to_bars(&klines, bar_type, &instrument, ts_init).unwrap();
1672
1673 assert_eq!(bars.len(), 1);
1674 assert_eq!(bars[0].bar_type, bar_type);
1675 assert_eq!(bars[0].open, Price::new(120.0, 2));
1676 assert_eq!(bars[0].high, Price::new(125.0, 2));
1677 assert_eq!(bars[0].low, Price::new(119.0, 2));
1678 assert_eq!(bars[0].close, Price::new(123.45, 2));
1679 assert_eq!(bars[0].volume, Quantity::new(123.45, 4));
1680 assert_eq!(
1681 bars[0].ts_event,
1682 UnixNanos::from(1_700_000_000_000_000_000u64)
1683 );
1684 assert_eq!(bars[0].ts_init, ts_init);
1685 }
1686
1687 mod bar_spec_tests {
1688 use std::num::NonZeroUsize;
1689
1690 use nautilus_model::{
1691 data::BarSpecification,
1692 enums::{BarAggregation, PriceType},
1693 };
1694
1695 use super::*;
1696 use crate::common::enums::BinanceKlineInterval;
1697
1698 fn make_bar_spec(step: usize, aggregation: BarAggregation) -> BarSpecification {
1699 BarSpecification {
1700 step: NonZeroUsize::new(step).unwrap(),
1701 aggregation,
1702 price_type: PriceType::Last,
1703 }
1704 }
1705
1706 #[rstest]
1707 #[case(1, BarAggregation::Minute, BinanceKlineInterval::Minute1)]
1708 #[case(3, BarAggregation::Minute, BinanceKlineInterval::Minute3)]
1709 #[case(5, BarAggregation::Minute, BinanceKlineInterval::Minute5)]
1710 #[case(15, BarAggregation::Minute, BinanceKlineInterval::Minute15)]
1711 #[case(30, BarAggregation::Minute, BinanceKlineInterval::Minute30)]
1712 #[case(1, BarAggregation::Hour, BinanceKlineInterval::Hour1)]
1713 #[case(2, BarAggregation::Hour, BinanceKlineInterval::Hour2)]
1714 #[case(4, BarAggregation::Hour, BinanceKlineInterval::Hour4)]
1715 #[case(6, BarAggregation::Hour, BinanceKlineInterval::Hour6)]
1716 #[case(8, BarAggregation::Hour, BinanceKlineInterval::Hour8)]
1717 #[case(12, BarAggregation::Hour, BinanceKlineInterval::Hour12)]
1718 #[case(1, BarAggregation::Day, BinanceKlineInterval::Day1)]
1719 #[case(3, BarAggregation::Day, BinanceKlineInterval::Day3)]
1720 #[case(1, BarAggregation::Week, BinanceKlineInterval::Week1)]
1721 #[case(1, BarAggregation::Month, BinanceKlineInterval::Month1)]
1722 fn test_bar_spec_to_binance_interval(
1723 #[case] step: usize,
1724 #[case] aggregation: BarAggregation,
1725 #[case] expected: BinanceKlineInterval,
1726 ) {
1727 let bar_spec = make_bar_spec(step, aggregation);
1728 let result = bar_spec_to_binance_interval(bar_spec).unwrap();
1729 assert_eq!(result, expected);
1730 }
1731
1732 #[rstest]
1733 fn test_unsupported_second_interval() {
1734 let bar_spec = make_bar_spec(1, BarAggregation::Second);
1735 let result = bar_spec_to_binance_interval(bar_spec);
1736 assert!(result.is_err());
1737 assert!(
1738 result
1739 .unwrap_err()
1740 .to_string()
1741 .contains("does not support second-level")
1742 );
1743 }
1744
1745 #[rstest]
1746 fn test_unsupported_minute_interval() {
1747 let bar_spec = make_bar_spec(7, BarAggregation::Minute);
1748 let result = bar_spec_to_binance_interval(bar_spec);
1749 assert!(result.is_err());
1750 assert!(
1751 result
1752 .unwrap_err()
1753 .to_string()
1754 .contains("Unsupported minute interval")
1755 );
1756 }
1757
1758 #[rstest]
1759 fn test_unsupported_aggregation() {
1760 let bar_spec = make_bar_spec(100, BarAggregation::Tick);
1761 let result = bar_spec_to_binance_interval(bar_spec);
1762 assert!(result.is_err());
1763 assert!(
1764 result
1765 .unwrap_err()
1766 .to_string()
1767 .contains("Unsupported bar aggregation")
1768 );
1769 }
1770 }
1771
1772 mod sbe_precision_tests {
1773 use super::*;
1774 use crate::spot::http::models::{BinanceLotSizeFilterSbe, BinancePriceFilterSbe};
1775
1776 #[rstest]
1777 #[case::precision_0(100_000_000, -8, 0)]
1778 #[case::precision_1(10_000_000, -8, 1)]
1779 #[case::precision_2(1_000_000, -8, 2)]
1780 #[case::precision_3(100_000, -8, 3)]
1781 #[case::precision_4(10_000, -8, 4)]
1782 #[case::precision_5(1_000, -8, 5)]
1783 #[case::precision_6(100, -8, 6)]
1784 #[case::precision_7(10, -8, 7)]
1785 #[case::precision_8(1, -8, 8)]
1786 fn test_sbe_mantissa_precision(
1787 #[case] mantissa: i64,
1788 #[case] exponent: i8,
1789 #[case] expected: u8,
1790 ) {
1791 let result = sbe_mantissa_precision(mantissa, exponent);
1792 assert_eq!(
1793 result, expected,
1794 "mantissa={mantissa}, exponent={exponent}: expected {expected}, was {result}"
1795 );
1796 }
1797
1798 #[rstest]
1799 fn test_sbe_mantissa_precision_zero_mantissa() {
1800 assert_eq!(sbe_mantissa_precision(0, -8), 0);
1801 }
1802
1803 #[rstest]
1804 fn test_sbe_mantissa_precision_positive_exponent() {
1805 assert_eq!(sbe_mantissa_precision(1, 0), 0);
1806 assert_eq!(sbe_mantissa_precision(5, 2), 0);
1807 }
1808
1809 #[rstest]
1810 fn test_parse_sbe_price_filter_ethusdc() {
1811 let filter = BinancePriceFilterSbe {
1812 price_exponent: -8,
1813 min_price: 1_000_000,
1814 max_price: 100_000_000_000_000,
1815 tick_size: 1_000_000,
1816 };
1817
1818 let (tick_size, max_price, min_price) = parse_sbe_price_filter(&filter).unwrap();
1819 let max_price = max_price.unwrap();
1820 let min_price = min_price.unwrap();
1821
1822 assert_eq!(tick_size.precision, 2, "tick_size precision");
1823 assert_eq!(tick_size.as_decimal(), dec!(0.01));
1824 assert_eq!(max_price.precision, 2);
1825 assert_eq!(max_price.as_decimal(), dec!(1000000.00));
1826 assert_eq!(min_price.precision, 2);
1827 assert_eq!(min_price.as_decimal(), dec!(0.01));
1828 }
1829
1830 #[rstest]
1831 fn test_parse_sbe_price_filter_shibusdt() {
1832 let filter = BinancePriceFilterSbe {
1833 price_exponent: -8,
1834 min_price: 1,
1835 max_price: 100_000_000,
1836 tick_size: 1,
1837 };
1838
1839 let (tick_size, _, _) = parse_sbe_price_filter(&filter).unwrap();
1840
1841 assert_eq!(tick_size.precision, 8);
1842 assert_eq!(tick_size.as_decimal(), dec!(0.00000001));
1843 }
1844
1845 #[rstest]
1846 fn test_parse_sbe_lot_size_filter_ethusdc() {
1847 let filter = BinanceLotSizeFilterSbe {
1848 qty_exponent: -8,
1849 min_qty: 10_000,
1850 max_qty: 900_000_000_000,
1851 step_size: 10_000,
1852 };
1853
1854 let (step_size, max_qty, min_qty) = parse_sbe_lot_size_filter(&filter).unwrap();
1855 let max_qty = max_qty.unwrap();
1856 let min_qty = min_qty.unwrap();
1857
1858 assert_eq!(step_size.precision, 4, "step_size precision");
1859 assert_eq!(step_size.as_decimal(), dec!(0.0001));
1860 assert_eq!(min_qty.precision, 4);
1861 assert_eq!(min_qty.as_decimal(), dec!(0.0001));
1862 assert_eq!(max_qty.precision, 4);
1863 assert_eq!(max_qty.as_decimal(), dec!(9000.0000));
1864 }
1865 }
1866}