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 enums::{BinanceContractStatus, BinanceKlineInterval, BinanceTradingStatus},
47 fixed::{mantissa_to_price, mantissa_to_quantity},
48 sbe::spot::{
49 order_side::OrderSide as SbeOrderSide, order_status::OrderStatus as SbeOrderStatus,
50 order_type::OrderType as SbeOrderType, time_in_force::TimeInForce as SbeTimeInForce,
51 },
52 },
53 futures::http::models::{BinanceFuturesCoinSymbol, BinanceFuturesUsdSymbol},
54 spot::http::models::{
55 BinanceAccountTrade, BinanceKlines, BinanceLotSizeFilterSbe, BinanceNewOrderResponse,
56 BinanceOrderResponse, BinancePriceFilterSbe, BinanceSymbolSbe, BinanceTrades,
57 },
58};
59
60const BINANCE_VENUE: &str = "BINANCE";
61const CONTRACT_TYPE_PERPETUAL: &str = "PERPETUAL";
62
63pub fn get_currency(code: &str) -> Currency {
65 Currency::get_or_create_crypto(code)
66}
67
68fn get_filter<'a>(filters: &'a [Value], filter_type: &str) -> Option<&'a Value> {
70 filters.iter().find(|f| {
71 f.get("filterType")
72 .and_then(|v| v.as_str())
73 .is_some_and(|t| t == filter_type)
74 })
75}
76
77fn parse_filter_string(filter: &Value, field: &str) -> anyhow::Result<String> {
79 filter
80 .get(field)
81 .and_then(|v| v.as_str())
82 .map(String::from)
83 .ok_or_else(|| anyhow::anyhow!("Missing field '{field}' in filter"))
84}
85
86fn parse_filter_price(filter: &Value, field: &str) -> anyhow::Result<Price> {
88 let value = parse_filter_string(filter, field)?;
89 Price::from_str(&value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
90}
91
92fn parse_filter_quantity(filter: &Value, field: &str) -> anyhow::Result<Quantity> {
94 let value = parse_filter_string(filter, field)?;
95 Quantity::from_str(&value)
96 .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
97}
98
99pub fn parse_usdm_instrument(
108 symbol: &BinanceFuturesUsdSymbol,
109 ts_event: UnixNanos,
110 ts_init: UnixNanos,
111) -> anyhow::Result<InstrumentAny> {
112 if symbol.contract_type != CONTRACT_TYPE_PERPETUAL {
114 anyhow::bail!(
115 "Unsupported contract type '{}' for symbol '{}', expected '{}'",
116 symbol.contract_type,
117 symbol.symbol,
118 CONTRACT_TYPE_PERPETUAL
119 );
120 }
121
122 if symbol.status != BinanceTradingStatus::Trading {
123 anyhow::bail!(
124 "Symbol '{}' is not trading (status: {:?})",
125 symbol.symbol,
126 symbol.status
127 );
128 }
129
130 let base_currency = get_currency(symbol.base_asset.as_str());
131 let quote_currency = get_currency(symbol.quote_asset.as_str());
132 let settlement_currency = get_currency(symbol.margin_asset.as_str());
133
134 let instrument_id = InstrumentId::new(
135 Symbol::from_str_unchecked(format!("{}-PERP", symbol.symbol)),
136 Venue::new(BINANCE_VENUE),
137 );
138 let raw_symbol = Symbol::new(symbol.symbol.as_str());
139
140 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
141 .context("Missing PRICE_FILTER in symbol filters")?;
142
143 let tick_size = parse_filter_price(price_filter, "tickSize")?;
144 if tick_size.is_zero() {
145 anyhow::bail!(
146 "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
147 symbol.symbol,
148 );
149 }
150 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
151 let min_price = parse_filter_price(price_filter, "minPrice").ok();
152
153 let lot_filter =
154 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
155
156 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
157 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
158 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
159
160 let default_margin = Decimal::new(1, 1);
162
163 let instrument = CryptoPerpetual::new(
164 instrument_id,
165 raw_symbol,
166 base_currency,
167 quote_currency,
168 settlement_currency,
169 false, tick_size.precision,
171 step_size.precision,
172 tick_size,
173 step_size,
174 None, Some(step_size),
176 max_quantity,
177 min_quantity,
178 None, None, max_price,
181 min_price,
182 Some(default_margin),
183 Some(default_margin),
184 None, None, ts_event,
187 ts_init,
188 );
189
190 Ok(InstrumentAny::CryptoPerpetual(instrument))
191}
192
193pub fn parse_coinm_instrument(
205 symbol: &BinanceFuturesCoinSymbol,
206 ts_event: UnixNanos,
207 ts_init: UnixNanos,
208) -> anyhow::Result<InstrumentAny> {
209 if symbol.contract_type != CONTRACT_TYPE_PERPETUAL {
210 anyhow::bail!(
211 "Unsupported contract type '{}' for symbol '{}', expected '{}'",
212 symbol.contract_type,
213 symbol.symbol,
214 CONTRACT_TYPE_PERPETUAL
215 );
216 }
217
218 if symbol.contract_status != Some(BinanceContractStatus::Trading) {
219 anyhow::bail!(
220 "Symbol '{}' is not trading (status: {:?})",
221 symbol.symbol,
222 symbol.contract_status
223 );
224 }
225
226 let base_currency = get_currency(symbol.base_asset.as_str());
227 let quote_currency = get_currency(symbol.quote_asset.as_str());
228
229 let settlement_currency = get_currency(symbol.margin_asset.as_str());
231
232 let instrument_id = InstrumentId::new(
233 Symbol::from_str_unchecked(format!("{}-PERP", symbol.symbol)),
234 Venue::new(BINANCE_VENUE),
235 );
236 let raw_symbol = Symbol::new(symbol.symbol.as_str());
237
238 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
239 .context("Missing PRICE_FILTER in symbol filters")?;
240
241 let tick_size = parse_filter_price(price_filter, "tickSize")?;
242 if tick_size.is_zero() {
243 anyhow::bail!(
244 "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
245 symbol.symbol,
246 );
247 }
248 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
249 let min_price = parse_filter_price(price_filter, "minPrice").ok();
250
251 let lot_filter =
252 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
253
254 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
255 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
256 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
257
258 let multiplier = Quantity::new(symbol.contract_size as f64, 0);
260
261 let default_margin = Decimal::new(1, 1);
263
264 let instrument = CryptoPerpetual::new(
265 instrument_id,
266 raw_symbol,
267 base_currency,
268 quote_currency,
269 settlement_currency,
270 true, tick_size.precision,
272 step_size.precision,
273 tick_size,
274 step_size,
275 Some(multiplier),
276 Some(step_size),
277 max_quantity,
278 min_quantity,
279 None, None, max_price,
282 min_price,
283 Some(default_margin),
284 Some(default_margin),
285 None, None, ts_event,
288 ts_init,
289 );
290
291 Ok(InstrumentAny::CryptoPerpetual(instrument))
292}
293
294const SBE_STATUS_TRADING: u8 = 0;
296
297fn parse_sbe_price_filter(
299 filter: &BinancePriceFilterSbe,
300) -> anyhow::Result<(Price, Option<Price>, Option<Price>)> {
301 let precision = (-filter.price_exponent).max(0) as u8;
302
303 let tick_size = mantissa_to_price(filter.tick_size, filter.price_exponent, precision);
304
305 let max_price = if filter.max_price != 0 {
306 Some(mantissa_to_price(
307 filter.max_price,
308 filter.price_exponent,
309 precision,
310 ))
311 } else {
312 None
313 };
314
315 let min_price = if filter.min_price != 0 {
316 Some(mantissa_to_price(
317 filter.min_price,
318 filter.price_exponent,
319 precision,
320 ))
321 } else {
322 None
323 };
324
325 Ok((tick_size, max_price, min_price))
326}
327
328fn parse_sbe_lot_size_filter(
330 filter: &BinanceLotSizeFilterSbe,
331) -> anyhow::Result<(Quantity, Option<Quantity>, Option<Quantity>)> {
332 let precision = (-filter.qty_exponent).max(0) as u8;
333
334 let step_size = mantissa_to_quantity(filter.step_size, filter.qty_exponent, precision);
335
336 let max_qty = if filter.max_qty != 0 {
337 Some(mantissa_to_quantity(
338 filter.max_qty,
339 filter.qty_exponent,
340 precision,
341 ))
342 } else {
343 None
344 };
345
346 let min_qty = if filter.min_qty != 0 {
347 Some(mantissa_to_quantity(
348 filter.min_qty,
349 filter.qty_exponent,
350 precision,
351 ))
352 } else {
353 None
354 };
355
356 Ok((step_size, max_qty, min_qty))
357}
358
359pub fn parse_spot_instrument_sbe(
368 symbol: &BinanceSymbolSbe,
369 ts_event: UnixNanos,
370 ts_init: UnixNanos,
371) -> anyhow::Result<InstrumentAny> {
372 if symbol.status != SBE_STATUS_TRADING {
373 anyhow::bail!(
374 "Symbol '{}' is not trading (status: {})",
375 symbol.symbol,
376 symbol.status
377 );
378 }
379
380 let base_currency = get_currency(&symbol.base_asset);
381 let quote_currency = get_currency(&symbol.quote_asset);
382
383 let instrument_id = InstrumentId::new(
384 Symbol::from_str_unchecked(&symbol.symbol),
385 Venue::new(BINANCE_VENUE),
386 );
387 let raw_symbol = Symbol::new(&symbol.symbol);
388
389 let price_filter = symbol
390 .filters
391 .price_filter
392 .as_ref()
393 .context("Missing PRICE_FILTER in symbol filters")?;
394
395 let (tick_size, max_price, min_price) = parse_sbe_price_filter(price_filter)?;
396
397 let lot_filter = symbol
398 .filters
399 .lot_size_filter
400 .as_ref()
401 .context("Missing LOT_SIZE in symbol filters")?;
402
403 let (step_size, max_quantity, min_quantity) = parse_sbe_lot_size_filter(lot_filter)?;
404
405 let default_margin = Decimal::new(1, 0);
407
408 let instrument = CurrencyPair::new(
409 instrument_id,
410 raw_symbol,
411 base_currency,
412 quote_currency,
413 tick_size.precision,
414 step_size.precision,
415 tick_size,
416 step_size,
417 None, Some(step_size),
419 max_quantity,
420 min_quantity,
421 None, None, max_price,
424 min_price,
425 Some(default_margin),
426 Some(default_margin),
427 None, None, ts_event,
430 ts_init,
431 );
432
433 Ok(InstrumentAny::CurrencyPair(instrument))
434}
435
436pub fn parse_spot_trades_sbe(
444 trades: &BinanceTrades,
445 instrument: &InstrumentAny,
446 ts_init: UnixNanos,
447) -> anyhow::Result<Vec<TradeTick>> {
448 let instrument_id = instrument.id();
449 let price_precision = instrument.price_precision();
450 let size_precision = instrument.size_precision();
451
452 let mut result = Vec::with_capacity(trades.trades.len());
453
454 for trade in &trades.trades {
455 let price = mantissa_to_price(trade.price_mantissa, trades.price_exponent, price_precision);
456 let size = mantissa_to_quantity(trade.qty_mantissa, trades.qty_exponent, size_precision);
457
458 let aggressor_side = if trade.is_buyer_maker {
460 AggressorSide::Seller
461 } else {
462 AggressorSide::Buyer
463 };
464
465 let ts_event = UnixNanos::from(trade.time as u64 * 1_000);
467
468 let tick = TradeTick::new(
469 instrument_id,
470 price,
471 size,
472 aggressor_side,
473 TradeId::new(trade.id.to_string()),
474 ts_event,
475 ts_init,
476 );
477
478 result.push(tick);
479 }
480
481 Ok(result)
482}
483
484#[must_use]
486pub const fn map_order_status_sbe(status: SbeOrderStatus) -> OrderStatus {
487 match status {
488 SbeOrderStatus::New => OrderStatus::Accepted,
489 SbeOrderStatus::PendingNew => OrderStatus::Submitted,
490 SbeOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
491 SbeOrderStatus::Filled => OrderStatus::Filled,
492 SbeOrderStatus::Canceled => OrderStatus::Canceled,
493 SbeOrderStatus::PendingCancel => OrderStatus::PendingCancel,
494 SbeOrderStatus::Rejected => OrderStatus::Rejected,
495 SbeOrderStatus::Expired | SbeOrderStatus::ExpiredInMatch => OrderStatus::Expired,
496 SbeOrderStatus::Unknown | SbeOrderStatus::NonRepresentable | SbeOrderStatus::NullVal => {
497 OrderStatus::Initialized
498 }
499 }
500}
501
502#[must_use]
504pub const fn map_order_type_sbe(order_type: SbeOrderType) -> OrderType {
505 match order_type {
506 SbeOrderType::Market => OrderType::Market,
507 SbeOrderType::Limit | SbeOrderType::LimitMaker => OrderType::Limit,
508 SbeOrderType::StopLoss | SbeOrderType::TakeProfit => OrderType::StopMarket,
509 SbeOrderType::StopLossLimit | SbeOrderType::TakeProfitLimit => OrderType::StopLimit,
510 SbeOrderType::NonRepresentable | SbeOrderType::NullVal => OrderType::Market,
511 }
512}
513
514#[must_use]
516pub const fn map_order_side_sbe(side: SbeOrderSide) -> OrderSide {
517 match side {
518 SbeOrderSide::Buy => OrderSide::Buy,
519 SbeOrderSide::Sell => OrderSide::Sell,
520 SbeOrderSide::NonRepresentable | SbeOrderSide::NullVal => OrderSide::NoOrderSide,
521 }
522}
523
524#[must_use]
526pub const fn map_time_in_force_sbe(tif: SbeTimeInForce) -> TimeInForce {
527 match tif {
528 SbeTimeInForce::Gtc => TimeInForce::Gtc,
529 SbeTimeInForce::Ioc => TimeInForce::Ioc,
530 SbeTimeInForce::Fok => TimeInForce::Fok,
531 SbeTimeInForce::NonRepresentable | SbeTimeInForce::NullVal => TimeInForce::Gtc,
532 }
533}
534
535#[allow(clippy::too_many_arguments)]
541pub fn parse_order_status_report_sbe(
542 order: &BinanceOrderResponse,
543 account_id: AccountId,
544 instrument: &InstrumentAny,
545 ts_init: UnixNanos,
546) -> anyhow::Result<OrderStatusReport> {
547 let instrument_id = instrument.id();
548 let price_precision = instrument.price_precision();
549 let size_precision = instrument.size_precision();
550
551 let price = if order.price_mantissa != 0 {
552 Some(mantissa_to_price(
553 order.price_mantissa,
554 order.price_exponent,
555 price_precision,
556 ))
557 } else {
558 None
559 };
560
561 let quantity =
562 mantissa_to_quantity(order.orig_qty_mantissa, order.qty_exponent, size_precision);
563 let filled_qty = mantissa_to_quantity(
564 order.executed_qty_mantissa,
565 order.qty_exponent,
566 size_precision,
567 );
568
569 let avg_px = if order.executed_qty_mantissa > 0 {
572 let quote_exp = (order.price_exponent as i32) + (order.qty_exponent as i32);
573 let cum_quote_dec = Decimal::new(order.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
574 let filled_dec = Decimal::new(
575 order.executed_qty_mantissa,
576 (-order.qty_exponent as i32) as u32,
577 );
578 let avg_dec = cum_quote_dec / filled_dec;
579 Some(
580 Price::from_decimal_dp(avg_dec, price_precision)
581 .unwrap_or(Price::zero(price_precision)),
582 )
583 } else {
584 None
585 };
586
587 let trigger_price = order.stop_price_mantissa.and_then(|mantissa| {
589 if mantissa != 0 {
590 Some(mantissa_to_price(
591 mantissa,
592 order.price_exponent,
593 price_precision,
594 ))
595 } else {
596 None
597 }
598 });
599
600 let order_status = map_order_status_sbe(order.status);
602 let order_type = map_order_type_sbe(order.order_type);
603 let order_side = map_order_side_sbe(order.side);
604 let time_in_force = map_time_in_force_sbe(order.time_in_force);
605
606 let trigger_type = if trigger_price.is_some() {
608 Some(TriggerType::LastPrice)
609 } else {
610 None
611 };
612
613 let ts_event = UnixNanos::from(order.update_time as u64 * 1000);
615
616 let order_list_id = order.order_list_id.and_then(|id| {
618 if id > 0 {
619 Some(OrderListId::new(id.to_string()))
620 } else {
621 None
622 }
623 });
624
625 let post_only = order.order_type == SbeOrderType::LimitMaker;
627
628 let ts_accepted = UnixNanos::from(order.time as u64 * 1000);
630
631 let mut report = OrderStatusReport::new(
632 account_id,
633 instrument_id,
634 Some(ClientOrderId::new(order.client_order_id.clone())),
635 VenueOrderId::new(order.order_id.to_string()),
636 order_side,
637 order_type,
638 time_in_force,
639 order_status,
640 quantity,
641 filled_qty,
642 ts_accepted,
643 ts_event,
644 ts_init,
645 None, );
647
648 if let Some(p) = price {
650 report = report.with_price(p);
651 }
652 if let Some(ap) = avg_px {
653 report = report.with_avg_px(ap.as_f64())?;
654 }
655 if let Some(tp) = trigger_price {
656 report = report.with_trigger_price(tp);
657 }
658 if let Some(tt) = trigger_type {
659 report = report.with_trigger_type(tt);
660 }
661 if let Some(oli) = order_list_id {
662 report = report.with_order_list_id(oli);
663 }
664 if post_only {
665 report = report.with_post_only(true);
666 }
667
668 Ok(report)
669}
670
671pub fn parse_new_order_response_sbe(
677 response: &BinanceNewOrderResponse,
678 account_id: AccountId,
679 instrument: &InstrumentAny,
680 ts_init: UnixNanos,
681) -> anyhow::Result<OrderStatusReport> {
682 let instrument_id = instrument.id();
683 let price_precision = instrument.price_precision();
684 let size_precision = instrument.size_precision();
685
686 let price = if response.price_mantissa != 0 {
687 Some(mantissa_to_price(
688 response.price_mantissa,
689 response.price_exponent,
690 price_precision,
691 ))
692 } else {
693 None
694 };
695
696 let quantity = mantissa_to_quantity(
697 response.orig_qty_mantissa,
698 response.qty_exponent,
699 size_precision,
700 );
701 let filled_qty = mantissa_to_quantity(
702 response.executed_qty_mantissa,
703 response.qty_exponent,
704 size_precision,
705 );
706
707 let avg_px = if response.executed_qty_mantissa > 0 {
710 let quote_exp = (response.price_exponent as i32) + (response.qty_exponent as i32);
711 let cum_quote_dec =
712 Decimal::new(response.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
713 let filled_dec = Decimal::new(
714 response.executed_qty_mantissa,
715 (-response.qty_exponent as i32) as u32,
716 );
717 let avg_dec = cum_quote_dec / filled_dec;
718 Some(
719 Price::from_decimal_dp(avg_dec, price_precision)
720 .unwrap_or(Price::zero(price_precision)),
721 )
722 } else {
723 None
724 };
725
726 let trigger_price = response.stop_price_mantissa.and_then(|mantissa| {
727 if mantissa != 0 {
728 Some(mantissa_to_price(
729 mantissa,
730 response.price_exponent,
731 price_precision,
732 ))
733 } else {
734 None
735 }
736 });
737
738 let order_status = map_order_status_sbe(response.status);
739 let order_type = map_order_type_sbe(response.order_type);
740 let order_side = map_order_side_sbe(response.side);
741 let time_in_force = map_time_in_force_sbe(response.time_in_force);
742
743 let trigger_type = if trigger_price.is_some() {
744 Some(TriggerType::LastPrice)
745 } else {
746 None
747 };
748
749 let ts_event = UnixNanos::from(response.transact_time as u64 * 1000);
751 let ts_accepted = ts_event;
752
753 let order_list_id = response.order_list_id.and_then(|id| {
754 if id > 0 {
755 Some(OrderListId::new(id.to_string()))
756 } else {
757 None
758 }
759 });
760
761 let post_only = response.order_type == SbeOrderType::LimitMaker;
763
764 let mut report = OrderStatusReport::new(
765 account_id,
766 instrument_id,
767 Some(ClientOrderId::new(response.client_order_id.clone())),
768 VenueOrderId::new(response.order_id.to_string()),
769 order_side,
770 order_type,
771 time_in_force,
772 order_status,
773 quantity,
774 filled_qty,
775 ts_accepted,
776 ts_event,
777 ts_init,
778 None,
779 );
780
781 if let Some(p) = price {
782 report = report.with_price(p);
783 }
784 if let Some(ap) = avg_px {
785 report = report.with_avg_px(ap.as_f64())?;
786 }
787 if let Some(tp) = trigger_price {
788 report = report.with_trigger_price(tp);
789 }
790 if let Some(tt) = trigger_type {
791 report = report.with_trigger_type(tt);
792 }
793 if let Some(oli) = order_list_id {
794 report = report.with_order_list_id(oli);
795 }
796 if post_only {
797 report = report.with_post_only(true);
798 }
799
800 Ok(report)
801}
802
803pub fn parse_fill_report_sbe(
809 trade: &BinanceAccountTrade,
810 account_id: AccountId,
811 instrument: &InstrumentAny,
812 commission_currency: Currency,
813 ts_init: UnixNanos,
814) -> anyhow::Result<FillReport> {
815 let instrument_id = instrument.id();
816 let price_precision = instrument.price_precision();
817 let size_precision = instrument.size_precision();
818
819 let last_px = mantissa_to_price(trade.price_mantissa, trade.price_exponent, price_precision);
820 let last_qty = mantissa_to_quantity(trade.qty_mantissa, trade.qty_exponent, size_precision);
821
822 let comm_exp = trade.commission_exponent as i32;
824 let comm_dec = Decimal::new(trade.commission_mantissa, (-comm_exp) as u32);
825 let commission = Money::new(comm_dec.to_f64().unwrap_or(0.0), commission_currency);
826
827 let order_side = if trade.is_buyer {
829 OrderSide::Buy
830 } else {
831 OrderSide::Sell
832 };
833
834 let liquidity_side = if trade.is_maker {
836 LiquiditySide::Maker
837 } else {
838 LiquiditySide::Taker
839 };
840
841 let ts_event = UnixNanos::from(trade.time as u64 * 1000);
843
844 Ok(FillReport::new(
845 account_id,
846 instrument_id,
847 VenueOrderId::new(trade.order_id.to_string()),
848 TradeId::new(trade.id.to_string()),
849 order_side,
850 last_qty,
851 last_px,
852 commission,
853 liquidity_side,
854 None, None, ts_event,
857 ts_init,
858 None, ))
860}
861
862pub fn parse_klines_to_bars(
868 klines: &BinanceKlines,
869 bar_type: BarType,
870 instrument: &InstrumentAny,
871 ts_init: UnixNanos,
872) -> anyhow::Result<Vec<Bar>> {
873 let price_precision = instrument.price_precision();
874 let size_precision = instrument.size_precision();
875
876 let mut bars = Vec::with_capacity(klines.klines.len());
877
878 for kline in &klines.klines {
879 let open = mantissa_to_price(kline.open_price, klines.price_exponent, price_precision);
880 let high = mantissa_to_price(kline.high_price, klines.price_exponent, price_precision);
881 let low = mantissa_to_price(kline.low_price, klines.price_exponent, price_precision);
882 let close = mantissa_to_price(kline.close_price, klines.price_exponent, price_precision);
883
884 let volume_mantissa = i128::from_le_bytes(kline.volume);
886 let volume_dec =
887 Decimal::from_i128_with_scale(volume_mantissa, (-klines.qty_exponent as i32) as u32);
888 let volume = Quantity::new(volume_dec.to_f64().unwrap_or(0.0), size_precision);
889
890 let ts_event = UnixNanos::from(kline.open_time as u64 * 1_000_000);
891
892 let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
893 bars.push(bar);
894 }
895
896 Ok(bars)
897}
898
899pub fn bar_spec_to_binance_interval(
906 bar_spec: BarSpecification,
907) -> anyhow::Result<BinanceKlineInterval> {
908 let step = bar_spec.step.get();
909 let interval = match bar_spec.aggregation {
910 BarAggregation::Second => {
911 anyhow::bail!("Binance Spot does not support second-level kline intervals")
912 }
913 BarAggregation::Minute => match step {
914 1 => BinanceKlineInterval::Minute1,
915 3 => BinanceKlineInterval::Minute3,
916 5 => BinanceKlineInterval::Minute5,
917 15 => BinanceKlineInterval::Minute15,
918 30 => BinanceKlineInterval::Minute30,
919 _ => anyhow::bail!("Unsupported minute interval: {step}m"),
920 },
921 BarAggregation::Hour => match step {
922 1 => BinanceKlineInterval::Hour1,
923 2 => BinanceKlineInterval::Hour2,
924 4 => BinanceKlineInterval::Hour4,
925 6 => BinanceKlineInterval::Hour6,
926 8 => BinanceKlineInterval::Hour8,
927 12 => BinanceKlineInterval::Hour12,
928 _ => anyhow::bail!("Unsupported hour interval: {step}h"),
929 },
930 BarAggregation::Day => match step {
931 1 => BinanceKlineInterval::Day1,
932 3 => BinanceKlineInterval::Day3,
933 _ => anyhow::bail!("Unsupported day interval: {step}d"),
934 },
935 BarAggregation::Week => match step {
936 1 => BinanceKlineInterval::Week1,
937 _ => anyhow::bail!("Unsupported week interval: {step}w"),
938 },
939 BarAggregation::Month => match step {
940 1 => BinanceKlineInterval::Month1,
941 _ => anyhow::bail!("Unsupported month interval: {step}M"),
942 },
943 agg => anyhow::bail!("Unsupported bar aggregation for Binance: {agg:?}"),
944 };
945
946 Ok(interval)
947}
948
949#[cfg(test)]
950mod tests {
951 use rstest::rstest;
952 use serde_json::json;
953 use ustr::Ustr;
954
955 use super::*;
956 use crate::common::enums::BinanceTradingStatus;
957
958 fn sample_usdm_symbol() -> BinanceFuturesUsdSymbol {
959 BinanceFuturesUsdSymbol {
960 symbol: Ustr::from("BTCUSDT"),
961 pair: Ustr::from("BTCUSDT"),
962 contract_type: "PERPETUAL".to_string(),
963 delivery_date: 4133404800000,
964 onboard_date: 1569398400000,
965 status: BinanceTradingStatus::Trading,
966 maint_margin_percent: "2.5000".to_string(),
967 required_margin_percent: "5.0000".to_string(),
968 base_asset: Ustr::from("BTC"),
969 quote_asset: Ustr::from("USDT"),
970 margin_asset: Ustr::from("USDT"),
971 price_precision: 2,
972 quantity_precision: 3,
973 base_asset_precision: 8,
974 quote_precision: 8,
975 underlying_type: Some("COIN".to_string()),
976 underlying_sub_type: vec!["PoW".to_string()],
977 settle_plan: None,
978 trigger_protect: Some("0.0500".to_string()),
979 liquidation_fee: Some("0.012500".to_string()),
980 market_take_bound: Some("0.05".to_string()),
981 order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
982 time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
983 filters: vec![
984 json!({
985 "filterType": "PRICE_FILTER",
986 "tickSize": "0.10",
987 "maxPrice": "4529764",
988 "minPrice": "556.80"
989 }),
990 json!({
991 "filterType": "LOT_SIZE",
992 "stepSize": "0.001",
993 "maxQty": "1000",
994 "minQty": "0.001"
995 }),
996 ],
997 }
998 }
999
1000 #[rstest]
1001 fn test_parse_usdm_perpetual() {
1002 let symbol = sample_usdm_symbol();
1003 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1004
1005 let result = parse_usdm_instrument(&symbol, ts, ts);
1006 assert!(result.is_ok(), "Failed: {:?}", result.err());
1007
1008 let instrument = result.unwrap();
1009 match instrument {
1010 InstrumentAny::CryptoPerpetual(perp) => {
1011 assert_eq!(perp.id.to_string(), "BTCUSDT-PERP.BINANCE");
1012 assert_eq!(perp.raw_symbol.to_string(), "BTCUSDT");
1013 assert_eq!(perp.base_currency.code.as_str(), "BTC");
1014 assert_eq!(perp.quote_currency.code.as_str(), "USDT");
1015 assert_eq!(perp.settlement_currency.code.as_str(), "USDT");
1016 assert!(!perp.is_inverse);
1017 assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
1018 assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1019 }
1020 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1021 }
1022 }
1023
1024 #[rstest]
1025 fn test_parse_non_perpetual_fails() {
1026 let mut symbol = sample_usdm_symbol();
1027 symbol.contract_type = "CURRENT_QUARTER".to_string();
1028 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1029
1030 let result = parse_usdm_instrument(&symbol, ts, ts);
1031 assert!(result.is_err());
1032 assert!(
1033 result
1034 .unwrap_err()
1035 .to_string()
1036 .contains("Unsupported contract type")
1037 );
1038 }
1039
1040 #[rstest]
1041 fn test_parse_missing_price_filter_fails() {
1042 let mut symbol = sample_usdm_symbol();
1043 symbol.filters = vec![json!({
1044 "filterType": "LOT_SIZE",
1045 "stepSize": "0.001",
1046 "maxQty": "1000",
1047 "minQty": "0.001"
1048 })];
1049 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1050
1051 let result = parse_usdm_instrument(&symbol, ts, ts);
1052 assert!(result.is_err());
1053 assert!(
1054 result
1055 .unwrap_err()
1056 .to_string()
1057 .contains("Missing PRICE_FILTER")
1058 );
1059 }
1060
1061 mod bar_spec_tests {
1062 use std::num::NonZeroUsize;
1063
1064 use nautilus_model::{
1065 data::BarSpecification,
1066 enums::{BarAggregation, PriceType},
1067 };
1068
1069 use super::*;
1070 use crate::common::enums::BinanceKlineInterval;
1071
1072 fn make_bar_spec(step: usize, aggregation: BarAggregation) -> BarSpecification {
1073 BarSpecification {
1074 step: NonZeroUsize::new(step).unwrap(),
1075 aggregation,
1076 price_type: PriceType::Last,
1077 }
1078 }
1079
1080 #[rstest]
1081 #[case(1, BarAggregation::Minute, BinanceKlineInterval::Minute1)]
1082 #[case(3, BarAggregation::Minute, BinanceKlineInterval::Minute3)]
1083 #[case(5, BarAggregation::Minute, BinanceKlineInterval::Minute5)]
1084 #[case(15, BarAggregation::Minute, BinanceKlineInterval::Minute15)]
1085 #[case(30, BarAggregation::Minute, BinanceKlineInterval::Minute30)]
1086 #[case(1, BarAggregation::Hour, BinanceKlineInterval::Hour1)]
1087 #[case(2, BarAggregation::Hour, BinanceKlineInterval::Hour2)]
1088 #[case(4, BarAggregation::Hour, BinanceKlineInterval::Hour4)]
1089 #[case(6, BarAggregation::Hour, BinanceKlineInterval::Hour6)]
1090 #[case(8, BarAggregation::Hour, BinanceKlineInterval::Hour8)]
1091 #[case(12, BarAggregation::Hour, BinanceKlineInterval::Hour12)]
1092 #[case(1, BarAggregation::Day, BinanceKlineInterval::Day1)]
1093 #[case(3, BarAggregation::Day, BinanceKlineInterval::Day3)]
1094 #[case(1, BarAggregation::Week, BinanceKlineInterval::Week1)]
1095 #[case(1, BarAggregation::Month, BinanceKlineInterval::Month1)]
1096 fn test_bar_spec_to_binance_interval(
1097 #[case] step: usize,
1098 #[case] aggregation: BarAggregation,
1099 #[case] expected: BinanceKlineInterval,
1100 ) {
1101 let bar_spec = make_bar_spec(step, aggregation);
1102 let result = bar_spec_to_binance_interval(bar_spec).unwrap();
1103 assert_eq!(result, expected);
1104 }
1105
1106 #[rstest]
1107 fn test_unsupported_second_interval() {
1108 let bar_spec = make_bar_spec(1, BarAggregation::Second);
1109 let result = bar_spec_to_binance_interval(bar_spec);
1110 assert!(result.is_err());
1111 assert!(
1112 result
1113 .unwrap_err()
1114 .to_string()
1115 .contains("does not support second-level")
1116 );
1117 }
1118
1119 #[rstest]
1120 fn test_unsupported_minute_interval() {
1121 let bar_spec = make_bar_spec(7, BarAggregation::Minute);
1122 let result = bar_spec_to_binance_interval(bar_spec);
1123 assert!(result.is_err());
1124 assert!(
1125 result
1126 .unwrap_err()
1127 .to_string()
1128 .contains("Unsupported minute interval")
1129 );
1130 }
1131
1132 #[rstest]
1133 fn test_unsupported_aggregation() {
1134 let bar_spec = make_bar_spec(100, BarAggregation::Tick);
1135 let result = bar_spec_to_binance_interval(bar_spec);
1136 assert!(result.is_err());
1137 assert!(
1138 result
1139 .unwrap_err()
1140 .to_string()
1141 .contains("Unsupported bar aggregation")
1142 );
1143 }
1144 }
1145}