Skip to main content

nautilus_binance/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing utilities for Binance API responses.
17//!
18//! Provides conversion functions to transform raw Binance exchange data
19//! into Nautilus domain objects such as instruments and market data.
20
21use std::str::FromStr;
22
23use anyhow::Context;
24use nautilus_core::nanos::UnixNanos;
25use nautilus_model::{
26    data::{Bar, BarSpecification, BarType, TradeTick},
27    enums::{
28        AggressorSide, BarAggregation, LiquiditySide, OrderSide, OrderStatus, OrderType,
29        TimeInForce, TriggerType,
30    },
31    identifiers::{
32        AccountId, ClientOrderId, InstrumentId, OrderListId, Symbol, TradeId, Venue, VenueOrderId,
33    },
34    instruments::{
35        Instrument, any::InstrumentAny, crypto_perpetual::CryptoPerpetual,
36        currency_pair::CurrencyPair,
37    },
38    reports::{FillReport, OrderStatusReport},
39    types::{Currency, Money, Price, Quantity},
40};
41use rust_decimal::{Decimal, prelude::ToPrimitive};
42use serde_json::Value;
43
44use crate::{
45    common::{
46        enums::{BinanceContractStatus, BinanceKlineInterval, BinanceTradingStatus},
47        fixed::{mantissa_to_price, mantissa_to_quantity},
48        sbe::spot::{
49            order_side::OrderSide as SbeOrderSide, order_status::OrderStatus as SbeOrderStatus,
50            order_type::OrderType as SbeOrderType, time_in_force::TimeInForce as SbeTimeInForce,
51        },
52    },
53    futures::http::models::{BinanceFuturesCoinSymbol, BinanceFuturesUsdSymbol},
54    spot::http::models::{
55        BinanceAccountTrade, BinanceKlines, BinanceLotSizeFilterSbe, BinanceNewOrderResponse,
56        BinanceOrderResponse, BinancePriceFilterSbe, BinanceSymbolSbe, BinanceTrades,
57    },
58};
59
60const BINANCE_VENUE: &str = "BINANCE";
61const CONTRACT_TYPE_PERPETUAL: &str = "PERPETUAL";
62
63/// Returns a currency from the internal map or creates a new crypto currency.
64pub fn get_currency(code: &str) -> Currency {
65    Currency::get_or_create_crypto(code)
66}
67
68/// Extracts filter values from Binance symbol filters array.
69fn get_filter<'a>(filters: &'a [Value], filter_type: &str) -> Option<&'a Value> {
70    filters.iter().find(|f| {
71        f.get("filterType")
72            .and_then(|v| v.as_str())
73            .is_some_and(|t| t == filter_type)
74    })
75}
76
77/// Parses a string field from a JSON value.
78fn parse_filter_string(filter: &Value, field: &str) -> anyhow::Result<String> {
79    filter
80        .get(field)
81        .and_then(|v| v.as_str())
82        .map(String::from)
83        .ok_or_else(|| anyhow::anyhow!("Missing field '{field}' in filter"))
84}
85
86/// Parses a Price from a filter field.
87fn parse_filter_price(filter: &Value, field: &str) -> anyhow::Result<Price> {
88    let value = parse_filter_string(filter, field)?;
89    Price::from_str(&value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
90}
91
92/// Parses a Quantity from a filter field.
93fn parse_filter_quantity(filter: &Value, field: &str) -> anyhow::Result<Quantity> {
94    let value = parse_filter_string(filter, field)?;
95    Quantity::from_str(&value)
96        .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
97}
98
99/// Parses a USD-M Futures symbol definition into a Nautilus CryptoPerpetual instrument.
100///
101/// # Errors
102///
103/// Returns an error if:
104/// - Required filter values are missing (PRICE_FILTER, LOT_SIZE).
105/// - Price or quantity values cannot be parsed.
106/// - The contract type is not PERPETUAL.
107pub fn parse_usdm_instrument(
108    symbol: &BinanceFuturesUsdSymbol,
109    ts_event: UnixNanos,
110    ts_init: UnixNanos,
111) -> anyhow::Result<InstrumentAny> {
112    // Only handle perpetual contracts for now
113    if symbol.contract_type != CONTRACT_TYPE_PERPETUAL {
114        anyhow::bail!(
115            "Unsupported contract type '{}' for symbol '{}', expected '{}'",
116            symbol.contract_type,
117            symbol.symbol,
118            CONTRACT_TYPE_PERPETUAL
119        );
120    }
121
122    if symbol.status != BinanceTradingStatus::Trading {
123        anyhow::bail!(
124            "Symbol '{}' is not trading (status: {:?})",
125            symbol.symbol,
126            symbol.status
127        );
128    }
129
130    let base_currency = get_currency(symbol.base_asset.as_str());
131    let quote_currency = get_currency(symbol.quote_asset.as_str());
132    let settlement_currency = get_currency(symbol.margin_asset.as_str());
133
134    let instrument_id = InstrumentId::new(
135        Symbol::from_str_unchecked(format!("{}-PERP", symbol.symbol)),
136        Venue::new(BINANCE_VENUE),
137    );
138    let raw_symbol = Symbol::new(symbol.symbol.as_str());
139
140    let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
141        .context("Missing PRICE_FILTER in symbol filters")?;
142
143    let tick_size = parse_filter_price(price_filter, "tickSize")?;
144    if tick_size.is_zero() {
145        anyhow::bail!(
146            "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
147            symbol.symbol,
148        );
149    }
150    let max_price = parse_filter_price(price_filter, "maxPrice").ok();
151    let min_price = parse_filter_price(price_filter, "minPrice").ok();
152
153    let lot_filter =
154        get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
155
156    let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
157    let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
158    let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
159
160    // Default margin (0.1 = 10x leverage)
161    let default_margin = Decimal::new(1, 1);
162
163    let instrument = CryptoPerpetual::new(
164        instrument_id,
165        raw_symbol,
166        base_currency,
167        quote_currency,
168        settlement_currency,
169        false, // is_inverse
170        tick_size.precision,
171        step_size.precision,
172        tick_size,
173        step_size,
174        None, // multiplier
175        Some(step_size),
176        max_quantity,
177        min_quantity,
178        None, // max_notional
179        None, // min_notional
180        max_price,
181        min_price,
182        Some(default_margin),
183        Some(default_margin),
184        None, // maker_fee
185        None, // taker_fee
186        ts_event,
187        ts_init,
188    );
189
190    Ok(InstrumentAny::CryptoPerpetual(instrument))
191}
192
193/// Parses a COIN-M Futures symbol definition into a Nautilus CryptoPerpetual instrument.
194///
195/// COIN-M perpetuals are inverse contracts settled in base currency (e.g., BTC).
196///
197/// # Errors
198///
199/// Returns an error if:
200/// - Required filter values are missing (PRICE_FILTER, LOT_SIZE).
201/// - Price or quantity values cannot be parsed.
202/// - The contract type is not PERPETUAL.
203/// - The contract is not in TRADING status.
204pub fn parse_coinm_instrument(
205    symbol: &BinanceFuturesCoinSymbol,
206    ts_event: UnixNanos,
207    ts_init: UnixNanos,
208) -> anyhow::Result<InstrumentAny> {
209    if symbol.contract_type != CONTRACT_TYPE_PERPETUAL {
210        anyhow::bail!(
211            "Unsupported contract type '{}' for symbol '{}', expected '{}'",
212            symbol.contract_type,
213            symbol.symbol,
214            CONTRACT_TYPE_PERPETUAL
215        );
216    }
217
218    if symbol.contract_status != Some(BinanceContractStatus::Trading) {
219        anyhow::bail!(
220            "Symbol '{}' is not trading (status: {:?})",
221            symbol.symbol,
222            symbol.contract_status
223        );
224    }
225
226    let base_currency = get_currency(symbol.base_asset.as_str());
227    let quote_currency = get_currency(symbol.quote_asset.as_str());
228
229    // COIN-M contracts are settled in the base currency (inverse)
230    let settlement_currency = get_currency(symbol.margin_asset.as_str());
231
232    let instrument_id = InstrumentId::new(
233        Symbol::from_str_unchecked(format!("{}-PERP", symbol.symbol)),
234        Venue::new(BINANCE_VENUE),
235    );
236    let raw_symbol = Symbol::new(symbol.symbol.as_str());
237
238    let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
239        .context("Missing PRICE_FILTER in symbol filters")?;
240
241    let tick_size = parse_filter_price(price_filter, "tickSize")?;
242    if tick_size.is_zero() {
243        anyhow::bail!(
244            "Invalid tickSize of 0 for symbol '{}', cannot create instrument",
245            symbol.symbol,
246        );
247    }
248    let max_price = parse_filter_price(price_filter, "maxPrice").ok();
249    let min_price = parse_filter_price(price_filter, "minPrice").ok();
250
251    let lot_filter =
252        get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
253
254    let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
255    let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
256    let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
257
258    // COIN-M has contract_size as the multiplier
259    let multiplier = Quantity::new(symbol.contract_size as f64, 0);
260
261    // Default margin (0.1 = 10x leverage)
262    let default_margin = Decimal::new(1, 1);
263
264    let instrument = CryptoPerpetual::new(
265        instrument_id,
266        raw_symbol,
267        base_currency,
268        quote_currency,
269        settlement_currency,
270        true, // is_inverse (COIN-M contracts are inverse)
271        tick_size.precision,
272        step_size.precision,
273        tick_size,
274        step_size,
275        Some(multiplier),
276        Some(step_size),
277        max_quantity,
278        min_quantity,
279        None, // max_notional
280        None, // min_notional
281        max_price,
282        min_price,
283        Some(default_margin),
284        Some(default_margin),
285        None, // maker_fee
286        None, // taker_fee
287        ts_event,
288        ts_init,
289    );
290
291    Ok(InstrumentAny::CryptoPerpetual(instrument))
292}
293
294/// SBE status value for Trading.
295const SBE_STATUS_TRADING: u8 = 0;
296
297/// Parses an SBE price filter into tick_size, max_price, min_price.
298fn parse_sbe_price_filter(
299    filter: &BinancePriceFilterSbe,
300) -> anyhow::Result<(Price, Option<Price>, Option<Price>)> {
301    let precision = (-filter.price_exponent).max(0) as u8;
302
303    let tick_size = mantissa_to_price(filter.tick_size, filter.price_exponent, precision);
304
305    let max_price = if filter.max_price != 0 {
306        Some(mantissa_to_price(
307            filter.max_price,
308            filter.price_exponent,
309            precision,
310        ))
311    } else {
312        None
313    };
314
315    let min_price = if filter.min_price != 0 {
316        Some(mantissa_to_price(
317            filter.min_price,
318            filter.price_exponent,
319            precision,
320        ))
321    } else {
322        None
323    };
324
325    Ok((tick_size, max_price, min_price))
326}
327
328/// Parses an SBE lot size filter into step_size, max_qty, min_qty.
329fn parse_sbe_lot_size_filter(
330    filter: &BinanceLotSizeFilterSbe,
331) -> anyhow::Result<(Quantity, Option<Quantity>, Option<Quantity>)> {
332    let precision = (-filter.qty_exponent).max(0) as u8;
333
334    let step_size = mantissa_to_quantity(filter.step_size, filter.qty_exponent, precision);
335
336    let max_qty = if filter.max_qty != 0 {
337        Some(mantissa_to_quantity(
338            filter.max_qty,
339            filter.qty_exponent,
340            precision,
341        ))
342    } else {
343        None
344    };
345
346    let min_qty = if filter.min_qty != 0 {
347        Some(mantissa_to_quantity(
348            filter.min_qty,
349            filter.qty_exponent,
350            precision,
351        ))
352    } else {
353        None
354    };
355
356    Ok((step_size, max_qty, min_qty))
357}
358
359/// Parses a Binance Spot SBE symbol into a Nautilus CurrencyPair instrument.
360///
361/// # Errors
362///
363/// Returns an error if:
364/// - Required filter values are missing (PRICE_FILTER, LOT_SIZE).
365/// - Price or quantity values cannot be parsed.
366/// - The symbol is not actively trading.
367pub fn parse_spot_instrument_sbe(
368    symbol: &BinanceSymbolSbe,
369    ts_event: UnixNanos,
370    ts_init: UnixNanos,
371) -> anyhow::Result<InstrumentAny> {
372    if symbol.status != SBE_STATUS_TRADING {
373        anyhow::bail!(
374            "Symbol '{}' is not trading (status: {})",
375            symbol.symbol,
376            symbol.status
377        );
378    }
379
380    let base_currency = get_currency(&symbol.base_asset);
381    let quote_currency = get_currency(&symbol.quote_asset);
382
383    let instrument_id = InstrumentId::new(
384        Symbol::from_str_unchecked(&symbol.symbol),
385        Venue::new(BINANCE_VENUE),
386    );
387    let raw_symbol = Symbol::new(&symbol.symbol);
388
389    let price_filter = symbol
390        .filters
391        .price_filter
392        .as_ref()
393        .context("Missing PRICE_FILTER in symbol filters")?;
394
395    let (tick_size, max_price, min_price) = parse_sbe_price_filter(price_filter)?;
396
397    let lot_filter = symbol
398        .filters
399        .lot_size_filter
400        .as_ref()
401        .context("Missing LOT_SIZE in symbol filters")?;
402
403    let (step_size, max_quantity, min_quantity) = parse_sbe_lot_size_filter(lot_filter)?;
404
405    // Spot has no leverage, use 1.0 margin
406    let default_margin = Decimal::new(1, 0);
407
408    let instrument = CurrencyPair::new(
409        instrument_id,
410        raw_symbol,
411        base_currency,
412        quote_currency,
413        tick_size.precision,
414        step_size.precision,
415        tick_size,
416        step_size,
417        None, // multiplier
418        Some(step_size),
419        max_quantity,
420        min_quantity,
421        None, // max_notional
422        None, // min_notional
423        max_price,
424        min_price,
425        Some(default_margin),
426        Some(default_margin),
427        None, // maker_fee
428        None, // taker_fee
429        ts_event,
430        ts_init,
431    );
432
433    Ok(InstrumentAny::CurrencyPair(instrument))
434}
435
436/// Parses Binance SBE trades into Nautilus TradeTick objects.
437///
438/// Uses mantissa/exponent encoding from SBE to construct proper Price and Quantity.
439///
440/// # Errors
441///
442/// Returns an error if any trade cannot be parsed.
443pub fn parse_spot_trades_sbe(
444    trades: &BinanceTrades,
445    instrument: &InstrumentAny,
446    ts_init: UnixNanos,
447) -> anyhow::Result<Vec<TradeTick>> {
448    let instrument_id = instrument.id();
449    let price_precision = instrument.price_precision();
450    let size_precision = instrument.size_precision();
451
452    let mut result = Vec::with_capacity(trades.trades.len());
453
454    for trade in &trades.trades {
455        let price = mantissa_to_price(trade.price_mantissa, trades.price_exponent, price_precision);
456        let size = mantissa_to_quantity(trade.qty_mantissa, trades.qty_exponent, size_precision);
457
458        // is_buyer_maker means the buyer was the maker, so the aggressor was selling
459        let aggressor_side = if trade.is_buyer_maker {
460            AggressorSide::Seller
461        } else {
462            AggressorSide::Buyer
463        };
464
465        // SBE trade timestamps are in microseconds
466        let ts_event = UnixNanos::from(trade.time as u64 * 1_000);
467
468        let tick = TradeTick::new(
469            instrument_id,
470            price,
471            size,
472            aggressor_side,
473            TradeId::new(trade.id.to_string()),
474            ts_event,
475            ts_init,
476        );
477
478        result.push(tick);
479    }
480
481    Ok(result)
482}
483
484/// Maps Binance SBE order status to Nautilus order status.
485#[must_use]
486pub const fn map_order_status_sbe(status: SbeOrderStatus) -> OrderStatus {
487    match status {
488        SbeOrderStatus::New => OrderStatus::Accepted,
489        SbeOrderStatus::PendingNew => OrderStatus::Submitted,
490        SbeOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
491        SbeOrderStatus::Filled => OrderStatus::Filled,
492        SbeOrderStatus::Canceled => OrderStatus::Canceled,
493        SbeOrderStatus::PendingCancel => OrderStatus::PendingCancel,
494        SbeOrderStatus::Rejected => OrderStatus::Rejected,
495        SbeOrderStatus::Expired | SbeOrderStatus::ExpiredInMatch => OrderStatus::Expired,
496        SbeOrderStatus::Unknown | SbeOrderStatus::NonRepresentable | SbeOrderStatus::NullVal => {
497            OrderStatus::Initialized
498        }
499    }
500}
501
502/// Maps Binance SBE order type to Nautilus order type.
503#[must_use]
504pub const fn map_order_type_sbe(order_type: SbeOrderType) -> OrderType {
505    match order_type {
506        SbeOrderType::Market => OrderType::Market,
507        SbeOrderType::Limit | SbeOrderType::LimitMaker => OrderType::Limit,
508        SbeOrderType::StopLoss | SbeOrderType::TakeProfit => OrderType::StopMarket,
509        SbeOrderType::StopLossLimit | SbeOrderType::TakeProfitLimit => OrderType::StopLimit,
510        SbeOrderType::NonRepresentable | SbeOrderType::NullVal => OrderType::Market,
511    }
512}
513
514/// Maps Binance SBE order side to Nautilus order side.
515#[must_use]
516pub const fn map_order_side_sbe(side: SbeOrderSide) -> OrderSide {
517    match side {
518        SbeOrderSide::Buy => OrderSide::Buy,
519        SbeOrderSide::Sell => OrderSide::Sell,
520        SbeOrderSide::NonRepresentable | SbeOrderSide::NullVal => OrderSide::NoOrderSide,
521    }
522}
523
524/// Maps Binance SBE time in force to Nautilus time in force.
525#[must_use]
526pub const fn map_time_in_force_sbe(tif: SbeTimeInForce) -> TimeInForce {
527    match tif {
528        SbeTimeInForce::Gtc => TimeInForce::Gtc,
529        SbeTimeInForce::Ioc => TimeInForce::Ioc,
530        SbeTimeInForce::Fok => TimeInForce::Fok,
531        SbeTimeInForce::NonRepresentable | SbeTimeInForce::NullVal => TimeInForce::Gtc,
532    }
533}
534
535/// Parses a Binance SBE order response into a Nautilus `OrderStatusReport`.
536///
537/// # Errors
538///
539/// Returns an error if any field cannot be parsed.
540#[allow(clippy::too_many_arguments)]
541pub fn parse_order_status_report_sbe(
542    order: &BinanceOrderResponse,
543    account_id: AccountId,
544    instrument: &InstrumentAny,
545    ts_init: UnixNanos,
546) -> anyhow::Result<OrderStatusReport> {
547    let instrument_id = instrument.id();
548    let price_precision = instrument.price_precision();
549    let size_precision = instrument.size_precision();
550
551    let price = if order.price_mantissa != 0 {
552        Some(mantissa_to_price(
553            order.price_mantissa,
554            order.price_exponent,
555            price_precision,
556        ))
557    } else {
558        None
559    };
560
561    let quantity =
562        mantissa_to_quantity(order.orig_qty_mantissa, order.qty_exponent, size_precision);
563    let filled_qty = mantissa_to_quantity(
564        order.executed_qty_mantissa,
565        order.qty_exponent,
566        size_precision,
567    );
568
569    // Calculate average price from cumulative quote qty / executed qty
570    // This requires decimal arithmetic since we're dividing two mantissas
571    let avg_px = if order.executed_qty_mantissa > 0 {
572        let quote_exp = (order.price_exponent as i32) + (order.qty_exponent as i32);
573        let cum_quote_dec = Decimal::new(order.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
574        let filled_dec = Decimal::new(
575            order.executed_qty_mantissa,
576            (-order.qty_exponent as i32) as u32,
577        );
578        let avg_dec = cum_quote_dec / filled_dec;
579        Some(
580            Price::from_decimal_dp(avg_dec, price_precision)
581                .unwrap_or(Price::zero(price_precision)),
582        )
583    } else {
584        None
585    };
586
587    // Parse trigger price for stop orders
588    let trigger_price = order.stop_price_mantissa.and_then(|mantissa| {
589        if mantissa != 0 {
590            Some(mantissa_to_price(
591                mantissa,
592                order.price_exponent,
593                price_precision,
594            ))
595        } else {
596            None
597        }
598    });
599
600    // Map enums
601    let order_status = map_order_status_sbe(order.status);
602    let order_type = map_order_type_sbe(order.order_type);
603    let order_side = map_order_side_sbe(order.side);
604    let time_in_force = map_time_in_force_sbe(order.time_in_force);
605
606    // Determine trigger type for stop orders
607    let trigger_type = if trigger_price.is_some() {
608        Some(TriggerType::LastPrice)
609    } else {
610        None
611    };
612
613    // Parse timestamps (SBE uses microseconds)
614    let ts_event = UnixNanos::from(order.update_time as u64 * 1000);
615
616    // Build order list ID if present
617    let order_list_id = order.order_list_id.and_then(|id| {
618        if id > 0 {
619            Some(OrderListId::new(id.to_string()))
620        } else {
621            None
622        }
623    });
624
625    // Determine post-only (limit maker orders are post-only)
626    let post_only = order.order_type == SbeOrderType::LimitMaker;
627
628    // Parse order creation time (SBE uses microseconds)
629    let ts_accepted = UnixNanos::from(order.time as u64 * 1000);
630
631    let mut report = OrderStatusReport::new(
632        account_id,
633        instrument_id,
634        Some(ClientOrderId::new(order.client_order_id.clone())),
635        VenueOrderId::new(order.order_id.to_string()),
636        order_side,
637        order_type,
638        time_in_force,
639        order_status,
640        quantity,
641        filled_qty,
642        ts_accepted,
643        ts_event,
644        ts_init,
645        None, // report_id (auto-generated)
646    );
647
648    // Apply optional fields using builder methods
649    if let Some(p) = price {
650        report = report.with_price(p);
651    }
652    if let Some(ap) = avg_px {
653        report = report.with_avg_px(ap.as_f64())?;
654    }
655    if let Some(tp) = trigger_price {
656        report = report.with_trigger_price(tp);
657    }
658    if let Some(tt) = trigger_type {
659        report = report.with_trigger_type(tt);
660    }
661    if let Some(oli) = order_list_id {
662        report = report.with_order_list_id(oli);
663    }
664    if post_only {
665        report = report.with_post_only(true);
666    }
667
668    Ok(report)
669}
670
671/// Parses a Binance new order response (SBE) into a Nautilus `OrderStatusReport`.
672///
673/// # Errors
674///
675/// Returns an error if any field cannot be parsed.
676pub fn parse_new_order_response_sbe(
677    response: &BinanceNewOrderResponse,
678    account_id: AccountId,
679    instrument: &InstrumentAny,
680    ts_init: UnixNanos,
681) -> anyhow::Result<OrderStatusReport> {
682    let instrument_id = instrument.id();
683    let price_precision = instrument.price_precision();
684    let size_precision = instrument.size_precision();
685
686    let price = if response.price_mantissa != 0 {
687        Some(mantissa_to_price(
688            response.price_mantissa,
689            response.price_exponent,
690            price_precision,
691        ))
692    } else {
693        None
694    };
695
696    let quantity = mantissa_to_quantity(
697        response.orig_qty_mantissa,
698        response.qty_exponent,
699        size_precision,
700    );
701    let filled_qty = mantissa_to_quantity(
702        response.executed_qty_mantissa,
703        response.qty_exponent,
704        size_precision,
705    );
706
707    // Calculate average price from cumulative quote qty / executed qty
708    // This requires decimal arithmetic since we're dividing two mantissas
709    let avg_px = if response.executed_qty_mantissa > 0 {
710        let quote_exp = (response.price_exponent as i32) + (response.qty_exponent as i32);
711        let cum_quote_dec =
712            Decimal::new(response.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
713        let filled_dec = Decimal::new(
714            response.executed_qty_mantissa,
715            (-response.qty_exponent as i32) as u32,
716        );
717        let avg_dec = cum_quote_dec / filled_dec;
718        Some(
719            Price::from_decimal_dp(avg_dec, price_precision)
720                .unwrap_or(Price::zero(price_precision)),
721        )
722    } else {
723        None
724    };
725
726    let trigger_price = response.stop_price_mantissa.and_then(|mantissa| {
727        if mantissa != 0 {
728            Some(mantissa_to_price(
729                mantissa,
730                response.price_exponent,
731                price_precision,
732            ))
733        } else {
734            None
735        }
736    });
737
738    let order_status = map_order_status_sbe(response.status);
739    let order_type = map_order_type_sbe(response.order_type);
740    let order_side = map_order_side_sbe(response.side);
741    let time_in_force = map_time_in_force_sbe(response.time_in_force);
742
743    let trigger_type = if trigger_price.is_some() {
744        Some(TriggerType::LastPrice)
745    } else {
746        None
747    };
748
749    // SBE uses microseconds; for new orders transact_time is both creation and event time
750    let ts_event = UnixNanos::from(response.transact_time as u64 * 1000);
751    let ts_accepted = ts_event;
752
753    let order_list_id = response.order_list_id.and_then(|id| {
754        if id > 0 {
755            Some(OrderListId::new(id.to_string()))
756        } else {
757            None
758        }
759    });
760
761    // Limit maker orders are post-only
762    let post_only = response.order_type == SbeOrderType::LimitMaker;
763
764    let mut report = OrderStatusReport::new(
765        account_id,
766        instrument_id,
767        Some(ClientOrderId::new(response.client_order_id.clone())),
768        VenueOrderId::new(response.order_id.to_string()),
769        order_side,
770        order_type,
771        time_in_force,
772        order_status,
773        quantity,
774        filled_qty,
775        ts_accepted,
776        ts_event,
777        ts_init,
778        None,
779    );
780
781    if let Some(p) = price {
782        report = report.with_price(p);
783    }
784    if let Some(ap) = avg_px {
785        report = report.with_avg_px(ap.as_f64())?;
786    }
787    if let Some(tp) = trigger_price {
788        report = report.with_trigger_price(tp);
789    }
790    if let Some(tt) = trigger_type {
791        report = report.with_trigger_type(tt);
792    }
793    if let Some(oli) = order_list_id {
794        report = report.with_order_list_id(oli);
795    }
796    if post_only {
797        report = report.with_post_only(true);
798    }
799
800    Ok(report)
801}
802
803/// Parses a Binance SBE account trade into a Nautilus `FillReport`.
804///
805/// # Errors
806///
807/// Returns an error if any field cannot be parsed.
808pub fn parse_fill_report_sbe(
809    trade: &BinanceAccountTrade,
810    account_id: AccountId,
811    instrument: &InstrumentAny,
812    commission_currency: Currency,
813    ts_init: UnixNanos,
814) -> anyhow::Result<FillReport> {
815    let instrument_id = instrument.id();
816    let price_precision = instrument.price_precision();
817    let size_precision = instrument.size_precision();
818
819    let last_px = mantissa_to_price(trade.price_mantissa, trade.price_exponent, price_precision);
820    let last_qty = mantissa_to_quantity(trade.qty_mantissa, trade.qty_exponent, size_precision);
821
822    // Commission still uses Decimal → f64 since Money::new takes f64
823    let comm_exp = trade.commission_exponent as i32;
824    let comm_dec = Decimal::new(trade.commission_mantissa, (-comm_exp) as u32);
825    let commission = Money::new(comm_dec.to_f64().unwrap_or(0.0), commission_currency);
826
827    // Determine order side from is_buyer
828    let order_side = if trade.is_buyer {
829        OrderSide::Buy
830    } else {
831        OrderSide::Sell
832    };
833
834    // Determine liquidity side from is_maker
835    let liquidity_side = if trade.is_maker {
836        LiquiditySide::Maker
837    } else {
838        LiquiditySide::Taker
839    };
840
841    // Parse timestamp (SBE uses microseconds)
842    let ts_event = UnixNanos::from(trade.time as u64 * 1000);
843
844    Ok(FillReport::new(
845        account_id,
846        instrument_id,
847        VenueOrderId::new(trade.order_id.to_string()),
848        TradeId::new(trade.id.to_string()),
849        order_side,
850        last_qty,
851        last_px,
852        commission,
853        liquidity_side,
854        None, // client_order_id (not in account trades response)
855        None, // venue_position_id
856        ts_event,
857        ts_init,
858        None, // report_id
859    ))
860}
861
862/// Parses Binance klines (candlesticks) into Nautilus Bar objects.
863///
864/// # Errors
865///
866/// Returns an error if any kline cannot be parsed.
867pub fn parse_klines_to_bars(
868    klines: &BinanceKlines,
869    bar_type: BarType,
870    instrument: &InstrumentAny,
871    ts_init: UnixNanos,
872) -> anyhow::Result<Vec<Bar>> {
873    let price_precision = instrument.price_precision();
874    let size_precision = instrument.size_precision();
875
876    let mut bars = Vec::with_capacity(klines.klines.len());
877
878    for kline in &klines.klines {
879        let open = mantissa_to_price(kline.open_price, klines.price_exponent, price_precision);
880        let high = mantissa_to_price(kline.high_price, klines.price_exponent, price_precision);
881        let low = mantissa_to_price(kline.low_price, klines.price_exponent, price_precision);
882        let close = mantissa_to_price(kline.close_price, klines.price_exponent, price_precision);
883
884        // Volume is 128-bit so we still use Decimal path for now
885        let volume_mantissa = i128::from_le_bytes(kline.volume);
886        let volume_dec =
887            Decimal::from_i128_with_scale(volume_mantissa, (-klines.qty_exponent as i32) as u32);
888        let volume = Quantity::new(volume_dec.to_f64().unwrap_or(0.0), size_precision);
889
890        let ts_event = UnixNanos::from(kline.open_time as u64 * 1_000_000);
891
892        let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
893        bars.push(bar);
894    }
895
896    Ok(bars)
897}
898
899/// Converts a Nautilus bar specification to a Binance kline interval.
900///
901/// # Errors
902///
903/// Returns an error if the bar specification does not map to a supported
904/// Binance kline interval.
905pub fn bar_spec_to_binance_interval(
906    bar_spec: BarSpecification,
907) -> anyhow::Result<BinanceKlineInterval> {
908    let step = bar_spec.step.get();
909    let interval = match bar_spec.aggregation {
910        BarAggregation::Second => {
911            anyhow::bail!("Binance Spot does not support second-level kline intervals")
912        }
913        BarAggregation::Minute => match step {
914            1 => BinanceKlineInterval::Minute1,
915            3 => BinanceKlineInterval::Minute3,
916            5 => BinanceKlineInterval::Minute5,
917            15 => BinanceKlineInterval::Minute15,
918            30 => BinanceKlineInterval::Minute30,
919            _ => anyhow::bail!("Unsupported minute interval: {step}m"),
920        },
921        BarAggregation::Hour => match step {
922            1 => BinanceKlineInterval::Hour1,
923            2 => BinanceKlineInterval::Hour2,
924            4 => BinanceKlineInterval::Hour4,
925            6 => BinanceKlineInterval::Hour6,
926            8 => BinanceKlineInterval::Hour8,
927            12 => BinanceKlineInterval::Hour12,
928            _ => anyhow::bail!("Unsupported hour interval: {step}h"),
929        },
930        BarAggregation::Day => match step {
931            1 => BinanceKlineInterval::Day1,
932            3 => BinanceKlineInterval::Day3,
933            _ => anyhow::bail!("Unsupported day interval: {step}d"),
934        },
935        BarAggregation::Week => match step {
936            1 => BinanceKlineInterval::Week1,
937            _ => anyhow::bail!("Unsupported week interval: {step}w"),
938        },
939        BarAggregation::Month => match step {
940            1 => BinanceKlineInterval::Month1,
941            _ => anyhow::bail!("Unsupported month interval: {step}M"),
942        },
943        agg => anyhow::bail!("Unsupported bar aggregation for Binance: {agg:?}"),
944    };
945
946    Ok(interval)
947}
948
949#[cfg(test)]
950mod tests {
951    use rstest::rstest;
952    use serde_json::json;
953    use ustr::Ustr;
954
955    use super::*;
956    use crate::common::enums::BinanceTradingStatus;
957
958    fn sample_usdm_symbol() -> BinanceFuturesUsdSymbol {
959        BinanceFuturesUsdSymbol {
960            symbol: Ustr::from("BTCUSDT"),
961            pair: Ustr::from("BTCUSDT"),
962            contract_type: "PERPETUAL".to_string(),
963            delivery_date: 4133404800000,
964            onboard_date: 1569398400000,
965            status: BinanceTradingStatus::Trading,
966            maint_margin_percent: "2.5000".to_string(),
967            required_margin_percent: "5.0000".to_string(),
968            base_asset: Ustr::from("BTC"),
969            quote_asset: Ustr::from("USDT"),
970            margin_asset: Ustr::from("USDT"),
971            price_precision: 2,
972            quantity_precision: 3,
973            base_asset_precision: 8,
974            quote_precision: 8,
975            underlying_type: Some("COIN".to_string()),
976            underlying_sub_type: vec!["PoW".to_string()],
977            settle_plan: None,
978            trigger_protect: Some("0.0500".to_string()),
979            liquidation_fee: Some("0.012500".to_string()),
980            market_take_bound: Some("0.05".to_string()),
981            order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
982            time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
983            filters: vec![
984                json!({
985                    "filterType": "PRICE_FILTER",
986                    "tickSize": "0.10",
987                    "maxPrice": "4529764",
988                    "minPrice": "556.80"
989                }),
990                json!({
991                    "filterType": "LOT_SIZE",
992                    "stepSize": "0.001",
993                    "maxQty": "1000",
994                    "minQty": "0.001"
995                }),
996            ],
997        }
998    }
999
1000    #[rstest]
1001    fn test_parse_usdm_perpetual() {
1002        let symbol = sample_usdm_symbol();
1003        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1004
1005        let result = parse_usdm_instrument(&symbol, ts, ts);
1006        assert!(result.is_ok(), "Failed: {:?}", result.err());
1007
1008        let instrument = result.unwrap();
1009        match instrument {
1010            InstrumentAny::CryptoPerpetual(perp) => {
1011                assert_eq!(perp.id.to_string(), "BTCUSDT-PERP.BINANCE");
1012                assert_eq!(perp.raw_symbol.to_string(), "BTCUSDT");
1013                assert_eq!(perp.base_currency.code.as_str(), "BTC");
1014                assert_eq!(perp.quote_currency.code.as_str(), "USDT");
1015                assert_eq!(perp.settlement_currency.code.as_str(), "USDT");
1016                assert!(!perp.is_inverse);
1017                assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
1018                assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1019            }
1020            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1021        }
1022    }
1023
1024    #[rstest]
1025    fn test_parse_non_perpetual_fails() {
1026        let mut symbol = sample_usdm_symbol();
1027        symbol.contract_type = "CURRENT_QUARTER".to_string();
1028        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1029
1030        let result = parse_usdm_instrument(&symbol, ts, ts);
1031        assert!(result.is_err());
1032        assert!(
1033            result
1034                .unwrap_err()
1035                .to_string()
1036                .contains("Unsupported contract type")
1037        );
1038    }
1039
1040    #[rstest]
1041    fn test_parse_missing_price_filter_fails() {
1042        let mut symbol = sample_usdm_symbol();
1043        symbol.filters = vec![json!({
1044            "filterType": "LOT_SIZE",
1045            "stepSize": "0.001",
1046            "maxQty": "1000",
1047            "minQty": "0.001"
1048        })];
1049        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
1050
1051        let result = parse_usdm_instrument(&symbol, ts, ts);
1052        assert!(result.is_err());
1053        assert!(
1054            result
1055                .unwrap_err()
1056                .to_string()
1057                .contains("Missing PRICE_FILTER")
1058        );
1059    }
1060
1061    mod bar_spec_tests {
1062        use std::num::NonZeroUsize;
1063
1064        use nautilus_model::{
1065            data::BarSpecification,
1066            enums::{BarAggregation, PriceType},
1067        };
1068
1069        use super::*;
1070        use crate::common::enums::BinanceKlineInterval;
1071
1072        fn make_bar_spec(step: usize, aggregation: BarAggregation) -> BarSpecification {
1073            BarSpecification {
1074                step: NonZeroUsize::new(step).unwrap(),
1075                aggregation,
1076                price_type: PriceType::Last,
1077            }
1078        }
1079
1080        #[rstest]
1081        #[case(1, BarAggregation::Minute, BinanceKlineInterval::Minute1)]
1082        #[case(3, BarAggregation::Minute, BinanceKlineInterval::Minute3)]
1083        #[case(5, BarAggregation::Minute, BinanceKlineInterval::Minute5)]
1084        #[case(15, BarAggregation::Minute, BinanceKlineInterval::Minute15)]
1085        #[case(30, BarAggregation::Minute, BinanceKlineInterval::Minute30)]
1086        #[case(1, BarAggregation::Hour, BinanceKlineInterval::Hour1)]
1087        #[case(2, BarAggregation::Hour, BinanceKlineInterval::Hour2)]
1088        #[case(4, BarAggregation::Hour, BinanceKlineInterval::Hour4)]
1089        #[case(6, BarAggregation::Hour, BinanceKlineInterval::Hour6)]
1090        #[case(8, BarAggregation::Hour, BinanceKlineInterval::Hour8)]
1091        #[case(12, BarAggregation::Hour, BinanceKlineInterval::Hour12)]
1092        #[case(1, BarAggregation::Day, BinanceKlineInterval::Day1)]
1093        #[case(3, BarAggregation::Day, BinanceKlineInterval::Day3)]
1094        #[case(1, BarAggregation::Week, BinanceKlineInterval::Week1)]
1095        #[case(1, BarAggregation::Month, BinanceKlineInterval::Month1)]
1096        fn test_bar_spec_to_binance_interval(
1097            #[case] step: usize,
1098            #[case] aggregation: BarAggregation,
1099            #[case] expected: BinanceKlineInterval,
1100        ) {
1101            let bar_spec = make_bar_spec(step, aggregation);
1102            let result = bar_spec_to_binance_interval(bar_spec).unwrap();
1103            assert_eq!(result, expected);
1104        }
1105
1106        #[rstest]
1107        fn test_unsupported_second_interval() {
1108            let bar_spec = make_bar_spec(1, BarAggregation::Second);
1109            let result = bar_spec_to_binance_interval(bar_spec);
1110            assert!(result.is_err());
1111            assert!(
1112                result
1113                    .unwrap_err()
1114                    .to_string()
1115                    .contains("does not support second-level")
1116            );
1117        }
1118
1119        #[rstest]
1120        fn test_unsupported_minute_interval() {
1121            let bar_spec = make_bar_spec(7, BarAggregation::Minute);
1122            let result = bar_spec_to_binance_interval(bar_spec);
1123            assert!(result.is_err());
1124            assert!(
1125                result
1126                    .unwrap_err()
1127                    .to_string()
1128                    .contains("Unsupported minute interval")
1129            );
1130        }
1131
1132        #[rstest]
1133        fn test_unsupported_aggregation() {
1134            let bar_spec = make_bar_spec(100, BarAggregation::Tick);
1135            let result = bar_spec_to_binance_interval(bar_spec);
1136            assert!(result.is_err());
1137            assert!(
1138                result
1139                    .unwrap_err()
1140                    .to_string()
1141                    .contains("Unsupported bar aggregation")
1142            );
1143        }
1144    }
1145}