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::TradeTick,
27    enums::{
28        AggressorSide, LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType,
29    },
30    identifiers::{
31        AccountId, ClientOrderId, InstrumentId, OrderListId, Symbol, TradeId, Venue, VenueOrderId,
32    },
33    instruments::{
34        Instrument, any::InstrumentAny, crypto_perpetual::CryptoPerpetual,
35        currency_pair::CurrencyPair,
36    },
37    reports::{FillReport, OrderStatusReport},
38    types::{Currency, Money, Price, Quantity},
39};
40use rust_decimal::{Decimal, prelude::ToPrimitive};
41use serde_json::Value;
42
43use crate::{
44    common::{
45        enums::BinanceTradingStatus,
46        sbe::spot::{
47            order_side::OrderSide as SbeOrderSide, order_status::OrderStatus as SbeOrderStatus,
48            order_type::OrderType as SbeOrderType, time_in_force::TimeInForce as SbeTimeInForce,
49        },
50    },
51    http::models::{BinanceFuturesUsdSymbol, BinanceSpotSymbol},
52    spot::http::models::{
53        BinanceAccountTrade, BinanceNewOrderResponse, BinanceOrderResponse, BinanceSymbolSbe,
54        BinanceTrades,
55    },
56};
57
58const BINANCE_VENUE: &str = "BINANCE";
59const CONTRACT_TYPE_PERPETUAL: &str = "PERPETUAL";
60
61/// Returns a currency from the internal map or creates a new crypto currency.
62pub fn get_currency(code: &str) -> Currency {
63    Currency::get_or_create_crypto(code)
64}
65
66/// Extracts filter values from Binance symbol filters array.
67fn get_filter<'a>(filters: &'a [Value], filter_type: &str) -> Option<&'a Value> {
68    filters.iter().find(|f| {
69        f.get("filterType")
70            .and_then(|v| v.as_str())
71            .is_some_and(|t| t == filter_type)
72    })
73}
74
75/// Parses a string field from a JSON value.
76fn parse_filter_string(filter: &Value, field: &str) -> anyhow::Result<String> {
77    filter
78        .get(field)
79        .and_then(|v| v.as_str())
80        .map(String::from)
81        .ok_or_else(|| anyhow::anyhow!("Missing field '{field}' in filter"))
82}
83
84/// Parses a Price from a filter field.
85fn parse_filter_price(filter: &Value, field: &str) -> anyhow::Result<Price> {
86    let value = parse_filter_string(filter, field)?;
87    Price::from_str(&value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
88}
89
90/// Parses a Quantity from a filter field.
91fn parse_filter_quantity(filter: &Value, field: &str) -> anyhow::Result<Quantity> {
92    let value = parse_filter_string(filter, field)?;
93    Quantity::from_str(&value)
94        .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
95}
96
97/// Parses a USD-M Futures symbol definition into a Nautilus CryptoPerpetual instrument.
98///
99/// # Errors
100///
101/// Returns an error if:
102/// - Required filter values are missing (PRICE_FILTER, LOT_SIZE).
103/// - Price or quantity values cannot be parsed.
104/// - The contract type is not PERPETUAL.
105pub fn parse_usdm_instrument(
106    symbol: &BinanceFuturesUsdSymbol,
107    ts_event: UnixNanos,
108    ts_init: UnixNanos,
109) -> anyhow::Result<InstrumentAny> {
110    // Only handle perpetual contracts for now
111    if symbol.contract_type != CONTRACT_TYPE_PERPETUAL {
112        anyhow::bail!(
113            "Unsupported contract type '{}' for symbol '{}', expected '{}'",
114            symbol.contract_type,
115            symbol.symbol,
116            CONTRACT_TYPE_PERPETUAL
117        );
118    }
119
120    let base_currency = get_currency(symbol.base_asset.as_str());
121    let quote_currency = get_currency(symbol.quote_asset.as_str());
122    let settlement_currency = get_currency(symbol.margin_asset.as_str());
123
124    let instrument_id = InstrumentId::new(
125        Symbol::from_str_unchecked(format!("{}-PERP", symbol.symbol)),
126        Venue::new(BINANCE_VENUE),
127    );
128    let raw_symbol = Symbol::new(symbol.symbol.as_str());
129
130    let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
131        .context("Missing PRICE_FILTER in symbol filters")?;
132
133    let tick_size = parse_filter_price(price_filter, "tickSize")?;
134    let max_price = parse_filter_price(price_filter, "maxPrice").ok();
135    let min_price = parse_filter_price(price_filter, "minPrice").ok();
136
137    let lot_filter =
138        get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
139
140    let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
141    let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
142    let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
143
144    // Default margin (0.1 = 10x leverage)
145    let default_margin = Decimal::new(1, 1);
146
147    let instrument = CryptoPerpetual::new(
148        instrument_id,
149        raw_symbol,
150        base_currency,
151        quote_currency,
152        settlement_currency,
153        false, // is_inverse
154        tick_size.precision,
155        step_size.precision,
156        tick_size,
157        step_size,
158        None, // multiplier
159        Some(step_size),
160        max_quantity,
161        min_quantity,
162        None, // max_notional
163        None, // min_notional
164        max_price,
165        min_price,
166        Some(default_margin),
167        Some(default_margin),
168        None, // maker_fee
169        None, // taker_fee
170        ts_event,
171        ts_init,
172    );
173
174    Ok(InstrumentAny::CryptoPerpetual(instrument))
175}
176
177/// SBE status value for Trading.
178const SBE_STATUS_TRADING: u8 = 0;
179
180/// Parses a Binance Spot SBE symbol into a Nautilus CurrencyPair instrument.
181///
182/// # Errors
183///
184/// Returns an error if:
185/// - Required filter values are missing (PRICE_FILTER, LOT_SIZE).
186/// - Price or quantity values cannot be parsed.
187/// - The symbol is not actively trading.
188pub fn parse_spot_instrument_sbe(
189    symbol: &BinanceSymbolSbe,
190    ts_event: UnixNanos,
191    ts_init: UnixNanos,
192) -> anyhow::Result<InstrumentAny> {
193    if symbol.status != SBE_STATUS_TRADING {
194        anyhow::bail!(
195            "Symbol '{}' is not trading (status: {})",
196            symbol.symbol,
197            symbol.status
198        );
199    }
200
201    let base_currency = get_currency(&symbol.base_asset);
202    let quote_currency = get_currency(&symbol.quote_asset);
203
204    let instrument_id = InstrumentId::new(
205        Symbol::from_str_unchecked(&symbol.symbol),
206        Venue::new(BINANCE_VENUE),
207    );
208    let raw_symbol = Symbol::new(&symbol.symbol);
209
210    let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
211        .context("Missing PRICE_FILTER in symbol filters")?;
212
213    let tick_size = parse_filter_price(price_filter, "tickSize")?;
214    let max_price = parse_filter_price(price_filter, "maxPrice").ok();
215    let min_price = parse_filter_price(price_filter, "minPrice").ok();
216
217    let lot_filter =
218        get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
219
220    let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
221    let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
222    let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
223
224    // Spot has no leverage, use 1.0 margin
225    let default_margin = Decimal::new(1, 0);
226
227    let instrument = CurrencyPair::new(
228        instrument_id,
229        raw_symbol,
230        base_currency,
231        quote_currency,
232        tick_size.precision,
233        step_size.precision,
234        tick_size,
235        step_size,
236        None, // multiplier
237        Some(step_size),
238        max_quantity,
239        min_quantity,
240        None, // max_notional
241        None, // min_notional
242        max_price,
243        min_price,
244        Some(default_margin),
245        Some(default_margin),
246        None, // maker_fee
247        None, // taker_fee
248        ts_event,
249        ts_init,
250    );
251
252    Ok(InstrumentAny::CurrencyPair(instrument))
253}
254
255/// Parses a Binance Spot symbol definition into a Nautilus CurrencyPair instrument.
256///
257/// # Errors
258///
259/// Returns an error if:
260/// - Required filter values are missing (PRICE_FILTER, LOT_SIZE).
261/// - Price or quantity values cannot be parsed.
262/// - The symbol is not actively trading.
263pub fn parse_spot_instrument(
264    symbol: &BinanceSpotSymbol,
265    ts_event: UnixNanos,
266    ts_init: UnixNanos,
267) -> anyhow::Result<InstrumentAny> {
268    if symbol.status != BinanceTradingStatus::Trading {
269        anyhow::bail!(
270            "Symbol '{}' is not trading (status: {:?})",
271            symbol.symbol,
272            symbol.status
273        );
274    }
275
276    let base_currency = get_currency(symbol.base_asset.as_str());
277    let quote_currency = get_currency(symbol.quote_asset.as_str());
278
279    let instrument_id = InstrumentId::new(
280        Symbol::from_str_unchecked(symbol.symbol.as_str()),
281        Venue::new(BINANCE_VENUE),
282    );
283    let raw_symbol = Symbol::new(symbol.symbol.as_str());
284
285    let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
286        .context("Missing PRICE_FILTER in symbol filters")?;
287
288    let tick_size = parse_filter_price(price_filter, "tickSize")?;
289    let max_price = parse_filter_price(price_filter, "maxPrice").ok();
290    let min_price = parse_filter_price(price_filter, "minPrice").ok();
291
292    let lot_filter =
293        get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
294
295    let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
296    let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
297    let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
298
299    // Spot has no leverage, use 1.0 margin
300    let default_margin = Decimal::new(1, 0);
301
302    let instrument = CurrencyPair::new(
303        instrument_id,
304        raw_symbol,
305        base_currency,
306        quote_currency,
307        tick_size.precision,
308        step_size.precision,
309        tick_size,
310        step_size,
311        None, // multiplier
312        Some(step_size),
313        max_quantity,
314        min_quantity,
315        None, // max_notional
316        None, // min_notional
317        max_price,
318        min_price,
319        Some(default_margin),
320        Some(default_margin),
321        None, // maker_fee
322        None, // taker_fee
323        ts_event,
324        ts_init,
325    );
326
327    Ok(InstrumentAny::CurrencyPair(instrument))
328}
329
330/// Parses Binance SBE trades into Nautilus TradeTick objects.
331///
332/// Uses mantissa/exponent encoding from SBE to construct proper Price and Quantity.
333///
334/// # Errors
335///
336/// Returns an error if any trade cannot be parsed.
337pub fn parse_spot_trades_sbe(
338    trades: &BinanceTrades,
339    instrument: &InstrumentAny,
340    ts_init: UnixNanos,
341) -> anyhow::Result<Vec<TradeTick>> {
342    let instrument_id = instrument.id();
343    let price_precision = instrument.price_precision();
344    let size_precision = instrument.size_precision();
345
346    let mut result = Vec::with_capacity(trades.trades.len());
347
348    for trade in &trades.trades {
349        // Convert mantissa + exponent to decimal value
350        let price_exp = trades.price_exponent as i32;
351        let qty_exp = trades.qty_exponent as i32;
352
353        let price_dec = Decimal::new(trade.price_mantissa, (-price_exp) as u32);
354        let qty_dec = Decimal::new(trade.qty_mantissa, (-qty_exp) as u32);
355
356        let price = Price::new(price_dec.to_f64().unwrap_or(0.0), price_precision);
357        let size = Quantity::new(qty_dec.to_f64().unwrap_or(0.0), size_precision);
358
359        // is_buyer_maker means the buyer was the maker, so the aggressor was selling
360        let aggressor_side = if trade.is_buyer_maker {
361            AggressorSide::Seller
362        } else {
363            AggressorSide::Buyer
364        };
365
366        // SBE trade timestamps are in microseconds
367        let ts_event = UnixNanos::from(trade.time as u64 * 1_000);
368
369        let tick = TradeTick::new(
370            instrument_id,
371            price,
372            size,
373            aggressor_side,
374            TradeId::new(trade.id.to_string()),
375            ts_event,
376            ts_init,
377        );
378
379        result.push(tick);
380    }
381
382    Ok(result)
383}
384
385/// Maps Binance SBE order status to Nautilus order status.
386#[must_use]
387pub const fn map_order_status_sbe(status: SbeOrderStatus) -> OrderStatus {
388    match status {
389        SbeOrderStatus::New => OrderStatus::Accepted,
390        SbeOrderStatus::PendingNew => OrderStatus::Submitted,
391        SbeOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
392        SbeOrderStatus::Filled => OrderStatus::Filled,
393        SbeOrderStatus::Canceled => OrderStatus::Canceled,
394        SbeOrderStatus::PendingCancel => OrderStatus::PendingCancel,
395        SbeOrderStatus::Rejected => OrderStatus::Rejected,
396        SbeOrderStatus::Expired | SbeOrderStatus::ExpiredInMatch => OrderStatus::Expired,
397        SbeOrderStatus::Unknown | SbeOrderStatus::NonRepresentable | SbeOrderStatus::NullVal => {
398            OrderStatus::Initialized
399        }
400    }
401}
402
403/// Maps Binance SBE order type to Nautilus order type.
404#[must_use]
405pub const fn map_order_type_sbe(order_type: SbeOrderType) -> OrderType {
406    match order_type {
407        SbeOrderType::Market => OrderType::Market,
408        SbeOrderType::Limit | SbeOrderType::LimitMaker => OrderType::Limit,
409        SbeOrderType::StopLoss | SbeOrderType::TakeProfit => OrderType::StopMarket,
410        SbeOrderType::StopLossLimit | SbeOrderType::TakeProfitLimit => OrderType::StopLimit,
411        SbeOrderType::NonRepresentable | SbeOrderType::NullVal => OrderType::Market,
412    }
413}
414
415/// Maps Binance SBE order side to Nautilus order side.
416#[must_use]
417pub const fn map_order_side_sbe(side: SbeOrderSide) -> OrderSide {
418    match side {
419        SbeOrderSide::Buy => OrderSide::Buy,
420        SbeOrderSide::Sell => OrderSide::Sell,
421        SbeOrderSide::NonRepresentable | SbeOrderSide::NullVal => OrderSide::NoOrderSide,
422    }
423}
424
425/// Maps Binance SBE time in force to Nautilus time in force.
426#[must_use]
427pub const fn map_time_in_force_sbe(tif: SbeTimeInForce) -> TimeInForce {
428    match tif {
429        SbeTimeInForce::Gtc => TimeInForce::Gtc,
430        SbeTimeInForce::Ioc => TimeInForce::Ioc,
431        SbeTimeInForce::Fok => TimeInForce::Fok,
432        SbeTimeInForce::NonRepresentable | SbeTimeInForce::NullVal => TimeInForce::Gtc,
433    }
434}
435
436/// Parses a Binance SBE order response into a Nautilus `OrderStatusReport`.
437///
438/// # Errors
439///
440/// Returns an error if any field cannot be parsed.
441#[allow(clippy::too_many_arguments)]
442pub fn parse_order_status_report_sbe(
443    order: &BinanceOrderResponse,
444    account_id: AccountId,
445    instrument: &InstrumentAny,
446    ts_init: UnixNanos,
447) -> anyhow::Result<OrderStatusReport> {
448    let instrument_id = instrument.id();
449    let price_precision = instrument.price_precision();
450    let size_precision = instrument.size_precision();
451
452    // Convert mantissa + exponent to values
453    let price_exp = order.price_exponent as i32;
454    let qty_exp = order.qty_exponent as i32;
455
456    let price_dec = Decimal::new(order.price_mantissa, (-price_exp) as u32);
457    let qty_dec = Decimal::new(order.orig_qty_mantissa, (-qty_exp) as u32);
458    let filled_dec = Decimal::new(order.executed_qty_mantissa, (-qty_exp) as u32);
459
460    let price = if order.price_mantissa != 0 {
461        Some(Price::new(
462            price_dec.to_f64().unwrap_or(0.0),
463            price_precision,
464        ))
465    } else {
466        None
467    };
468
469    let quantity = Quantity::new(qty_dec.to_f64().unwrap_or(0.0), size_precision);
470    let filled_qty = Quantity::new(filled_dec.to_f64().unwrap_or(0.0), size_precision);
471
472    // Calculate average price from cumulative quote qty / executed qty
473    // Quote qty = price * qty, so its exponent is (price_exp + qty_exp)
474    let avg_px = if order.executed_qty_mantissa > 0 {
475        let quote_exp = price_exp + qty_exp;
476        let cum_quote_dec = Decimal::new(order.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
477        let avg_dec = cum_quote_dec / filled_dec;
478        Some(Price::new(avg_dec.to_f64().unwrap_or(0.0), price_precision))
479    } else {
480        None
481    };
482
483    // Parse trigger price for stop orders
484    let trigger_price = order.stop_price_mantissa.and_then(|mantissa| {
485        if mantissa != 0 {
486            let stop_dec = Decimal::new(mantissa, (-price_exp) as u32);
487            Some(Price::new(
488                stop_dec.to_f64().unwrap_or(0.0),
489                price_precision,
490            ))
491        } else {
492            None
493        }
494    });
495
496    // Map enums
497    let order_status = map_order_status_sbe(order.status);
498    let order_type = map_order_type_sbe(order.order_type);
499    let order_side = map_order_side_sbe(order.side);
500    let time_in_force = map_time_in_force_sbe(order.time_in_force);
501
502    // Determine trigger type for stop orders
503    let trigger_type = if trigger_price.is_some() {
504        Some(TriggerType::LastPrice)
505    } else {
506        None
507    };
508
509    // Parse timestamps (SBE uses microseconds)
510    let ts_event = UnixNanos::from(order.update_time as u64 * 1000);
511
512    // Build order list ID if present
513    let order_list_id = order.order_list_id.and_then(|id| {
514        if id > 0 {
515            Some(OrderListId::new(id.to_string()))
516        } else {
517            None
518        }
519    });
520
521    // Determine post-only (limit maker orders are post-only)
522    let post_only = order.order_type == SbeOrderType::LimitMaker;
523
524    // Parse order creation time (SBE uses microseconds)
525    let ts_accepted = UnixNanos::from(order.time as u64 * 1000);
526
527    let mut report = OrderStatusReport::new(
528        account_id,
529        instrument_id,
530        Some(ClientOrderId::new(order.client_order_id.clone())),
531        VenueOrderId::new(order.order_id.to_string()),
532        order_side,
533        order_type,
534        time_in_force,
535        order_status,
536        quantity,
537        filled_qty,
538        ts_accepted,
539        ts_event,
540        ts_init,
541        None, // report_id (auto-generated)
542    );
543
544    // Apply optional fields using builder methods
545    if let Some(p) = price {
546        report = report.with_price(p);
547    }
548    if let Some(ap) = avg_px {
549        report = report.with_avg_px(ap.as_f64())?;
550    }
551    if let Some(tp) = trigger_price {
552        report = report.with_trigger_price(tp);
553    }
554    if let Some(tt) = trigger_type {
555        report = report.with_trigger_type(tt);
556    }
557    if let Some(oli) = order_list_id {
558        report = report.with_order_list_id(oli);
559    }
560    if post_only {
561        report = report.with_post_only(true);
562    }
563
564    Ok(report)
565}
566
567/// Parses a Binance new order response (SBE) into a Nautilus `OrderStatusReport`.
568///
569/// # Errors
570///
571/// Returns an error if any field cannot be parsed.
572pub fn parse_new_order_response_sbe(
573    response: &BinanceNewOrderResponse,
574    account_id: AccountId,
575    instrument: &InstrumentAny,
576    ts_init: UnixNanos,
577) -> anyhow::Result<OrderStatusReport> {
578    let instrument_id = instrument.id();
579    let price_precision = instrument.price_precision();
580    let size_precision = instrument.size_precision();
581
582    let price_exp = response.price_exponent as i32;
583    let qty_exp = response.qty_exponent as i32;
584
585    let price_dec = Decimal::new(response.price_mantissa, (-price_exp) as u32);
586    let qty_dec = Decimal::new(response.orig_qty_mantissa, (-qty_exp) as u32);
587    let filled_dec = Decimal::new(response.executed_qty_mantissa, (-qty_exp) as u32);
588
589    let price = if response.price_mantissa != 0 {
590        Some(Price::new(
591            price_dec.to_f64().unwrap_or(0.0),
592            price_precision,
593        ))
594    } else {
595        None
596    };
597
598    let quantity = Quantity::new(qty_dec.to_f64().unwrap_or(0.0), size_precision);
599    let filled_qty = Quantity::new(filled_dec.to_f64().unwrap_or(0.0), size_precision);
600
601    // Quote qty = price * qty, so exponent is (price_exp + qty_exp)
602    let avg_px = if response.executed_qty_mantissa > 0 {
603        let quote_exp = price_exp + qty_exp;
604        let cum_quote_dec =
605            Decimal::new(response.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
606        let avg_dec = cum_quote_dec / filled_dec;
607        Some(Price::new(avg_dec.to_f64().unwrap_or(0.0), price_precision))
608    } else {
609        None
610    };
611
612    let trigger_price = response.stop_price_mantissa.and_then(|mantissa| {
613        if mantissa != 0 {
614            let stop_dec = Decimal::new(mantissa, (-price_exp) as u32);
615            Some(Price::new(
616                stop_dec.to_f64().unwrap_or(0.0),
617                price_precision,
618            ))
619        } else {
620            None
621        }
622    });
623
624    let order_status = map_order_status_sbe(response.status);
625    let order_type = map_order_type_sbe(response.order_type);
626    let order_side = map_order_side_sbe(response.side);
627    let time_in_force = map_time_in_force_sbe(response.time_in_force);
628
629    let trigger_type = if trigger_price.is_some() {
630        Some(TriggerType::LastPrice)
631    } else {
632        None
633    };
634
635    // SBE uses microseconds; for new orders transact_time is both creation and event time
636    let ts_event = UnixNanos::from(response.transact_time as u64 * 1000);
637    let ts_accepted = ts_event;
638
639    let order_list_id = response.order_list_id.and_then(|id| {
640        if id > 0 {
641            Some(OrderListId::new(id.to_string()))
642        } else {
643            None
644        }
645    });
646
647    // Limit maker orders are post-only
648    let post_only = response.order_type == SbeOrderType::LimitMaker;
649
650    let mut report = OrderStatusReport::new(
651        account_id,
652        instrument_id,
653        Some(ClientOrderId::new(response.client_order_id.clone())),
654        VenueOrderId::new(response.order_id.to_string()),
655        order_side,
656        order_type,
657        time_in_force,
658        order_status,
659        quantity,
660        filled_qty,
661        ts_accepted,
662        ts_event,
663        ts_init,
664        None,
665    );
666
667    if let Some(p) = price {
668        report = report.with_price(p);
669    }
670    if let Some(ap) = avg_px {
671        report = report.with_avg_px(ap.as_f64())?;
672    }
673    if let Some(tp) = trigger_price {
674        report = report.with_trigger_price(tp);
675    }
676    if let Some(tt) = trigger_type {
677        report = report.with_trigger_type(tt);
678    }
679    if let Some(oli) = order_list_id {
680        report = report.with_order_list_id(oli);
681    }
682    if post_only {
683        report = report.with_post_only(true);
684    }
685
686    Ok(report)
687}
688
689/// Parses a Binance SBE account trade into a Nautilus `FillReport`.
690///
691/// # Errors
692///
693/// Returns an error if any field cannot be parsed.
694pub fn parse_fill_report_sbe(
695    trade: &BinanceAccountTrade,
696    account_id: AccountId,
697    instrument: &InstrumentAny,
698    commission_currency: Currency,
699    ts_init: UnixNanos,
700) -> anyhow::Result<FillReport> {
701    let instrument_id = instrument.id();
702    let price_precision = instrument.price_precision();
703    let size_precision = instrument.size_precision();
704
705    // Convert mantissa + exponent to values
706    let price_exp = trade.price_exponent as i32;
707    let qty_exp = trade.qty_exponent as i32;
708    let comm_exp = trade.commission_exponent as i32;
709
710    let price_dec = Decimal::new(trade.price_mantissa, (-price_exp) as u32);
711    let qty_dec = Decimal::new(trade.qty_mantissa, (-qty_exp) as u32);
712    let comm_dec = Decimal::new(trade.commission_mantissa, (-comm_exp) as u32);
713
714    let last_px = Price::new(price_dec.to_f64().unwrap_or(0.0), price_precision);
715    let last_qty = Quantity::new(qty_dec.to_f64().unwrap_or(0.0), size_precision);
716    let commission = Money::new(comm_dec.to_f64().unwrap_or(0.0), commission_currency);
717
718    // Determine order side from is_buyer
719    let order_side = if trade.is_buyer {
720        OrderSide::Buy
721    } else {
722        OrderSide::Sell
723    };
724
725    // Determine liquidity side from is_maker
726    let liquidity_side = if trade.is_maker {
727        LiquiditySide::Maker
728    } else {
729        LiquiditySide::Taker
730    };
731
732    // Parse timestamp (SBE uses microseconds)
733    let ts_event = UnixNanos::from(trade.time as u64 * 1000);
734
735    Ok(FillReport::new(
736        account_id,
737        instrument_id,
738        VenueOrderId::new(trade.order_id.to_string()),
739        TradeId::new(trade.id.to_string()),
740        order_side,
741        last_qty,
742        last_px,
743        commission,
744        liquidity_side,
745        None, // client_order_id (not in account trades response)
746        None, // venue_position_id
747        ts_event,
748        ts_init,
749        None, // report_id
750    ))
751}
752
753#[cfg(test)]
754mod tests {
755    use rstest::rstest;
756    use serde_json::json;
757    use ustr::Ustr;
758
759    use super::*;
760    use crate::http::models::BinanceSpotSymbol;
761
762    fn sample_usdm_symbol() -> BinanceFuturesUsdSymbol {
763        BinanceFuturesUsdSymbol {
764            symbol: Ustr::from("BTCUSDT"),
765            pair: Ustr::from("BTCUSDT"),
766            contract_type: "PERPETUAL".to_string(),
767            delivery_date: 4133404800000,
768            onboard_date: 1569398400000,
769            status: BinanceTradingStatus::Trading,
770            maint_margin_percent: "2.5000".to_string(),
771            required_margin_percent: "5.0000".to_string(),
772            base_asset: Ustr::from("BTC"),
773            quote_asset: Ustr::from("USDT"),
774            margin_asset: Ustr::from("USDT"),
775            price_precision: 2,
776            quantity_precision: 3,
777            base_asset_precision: 8,
778            quote_precision: 8,
779            underlying_type: Some("COIN".to_string()),
780            underlying_sub_type: vec!["PoW".to_string()],
781            settle_plan: None,
782            trigger_protect: Some("0.0500".to_string()),
783            liquidation_fee: Some("0.012500".to_string()),
784            market_take_bound: Some("0.05".to_string()),
785            order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
786            time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
787            filters: vec![
788                json!({
789                    "filterType": "PRICE_FILTER",
790                    "tickSize": "0.10",
791                    "maxPrice": "4529764",
792                    "minPrice": "556.80"
793                }),
794                json!({
795                    "filterType": "LOT_SIZE",
796                    "stepSize": "0.001",
797                    "maxQty": "1000",
798                    "minQty": "0.001"
799                }),
800            ],
801        }
802    }
803
804    #[rstest]
805    fn test_parse_usdm_perpetual() {
806        let symbol = sample_usdm_symbol();
807        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
808
809        let result = parse_usdm_instrument(&symbol, ts, ts);
810        assert!(result.is_ok(), "Failed: {:?}", result.err());
811
812        let instrument = result.unwrap();
813        match instrument {
814            InstrumentAny::CryptoPerpetual(perp) => {
815                assert_eq!(perp.id.to_string(), "BTCUSDT-PERP.BINANCE");
816                assert_eq!(perp.raw_symbol.to_string(), "BTCUSDT");
817                assert_eq!(perp.base_currency.code.as_str(), "BTC");
818                assert_eq!(perp.quote_currency.code.as_str(), "USDT");
819                assert_eq!(perp.settlement_currency.code.as_str(), "USDT");
820                assert!(!perp.is_inverse);
821                assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
822                assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
823            }
824            other => panic!("Expected CryptoPerpetual, got {other:?}"),
825        }
826    }
827
828    #[rstest]
829    fn test_parse_non_perpetual_fails() {
830        let mut symbol = sample_usdm_symbol();
831        symbol.contract_type = "CURRENT_QUARTER".to_string();
832        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
833
834        let result = parse_usdm_instrument(&symbol, ts, ts);
835        assert!(result.is_err());
836        assert!(
837            result
838                .unwrap_err()
839                .to_string()
840                .contains("Unsupported contract type")
841        );
842    }
843
844    #[rstest]
845    fn test_parse_missing_price_filter_fails() {
846        let mut symbol = sample_usdm_symbol();
847        symbol.filters = vec![json!({
848            "filterType": "LOT_SIZE",
849            "stepSize": "0.001",
850            "maxQty": "1000",
851            "minQty": "0.001"
852        })];
853        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
854
855        let result = parse_usdm_instrument(&symbol, ts, ts);
856        assert!(result.is_err());
857        assert!(
858            result
859                .unwrap_err()
860                .to_string()
861                .contains("Missing PRICE_FILTER")
862        );
863    }
864
865    fn sample_spot_symbol() -> BinanceSpotSymbol {
866        BinanceSpotSymbol {
867            symbol: Ustr::from("BTCUSDT"),
868            status: BinanceTradingStatus::Trading,
869            base_asset: Ustr::from("BTC"),
870            base_asset_precision: 8,
871            quote_asset: Ustr::from("USDT"),
872            quote_precision: 8,
873            quote_asset_precision: Some(8),
874            order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
875            iceberg_allowed: true,
876            oco_allowed: Some(true),
877            quote_order_qty_market_allowed: Some(true),
878            allow_trailing_stop: Some(true),
879            is_spot_trading_allowed: Some(true),
880            is_margin_trading_allowed: Some(false),
881            filters: vec![
882                json!({
883                    "filterType": "PRICE_FILTER",
884                    "tickSize": "0.01",
885                    "maxPrice": "1000000.00",
886                    "minPrice": "0.01"
887                }),
888                json!({
889                    "filterType": "LOT_SIZE",
890                    "stepSize": "0.00001",
891                    "maxQty": "9000.00000",
892                    "minQty": "0.00001"
893                }),
894            ],
895            permissions: vec!["SPOT".to_string()],
896            permission_sets: vec![],
897            default_self_trade_prevention_mode: Some("EXPIRE_MAKER".to_string()),
898            allowed_self_trade_prevention_modes: vec!["EXPIRE_MAKER".to_string()],
899        }
900    }
901
902    #[rstest]
903    fn test_parse_spot_instrument() {
904        let symbol = sample_spot_symbol();
905        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
906
907        let result = parse_spot_instrument(&symbol, ts, ts);
908        assert!(result.is_ok(), "Failed: {:?}", result.err());
909
910        let instrument = result.unwrap();
911        match instrument {
912            InstrumentAny::CurrencyPair(pair) => {
913                assert_eq!(pair.id.to_string(), "BTCUSDT.BINANCE");
914                assert_eq!(pair.raw_symbol.to_string(), "BTCUSDT");
915                assert_eq!(pair.base_currency.code.as_str(), "BTC");
916                assert_eq!(pair.quote_currency.code.as_str(), "USDT");
917                assert_eq!(pair.price_increment, Price::from_str("0.01").unwrap());
918                assert_eq!(pair.size_increment, Quantity::from_str("0.00001").unwrap());
919            }
920            other => panic!("Expected CurrencyPair, got {other:?}"),
921        }
922    }
923
924    #[rstest]
925    fn test_parse_spot_non_trading_fails() {
926        let mut symbol = sample_spot_symbol();
927        symbol.status = BinanceTradingStatus::Break;
928        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
929
930        let result = parse_spot_instrument(&symbol, ts, ts);
931        assert!(result.is_err());
932        assert!(result.unwrap_err().to_string().contains("is not trading"));
933    }
934}