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