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