Skip to main content

nautilus_binance/futures/websocket/streams/
parse_exec.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//! Parse functions for converting Binance Futures venue types to Nautilus reports.
17//!
18//! Pure functions that take venue message + precision + account_id + ts_init
19//! and return Nautilus report types.
20
21use nautilus_core::{UUID4, UnixNanos};
22use nautilus_model::{
23    enums::{
24        AccountType, LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce,
25        TrailingOffsetType,
26    },
27    events::AccountState,
28    identifiers::{AccountId, ClientOrderId, InstrumentId, PositionId, TradeId, VenueOrderId},
29    reports::{FillReport, OrderStatusReport},
30    types::{AccountBalance, Currency, Money, Price, Quantity},
31};
32use rust_decimal::Decimal;
33
34use super::messages::{
35    AlgoOrderUpdateData, BinanceFuturesAccountUpdateMsg, BinanceFuturesOrderUpdateMsg,
36    OrderUpdateData,
37};
38use crate::{
39    common::{
40        consts::BINANCE_NAUTILUS_FUTURES_BROKER_ID,
41        encoder::decode_broker_id,
42        enums::{
43            BinanceAlgoStatus, BinanceFuturesOrderType, BinanceOrderStatus, BinanceSide,
44            BinanceTimeInForce,
45        },
46        parse::{
47            parse_required_decimal, parse_required_price_at_precision,
48            parse_required_quantity_at_precision,
49        },
50    },
51    futures::conversions::normalize_futures_asset,
52};
53
54/// Converts a Binance Futures order update to a Nautilus order status report.
55///
56/// # Errors
57///
58/// Returns an error if report construction fails.
59pub fn parse_futures_order_update_to_order_status(
60    msg: &BinanceFuturesOrderUpdateMsg,
61    instrument_id: InstrumentId,
62    price_precision: u8,
63    size_precision: u8,
64    account_id: AccountId,
65    treat_expired_as_canceled: bool,
66    ts_init: UnixNanos,
67) -> anyhow::Result<OrderStatusReport> {
68    let order = &msg.order;
69    let ts_event = UnixNanos::from_millis(msg.event_time as u64);
70
71    let client_order_id = ClientOrderId::new(decode_broker_id(
72        &order.client_order_id,
73        BINANCE_NAUTILUS_FUTURES_BROKER_ID,
74    ));
75    let venue_order_id = VenueOrderId::new(order.order_id.to_string());
76
77    let order_side = parse_side(order.side);
78    let order_status = parse_order_status(order.order_status, treat_expired_as_canceled);
79    let order_type = parse_futures_order_type(order.order_type);
80    let time_in_force = parse_time_in_force(order.time_in_force);
81
82    let quantity =
83        parse_required_quantity_at_precision(&order.original_qty, size_precision, "original_qty")?;
84    let filled_qty = parse_required_quantity_at_precision(
85        &order.cumulative_filled_qty,
86        size_precision,
87        "cumulative_filled_qty",
88    )?;
89    let price = parse_required_price_at_precision(
90        &order.original_price,
91        price_precision,
92        "original_price",
93    )?;
94
95    let avg_px = if filled_qty.as_decimal() > Decimal::ZERO {
96        parse_optional_positive_price_at_precision(&order.average_price, price_precision)
97    } else {
98        None
99    };
100
101    let mut report = OrderStatusReport::new(
102        account_id,
103        instrument_id,
104        Some(client_order_id),
105        venue_order_id,
106        order_side,
107        order_type,
108        time_in_force,
109        order_status,
110        quantity,
111        filled_qty,
112        ts_event,
113        ts_event,
114        ts_init,
115        None, // report_id
116    );
117
118    report.price = Some(price);
119    report.post_only = order.order_type == BinanceFuturesOrderType::Limit
120        && order.time_in_force == BinanceTimeInForce::Gtx;
121
122    if let Some(stop_price) =
123        parse_optional_positive_price_at_precision(&order.stop_price, price_precision)
124    {
125        report.trigger_price = Some(stop_price);
126    }
127
128    if let Some(offset) = order
129        .callback_rate
130        .as_deref()
131        .and_then(parse_trailing_offset_basis_points)
132    {
133        report.trailing_offset = Some(offset);
134        report.trailing_offset_type = TrailingOffsetType::BasisPoints;
135    }
136
137    if let Some(avg) = avg_px {
138        report.avg_px = Some(avg.as_decimal());
139    }
140
141    Ok(report)
142}
143
144/// Resolves the commission for a Binance fill event.
145///
146/// Uses the venue-provided commission fields (N/n) when present. Falls back to
147/// estimating `taker_fee * qty * price` when the venue omits them, matching the
148/// Python adapter behavior for exchange-generated fills (liquidation, ADL).
149/// Returns zero USDT when neither source is available.
150///
151/// # Errors
152///
153/// Returns an error if venue commission or fee fallback values cannot be
154/// parsed or converted to `Money`.
155pub fn resolve_commission(
156    order: &OrderUpdateData,
157    last_qty: Quantity,
158    last_px: Price,
159    taker_fee: Option<Decimal>,
160    quote_currency: Option<Currency>,
161    bnfcr_currency: Currency,
162) -> anyhow::Result<Money> {
163    if order.commission.is_some() || order.commission_asset.is_some() {
164        let raw_commission = order.commission.as_deref().unwrap_or("0");
165        let amount = parse_required_decimal(raw_commission, "commission")?;
166        let currency = order.commission_asset.as_ref().map_or(bnfcr_currency, |a| {
167            normalize_futures_asset(a.as_str(), bnfcr_currency)
168        });
169        Money::from_decimal(amount, currency)
170            .map_err(|e| anyhow::anyhow!("invalid commission='{raw_commission}': {e}"))
171    } else if let Some(fee) = taker_fee {
172        let currency = quote_currency.unwrap_or_else(Currency::USDT);
173        let notional = last_qty
174            .as_decimal()
175            .checked_mul(last_px.as_decimal())
176            .ok_or_else(|| {
177                anyhow::anyhow!(
178                    "invalid fee notional for last_qty='{last_qty}' and last_px='{last_px}': multiplication overflow",
179                )
180            })?;
181        let amount = fee.checked_mul(notional).ok_or_else(|| {
182            anyhow::anyhow!(
183                "invalid fee amount for taker_fee='{fee}' and notional='{notional}': multiplication overflow"
184            )
185        })?;
186        Money::from_decimal(amount, currency)
187            .map_err(|e| anyhow::anyhow!("invalid fee amount='{amount}': {e}"))
188    } else {
189        Ok(Money::zero(Currency::USDT()))
190    }
191}
192
193/// Converts a Binance Futures order update (Trade type) to a Nautilus fill report.
194///
195/// # Errors
196///
197/// Returns an error if report construction fails.
198#[expect(clippy::too_many_arguments)]
199pub fn parse_futures_order_update_to_fill(
200    msg: &BinanceFuturesOrderUpdateMsg,
201    account_id: AccountId,
202    instrument_id: InstrumentId,
203    price_precision: u8,
204    size_precision: u8,
205    taker_fee: Option<Decimal>,
206    quote_currency: Option<Currency>,
207    bnfcr_currency: Currency,
208    venue_position_id: Option<PositionId>,
209    ts_init: UnixNanos,
210) -> anyhow::Result<FillReport> {
211    let order = &msg.order;
212    let ts_event = UnixNanos::from_millis(msg.event_time as u64);
213
214    let client_order_id = ClientOrderId::new(decode_broker_id(
215        &order.client_order_id,
216        BINANCE_NAUTILUS_FUTURES_BROKER_ID,
217    ));
218    let venue_order_id = VenueOrderId::new(order.order_id.to_string());
219    let trade_id = TradeId::new(order.trade_id.to_string());
220
221    let order_side = parse_side(order.side);
222
223    let liquidity_side = if order.is_maker {
224        LiquiditySide::Maker
225    } else {
226        LiquiditySide::Taker
227    };
228
229    let last_qty = parse_required_quantity_at_precision(
230        &order.last_filled_qty,
231        size_precision,
232        "last_filled_qty",
233    )?;
234    let last_px = parse_required_price_at_precision(
235        &order.last_filled_price,
236        price_precision,
237        "last_filled_price",
238    )?;
239    let commission = resolve_commission(
240        order,
241        last_qty,
242        last_px,
243        taker_fee,
244        quote_currency,
245        bnfcr_currency,
246    )?;
247
248    Ok(FillReport::new(
249        account_id,
250        instrument_id,
251        venue_order_id,
252        trade_id,
253        order_side,
254        last_qty,
255        last_px,
256        commission,
257        liquidity_side,
258        Some(client_order_id),
259        venue_position_id,
260        ts_event,
261        ts_init,
262        None, // report_id
263    ))
264}
265
266/// Converts a Binance Futures algo order update to a Nautilus order status report.
267///
268/// Returns `None` for algo statuses that don't map to an order status report
269/// (e.g. New, Triggering, Triggered, Finished, Unknown).
270///
271/// # Errors
272///
273/// Returns an error if report quantity parsing fails.
274pub fn parse_futures_algo_update_to_order_status(
275    algo_data: &AlgoOrderUpdateData,
276    event_time: i64,
277    instrument_id: InstrumentId,
278    _price_precision: u8,
279    size_precision: u8,
280    account_id: AccountId,
281    ts_init: UnixNanos,
282) -> anyhow::Result<Option<OrderStatusReport>> {
283    let ts_event = UnixNanos::from_millis(event_time as u64);
284
285    let client_order_id = ClientOrderId::new(decode_broker_id(
286        &algo_data.client_algo_id,
287        BINANCE_NAUTILUS_FUTURES_BROKER_ID,
288    ));
289
290    let venue_order_id = algo_data
291        .actual_order_id
292        .as_ref()
293        .filter(|id| !id.is_empty())
294        .map_or_else(
295            || VenueOrderId::new(algo_data.algo_id.to_string()),
296            |id| VenueOrderId::new(id.clone()),
297        );
298
299    let order_status = match algo_data.algo_status {
300        BinanceAlgoStatus::Canceled | BinanceAlgoStatus::Expired => OrderStatus::Canceled,
301        BinanceAlgoStatus::Rejected => OrderStatus::Rejected,
302        _ => return Ok(None),
303    };
304
305    let order_side = parse_side(algo_data.side);
306    let order_type = parse_futures_order_type(algo_data.order_type);
307    let time_in_force = parse_time_in_force(algo_data.time_in_force);
308
309    let quantity =
310        parse_required_quantity_at_precision(&algo_data.quantity, size_precision, "quantity")?;
311
312    let report = OrderStatusReport::new(
313        account_id,
314        instrument_id,
315        Some(client_order_id),
316        venue_order_id,
317        order_side,
318        order_type,
319        time_in_force,
320        order_status,
321        quantity,
322        Quantity::zero(size_precision),
323        ts_event,
324        ts_event,
325        ts_init,
326        None, // report_id
327    );
328
329    Ok(Some(report))
330}
331
332/// Converts a Binance Futures account update to a Nautilus account state.
333pub fn parse_futures_account_update(
334    msg: &BinanceFuturesAccountUpdateMsg,
335    account_id: AccountId,
336    bnfcr_currency: Currency,
337    ts_init: UnixNanos,
338) -> Option<AccountState> {
339    let ts_event = UnixNanos::from_millis(msg.event_time as u64);
340
341    let balances: Vec<AccountBalance> = msg
342        .account
343        .balances
344        .iter()
345        .filter_map(|b| {
346            if b.wallet_balance.is_zero() {
347                return None;
348            }
349
350            let currency = normalize_futures_asset(b.asset, bnfcr_currency);
351            AccountBalance::from_total_and_free(b.wallet_balance, b.cross_wallet_balance, currency)
352                .ok()
353        })
354        .collect();
355
356    if balances.is_empty() {
357        return None;
358    }
359
360    Some(AccountState::new(
361        account_id,
362        AccountType::Margin,
363        balances,
364        vec![], // Margins handled separately
365        true,   // is_reported
366        UUID4::new(),
367        ts_event,
368        ts_init,
369        None, // base_currency
370    ))
371}
372
373/// Returns the decoded client order ID from an [`OrderUpdateData`].
374pub fn decode_order_client_id(order: &OrderUpdateData) -> ClientOrderId {
375    ClientOrderId::new(decode_broker_id(
376        &order.client_order_id,
377        BINANCE_NAUTILUS_FUTURES_BROKER_ID,
378    ))
379}
380
381/// Returns the decoded client order ID from an [`AlgoOrderUpdateData`].
382pub fn decode_algo_client_id(algo: &AlgoOrderUpdateData) -> ClientOrderId {
383    ClientOrderId::new(decode_broker_id(
384        &algo.client_algo_id,
385        BINANCE_NAUTILUS_FUTURES_BROKER_ID,
386    ))
387}
388
389fn parse_optional_positive_price_at_precision(raw: &str, precision: u8) -> Option<Price> {
390    let decimal = parse_required_decimal(raw, "optional_price").ok()?;
391    if decimal <= Decimal::ZERO {
392        return None;
393    }
394
395    Price::from_decimal_dp(decimal, precision).ok()
396}
397
398fn parse_trailing_offset_basis_points(raw: &str) -> Option<Decimal> {
399    let rate = parse_required_decimal(raw, "callback_rate").ok()?;
400    if rate <= Decimal::ZERO {
401        return None;
402    }
403
404    rate.checked_mul(Decimal::from(100))
405}
406
407fn parse_side(side: BinanceSide) -> OrderSide {
408    match side {
409        BinanceSide::Buy => OrderSide::Buy,
410        BinanceSide::Sell => OrderSide::Sell,
411    }
412}
413
414fn parse_order_status(status: BinanceOrderStatus, treat_expired_as_canceled: bool) -> OrderStatus {
415    match status {
416        BinanceOrderStatus::New | BinanceOrderStatus::PendingNew => OrderStatus::Accepted,
417        BinanceOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
418        BinanceOrderStatus::Filled
419        | BinanceOrderStatus::NewAdl
420        | BinanceOrderStatus::NewInsurance => OrderStatus::Filled,
421        BinanceOrderStatus::Canceled | BinanceOrderStatus::PendingCancel => OrderStatus::Canceled,
422        BinanceOrderStatus::Rejected => OrderStatus::Rejected,
423        BinanceOrderStatus::Expired | BinanceOrderStatus::ExpiredInMatch => {
424            if treat_expired_as_canceled {
425                OrderStatus::Canceled
426            } else {
427                OrderStatus::Expired
428            }
429        }
430        BinanceOrderStatus::Unknown => OrderStatus::Accepted,
431    }
432}
433
434fn parse_futures_order_type(order_type: BinanceFuturesOrderType) -> OrderType {
435    match order_type {
436        BinanceFuturesOrderType::Limit => OrderType::Limit,
437        BinanceFuturesOrderType::Market => OrderType::Market,
438        BinanceFuturesOrderType::Stop => OrderType::StopLimit,
439        BinanceFuturesOrderType::StopMarket => OrderType::StopMarket,
440        BinanceFuturesOrderType::TakeProfit => OrderType::LimitIfTouched,
441        BinanceFuturesOrderType::TakeProfitMarket => OrderType::MarketIfTouched,
442        BinanceFuturesOrderType::TrailingStopMarket => OrderType::TrailingStopMarket,
443        BinanceFuturesOrderType::Liquidation
444        | BinanceFuturesOrderType::Adl
445        | BinanceFuturesOrderType::Unknown => OrderType::Market,
446    }
447}
448
449fn parse_time_in_force(tif: BinanceTimeInForce) -> TimeInForce {
450    match tif {
451        BinanceTimeInForce::Gtc | BinanceTimeInForce::Gtx => TimeInForce::Gtc,
452        BinanceTimeInForce::Ioc | BinanceTimeInForce::Rpi => TimeInForce::Ioc,
453        BinanceTimeInForce::Fok => TimeInForce::Fok,
454        BinanceTimeInForce::Gtd => TimeInForce::Gtd,
455        BinanceTimeInForce::Unknown => TimeInForce::Gtc,
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use rstest::rstest;
462    use serde::de::DeserializeOwned;
463
464    use super::*;
465    use crate::{
466        common::{
467            consts::BINANCE_NAUTILUS_FUTURES_BROKER_ID,
468            encoder::encode_broker_id,
469            enums::{BinancePriceMatch, BinanceSelfTradePreventionMode},
470            testing::load_fixture_string,
471        },
472        futures::websocket::streams::messages::{
473            BinanceFuturesAccountUpdateMsg, BinanceFuturesAlgoUpdateMsg,
474            BinanceFuturesOrderUpdateMsg,
475        },
476    };
477
478    const PRICE_PRECISION: u8 = 2;
479    const SIZE_PRECISION: u8 = 3;
480
481    fn instrument_id() -> InstrumentId {
482        InstrumentId::from("ETHUSDT-PERP.BINANCE")
483    }
484
485    fn account_id() -> AccountId {
486        AccountId::from("BINANCE-FUTURES-001")
487    }
488
489    fn load_user_data_fixture<T: DeserializeOwned>(filename: &str) -> T {
490        let path = format!("futures/user_data_json/{filename}");
491        serde_json::from_str(&load_fixture_string(&path))
492            .unwrap_or_else(|e| panic!("Failed to parse fixture {path}: {e}"))
493    }
494
495    #[rstest]
496    fn test_parse_order_update_to_order_status_new() {
497        let msg: BinanceFuturesOrderUpdateMsg = load_user_data_fixture("order_update_new.json");
498        let ts_init = UnixNanos::from(1_000_000_000u64);
499
500        let report = parse_futures_order_update_to_order_status(
501            &msg,
502            instrument_id(),
503            PRICE_PRECISION,
504            SIZE_PRECISION,
505            account_id(),
506            false,
507            ts_init,
508        )
509        .unwrap();
510
511        assert_eq!(report.account_id, account_id());
512        assert_eq!(report.instrument_id, instrument_id());
513        assert_eq!(report.order_side, OrderSide::Buy);
514        assert_eq!(report.order_status, OrderStatus::Accepted);
515        assert_eq!(report.order_type, OrderType::TrailingStopMarket);
516        assert_eq!(report.venue_order_id, VenueOrderId::new("8886774"));
517        assert_eq!(report.client_order_id, Some(ClientOrderId::from("TEST")));
518    }
519
520    #[rstest]
521    fn test_parse_order_update_to_order_status_rejects_invalid_quantity() {
522        let mut msg: BinanceFuturesOrderUpdateMsg = load_user_data_fixture("order_update_new.json");
523        msg.order.original_qty = "not-a-number".to_string();
524        let ts_init = UnixNanos::from(1_000_000_000u64);
525
526        let result = parse_futures_order_update_to_order_status(
527            &msg,
528            instrument_id(),
529            PRICE_PRECISION,
530            SIZE_PRECISION,
531            account_id(),
532            false,
533            ts_init,
534        );
535
536        let error = result.unwrap_err().to_string();
537        assert!(error.contains("original_qty"));
538    }
539
540    #[rstest]
541    fn test_parse_order_update_to_order_status_rejects_invalid_filled_quantity() {
542        let mut msg: BinanceFuturesOrderUpdateMsg = load_user_data_fixture("order_update_new.json");
543        msg.order.cumulative_filled_qty = "not-a-number".to_string();
544        let ts_init = UnixNanos::from(1_000_000_000u64);
545
546        let result = parse_futures_order_update_to_order_status(
547            &msg,
548            instrument_id(),
549            PRICE_PRECISION,
550            SIZE_PRECISION,
551            account_id(),
552            false,
553            ts_init,
554        );
555
556        let error = result.unwrap_err().to_string();
557        assert!(error.contains("cumulative_filled_qty"));
558    }
559
560    #[rstest]
561    fn test_parse_order_update_to_order_status_rejects_invalid_price() {
562        let mut msg: BinanceFuturesOrderUpdateMsg = load_user_data_fixture("order_update_new.json");
563        msg.order.original_price = "not-a-number".to_string();
564        let ts_init = UnixNanos::from(1_000_000_000u64);
565
566        let result = parse_futures_order_update_to_order_status(
567            &msg,
568            instrument_id(),
569            PRICE_PRECISION,
570            SIZE_PRECISION,
571            account_id(),
572            false,
573            ts_init,
574        );
575
576        let error = result.unwrap_err().to_string();
577        assert!(error.contains("original_price"));
578    }
579
580    #[rstest]
581    fn test_parse_order_update_to_order_status_skips_invalid_optional_fields() {
582        let mut msg: BinanceFuturesOrderUpdateMsg =
583            load_user_data_fixture("order_update_trade.json");
584        msg.order.average_price = "not-a-number".to_string();
585        msg.order.stop_price = "not-a-number".to_string();
586        msg.order.callback_rate = Some("not-a-number".to_string());
587        let ts_init = UnixNanos::from(1_000_000_000u64);
588
589        let report = parse_futures_order_update_to_order_status(
590            &msg,
591            instrument_id(),
592            PRICE_PRECISION,
593            SIZE_PRECISION,
594            account_id(),
595            false,
596            ts_init,
597        )
598        .unwrap();
599
600        assert!(report.avg_px.is_none());
601        assert!(report.trigger_price.is_none());
602        assert_eq!(report.trailing_offset, None);
603    }
604
605    #[rstest]
606    fn test_parse_order_update_to_fill_report() {
607        let msg: BinanceFuturesOrderUpdateMsg = load_user_data_fixture("order_update_trade.json");
608        let ts_init = UnixNanos::from(1_000_000_000u64);
609
610        assert_eq!(
611            msg.order.stp_mode,
612            Some(BinanceSelfTradePreventionMode::ExpireTaker),
613        );
614
615        let report = parse_futures_order_update_to_fill(
616            &msg,
617            account_id(),
618            instrument_id(),
619            PRICE_PRECISION,
620            SIZE_PRECISION,
621            None,
622            None,
623            Currency::USDT(),
624            None,
625            ts_init,
626        )
627        .unwrap();
628
629        assert_eq!(report.account_id, account_id());
630        assert_eq!(report.instrument_id, instrument_id());
631        assert_eq!(report.order_side, OrderSide::Buy);
632        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
633        assert_eq!(report.trade_id, TradeId::new("12345678"));
634        assert_eq!(report.client_order_id, Some(ClientOrderId::from("TEST")));
635        assert_eq!(report.last_qty, Quantity::new(0.001, SIZE_PRECISION));
636        assert_eq!(report.last_px, Price::new(7100.50, PRICE_PRECISION));
637    }
638
639    #[rstest]
640    fn test_parse_order_update_to_fill_rejects_invalid_price() {
641        let mut msg: BinanceFuturesOrderUpdateMsg =
642            load_user_data_fixture("order_update_trade.json");
643        msg.order.last_filled_price = "not-a-number".to_string();
644        let ts_init = UnixNanos::from(1_000_000_000u64);
645
646        let result = parse_futures_order_update_to_fill(
647            &msg,
648            account_id(),
649            instrument_id(),
650            PRICE_PRECISION,
651            SIZE_PRECISION,
652            None,
653            None,
654            Currency::USDT(),
655            None,
656            ts_init,
657        );
658
659        let error = result.unwrap_err().to_string();
660        assert!(error.contains("last_filled_price"));
661    }
662
663    #[rstest]
664    fn test_parse_order_update_to_fill_rejects_invalid_quantity() {
665        let mut msg: BinanceFuturesOrderUpdateMsg =
666            load_user_data_fixture("order_update_trade.json");
667        msg.order.last_filled_qty = "not-a-number".to_string();
668        let ts_init = UnixNanos::from(1_000_000_000u64);
669
670        let result = parse_futures_order_update_to_fill(
671            &msg,
672            account_id(),
673            instrument_id(),
674            PRICE_PRECISION,
675            SIZE_PRECISION,
676            None,
677            None,
678            Currency::USDT(),
679            None,
680            ts_init,
681        );
682
683        let error = result.unwrap_err().to_string();
684        assert!(error.contains("last_filled_qty"));
685    }
686
687    #[rstest]
688    fn test_parse_order_update_to_fill_rejects_invalid_commission() {
689        let mut msg: BinanceFuturesOrderUpdateMsg =
690            load_user_data_fixture("order_update_trade.json");
691        msg.order.commission = Some("not-a-number".to_string());
692        let ts_init = UnixNanos::from(1_000_000_000u64);
693
694        let result = parse_futures_order_update_to_fill(
695            &msg,
696            account_id(),
697            instrument_id(),
698            PRICE_PRECISION,
699            SIZE_PRECISION,
700            None,
701            None,
702            Currency::USDT(),
703            None,
704            ts_init,
705        );
706
707        let error = result.unwrap_err().to_string();
708        assert!(error.contains("commission"));
709    }
710
711    #[rstest]
712    fn test_parse_account_update() {
713        let msg: BinanceFuturesAccountUpdateMsg = load_user_data_fixture("account_update.json");
714        let ts_init = UnixNanos::from(1_000_000_000u64);
715
716        let state =
717            parse_futures_account_update(&msg, account_id(), Currency::USDT(), ts_init).unwrap();
718
719        assert_eq!(state.account_id, account_id());
720        assert_eq!(state.account_type, AccountType::Margin);
721        assert!(state.is_reported);
722        assert_eq!(state.balances.len(), 1);
723    }
724
725    // Credits Trading Mode (EU) reports the wallet in BNFCR, which is absent from the
726    // currency table; resolving it to the configured `bnfcr_currency` keeps the balance
727    // denominated in the stablecoin the contracts settle in instead of panicking.
728    #[rstest]
729    #[case(Currency::USDT())]
730    #[case(Currency::USDC())]
731    fn test_parse_account_update_maps_bnfcr_to_configured_currency(
732        #[case] bnfcr_currency: Currency,
733    ) {
734        let msg: BinanceFuturesAccountUpdateMsg =
735            load_user_data_fixture("account_update_bnfcr.json");
736        let ts_init = UnixNanos::from(1_000_000_000u64);
737
738        let state =
739            parse_futures_account_update(&msg, account_id(), bnfcr_currency, ts_init).unwrap();
740
741        assert_eq!(state.balances.len(), 1);
742        assert_eq!(state.balances[0].total.currency, bnfcr_currency);
743        assert_eq!(
744            state.balances[0].total.as_decimal(),
745            Decimal::from_str_exact("5001.28983031").unwrap()
746        );
747    }
748
749    // Regression for the #3867 bug class: WS balances whose `wb` and `cw` have more decimal
750    // places than the asset's currency precision used to trip the invariant when Money::new
751    // rounded each side independently.
752    #[rstest]
753    fn test_parse_account_update_precision_drift() {
754        let json = r#"{
755            "e": "ACCOUNT_UPDATE",
756            "E": 1700000000000,
757            "T": 1700000000000,
758            "a": {
759                "m": "ORDER",
760                "B": [{
761                    "a": "USDT",
762                    "wb": "10.000000034999",
763                    "cw": "9.999999994999"
764                }],
765                "P": []
766            }
767        }"#;
768        let msg: BinanceFuturesAccountUpdateMsg = serde_json::from_str(json).unwrap();
769        let ts_init = UnixNanos::from(1_000_000_000u64);
770
771        let state =
772            parse_futures_account_update(&msg, account_id(), Currency::USDT(), ts_init).unwrap();
773
774        assert_eq!(state.balances.len(), 1);
775        let balance = &state.balances[0];
776        assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
777    }
778
779    #[rstest]
780    fn test_parse_algo_update_to_order_status_canceled() {
781        let msg: BinanceFuturesAlgoUpdateMsg = load_user_data_fixture("algo_update_canceled.json");
782        let ts_init = UnixNanos::from(1_000_000_000u64);
783
784        assert_eq!(
785            msg.algo_order.stp_mode,
786            Some(BinanceSelfTradePreventionMode::ExpireMaker),
787        );
788        assert_eq!(msg.algo_order.price_match, Some(BinancePriceMatch::None));
789
790        let report = parse_futures_algo_update_to_order_status(
791            &msg.algo_order,
792            msg.event_time,
793            instrument_id(),
794            PRICE_PRECISION,
795            SIZE_PRECISION,
796            account_id(),
797            ts_init,
798        )
799        .unwrap()
800        .unwrap();
801
802        assert_eq!(report.account_id, account_id());
803        assert_eq!(report.instrument_id, instrument_id());
804        assert_eq!(
805            report.client_order_id,
806            Some(ClientOrderId::new("Q5xaq5EGKgXXa0fD7fs0Ip")),
807        );
808        assert_eq!(report.venue_order_id, VenueOrderId::new("2148719"));
809        assert_eq!(report.order_side, OrderSide::Sell);
810        assert_eq!(report.order_type, OrderType::LimitIfTouched);
811        assert_eq!(report.time_in_force, TimeInForce::Gtc);
812        assert_eq!(report.order_status, OrderStatus::Canceled);
813        assert_eq!(report.quantity, Quantity::new(0.01, SIZE_PRECISION));
814        assert_eq!(report.filled_qty, Quantity::new(0.0, SIZE_PRECISION));
815        assert_eq!(
816            report.ts_accepted,
817            UnixNanos::from(1_750_515_742_303_000_000u64)
818        );
819        assert_eq!(
820            report.ts_last,
821            UnixNanos::from(1_750_515_742_303_000_000u64)
822        );
823        assert_eq!(report.ts_init, ts_init);
824    }
825
826    #[rstest]
827    fn test_parse_algo_update_to_order_status_new_returns_none() {
828        let msg: BinanceFuturesAlgoUpdateMsg = load_user_data_fixture("algo_update_new.json");
829        let report = parse_futures_algo_update_to_order_status(
830            &msg.algo_order,
831            msg.event_time,
832            instrument_id(),
833            PRICE_PRECISION,
834            SIZE_PRECISION,
835            account_id(),
836            UnixNanos::default(),
837        );
838
839        assert!(report.unwrap().is_none());
840    }
841
842    #[rstest]
843    fn test_parse_algo_update_to_order_status_rejects_invalid_quantity() {
844        let mut msg: BinanceFuturesAlgoUpdateMsg =
845            load_user_data_fixture("algo_update_canceled.json");
846        msg.algo_order.quantity = "not-a-number".to_string();
847
848        let result = parse_futures_algo_update_to_order_status(
849            &msg.algo_order,
850            msg.event_time,
851            instrument_id(),
852            PRICE_PRECISION,
853            SIZE_PRECISION,
854            account_id(),
855            UnixNanos::default(),
856        );
857
858        let error = result.unwrap_err().to_string();
859        assert!(error.contains("quantity"));
860    }
861
862    #[rstest]
863    fn test_decode_order_client_id() {
864        let mut msg: BinanceFuturesOrderUpdateMsg = load_user_data_fixture("order_update_new.json");
865        let original = ClientOrderId::from("O-20200101-000000-000-000-1");
866        msg.order.client_order_id = encode_broker_id(&original, BINANCE_NAUTILUS_FUTURES_BROKER_ID);
867
868        let decoded = decode_order_client_id(&msg.order);
869
870        assert_eq!(decoded, original);
871    }
872
873    #[rstest]
874    fn test_decode_algo_client_id() {
875        let mut msg: BinanceFuturesAlgoUpdateMsg =
876            load_user_data_fixture("algo_update_canceled.json");
877        let original = ClientOrderId::from("O-20200101-000000-000-000-2");
878        msg.algo_order.client_algo_id =
879            encode_broker_id(&original, BINANCE_NAUTILUS_FUTURES_BROKER_ID);
880
881        let decoded = decode_algo_client_id(&msg.algo_order);
882
883        assert_eq!(decoded, original);
884    }
885
886    #[rstest]
887    fn test_parse_liquidation_fill() {
888        let msg: BinanceFuturesOrderUpdateMsg =
889            load_user_data_fixture("order_update_calculated.json");
890        let ts_init = UnixNanos::from(1_000_000_000u64);
891
892        assert!(msg.order.is_liquidation());
893        assert!(msg.order.is_exchange_generated());
894
895        let fill = parse_futures_order_update_to_fill(
896            &msg,
897            account_id(),
898            instrument_id(),
899            PRICE_PRECISION,
900            SIZE_PRECISION,
901            None,
902            None,
903            Currency::USDT(),
904            None,
905            ts_init,
906        )
907        .unwrap();
908
909        assert_eq!(fill.account_id, account_id());
910        assert_eq!(fill.instrument_id, instrument_id());
911        assert_eq!(
912            fill.client_order_id,
913            Some(ClientOrderId::new("autoclose-1234567890"))
914        );
915        assert_eq!(fill.venue_order_id, VenueOrderId::new("8886999"));
916        assert_eq!(fill.trade_id, TradeId::new("12345999"));
917        assert_eq!(fill.order_side, OrderSide::Sell);
918        assert_eq!(fill.last_qty, Quantity::new(0.014, SIZE_PRECISION));
919        assert_eq!(fill.last_px, Price::new(9910.12, PRICE_PRECISION));
920        assert_eq!(
921            fill.commission,
922            Money::new(0.06937084, Currency::from("USDT"))
923        );
924        assert_eq!(fill.liquidity_side, LiquiditySide::Taker);
925    }
926
927    #[rstest]
928    fn test_parse_liquidation_status_report() {
929        let msg: BinanceFuturesOrderUpdateMsg =
930            load_user_data_fixture("order_update_calculated.json");
931        let ts_init = UnixNanos::from(1_000_000_000u64);
932
933        let status = parse_futures_order_update_to_order_status(
934            &msg,
935            instrument_id(),
936            PRICE_PRECISION,
937            SIZE_PRECISION,
938            account_id(),
939            false,
940            ts_init,
941        )
942        .unwrap();
943
944        assert_eq!(status.account_id, account_id());
945        assert_eq!(status.instrument_id, instrument_id());
946        assert_eq!(
947            status.client_order_id,
948            Some(ClientOrderId::new("autoclose-1234567890"))
949        );
950        assert_eq!(status.venue_order_id, VenueOrderId::new("8886999"));
951        assert_eq!(status.order_side, OrderSide::Sell);
952        assert_eq!(status.order_status, OrderStatus::Filled);
953        assert_eq!(status.quantity, Quantity::new(0.014, SIZE_PRECISION));
954        assert_eq!(status.filled_qty, Quantity::new(0.014, SIZE_PRECISION));
955    }
956
957    #[rstest]
958    fn test_parse_adl_fill_with_new_adl_status() {
959        let msg: BinanceFuturesOrderUpdateMsg = load_user_data_fixture("order_update_adl.json");
960        let ts_init = UnixNanos::from(1_000_000_000u64);
961
962        assert!(msg.order.is_adl());
963        assert!(msg.order.is_exchange_generated());
964        assert!(!msg.order.is_liquidation());
965
966        let fill = parse_futures_order_update_to_fill(
967            &msg,
968            account_id(),
969            instrument_id(),
970            PRICE_PRECISION,
971            SIZE_PRECISION,
972            None,
973            None,
974            Currency::USDT(),
975            None,
976            ts_init,
977        )
978        .unwrap();
979
980        assert_eq!(
981            fill.client_order_id,
982            Some(ClientOrderId::new("adl_autoclose_12345"))
983        );
984        assert_eq!(fill.venue_order_id, VenueOrderId::new("8887001"));
985        assert_eq!(fill.order_side, OrderSide::Buy);
986        assert_eq!(fill.last_qty, Quantity::new(0.005, SIZE_PRECISION));
987        assert_eq!(fill.last_px, Price::new(42000.00, PRICE_PRECISION));
988        assert_eq!(fill.liquidity_side, LiquiditySide::Taker);
989    }
990
991    #[rstest]
992    fn test_parse_adl_status_report_maps_new_adl_to_filled() {
993        let msg: BinanceFuturesOrderUpdateMsg = load_user_data_fixture("order_update_adl.json");
994        let ts_init = UnixNanos::from(1_000_000_000u64);
995
996        let status = parse_futures_order_update_to_order_status(
997            &msg,
998            instrument_id(),
999            PRICE_PRECISION,
1000            SIZE_PRECISION,
1001            account_id(),
1002            false,
1003            ts_init,
1004        )
1005        .unwrap();
1006
1007        assert_eq!(status.order_status, OrderStatus::Filled);
1008        assert_eq!(status.filled_qty, Quantity::new(0.005, SIZE_PRECISION));
1009    }
1010
1011    #[rstest]
1012    fn test_parse_settlement_fill_with_trade_exec_type() {
1013        let msg: BinanceFuturesOrderUpdateMsg =
1014            load_user_data_fixture("order_update_settlement.json");
1015        let ts_init = UnixNanos::from(1_000_000_000u64);
1016
1017        assert!(msg.order.is_settlement());
1018        assert!(msg.order.is_exchange_generated());
1019        assert!(!msg.order.is_liquidation());
1020        assert!(!msg.order.is_adl());
1021
1022        let fill = parse_futures_order_update_to_fill(
1023            &msg,
1024            account_id(),
1025            instrument_id(),
1026            PRICE_PRECISION,
1027            SIZE_PRECISION,
1028            None,
1029            None,
1030            Currency::USDT(),
1031            None,
1032            ts_init,
1033        )
1034        .unwrap();
1035
1036        assert_eq!(
1037            fill.client_order_id,
1038            Some(ClientOrderId::new("settlement_autoclose-9999"))
1039        );
1040        assert_eq!(fill.venue_order_id, VenueOrderId::new("8887002"));
1041        assert_eq!(fill.order_side, OrderSide::Sell);
1042        assert_eq!(fill.last_qty, Quantity::new(0.010, SIZE_PRECISION));
1043        assert_eq!(fill.last_px, Price::new(50000.00, PRICE_PRECISION));
1044    }
1045
1046    #[rstest]
1047    fn test_parse_order_status_new_adl_maps_to_filled() {
1048        let result = parse_order_status(BinanceOrderStatus::NewAdl, false);
1049        assert_eq!(result, OrderStatus::Filled);
1050    }
1051
1052    #[rstest]
1053    fn test_parse_order_status_new_insurance_maps_to_filled() {
1054        let result = parse_order_status(BinanceOrderStatus::NewInsurance, false);
1055        assert_eq!(result, OrderStatus::Filled);
1056    }
1057
1058    #[rstest]
1059    #[case(BinanceOrderStatus::Expired, false, OrderStatus::Expired)]
1060    #[case(BinanceOrderStatus::Expired, true, OrderStatus::Canceled)]
1061    #[case(BinanceOrderStatus::ExpiredInMatch, false, OrderStatus::Expired)]
1062    #[case(BinanceOrderStatus::ExpiredInMatch, true, OrderStatus::Canceled)]
1063    fn test_parse_order_status_expired_respects_treat_as_canceled(
1064        #[case] status: BinanceOrderStatus,
1065        #[case] treat_expired_as_canceled: bool,
1066        #[case] expected: OrderStatus,
1067    ) {
1068        let result = parse_order_status(status, treat_expired_as_canceled);
1069        assert_eq!(result, expected);
1070    }
1071
1072    #[rstest]
1073    fn test_is_exchange_generated_autoclose() {
1074        let msg: BinanceFuturesOrderUpdateMsg =
1075            load_user_data_fixture("order_update_calculated.json");
1076        assert!(msg.order.is_exchange_generated());
1077        assert!(msg.order.is_liquidation());
1078    }
1079
1080    #[rstest]
1081    fn test_is_exchange_generated_adl_autoclose() {
1082        let msg: BinanceFuturesOrderUpdateMsg = load_user_data_fixture("order_update_adl.json");
1083        assert!(msg.order.is_exchange_generated());
1084        assert!(msg.order.is_adl());
1085    }
1086
1087    #[rstest]
1088    fn test_is_exchange_generated_settlement_autoclose() {
1089        let msg: BinanceFuturesOrderUpdateMsg =
1090            load_user_data_fixture("order_update_settlement.json");
1091        assert!(msg.order.is_exchange_generated());
1092        assert!(msg.order.is_settlement());
1093    }
1094
1095    #[rstest]
1096    fn test_is_exchange_generated_delivery_autoclose() {
1097        let msg: BinanceFuturesOrderUpdateMsg =
1098            load_user_data_fixture("order_update_delivery.json");
1099        assert!(msg.order.is_exchange_generated());
1100        assert!(msg.order.is_settlement());
1101        assert!(!msg.order.is_liquidation());
1102        assert!(!msg.order.is_adl());
1103    }
1104
1105    #[rstest]
1106    fn test_normal_order_is_not_exchange_generated() {
1107        let msg: BinanceFuturesOrderUpdateMsg = load_user_data_fixture("order_update_trade.json");
1108        assert!(!msg.order.is_exchange_generated());
1109        assert!(!msg.order.is_liquidation());
1110        assert!(!msg.order.is_adl());
1111        assert!(!msg.order.is_settlement());
1112    }
1113
1114    #[rstest]
1115    fn test_parse_insurance_fill_with_new_insurance_status() {
1116        let msg: BinanceFuturesOrderUpdateMsg =
1117            load_user_data_fixture("order_update_insurance.json");
1118
1119        assert!(msg.order.is_liquidation());
1120        assert!(msg.order.is_exchange_generated());
1121        assert_eq!(msg.order.order_status, BinanceOrderStatus::NewInsurance);
1122
1123        let fill = parse_futures_order_update_to_fill(
1124            &msg,
1125            account_id(),
1126            instrument_id(),
1127            PRICE_PRECISION,
1128            SIZE_PRECISION,
1129            None,
1130            None,
1131            Currency::USDT(),
1132            None,
1133            UnixNanos::from(1_000_000_000u64),
1134        )
1135        .unwrap();
1136
1137        assert_eq!(
1138            fill.client_order_id,
1139            Some(ClientOrderId::new("autoclose-insurance-5678"))
1140        );
1141        assert_eq!(fill.order_side, OrderSide::Sell);
1142        assert_eq!(fill.last_qty, Quantity::new(0.020, SIZE_PRECISION));
1143        assert_eq!(fill.last_px, Price::new(45000.00, PRICE_PRECISION));
1144    }
1145
1146    #[rstest]
1147    fn test_parse_insurance_status_maps_new_insurance_to_filled() {
1148        let msg: BinanceFuturesOrderUpdateMsg =
1149            load_user_data_fixture("order_update_insurance.json");
1150
1151        let status = parse_futures_order_update_to_order_status(
1152            &msg,
1153            instrument_id(),
1154            PRICE_PRECISION,
1155            SIZE_PRECISION,
1156            account_id(),
1157            false,
1158            UnixNanos::from(1_000_000_000u64),
1159        )
1160        .unwrap();
1161
1162        assert_eq!(status.order_status, OrderStatus::Filled);
1163    }
1164
1165    #[rstest]
1166    fn test_parse_settlement_status_report() {
1167        let msg: BinanceFuturesOrderUpdateMsg =
1168            load_user_data_fixture("order_update_settlement.json");
1169
1170        let status = parse_futures_order_update_to_order_status(
1171            &msg,
1172            instrument_id(),
1173            PRICE_PRECISION,
1174            SIZE_PRECISION,
1175            account_id(),
1176            false,
1177            UnixNanos::from(1_000_000_000u64),
1178        )
1179        .unwrap();
1180
1181        assert_eq!(status.order_status, OrderStatus::Filled);
1182        assert_eq!(status.order_side, OrderSide::Sell);
1183        assert_eq!(status.quantity, Quantity::new(0.010, SIZE_PRECISION));
1184        assert_eq!(status.filled_qty, Quantity::new(0.010, SIZE_PRECISION));
1185    }
1186
1187    #[rstest]
1188    fn test_pending_liquidation_has_zero_fill_qty() {
1189        let msg: BinanceFuturesOrderUpdateMsg =
1190            load_user_data_fixture("order_update_calculated_pending.json");
1191
1192        assert!(msg.order.is_exchange_generated());
1193        assert!(msg.order.is_liquidation());
1194
1195        let last_qty = parse_required_decimal(&msg.order.last_filled_qty, "last_filled_qty")
1196            .expect("last_filled_qty should parse");
1197        assert!(last_qty.is_zero());
1198    }
1199
1200    #[rstest]
1201    #[case::venue_provided(Some("USDT"), Some("0.06937084"), None, None, 0.06937084, "USDT")]
1202    #[case::fallback_from_taker_fee(
1203        None, None,
1204        Some("0.0004"), Some("USDT"),
1205        0.055496, "USDT"  // 0.0004 * 0.014 * 9910.12 ≈ 0.05549...
1206    )]
1207    #[case::no_commission_no_fee(None, None, None, None, 0.0, "USDT")]
1208    fn test_resolve_commission(
1209        #[case] commission_asset: Option<&str>,
1210        #[case] commission_amount: Option<&str>,
1211        #[case] taker_fee_str: Option<&str>,
1212        #[case] quote_currency_str: Option<&str>,
1213        #[case] expected_amount: f64,
1214        #[case] expected_currency: &str,
1215    ) {
1216        let mut msg: BinanceFuturesOrderUpdateMsg =
1217            load_user_data_fixture("order_update_calculated.json");
1218        msg.order.commission_asset = commission_asset.map(ustr::Ustr::from);
1219        msg.order.commission = commission_amount.map(String::from);
1220
1221        let last_qty = Quantity::from_decimal_dp(
1222            Decimal::from_str_exact(&msg.order.last_filled_qty).unwrap(),
1223            SIZE_PRECISION,
1224        )
1225        .unwrap();
1226        let last_px = Price::from_decimal_dp(
1227            Decimal::from_str_exact(&msg.order.last_filled_price).unwrap(),
1228            PRICE_PRECISION,
1229        )
1230        .unwrap();
1231        let taker_fee = taker_fee_str.map(|s| Decimal::from_str_exact(s).unwrap());
1232        let quote_currency = quote_currency_str.map(Currency::from);
1233
1234        let commission = resolve_commission(
1235            &msg.order,
1236            last_qty,
1237            last_px,
1238            taker_fee,
1239            quote_currency,
1240            Currency::USDT(),
1241        )
1242        .unwrap();
1243
1244        assert_eq!(commission.currency, Currency::from(expected_currency));
1245        let diff = (commission.as_f64() - expected_amount).abs();
1246        assert!(
1247            diff < 1e-4,
1248            "expected {expected_amount}, was {}",
1249            commission.as_f64()
1250        );
1251    }
1252
1253    #[rstest]
1254    #[case::with_venue_position_id(
1255        Some(Decimal::from_str_exact("0.0004").unwrap()),
1256        Some(Currency::from("USDT")),
1257        Some(PositionId::new("ETHUSDT-PERP.BINANCE-LONG")),
1258    )]
1259    #[case::without_extras(None, None, None)]
1260    fn test_parse_fill_with_optional_params(
1261        #[case] taker_fee: Option<Decimal>,
1262        #[case] quote_currency: Option<Currency>,
1263        #[case] venue_position_id: Option<PositionId>,
1264    ) {
1265        let msg: BinanceFuturesOrderUpdateMsg =
1266            load_user_data_fixture("order_update_calculated.json");
1267        let ts_init = UnixNanos::from(1_000_000_000u64);
1268
1269        let fill = parse_futures_order_update_to_fill(
1270            &msg,
1271            account_id(),
1272            instrument_id(),
1273            PRICE_PRECISION,
1274            SIZE_PRECISION,
1275            taker_fee,
1276            quote_currency,
1277            Currency::USDT(),
1278            venue_position_id,
1279            ts_init,
1280        )
1281        .unwrap();
1282
1283        assert_eq!(fill.venue_position_id, venue_position_id);
1284        assert_eq!(fill.account_id, account_id());
1285        assert_eq!(fill.instrument_id, instrument_id());
1286    }
1287}