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::{AccountBalance, 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/// Converts an SBE `mantissa * 10^exponent` pair to a [`Decimal`] with exact integer scaling.
598///
599/// # Errors
600///
601/// Returns an error if the magnitude of a negative `exponent` exceeds [`Decimal`]'s maximum scale of 28, or if
602/// scaling by a positive exponent overflows. The conversion does not wrap and does not replace a
603/// nonzero mantissa with zero.
604pub(crate) fn sbe_mantissa_to_decimal(mantissa: i64, exponent: i8) -> anyhow::Result<Decimal> {
605    if exponent >= 0 {
606        let mut value = Decimal::from(mantissa);
607        for _ in 0..exponent {
608            value = value.checked_mul(Decimal::TEN).ok_or_else(|| {
609                anyhow::anyhow!("decimal overflow for mantissa {mantissa}, exponent {exponent}")
610            })?;
611        }
612
613        Ok(value)
614    } else {
615        let scale = u32::from(exponent.unsigned_abs());
616        Decimal::try_new(mantissa, scale).map_err(|e| {
617            anyhow::anyhow!("invalid decimal for mantissa {mantissa}, exponent {exponent}: {e}")
618        })
619    }
620}
621
622/// Builds an account balance from free and locked decimals.
623///
624/// # Errors
625///
626/// Returns an error if `free + locked` overflows, or if the total, locked, or derived free amount
627/// cannot be represented as [`Money`] for `currency`.
628pub(crate) fn account_balance_from_free_locked(
629    asset: &str,
630    free: Decimal,
631    locked: Decimal,
632    currency: Currency,
633) -> anyhow::Result<AccountBalance> {
634    let total = free
635        .checked_add(locked)
636        .ok_or_else(|| anyhow::anyhow!("decimal overflow adding free and locked for {asset}"))?;
637    AccountBalance::from_total_and_locked(total, locked, currency)
638        .with_context(|| format!("failed to build account balance for {asset}"))
639}
640
641/// Derives the number of significant decimal places from an SBE mantissa/exponent pair.
642///
643/// Binance SBE encodes values as `mantissa * 10^exponent` where `exponent` is a global
644/// fixed-point encoding parameter (typically -8), not the instrument's trading precision.
645/// The actual precision is determined by how many trailing zeros the mantissa carries.
646///
647/// # Examples
648///
649/// - ETHUSDC tick_size: mantissa=1_000_000, exp=-8 → 0.01 → precision=2
650/// - DOGEUSDT tick_size: mantissa=1_000, exp=-8 → 0.00001 → precision=5
651/// - SHIBUSDT tick_size: mantissa=1, exp=-8 → 0.00000001 → precision=8
652/// - BTCTRY tick_size: mantissa=100_000_000, exp=-8 → 1.0 → precision=0
653fn sbe_mantissa_precision(mantissa: i64, exponent: i8) -> u8 {
654    if mantissa == 0 {
655        return 0;
656    }
657    let mut m = mantissa.abs();
658    let mut trailing_zeros: i8 = 0;
659
660    while m > 0 && m % 10 == 0 {
661        m /= 10;
662        trailing_zeros += 1;
663    }
664    (-exponent - trailing_zeros).max(0) as u8
665}
666
667/// Parses an SBE price filter into tick_size, max_price, min_price.
668fn parse_sbe_price_filter(
669    filter: &BinancePriceFilterSbe,
670) -> anyhow::Result<(Price, Option<Price>, Option<Price>)> {
671    let precision = sbe_mantissa_precision(filter.tick_size, filter.price_exponent);
672
673    let tick_size =
674        Price::from_mantissa_exponent_checked(filter.tick_size, filter.price_exponent, precision)?;
675
676    let max_price = if filter.max_price != 0 {
677        Some(Price::from_mantissa_exponent_checked(
678            filter.max_price,
679            filter.price_exponent,
680            precision,
681        )?)
682    } else {
683        None
684    };
685
686    let min_price = if filter.min_price != 0 {
687        Some(Price::from_mantissa_exponent_checked(
688            filter.min_price,
689            filter.price_exponent,
690            precision,
691        )?)
692    } else {
693        None
694    };
695
696    Ok((tick_size, max_price, min_price))
697}
698
699/// Parses an SBE lot size filter into step_size, max_qty, min_qty.
700fn parse_sbe_lot_size_filter(
701    filter: &BinanceLotSizeFilterSbe,
702) -> anyhow::Result<(Quantity, Option<Quantity>, Option<Quantity>)> {
703    let precision = sbe_mantissa_precision(filter.step_size, filter.qty_exponent);
704
705    let step_size = Quantity::from_mantissa_exponent_checked(
706        filter.step_size as u64,
707        filter.qty_exponent,
708        precision,
709    )?;
710
711    let max_qty = if filter.max_qty != 0 {
712        Some(Quantity::from_mantissa_exponent_checked(
713            filter.max_qty as u64,
714            filter.qty_exponent,
715            precision,
716        )?)
717    } else {
718        None
719    };
720
721    let min_qty = if filter.min_qty != 0 {
722        Some(Quantity::from_mantissa_exponent_checked(
723            filter.min_qty as u64,
724            filter.qty_exponent,
725            precision,
726        )?)
727    } else {
728        None
729    };
730
731    Ok((step_size, max_qty, min_qty))
732}
733
734/// Parses a Binance Spot SBE symbol into a Nautilus CurrencyPair instrument.
735///
736/// # Errors
737///
738/// Returns an error if:
739/// - Required filter values are missing (PRICE_FILTER, LOT_SIZE).
740/// - Price or quantity values cannot be parsed.
741/// - The symbol is not actively trading.
742pub fn parse_spot_instrument_sbe(
743    symbol: &BinanceSymbolSbe,
744    ts_event: UnixNanos,
745    ts_init: UnixNanos,
746) -> anyhow::Result<InstrumentAny> {
747    parse_spot_instrument_sbe_with_fees(symbol, ts_event, ts_init)
748}
749
750pub(crate) fn parse_spot_instrument_sbe_with_fees(
751    symbol: &BinanceSymbolSbe,
752    ts_event: UnixNanos,
753    ts_init: UnixNanos,
754) -> anyhow::Result<InstrumentAny> {
755    if symbol.status != SBE_STATUS_TRADING {
756        anyhow::bail!(
757            "Symbol '{}' is not trading (status: {})",
758            symbol.symbol,
759            symbol.status
760        );
761    }
762
763    let base_currency = get_currency(&symbol.base_asset);
764    let quote_currency = get_currency(&symbol.quote_asset);
765
766    let instrument_id = InstrumentId::new(
767        Symbol::from_str_unchecked(&symbol.symbol),
768        Venue::new(BINANCE),
769    );
770    let raw_symbol = Symbol::new(&symbol.symbol);
771
772    let price_filter = symbol
773        .filters
774        .price_filter
775        .as_ref()
776        .context("Missing PRICE_FILTER in symbol filters")?;
777
778    let (tick_size, max_price, min_price) = parse_sbe_price_filter(price_filter)?;
779
780    let lot_filter = symbol
781        .filters
782        .lot_size_filter
783        .as_ref()
784        .context("Missing LOT_SIZE in symbol filters")?;
785
786    let (step_size, max_quantity, min_quantity) = parse_sbe_lot_size_filter(lot_filter)?;
787
788    let (min_notional, max_notional) =
789        parse_spot_notional_rules(&symbol.filters.notional_filters, quote_currency)?;
790
791    // Spot has no leverage, use 1.0 margin
792    let default_margin = Decimal::new(1, 0);
793
794    let instrument = CurrencyPair::builder()
795        .instrument_id(instrument_id)
796        .raw_symbol(raw_symbol)
797        .base_currency(base_currency)
798        .quote_currency(quote_currency)
799        .price_precision(tick_size.precision)
800        .size_precision(step_size.precision)
801        .price_increment(tick_size)
802        .size_increment(step_size)
803        .lot_size(step_size)
804        .maybe_max_quantity(max_quantity)
805        .maybe_min_quantity(min_quantity)
806        .maybe_min_notional(min_notional)
807        .maybe_max_notional(max_notional)
808        .maybe_max_price(max_price)
809        .maybe_min_price(min_price)
810        .margin_init(default_margin)
811        .margin_maint(default_margin)
812        .ts_event(ts_event)
813        .ts_init(ts_init)
814        .build()
815        .unwrap();
816
817    Ok(InstrumentAny::CurrencyPair(instrument))
818}
819
820pub(crate) fn parse_spot_instrument_json_with_fees(
821    symbol: &BinanceSymbolJson,
822    ts_event: UnixNanos,
823    ts_init: UnixNanos,
824) -> anyhow::Result<InstrumentAny> {
825    anyhow::ensure!(
826        symbol.status == "TRADING",
827        "Symbol '{}' is not trading (status: {})",
828        symbol.symbol,
829        symbol.status,
830    );
831
832    let price_filter = symbol
833        .filters
834        .iter()
835        .find(|filter| filter.filter_type == "PRICE_FILTER")
836        .context("Missing PRICE_FILTER in symbol filters")?;
837    let lot_filter = symbol
838        .filters
839        .iter()
840        .find(|filter| filter.filter_type == "LOT_SIZE")
841        .context("Missing LOT_SIZE in symbol filters")?;
842
843    let tick_size = decimal_price(
844        price_filter
845            .tick_size
846            .as_deref()
847            .context("Missing PRICE_FILTER tickSize")?,
848    )?;
849    anyhow::ensure!(!tick_size.is_zero(), "Invalid tickSize of 0");
850    let step_size = decimal_quantity(
851        lot_filter
852            .step_size
853            .as_deref()
854            .context("Missing LOT_SIZE stepSize")?,
855    )?;
856    anyhow::ensure!(!step_size.is_zero(), "Invalid stepSize of 0");
857
858    let quote_currency = get_currency(&symbol.quote_asset);
859    let mut rules = Vec::new();
860
861    for filter in &symbol.filters {
862        if !matches!(filter.filter_type.as_str(), "MIN_NOTIONAL" | "NOTIONAL") {
863            continue;
864        }
865
866        let range = filter.filter_type == "NOTIONAL";
867
868        rules.push(BinanceNotionalFilter {
869            min: Decimal::from_str_exact(
870                filter
871                    .min_notional
872                    .as_deref()
873                    .context("missing minNotional")?,
874            )?,
875
876            max: if range {
877                Some(Decimal::from_str_exact(
878                    filter
879                        .max_notional
880                        .as_deref()
881                        .context("missing maxNotional")?,
882                )?)
883            } else {
884                None
885            },
886
887            apply_min_to_market: if range {
888                filter.apply_min_to_market
889            } else {
890                filter.apply_to_market
891            }
892            .context("missing minimum notional market flag")?,
893
894            apply_max_to_market: if range {
895                filter
896                    .apply_max_to_market
897                    .context("missing applyMaxToMarket")?
898            } else {
899                false
900            },
901
902            avg_price_mins: filter.avg_price_mins.context("missing avgPriceMins")?,
903        });
904    }
905
906    let (min_notional, max_notional) = parse_spot_notional_rules(&rules, quote_currency)?;
907
908    let instrument = CurrencyPair::builder()
909        .instrument_id(InstrumentId::new(
910            Symbol::from_str_unchecked(&symbol.symbol),
911            Venue::new(BINANCE),
912        ))
913        .raw_symbol(Symbol::new(&symbol.symbol))
914        .base_currency(get_currency(&symbol.base_asset))
915        .quote_currency(quote_currency)
916        .maybe_min_notional(min_notional)
917        .maybe_max_notional(max_notional)
918        .price_precision(tick_size.precision)
919        .size_precision(step_size.precision)
920        .price_increment(tick_size)
921        .size_increment(step_size)
922        .lot_size(step_size)
923        .maybe_max_quantity(optional_decimal_quantity(
924            lot_filter.max_qty.as_deref(),
925            step_size.precision,
926        )?)
927        .maybe_min_quantity(optional_decimal_quantity(
928            lot_filter.min_qty.as_deref(),
929            step_size.precision,
930        )?)
931        .maybe_max_price(optional_decimal_price(
932            price_filter.max_price.as_deref(),
933            tick_size.precision,
934        )?)
935        .maybe_min_price(optional_decimal_price(
936            price_filter.min_price.as_deref(),
937            tick_size.precision,
938        )?)
939        .margin_init(Decimal::ONE)
940        .margin_maint(Decimal::ONE)
941        .ts_event(ts_event)
942        .ts_init(ts_init)
943        .build()
944        .unwrap();
945
946    Ok(InstrumentAny::CurrencyPair(instrument))
947}
948
949fn parse_spot_notional_rules(
950    rules: &[BinanceNotionalFilter],
951    currency: Currency,
952) -> anyhow::Result<(Option<Money>, Option<Money>)> {
953    let mut minimum: Option<Decimal> = None;
954    let mut maximum: Option<Decimal> = None;
955    for rule in rules {
956        anyhow::ensure!(rule.min >= Decimal::ZERO, "negative minimum notional");
957        minimum = Some(minimum.map_or(rule.min, |min| min.max(rule.min)));
958
959        if let Some(max) = rule.max {
960            anyhow::ensure!(max >= rule.min, "maximum notional is below minimum");
961            maximum = Some(maximum.map_or(max, |current| current.min(max)));
962        }
963    }
964
965    if let (Some(min), Some(max)) = (minimum, maximum) {
966        anyhow::ensure!(min <= max, "conflicting notional bounds");
967    }
968    Ok((
969        minimum
970            .map(|value| Money::from_decimal(value, currency))
971            .transpose()?,
972        maximum
973            .map(|value| Money::from_decimal(value, currency))
974            .transpose()?,
975    ))
976}
977
978fn decimal_price(value: &str) -> anyhow::Result<Price> {
979    let decimal = Decimal::from_str_exact(value)?.normalize();
980    let precision = u8::try_from(decimal.scale()).context("price precision exceeds u8")?;
981    Ok(Price::from_decimal_dp(decimal, precision)?)
982}
983
984fn decimal_quantity(value: &str) -> anyhow::Result<Quantity> {
985    let decimal = Decimal::from_str_exact(value)?.normalize();
986    let precision = u8::try_from(decimal.scale()).context("quantity precision exceeds u8")?;
987    Ok(Quantity::from_decimal_dp(decimal, precision)?)
988}
989
990fn optional_decimal_price(value: Option<&str>, precision: u8) -> anyhow::Result<Option<Price>> {
991    let Some(value) = value else {
992        return Ok(None);
993    };
994    let decimal = Decimal::from_str_exact(value)?;
995    if decimal.is_zero() {
996        return Ok(None);
997    }
998    Ok(Some(Price::from_decimal_dp(decimal, precision)?))
999}
1000
1001fn optional_decimal_quantity(
1002    value: Option<&str>,
1003    precision: u8,
1004) -> anyhow::Result<Option<Quantity>> {
1005    let Some(value) = value else {
1006        return Ok(None);
1007    };
1008    let decimal = Decimal::from_str_exact(value)?;
1009    if decimal.is_zero() {
1010        return Ok(None);
1011    }
1012    Ok(Some(Quantity::from_decimal_dp(decimal, precision)?))
1013}
1014
1015/// Parses Binance SBE trades into Nautilus TradeTick objects.
1016///
1017/// Uses mantissa/exponent encoding from SBE to construct proper Price and Quantity.
1018///
1019/// # Errors
1020///
1021/// Returns an error if any trade cannot be parsed.
1022pub fn parse_spot_trades_sbe(
1023    trades: &BinanceTrades,
1024    instrument: &InstrumentAny,
1025    ts_init: UnixNanos,
1026) -> anyhow::Result<Vec<TradeTick>> {
1027    let instrument_id = instrument.id();
1028    let price_precision = instrument.price_precision();
1029    let size_precision = instrument.size_precision();
1030
1031    let mut result = Vec::with_capacity(trades.trades.len());
1032
1033    for trade in &trades.trades {
1034        let price = Price::from_mantissa_exponent(
1035            trade.price_mantissa,
1036            trades.price_exponent,
1037            price_precision,
1038        );
1039        let size = Quantity::from_mantissa_exponent(
1040            trade.qty_mantissa as u64,
1041            trades.qty_exponent,
1042            size_precision,
1043        );
1044
1045        // is_buyer_maker means the buyer was the maker, so the aggressor was selling
1046        let aggressor_side = if trade.is_buyer_maker {
1047            AggressorSide::Sell
1048        } else {
1049            AggressorSide::Buy
1050        };
1051
1052        // SBE trade timestamps are in microseconds
1053        let ts_event = parse_micros(trade.time, "Spot SBE trade time")?;
1054
1055        let tick = TradeTick::new(
1056            instrument_id,
1057            price,
1058            size,
1059            aggressor_side,
1060            TradeId::new(trade.id.to_string()),
1061            ts_event,
1062            ts_init,
1063        );
1064
1065        result.push(tick);
1066    }
1067
1068    Ok(result)
1069}
1070
1071/// Maps Binance SBE order status to Nautilus order status.
1072///
1073/// # Errors
1074///
1075/// Returns an error if the venue value is unknown or absent.
1076pub fn map_order_status_sbe(status: SbeOrderStatus) -> anyhow::Result<OrderStatus> {
1077    Ok(match status {
1078        SbeOrderStatus::New => OrderStatus::Accepted,
1079        SbeOrderStatus::PendingNew => OrderStatus::Submitted,
1080        SbeOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1081        SbeOrderStatus::Filled => OrderStatus::Filled,
1082        SbeOrderStatus::Canceled => OrderStatus::Canceled,
1083        SbeOrderStatus::PendingCancel => OrderStatus::PendingCancel,
1084        SbeOrderStatus::Rejected => OrderStatus::Rejected,
1085        SbeOrderStatus::Expired | SbeOrderStatus::ExpiredInMatch => OrderStatus::Expired,
1086        SbeOrderStatus::Unknown | SbeOrderStatus::NonRepresentable | SbeOrderStatus::NullVal => {
1087            anyhow::bail!("unknown Binance SBE order status")
1088        }
1089    })
1090}
1091
1092/// Maps Binance SBE order type to Nautilus order type.
1093///
1094/// # Errors
1095///
1096/// Returns an error if the venue value is unknown or absent.
1097pub fn map_order_type_sbe(order_type: SbeOrderType) -> anyhow::Result<OrderType> {
1098    Ok(match order_type {
1099        SbeOrderType::Market => OrderType::Market,
1100        SbeOrderType::Limit | SbeOrderType::LimitMaker => OrderType::Limit,
1101        SbeOrderType::StopLoss => OrderType::StopMarket,
1102        SbeOrderType::StopLossLimit => OrderType::StopLimit,
1103        SbeOrderType::TakeProfit => OrderType::MarketIfTouched,
1104        SbeOrderType::TakeProfitLimit => OrderType::LimitIfTouched,
1105        SbeOrderType::NonRepresentable | SbeOrderType::NullVal => {
1106            anyhow::bail!("unknown Binance SBE order type")
1107        }
1108    })
1109}
1110
1111/// Maps Binance SBE order side to Nautilus order side.
1112#[must_use]
1113pub const fn map_order_side_sbe(side: SbeOrderSide) -> Option<OrderSide> {
1114    match side {
1115        SbeOrderSide::Buy => Some(OrderSide::Buy),
1116        SbeOrderSide::Sell => Some(OrderSide::Sell),
1117        SbeOrderSide::NonRepresentable | SbeOrderSide::NullVal => None,
1118    }
1119}
1120
1121/// Maps Binance SBE time in force to Nautilus time in force.
1122///
1123/// # Errors
1124///
1125/// Returns an error if the venue value is unknown or absent.
1126pub fn map_time_in_force_sbe(tif: SbeTimeInForce) -> anyhow::Result<TimeInForce> {
1127    Ok(match tif {
1128        SbeTimeInForce::Gtc => TimeInForce::Gtc,
1129        SbeTimeInForce::Ioc => TimeInForce::Ioc,
1130        SbeTimeInForce::Fok => TimeInForce::Fok,
1131        SbeTimeInForce::NonRepresentable | SbeTimeInForce::NullVal => {
1132            anyhow::bail!("unknown Binance SBE time in force")
1133        }
1134    })
1135}
1136
1137/// Parses a Binance SBE order response into a Nautilus `OrderStatusReport`.
1138///
1139/// # Errors
1140///
1141/// Returns an error if any field cannot be parsed.
1142pub fn parse_order_status_report_sbe(
1143    order: &BinanceOrderResponse,
1144    account_id: AccountId,
1145    instrument: &InstrumentAny,
1146    broker_id: &str,
1147    ts_init: UnixNanos,
1148) -> anyhow::Result<OrderStatusReport> {
1149    let instrument_id = instrument.id();
1150    let price_precision = instrument.price_precision();
1151    let size_precision = instrument.size_precision();
1152
1153    let price = if order.price_mantissa != 0 {
1154        Some(Price::from_mantissa_exponent(
1155            order.price_mantissa,
1156            order.price_exponent,
1157            price_precision,
1158        ))
1159    } else {
1160        None
1161    };
1162
1163    let quantity = Quantity::from_mantissa_exponent(
1164        order.orig_qty_mantissa as u64,
1165        order.qty_exponent,
1166        size_precision,
1167    );
1168    let filled_qty = Quantity::from_mantissa_exponent(
1169        order.executed_qty_mantissa as u64,
1170        order.qty_exponent,
1171        size_precision,
1172    );
1173
1174    // Calculate average price from cumulative quote qty / executed qty
1175    // This requires decimal arithmetic since we're dividing two mantissas
1176    let avg_px = if order.executed_qty_mantissa > 0 {
1177        let quote_exp = (order.price_exponent as i32) + (order.qty_exponent as i32);
1178        let cum_quote_dec = Decimal::new(order.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
1179        let filled_dec = Decimal::new(
1180            order.executed_qty_mantissa,
1181            (-order.qty_exponent as i32) as u32,
1182        );
1183        let avg_dec = cum_quote_dec / filled_dec;
1184        Some(
1185            Price::from_decimal_dp(avg_dec, price_precision)
1186                .unwrap_or(Price::zero(price_precision)),
1187        )
1188    } else {
1189        None
1190    };
1191
1192    // Parse trigger price for stop orders
1193    let trigger_price = order.stop_price_mantissa.and_then(|mantissa| {
1194        if mantissa != 0 {
1195            Some(Price::from_mantissa_exponent(
1196                mantissa,
1197                order.price_exponent,
1198                price_precision,
1199            ))
1200        } else {
1201            None
1202        }
1203    });
1204
1205    // Map enums
1206    let order_status = map_order_status_sbe(order.status)?;
1207    let order_type = map_order_type_sbe(order.order_type)?;
1208    let order_side = map_order_side_sbe(order.side);
1209    let time_in_force = map_time_in_force_sbe(order.time_in_force)?;
1210
1211    // Determine trigger type for stop orders
1212    let trigger_type = if trigger_price.is_some() {
1213        Some(TriggerType::LastPrice)
1214    } else {
1215        None
1216    };
1217
1218    // Parse timestamps (SBE uses microseconds)
1219    let ts_event = parse_micros(order.update_time, "Spot SBE order update time")?;
1220
1221    // Build order list ID if present
1222    let order_list_id = order.order_list_id.and_then(|id| {
1223        if id > 0 {
1224            Some(OrderListId::new(id.to_string()))
1225        } else {
1226            None
1227        }
1228    });
1229
1230    // Determine post-only (limit maker orders are post-only)
1231    let post_only = order.order_type == SbeOrderType::LimitMaker;
1232
1233    // Parse order creation time (SBE uses microseconds)
1234    let ts_accepted = parse_micros(order.time, "Spot SBE order time")?;
1235
1236    let mut report = OrderStatusReport::new(
1237        account_id,
1238        instrument_id,
1239        Some(decode_client_order_id(&order.client_order_id, broker_id)?),
1240        VenueOrderId::new(order.order_id.to_string()),
1241        order_side,
1242        order_type,
1243        time_in_force,
1244        order_status,
1245        quantity,
1246        filled_qty,
1247        ts_accepted,
1248        ts_event,
1249        ts_init,
1250        None, // report_id (auto-generated)
1251    );
1252
1253    // Apply optional fields using builder methods
1254    if let Some(p) = price {
1255        report = report.with_price(p);
1256    }
1257
1258    if let Some(ap) = avg_px {
1259        report = report.with_avg_px(ap.as_decimal());
1260    }
1261
1262    if let Some(tp) = trigger_price {
1263        report = report.with_trigger_price(tp);
1264    }
1265
1266    if let Some(tt) = trigger_type {
1267        report = report.with_trigger_type(tt);
1268    }
1269
1270    if let Some(oli) = order_list_id {
1271        report = report.with_order_list_id(oli);
1272    }
1273
1274    if post_only {
1275        report = report.with_post_only(true);
1276    }
1277
1278    Ok(report)
1279}
1280
1281/// Parses a Binance new order response (SBE) into a Nautilus `OrderStatusReport`.
1282///
1283/// # Errors
1284///
1285/// Returns an error if any field cannot be parsed.
1286pub fn parse_new_order_response_sbe(
1287    response: &BinanceNewOrderResponse,
1288    account_id: AccountId,
1289    instrument: &InstrumentAny,
1290    broker_id: &str,
1291    ts_init: UnixNanos,
1292) -> anyhow::Result<OrderStatusReport> {
1293    let instrument_id = instrument.id();
1294    let price_precision = instrument.price_precision();
1295    let size_precision = instrument.size_precision();
1296
1297    let price = if response.price_mantissa != 0 {
1298        Some(Price::from_mantissa_exponent(
1299            response.price_mantissa,
1300            response.price_exponent,
1301            price_precision,
1302        ))
1303    } else {
1304        None
1305    };
1306
1307    let quantity = Quantity::from_mantissa_exponent(
1308        response.orig_qty_mantissa as u64,
1309        response.qty_exponent,
1310        size_precision,
1311    );
1312    let filled_qty = Quantity::from_mantissa_exponent(
1313        response.executed_qty_mantissa as u64,
1314        response.qty_exponent,
1315        size_precision,
1316    );
1317
1318    // Calculate average price from cumulative quote qty / executed qty
1319    // This requires decimal arithmetic since we're dividing two mantissas
1320    let avg_px = if response.executed_qty_mantissa > 0 {
1321        let quote_exp = (response.price_exponent as i32) + (response.qty_exponent as i32);
1322        let cum_quote_dec =
1323            Decimal::new(response.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
1324        let filled_dec = Decimal::new(
1325            response.executed_qty_mantissa,
1326            (-response.qty_exponent as i32) as u32,
1327        );
1328        let avg_dec = cum_quote_dec / filled_dec;
1329        Some(
1330            Price::from_decimal_dp(avg_dec, price_precision)
1331                .unwrap_or(Price::zero(price_precision)),
1332        )
1333    } else {
1334        None
1335    };
1336
1337    let trigger_price = response.stop_price_mantissa.and_then(|mantissa| {
1338        if mantissa != 0 {
1339            Some(Price::from_mantissa_exponent(
1340                mantissa,
1341                response.price_exponent,
1342                price_precision,
1343            ))
1344        } else {
1345            None
1346        }
1347    });
1348
1349    let order_status = map_order_status_sbe(response.status)?;
1350    let order_type = map_order_type_sbe(response.order_type)?;
1351    let order_side = map_order_side_sbe(response.side);
1352    let time_in_force = map_time_in_force_sbe(response.time_in_force)?;
1353
1354    let trigger_type = if trigger_price.is_some() {
1355        Some(TriggerType::LastPrice)
1356    } else {
1357        None
1358    };
1359
1360    // SBE uses microseconds; for new orders transact_time is both creation and event time
1361    let ts_event = parse_micros(response.transact_time, "Spot SBE transaction time")?;
1362    let ts_accepted = ts_event;
1363
1364    let order_list_id = response.order_list_id.and_then(|id| {
1365        if id > 0 {
1366            Some(OrderListId::new(id.to_string()))
1367        } else {
1368            None
1369        }
1370    });
1371
1372    // Limit maker orders are post-only
1373    let post_only = response.order_type == SbeOrderType::LimitMaker;
1374
1375    let mut report = OrderStatusReport::new(
1376        account_id,
1377        instrument_id,
1378        Some(decode_client_order_id(
1379            &response.client_order_id,
1380            broker_id,
1381        )?),
1382        VenueOrderId::new(response.order_id.to_string()),
1383        order_side,
1384        order_type,
1385        time_in_force,
1386        order_status,
1387        quantity,
1388        filled_qty,
1389        ts_accepted,
1390        ts_event,
1391        ts_init,
1392        None,
1393    );
1394
1395    if let Some(p) = price {
1396        report = report.with_price(p);
1397    }
1398
1399    if let Some(ap) = avg_px {
1400        report = report.with_avg_px(ap.as_decimal());
1401    }
1402
1403    if let Some(tp) = trigger_price {
1404        report = report.with_trigger_price(tp);
1405    }
1406
1407    if let Some(tt) = trigger_type {
1408        report = report.with_trigger_type(tt);
1409    }
1410
1411    if let Some(oli) = order_list_id {
1412        report = report.with_order_list_id(oli);
1413    }
1414
1415    if post_only {
1416        report = report.with_post_only(true);
1417    }
1418
1419    Ok(report)
1420}
1421
1422/// Parses a Binance SBE account trade into a Nautilus `FillReport`.
1423///
1424/// # Errors
1425///
1426/// Returns an error if any field cannot be parsed.
1427pub fn parse_fill_report_sbe(
1428    trade: &BinanceAccountTrade,
1429    account_id: AccountId,
1430    instrument: &InstrumentAny,
1431    commission_currency: Currency,
1432    ts_init: UnixNanos,
1433) -> anyhow::Result<FillReport> {
1434    let instrument_id = instrument.id();
1435    let price_precision = instrument.price_precision();
1436    let size_precision = instrument.size_precision();
1437
1438    let last_px =
1439        Price::from_mantissa_exponent(trade.price_mantissa, trade.price_exponent, price_precision);
1440    let last_qty = Quantity::from_mantissa_exponent(
1441        trade.qty_mantissa as u64,
1442        trade.qty_exponent,
1443        size_precision,
1444    );
1445
1446    let comm_exp = trade.commission_exponent as i32;
1447    let comm_dec = Decimal::new(trade.commission_mantissa, (-comm_exp) as u32);
1448    let commission = Money::from_decimal(comm_dec, commission_currency)?;
1449
1450    // Determine order side from is_buyer
1451    let order_side = if trade.is_buyer {
1452        OrderSide::Buy
1453    } else {
1454        OrderSide::Sell
1455    };
1456
1457    // Determine liquidity side from is_maker
1458    let liquidity_side = if trade.is_maker {
1459        LiquiditySide::Maker
1460    } else {
1461        LiquiditySide::Taker
1462    };
1463
1464    // Parse timestamp (SBE uses microseconds)
1465    let ts_event = parse_micros(trade.time, "Spot SBE account trade time")?;
1466
1467    Ok(FillReport::new(
1468        account_id,
1469        instrument_id,
1470        VenueOrderId::new(trade.order_id.to_string()),
1471        TradeId::new(trade.id.to_string()),
1472        order_side,
1473        last_qty,
1474        last_px,
1475        commission,
1476        liquidity_side,
1477        None, // client_order_id (not in account trades response)
1478        None, // venue_position_id
1479        ts_event,
1480        ts_init,
1481        None, // report_id
1482    ))
1483}
1484
1485/// Parses Binance klines (candlesticks) into Nautilus Bar objects.
1486///
1487/// # Errors
1488///
1489/// Returns an error if any kline cannot be parsed.
1490pub fn parse_klines_to_binance_bars(
1491    klines: &BinanceKlines,
1492    bar_type: BarType,
1493    instrument: &InstrumentAny,
1494    ts_init: UnixNanos,
1495) -> anyhow::Result<Vec<crate::common::bar::BinanceBar>> {
1496    let price_precision = instrument.price_precision();
1497    let size_precision = instrument.size_precision();
1498
1499    let mut bars = Vec::with_capacity(klines.klines.len());
1500
1501    for kline in &klines.klines {
1502        let open =
1503            Price::from_mantissa_exponent(kline.open_price, klines.price_exponent, price_precision);
1504        let high =
1505            Price::from_mantissa_exponent(kline.high_price, klines.price_exponent, price_precision);
1506        let low =
1507            Price::from_mantissa_exponent(kline.low_price, klines.price_exponent, price_precision);
1508        let close = Price::from_mantissa_exponent(
1509            kline.close_price,
1510            klines.price_exponent,
1511            price_precision,
1512        );
1513
1514        let volume_mantissa = i128::from_le_bytes(kline.volume);
1515        let volume_dec =
1516            Decimal::from_i128_with_scale(volume_mantissa, (-klines.qty_exponent as i32) as u32);
1517        let volume = Quantity::from_decimal_dp(volume_dec, size_precision)?;
1518
1519        let quote_volume = Decimal::from_i128_with_scale(
1520            i128::from_le_bytes(kline.quote_volume),
1521            (-klines.price_exponent as i32) as u32,
1522        );
1523        let taker_buy_base_volume = Decimal::from_i128_with_scale(
1524            i128::from_le_bytes(kline.taker_buy_base_volume),
1525            (-klines.qty_exponent as i32) as u32,
1526        );
1527        let taker_buy_quote_volume = Decimal::from_i128_with_scale(
1528            i128::from_le_bytes(kline.taker_buy_quote_volume),
1529            (-klines.price_exponent as i32) as u32,
1530        );
1531        let count = u64::try_from(kline.num_trades).map_err(|_| {
1532            anyhow::anyhow!("invalid negative kline trade count {}", kline.num_trades)
1533        })?;
1534        let ts_event = parse_micros(kline.close_time, "Spot SBE kline close time")?;
1535
1536        let bar = crate::common::bar::BinanceBar::new(
1537            bar_type,
1538            open,
1539            high,
1540            low,
1541            close,
1542            volume,
1543            quote_volume,
1544            count,
1545            taker_buy_base_volume,
1546            taker_buy_quote_volume,
1547            ts_event,
1548            ts_init,
1549        );
1550        bars.push(bar);
1551    }
1552
1553    Ok(bars)
1554}
1555
1556/// Parses Binance SBE klines into core bars.
1557///
1558/// # Errors
1559///
1560/// Returns an error if any kline cannot be parsed.
1561pub fn parse_klines_to_bars(
1562    klines: &BinanceKlines,
1563    bar_type: BarType,
1564    instrument: &InstrumentAny,
1565    ts_init: UnixNanos,
1566) -> anyhow::Result<Vec<Bar>> {
1567    Ok(
1568        parse_klines_to_binance_bars(klines, bar_type, instrument, ts_init)?
1569            .into_iter()
1570            .map(|bar| bar.bar())
1571            .collect(),
1572    )
1573}
1574
1575/// Converts a Nautilus bar specification to a Binance kline interval.
1576///
1577/// # Errors
1578///
1579/// Returns an error if the bar specification does not map to a supported
1580/// Binance kline interval.
1581pub fn bar_spec_to_binance_interval(
1582    bar_spec: BarSpecification,
1583) -> anyhow::Result<BinanceKlineInterval> {
1584    let step = bar_spec.step.get();
1585    let interval = match bar_spec.aggregation {
1586        BarAggregation::Second => match step {
1587            1 => BinanceKlineInterval::Second1,
1588            _ => anyhow::bail!("Unsupported second interval: {step}s"),
1589        },
1590        BarAggregation::Minute => match step {
1591            1 => BinanceKlineInterval::Minute1,
1592            3 => BinanceKlineInterval::Minute3,
1593            5 => BinanceKlineInterval::Minute5,
1594            15 => BinanceKlineInterval::Minute15,
1595            30 => BinanceKlineInterval::Minute30,
1596            _ => anyhow::bail!("Unsupported minute interval: {step}m"),
1597        },
1598        BarAggregation::Hour => match step {
1599            1 => BinanceKlineInterval::Hour1,
1600            2 => BinanceKlineInterval::Hour2,
1601            4 => BinanceKlineInterval::Hour4,
1602            6 => BinanceKlineInterval::Hour6,
1603            8 => BinanceKlineInterval::Hour8,
1604            12 => BinanceKlineInterval::Hour12,
1605            _ => anyhow::bail!("Unsupported hour interval: {step}h"),
1606        },
1607        BarAggregation::Day => match step {
1608            1 => BinanceKlineInterval::Day1,
1609            3 => BinanceKlineInterval::Day3,
1610            _ => anyhow::bail!("Unsupported day interval: {step}d"),
1611        },
1612        BarAggregation::Week => match step {
1613            1 => BinanceKlineInterval::Week1,
1614            _ => anyhow::bail!("Unsupported week interval: {step}w"),
1615        },
1616        BarAggregation::Month => match step {
1617            1 => BinanceKlineInterval::Month1,
1618            _ => anyhow::bail!("Unsupported month interval: {step}M"),
1619        },
1620        agg => anyhow::bail!("Unsupported bar aggregation for Binance: {agg:?}"),
1621    };
1622
1623    Ok(interval)
1624}
1625
1626pub(crate) fn quote_to_l1_deltas(quote: QuoteTick, sequence: u64) -> OrderBookDeltas {
1627    let bid_action = if quote.bid_size.is_zero() {
1628        BookAction::Delete
1629    } else {
1630        BookAction::Update
1631    };
1632    let ask_action = if quote.ask_size.is_zero() {
1633        BookAction::Delete
1634    } else {
1635        BookAction::Update
1636    };
1637    let bid = OrderBookDelta::new(
1638        quote.instrument_id,
1639        bid_action,
1640        BookOrder::new(OrderSide::Buy, quote.bid_price, quote.bid_size, 0),
1641        RecordFlag::F_MBP as u8,
1642        sequence,
1643        quote.ts_event,
1644        quote.ts_init,
1645    );
1646    let ask = OrderBookDelta::new(
1647        quote.instrument_id,
1648        ask_action,
1649        BookOrder::new(OrderSide::Sell, quote.ask_price, quote.ask_size, 0),
1650        RecordFlag::F_MBP as u8 | RecordFlag::F_LAST as u8,
1651        sequence,
1652        quote.ts_event,
1653        quote.ts_init,
1654    );
1655
1656    OrderBookDeltas::new(quote.instrument_id, vec![bid, ask])
1657}
1658
1659#[cfg(test)]
1660mod tests {
1661    use nautilus_model::identifiers::ClientOrderId;
1662    use rstest::rstest;
1663    use rust_decimal_macros::dec;
1664    use serde_json::json;
1665    use ustr::Ustr;
1666
1667    use super::*;
1668    use crate::{
1669        common::{
1670            consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
1671            enums::{BinanceContractStatus, BinanceTradingStatus},
1672            testing::load_fixture_string,
1673        },
1674        spot::websocket::trading::{
1675            parse::parse_spot_exec_report_to_order_status, user_data::BinanceSpotExecutionReport,
1676        },
1677    };
1678
1679    #[rstest]
1680    #[case(SbeOrderType::TakeProfit, "TAKE_PROFIT", OrderType::MarketIfTouched)]
1681    #[case(
1682        SbeOrderType::TakeProfitLimit,
1683        "TAKE_PROFIT_LIMIT",
1684        OrderType::LimitIfTouched
1685    )]
1686    #[case(SbeOrderType::StopLoss, "STOP_LOSS", OrderType::StopMarket)]
1687    #[case(SbeOrderType::StopLossLimit, "STOP_LOSS_LIMIT", OrderType::StopLimit)]
1688    fn test_sbe_order_type_matches_json_report(
1689        #[case] sbe_order_type: SbeOrderType,
1690        #[case] json_order_type: &str,
1691        #[case] expected: OrderType,
1692    ) {
1693        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
1694        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
1695        msg.order_type = json_order_type.to_string();
1696        let report = parse_spot_exec_report_to_order_status(
1697            &msg,
1698            InstrumentId::from("ETHUSDT.BINANCE"),
1699            2,
1700            5,
1701            sample_account_id(),
1702            false,
1703            UnixNanos::default(),
1704        )
1705        .unwrap();
1706
1707        assert_eq!(report.order_type, expected);
1708        assert_eq!(
1709            map_order_type_sbe(sbe_order_type).unwrap(),
1710            report.order_type
1711        );
1712    }
1713
1714    #[rstest]
1715    fn test_sbe_order_mappings_reject_unknown_values() {
1716        for status in [
1717            SbeOrderStatus::Unknown,
1718            SbeOrderStatus::NonRepresentable,
1719            SbeOrderStatus::NullVal,
1720        ] {
1721            assert_eq!(
1722                map_order_status_sbe(status).unwrap_err().to_string(),
1723                "unknown Binance SBE order status"
1724            );
1725        }
1726
1727        for order_type in [SbeOrderType::NonRepresentable, SbeOrderType::NullVal] {
1728            assert_eq!(
1729                map_order_type_sbe(order_type).unwrap_err().to_string(),
1730                "unknown Binance SBE order type"
1731            );
1732        }
1733
1734        for tif in [SbeTimeInForce::NonRepresentable, SbeTimeInForce::NullVal] {
1735            assert_eq!(
1736                map_time_in_force_sbe(tif).unwrap_err().to_string(),
1737                "unknown Binance SBE time in force"
1738            );
1739        }
1740    }
1741
1742    #[rstest]
1743    fn test_quote_to_l1_deltas_maps_all_fields() {
1744        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
1745        let ts_event = UnixNanos::from(1_700_000_000_000_000_001u64);
1746        let ts_init = UnixNanos::from(1_700_000_000_000_000_002u64);
1747        let quote = QuoteTick::new(
1748            instrument_id,
1749            Price::from("42000.01"),
1750            Price::from("42000.02"),
1751            Quantity::from("1.23456"),
1752            Quantity::from("2.34567"),
1753            ts_event,
1754            ts_init,
1755        );
1756        let expected = OrderBookDeltas::new(
1757            instrument_id,
1758            vec![
1759                OrderBookDelta::new(
1760                    instrument_id,
1761                    BookAction::Update,
1762                    BookOrder::new(
1763                        OrderSide::Buy,
1764                        Price::from("42000.01"),
1765                        Quantity::from("1.23456"),
1766                        0,
1767                    ),
1768                    RecordFlag::F_MBP as u8,
1769                    12345,
1770                    ts_event,
1771                    ts_init,
1772                ),
1773                OrderBookDelta::new(
1774                    instrument_id,
1775                    BookAction::Update,
1776                    BookOrder::new(
1777                        OrderSide::Sell,
1778                        Price::from("42000.02"),
1779                        Quantity::from("2.34567"),
1780                        0,
1781                    ),
1782                    RecordFlag::F_MBP as u8 | RecordFlag::F_LAST as u8,
1783                    12345,
1784                    ts_event,
1785                    ts_init,
1786                ),
1787            ],
1788        );
1789
1790        let actual = quote_to_l1_deltas(quote, 12345);
1791
1792        assert_eq!(actual, expected);
1793    }
1794
1795    #[rstest]
1796    fn test_quote_to_l1_deltas_deletes_empty_sides() {
1797        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
1798        let quote = QuoteTick::new(
1799            instrument_id,
1800            Price::from("42000.01"),
1801            Price::from("42000.02"),
1802            Quantity::from("0.00000"),
1803            Quantity::from("0.00000"),
1804            UnixNanos::from(1_700_000_000_000_000_001u64),
1805            UnixNanos::from(1_700_000_000_000_000_002u64),
1806        );
1807
1808        let actual = quote_to_l1_deltas(quote, 12345);
1809
1810        assert_eq!(actual.deltas[0].action, BookAction::Delete);
1811        assert_eq!(actual.deltas[1].action, BookAction::Delete);
1812    }
1813
1814    #[rstest]
1815    #[case::positive("0.001", 8, Some(Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap()))]
1816    #[case::trailing_zero("0.00100000", 8, Some(Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap()))]
1817    #[case::zero("0", 8, None)]
1818    #[case::negative("-1", 8, None)]
1819    #[case::empty("", 8, None)]
1820    #[case::garbage("abc", 8, None)]
1821    fn test_parse_quantity_at_precision(
1822        #[case] raw: &str,
1823        #[case] precision: u8,
1824        #[case] expected: Option<Quantity>,
1825    ) {
1826        assert_eq!(parse_quantity_at_precision(raw, precision), expected);
1827    }
1828
1829    #[rstest]
1830    #[case::positive("7100.50", 2, Some(Price::from_decimal_dp(Decimal::from_str("7100.50").unwrap(), 2).unwrap()))]
1831    #[case::high_precision("0.000000001", 9, Some(Price::from_decimal_dp(Decimal::from_str("0.000000001").unwrap(), 9).unwrap()))]
1832    #[case::zero("0", 2, None)]
1833    #[case::negative("-100", 2, None)]
1834    #[case::empty("", 2, None)]
1835    fn test_parse_price_at_precision(
1836        #[case] raw: &str,
1837        #[case] precision: u8,
1838        #[case] expected: Option<Price>,
1839    ) {
1840        assert_eq!(parse_price_at_precision(raw, precision), expected);
1841    }
1842
1843    #[rstest]
1844    fn test_quantity_at_precision_re_precisions_via_decimal() {
1845        let original = Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 3).unwrap();
1846        let widened = quantity_at_precision(original, 8).unwrap();
1847        let expected = Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap();
1848        assert_eq!(widened, expected);
1849    }
1850
1851    #[rstest]
1852    fn test_price_at_precision_re_precisions_via_decimal() {
1853        let original = Price::from_decimal_dp(Decimal::from_str("7100.5").unwrap(), 1).unwrap();
1854        let widened = price_at_precision(original, 8).unwrap();
1855        let expected = Price::from_decimal_dp(Decimal::from_str("7100.5").unwrap(), 8).unwrap();
1856        assert_eq!(widened, expected);
1857    }
1858
1859    fn sample_usdm_symbol() -> BinanceFuturesUsdSymbol {
1860        BinanceFuturesUsdSymbol {
1861            symbol: Ustr::from("BTCUSDT"),
1862            pair: Ustr::from("BTCUSDT"),
1863            contract_type: "PERPETUAL".to_string(),
1864            delivery_date: 4133404800000,
1865            onboard_date: 1569398400000,
1866            status: BinanceTradingStatus::Trading,
1867            maint_margin_percent: "2.5000".to_string(),
1868            required_margin_percent: "5.0000".to_string(),
1869            base_asset: Ustr::from("BTC"),
1870            quote_asset: Ustr::from("USDT"),
1871            margin_asset: Ustr::from("USDT"),
1872            price_precision: 2,
1873            quantity_precision: 3,
1874            base_asset_precision: 8,
1875            quote_precision: 8,
1876            underlying_type: Some("COIN".to_string()),
1877            underlying_sub_type: vec!["PoW".to_string()],
1878            settle_plan: None,
1879            trigger_protect: Some("0.0500".to_string()),
1880            liquidation_fee: Some("0.012500".to_string()),
1881            market_take_bound: Some("0.05".to_string()),
1882            order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
1883            time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
1884            filters: vec![
1885                json!({
1886                    "filterType": "PRICE_FILTER",
1887                    "tickSize": "0.10",
1888                    "maxPrice": "4529764",
1889                    "minPrice": "556.80"
1890                }),
1891                json!({
1892                    "filterType": "LOT_SIZE",
1893                    "stepSize": "0.001",
1894                    "maxQty": "1000",
1895                    "minQty": "0.001"
1896                }),
1897                json!({
1898                    "filterType": "MIN_NOTIONAL",
1899                    "notional": "5"
1900                }),
1901            ],
1902        }
1903    }
1904
1905    fn sample_tradifi_usdm_symbol(
1906        symbol: &str,
1907        underlying: &str,
1908        underlying_type: Option<&str>,
1909    ) -> BinanceFuturesUsdSymbol {
1910        let mut definition = sample_usdm_symbol();
1911        definition.symbol = Ustr::from(symbol);
1912        definition.pair = Ustr::from(symbol);
1913        definition.contract_type = CONTRACT_TYPE_TRADIFI_PERPETUAL.to_string();
1914        definition.base_asset = Ustr::from(underlying);
1915        definition.underlying_type = underlying_type.map(str::to_string);
1916        definition
1917    }
1918
1919    fn sample_coinm_symbol() -> BinanceFuturesCoinSymbol {
1920        BinanceFuturesCoinSymbol {
1921            symbol: Ustr::from("BTCUSD_PERP"),
1922            pair: Ustr::from("BTCUSD"),
1923            contract_type: "PERPETUAL".to_string(),
1924            delivery_date: 4_133_404_800_000,
1925            onboard_date: 1_569_398_400_000,
1926            contract_status: Some(BinanceContractStatus::Trading),
1927            contract_size: 100,
1928            maint_margin_percent: "2.5000".to_string(),
1929            required_margin_percent: "5.0000".to_string(),
1930            base_asset: Ustr::from("BTC"),
1931            quote_asset: Ustr::from("USD"),
1932            margin_asset: Ustr::from("BTC"),
1933            price_precision: 1,
1934            quantity_precision: 0,
1935            base_asset_precision: 8,
1936            quote_precision: 8,
1937            equal_qty_precision: None,
1938            trigger_protect: Some("0.0500".to_string()),
1939            liquidation_fee: Some("0.012500".to_string()),
1940            market_take_bound: Some("0.05".to_string()),
1941            order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
1942            time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
1943            filters: vec![
1944                json!({
1945                    "filterType": "PRICE_FILTER",
1946                    "tickSize": "0.10",
1947                    "maxPrice": "1000000",
1948                    "minPrice": "0.10"
1949                }),
1950                json!({
1951                    "filterType": "LOT_SIZE",
1952                    "stepSize": "1",
1953                    "maxQty": "1000",
1954                    "minQty": "1"
1955                }),
1956                json!({
1957                    "filterType": "MIN_NOTIONAL",
1958                    "notional": "1"
1959                }),
1960            ],
1961        }
1962    }
1963
1964    fn sample_spot_symbol_sbe() -> BinanceSymbolSbe {
1965        BinanceSymbolSbe {
1966            symbol: "ETHUSDT".to_string(),
1967            base_asset: "ETH".to_string(),
1968            quote_asset: "USDT".to_string(),
1969            base_asset_precision: 8,
1970            quote_asset_precision: 8,
1971            status: SBE_STATUS_TRADING,
1972            order_types: 0,
1973            iceberg_allowed: true,
1974            oco_allowed: true,
1975            oto_allowed: false,
1976            quote_order_qty_market_allowed: true,
1977            allow_trailing_stop: true,
1978            cancel_replace_allowed: true,
1979            amend_allowed: true,
1980            is_spot_trading_allowed: true,
1981            is_margin_trading_allowed: false,
1982            filters: crate::spot::http::models::BinanceSymbolFiltersSbe {
1983                notional_filters: Vec::new(),
1984                price_filter: Some(BinancePriceFilterSbe {
1985                    price_exponent: -8,
1986                    min_price: 1_000_000,
1987                    max_price: 100_000_000_000_000,
1988                    tick_size: 1_000_000,
1989                }),
1990                lot_size_filter: Some(BinanceLotSizeFilterSbe {
1991                    qty_exponent: -8,
1992                    min_qty: 10_000,
1993                    max_qty: 900_000_000_000,
1994                    step_size: 10_000,
1995                }),
1996            },
1997            permissions: vec![vec!["SPOT".to_string()]],
1998        }
1999    }
2000
2001    #[rstest]
2002    #[case::min(
2003        include_bytes!("../../test_data/spot/http_sbe/notional_min.sbe").as_slice(),
2004        include_str!("../../test_data/spot/http_json/notional_min.json"),
2005        dec!(12.34567891), None,
2006    )]
2007    #[case::range(
2008        include_bytes!("../../test_data/spot/http_sbe/notional_range.sbe").as_slice(),
2009        include_str!("../../test_data/spot/http_json/notional_range.json"),
2010        dec!(23.45678912), Some(dec!(98.76543219)),
2011    )]
2012    #[case::both(
2013        include_bytes!("../../test_data/spot/http_sbe/notional_both.sbe").as_slice(),
2014        include_str!("../../test_data/spot/http_json/notional_both.json"),
2015        dec!(23.45678912), Some(dec!(98.76543219)),
2016    )]
2017    fn test_spot_notional_fixtures(
2018        #[case] wire: &[u8],
2019        #[case] json: &str,
2020        #[case] minimum: Decimal,
2021        #[case] maximum: Option<Decimal>,
2022    ) {
2023        let decoded = crate::spot::http::parse::decode_exchange_info(wire).unwrap();
2024        let symbol: BinanceSymbolJson = serde_json::from_str(json).unwrap();
2025        let ts_event = UnixNanos::from(123u64);
2026        let ts_init = UnixNanos::from(456u64);
2027        let sbe =
2028            parse_spot_instrument_sbe_with_fees(&decoded.symbols[0], ts_event, ts_init).unwrap();
2029        let json = parse_spot_instrument_json_with_fees(&symbol, ts_event, ts_init).unwrap();
2030        let InstrumentAny::CurrencyPair(mut expected) =
2031            parse_spot_instrument_sbe_with_fees(&sample_spot_symbol_sbe(), ts_event, ts_init)
2032                .unwrap()
2033        else {
2034            panic!("Expected CurrencyPair")
2035        };
2036        let rules = decoded.symbols[0].filters.notional_filters.clone();
2037        expected.min_notional =
2038            Some(Money::from_decimal(minimum, expected.quote_currency).unwrap());
2039        expected.max_notional =
2040            maximum.map(|value| Money::from_decimal(value, expected.quote_currency).unwrap());
2041        let expected = serde_json::to_value(InstrumentAny::CurrencyPair(expected)).unwrap();
2042
2043        assert_eq!(serde_json::to_value(sbe).unwrap(), expected);
2044        assert_eq!(serde_json::to_value(json).unwrap(), expected);
2045        assert_eq!(
2046            rules[0].min,
2047            if rules.len() == 2 || maximum.is_none() {
2048                dec!(12.34567891)
2049            } else {
2050                minimum
2051            }
2052        );
2053
2054        for rule in &rules {
2055            if rule.max.is_none() {
2056                assert_eq!(
2057                    rule,
2058                    &BinanceNotionalFilter {
2059                        min: dec!(12.34567891),
2060                        max: None,
2061                        apply_min_to_market: true,
2062                        apply_max_to_market: false,
2063                        avg_price_mins: 7
2064                    }
2065                );
2066            } else {
2067                assert_eq!(
2068                    rule,
2069                    &BinanceNotionalFilter {
2070                        min: dec!(23.45678912),
2071                        max: Some(dec!(98.76543219)),
2072                        apply_min_to_market: false,
2073                        apply_max_to_market: true,
2074                        avg_price_mins: 3
2075                    }
2076                );
2077            }
2078        }
2079    }
2080
2081    #[rstest]
2082    #[case("minNotional", "missing minNotional")]
2083    #[case("maxNotional", "missing maxNotional")]
2084    #[case("applyMinToMarket", "missing minimum notional market flag")]
2085    #[case("applyMaxToMarket", "missing applyMaxToMarket")]
2086    #[case("avgPriceMins", "missing avgPriceMins")]
2087    fn test_spot_notional_json_missing_fields(#[case] field: &str, #[case] error: &str) {
2088        let mut value: Value = serde_json::from_str(include_str!(
2089            "../../test_data/spot/http_json/notional_range.json"
2090        ))
2091        .unwrap();
2092        value["filters"][2].as_object_mut().unwrap().remove(field);
2093        let symbol = serde_json::from_value(value).unwrap();
2094        let result = parse_spot_instrument_json_with_fees(
2095            &symbol,
2096            UnixNanos::default(),
2097            UnixNanos::default(),
2098        );
2099        assert_eq!(result.unwrap_err().to_string(), error);
2100    }
2101
2102    #[rstest]
2103    #[case(vec![(dec!(-1), None)], "negative minimum notional")]
2104    #[case(vec![(dec!(2), Some(dec!(1)))], "maximum notional is below minimum")]
2105    #[case(vec![(dec!(2), None), (dec!(0), Some(dec!(1)))], "conflicting notional bounds")]
2106    fn test_spot_notional_invalid_bounds(
2107        #[case] bounds: Vec<(Decimal, Option<Decimal>)>,
2108        #[case] error: &str,
2109    ) {
2110        let rules = bounds
2111            .into_iter()
2112            .map(|(min, max)| BinanceNotionalFilter {
2113                min,
2114                max,
2115                apply_min_to_market: false,
2116                apply_max_to_market: true,
2117                avg_price_mins: 7,
2118            })
2119            .collect::<Vec<_>>();
2120
2121        let result = parse_spot_notional_rules(&rules, Currency::USD());
2122
2123        assert_eq!(result.unwrap_err().to_string(), error);
2124    }
2125
2126    fn sample_spot_instrument() -> InstrumentAny {
2127        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2128        parse_spot_instrument_sbe(&sample_spot_symbol_sbe(), ts, ts).unwrap()
2129    }
2130
2131    fn sample_account_id() -> AccountId {
2132        AccountId::from("BINANCE-SPOT-001")
2133    }
2134
2135    #[rstest]
2136    fn test_parse_usdm_perpetual() {
2137        let symbol = sample_usdm_symbol();
2138        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2139
2140        let result = parse_usdm_instrument(&symbol, ts, ts);
2141        assert!(result.is_ok(), "Failed: {:?}", result.err());
2142
2143        let instrument = result.unwrap();
2144        match instrument {
2145            InstrumentAny::CryptoPerpetual(perp) => {
2146                assert_eq!(perp.id.to_string(), "BTCUSDT-PERP.BINANCE");
2147                assert_eq!(perp.raw_symbol.to_string(), "BTCUSDT");
2148                assert_eq!(perp.base_currency.code, "BTC");
2149                assert_eq!(perp.quote_currency.code, "USDT");
2150                assert_eq!(perp.settlement_currency.code, "USDT");
2151                assert!(!perp.is_inverse);
2152                assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
2153                assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
2154                assert_eq!(
2155                    perp.min_notional,
2156                    Some(Money::new(5.0, perp.quote_currency)),
2157                );
2158            }
2159            other => panic!("Expected CryptoPerpetual, was {other:?}"),
2160        }
2161    }
2162
2163    #[rstest]
2164    #[case::equity("SNDKUSDT", "SNDK", "EQUITY", AssetClass::Equity)]
2165    #[case::chinese_equity("UNITREEUSDT", "UNITREE", "CN_EQUITY", AssetClass::Equity)]
2166    #[case::korean_equity("005930USDT", "005930", "KR_EQUITY", AssetClass::Equity)]
2167    #[case::hong_kong_equity("0700USDT", "0700", "HK_EQUITY", AssetClass::Equity)]
2168    #[case::premarket("SPCXUSDT", "SPCX", "PREMARKET", AssetClass::Equity)]
2169    #[case::commodity("XAUUSDT", "XAU", "COMMODITY", AssetClass::Commodity)]
2170    fn test_parse_usdm_tradifi_perpetual(
2171        #[case] raw_symbol: &str,
2172        #[case] underlying: &str,
2173        #[case] underlying_type: &str,
2174        #[case] expected_asset_class: AssetClass,
2175    ) {
2176        let symbol = sample_tradifi_usdm_symbol(raw_symbol, underlying, Some(underlying_type));
2177        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2178
2179        let instrument = parse_usdm_instrument(&symbol, ts, ts).unwrap();
2180        match instrument {
2181            InstrumentAny::PerpetualContract(perp) => {
2182                assert_eq!(perp.id.to_string(), format!("{raw_symbol}-PERP.BINANCE"));
2183                assert_eq!(perp.raw_symbol.to_string(), raw_symbol);
2184                assert_eq!(perp.underlying, Ustr::from(underlying));
2185                assert_eq!(perp.asset_class, expected_asset_class);
2186                assert_eq!(perp.base_currency, None);
2187                assert_eq!(perp.quote_currency.code, "USDT");
2188                assert_eq!(perp.settlement_currency.code, "USDT");
2189                assert!(!perp.is_inverse);
2190                assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
2191                assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
2192                assert_eq!(
2193                    perp.min_notional,
2194                    Some(Money::new(5.0, perp.quote_currency)),
2195                );
2196            }
2197            other => panic!("Expected PerpetualContract, was {other:?}"),
2198        }
2199    }
2200
2201    #[rstest]
2202    #[case::missing(
2203        None,
2204        "Missing underlying type for TRADIFI_PERPETUAL symbol 'SNDKUSDT'"
2205    )]
2206    #[case::unknown(
2207        Some("INDEX"),
2208        "Unsupported underlying type 'INDEX' for TRADIFI_PERPETUAL symbol 'SNDKUSDT'"
2209    )]
2210    fn test_parse_usdm_tradifi_perpetual_rejects_invalid_underlying_type(
2211        #[case] underlying_type: Option<&str>,
2212        #[case] expected_error: &str,
2213    ) {
2214        let symbol = sample_tradifi_usdm_symbol("SNDKUSDT", "SNDK", underlying_type);
2215        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2216
2217        let error = parse_usdm_instrument(&symbol, ts, ts).unwrap_err();
2218        assert_eq!(error.to_string(), expected_error);
2219    }
2220
2221    #[rstest]
2222    #[case::current_week(CONTRACT_TYPE_CURRENT_WEEK)]
2223    #[case::next_week(CONTRACT_TYPE_NEXT_WEEK)]
2224    #[case::current_month(CONTRACT_TYPE_CURRENT_MONTH)]
2225    #[case::next_month(CONTRACT_TYPE_NEXT_MONTH)]
2226    #[case::current_quarter(CONTRACT_TYPE_CURRENT_QUARTER)]
2227    #[case::next_quarter(CONTRACT_TYPE_NEXT_QUARTER)]
2228    fn test_parse_usdm_delivery(#[case] contract_type: &str) {
2229        let mut symbol = sample_usdm_symbol();
2230        symbol.symbol = Ustr::from("BTCUSDT_260925");
2231        symbol.contract_type = contract_type.to_string();
2232        symbol.onboard_date = 1_774_598_400_000;
2233        symbol.delivery_date = 1_790_323_200_000;
2234        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2235
2236        let result = parse_usdm_instrument(&symbol, ts, ts).unwrap();
2237        let InstrumentAny::CryptoFuture(future) = result else {
2238            panic!("Expected CryptoFuture, was {result:?}");
2239        };
2240
2241        assert_eq!(future.id.to_string(), "BTCUSDT_260925.BINANCE");
2242        assert_eq!(future.raw_symbol.to_string(), "BTCUSDT_260925");
2243        assert_eq!(future.underlying.code, "BTC");
2244        assert_eq!(future.quote_currency.code, "USDT");
2245        assert_eq!(future.settlement_currency.code, "USDT");
2246        assert!(!future.is_inverse);
2247        assert_eq!(
2248            future.activation_ns,
2249            UnixNanos::from_millis(1_774_598_400_000)
2250        );
2251        assert_eq!(
2252            future.expiration_ns,
2253            UnixNanos::from_millis(1_790_323_200_000)
2254        );
2255        assert_eq!(future.price_increment, Price::from_str("0.10").unwrap());
2256        assert_eq!(future.size_increment, Quantity::from_str("0.001").unwrap());
2257        assert_eq!(future.multiplier, Quantity::from(1));
2258        assert_eq!(
2259            future.max_quantity,
2260            Some(Quantity::from_str("1000").unwrap())
2261        );
2262        assert_eq!(
2263            future.min_quantity,
2264            Some(Quantity::from_str("0.001").unwrap())
2265        );
2266        assert_eq!(
2267            future.min_notional,
2268            Some(Money::new(5.0, future.quote_currency)),
2269        );
2270        assert_eq!(future.max_price, Some(Price::from_str("4529764").unwrap()));
2271        assert_eq!(future.min_price, Some(Price::from_str("556.80").unwrap()));
2272    }
2273
2274    #[rstest]
2275    fn test_parse_usdm_unsupported_contract_type_fails() {
2276        let mut symbol = sample_usdm_symbol();
2277        symbol.contract_type = "UNKNOWN".to_string();
2278        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2279
2280        let error = parse_usdm_instrument(&symbol, ts, ts).unwrap_err();
2281
2282        assert!(
2283            error
2284                .to_string()
2285                .contains("Unsupported USD-M contract type")
2286        );
2287    }
2288
2289    #[rstest]
2290    fn test_parse_missing_price_filter_fails() {
2291        let mut symbol = sample_usdm_symbol();
2292        symbol.filters = vec![json!({
2293            "filterType": "LOT_SIZE",
2294            "stepSize": "0.001",
2295            "maxQty": "1000",
2296            "minQty": "0.001"
2297        })];
2298        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2299
2300        let result = parse_usdm_instrument(&symbol, ts, ts);
2301        assert!(result.is_err());
2302        assert!(
2303            result
2304                .unwrap_err()
2305                .to_string()
2306                .contains("Missing PRICE_FILTER")
2307        );
2308    }
2309
2310    #[rstest]
2311    fn test_parse_coinm_perpetual() {
2312        let symbol = sample_coinm_symbol();
2313        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2314
2315        let result = parse_coinm_instrument(&symbol, ts, ts).unwrap();
2316
2317        match result {
2318            InstrumentAny::CryptoPerpetual(perp) => {
2319                assert_eq!(perp.id.to_string(), "BTCUSD_PERP.BINANCE");
2320                assert_eq!(perp.raw_symbol.to_string(), "BTCUSD_PERP");
2321                assert_eq!(perp.base_currency.code, "BTC");
2322                assert_eq!(perp.quote_currency.code, "USD");
2323                assert_eq!(perp.settlement_currency.code, "BTC");
2324                assert!(perp.is_inverse);
2325                assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
2326                assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
2327                assert_eq!(
2328                    perp.min_notional,
2329                    Some(Money::new(1.0, perp.quote_currency)),
2330                );
2331            }
2332            other => panic!("Expected CryptoPerpetual, was {other:?}"),
2333        }
2334    }
2335
2336    #[rstest]
2337    #[case::current_quarter(CONTRACT_TYPE_CURRENT_QUARTER)]
2338    #[case::next_quarter(CONTRACT_TYPE_NEXT_QUARTER)]
2339    fn test_parse_coinm_delivery(#[case] contract_type: &str) {
2340        let mut symbol = sample_coinm_symbol();
2341        symbol.symbol = Ustr::from("BTCUSD_260925");
2342        symbol.contract_type = contract_type.to_string();
2343        symbol.onboard_date = 1_774_598_400_000;
2344        symbol.delivery_date = 1_790_323_200_000;
2345        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2346
2347        let result = parse_coinm_instrument(&symbol, ts, ts).unwrap();
2348        let InstrumentAny::CryptoFuture(future) = result else {
2349            panic!("Expected CryptoFuture, was {result:?}");
2350        };
2351
2352        assert_eq!(future.id.to_string(), "BTCUSD_260925.BINANCE");
2353        assert_eq!(future.raw_symbol.to_string(), "BTCUSD_260925");
2354        assert_eq!(future.underlying.code, "BTC");
2355        assert_eq!(future.quote_currency.code, "USD");
2356        assert_eq!(future.settlement_currency.code, "BTC");
2357        assert!(future.is_inverse);
2358        assert_eq!(
2359            future.activation_ns,
2360            UnixNanos::from_millis(1_774_598_400_000)
2361        );
2362        assert_eq!(
2363            future.expiration_ns,
2364            UnixNanos::from_millis(1_790_323_200_000)
2365        );
2366        assert_eq!(future.price_increment, Price::from_str("0.10").unwrap());
2367        assert_eq!(future.size_increment, Quantity::from_str("1").unwrap());
2368        assert_eq!(future.multiplier, Quantity::from(100));
2369        assert_eq!(
2370            future.max_quantity,
2371            Some(Quantity::from_str("1000").unwrap())
2372        );
2373        assert_eq!(future.min_quantity, Some(Quantity::from_str("1").unwrap()));
2374        assert_eq!(future.max_price, Some(Price::from_str("1000000").unwrap()));
2375        assert_eq!(future.min_price, Some(Price::from_str("0.10").unwrap()));
2376    }
2377
2378    #[rstest]
2379    fn test_parse_coinm_month_contract_fails() {
2380        let mut symbol = sample_coinm_symbol();
2381        symbol.contract_type = CONTRACT_TYPE_CURRENT_MONTH.to_string();
2382        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2383
2384        let error = parse_coinm_instrument(&symbol, ts, ts).unwrap_err();
2385
2386        assert!(
2387            error
2388                .to_string()
2389                .contains("Unsupported COIN-M contract type")
2390        );
2391    }
2392
2393    #[rstest]
2394    fn test_parse_spot_instrument_sbe() {
2395        let symbol = sample_spot_symbol_sbe();
2396        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2397
2398        let result = parse_spot_instrument_sbe(&symbol, ts, ts).unwrap();
2399
2400        match result {
2401            InstrumentAny::CurrencyPair(pair) => {
2402                assert_eq!(pair.id.to_string(), "ETHUSDT.BINANCE");
2403                assert_eq!(pair.raw_symbol.to_string(), "ETHUSDT");
2404                assert_eq!(pair.base_currency.code, "ETH");
2405                assert_eq!(pair.quote_currency.code, "USDT");
2406                assert_eq!(pair.price_increment, Price::from_str("0.01").unwrap());
2407                assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
2408            }
2409            other => panic!("Expected CurrencyPair, was {other:?}"),
2410        }
2411    }
2412
2413    #[rstest]
2414    fn test_not_trading_parse_errors_stay_debug_for_bulk_loads() {
2415        let mut symbol = sample_spot_symbol_sbe();
2416        symbol.status = 3;
2417        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2418        let error = parse_spot_instrument_sbe(&symbol, ts, ts).unwrap_err();
2419
2420        assert!(is_not_trading_error(&error));
2421        assert!(!should_warn_on_instrument_parse_error(true, false, &error));
2422        assert!(should_warn_on_instrument_parse_error(true, true, &error));
2423    }
2424
2425    #[rstest]
2426    fn test_all_producers_emit_detectable_not_trading_errors() {
2427        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
2428
2429        let mut sbe = sample_spot_symbol_sbe();
2430        sbe.status = 3;
2431        let sbe_err = parse_spot_instrument_sbe(&sbe, ts, ts).unwrap_err();
2432
2433        let json = BinanceSymbolJson {
2434            symbol: "ETHUSDT".to_string(),
2435            status: "BREAK".to_string(),
2436            base_asset: "ETH".to_string(),
2437            quote_asset: "USDT".to_string(),
2438            base_asset_precision: 8,
2439            quote_asset_precision: 8,
2440            filters: Vec::new(),
2441        };
2442        let json_err = parse_spot_instrument_json_with_fees(&json, ts, ts).unwrap_err();
2443
2444        let mut usdm = sample_usdm_symbol();
2445        usdm.status = BinanceTradingStatus::Halt;
2446        let usdm_err = parse_usdm_instrument(&usdm, ts, ts).unwrap_err();
2447
2448        let mut coinm = sample_coinm_symbol();
2449        coinm.contract_status = Some(BinanceContractStatus::TradingHalt);
2450        let coinm_err = parse_coinm_instrument(&coinm, ts, ts).unwrap_err();
2451
2452        for error in [&sbe_err, &json_err, &usdm_err, &coinm_err] {
2453            assert!(is_not_trading_error(error), "undetected: {error}");
2454        }
2455    }
2456
2457    #[rstest]
2458    fn test_unexpected_parse_errors_honor_log_warnings() {
2459        let error = anyhow::anyhow!("Missing PRICE_FILTER in symbol filters");
2460
2461        assert!(!is_not_trading_error(&error));
2462        assert!(should_warn_on_instrument_parse_error(true, false, &error));
2463        assert!(!should_warn_on_instrument_parse_error(false, true, &error));
2464    }
2465
2466    #[rstest]
2467    fn test_parse_spot_trades_sbe() {
2468        let instrument = sample_spot_instrument();
2469        let trades = BinanceTrades {
2470            price_exponent: -2,
2471            qty_exponent: -4,
2472            trades: vec![
2473                crate::spot::http::models::BinanceTrade {
2474                    id: 1,
2475                    price_mantissa: 12_345,
2476                    qty_mantissa: 25_000,
2477                    quote_qty_mantissa: 0,
2478                    time: 1_700_000_000_000_000,
2479                    is_buyer_maker: false,
2480                    is_best_match: true,
2481                },
2482                crate::spot::http::models::BinanceTrade {
2483                    id: 2,
2484                    price_mantissa: 12_340,
2485                    qty_mantissa: 10_000,
2486                    quote_qty_mantissa: 0,
2487                    time: 1_700_000_000_500_000,
2488                    is_buyer_maker: true,
2489                    is_best_match: true,
2490                },
2491            ],
2492        };
2493        let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2494
2495        let result = parse_spot_trades_sbe(&trades, &instrument, ts_init).unwrap();
2496
2497        assert_eq!(result.len(), 2);
2498        assert_eq!(result[0].instrument_id, instrument.id());
2499        assert_eq!(result[0].price.as_f64(), 123.45);
2500        assert_eq!(result[0].size.as_f64(), 2.5);
2501        assert_eq!(result[0].aggressor_side, AggressorSide::Buy);
2502        assert_eq!(result[0].trade_id, TradeId::new("1"));
2503        assert_eq!(
2504            result[0].ts_event,
2505            UnixNanos::from(1_700_000_000_000_000_000u64)
2506        );
2507        assert_eq!(result[0].ts_init, ts_init);
2508        assert_eq!(result[1].aggressor_side, AggressorSide::Sell);
2509    }
2510
2511    #[rstest]
2512    #[case(SbeOrderType::LimitMaker, OrderType::Limit, None)]
2513    #[case(SbeOrderType::TakeProfit, OrderType::MarketIfTouched, Some(12_000))]
2514    #[case(SbeOrderType::TakeProfitLimit, OrderType::LimitIfTouched, Some(12_000))]
2515    #[case(SbeOrderType::StopLoss, OrderType::StopMarket, Some(12_000))]
2516    #[case(SbeOrderType::StopLossLimit, OrderType::StopLimit, Some(12_000))]
2517    fn test_parse_order_status_report_sbe(
2518        #[case] order_type: SbeOrderType,
2519        #[case] expected: OrderType,
2520        #[case] stop_price_mantissa: Option<i64>,
2521    ) {
2522        let instrument = sample_spot_instrument();
2523        let order = BinanceOrderResponse {
2524            price_exponent: -2,
2525            qty_exponent: -4,
2526            order_id: 42,
2527            order_list_id: Some(77),
2528            price_mantissa: 12_345,
2529            orig_qty_mantissa: 25_000,
2530            executed_qty_mantissa: 10_000,
2531            cummulative_quote_qty_mantissa: 123_450_000,
2532            status: SbeOrderStatus::PartiallyFilled,
2533            time_in_force: SbeTimeInForce::Gtc,
2534            order_type,
2535            side: SbeOrderSide::Buy,
2536            stop_price_mantissa,
2537            iceberg_qty_mantissa: None,
2538            time: 1_700_000_000_000_000,
2539            update_time: 1_700_000_000_100_000,
2540            is_working: true,
2541            working_time: Some(1_700_000_000_050_000),
2542            orig_quote_order_qty_mantissa: 0,
2543            self_trade_prevention_mode:
2544                crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
2545            client_order_id: "client-123".to_string(),
2546            symbol: "ETHUSDT".to_string(),
2547            expiry_reason: None,
2548        };
2549        let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2550
2551        let report = parse_order_status_report_sbe(
2552            &order,
2553            sample_account_id(),
2554            &instrument,
2555            BINANCE_NAUTILUS_SPOT_BROKER_ID,
2556            ts_init,
2557        )
2558        .unwrap();
2559
2560        assert_eq!(report.account_id, sample_account_id());
2561        assert_eq!(report.instrument_id, instrument.id());
2562        assert_eq!(
2563            report.client_order_id,
2564            Some(ClientOrderId::new("client-123"))
2565        );
2566        assert_eq!(report.venue_order_id, VenueOrderId::new("42"));
2567        assert_eq!(report.order_side, OrderSide::Buy.into());
2568        assert_eq!(report.order_type, expected);
2569        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2570        assert_eq!(report.quantity.as_f64(), 2.5);
2571        assert_eq!(report.filled_qty.as_f64(), 1.0);
2572        assert_eq!(report.order_list_id, Some(OrderListId::new("77")));
2573        assert_eq!(report.price, Some(Price::new(123.45, 2)));
2574        assert_eq!(report.avg_px.unwrap().to_string(), "123.45");
2575        assert_eq!(report.post_only, order_type == SbeOrderType::LimitMaker);
2576        assert_eq!(
2577            report.trigger_price,
2578            stop_price_mantissa.map(|_| Price::from("120.00"))
2579        );
2580        assert_eq!(
2581            report.trigger_type,
2582            stop_price_mantissa.map(|_| TriggerType::LastPrice)
2583        );
2584        assert_eq!(
2585            report.ts_accepted,
2586            UnixNanos::from(1_700_000_000_000_000_000u64)
2587        );
2588        assert_eq!(
2589            report.ts_last,
2590            UnixNanos::from(1_700_000_000_100_000_000u64)
2591        );
2592        assert_eq!(report.ts_init, ts_init);
2593    }
2594
2595    #[rstest]
2596    #[case(SbeOrderType::TakeProfit, OrderType::MarketIfTouched)]
2597    #[case(SbeOrderType::TakeProfitLimit, OrderType::LimitIfTouched)]
2598    #[case(SbeOrderType::StopLoss, OrderType::StopMarket)]
2599    #[case(SbeOrderType::StopLossLimit, OrderType::StopLimit)]
2600    fn test_parse_new_order_response_sbe(
2601        #[case] order_type: SbeOrderType,
2602        #[case] expected: OrderType,
2603    ) {
2604        let instrument = sample_spot_instrument();
2605        let response = BinanceNewOrderResponse {
2606            price_exponent: -2,
2607            qty_exponent: -4,
2608            order_id: 99,
2609            order_list_id: Some(7),
2610            transact_time: 1_700_000_000_000_000,
2611            price_mantissa: 12_100,
2612            orig_qty_mantissa: 20_000,
2613            executed_qty_mantissa: 5_000,
2614            cummulative_quote_qty_mantissa: 60_500_000,
2615            status: SbeOrderStatus::New,
2616            time_in_force: SbeTimeInForce::Gtc,
2617            order_type,
2618            side: SbeOrderSide::Sell,
2619            stop_price_mantissa: Some(12_000),
2620            working_time: Some(1_700_000_000_000_000),
2621            self_trade_prevention_mode:
2622                crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
2623            client_order_id: "client-456".to_string(),
2624            symbol: "ETHUSDT".to_string(),
2625            fills: vec![],
2626            expiry_reason: None,
2627        };
2628        let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2629
2630        let report = parse_new_order_response_sbe(
2631            &response,
2632            sample_account_id(),
2633            &instrument,
2634            BINANCE_NAUTILUS_SPOT_BROKER_ID,
2635            ts_init,
2636        )
2637        .unwrap();
2638
2639        assert_eq!(report.account_id, sample_account_id());
2640        assert_eq!(report.instrument_id, instrument.id());
2641        assert_eq!(
2642            report.client_order_id,
2643            Some(ClientOrderId::new("client-456"))
2644        );
2645        assert_eq!(report.venue_order_id, VenueOrderId::new("99"));
2646        assert_eq!(report.order_side, OrderSide::Sell.into());
2647        assert_eq!(report.order_type, expected);
2648        assert_eq!(report.order_status, OrderStatus::Accepted);
2649        assert_eq!(report.quantity.as_f64(), 2.0);
2650        assert_eq!(report.filled_qty.as_f64(), 0.5);
2651        assert_eq!(report.order_list_id, Some(OrderListId::new("7")));
2652        assert_eq!(report.price, Some(Price::new(121.0, 2)));
2653        assert_eq!(report.trigger_price, Some(Price::new(120.0, 2)));
2654        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2655        // `as_decimal()` carries the price precision, where the old `as_f64()` hop dropped it
2656        assert_eq!(report.avg_px, Some(dec!(121.00)));
2657        assert_eq!(report.avg_px.unwrap().to_string(), "121.00");
2658        assert!(!report.post_only);
2659        assert_eq!(
2660            report.ts_accepted,
2661            UnixNanos::from(1_700_000_000_000_000_000u64)
2662        );
2663        assert_eq!(
2664            report.ts_last,
2665            UnixNanos::from(1_700_000_000_000_000_000u64)
2666        );
2667    }
2668
2669    #[rstest]
2670    fn test_parse_fill_report_sbe() {
2671        let instrument = sample_spot_instrument();
2672        let trade = BinanceAccountTrade {
2673            price_exponent: -2,
2674            qty_exponent: -4,
2675            commission_exponent: -8,
2676            id: 123,
2677            order_id: 456,
2678            order_list_id: None,
2679            price_mantissa: 12_345,
2680            qty_mantissa: 25_000,
2681            quote_qty_mantissa: 0,
2682            commission_mantissa: 10_000,
2683            time: 1_700_000_000_000_000,
2684            is_buyer: false,
2685            is_maker: true,
2686            is_best_match: true,
2687            symbol: "ETHUSDT".to_string(),
2688            commission_asset: "USDT".to_string(),
2689        };
2690        let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2691
2692        let report = parse_fill_report_sbe(
2693            &trade,
2694            sample_account_id(),
2695            &instrument,
2696            Currency::from("USDT"),
2697            ts_init,
2698        )
2699        .unwrap();
2700
2701        assert_eq!(report.account_id, sample_account_id());
2702        assert_eq!(report.instrument_id, instrument.id());
2703        assert_eq!(report.venue_order_id, VenueOrderId::new("456"));
2704        assert_eq!(report.trade_id, TradeId::new("123"));
2705        assert_eq!(report.order_side, OrderSide::Sell);
2706        assert_eq!(report.last_qty.as_f64(), 2.5);
2707        assert_eq!(report.last_px.as_f64(), 123.45);
2708        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
2709        assert_eq!(report.commission.as_f64(), 0.0001);
2710        assert_eq!(
2711            report.ts_event,
2712            UnixNanos::from(1_700_000_000_000_000_000u64)
2713        );
2714        assert_eq!(report.ts_init, ts_init);
2715        assert!(report.client_order_id.is_none());
2716    }
2717
2718    #[rstest]
2719    fn test_parse_klines_to_bars() {
2720        use nautilus_model::enums::{AggregationSource, PriceType};
2721
2722        let instrument = sample_spot_instrument();
2723        let bar_type = BarType::new(
2724            instrument.id(),
2725            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
2726            AggregationSource::External,
2727        );
2728        let klines = BinanceKlines {
2729            price_exponent: -2,
2730            qty_exponent: -4,
2731            klines: vec![crate::spot::http::models::BinanceKline {
2732                open_time: 1_700_000_000_000_000,
2733                open_price: 12_000,
2734                high_price: 12_500,
2735                low_price: 11_900,
2736                close_price: 12_345,
2737                volume: 1_234_500_i128.to_le_bytes(),
2738                close_time: 1_700_000_059_999_000,
2739                quote_volume: 777_788_i128.to_le_bytes(),
2740                num_trades: 100,
2741                taker_buy_base_volume: 56_789_i128.to_le_bytes(),
2742                taker_buy_quote_volume: 9_901_i128.to_le_bytes(),
2743            }],
2744        };
2745        let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
2746
2747        let bars = parse_klines_to_binance_bars(&klines, bar_type, &instrument, ts_init).unwrap();
2748
2749        assert_eq!(bars.len(), 1);
2750        assert_eq!(bars[0].bar_type, bar_type);
2751        assert_eq!(bars[0].open, Price::new(120.0, 2));
2752        assert_eq!(bars[0].high, Price::new(125.0, 2));
2753        assert_eq!(bars[0].low, Price::new(119.0, 2));
2754        assert_eq!(bars[0].close, Price::new(123.45, 2));
2755        assert_eq!(bars[0].volume, Quantity::new(123.45, 4));
2756        assert_eq!(bars[0].quote_volume, dec!(7777.88));
2757        assert_eq!(bars[0].count, 100);
2758        assert_eq!(bars[0].taker_buy_base_volume, dec!(5.6789));
2759        assert_eq!(bars[0].taker_buy_quote_volume, dec!(99.01));
2760        assert_eq!(
2761            bars[0].ts_event,
2762            UnixNanos::from(1_700_000_059_999_000_000u64)
2763        );
2764        assert_eq!(bars[0].ts_init, ts_init);
2765    }
2766
2767    mod bar_spec_tests {
2768        use std::num::NonZeroUsize;
2769
2770        use nautilus_model::{
2771            data::BarSpecification,
2772            enums::{BarAggregation, PriceType},
2773        };
2774
2775        use super::*;
2776        use crate::common::enums::BinanceKlineInterval;
2777
2778        fn make_bar_spec(step: usize, aggregation: BarAggregation) -> BarSpecification {
2779            BarSpecification {
2780                step: NonZeroUsize::new(step).unwrap(),
2781                aggregation,
2782                price_type: PriceType::Last,
2783            }
2784        }
2785
2786        #[rstest]
2787        #[case(1, BarAggregation::Second, BinanceKlineInterval::Second1)]
2788        #[case(1, BarAggregation::Minute, BinanceKlineInterval::Minute1)]
2789        #[case(3, BarAggregation::Minute, BinanceKlineInterval::Minute3)]
2790        #[case(5, BarAggregation::Minute, BinanceKlineInterval::Minute5)]
2791        #[case(15, BarAggregation::Minute, BinanceKlineInterval::Minute15)]
2792        #[case(30, BarAggregation::Minute, BinanceKlineInterval::Minute30)]
2793        #[case(1, BarAggregation::Hour, BinanceKlineInterval::Hour1)]
2794        #[case(2, BarAggregation::Hour, BinanceKlineInterval::Hour2)]
2795        #[case(4, BarAggregation::Hour, BinanceKlineInterval::Hour4)]
2796        #[case(6, BarAggregation::Hour, BinanceKlineInterval::Hour6)]
2797        #[case(8, BarAggregation::Hour, BinanceKlineInterval::Hour8)]
2798        #[case(12, BarAggregation::Hour, BinanceKlineInterval::Hour12)]
2799        #[case(1, BarAggregation::Day, BinanceKlineInterval::Day1)]
2800        #[case(3, BarAggregation::Day, BinanceKlineInterval::Day3)]
2801        #[case(1, BarAggregation::Week, BinanceKlineInterval::Week1)]
2802        #[case(1, BarAggregation::Month, BinanceKlineInterval::Month1)]
2803        fn test_bar_spec_to_binance_interval(
2804            #[case] step: usize,
2805            #[case] aggregation: BarAggregation,
2806            #[case] expected: BinanceKlineInterval,
2807        ) {
2808            let bar_spec = make_bar_spec(step, aggregation);
2809            let result = bar_spec_to_binance_interval(bar_spec).unwrap();
2810            assert_eq!(result, expected);
2811        }
2812
2813        #[rstest]
2814        fn test_unsupported_second_interval() {
2815            let bar_spec = make_bar_spec(2, BarAggregation::Second);
2816            let result = bar_spec_to_binance_interval(bar_spec);
2817            assert!(result.is_err());
2818            assert!(
2819                result
2820                    .unwrap_err()
2821                    .to_string()
2822                    .contains("Unsupported second interval")
2823            );
2824        }
2825
2826        #[rstest]
2827        fn test_unsupported_minute_interval() {
2828            let bar_spec = make_bar_spec(7, BarAggregation::Minute);
2829            let result = bar_spec_to_binance_interval(bar_spec);
2830            assert!(result.is_err());
2831            assert!(
2832                result
2833                    .unwrap_err()
2834                    .to_string()
2835                    .contains("Unsupported minute interval")
2836            );
2837        }
2838
2839        #[rstest]
2840        fn test_unsupported_aggregation() {
2841            let bar_spec = make_bar_spec(100, BarAggregation::Tick);
2842            let result = bar_spec_to_binance_interval(bar_spec);
2843            assert!(result.is_err());
2844            assert!(
2845                result
2846                    .unwrap_err()
2847                    .to_string()
2848                    .contains("Unsupported bar aggregation")
2849            );
2850        }
2851    }
2852
2853    mod sbe_precision_tests {
2854        use super::*;
2855        use crate::spot::http::models::{BinanceLotSizeFilterSbe, BinancePriceFilterSbe};
2856
2857        #[rstest]
2858        #[case::precision_0(100_000_000, -8, 0)]
2859        #[case::precision_1(10_000_000, -8, 1)]
2860        #[case::precision_2(1_000_000, -8, 2)]
2861        #[case::precision_3(100_000, -8, 3)]
2862        #[case::precision_4(10_000, -8, 4)]
2863        #[case::precision_5(1_000, -8, 5)]
2864        #[case::precision_6(100, -8, 6)]
2865        #[case::precision_7(10, -8, 7)]
2866        #[case::precision_8(1, -8, 8)]
2867        fn test_sbe_mantissa_precision(
2868            #[case] mantissa: i64,
2869            #[case] exponent: i8,
2870            #[case] expected: u8,
2871        ) {
2872            let result = sbe_mantissa_precision(mantissa, exponent);
2873            assert_eq!(
2874                result, expected,
2875                "mantissa={mantissa}, exponent={exponent}: expected {expected}, was {result}"
2876            );
2877        }
2878
2879        #[rstest]
2880        fn test_sbe_mantissa_precision_zero_mantissa() {
2881            assert_eq!(sbe_mantissa_precision(0, -8), 0);
2882        }
2883
2884        #[rstest]
2885        fn test_sbe_mantissa_precision_positive_exponent() {
2886            assert_eq!(sbe_mantissa_precision(1, 0), 0);
2887            assert_eq!(sbe_mantissa_precision(5, 2), 0);
2888        }
2889
2890        #[rstest]
2891        fn test_parse_sbe_price_filter_ethusdc() {
2892            let filter = BinancePriceFilterSbe {
2893                price_exponent: -8,
2894                min_price: 1_000_000,
2895                max_price: 100_000_000_000_000,
2896                tick_size: 1_000_000,
2897            };
2898
2899            let (tick_size, max_price, min_price) = parse_sbe_price_filter(&filter).unwrap();
2900            let max_price = max_price.unwrap();
2901            let min_price = min_price.unwrap();
2902
2903            assert_eq!(tick_size.precision, 2, "tick_size precision");
2904            assert_eq!(tick_size.as_decimal(), dec!(0.01));
2905            assert_eq!(max_price.precision, 2);
2906            assert_eq!(max_price.as_decimal(), dec!(1000000.00));
2907            assert_eq!(min_price.precision, 2);
2908            assert_eq!(min_price.as_decimal(), dec!(0.01));
2909        }
2910
2911        #[rstest]
2912        fn test_parse_sbe_price_filter_shibusdt() {
2913            let filter = BinancePriceFilterSbe {
2914                price_exponent: -8,
2915                min_price: 1,
2916                max_price: 100_000_000,
2917                tick_size: 1,
2918            };
2919
2920            let (tick_size, _, _) = parse_sbe_price_filter(&filter).unwrap();
2921
2922            assert_eq!(tick_size.precision, 8);
2923            assert_eq!(tick_size.as_decimal(), dec!(0.00000001));
2924        }
2925
2926        #[rstest]
2927        fn test_parse_sbe_lot_size_filter_ethusdc() {
2928            let filter = BinanceLotSizeFilterSbe {
2929                qty_exponent: -8,
2930                min_qty: 10_000,
2931                max_qty: 900_000_000_000,
2932                step_size: 10_000,
2933            };
2934
2935            let (step_size, max_qty, min_qty) = parse_sbe_lot_size_filter(&filter).unwrap();
2936            let max_qty = max_qty.unwrap();
2937            let min_qty = min_qty.unwrap();
2938
2939            assert_eq!(step_size.precision, 4, "step_size precision");
2940            assert_eq!(step_size.as_decimal(), dec!(0.0001));
2941            assert_eq!(min_qty.precision, 4);
2942            assert_eq!(min_qty.as_decimal(), dec!(0.0001));
2943            assert_eq!(max_qty.precision, 4);
2944            assert_eq!(max_qty.as_decimal(), dec!(9000.0000));
2945        }
2946    }
2947}