1use std::str::FromStr;
22
23use anyhow::Context;
24use nautilus_core::nanos::UnixNanos;
25use nautilus_model::{
26 data::TradeTick,
27 enums::{
28 AggressorSide, LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType,
29 },
30 identifiers::{
31 AccountId, ClientOrderId, InstrumentId, OrderListId, Symbol, TradeId, Venue, VenueOrderId,
32 },
33 instruments::{
34 Instrument, any::InstrumentAny, crypto_perpetual::CryptoPerpetual,
35 currency_pair::CurrencyPair,
36 },
37 reports::{FillReport, OrderStatusReport},
38 types::{Currency, Money, Price, Quantity},
39};
40use rust_decimal::{Decimal, prelude::ToPrimitive};
41use serde_json::Value;
42
43use crate::{
44 common::{
45 enums::BinanceTradingStatus,
46 sbe::spot::{
47 order_side::OrderSide as SbeOrderSide, order_status::OrderStatus as SbeOrderStatus,
48 order_type::OrderType as SbeOrderType, time_in_force::TimeInForce as SbeTimeInForce,
49 },
50 },
51 http::models::{BinanceFuturesUsdSymbol, BinanceSpotSymbol},
52 spot::http::models::{
53 BinanceAccountTrade, BinanceNewOrderResponse, BinanceOrderResponse, BinanceSymbolSbe,
54 BinanceTrades,
55 },
56};
57
58const BINANCE_VENUE: &str = "BINANCE";
59const CONTRACT_TYPE_PERPETUAL: &str = "PERPETUAL";
60
61pub fn get_currency(code: &str) -> Currency {
63 Currency::get_or_create_crypto(code)
64}
65
66fn get_filter<'a>(filters: &'a [Value], filter_type: &str) -> Option<&'a Value> {
68 filters.iter().find(|f| {
69 f.get("filterType")
70 .and_then(|v| v.as_str())
71 .is_some_and(|t| t == filter_type)
72 })
73}
74
75fn parse_filter_string(filter: &Value, field: &str) -> anyhow::Result<String> {
77 filter
78 .get(field)
79 .and_then(|v| v.as_str())
80 .map(String::from)
81 .ok_or_else(|| anyhow::anyhow!("Missing field '{field}' in filter"))
82}
83
84fn parse_filter_price(filter: &Value, field: &str) -> anyhow::Result<Price> {
86 let value = parse_filter_string(filter, field)?;
87 Price::from_str(&value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
88}
89
90fn parse_filter_quantity(filter: &Value, field: &str) -> anyhow::Result<Quantity> {
92 let value = parse_filter_string(filter, field)?;
93 Quantity::from_str(&value)
94 .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
95}
96
97pub fn parse_usdm_instrument(
106 symbol: &BinanceFuturesUsdSymbol,
107 ts_event: UnixNanos,
108 ts_init: UnixNanos,
109) -> anyhow::Result<InstrumentAny> {
110 if symbol.contract_type != CONTRACT_TYPE_PERPETUAL {
112 anyhow::bail!(
113 "Unsupported contract type '{}' for symbol '{}', expected '{}'",
114 symbol.contract_type,
115 symbol.symbol,
116 CONTRACT_TYPE_PERPETUAL
117 );
118 }
119
120 let base_currency = get_currency(symbol.base_asset.as_str());
121 let quote_currency = get_currency(symbol.quote_asset.as_str());
122 let settlement_currency = get_currency(symbol.margin_asset.as_str());
123
124 let instrument_id = InstrumentId::new(
125 Symbol::from_str_unchecked(format!("{}-PERP", symbol.symbol)),
126 Venue::new(BINANCE_VENUE),
127 );
128 let raw_symbol = Symbol::new(symbol.symbol.as_str());
129
130 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
131 .context("Missing PRICE_FILTER in symbol filters")?;
132
133 let tick_size = parse_filter_price(price_filter, "tickSize")?;
134 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
135 let min_price = parse_filter_price(price_filter, "minPrice").ok();
136
137 let lot_filter =
138 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
139
140 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
141 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
142 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
143
144 let default_margin = Decimal::new(1, 1);
146
147 let instrument = CryptoPerpetual::new(
148 instrument_id,
149 raw_symbol,
150 base_currency,
151 quote_currency,
152 settlement_currency,
153 false, tick_size.precision,
155 step_size.precision,
156 tick_size,
157 step_size,
158 None, Some(step_size),
160 max_quantity,
161 min_quantity,
162 None, None, max_price,
165 min_price,
166 Some(default_margin),
167 Some(default_margin),
168 None, None, ts_event,
171 ts_init,
172 );
173
174 Ok(InstrumentAny::CryptoPerpetual(instrument))
175}
176
177const SBE_STATUS_TRADING: u8 = 0;
179
180pub fn parse_spot_instrument_sbe(
189 symbol: &BinanceSymbolSbe,
190 ts_event: UnixNanos,
191 ts_init: UnixNanos,
192) -> anyhow::Result<InstrumentAny> {
193 if symbol.status != SBE_STATUS_TRADING {
194 anyhow::bail!(
195 "Symbol '{}' is not trading (status: {})",
196 symbol.symbol,
197 symbol.status
198 );
199 }
200
201 let base_currency = get_currency(&symbol.base_asset);
202 let quote_currency = get_currency(&symbol.quote_asset);
203
204 let instrument_id = InstrumentId::new(
205 Symbol::from_str_unchecked(&symbol.symbol),
206 Venue::new(BINANCE_VENUE),
207 );
208 let raw_symbol = Symbol::new(&symbol.symbol);
209
210 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
211 .context("Missing PRICE_FILTER in symbol filters")?;
212
213 let tick_size = parse_filter_price(price_filter, "tickSize")?;
214 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
215 let min_price = parse_filter_price(price_filter, "minPrice").ok();
216
217 let lot_filter =
218 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
219
220 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
221 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
222 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
223
224 let default_margin = Decimal::new(1, 0);
226
227 let instrument = CurrencyPair::new(
228 instrument_id,
229 raw_symbol,
230 base_currency,
231 quote_currency,
232 tick_size.precision,
233 step_size.precision,
234 tick_size,
235 step_size,
236 None, Some(step_size),
238 max_quantity,
239 min_quantity,
240 None, None, max_price,
243 min_price,
244 Some(default_margin),
245 Some(default_margin),
246 None, None, ts_event,
249 ts_init,
250 );
251
252 Ok(InstrumentAny::CurrencyPair(instrument))
253}
254
255pub fn parse_spot_instrument(
264 symbol: &BinanceSpotSymbol,
265 ts_event: UnixNanos,
266 ts_init: UnixNanos,
267) -> anyhow::Result<InstrumentAny> {
268 if symbol.status != BinanceTradingStatus::Trading {
269 anyhow::bail!(
270 "Symbol '{}' is not trading (status: {:?})",
271 symbol.symbol,
272 symbol.status
273 );
274 }
275
276 let base_currency = get_currency(symbol.base_asset.as_str());
277 let quote_currency = get_currency(symbol.quote_asset.as_str());
278
279 let instrument_id = InstrumentId::new(
280 Symbol::from_str_unchecked(symbol.symbol.as_str()),
281 Venue::new(BINANCE_VENUE),
282 );
283 let raw_symbol = Symbol::new(symbol.symbol.as_str());
284
285 let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
286 .context("Missing PRICE_FILTER in symbol filters")?;
287
288 let tick_size = parse_filter_price(price_filter, "tickSize")?;
289 let max_price = parse_filter_price(price_filter, "maxPrice").ok();
290 let min_price = parse_filter_price(price_filter, "minPrice").ok();
291
292 let lot_filter =
293 get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
294
295 let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
296 let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
297 let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
298
299 let default_margin = Decimal::new(1, 0);
301
302 let instrument = CurrencyPair::new(
303 instrument_id,
304 raw_symbol,
305 base_currency,
306 quote_currency,
307 tick_size.precision,
308 step_size.precision,
309 tick_size,
310 step_size,
311 None, Some(step_size),
313 max_quantity,
314 min_quantity,
315 None, None, max_price,
318 min_price,
319 Some(default_margin),
320 Some(default_margin),
321 None, None, ts_event,
324 ts_init,
325 );
326
327 Ok(InstrumentAny::CurrencyPair(instrument))
328}
329
330pub fn parse_spot_trades_sbe(
338 trades: &BinanceTrades,
339 instrument: &InstrumentAny,
340 ts_init: UnixNanos,
341) -> anyhow::Result<Vec<TradeTick>> {
342 let instrument_id = instrument.id();
343 let price_precision = instrument.price_precision();
344 let size_precision = instrument.size_precision();
345
346 let mut result = Vec::with_capacity(trades.trades.len());
347
348 for trade in &trades.trades {
349 let price_exp = trades.price_exponent as i32;
351 let qty_exp = trades.qty_exponent as i32;
352
353 let price_dec = Decimal::new(trade.price_mantissa, (-price_exp) as u32);
354 let qty_dec = Decimal::new(trade.qty_mantissa, (-qty_exp) as u32);
355
356 let price = Price::new(price_dec.to_f64().unwrap_or(0.0), price_precision);
357 let size = Quantity::new(qty_dec.to_f64().unwrap_or(0.0), size_precision);
358
359 let aggressor_side = if trade.is_buyer_maker {
361 AggressorSide::Seller
362 } else {
363 AggressorSide::Buyer
364 };
365
366 let ts_event = UnixNanos::from(trade.time as u64 * 1_000);
368
369 let tick = TradeTick::new(
370 instrument_id,
371 price,
372 size,
373 aggressor_side,
374 TradeId::new(trade.id.to_string()),
375 ts_event,
376 ts_init,
377 );
378
379 result.push(tick);
380 }
381
382 Ok(result)
383}
384
385#[must_use]
387pub const fn map_order_status_sbe(status: SbeOrderStatus) -> OrderStatus {
388 match status {
389 SbeOrderStatus::New => OrderStatus::Accepted,
390 SbeOrderStatus::PendingNew => OrderStatus::Submitted,
391 SbeOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
392 SbeOrderStatus::Filled => OrderStatus::Filled,
393 SbeOrderStatus::Canceled => OrderStatus::Canceled,
394 SbeOrderStatus::PendingCancel => OrderStatus::PendingCancel,
395 SbeOrderStatus::Rejected => OrderStatus::Rejected,
396 SbeOrderStatus::Expired | SbeOrderStatus::ExpiredInMatch => OrderStatus::Expired,
397 SbeOrderStatus::Unknown | SbeOrderStatus::NonRepresentable | SbeOrderStatus::NullVal => {
398 OrderStatus::Initialized
399 }
400 }
401}
402
403#[must_use]
405pub const fn map_order_type_sbe(order_type: SbeOrderType) -> OrderType {
406 match order_type {
407 SbeOrderType::Market => OrderType::Market,
408 SbeOrderType::Limit | SbeOrderType::LimitMaker => OrderType::Limit,
409 SbeOrderType::StopLoss | SbeOrderType::TakeProfit => OrderType::StopMarket,
410 SbeOrderType::StopLossLimit | SbeOrderType::TakeProfitLimit => OrderType::StopLimit,
411 SbeOrderType::NonRepresentable | SbeOrderType::NullVal => OrderType::Market,
412 }
413}
414
415#[must_use]
417pub const fn map_order_side_sbe(side: SbeOrderSide) -> OrderSide {
418 match side {
419 SbeOrderSide::Buy => OrderSide::Buy,
420 SbeOrderSide::Sell => OrderSide::Sell,
421 SbeOrderSide::NonRepresentable | SbeOrderSide::NullVal => OrderSide::NoOrderSide,
422 }
423}
424
425#[must_use]
427pub const fn map_time_in_force_sbe(tif: SbeTimeInForce) -> TimeInForce {
428 match tif {
429 SbeTimeInForce::Gtc => TimeInForce::Gtc,
430 SbeTimeInForce::Ioc => TimeInForce::Ioc,
431 SbeTimeInForce::Fok => TimeInForce::Fok,
432 SbeTimeInForce::NonRepresentable | SbeTimeInForce::NullVal => TimeInForce::Gtc,
433 }
434}
435
436#[allow(clippy::too_many_arguments)]
442pub fn parse_order_status_report_sbe(
443 order: &BinanceOrderResponse,
444 account_id: AccountId,
445 instrument: &InstrumentAny,
446 ts_init: UnixNanos,
447) -> anyhow::Result<OrderStatusReport> {
448 let instrument_id = instrument.id();
449 let price_precision = instrument.price_precision();
450 let size_precision = instrument.size_precision();
451
452 let price_exp = order.price_exponent as i32;
454 let qty_exp = order.qty_exponent as i32;
455
456 let price_dec = Decimal::new(order.price_mantissa, (-price_exp) as u32);
457 let qty_dec = Decimal::new(order.orig_qty_mantissa, (-qty_exp) as u32);
458 let filled_dec = Decimal::new(order.executed_qty_mantissa, (-qty_exp) as u32);
459
460 let price = if order.price_mantissa != 0 {
461 Some(Price::new(
462 price_dec.to_f64().unwrap_or(0.0),
463 price_precision,
464 ))
465 } else {
466 None
467 };
468
469 let quantity = Quantity::new(qty_dec.to_f64().unwrap_or(0.0), size_precision);
470 let filled_qty = Quantity::new(filled_dec.to_f64().unwrap_or(0.0), size_precision);
471
472 let avg_px = if order.executed_qty_mantissa > 0 {
475 let quote_exp = price_exp + qty_exp;
476 let cum_quote_dec = Decimal::new(order.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
477 let avg_dec = cum_quote_dec / filled_dec;
478 Some(Price::new(avg_dec.to_f64().unwrap_or(0.0), price_precision))
479 } else {
480 None
481 };
482
483 let trigger_price = order.stop_price_mantissa.and_then(|mantissa| {
485 if mantissa != 0 {
486 let stop_dec = Decimal::new(mantissa, (-price_exp) as u32);
487 Some(Price::new(
488 stop_dec.to_f64().unwrap_or(0.0),
489 price_precision,
490 ))
491 } else {
492 None
493 }
494 });
495
496 let order_status = map_order_status_sbe(order.status);
498 let order_type = map_order_type_sbe(order.order_type);
499 let order_side = map_order_side_sbe(order.side);
500 let time_in_force = map_time_in_force_sbe(order.time_in_force);
501
502 let trigger_type = if trigger_price.is_some() {
504 Some(TriggerType::LastPrice)
505 } else {
506 None
507 };
508
509 let ts_event = UnixNanos::from(order.update_time as u64 * 1000);
511
512 let order_list_id = order.order_list_id.and_then(|id| {
514 if id > 0 {
515 Some(OrderListId::new(id.to_string()))
516 } else {
517 None
518 }
519 });
520
521 let post_only = order.order_type == SbeOrderType::LimitMaker;
523
524 let ts_accepted = UnixNanos::from(order.time as u64 * 1000);
526
527 let mut report = OrderStatusReport::new(
528 account_id,
529 instrument_id,
530 Some(ClientOrderId::new(order.client_order_id.clone())),
531 VenueOrderId::new(order.order_id.to_string()),
532 order_side,
533 order_type,
534 time_in_force,
535 order_status,
536 quantity,
537 filled_qty,
538 ts_accepted,
539 ts_event,
540 ts_init,
541 None, );
543
544 if let Some(p) = price {
546 report = report.with_price(p);
547 }
548 if let Some(ap) = avg_px {
549 report = report.with_avg_px(ap.as_f64())?;
550 }
551 if let Some(tp) = trigger_price {
552 report = report.with_trigger_price(tp);
553 }
554 if let Some(tt) = trigger_type {
555 report = report.with_trigger_type(tt);
556 }
557 if let Some(oli) = order_list_id {
558 report = report.with_order_list_id(oli);
559 }
560 if post_only {
561 report = report.with_post_only(true);
562 }
563
564 Ok(report)
565}
566
567pub fn parse_new_order_response_sbe(
573 response: &BinanceNewOrderResponse,
574 account_id: AccountId,
575 instrument: &InstrumentAny,
576 ts_init: UnixNanos,
577) -> anyhow::Result<OrderStatusReport> {
578 let instrument_id = instrument.id();
579 let price_precision = instrument.price_precision();
580 let size_precision = instrument.size_precision();
581
582 let price_exp = response.price_exponent as i32;
583 let qty_exp = response.qty_exponent as i32;
584
585 let price_dec = Decimal::new(response.price_mantissa, (-price_exp) as u32);
586 let qty_dec = Decimal::new(response.orig_qty_mantissa, (-qty_exp) as u32);
587 let filled_dec = Decimal::new(response.executed_qty_mantissa, (-qty_exp) as u32);
588
589 let price = if response.price_mantissa != 0 {
590 Some(Price::new(
591 price_dec.to_f64().unwrap_or(0.0),
592 price_precision,
593 ))
594 } else {
595 None
596 };
597
598 let quantity = Quantity::new(qty_dec.to_f64().unwrap_or(0.0), size_precision);
599 let filled_qty = Quantity::new(filled_dec.to_f64().unwrap_or(0.0), size_precision);
600
601 let avg_px = if response.executed_qty_mantissa > 0 {
603 let quote_exp = price_exp + qty_exp;
604 let cum_quote_dec =
605 Decimal::new(response.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
606 let avg_dec = cum_quote_dec / filled_dec;
607 Some(Price::new(avg_dec.to_f64().unwrap_or(0.0), price_precision))
608 } else {
609 None
610 };
611
612 let trigger_price = response.stop_price_mantissa.and_then(|mantissa| {
613 if mantissa != 0 {
614 let stop_dec = Decimal::new(mantissa, (-price_exp) as u32);
615 Some(Price::new(
616 stop_dec.to_f64().unwrap_or(0.0),
617 price_precision,
618 ))
619 } else {
620 None
621 }
622 });
623
624 let order_status = map_order_status_sbe(response.status);
625 let order_type = map_order_type_sbe(response.order_type);
626 let order_side = map_order_side_sbe(response.side);
627 let time_in_force = map_time_in_force_sbe(response.time_in_force);
628
629 let trigger_type = if trigger_price.is_some() {
630 Some(TriggerType::LastPrice)
631 } else {
632 None
633 };
634
635 let ts_event = UnixNanos::from(response.transact_time as u64 * 1000);
637 let ts_accepted = ts_event;
638
639 let order_list_id = response.order_list_id.and_then(|id| {
640 if id > 0 {
641 Some(OrderListId::new(id.to_string()))
642 } else {
643 None
644 }
645 });
646
647 let post_only = response.order_type == SbeOrderType::LimitMaker;
649
650 let mut report = OrderStatusReport::new(
651 account_id,
652 instrument_id,
653 Some(ClientOrderId::new(response.client_order_id.clone())),
654 VenueOrderId::new(response.order_id.to_string()),
655 order_side,
656 order_type,
657 time_in_force,
658 order_status,
659 quantity,
660 filled_qty,
661 ts_accepted,
662 ts_event,
663 ts_init,
664 None,
665 );
666
667 if let Some(p) = price {
668 report = report.with_price(p);
669 }
670 if let Some(ap) = avg_px {
671 report = report.with_avg_px(ap.as_f64())?;
672 }
673 if let Some(tp) = trigger_price {
674 report = report.with_trigger_price(tp);
675 }
676 if let Some(tt) = trigger_type {
677 report = report.with_trigger_type(tt);
678 }
679 if let Some(oli) = order_list_id {
680 report = report.with_order_list_id(oli);
681 }
682 if post_only {
683 report = report.with_post_only(true);
684 }
685
686 Ok(report)
687}
688
689pub fn parse_fill_report_sbe(
695 trade: &BinanceAccountTrade,
696 account_id: AccountId,
697 instrument: &InstrumentAny,
698 commission_currency: Currency,
699 ts_init: UnixNanos,
700) -> anyhow::Result<FillReport> {
701 let instrument_id = instrument.id();
702 let price_precision = instrument.price_precision();
703 let size_precision = instrument.size_precision();
704
705 let price_exp = trade.price_exponent as i32;
707 let qty_exp = trade.qty_exponent as i32;
708 let comm_exp = trade.commission_exponent as i32;
709
710 let price_dec = Decimal::new(trade.price_mantissa, (-price_exp) as u32);
711 let qty_dec = Decimal::new(trade.qty_mantissa, (-qty_exp) as u32);
712 let comm_dec = Decimal::new(trade.commission_mantissa, (-comm_exp) as u32);
713
714 let last_px = Price::new(price_dec.to_f64().unwrap_or(0.0), price_precision);
715 let last_qty = Quantity::new(qty_dec.to_f64().unwrap_or(0.0), size_precision);
716 let commission = Money::new(comm_dec.to_f64().unwrap_or(0.0), commission_currency);
717
718 let order_side = if trade.is_buyer {
720 OrderSide::Buy
721 } else {
722 OrderSide::Sell
723 };
724
725 let liquidity_side = if trade.is_maker {
727 LiquiditySide::Maker
728 } else {
729 LiquiditySide::Taker
730 };
731
732 let ts_event = UnixNanos::from(trade.time as u64 * 1000);
734
735 Ok(FillReport::new(
736 account_id,
737 instrument_id,
738 VenueOrderId::new(trade.order_id.to_string()),
739 TradeId::new(trade.id.to_string()),
740 order_side,
741 last_qty,
742 last_px,
743 commission,
744 liquidity_side,
745 None, None, ts_event,
748 ts_init,
749 None, ))
751}
752
753#[cfg(test)]
754mod tests {
755 use rstest::rstest;
756 use serde_json::json;
757 use ustr::Ustr;
758
759 use super::*;
760 use crate::http::models::BinanceSpotSymbol;
761
762 fn sample_usdm_symbol() -> BinanceFuturesUsdSymbol {
763 BinanceFuturesUsdSymbol {
764 symbol: Ustr::from("BTCUSDT"),
765 pair: Ustr::from("BTCUSDT"),
766 contract_type: "PERPETUAL".to_string(),
767 delivery_date: 4133404800000,
768 onboard_date: 1569398400000,
769 status: BinanceTradingStatus::Trading,
770 maint_margin_percent: "2.5000".to_string(),
771 required_margin_percent: "5.0000".to_string(),
772 base_asset: Ustr::from("BTC"),
773 quote_asset: Ustr::from("USDT"),
774 margin_asset: Ustr::from("USDT"),
775 price_precision: 2,
776 quantity_precision: 3,
777 base_asset_precision: 8,
778 quote_precision: 8,
779 underlying_type: Some("COIN".to_string()),
780 underlying_sub_type: vec!["PoW".to_string()],
781 settle_plan: None,
782 trigger_protect: Some("0.0500".to_string()),
783 liquidation_fee: Some("0.012500".to_string()),
784 market_take_bound: Some("0.05".to_string()),
785 order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
786 time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
787 filters: vec![
788 json!({
789 "filterType": "PRICE_FILTER",
790 "tickSize": "0.10",
791 "maxPrice": "4529764",
792 "minPrice": "556.80"
793 }),
794 json!({
795 "filterType": "LOT_SIZE",
796 "stepSize": "0.001",
797 "maxQty": "1000",
798 "minQty": "0.001"
799 }),
800 ],
801 }
802 }
803
804 #[rstest]
805 fn test_parse_usdm_perpetual() {
806 let symbol = sample_usdm_symbol();
807 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
808
809 let result = parse_usdm_instrument(&symbol, ts, ts);
810 assert!(result.is_ok(), "Failed: {:?}", result.err());
811
812 let instrument = result.unwrap();
813 match instrument {
814 InstrumentAny::CryptoPerpetual(perp) => {
815 assert_eq!(perp.id.to_string(), "BTCUSDT-PERP.BINANCE");
816 assert_eq!(perp.raw_symbol.to_string(), "BTCUSDT");
817 assert_eq!(perp.base_currency.code.as_str(), "BTC");
818 assert_eq!(perp.quote_currency.code.as_str(), "USDT");
819 assert_eq!(perp.settlement_currency.code.as_str(), "USDT");
820 assert!(!perp.is_inverse);
821 assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
822 assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
823 }
824 other => panic!("Expected CryptoPerpetual, got {other:?}"),
825 }
826 }
827
828 #[rstest]
829 fn test_parse_non_perpetual_fails() {
830 let mut symbol = sample_usdm_symbol();
831 symbol.contract_type = "CURRENT_QUARTER".to_string();
832 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
833
834 let result = parse_usdm_instrument(&symbol, ts, ts);
835 assert!(result.is_err());
836 assert!(
837 result
838 .unwrap_err()
839 .to_string()
840 .contains("Unsupported contract type")
841 );
842 }
843
844 #[rstest]
845 fn test_parse_missing_price_filter_fails() {
846 let mut symbol = sample_usdm_symbol();
847 symbol.filters = vec![json!({
848 "filterType": "LOT_SIZE",
849 "stepSize": "0.001",
850 "maxQty": "1000",
851 "minQty": "0.001"
852 })];
853 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
854
855 let result = parse_usdm_instrument(&symbol, ts, ts);
856 assert!(result.is_err());
857 assert!(
858 result
859 .unwrap_err()
860 .to_string()
861 .contains("Missing PRICE_FILTER")
862 );
863 }
864
865 fn sample_spot_symbol() -> BinanceSpotSymbol {
866 BinanceSpotSymbol {
867 symbol: Ustr::from("BTCUSDT"),
868 status: BinanceTradingStatus::Trading,
869 base_asset: Ustr::from("BTC"),
870 base_asset_precision: 8,
871 quote_asset: Ustr::from("USDT"),
872 quote_precision: 8,
873 quote_asset_precision: Some(8),
874 order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
875 iceberg_allowed: true,
876 oco_allowed: Some(true),
877 quote_order_qty_market_allowed: Some(true),
878 allow_trailing_stop: Some(true),
879 is_spot_trading_allowed: Some(true),
880 is_margin_trading_allowed: Some(false),
881 filters: vec![
882 json!({
883 "filterType": "PRICE_FILTER",
884 "tickSize": "0.01",
885 "maxPrice": "1000000.00",
886 "minPrice": "0.01"
887 }),
888 json!({
889 "filterType": "LOT_SIZE",
890 "stepSize": "0.00001",
891 "maxQty": "9000.00000",
892 "minQty": "0.00001"
893 }),
894 ],
895 permissions: vec!["SPOT".to_string()],
896 permission_sets: vec![],
897 default_self_trade_prevention_mode: Some("EXPIRE_MAKER".to_string()),
898 allowed_self_trade_prevention_modes: vec!["EXPIRE_MAKER".to_string()],
899 }
900 }
901
902 #[rstest]
903 fn test_parse_spot_instrument() {
904 let symbol = sample_spot_symbol();
905 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
906
907 let result = parse_spot_instrument(&symbol, ts, ts);
908 assert!(result.is_ok(), "Failed: {:?}", result.err());
909
910 let instrument = result.unwrap();
911 match instrument {
912 InstrumentAny::CurrencyPair(pair) => {
913 assert_eq!(pair.id.to_string(), "BTCUSDT.BINANCE");
914 assert_eq!(pair.raw_symbol.to_string(), "BTCUSDT");
915 assert_eq!(pair.base_currency.code.as_str(), "BTC");
916 assert_eq!(pair.quote_currency.code.as_str(), "USDT");
917 assert_eq!(pair.price_increment, Price::from_str("0.01").unwrap());
918 assert_eq!(pair.size_increment, Quantity::from_str("0.00001").unwrap());
919 }
920 other => panic!("Expected CurrencyPair, got {other:?}"),
921 }
922 }
923
924 #[rstest]
925 fn test_parse_spot_non_trading_fails() {
926 let mut symbol = sample_spot_symbol();
927 symbol.status = BinanceTradingStatus::Break;
928 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
929
930 let result = parse_spot_instrument(&symbol, ts, ts);
931 assert!(result.is_err());
932 assert!(result.unwrap_err().to_string().contains("is not trading"));
933 }
934}