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