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