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