Skip to main content

nautilus_binance/common/
parse.rs

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