Skip to main content

nautilus_binance/common/
parse.rs

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