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::{
27        Bar, BarSpecification, BarType, BookOrder, OrderBookDelta, OrderBookDeltas, QuoteTick,
28        TradeTick,
29    },
30    enums::{
31        AggressorSide, AssetClass, BarAggregation, BookAction, LiquiditySide, OrderSide,
32        OrderStatus, OrderType, RecordFlag, TimeInForce, TriggerType,
33    },
34    identifiers::{AccountId, InstrumentId, OrderListId, Symbol, TradeId, Venue, VenueOrderId},
35    instruments::{
36        Instrument, any::InstrumentAny, crypto_future::CryptoFuture,
37        crypto_perpetual::CryptoPerpetual, currency_pair::CurrencyPair,
38        perpetual_contract::PerpetualContract,
39    },
40    reports::{FillReport, OrderStatusReport},
41    types::{Currency, Money, Price, Quantity},
42};
43use rust_decimal::Decimal;
44use serde_json::Value;
45
46use crate::{
47    common::{
48        consts::BINANCE,
49        encoder::decode_client_order_id,
50        enums::{
51            BinanceContractStatus, BinanceKlineInterval, BinanceProductType, BinanceTradingStatus,
52        },
53        symbol::format_instrument_id,
54    },
55    futures::http::models::{BinanceFuturesCoinSymbol, BinanceFuturesUsdSymbol},
56    spot::{
57        http::models::{
58            BinanceAccountTrade, BinanceKlines, BinanceLotSizeFilterSbe, BinanceNewOrderResponse,
59            BinanceOrderResponse, BinancePriceFilterSbe, BinanceSymbolJson, BinanceSymbolSbe,
60            BinanceTrades,
61        },
62        sbe::spot::{
63            order_side::OrderSide as SbeOrderSide, order_status::OrderStatus as SbeOrderStatus,
64            order_type::OrderType as SbeOrderType, time_in_force::TimeInForce as SbeTimeInForce,
65        },
66    },
67};
68const CONTRACT_TYPE_PERPETUAL: &str = "PERPETUAL";
69const CONTRACT_TYPE_TRADIFI_PERPETUAL: &str = "TRADIFI_PERPETUAL";
70const CONTRACT_TYPE_CURRENT_MONTH: &str = "CURRENT_MONTH";
71const CONTRACT_TYPE_NEXT_MONTH: &str = "NEXT_MONTH";
72const CONTRACT_TYPE_CURRENT_QUARTER: &str = "CURRENT_QUARTER";
73const CONTRACT_TYPE_NEXT_QUARTER: &str = "NEXT_QUARTER";
74
75pub(crate) fn parse_millis(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
76    parse_timestamp(value, UnixNanos::from_millis_checked(value), field)
77}
78
79pub(crate) fn parse_micros(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
80    parse_timestamp(value, UnixNanos::from_micros_checked(value), field)
81}
82
83fn parse_timestamp(
84    value: i64,
85    timestamp: Option<UnixNanos>,
86    field: &str,
87) -> anyhow::Result<UnixNanos> {
88    timestamp.ok_or_else(|| {
89        if value < 0 {
90            anyhow::anyhow!("invalid negative Binance {field} timestamp: {value}")
91        } else {
92            anyhow::anyhow!("Binance {field} timestamp is outside the UnixNanos range: {value}")
93        }
94    })
95}
96
97pub(crate) fn parse_millis_or_init(value: i64, field: &str, ts_init: UnixNanos) -> UnixNanos {
98    timestamp_or_init(parse_millis(value, field), ts_init)
99}
100
101pub(crate) fn parse_micros_or_init(value: i64, field: &str, ts_init: UnixNanos) -> UnixNanos {
102    timestamp_or_init(parse_micros(value, field), ts_init)
103}
104
105fn timestamp_or_init(timestamp: anyhow::Result<UnixNanos>, ts_init: UnixNanos) -> UnixNanos {
106    match timestamp {
107        Ok(timestamp) => timestamp,
108        Err(e) => {
109            log::warn!("{e}; using initialization timestamp");
110            ts_init
111        }
112    }
113}
114
115fn parse_tradifi_asset_class(symbol: &BinanceFuturesUsdSymbol) -> anyhow::Result<AssetClass> {
116    let underlying_type = symbol.underlying_type.as_deref().with_context(|| {
117        format!(
118            "Missing underlying type for TRADIFI_PERPETUAL symbol '{}'",
119            symbol.symbol
120        )
121    })?;
122
123    match underlying_type {
124        "EQUITY" | "KR_EQUITY" | "HK_EQUITY" | "PREMARKET" => Ok(AssetClass::Equity),
125        "COMMODITY" => Ok(AssetClass::Commodity),
126        _ => anyhow::bail!(
127            "Unsupported underlying type '{underlying_type}' for TRADIFI_PERPETUAL symbol '{}'",
128            symbol.symbol
129        ),
130    }
131}
132
133/// Returns a currency from the internal map or creates a new crypto currency.
134pub fn get_currency(code: &str) -> Currency {
135    Currency::get_or_create_crypto(code)
136}
137
138/// Extracts filter values from Binance symbol filters array.
139fn get_filter<'a>(filters: &'a [Value], filter_type: &str) -> Option<&'a Value> {
140    filters.iter().find(|f| {
141        f.get("filterType")
142            .and_then(|v| v.as_str())
143            .is_some_and(|t| t == filter_type)
144    })
145}
146
147/// Parses a string field from a JSON value.
148fn parse_filter_string(filter: &Value, field: &str) -> anyhow::Result<String> {
149    filter
150        .get(field)
151        .and_then(|v| v.as_str())
152        .map(String::from)
153        .ok_or_else(|| anyhow::anyhow!("Missing field '{field}' in filter"))
154}
155
156/// Parses a Price from a filter field.
157fn parse_filter_price(filter: &Value, field: &str) -> anyhow::Result<Price> {
158    let value = parse_filter_string(filter, field)?;
159    Price::from_str(&value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
160}
161
162/// Parses a Quantity from a filter field.
163fn parse_filter_quantity(filter: &Value, field: &str) -> anyhow::Result<Quantity> {
164    let value = parse_filter_string(filter, field)?;
165    Quantity::from_str(&value)
166        .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
167}
168
169/// Parses the futures `MIN_NOTIONAL` filter into a `Money` value in `currency`.
170///
171/// Returns `None` when the filter is absent, the `notional` field cannot be
172/// parsed, or the value is non-positive.
173fn parse_futures_min_notional(filters: &[Value], currency: Currency) -> Option<Money> {
174    let filter = get_filter(filters, "MIN_NOTIONAL")?;
175    let raw = filter.get("notional").and_then(|v| v.as_str())?;
176    let amount = f64::from_str(raw).ok()?;
177    if amount <= 0.0 {
178        return None;
179    }
180    Some(Money::new(amount, currency))
181}
182
183/// Parses a venue quantity string into a `Quantity` at the given precision.
184///
185/// Returns `None` for unparsable, zero, or negative values. Goes through
186/// `Decimal` so equality comparisons against domain quantities are exact and
187/// independent of `f64` rounding.
188#[must_use]
189pub(crate) fn parse_quantity_at_precision(raw: &str, precision: u8) -> Option<Quantity> {
190    let decimal = Decimal::from_str(raw).ok()?;
191    if !decimal.is_sign_positive() || decimal.is_zero() {
192        return None;
193    }
194
195    Quantity::from_decimal_dp(decimal, precision).ok()
196}
197
198/// Parses a venue price string into a `Price` at the given precision.
199///
200/// Returns `None` for unparsable, zero, or negative values. Goes through
201/// `Decimal` for exact comparison semantics.
202#[must_use]
203pub(crate) fn parse_price_at_precision(raw: &str, precision: u8) -> Option<Price> {
204    let decimal = Decimal::from_str(raw).ok()?;
205    if !decimal.is_sign_positive() || decimal.is_zero() {
206        return None;
207    }
208
209    Price::from_decimal_dp(decimal, precision).ok()
210}
211
212/// Parses a required venue decimal string.
213pub(crate) fn parse_required_decimal(raw: &str, field: &str) -> anyhow::Result<Decimal> {
214    Decimal::from_str(raw).map_err(|e| anyhow::anyhow!("invalid {field}='{raw}': {e}"))
215}
216
217/// Parses a required venue quantity string into a `Quantity` at the given precision.
218pub(crate) fn parse_required_quantity_at_precision(
219    raw: &str,
220    precision: u8,
221    field: &str,
222) -> anyhow::Result<Quantity> {
223    let decimal = parse_required_decimal(raw, field)?;
224    Quantity::from_decimal_dp(decimal, precision)
225        .map_err(|e| anyhow::anyhow!("invalid {field}='{raw}' at precision {precision}: {e}"))
226}
227
228/// Parses a required venue price string into a `Price` at the given precision.
229pub(crate) fn parse_required_price_at_precision(
230    raw: &str,
231    precision: u8,
232    field: &str,
233) -> anyhow::Result<Price> {
234    let decimal = parse_required_decimal(raw, field)?;
235    Price::from_decimal_dp(decimal, precision)
236        .map_err(|e| anyhow::anyhow!("invalid {field}='{raw}' at precision {precision}: {e}"))
237}
238
239/// Re-precisions an existing `Quantity` to the given precision via `Decimal`.
240#[must_use]
241pub(crate) fn quantity_at_precision(quantity: Quantity, precision: u8) -> Option<Quantity> {
242    Quantity::from_decimal_dp(quantity.as_decimal(), precision).ok()
243}
244
245/// Re-precisions an existing `Price` to the given precision via `Decimal`.
246#[must_use]
247pub(crate) fn price_at_precision(price: Price, precision: u8) -> Option<Price> {
248    Price::from_decimal_dp(price.as_decimal(), precision).ok()
249}
250
251/// Parses a USD-M Futures symbol definition into a Nautilus futures instrument.
252///
253/// # Errors
254///
255/// Returns an error if:
256/// - Required filter values are missing (PRICE_FILTER, LOT_SIZE).
257/// - Price or quantity values cannot be parsed.
258/// - The contract type is not a supported perpetual or delivery contract.
259/// - A TRADIFI_PERPETUAL underlying type is missing or unsupported.
260pub fn parse_usdm_instrument(
261    symbol: &BinanceFuturesUsdSymbol,
262    ts_event: UnixNanos,
263    ts_init: UnixNanos,
264) -> anyhow::Result<InstrumentAny> {
265    parse_usdm_instrument_with_fees(symbol, None, None, ts_event, ts_init)
266}
267
268pub(crate) fn parse_usdm_instrument_with_fees(
269    symbol: &BinanceFuturesUsdSymbol,
270    maker_fee: Option<Decimal>,
271    taker_fee: Option<Decimal>,
272    ts_event: UnixNanos,
273    ts_init: UnixNanos,
274) -> anyhow::Result<InstrumentAny> {
275    enum ContractKind {
276        CryptoPerpetual,
277        TradFi(AssetClass),
278        Delivery,
279    }
280
281    let contract_kind = match symbol.contract_type.as_str() {
282        CONTRACT_TYPE_PERPETUAL => ContractKind::CryptoPerpetual,
283        CONTRACT_TYPE_TRADIFI_PERPETUAL => ContractKind::TradFi(parse_tradifi_asset_class(symbol)?),
284        CONTRACT_TYPE_CURRENT_MONTH
285        | CONTRACT_TYPE_NEXT_MONTH
286        | CONTRACT_TYPE_CURRENT_QUARTER
287        | CONTRACT_TYPE_NEXT_QUARTER => ContractKind::Delivery,
288        _ => anyhow::bail!(
289            "Unsupported USD-M contract type '{}' for symbol '{}'",
290            symbol.contract_type,
291            symbol.symbol,
292        ),
293    };
294
295    if symbol.status != BinanceTradingStatus::Trading {
296        anyhow::bail!(
297            "Symbol '{}' is not trading (status: {:?})",
298            symbol.symbol,
299            symbol.status
300        );
301    }
302
303    let quote_currency = get_currency(symbol.quote_asset.as_str());
304    let settlement_currency = get_currency(symbol.margin_asset.as_str());
305
306    let instrument_id = format_instrument_id(&symbol.symbol, BinanceProductType::UsdM);
307    let raw_symbol = Symbol::new(symbol.symbol.as_str());
308
309    let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
310        .context("Missing PRICE_FILTER in symbol filters")?;
311
312    let tick_size = parse_filter_price(price_filter, "tickSize")?;
313    if tick_size.is_zero() {
314        anyhow::bail!(
315            "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
316            symbol.symbol,
317        );
318    }
319    let max_price = parse_filter_price(price_filter, "maxPrice").ok();
320    let min_price = parse_filter_price(price_filter, "minPrice").ok();
321
322    let lot_filter =
323        get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
324
325    let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
326    let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
327    let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
328
329    let min_notional = parse_futures_min_notional(&symbol.filters, quote_currency);
330
331    // Default margin (0.1 = 10x leverage)
332    let default_margin = Decimal::new(1, 1);
333
334    match contract_kind {
335        ContractKind::TradFi(asset_class) => {
336            let instrument = PerpetualContract::builder()
337                .instrument_id(instrument_id)
338                .raw_symbol(raw_symbol)
339                .underlying(symbol.base_asset)
340                .asset_class(asset_class)
341                .quote_currency(quote_currency)
342                .settlement_currency(settlement_currency)
343                .is_inverse(false)
344                .price_precision(tick_size.precision)
345                .size_precision(step_size.precision)
346                .price_increment(tick_size)
347                .size_increment(step_size)
348                .lot_size(step_size)
349                .maybe_max_quantity(max_quantity)
350                .maybe_min_quantity(min_quantity)
351                .maybe_min_notional(min_notional)
352                .maybe_max_price(max_price)
353                .maybe_min_price(min_price)
354                .margin_init(default_margin)
355                .margin_maint(default_margin)
356                .maybe_maker_fee(maker_fee)
357                .maybe_taker_fee(taker_fee)
358                .ts_event(ts_event)
359                .ts_init(ts_init)
360                .build()?;
361            Ok(InstrumentAny::PerpetualContract(instrument))
362        }
363        ContractKind::CryptoPerpetual => {
364            let instrument = CryptoPerpetual::builder()
365                .instrument_id(instrument_id)
366                .raw_symbol(raw_symbol)
367                .base_currency(get_currency(symbol.base_asset.as_str()))
368                .quote_currency(quote_currency)
369                .settlement_currency(settlement_currency)
370                .is_inverse(false)
371                .price_precision(tick_size.precision)
372                .size_precision(step_size.precision)
373                .price_increment(tick_size)
374                .size_increment(step_size)
375                .lot_size(step_size)
376                .maybe_max_quantity(max_quantity)
377                .maybe_min_quantity(min_quantity)
378                .maybe_min_notional(min_notional)
379                .maybe_max_price(max_price)
380                .maybe_min_price(min_price)
381                .margin_init(default_margin)
382                .margin_maint(default_margin)
383                .maybe_maker_fee(maker_fee)
384                .maybe_taker_fee(taker_fee)
385                .ts_event(ts_event)
386                .ts_init(ts_init)
387                .build()
388                .unwrap();
389            Ok(InstrumentAny::CryptoPerpetual(instrument))
390        }
391        ContractKind::Delivery => {
392            let activation_ns = parse_millis(symbol.onboard_date, "Futures onboardDate")?;
393            let expiration_ns = parse_millis(symbol.delivery_date, "Futures deliveryDate")?;
394            let instrument = CryptoFuture::builder()
395                .instrument_id(instrument_id)
396                .raw_symbol(raw_symbol)
397                .underlying(get_currency(symbol.base_asset.as_str()))
398                .quote_currency(quote_currency)
399                .settlement_currency(settlement_currency)
400                .is_inverse(false)
401                .activation_ns(activation_ns)
402                .expiration_ns(expiration_ns)
403                .price_precision(tick_size.precision)
404                .size_precision(step_size.precision)
405                .price_increment(tick_size)
406                .size_increment(step_size)
407                .lot_size(step_size)
408                .maybe_max_quantity(max_quantity)
409                .maybe_min_quantity(min_quantity)
410                .maybe_min_notional(min_notional)
411                .maybe_max_price(max_price)
412                .maybe_min_price(min_price)
413                .margin_init(default_margin)
414                .margin_maint(default_margin)
415                .maybe_maker_fee(maker_fee)
416                .maybe_taker_fee(taker_fee)
417                .ts_event(ts_event)
418                .ts_init(ts_init)
419                .build()
420                .unwrap();
421            Ok(InstrumentAny::CryptoFuture(instrument))
422        }
423    }
424}
425
426/// Parses a COIN-M Futures symbol definition into a Nautilus crypto futures instrument.
427///
428/// COIN-M perpetuals are inverse contracts settled in base currency (e.g., BTC).
429///
430/// # Errors
431///
432/// Returns an error if:
433/// - Required filter values are missing (PRICE_FILTER, LOT_SIZE).
434/// - Price or quantity values cannot be parsed.
435/// - The contract type is not a supported perpetual or quarterly delivery contract.
436/// - The contract is not in TRADING status.
437pub fn parse_coinm_instrument(
438    symbol: &BinanceFuturesCoinSymbol,
439    ts_event: UnixNanos,
440    ts_init: UnixNanos,
441) -> anyhow::Result<InstrumentAny> {
442    parse_coinm_instrument_with_fees(symbol, None, None, ts_event, ts_init)
443}
444
445pub(crate) fn parse_coinm_instrument_with_fees(
446    symbol: &BinanceFuturesCoinSymbol,
447    maker_fee: Option<Decimal>,
448    taker_fee: Option<Decimal>,
449    ts_event: UnixNanos,
450    ts_init: UnixNanos,
451) -> anyhow::Result<InstrumentAny> {
452    let is_perpetual = symbol.contract_type == CONTRACT_TYPE_PERPETUAL;
453    let is_delivery = matches!(
454        symbol.contract_type.as_str(),
455        CONTRACT_TYPE_CURRENT_QUARTER | CONTRACT_TYPE_NEXT_QUARTER
456    );
457
458    if !is_perpetual && !is_delivery {
459        anyhow::bail!(
460            "Unsupported COIN-M contract type '{}' for symbol '{}'",
461            symbol.contract_type,
462            symbol.symbol,
463        );
464    }
465
466    if symbol.contract_status != Some(BinanceContractStatus::Trading) {
467        anyhow::bail!(
468            "Symbol '{}' is not trading (status: {:?})",
469            symbol.symbol,
470            symbol.contract_status
471        );
472    }
473
474    let base_currency = get_currency(symbol.base_asset.as_str());
475    let quote_currency = get_currency(symbol.quote_asset.as_str());
476
477    // COIN-M contracts are settled in the base currency (inverse)
478    let settlement_currency = get_currency(symbol.margin_asset.as_str());
479
480    let instrument_id = format_instrument_id(&symbol.symbol, BinanceProductType::CoinM);
481    let raw_symbol = Symbol::new(symbol.symbol.as_str());
482
483    let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
484        .context("Missing PRICE_FILTER in symbol filters")?;
485
486    let tick_size = parse_filter_price(price_filter, "tickSize")?;
487    if tick_size.is_zero() {
488        anyhow::bail!(
489            "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
490            symbol.symbol,
491        );
492    }
493    let max_price = parse_filter_price(price_filter, "maxPrice").ok();
494    let min_price = parse_filter_price(price_filter, "minPrice").ok();
495
496    let lot_filter =
497        get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
498
499    let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
500    let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
501    let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
502
503    // COIN-M has contract_size as the multiplier
504    let multiplier = Quantity::from(symbol.contract_size);
505
506    let min_notional = parse_futures_min_notional(&symbol.filters, quote_currency);
507
508    // Default margin (0.1 = 10x leverage)
509    let default_margin = Decimal::new(1, 1);
510
511    if is_perpetual {
512        let instrument = CryptoPerpetual::builder()
513            .instrument_id(instrument_id)
514            .raw_symbol(raw_symbol)
515            .base_currency(base_currency)
516            .quote_currency(quote_currency)
517            .settlement_currency(settlement_currency)
518            .is_inverse(true)
519            .price_precision(tick_size.precision)
520            .size_precision(step_size.precision)
521            .price_increment(tick_size)
522            .size_increment(step_size)
523            .multiplier(multiplier)
524            .lot_size(step_size)
525            .maybe_max_quantity(max_quantity)
526            .maybe_min_quantity(min_quantity)
527            .maybe_min_notional(min_notional)
528            .maybe_max_price(max_price)
529            .maybe_min_price(min_price)
530            .margin_init(default_margin)
531            .margin_maint(default_margin)
532            .maybe_maker_fee(maker_fee)
533            .maybe_taker_fee(taker_fee)
534            .ts_event(ts_event)
535            .ts_init(ts_init)
536            .build()
537            .unwrap();
538        Ok(InstrumentAny::CryptoPerpetual(instrument))
539    } else {
540        let activation_ns = parse_millis(symbol.onboard_date, "Futures onboardDate")?;
541        let expiration_ns = parse_millis(symbol.delivery_date, "Futures deliveryDate")?;
542        let instrument = CryptoFuture::builder()
543            .instrument_id(instrument_id)
544            .raw_symbol(raw_symbol)
545            .underlying(base_currency)
546            .quote_currency(quote_currency)
547            .settlement_currency(settlement_currency)
548            .is_inverse(true)
549            .activation_ns(activation_ns)
550            .expiration_ns(expiration_ns)
551            .price_precision(tick_size.precision)
552            .size_precision(step_size.precision)
553            .price_increment(tick_size)
554            .size_increment(step_size)
555            .multiplier(multiplier)
556            .lot_size(step_size)
557            .maybe_max_quantity(max_quantity)
558            .maybe_min_quantity(min_quantity)
559            .maybe_min_notional(min_notional)
560            .maybe_max_price(max_price)
561            .maybe_min_price(min_price)
562            .margin_init(default_margin)
563            .margin_maint(default_margin)
564            .maybe_maker_fee(maker_fee)
565            .maybe_taker_fee(taker_fee)
566            .ts_event(ts_event)
567            .ts_init(ts_init)
568            .build()
569            .unwrap();
570        Ok(InstrumentAny::CryptoFuture(instrument))
571    }
572}
573
574/// SBE status value for Trading.
575const SBE_STATUS_TRADING: u8 = 0;
576
577/// Derives the number of significant decimal places from an SBE mantissa/exponent pair.
578///
579/// Binance SBE encodes values as `mantissa * 10^exponent` where `exponent` is a global
580/// fixed-point encoding parameter (typically -8), not the instrument's trading precision.
581/// The actual precision is determined by how many trailing zeros the mantissa carries.
582///
583/// # Examples
584///
585/// - ETHUSDC tick_size: mantissa=1_000_000, exp=-8 → 0.01 → precision=2
586/// - DOGEUSDT tick_size: mantissa=1_000, exp=-8 → 0.00001 → precision=5
587/// - SHIBUSDT tick_size: mantissa=1, exp=-8 → 0.00000001 → precision=8
588/// - BTCTRY tick_size: mantissa=100_000_000, exp=-8 → 1.0 → precision=0
589fn sbe_mantissa_precision(mantissa: i64, exponent: i8) -> u8 {
590    if mantissa == 0 {
591        return 0;
592    }
593    let mut m = mantissa.abs();
594    let mut trailing_zeros: i8 = 0;
595
596    while m > 0 && m % 10 == 0 {
597        m /= 10;
598        trailing_zeros += 1;
599    }
600    (-exponent - trailing_zeros).max(0) as u8
601}
602
603/// Parses an SBE price filter into tick_size, max_price, min_price.
604fn parse_sbe_price_filter(
605    filter: &BinancePriceFilterSbe,
606) -> anyhow::Result<(Price, Option<Price>, Option<Price>)> {
607    let precision = sbe_mantissa_precision(filter.tick_size, filter.price_exponent);
608
609    let tick_size =
610        Price::from_mantissa_exponent_checked(filter.tick_size, filter.price_exponent, precision)?;
611
612    let max_price = if filter.max_price != 0 {
613        Some(Price::from_mantissa_exponent_checked(
614            filter.max_price,
615            filter.price_exponent,
616            precision,
617        )?)
618    } else {
619        None
620    };
621
622    let min_price = if filter.min_price != 0 {
623        Some(Price::from_mantissa_exponent_checked(
624            filter.min_price,
625            filter.price_exponent,
626            precision,
627        )?)
628    } else {
629        None
630    };
631
632    Ok((tick_size, max_price, min_price))
633}
634
635/// Parses an SBE lot size filter into step_size, max_qty, min_qty.
636fn parse_sbe_lot_size_filter(
637    filter: &BinanceLotSizeFilterSbe,
638) -> anyhow::Result<(Quantity, Option<Quantity>, Option<Quantity>)> {
639    let precision = sbe_mantissa_precision(filter.step_size, filter.qty_exponent);
640
641    let step_size = Quantity::from_mantissa_exponent_checked(
642        filter.step_size as u64,
643        filter.qty_exponent,
644        precision,
645    )?;
646
647    let max_qty = if filter.max_qty != 0 {
648        Some(Quantity::from_mantissa_exponent_checked(
649            filter.max_qty as u64,
650            filter.qty_exponent,
651            precision,
652        )?)
653    } else {
654        None
655    };
656
657    let min_qty = if filter.min_qty != 0 {
658        Some(Quantity::from_mantissa_exponent_checked(
659            filter.min_qty as u64,
660            filter.qty_exponent,
661            precision,
662        )?)
663    } else {
664        None
665    };
666
667    Ok((step_size, max_qty, min_qty))
668}
669
670/// Parses a Binance Spot SBE symbol into a Nautilus CurrencyPair instrument.
671///
672/// # Errors
673///
674/// Returns an error if:
675/// - Required filter values are missing (PRICE_FILTER, LOT_SIZE).
676/// - Price or quantity values cannot be parsed.
677/// - The symbol is not actively trading.
678pub fn parse_spot_instrument_sbe(
679    symbol: &BinanceSymbolSbe,
680    ts_event: UnixNanos,
681    ts_init: UnixNanos,
682) -> anyhow::Result<InstrumentAny> {
683    parse_spot_instrument_sbe_with_fees(symbol, None, None, ts_event, ts_init)
684}
685
686pub(crate) fn parse_spot_instrument_sbe_with_fees(
687    symbol: &BinanceSymbolSbe,
688    maker_fee: Option<Decimal>,
689    taker_fee: Option<Decimal>,
690    ts_event: UnixNanos,
691    ts_init: UnixNanos,
692) -> anyhow::Result<InstrumentAny> {
693    if symbol.status != SBE_STATUS_TRADING {
694        anyhow::bail!(
695            "Symbol '{}' is not trading (status: {})",
696            symbol.symbol,
697            symbol.status
698        );
699    }
700
701    let base_currency = get_currency(&symbol.base_asset);
702    let quote_currency = get_currency(&symbol.quote_asset);
703
704    let instrument_id = InstrumentId::new(
705        Symbol::from_str_unchecked(&symbol.symbol),
706        Venue::new(BINANCE),
707    );
708    let raw_symbol = Symbol::new(&symbol.symbol);
709
710    let price_filter = symbol
711        .filters
712        .price_filter
713        .as_ref()
714        .context("Missing PRICE_FILTER in symbol filters")?;
715
716    let (tick_size, max_price, min_price) = parse_sbe_price_filter(price_filter)?;
717
718    let lot_filter = symbol
719        .filters
720        .lot_size_filter
721        .as_ref()
722        .context("Missing LOT_SIZE in symbol filters")?;
723
724    let (step_size, max_quantity, min_quantity) = parse_sbe_lot_size_filter(lot_filter)?;
725
726    // Spot has no leverage, use 1.0 margin
727    let default_margin = Decimal::new(1, 0);
728
729    let instrument = CurrencyPair::builder()
730        .instrument_id(instrument_id)
731        .raw_symbol(raw_symbol)
732        .base_currency(base_currency)
733        .quote_currency(quote_currency)
734        .price_precision(tick_size.precision)
735        .size_precision(step_size.precision)
736        .price_increment(tick_size)
737        .size_increment(step_size)
738        .lot_size(step_size)
739        .maybe_max_quantity(max_quantity)
740        .maybe_min_quantity(min_quantity)
741        .maybe_max_price(max_price)
742        .maybe_min_price(min_price)
743        .margin_init(default_margin)
744        .margin_maint(default_margin)
745        .maybe_maker_fee(maker_fee)
746        .maybe_taker_fee(taker_fee)
747        .ts_event(ts_event)
748        .ts_init(ts_init)
749        .build()
750        .unwrap();
751
752    Ok(InstrumentAny::CurrencyPair(instrument))
753}
754
755pub(crate) fn parse_spot_instrument_json_with_fees(
756    symbol: &BinanceSymbolJson,
757    maker_fee: Option<Decimal>,
758    taker_fee: Option<Decimal>,
759    ts_event: UnixNanos,
760    ts_init: UnixNanos,
761) -> anyhow::Result<InstrumentAny> {
762    anyhow::ensure!(
763        symbol.status == "TRADING",
764        "Symbol '{}' is not trading (status: {})",
765        symbol.symbol,
766        symbol.status,
767    );
768
769    let price_filter = symbol
770        .filters
771        .iter()
772        .find(|filter| filter.filter_type == "PRICE_FILTER")
773        .context("Missing PRICE_FILTER in symbol filters")?;
774    let lot_filter = symbol
775        .filters
776        .iter()
777        .find(|filter| filter.filter_type == "LOT_SIZE")
778        .context("Missing LOT_SIZE in symbol filters")?;
779
780    let tick_size = decimal_price(
781        price_filter
782            .tick_size
783            .as_deref()
784            .context("Missing PRICE_FILTER tickSize")?,
785    )?;
786    anyhow::ensure!(!tick_size.is_zero(), "Invalid tickSize of 0");
787    let step_size = decimal_quantity(
788        lot_filter
789            .step_size
790            .as_deref()
791            .context("Missing LOT_SIZE stepSize")?,
792    )?;
793    anyhow::ensure!(!step_size.is_zero(), "Invalid stepSize of 0");
794
795    let instrument = CurrencyPair::builder()
796        .instrument_id(InstrumentId::new(
797            Symbol::from_str_unchecked(&symbol.symbol),
798            Venue::new(BINANCE),
799        ))
800        .raw_symbol(Symbol::new(&symbol.symbol))
801        .base_currency(get_currency(&symbol.base_asset))
802        .quote_currency(get_currency(&symbol.quote_asset))
803        .price_precision(tick_size.precision)
804        .size_precision(step_size.precision)
805        .price_increment(tick_size)
806        .size_increment(step_size)
807        .lot_size(step_size)
808        .maybe_max_quantity(optional_decimal_quantity(
809            lot_filter.max_qty.as_deref(),
810            step_size.precision,
811        )?)
812        .maybe_min_quantity(optional_decimal_quantity(
813            lot_filter.min_qty.as_deref(),
814            step_size.precision,
815        )?)
816        .maybe_max_price(optional_decimal_price(
817            price_filter.max_price.as_deref(),
818            tick_size.precision,
819        )?)
820        .maybe_min_price(optional_decimal_price(
821            price_filter.min_price.as_deref(),
822            tick_size.precision,
823        )?)
824        .margin_init(Decimal::ONE)
825        .margin_maint(Decimal::ONE)
826        .maybe_maker_fee(maker_fee)
827        .maybe_taker_fee(taker_fee)
828        .ts_event(ts_event)
829        .ts_init(ts_init)
830        .build()
831        .unwrap();
832
833    Ok(InstrumentAny::CurrencyPair(instrument))
834}
835
836fn decimal_price(value: &str) -> anyhow::Result<Price> {
837    let decimal = Decimal::from_str_exact(value)?.normalize();
838    let precision = u8::try_from(decimal.scale()).context("price precision exceeds u8")?;
839    Ok(Price::from_decimal_dp(decimal, precision)?)
840}
841
842fn decimal_quantity(value: &str) -> anyhow::Result<Quantity> {
843    let decimal = Decimal::from_str_exact(value)?.normalize();
844    let precision = u8::try_from(decimal.scale()).context("quantity precision exceeds u8")?;
845    Ok(Quantity::from_decimal_dp(decimal, precision)?)
846}
847
848fn optional_decimal_price(value: Option<&str>, precision: u8) -> anyhow::Result<Option<Price>> {
849    let Some(value) = value else {
850        return Ok(None);
851    };
852    let decimal = Decimal::from_str_exact(value)?;
853    if decimal.is_zero() {
854        return Ok(None);
855    }
856    Ok(Some(Price::from_decimal_dp(decimal, precision)?))
857}
858
859fn optional_decimal_quantity(
860    value: Option<&str>,
861    precision: u8,
862) -> anyhow::Result<Option<Quantity>> {
863    let Some(value) = value else {
864        return Ok(None);
865    };
866    let decimal = Decimal::from_str_exact(value)?;
867    if decimal.is_zero() {
868        return Ok(None);
869    }
870    Ok(Some(Quantity::from_decimal_dp(decimal, precision)?))
871}
872
873/// Parses Binance SBE trades into Nautilus TradeTick objects.
874///
875/// Uses mantissa/exponent encoding from SBE to construct proper Price and Quantity.
876///
877/// # Errors
878///
879/// Returns an error if any trade cannot be parsed.
880pub fn parse_spot_trades_sbe(
881    trades: &BinanceTrades,
882    instrument: &InstrumentAny,
883    ts_init: UnixNanos,
884) -> anyhow::Result<Vec<TradeTick>> {
885    let instrument_id = instrument.id();
886    let price_precision = instrument.price_precision();
887    let size_precision = instrument.size_precision();
888
889    let mut result = Vec::with_capacity(trades.trades.len());
890
891    for trade in &trades.trades {
892        let price = Price::from_mantissa_exponent(
893            trade.price_mantissa,
894            trades.price_exponent,
895            price_precision,
896        );
897        let size = Quantity::from_mantissa_exponent(
898            trade.qty_mantissa as u64,
899            trades.qty_exponent,
900            size_precision,
901        );
902
903        // is_buyer_maker means the buyer was the maker, so the aggressor was selling
904        let aggressor_side = if trade.is_buyer_maker {
905            AggressorSide::Sell
906        } else {
907            AggressorSide::Buy
908        };
909
910        // SBE trade timestamps are in microseconds
911        let ts_event = parse_micros(trade.time, "Spot SBE trade time")?;
912
913        let tick = TradeTick::new(
914            instrument_id,
915            price,
916            size,
917            aggressor_side,
918            TradeId::new(trade.id.to_string()),
919            ts_event,
920            ts_init,
921        );
922
923        result.push(tick);
924    }
925
926    Ok(result)
927}
928
929/// Maps Binance SBE order status to Nautilus order status.
930#[must_use]
931pub const fn map_order_status_sbe(status: SbeOrderStatus) -> OrderStatus {
932    match status {
933        SbeOrderStatus::New => OrderStatus::Accepted,
934        SbeOrderStatus::PendingNew => OrderStatus::Submitted,
935        SbeOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
936        SbeOrderStatus::Filled => OrderStatus::Filled,
937        SbeOrderStatus::Canceled => OrderStatus::Canceled,
938        SbeOrderStatus::PendingCancel => OrderStatus::PendingCancel,
939        SbeOrderStatus::Rejected => OrderStatus::Rejected,
940        SbeOrderStatus::Expired | SbeOrderStatus::ExpiredInMatch => OrderStatus::Expired,
941        SbeOrderStatus::Unknown | SbeOrderStatus::NonRepresentable | SbeOrderStatus::NullVal => {
942            OrderStatus::Initialized
943        }
944    }
945}
946
947/// Maps Binance SBE order type to Nautilus order type.
948#[must_use]
949pub const fn map_order_type_sbe(order_type: SbeOrderType) -> OrderType {
950    match order_type {
951        SbeOrderType::Market => OrderType::Market,
952        SbeOrderType::Limit | SbeOrderType::LimitMaker => OrderType::Limit,
953        SbeOrderType::StopLoss | SbeOrderType::TakeProfit => OrderType::StopMarket,
954        SbeOrderType::StopLossLimit | SbeOrderType::TakeProfitLimit => OrderType::StopLimit,
955        SbeOrderType::NonRepresentable | SbeOrderType::NullVal => OrderType::Market,
956    }
957}
958
959/// Maps Binance SBE order side to Nautilus order side.
960#[must_use]
961pub const fn map_order_side_sbe(side: SbeOrderSide) -> Option<OrderSide> {
962    match side {
963        SbeOrderSide::Buy => Some(OrderSide::Buy),
964        SbeOrderSide::Sell => Some(OrderSide::Sell),
965        SbeOrderSide::NonRepresentable | SbeOrderSide::NullVal => None,
966    }
967}
968
969/// Maps Binance SBE time in force to Nautilus time in force.
970#[must_use]
971pub const fn map_time_in_force_sbe(tif: SbeTimeInForce) -> TimeInForce {
972    match tif {
973        SbeTimeInForce::Gtc => TimeInForce::Gtc,
974        SbeTimeInForce::Ioc => TimeInForce::Ioc,
975        SbeTimeInForce::Fok => TimeInForce::Fok,
976        SbeTimeInForce::NonRepresentable | SbeTimeInForce::NullVal => TimeInForce::Gtc,
977    }
978}
979
980/// Parses a Binance SBE order response into a Nautilus `OrderStatusReport`.
981///
982/// # Errors
983///
984/// Returns an error if any field cannot be parsed.
985pub fn parse_order_status_report_sbe(
986    order: &BinanceOrderResponse,
987    account_id: AccountId,
988    instrument: &InstrumentAny,
989    broker_id: &str,
990    ts_init: UnixNanos,
991) -> anyhow::Result<OrderStatusReport> {
992    let instrument_id = instrument.id();
993    let price_precision = instrument.price_precision();
994    let size_precision = instrument.size_precision();
995
996    let price = if order.price_mantissa != 0 {
997        Some(Price::from_mantissa_exponent(
998            order.price_mantissa,
999            order.price_exponent,
1000            price_precision,
1001        ))
1002    } else {
1003        None
1004    };
1005
1006    let quantity = Quantity::from_mantissa_exponent(
1007        order.orig_qty_mantissa as u64,
1008        order.qty_exponent,
1009        size_precision,
1010    );
1011    let filled_qty = Quantity::from_mantissa_exponent(
1012        order.executed_qty_mantissa as u64,
1013        order.qty_exponent,
1014        size_precision,
1015    );
1016
1017    // Calculate average price from cumulative quote qty / executed qty
1018    // This requires decimal arithmetic since we're dividing two mantissas
1019    let avg_px = if order.executed_qty_mantissa > 0 {
1020        let quote_exp = (order.price_exponent as i32) + (order.qty_exponent as i32);
1021        let cum_quote_dec = Decimal::new(order.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
1022        let filled_dec = Decimal::new(
1023            order.executed_qty_mantissa,
1024            (-order.qty_exponent as i32) as u32,
1025        );
1026        let avg_dec = cum_quote_dec / filled_dec;
1027        Some(
1028            Price::from_decimal_dp(avg_dec, price_precision)
1029                .unwrap_or(Price::zero(price_precision)),
1030        )
1031    } else {
1032        None
1033    };
1034
1035    // Parse trigger price for stop orders
1036    let trigger_price = order.stop_price_mantissa.and_then(|mantissa| {
1037        if mantissa != 0 {
1038            Some(Price::from_mantissa_exponent(
1039                mantissa,
1040                order.price_exponent,
1041                price_precision,
1042            ))
1043        } else {
1044            None
1045        }
1046    });
1047
1048    // Map enums
1049    let order_status = map_order_status_sbe(order.status);
1050    let order_type = map_order_type_sbe(order.order_type);
1051    let order_side = map_order_side_sbe(order.side);
1052    let time_in_force = map_time_in_force_sbe(order.time_in_force);
1053
1054    // Determine trigger type for stop orders
1055    let trigger_type = if trigger_price.is_some() {
1056        Some(TriggerType::LastPrice)
1057    } else {
1058        None
1059    };
1060
1061    // Parse timestamps (SBE uses microseconds)
1062    let ts_event = parse_micros(order.update_time, "Spot SBE order update time")?;
1063
1064    // Build order list ID if present
1065    let order_list_id = order.order_list_id.and_then(|id| {
1066        if id > 0 {
1067            Some(OrderListId::new(id.to_string()))
1068        } else {
1069            None
1070        }
1071    });
1072
1073    // Determine post-only (limit maker orders are post-only)
1074    let post_only = order.order_type == SbeOrderType::LimitMaker;
1075
1076    // Parse order creation time (SBE uses microseconds)
1077    let ts_accepted = parse_micros(order.time, "Spot SBE order time")?;
1078
1079    let mut report = OrderStatusReport::new(
1080        account_id,
1081        instrument_id,
1082        Some(decode_client_order_id(&order.client_order_id, broker_id)?),
1083        VenueOrderId::new(order.order_id.to_string()),
1084        order_side,
1085        order_type,
1086        time_in_force,
1087        order_status,
1088        quantity,
1089        filled_qty,
1090        ts_accepted,
1091        ts_event,
1092        ts_init,
1093        None, // report_id (auto-generated)
1094    );
1095
1096    // Apply optional fields using builder methods
1097    if let Some(p) = price {
1098        report = report.with_price(p);
1099    }
1100
1101    if let Some(ap) = avg_px {
1102        report = report.with_avg_px(ap.as_decimal());
1103    }
1104
1105    if let Some(tp) = trigger_price {
1106        report = report.with_trigger_price(tp);
1107    }
1108
1109    if let Some(tt) = trigger_type {
1110        report = report.with_trigger_type(tt);
1111    }
1112
1113    if let Some(oli) = order_list_id {
1114        report = report.with_order_list_id(oli);
1115    }
1116
1117    if post_only {
1118        report = report.with_post_only(true);
1119    }
1120
1121    Ok(report)
1122}
1123
1124/// Parses a Binance new order response (SBE) into a Nautilus `OrderStatusReport`.
1125///
1126/// # Errors
1127///
1128/// Returns an error if any field cannot be parsed.
1129pub fn parse_new_order_response_sbe(
1130    response: &BinanceNewOrderResponse,
1131    account_id: AccountId,
1132    instrument: &InstrumentAny,
1133    broker_id: &str,
1134    ts_init: UnixNanos,
1135) -> anyhow::Result<OrderStatusReport> {
1136    let instrument_id = instrument.id();
1137    let price_precision = instrument.price_precision();
1138    let size_precision = instrument.size_precision();
1139
1140    let price = if response.price_mantissa != 0 {
1141        Some(Price::from_mantissa_exponent(
1142            response.price_mantissa,
1143            response.price_exponent,
1144            price_precision,
1145        ))
1146    } else {
1147        None
1148    };
1149
1150    let quantity = Quantity::from_mantissa_exponent(
1151        response.orig_qty_mantissa as u64,
1152        response.qty_exponent,
1153        size_precision,
1154    );
1155    let filled_qty = Quantity::from_mantissa_exponent(
1156        response.executed_qty_mantissa as u64,
1157        response.qty_exponent,
1158        size_precision,
1159    );
1160
1161    // Calculate average price from cumulative quote qty / executed qty
1162    // This requires decimal arithmetic since we're dividing two mantissas
1163    let avg_px = if response.executed_qty_mantissa > 0 {
1164        let quote_exp = (response.price_exponent as i32) + (response.qty_exponent as i32);
1165        let cum_quote_dec =
1166            Decimal::new(response.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
1167        let filled_dec = Decimal::new(
1168            response.executed_qty_mantissa,
1169            (-response.qty_exponent as i32) as u32,
1170        );
1171        let avg_dec = cum_quote_dec / filled_dec;
1172        Some(
1173            Price::from_decimal_dp(avg_dec, price_precision)
1174                .unwrap_or(Price::zero(price_precision)),
1175        )
1176    } else {
1177        None
1178    };
1179
1180    let trigger_price = response.stop_price_mantissa.and_then(|mantissa| {
1181        if mantissa != 0 {
1182            Some(Price::from_mantissa_exponent(
1183                mantissa,
1184                response.price_exponent,
1185                price_precision,
1186            ))
1187        } else {
1188            None
1189        }
1190    });
1191
1192    let order_status = map_order_status_sbe(response.status);
1193    let order_type = map_order_type_sbe(response.order_type);
1194    let order_side = map_order_side_sbe(response.side);
1195    let time_in_force = map_time_in_force_sbe(response.time_in_force);
1196
1197    let trigger_type = if trigger_price.is_some() {
1198        Some(TriggerType::LastPrice)
1199    } else {
1200        None
1201    };
1202
1203    // SBE uses microseconds; for new orders transact_time is both creation and event time
1204    let ts_event = parse_micros(response.transact_time, "Spot SBE transaction time")?;
1205    let ts_accepted = ts_event;
1206
1207    let order_list_id = response.order_list_id.and_then(|id| {
1208        if id > 0 {
1209            Some(OrderListId::new(id.to_string()))
1210        } else {
1211            None
1212        }
1213    });
1214
1215    // Limit maker orders are post-only
1216    let post_only = response.order_type == SbeOrderType::LimitMaker;
1217
1218    let mut report = OrderStatusReport::new(
1219        account_id,
1220        instrument_id,
1221        Some(decode_client_order_id(
1222            &response.client_order_id,
1223            broker_id,
1224        )?),
1225        VenueOrderId::new(response.order_id.to_string()),
1226        order_side,
1227        order_type,
1228        time_in_force,
1229        order_status,
1230        quantity,
1231        filled_qty,
1232        ts_accepted,
1233        ts_event,
1234        ts_init,
1235        None,
1236    );
1237
1238    if let Some(p) = price {
1239        report = report.with_price(p);
1240    }
1241
1242    if let Some(ap) = avg_px {
1243        report = report.with_avg_px(ap.as_decimal());
1244    }
1245
1246    if let Some(tp) = trigger_price {
1247        report = report.with_trigger_price(tp);
1248    }
1249
1250    if let Some(tt) = trigger_type {
1251        report = report.with_trigger_type(tt);
1252    }
1253
1254    if let Some(oli) = order_list_id {
1255        report = report.with_order_list_id(oli);
1256    }
1257
1258    if post_only {
1259        report = report.with_post_only(true);
1260    }
1261
1262    Ok(report)
1263}
1264
1265/// Parses a Binance SBE account trade into a Nautilus `FillReport`.
1266///
1267/// # Errors
1268///
1269/// Returns an error if any field cannot be parsed.
1270pub fn parse_fill_report_sbe(
1271    trade: &BinanceAccountTrade,
1272    account_id: AccountId,
1273    instrument: &InstrumentAny,
1274    commission_currency: Currency,
1275    ts_init: UnixNanos,
1276) -> anyhow::Result<FillReport> {
1277    let instrument_id = instrument.id();
1278    let price_precision = instrument.price_precision();
1279    let size_precision = instrument.size_precision();
1280
1281    let last_px =
1282        Price::from_mantissa_exponent(trade.price_mantissa, trade.price_exponent, price_precision);
1283    let last_qty = Quantity::from_mantissa_exponent(
1284        trade.qty_mantissa as u64,
1285        trade.qty_exponent,
1286        size_precision,
1287    );
1288
1289    let comm_exp = trade.commission_exponent as i32;
1290    let comm_dec = Decimal::new(trade.commission_mantissa, (-comm_exp) as u32);
1291    let commission = Money::from_decimal(comm_dec, commission_currency)?;
1292
1293    // Determine order side from is_buyer
1294    let order_side = if trade.is_buyer {
1295        OrderSide::Buy
1296    } else {
1297        OrderSide::Sell
1298    };
1299
1300    // Determine liquidity side from is_maker
1301    let liquidity_side = if trade.is_maker {
1302        LiquiditySide::Maker
1303    } else {
1304        LiquiditySide::Taker
1305    };
1306
1307    // Parse timestamp (SBE uses microseconds)
1308    let ts_event = parse_micros(trade.time, "Spot SBE account trade time")?;
1309
1310    Ok(FillReport::new(
1311        account_id,
1312        instrument_id,
1313        VenueOrderId::new(trade.order_id.to_string()),
1314        TradeId::new(trade.id.to_string()),
1315        order_side,
1316        last_qty,
1317        last_px,
1318        commission,
1319        liquidity_side,
1320        None, // client_order_id (not in account trades response)
1321        None, // venue_position_id
1322        ts_event,
1323        ts_init,
1324        None, // report_id
1325    ))
1326}
1327
1328/// Parses Binance klines (candlesticks) into Nautilus Bar objects.
1329///
1330/// # Errors
1331///
1332/// Returns an error if any kline cannot be parsed.
1333pub fn parse_klines_to_binance_bars(
1334    klines: &BinanceKlines,
1335    bar_type: BarType,
1336    instrument: &InstrumentAny,
1337    ts_init: UnixNanos,
1338) -> anyhow::Result<Vec<crate::common::bar::BinanceBar>> {
1339    let price_precision = instrument.price_precision();
1340    let size_precision = instrument.size_precision();
1341
1342    let mut bars = Vec::with_capacity(klines.klines.len());
1343
1344    for kline in &klines.klines {
1345        let open =
1346            Price::from_mantissa_exponent(kline.open_price, klines.price_exponent, price_precision);
1347        let high =
1348            Price::from_mantissa_exponent(kline.high_price, klines.price_exponent, price_precision);
1349        let low =
1350            Price::from_mantissa_exponent(kline.low_price, klines.price_exponent, price_precision);
1351        let close = Price::from_mantissa_exponent(
1352            kline.close_price,
1353            klines.price_exponent,
1354            price_precision,
1355        );
1356
1357        let volume_mantissa = i128::from_le_bytes(kline.volume);
1358        let volume_dec =
1359            Decimal::from_i128_with_scale(volume_mantissa, (-klines.qty_exponent as i32) as u32);
1360        let volume = Quantity::from_decimal_dp(volume_dec, size_precision)?;
1361
1362        let quote_volume = Decimal::from_i128_with_scale(
1363            i128::from_le_bytes(kline.quote_volume),
1364            (-klines.price_exponent as i32) as u32,
1365        );
1366        let taker_buy_base_volume = Decimal::from_i128_with_scale(
1367            i128::from_le_bytes(kline.taker_buy_base_volume),
1368            (-klines.qty_exponent as i32) as u32,
1369        );
1370        let taker_buy_quote_volume = Decimal::from_i128_with_scale(
1371            i128::from_le_bytes(kline.taker_buy_quote_volume),
1372            (-klines.price_exponent as i32) as u32,
1373        );
1374        let count = u64::try_from(kline.num_trades).map_err(|_| {
1375            anyhow::anyhow!("invalid negative kline trade count {}", kline.num_trades)
1376        })?;
1377        let ts_event = parse_micros(kline.close_time, "Spot SBE kline close time")?;
1378
1379        let bar = crate::common::bar::BinanceBar::new(
1380            bar_type,
1381            open,
1382            high,
1383            low,
1384            close,
1385            volume,
1386            quote_volume,
1387            count,
1388            taker_buy_base_volume,
1389            taker_buy_quote_volume,
1390            ts_event,
1391            ts_init,
1392        );
1393        bars.push(bar);
1394    }
1395
1396    Ok(bars)
1397}
1398
1399/// Parses Binance SBE klines into core bars.
1400///
1401/// # Errors
1402///
1403/// Returns an error if any kline cannot be parsed.
1404pub fn parse_klines_to_bars(
1405    klines: &BinanceKlines,
1406    bar_type: BarType,
1407    instrument: &InstrumentAny,
1408    ts_init: UnixNanos,
1409) -> anyhow::Result<Vec<Bar>> {
1410    Ok(
1411        parse_klines_to_binance_bars(klines, bar_type, instrument, ts_init)?
1412            .into_iter()
1413            .map(|bar| bar.bar())
1414            .collect(),
1415    )
1416}
1417
1418/// Converts a Nautilus bar specification to a Binance kline interval.
1419///
1420/// # Errors
1421///
1422/// Returns an error if the bar specification does not map to a supported
1423/// Binance kline interval.
1424pub fn bar_spec_to_binance_interval(
1425    bar_spec: BarSpecification,
1426) -> anyhow::Result<BinanceKlineInterval> {
1427    let step = bar_spec.step.get();
1428    let interval = match bar_spec.aggregation {
1429        BarAggregation::Second => match step {
1430            1 => BinanceKlineInterval::Second1,
1431            _ => anyhow::bail!("Unsupported second interval: {step}s"),
1432        },
1433        BarAggregation::Minute => match step {
1434            1 => BinanceKlineInterval::Minute1,
1435            3 => BinanceKlineInterval::Minute3,
1436            5 => BinanceKlineInterval::Minute5,
1437            15 => BinanceKlineInterval::Minute15,
1438            30 => BinanceKlineInterval::Minute30,
1439            _ => anyhow::bail!("Unsupported minute interval: {step}m"),
1440        },
1441        BarAggregation::Hour => match step {
1442            1 => BinanceKlineInterval::Hour1,
1443            2 => BinanceKlineInterval::Hour2,
1444            4 => BinanceKlineInterval::Hour4,
1445            6 => BinanceKlineInterval::Hour6,
1446            8 => BinanceKlineInterval::Hour8,
1447            12 => BinanceKlineInterval::Hour12,
1448            _ => anyhow::bail!("Unsupported hour interval: {step}h"),
1449        },
1450        BarAggregation::Day => match step {
1451            1 => BinanceKlineInterval::Day1,
1452            3 => BinanceKlineInterval::Day3,
1453            _ => anyhow::bail!("Unsupported day interval: {step}d"),
1454        },
1455        BarAggregation::Week => match step {
1456            1 => BinanceKlineInterval::Week1,
1457            _ => anyhow::bail!("Unsupported week interval: {step}w"),
1458        },
1459        BarAggregation::Month => match step {
1460            1 => BinanceKlineInterval::Month1,
1461            _ => anyhow::bail!("Unsupported month interval: {step}M"),
1462        },
1463        agg => anyhow::bail!("Unsupported bar aggregation for Binance: {agg:?}"),
1464    };
1465
1466    Ok(interval)
1467}
1468
1469pub(crate) fn quote_to_l1_deltas(quote: QuoteTick, sequence: u64) -> OrderBookDeltas {
1470    let bid_action = if quote.bid_size.is_zero() {
1471        BookAction::Delete
1472    } else {
1473        BookAction::Update
1474    };
1475    let ask_action = if quote.ask_size.is_zero() {
1476        BookAction::Delete
1477    } else {
1478        BookAction::Update
1479    };
1480    let bid = OrderBookDelta::new(
1481        quote.instrument_id,
1482        bid_action,
1483        BookOrder::new(OrderSide::Buy, quote.bid_price, quote.bid_size, 0),
1484        RecordFlag::F_MBP as u8,
1485        sequence,
1486        quote.ts_event,
1487        quote.ts_init,
1488    );
1489    let ask = OrderBookDelta::new(
1490        quote.instrument_id,
1491        ask_action,
1492        BookOrder::new(OrderSide::Sell, quote.ask_price, quote.ask_size, 0),
1493        RecordFlag::F_MBP as u8 | RecordFlag::F_LAST as u8,
1494        sequence,
1495        quote.ts_event,
1496        quote.ts_init,
1497    );
1498
1499    OrderBookDeltas::new(quote.instrument_id, vec![bid, ask])
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504    use nautilus_model::identifiers::ClientOrderId;
1505    use rstest::rstest;
1506    use rust_decimal_macros::dec;
1507    use serde_json::json;
1508    use ustr::Ustr;
1509
1510    use super::*;
1511    use crate::common::{
1512        consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
1513        enums::{BinanceContractStatus, BinanceTradingStatus},
1514    };
1515
1516    #[rstest]
1517    fn test_quote_to_l1_deltas_maps_all_fields() {
1518        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
1519        let ts_event = UnixNanos::from(1_700_000_000_000_000_001u64);
1520        let ts_init = UnixNanos::from(1_700_000_000_000_000_002u64);
1521        let quote = QuoteTick::new(
1522            instrument_id,
1523            Price::from("42000.01"),
1524            Price::from("42000.02"),
1525            Quantity::from("1.23456"),
1526            Quantity::from("2.34567"),
1527            ts_event,
1528            ts_init,
1529        );
1530        let expected = OrderBookDeltas::new(
1531            instrument_id,
1532            vec![
1533                OrderBookDelta::new(
1534                    instrument_id,
1535                    BookAction::Update,
1536                    BookOrder::new(
1537                        OrderSide::Buy,
1538                        Price::from("42000.01"),
1539                        Quantity::from("1.23456"),
1540                        0,
1541                    ),
1542                    RecordFlag::F_MBP as u8,
1543                    12345,
1544                    ts_event,
1545                    ts_init,
1546                ),
1547                OrderBookDelta::new(
1548                    instrument_id,
1549                    BookAction::Update,
1550                    BookOrder::new(
1551                        OrderSide::Sell,
1552                        Price::from("42000.02"),
1553                        Quantity::from("2.34567"),
1554                        0,
1555                    ),
1556                    RecordFlag::F_MBP as u8 | RecordFlag::F_LAST as u8,
1557                    12345,
1558                    ts_event,
1559                    ts_init,
1560                ),
1561            ],
1562        );
1563
1564        let actual = quote_to_l1_deltas(quote, 12345);
1565
1566        assert_eq!(actual, expected);
1567    }
1568
1569    #[rstest]
1570    fn test_quote_to_l1_deltas_deletes_empty_sides() {
1571        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
1572        let quote = QuoteTick::new(
1573            instrument_id,
1574            Price::from("42000.01"),
1575            Price::from("42000.02"),
1576            Quantity::from("0.00000"),
1577            Quantity::from("0.00000"),
1578            UnixNanos::from(1_700_000_000_000_000_001u64),
1579            UnixNanos::from(1_700_000_000_000_000_002u64),
1580        );
1581
1582        let actual = quote_to_l1_deltas(quote, 12345);
1583
1584        assert_eq!(actual.deltas[0].action, BookAction::Delete);
1585        assert_eq!(actual.deltas[1].action, BookAction::Delete);
1586    }
1587
1588    #[rstest]
1589    #[case::positive("0.001", 8, Some(Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap()))]
1590    #[case::trailing_zero("0.00100000", 8, Some(Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap()))]
1591    #[case::zero("0", 8, None)]
1592    #[case::negative("-1", 8, None)]
1593    #[case::empty("", 8, None)]
1594    #[case::garbage("abc", 8, None)]
1595    fn test_parse_quantity_at_precision(
1596        #[case] raw: &str,
1597        #[case] precision: u8,
1598        #[case] expected: Option<Quantity>,
1599    ) {
1600        assert_eq!(parse_quantity_at_precision(raw, precision), expected);
1601    }
1602
1603    #[rstest]
1604    #[case::positive("7100.50", 2, Some(Price::from_decimal_dp(Decimal::from_str("7100.50").unwrap(), 2).unwrap()))]
1605    #[case::high_precision("0.000000001", 9, Some(Price::from_decimal_dp(Decimal::from_str("0.000000001").unwrap(), 9).unwrap()))]
1606    #[case::zero("0", 2, None)]
1607    #[case::negative("-100", 2, None)]
1608    #[case::empty("", 2, None)]
1609    fn test_parse_price_at_precision(
1610        #[case] raw: &str,
1611        #[case] precision: u8,
1612        #[case] expected: Option<Price>,
1613    ) {
1614        assert_eq!(parse_price_at_precision(raw, precision), expected);
1615    }
1616
1617    #[rstest]
1618    fn test_quantity_at_precision_re_precisions_via_decimal() {
1619        let original = Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 3).unwrap();
1620        let widened = quantity_at_precision(original, 8).unwrap();
1621        let expected = Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap();
1622        assert_eq!(widened, expected);
1623    }
1624
1625    #[rstest]
1626    fn test_price_at_precision_re_precisions_via_decimal() {
1627        let original = Price::from_decimal_dp(Decimal::from_str("7100.5").unwrap(), 1).unwrap();
1628        let widened = price_at_precision(original, 8).unwrap();
1629        let expected = Price::from_decimal_dp(Decimal::from_str("7100.5").unwrap(), 8).unwrap();
1630        assert_eq!(widened, expected);
1631    }
1632
1633    fn sample_usdm_symbol() -> BinanceFuturesUsdSymbol {
1634        BinanceFuturesUsdSymbol {
1635            symbol: Ustr::from("BTCUSDT"),
1636            pair: Ustr::from("BTCUSDT"),
1637            contract_type: "PERPETUAL".to_string(),
1638            delivery_date: 4133404800000,
1639            onboard_date: 1569398400000,
1640            status: BinanceTradingStatus::Trading,
1641            maint_margin_percent: "2.5000".to_string(),
1642            required_margin_percent: "5.0000".to_string(),
1643            base_asset: Ustr::from("BTC"),
1644            quote_asset: Ustr::from("USDT"),
1645            margin_asset: Ustr::from("USDT"),
1646            price_precision: 2,
1647            quantity_precision: 3,
1648            base_asset_precision: 8,
1649            quote_precision: 8,
1650            underlying_type: Some("COIN".to_string()),
1651            underlying_sub_type: vec!["PoW".to_string()],
1652            settle_plan: None,
1653            trigger_protect: Some("0.0500".to_string()),
1654            liquidation_fee: Some("0.012500".to_string()),
1655            market_take_bound: Some("0.05".to_string()),
1656            order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
1657            time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
1658            filters: vec![
1659                json!({
1660                    "filterType": "PRICE_FILTER",
1661                    "tickSize": "0.10",
1662                    "maxPrice": "4529764",
1663                    "minPrice": "556.80"
1664                }),
1665                json!({
1666                    "filterType": "LOT_SIZE",
1667                    "stepSize": "0.001",
1668                    "maxQty": "1000",
1669                    "minQty": "0.001"
1670                }),
1671                json!({
1672                    "filterType": "MIN_NOTIONAL",
1673                    "notional": "5"
1674                }),
1675            ],
1676        }
1677    }
1678
1679    fn sample_tradifi_usdm_symbol(
1680        symbol: &str,
1681        underlying: &str,
1682        underlying_type: Option<&str>,
1683    ) -> BinanceFuturesUsdSymbol {
1684        let mut definition = sample_usdm_symbol();
1685        definition.symbol = Ustr::from(symbol);
1686        definition.pair = Ustr::from(symbol);
1687        definition.contract_type = CONTRACT_TYPE_TRADIFI_PERPETUAL.to_string();
1688        definition.base_asset = Ustr::from(underlying);
1689        definition.underlying_type = underlying_type.map(str::to_string);
1690        definition
1691    }
1692
1693    fn sample_coinm_symbol() -> BinanceFuturesCoinSymbol {
1694        BinanceFuturesCoinSymbol {
1695            symbol: Ustr::from("BTCUSD_PERP"),
1696            pair: Ustr::from("BTCUSD"),
1697            contract_type: "PERPETUAL".to_string(),
1698            delivery_date: 4_133_404_800_000,
1699            onboard_date: 1_569_398_400_000,
1700            contract_status: Some(BinanceContractStatus::Trading),
1701            contract_size: 100,
1702            maint_margin_percent: "2.5000".to_string(),
1703            required_margin_percent: "5.0000".to_string(),
1704            base_asset: Ustr::from("BTC"),
1705            quote_asset: Ustr::from("USD"),
1706            margin_asset: Ustr::from("BTC"),
1707            price_precision: 1,
1708            quantity_precision: 0,
1709            base_asset_precision: 8,
1710            quote_precision: 8,
1711            equal_qty_precision: None,
1712            trigger_protect: Some("0.0500".to_string()),
1713            liquidation_fee: Some("0.012500".to_string()),
1714            market_take_bound: Some("0.05".to_string()),
1715            order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
1716            time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
1717            filters: vec![
1718                json!({
1719                    "filterType": "PRICE_FILTER",
1720                    "tickSize": "0.10",
1721                    "maxPrice": "1000000",
1722                    "minPrice": "0.10"
1723                }),
1724                json!({
1725                    "filterType": "LOT_SIZE",
1726                    "stepSize": "1",
1727                    "maxQty": "1000",
1728                    "minQty": "1"
1729                }),
1730                json!({
1731                    "filterType": "MIN_NOTIONAL",
1732                    "notional": "1"
1733                }),
1734            ],
1735        }
1736    }
1737
1738    fn sample_spot_symbol_sbe() -> BinanceSymbolSbe {
1739        BinanceSymbolSbe {
1740            symbol: "ETHUSDT".to_string(),
1741            base_asset: "ETH".to_string(),
1742            quote_asset: "USDT".to_string(),
1743            base_asset_precision: 8,
1744            quote_asset_precision: 8,
1745            status: SBE_STATUS_TRADING,
1746            order_types: 0,
1747            iceberg_allowed: true,
1748            oco_allowed: true,
1749            oto_allowed: false,
1750            quote_order_qty_market_allowed: true,
1751            allow_trailing_stop: true,
1752            cancel_replace_allowed: true,
1753            amend_allowed: true,
1754            is_spot_trading_allowed: true,
1755            is_margin_trading_allowed: false,
1756            filters: crate::spot::http::models::BinanceSymbolFiltersSbe {
1757                price_filter: Some(BinancePriceFilterSbe {
1758                    price_exponent: -8,
1759                    min_price: 1_000_000,
1760                    max_price: 100_000_000_000_000,
1761                    tick_size: 1_000_000,
1762                }),
1763                lot_size_filter: Some(BinanceLotSizeFilterSbe {
1764                    qty_exponent: -8,
1765                    min_qty: 10_000,
1766                    max_qty: 900_000_000_000,
1767                    step_size: 10_000,
1768                }),
1769            },
1770            permissions: vec![vec!["SPOT".to_string()]],
1771        }
1772    }
1773
1774    fn sample_spot_instrument() -> InstrumentAny {
1775        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1776        parse_spot_instrument_sbe(&sample_spot_symbol_sbe(), ts, ts).unwrap()
1777    }
1778
1779    fn sample_account_id() -> AccountId {
1780        AccountId::from("BINANCE-SPOT-001")
1781    }
1782
1783    #[rstest]
1784    fn test_parse_usdm_perpetual() {
1785        let symbol = sample_usdm_symbol();
1786        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1787
1788        let result = parse_usdm_instrument(&symbol, ts, ts);
1789        assert!(result.is_ok(), "Failed: {:?}", result.err());
1790
1791        let instrument = result.unwrap();
1792        match instrument {
1793            InstrumentAny::CryptoPerpetual(perp) => {
1794                assert_eq!(perp.id.to_string(), "BTCUSDT-PERP.BINANCE");
1795                assert_eq!(perp.raw_symbol.to_string(), "BTCUSDT");
1796                assert_eq!(perp.base_currency.code.as_str(), "BTC");
1797                assert_eq!(perp.quote_currency.code.as_str(), "USDT");
1798                assert_eq!(perp.settlement_currency.code.as_str(), "USDT");
1799                assert!(!perp.is_inverse);
1800                assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
1801                assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1802                assert_eq!(
1803                    perp.min_notional,
1804                    Some(Money::new(5.0, perp.quote_currency)),
1805                );
1806            }
1807            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1808        }
1809    }
1810
1811    #[rstest]
1812    fn test_parse_usdm_perpetual_populates_fees() {
1813        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1814        let instrument = parse_usdm_instrument_with_fees(
1815            &sample_usdm_symbol(),
1816            Some(dec!(0.00016)),
1817            Some(dec!(0.0004)),
1818            ts,
1819            ts,
1820        )
1821        .unwrap();
1822        let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
1823            panic!("expected CryptoPerpetual, was {instrument:?}");
1824        };
1825
1826        assert_eq!(perpetual.maker_fee, dec!(0.00016));
1827        assert_eq!(perpetual.taker_fee, dec!(0.0004));
1828    }
1829
1830    #[rstest]
1831    #[case::equity("SNDKUSDT", "SNDK", "EQUITY", AssetClass::Equity)]
1832    #[case::korean_equity("005930USDT", "005930", "KR_EQUITY", AssetClass::Equity)]
1833    #[case::hong_kong_equity("0700USDT", "0700", "HK_EQUITY", AssetClass::Equity)]
1834    #[case::premarket("SPCXUSDT", "SPCX", "PREMARKET", AssetClass::Equity)]
1835    #[case::commodity("XAUUSDT", "XAU", "COMMODITY", AssetClass::Commodity)]
1836    fn test_parse_usdm_tradifi_perpetual(
1837        #[case] raw_symbol: &str,
1838        #[case] underlying: &str,
1839        #[case] underlying_type: &str,
1840        #[case] expected_asset_class: AssetClass,
1841    ) {
1842        let symbol = sample_tradifi_usdm_symbol(raw_symbol, underlying, Some(underlying_type));
1843        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1844
1845        let instrument = parse_usdm_instrument(&symbol, ts, ts).unwrap();
1846        match instrument {
1847            InstrumentAny::PerpetualContract(perp) => {
1848                assert_eq!(perp.id.to_string(), format!("{raw_symbol}-PERP.BINANCE"));
1849                assert_eq!(perp.raw_symbol.to_string(), raw_symbol);
1850                assert_eq!(perp.underlying, Ustr::from(underlying));
1851                assert_eq!(perp.asset_class, expected_asset_class);
1852                assert_eq!(perp.base_currency, None);
1853                assert_eq!(perp.quote_currency.code.as_str(), "USDT");
1854                assert_eq!(perp.settlement_currency.code.as_str(), "USDT");
1855                assert!(!perp.is_inverse);
1856                assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
1857                assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1858                assert_eq!(
1859                    perp.min_notional,
1860                    Some(Money::new(5.0, perp.quote_currency)),
1861                );
1862            }
1863            other => panic!("Expected PerpetualContract, was {other:?}"),
1864        }
1865    }
1866
1867    #[rstest]
1868    #[case::missing(
1869        None,
1870        "Missing underlying type for TRADIFI_PERPETUAL symbol 'SNDKUSDT'"
1871    )]
1872    #[case::unknown(
1873        Some("INDEX"),
1874        "Unsupported underlying type 'INDEX' for TRADIFI_PERPETUAL symbol 'SNDKUSDT'"
1875    )]
1876    fn test_parse_usdm_tradifi_perpetual_rejects_invalid_underlying_type(
1877        #[case] underlying_type: Option<&str>,
1878        #[case] expected_error: &str,
1879    ) {
1880        let symbol = sample_tradifi_usdm_symbol("SNDKUSDT", "SNDK", underlying_type);
1881        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1882
1883        let error = parse_usdm_instrument(&symbol, ts, ts).unwrap_err();
1884        assert_eq!(error.to_string(), expected_error);
1885    }
1886
1887    #[rstest]
1888    #[case::current_month(CONTRACT_TYPE_CURRENT_MONTH)]
1889    #[case::next_month(CONTRACT_TYPE_NEXT_MONTH)]
1890    #[case::current_quarter(CONTRACT_TYPE_CURRENT_QUARTER)]
1891    #[case::next_quarter(CONTRACT_TYPE_NEXT_QUARTER)]
1892    fn test_parse_usdm_delivery(#[case] contract_type: &str) {
1893        let mut symbol = sample_usdm_symbol();
1894        symbol.symbol = Ustr::from("BTCUSDT_260925");
1895        symbol.contract_type = contract_type.to_string();
1896        symbol.onboard_date = 1_774_598_400_000;
1897        symbol.delivery_date = 1_790_323_200_000;
1898        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1899
1900        let result = parse_usdm_instrument(&symbol, ts, ts).unwrap();
1901        let InstrumentAny::CryptoFuture(future) = result else {
1902            panic!("Expected CryptoFuture, was {result:?}");
1903        };
1904
1905        assert_eq!(future.id.to_string(), "BTCUSDT_260925.BINANCE");
1906        assert_eq!(future.raw_symbol.to_string(), "BTCUSDT_260925");
1907        assert_eq!(future.underlying.code.as_str(), "BTC");
1908        assert_eq!(future.quote_currency.code.as_str(), "USDT");
1909        assert_eq!(future.settlement_currency.code.as_str(), "USDT");
1910        assert!(!future.is_inverse);
1911        assert_eq!(
1912            future.activation_ns,
1913            UnixNanos::from_millis(1_774_598_400_000)
1914        );
1915        assert_eq!(
1916            future.expiration_ns,
1917            UnixNanos::from_millis(1_790_323_200_000)
1918        );
1919        assert_eq!(future.price_increment, Price::from_str("0.10").unwrap());
1920        assert_eq!(future.size_increment, Quantity::from_str("0.001").unwrap());
1921        assert_eq!(future.multiplier, Quantity::from(1));
1922        assert_eq!(
1923            future.max_quantity,
1924            Some(Quantity::from_str("1000").unwrap())
1925        );
1926        assert_eq!(
1927            future.min_quantity,
1928            Some(Quantity::from_str("0.001").unwrap())
1929        );
1930        assert_eq!(
1931            future.min_notional,
1932            Some(Money::new(5.0, future.quote_currency)),
1933        );
1934        assert_eq!(future.max_price, Some(Price::from_str("4529764").unwrap()));
1935        assert_eq!(future.min_price, Some(Price::from_str("556.80").unwrap()));
1936    }
1937
1938    #[rstest]
1939    fn test_parse_usdm_unsupported_contract_type_fails() {
1940        let mut symbol = sample_usdm_symbol();
1941        symbol.contract_type = "UNKNOWN".to_string();
1942        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1943
1944        let error = parse_usdm_instrument(&symbol, ts, ts).unwrap_err();
1945
1946        assert!(
1947            error
1948                .to_string()
1949                .contains("Unsupported USD-M contract type")
1950        );
1951    }
1952
1953    #[rstest]
1954    fn test_parse_missing_price_filter_fails() {
1955        let mut symbol = sample_usdm_symbol();
1956        symbol.filters = vec![json!({
1957            "filterType": "LOT_SIZE",
1958            "stepSize": "0.001",
1959            "maxQty": "1000",
1960            "minQty": "0.001"
1961        })];
1962        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1963
1964        let result = parse_usdm_instrument(&symbol, ts, ts);
1965        assert!(result.is_err());
1966        assert!(
1967            result
1968                .unwrap_err()
1969                .to_string()
1970                .contains("Missing PRICE_FILTER")
1971        );
1972    }
1973
1974    #[rstest]
1975    fn test_parse_coinm_perpetual() {
1976        let symbol = sample_coinm_symbol();
1977        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1978
1979        let result = parse_coinm_instrument(&symbol, ts, ts).unwrap();
1980
1981        match result {
1982            InstrumentAny::CryptoPerpetual(perp) => {
1983                assert_eq!(perp.id.to_string(), "BTCUSD_PERP.BINANCE");
1984                assert_eq!(perp.raw_symbol.to_string(), "BTCUSD_PERP");
1985                assert_eq!(perp.base_currency.code.as_str(), "BTC");
1986                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1987                assert_eq!(perp.settlement_currency.code.as_str(), "BTC");
1988                assert!(perp.is_inverse);
1989                assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
1990                assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
1991                assert_eq!(
1992                    perp.min_notional,
1993                    Some(Money::new(1.0, perp.quote_currency)),
1994                );
1995            }
1996            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1997        }
1998    }
1999
2000    #[rstest]
2001    fn test_parse_coinm_delivery_populates_fees() {
2002        let mut symbol = sample_coinm_symbol();
2003        symbol.symbol = Ustr::from("BTCUSD_260925");
2004        symbol.contract_type = CONTRACT_TYPE_CURRENT_QUARTER.to_string();
2005        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2006        let instrument = parse_coinm_instrument_with_fees(
2007            &symbol,
2008            Some(dec!(0.00014)),
2009            Some(dec!(0.00035)),
2010            ts,
2011            ts,
2012        )
2013        .unwrap();
2014        let InstrumentAny::CryptoFuture(future) = instrument else {
2015            panic!("expected CryptoFuture, was {instrument:?}");
2016        };
2017
2018        assert_eq!(future.maker_fee, dec!(0.00014));
2019        assert_eq!(future.taker_fee, dec!(0.00035));
2020    }
2021
2022    #[rstest]
2023    #[case::current_quarter(CONTRACT_TYPE_CURRENT_QUARTER)]
2024    #[case::next_quarter(CONTRACT_TYPE_NEXT_QUARTER)]
2025    fn test_parse_coinm_delivery(#[case] contract_type: &str) {
2026        let mut symbol = sample_coinm_symbol();
2027        symbol.symbol = Ustr::from("BTCUSD_260925");
2028        symbol.contract_type = contract_type.to_string();
2029        symbol.onboard_date = 1_774_598_400_000;
2030        symbol.delivery_date = 1_790_323_200_000;
2031        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2032
2033        let result = parse_coinm_instrument(&symbol, ts, ts).unwrap();
2034        let InstrumentAny::CryptoFuture(future) = result else {
2035            panic!("Expected CryptoFuture, was {result:?}");
2036        };
2037
2038        assert_eq!(future.id.to_string(), "BTCUSD_260925.BINANCE");
2039        assert_eq!(future.raw_symbol.to_string(), "BTCUSD_260925");
2040        assert_eq!(future.underlying.code.as_str(), "BTC");
2041        assert_eq!(future.quote_currency.code.as_str(), "USD");
2042        assert_eq!(future.settlement_currency.code.as_str(), "BTC");
2043        assert!(future.is_inverse);
2044        assert_eq!(
2045            future.activation_ns,
2046            UnixNanos::from_millis(1_774_598_400_000)
2047        );
2048        assert_eq!(
2049            future.expiration_ns,
2050            UnixNanos::from_millis(1_790_323_200_000)
2051        );
2052        assert_eq!(future.price_increment, Price::from_str("0.10").unwrap());
2053        assert_eq!(future.size_increment, Quantity::from_str("1").unwrap());
2054        assert_eq!(future.multiplier, Quantity::from(100));
2055        assert_eq!(
2056            future.max_quantity,
2057            Some(Quantity::from_str("1000").unwrap())
2058        );
2059        assert_eq!(future.min_quantity, Some(Quantity::from_str("1").unwrap()));
2060        assert_eq!(future.max_price, Some(Price::from_str("1000000").unwrap()));
2061        assert_eq!(future.min_price, Some(Price::from_str("0.10").unwrap()));
2062    }
2063
2064    #[rstest]
2065    fn test_parse_coinm_month_contract_fails() {
2066        let mut symbol = sample_coinm_symbol();
2067        symbol.contract_type = CONTRACT_TYPE_CURRENT_MONTH.to_string();
2068        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2069
2070        let error = parse_coinm_instrument(&symbol, ts, ts).unwrap_err();
2071
2072        assert!(
2073            error
2074                .to_string()
2075                .contains("Unsupported COIN-M contract type")
2076        );
2077    }
2078
2079    #[rstest]
2080    fn test_parse_spot_instrument_sbe() {
2081        let symbol = sample_spot_symbol_sbe();
2082        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2083
2084        let result = parse_spot_instrument_sbe(&symbol, ts, ts).unwrap();
2085
2086        match result {
2087            InstrumentAny::CurrencyPair(pair) => {
2088                assert_eq!(pair.id.to_string(), "ETHUSDT.BINANCE");
2089                assert_eq!(pair.raw_symbol.to_string(), "ETHUSDT");
2090                assert_eq!(pair.base_currency.code.as_str(), "ETH");
2091                assert_eq!(pair.quote_currency.code.as_str(), "USDT");
2092                assert_eq!(pair.price_increment, Price::from_str("0.01").unwrap());
2093                assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
2094            }
2095            other => panic!("Expected CurrencyPair, was {other:?}"),
2096        }
2097    }
2098
2099    #[rstest]
2100    fn test_parse_spot_instrument_populates_fees() {
2101        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2102        let instrument = parse_spot_instrument_sbe_with_fees(
2103            &sample_spot_symbol_sbe(),
2104            Some(dec!(0.0008)),
2105            Some(dec!(0.0011)),
2106            ts,
2107            ts,
2108        )
2109        .unwrap();
2110        let InstrumentAny::CurrencyPair(pair) = instrument else {
2111            panic!("expected CurrencyPair, was {instrument:?}");
2112        };
2113
2114        assert_eq!(pair.maker_fee, dec!(0.0008));
2115        assert_eq!(pair.taker_fee, dec!(0.0011));
2116    }
2117
2118    #[rstest]
2119    fn test_parse_spot_trades_sbe() {
2120        let instrument = sample_spot_instrument();
2121        let trades = BinanceTrades {
2122            price_exponent: -2,
2123            qty_exponent: -4,
2124            trades: vec![
2125                crate::spot::http::models::BinanceTrade {
2126                    id: 1,
2127                    price_mantissa: 12_345,
2128                    qty_mantissa: 25_000,
2129                    quote_qty_mantissa: 0,
2130                    time: 1_700_000_000_000_000,
2131                    is_buyer_maker: false,
2132                    is_best_match: true,
2133                },
2134                crate::spot::http::models::BinanceTrade {
2135                    id: 2,
2136                    price_mantissa: 12_340,
2137                    qty_mantissa: 10_000,
2138                    quote_qty_mantissa: 0,
2139                    time: 1_700_000_000_500_000,
2140                    is_buyer_maker: true,
2141                    is_best_match: true,
2142                },
2143            ],
2144        };
2145        let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2146
2147        let result = parse_spot_trades_sbe(&trades, &instrument, ts_init).unwrap();
2148
2149        assert_eq!(result.len(), 2);
2150        assert_eq!(result[0].instrument_id, instrument.id());
2151        assert_eq!(result[0].price.as_f64(), 123.45);
2152        assert_eq!(result[0].size.as_f64(), 2.5);
2153        assert_eq!(result[0].aggressor_side, AggressorSide::Buy);
2154        assert_eq!(result[0].trade_id, TradeId::new("1"));
2155        assert_eq!(
2156            result[0].ts_event,
2157            UnixNanos::from(1_700_000_000_000_000_000u64)
2158        );
2159        assert_eq!(result[0].ts_init, ts_init);
2160        assert_eq!(result[1].aggressor_side, AggressorSide::Sell);
2161    }
2162
2163    #[rstest]
2164    fn test_parse_order_status_report_sbe() {
2165        let instrument = sample_spot_instrument();
2166        let order = BinanceOrderResponse {
2167            price_exponent: -2,
2168            qty_exponent: -4,
2169            order_id: 42,
2170            order_list_id: Some(77),
2171            price_mantissa: 12_345,
2172            orig_qty_mantissa: 25_000,
2173            executed_qty_mantissa: 10_000,
2174            cummulative_quote_qty_mantissa: 123_450_000,
2175            status: SbeOrderStatus::PartiallyFilled,
2176            time_in_force: SbeTimeInForce::Gtc,
2177            order_type: SbeOrderType::LimitMaker,
2178            side: SbeOrderSide::Buy,
2179            stop_price_mantissa: None,
2180            iceberg_qty_mantissa: None,
2181            time: 1_700_000_000_000_000,
2182            update_time: 1_700_000_000_100_000,
2183            is_working: true,
2184            working_time: Some(1_700_000_000_050_000),
2185            orig_quote_order_qty_mantissa: 0,
2186            self_trade_prevention_mode:
2187                crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
2188            client_order_id: "client-123".to_string(),
2189            symbol: "ETHUSDT".to_string(),
2190            expiry_reason: None,
2191        };
2192        let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2193
2194        let report = parse_order_status_report_sbe(
2195            &order,
2196            sample_account_id(),
2197            &instrument,
2198            BINANCE_NAUTILUS_SPOT_BROKER_ID,
2199            ts_init,
2200        )
2201        .unwrap();
2202
2203        assert_eq!(report.account_id, sample_account_id());
2204        assert_eq!(report.instrument_id, instrument.id());
2205        assert_eq!(
2206            report.client_order_id,
2207            Some(ClientOrderId::new("client-123"))
2208        );
2209        assert_eq!(report.venue_order_id, VenueOrderId::new("42"));
2210        assert_eq!(report.order_side, OrderSide::Buy.into());
2211        assert_eq!(report.order_type, OrderType::Limit);
2212        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2213        assert_eq!(report.quantity.as_f64(), 2.5);
2214        assert_eq!(report.filled_qty.as_f64(), 1.0);
2215        assert_eq!(report.order_list_id, Some(OrderListId::new("77")));
2216        assert_eq!(report.price, Some(Price::new(123.45, 2)));
2217        assert_eq!(report.avg_px.unwrap().to_string(), "123.45");
2218        assert!(report.post_only);
2219        assert_eq!(
2220            report.ts_accepted,
2221            UnixNanos::from(1_700_000_000_000_000_000u64)
2222        );
2223        assert_eq!(
2224            report.ts_last,
2225            UnixNanos::from(1_700_000_000_100_000_000u64)
2226        );
2227        assert_eq!(report.ts_init, ts_init);
2228    }
2229
2230    #[rstest]
2231    fn test_parse_new_order_response_sbe() {
2232        let instrument = sample_spot_instrument();
2233        let response = BinanceNewOrderResponse {
2234            price_exponent: -2,
2235            qty_exponent: -4,
2236            order_id: 99,
2237            order_list_id: Some(7),
2238            transact_time: 1_700_000_000_000_000,
2239            price_mantissa: 12_100,
2240            orig_qty_mantissa: 20_000,
2241            executed_qty_mantissa: 5_000,
2242            cummulative_quote_qty_mantissa: 60_500_000,
2243            status: SbeOrderStatus::New,
2244            time_in_force: SbeTimeInForce::Gtc,
2245            order_type: SbeOrderType::StopLossLimit,
2246            side: SbeOrderSide::Sell,
2247            stop_price_mantissa: Some(12_000),
2248            working_time: Some(1_700_000_000_000_000),
2249            self_trade_prevention_mode:
2250                crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
2251            client_order_id: "client-456".to_string(),
2252            symbol: "ETHUSDT".to_string(),
2253            fills: vec![],
2254            expiry_reason: None,
2255        };
2256        let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2257
2258        let report = parse_new_order_response_sbe(
2259            &response,
2260            sample_account_id(),
2261            &instrument,
2262            BINANCE_NAUTILUS_SPOT_BROKER_ID,
2263            ts_init,
2264        )
2265        .unwrap();
2266
2267        assert_eq!(report.account_id, sample_account_id());
2268        assert_eq!(report.instrument_id, instrument.id());
2269        assert_eq!(
2270            report.client_order_id,
2271            Some(ClientOrderId::new("client-456"))
2272        );
2273        assert_eq!(report.venue_order_id, VenueOrderId::new("99"));
2274        assert_eq!(report.order_side, OrderSide::Sell.into());
2275        assert_eq!(report.order_type, OrderType::StopLimit);
2276        assert_eq!(report.order_status, OrderStatus::Accepted);
2277        assert_eq!(report.quantity.as_f64(), 2.0);
2278        assert_eq!(report.filled_qty.as_f64(), 0.5);
2279        assert_eq!(report.order_list_id, Some(OrderListId::new("7")));
2280        assert_eq!(report.price, Some(Price::new(121.0, 2)));
2281        assert_eq!(report.trigger_price, Some(Price::new(120.0, 2)));
2282        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2283        // `as_decimal()` carries the price precision, where the old `as_f64()` hop dropped it
2284        assert_eq!(report.avg_px, Some(dec!(121.00)));
2285        assert_eq!(report.avg_px.unwrap().to_string(), "121.00");
2286        assert!(!report.post_only);
2287        assert_eq!(
2288            report.ts_accepted,
2289            UnixNanos::from(1_700_000_000_000_000_000u64)
2290        );
2291        assert_eq!(
2292            report.ts_last,
2293            UnixNanos::from(1_700_000_000_000_000_000u64)
2294        );
2295    }
2296
2297    #[rstest]
2298    fn test_parse_fill_report_sbe() {
2299        let instrument = sample_spot_instrument();
2300        let trade = BinanceAccountTrade {
2301            price_exponent: -2,
2302            qty_exponent: -4,
2303            commission_exponent: -8,
2304            id: 123,
2305            order_id: 456,
2306            order_list_id: None,
2307            price_mantissa: 12_345,
2308            qty_mantissa: 25_000,
2309            quote_qty_mantissa: 0,
2310            commission_mantissa: 10_000,
2311            time: 1_700_000_000_000_000,
2312            is_buyer: false,
2313            is_maker: true,
2314            is_best_match: true,
2315            symbol: "ETHUSDT".to_string(),
2316            commission_asset: "USDT".to_string(),
2317        };
2318        let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2319
2320        let report = parse_fill_report_sbe(
2321            &trade,
2322            sample_account_id(),
2323            &instrument,
2324            Currency::from("USDT"),
2325            ts_init,
2326        )
2327        .unwrap();
2328
2329        assert_eq!(report.account_id, sample_account_id());
2330        assert_eq!(report.instrument_id, instrument.id());
2331        assert_eq!(report.venue_order_id, VenueOrderId::new("456"));
2332        assert_eq!(report.trade_id, TradeId::new("123"));
2333        assert_eq!(report.order_side, OrderSide::Sell);
2334        assert_eq!(report.last_qty.as_f64(), 2.5);
2335        assert_eq!(report.last_px.as_f64(), 123.45);
2336        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
2337        assert_eq!(report.commission.as_f64(), 0.0001);
2338        assert_eq!(
2339            report.ts_event,
2340            UnixNanos::from(1_700_000_000_000_000_000u64)
2341        );
2342        assert_eq!(report.ts_init, ts_init);
2343        assert!(report.client_order_id.is_none());
2344    }
2345
2346    #[rstest]
2347    fn test_parse_klines_to_bars() {
2348        use nautilus_model::enums::{AggregationSource, PriceType};
2349
2350        let instrument = sample_spot_instrument();
2351        let bar_type = BarType::new(
2352            instrument.id(),
2353            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
2354            AggregationSource::External,
2355        );
2356        let klines = BinanceKlines {
2357            price_exponent: -2,
2358            qty_exponent: -4,
2359            klines: vec![crate::spot::http::models::BinanceKline {
2360                open_time: 1_700_000_000_000_000,
2361                open_price: 12_000,
2362                high_price: 12_500,
2363                low_price: 11_900,
2364                close_price: 12_345,
2365                volume: 1_234_500_i128.to_le_bytes(),
2366                close_time: 1_700_000_059_999_000,
2367                quote_volume: 777_788_i128.to_le_bytes(),
2368                num_trades: 100,
2369                taker_buy_base_volume: 56_789_i128.to_le_bytes(),
2370                taker_buy_quote_volume: 9_901_i128.to_le_bytes(),
2371            }],
2372        };
2373        let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2374
2375        let bars = parse_klines_to_binance_bars(&klines, bar_type, &instrument, ts_init).unwrap();
2376
2377        assert_eq!(bars.len(), 1);
2378        assert_eq!(bars[0].bar_type, bar_type);
2379        assert_eq!(bars[0].open, Price::new(120.0, 2));
2380        assert_eq!(bars[0].high, Price::new(125.0, 2));
2381        assert_eq!(bars[0].low, Price::new(119.0, 2));
2382        assert_eq!(bars[0].close, Price::new(123.45, 2));
2383        assert_eq!(bars[0].volume, Quantity::new(123.45, 4));
2384        assert_eq!(bars[0].quote_volume, dec!(7777.88));
2385        assert_eq!(bars[0].count, 100);
2386        assert_eq!(bars[0].taker_buy_base_volume, dec!(5.6789));
2387        assert_eq!(bars[0].taker_buy_quote_volume, dec!(99.01));
2388        assert_eq!(
2389            bars[0].ts_event,
2390            UnixNanos::from(1_700_000_059_999_000_000u64)
2391        );
2392        assert_eq!(bars[0].ts_init, ts_init);
2393    }
2394
2395    mod bar_spec_tests {
2396        use std::num::NonZeroUsize;
2397
2398        use nautilus_model::{
2399            data::BarSpecification,
2400            enums::{BarAggregation, PriceType},
2401        };
2402
2403        use super::*;
2404        use crate::common::enums::BinanceKlineInterval;
2405
2406        fn make_bar_spec(step: usize, aggregation: BarAggregation) -> BarSpecification {
2407            BarSpecification {
2408                step: NonZeroUsize::new(step).unwrap(),
2409                aggregation,
2410                price_type: PriceType::Last,
2411            }
2412        }
2413
2414        #[rstest]
2415        #[case(1, BarAggregation::Second, BinanceKlineInterval::Second1)]
2416        #[case(1, BarAggregation::Minute, BinanceKlineInterval::Minute1)]
2417        #[case(3, BarAggregation::Minute, BinanceKlineInterval::Minute3)]
2418        #[case(5, BarAggregation::Minute, BinanceKlineInterval::Minute5)]
2419        #[case(15, BarAggregation::Minute, BinanceKlineInterval::Minute15)]
2420        #[case(30, BarAggregation::Minute, BinanceKlineInterval::Minute30)]
2421        #[case(1, BarAggregation::Hour, BinanceKlineInterval::Hour1)]
2422        #[case(2, BarAggregation::Hour, BinanceKlineInterval::Hour2)]
2423        #[case(4, BarAggregation::Hour, BinanceKlineInterval::Hour4)]
2424        #[case(6, BarAggregation::Hour, BinanceKlineInterval::Hour6)]
2425        #[case(8, BarAggregation::Hour, BinanceKlineInterval::Hour8)]
2426        #[case(12, BarAggregation::Hour, BinanceKlineInterval::Hour12)]
2427        #[case(1, BarAggregation::Day, BinanceKlineInterval::Day1)]
2428        #[case(3, BarAggregation::Day, BinanceKlineInterval::Day3)]
2429        #[case(1, BarAggregation::Week, BinanceKlineInterval::Week1)]
2430        #[case(1, BarAggregation::Month, BinanceKlineInterval::Month1)]
2431        fn test_bar_spec_to_binance_interval(
2432            #[case] step: usize,
2433            #[case] aggregation: BarAggregation,
2434            #[case] expected: BinanceKlineInterval,
2435        ) {
2436            let bar_spec = make_bar_spec(step, aggregation);
2437            let result = bar_spec_to_binance_interval(bar_spec).unwrap();
2438            assert_eq!(result, expected);
2439        }
2440
2441        #[rstest]
2442        fn test_unsupported_second_interval() {
2443            let bar_spec = make_bar_spec(2, BarAggregation::Second);
2444            let result = bar_spec_to_binance_interval(bar_spec);
2445            assert!(result.is_err());
2446            assert!(
2447                result
2448                    .unwrap_err()
2449                    .to_string()
2450                    .contains("Unsupported second interval")
2451            );
2452        }
2453
2454        #[rstest]
2455        fn test_unsupported_minute_interval() {
2456            let bar_spec = make_bar_spec(7, BarAggregation::Minute);
2457            let result = bar_spec_to_binance_interval(bar_spec);
2458            assert!(result.is_err());
2459            assert!(
2460                result
2461                    .unwrap_err()
2462                    .to_string()
2463                    .contains("Unsupported minute interval")
2464            );
2465        }
2466
2467        #[rstest]
2468        fn test_unsupported_aggregation() {
2469            let bar_spec = make_bar_spec(100, BarAggregation::Tick);
2470            let result = bar_spec_to_binance_interval(bar_spec);
2471            assert!(result.is_err());
2472            assert!(
2473                result
2474                    .unwrap_err()
2475                    .to_string()
2476                    .contains("Unsupported bar aggregation")
2477            );
2478        }
2479    }
2480
2481    mod sbe_precision_tests {
2482        use super::*;
2483        use crate::spot::http::models::{BinanceLotSizeFilterSbe, BinancePriceFilterSbe};
2484
2485        #[rstest]
2486        #[case::precision_0(100_000_000, -8, 0)]
2487        #[case::precision_1(10_000_000, -8, 1)]
2488        #[case::precision_2(1_000_000, -8, 2)]
2489        #[case::precision_3(100_000, -8, 3)]
2490        #[case::precision_4(10_000, -8, 4)]
2491        #[case::precision_5(1_000, -8, 5)]
2492        #[case::precision_6(100, -8, 6)]
2493        #[case::precision_7(10, -8, 7)]
2494        #[case::precision_8(1, -8, 8)]
2495        fn test_sbe_mantissa_precision(
2496            #[case] mantissa: i64,
2497            #[case] exponent: i8,
2498            #[case] expected: u8,
2499        ) {
2500            let result = sbe_mantissa_precision(mantissa, exponent);
2501            assert_eq!(
2502                result, expected,
2503                "mantissa={mantissa}, exponent={exponent}: expected {expected}, was {result}"
2504            );
2505        }
2506
2507        #[rstest]
2508        fn test_sbe_mantissa_precision_zero_mantissa() {
2509            assert_eq!(sbe_mantissa_precision(0, -8), 0);
2510        }
2511
2512        #[rstest]
2513        fn test_sbe_mantissa_precision_positive_exponent() {
2514            assert_eq!(sbe_mantissa_precision(1, 0), 0);
2515            assert_eq!(sbe_mantissa_precision(5, 2), 0);
2516        }
2517
2518        #[rstest]
2519        fn test_parse_sbe_price_filter_ethusdc() {
2520            let filter = BinancePriceFilterSbe {
2521                price_exponent: -8,
2522                min_price: 1_000_000,
2523                max_price: 100_000_000_000_000,
2524                tick_size: 1_000_000,
2525            };
2526
2527            let (tick_size, max_price, min_price) = parse_sbe_price_filter(&filter).unwrap();
2528            let max_price = max_price.unwrap();
2529            let min_price = min_price.unwrap();
2530
2531            assert_eq!(tick_size.precision, 2, "tick_size precision");
2532            assert_eq!(tick_size.as_decimal(), dec!(0.01));
2533            assert_eq!(max_price.precision, 2);
2534            assert_eq!(max_price.as_decimal(), dec!(1000000.00));
2535            assert_eq!(min_price.precision, 2);
2536            assert_eq!(min_price.as_decimal(), dec!(0.01));
2537        }
2538
2539        #[rstest]
2540        fn test_parse_sbe_price_filter_shibusdt() {
2541            let filter = BinancePriceFilterSbe {
2542                price_exponent: -8,
2543                min_price: 1,
2544                max_price: 100_000_000,
2545                tick_size: 1,
2546            };
2547
2548            let (tick_size, _, _) = parse_sbe_price_filter(&filter).unwrap();
2549
2550            assert_eq!(tick_size.precision, 8);
2551            assert_eq!(tick_size.as_decimal(), dec!(0.00000001));
2552        }
2553
2554        #[rstest]
2555        fn test_parse_sbe_lot_size_filter_ethusdc() {
2556            let filter = BinanceLotSizeFilterSbe {
2557                qty_exponent: -8,
2558                min_qty: 10_000,
2559                max_qty: 900_000_000_000,
2560                step_size: 10_000,
2561            };
2562
2563            let (step_size, max_qty, min_qty) = parse_sbe_lot_size_filter(&filter).unwrap();
2564            let max_qty = max_qty.unwrap();
2565            let min_qty = min_qty.unwrap();
2566
2567            assert_eq!(step_size.precision, 4, "step_size precision");
2568            assert_eq!(step_size.as_decimal(), dec!(0.0001));
2569            assert_eq!(min_qty.precision, 4);
2570            assert_eq!(min_qty.as_decimal(), dec!(0.0001));
2571            assert_eq!(max_qty.precision, 4);
2572            assert_eq!(max_qty.as_decimal(), dec!(9000.0000));
2573        }
2574    }
2575}