Skip to main content

nautilus_binance/spot/websocket/trading/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parse functions for converting Binance Spot venue types to Nautilus reports.
17//!
18//! Pure functions that take venue message + instrument + account_id + ts_init
19//! and return Nautilus report types.
20
21use nautilus_core::{UUID4, UnixNanos};
22use nautilus_model::{
23    enums::{AccountType, LiquiditySide, OrderSide, OrderStatus, OrderType},
24    events::AccountState,
25    identifiers::{AccountId, InstrumentId, TradeId, VenueOrderId},
26    reports::{FillReport, OrderStatusReport},
27    types::{Currency, Money, Price},
28};
29use rust_decimal::Decimal;
30
31use super::user_data::{BinanceSpotAccountPositionMsg, BinanceSpotExecutionReport};
32use crate::common::{
33    consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
34    encoder::decode_client_order_id,
35    enums::{BinanceOrderStatus, BinanceSide},
36    parse::{
37        account_balance_from_free_locked, parse_millis_or_init, parse_required_decimal,
38        parse_required_price_at_precision, parse_required_quantity_at_precision,
39    },
40};
41
42/// Converts a Binance Spot execution report to a Nautilus order status report.
43///
44/// # Errors
45///
46/// Returns an error if report construction fails.
47pub fn parse_spot_exec_report_to_order_status(
48    msg: &BinanceSpotExecutionReport,
49    instrument_id: InstrumentId,
50    price_precision: u8,
51    size_precision: u8,
52    account_id: AccountId,
53    treat_expired_as_canceled: bool,
54    ts_init: UnixNanos,
55) -> anyhow::Result<OrderStatusReport> {
56    let client_order_id =
57        decode_client_order_id(msg.order_client_order_id(), BINANCE_NAUTILUS_SPOT_BROKER_ID)?;
58    let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
59    let ts_event = parse_millis_or_init(msg.event_time, "Spot execution event time", ts_init);
60
61    let order_side = match msg.side {
62        BinanceSide::Buy => OrderSide::Buy,
63        BinanceSide::Sell => OrderSide::Sell,
64    };
65
66    let order_status = parse_order_status(msg.order_status, treat_expired_as_canceled)?;
67    let order_type = parse_spot_order_type(&msg.order_type)?;
68    let time_in_force = msg.time_in_force.to_nautilus_time_in_force()?;
69
70    let quantity =
71        parse_required_quantity_at_precision(&msg.original_qty, size_precision, "original_qty")?;
72    let filled_qty = parse_required_quantity_at_precision(
73        &msg.cumulative_filled_qty,
74        size_precision,
75        "cumulative_filled_qty",
76    )?;
77    let price = parse_required_price_at_precision(&msg.price, price_precision, "price")?;
78
79    let filled_qty_decimal =
80        parse_required_decimal(&msg.cumulative_filled_qty, "cumulative_filled_qty")?;
81    let avg_px = if filled_qty_decimal > Decimal::ZERO {
82        let cum_quote = parse_required_decimal(&msg.cumulative_quote_qty, "cumulative_quote_qty")?;
83        let avg_px = cum_quote.checked_div(filled_qty_decimal).ok_or_else(|| {
84            anyhow::anyhow!(
85                "invalid cumulative_quote_qty='{}' for cumulative_filled_qty='{}': division overflow",
86                msg.cumulative_quote_qty,
87                msg.cumulative_filled_qty,
88            )
89        })?;
90        Some(Price::from_decimal_dp(avg_px, price_precision)?)
91    } else {
92        None
93    };
94
95    let mut report = OrderStatusReport::new(
96        account_id,
97        instrument_id,
98        Some(client_order_id),
99        venue_order_id,
100        order_side.into(),
101        order_type,
102        time_in_force,
103        order_status,
104        quantity,
105        filled_qty,
106        ts_event,
107        ts_event,
108        ts_init,
109        None, // report_id
110    );
111
112    report.price = Some(price);
113    report.post_only = msg.order_type == "LIMIT_MAKER";
114
115    let stop_price = parse_required_decimal(&msg.stop_price, "stop_price")?;
116    if stop_price > Decimal::ZERO {
117        report.trigger_price = Some(parse_required_price_at_precision(
118            &msg.stop_price,
119            price_precision,
120            "stop_price",
121        )?);
122    }
123
124    if let Some(avg) = avg_px {
125        report.avg_px = Some(avg.as_decimal());
126    }
127
128    Ok(report)
129}
130
131/// Converts a Binance Spot execution report (Trade type) to a Nautilus fill report.
132///
133/// # Errors
134///
135/// Returns an error if report construction fails.
136pub fn parse_spot_exec_report_to_fill(
137    msg: &BinanceSpotExecutionReport,
138    instrument_id: InstrumentId,
139    price_precision: u8,
140    size_precision: u8,
141    account_id: AccountId,
142    ts_init: UnixNanos,
143) -> anyhow::Result<FillReport> {
144    let client_order_id =
145        decode_client_order_id(msg.order_client_order_id(), BINANCE_NAUTILUS_SPOT_BROKER_ID)?;
146    let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
147    let trade_id = TradeId::new(msg.trade_id.to_string());
148    let ts_event = parse_millis_or_init(msg.event_time, "Spot execution event time", ts_init);
149
150    let order_side = match msg.side {
151        BinanceSide::Buy => OrderSide::Buy,
152        BinanceSide::Sell => OrderSide::Sell,
153    };
154
155    let liquidity_side = if msg.is_maker {
156        LiquiditySide::Maker
157    } else {
158        LiquiditySide::Taker
159    };
160
161    let last_qty = parse_required_quantity_at_precision(
162        &msg.last_filled_qty,
163        size_precision,
164        "last_filled_qty",
165    )?;
166    let last_px = parse_required_price_at_precision(
167        &msg.last_filled_price,
168        price_precision,
169        "last_filled_price",
170    )?;
171    let commission = parse_required_decimal(&msg.commission, "commission")?;
172    let commission_currency = msg
173        .commission_asset
174        .as_ref()
175        .map_or_else(Currency::USDT, |a| {
176            Currency::get_or_create_crypto(a.as_str())
177        });
178
179    Ok(FillReport::new(
180        account_id,
181        instrument_id,
182        venue_order_id,
183        trade_id,
184        order_side,
185        last_qty,
186        last_px,
187        Money::from_decimal(commission, commission_currency)?,
188        liquidity_side,
189        Some(client_order_id),
190        None, // venue_position_id
191        ts_event,
192        ts_init,
193        None, // report_id
194    ))
195}
196
197/// Converts a Binance Spot account position update to a Nautilus account state.
198///
199/// # Errors
200///
201/// Returns an error if a balance's free and locked amounts overflow when added, or if an amount
202/// cannot be represented as money. No account state is returned when any balance fails.
203pub fn parse_spot_account_position(
204    msg: &BinanceSpotAccountPositionMsg,
205    account_id: AccountId,
206    ts_init: UnixNanos,
207) -> anyhow::Result<AccountState> {
208    let ts_event =
209        parse_millis_or_init(msg.event_time, "Spot account position event time", ts_init);
210
211    let mut balances = Vec::with_capacity(msg.balances.len());
212    for balance in &msg.balances {
213        let currency = Currency::get_or_create_crypto(balance.asset.as_str());
214        let account_balance = account_balance_from_free_locked(
215            balance.asset.as_str(),
216            balance.free,
217            balance.locked,
218            currency,
219        )?;
220        balances.push(account_balance);
221    }
222
223    Ok(AccountState::new(
224        account_id,
225        AccountType::Cash,
226        balances,
227        vec![], // No margins for spot
228        true,   // is_reported
229        UUID4::new(),
230        ts_event,
231        ts_init,
232        None, // base_currency
233    ))
234}
235
236fn parse_order_status(
237    status: BinanceOrderStatus,
238    treat_expired_as_canceled: bool,
239) -> anyhow::Result<OrderStatus> {
240    Ok(match status {
241        BinanceOrderStatus::New | BinanceOrderStatus::PendingNew => OrderStatus::Accepted,
242        BinanceOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
243        BinanceOrderStatus::Filled
244        | BinanceOrderStatus::NewAdl
245        | BinanceOrderStatus::NewInsurance => OrderStatus::Filled,
246        BinanceOrderStatus::Canceled | BinanceOrderStatus::PendingCancel => OrderStatus::Canceled,
247        BinanceOrderStatus::Rejected => OrderStatus::Rejected,
248        BinanceOrderStatus::Expired | BinanceOrderStatus::ExpiredInMatch => {
249            if treat_expired_as_canceled {
250                OrderStatus::Canceled
251            } else {
252                OrderStatus::Expired
253            }
254        }
255        BinanceOrderStatus::Unknown => anyhow::bail!("unknown Binance Spot order status"),
256    })
257}
258
259fn parse_spot_order_type(order_type: &str) -> anyhow::Result<OrderType> {
260    Ok(match order_type {
261        "LIMIT" | "LIMIT_MAKER" => OrderType::Limit,
262        "MARKET" => OrderType::Market,
263        "STOP_LOSS" => OrderType::StopMarket,
264        "STOP_LOSS_LIMIT" => OrderType::StopLimit,
265        "TAKE_PROFIT" => OrderType::MarketIfTouched,
266        "TAKE_PROFIT_LIMIT" => OrderType::LimitIfTouched,
267        _ => anyhow::bail!("unknown Binance Spot order type: {order_type}"),
268    })
269}
270
271#[cfg(test)]
272mod tests {
273    use nautilus_model::{enums::TimeInForce, identifiers::ClientOrderId, types::Quantity};
274    use rstest::rstest;
275
276    use super::*;
277    use crate::{
278        common::testing::load_fixture_string,
279        spot::websocket::trading::user_data::BinanceSpotExecutionReport,
280    };
281
282    const PRICE_PRECISION: u8 = 2;
283    const SIZE_PRECISION: u8 = 5;
284
285    fn instrument_id() -> InstrumentId {
286        InstrumentId::from("ETHUSDT.BINANCE")
287    }
288
289    #[rstest]
290    #[case::status("X", "unknown Binance Spot order status")]
291    #[case::order_type("o", "unknown Binance Spot order type: UNRECOGNIZED")]
292    #[case::tif("f", "unknown Binance time in force")]
293    fn test_order_report_rejects_unknown_values(#[case] field: &str, #[case] expected: &str) {
294        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
295        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
296        value[field] = serde_json::Value::String("UNRECOGNIZED".to_string());
297        let msg: BinanceSpotExecutionReport = serde_json::from_value(value).unwrap();
298
299        let error = parse_spot_exec_report_to_order_status(
300            &msg,
301            InstrumentId::from("ETHUSDT.BINANCE"),
302            2,
303            5,
304            AccountId::from("BINANCE-001"),
305            false,
306            UnixNanos::default(),
307        )
308        .unwrap_err();
309
310        assert_eq!(error.to_string(), expected);
311    }
312
313    #[rstest]
314    #[case::as_expired(false, OrderStatus::Expired)]
315    #[case::as_canceled(true, OrderStatus::Canceled)]
316    fn test_parse_order_status_expired_respects_treat_as_canceled(
317        #[case] treat_expired_as_canceled: bool,
318        #[case] expected: OrderStatus,
319    ) {
320        assert_eq!(
321            parse_order_status(BinanceOrderStatus::Expired, treat_expired_as_canceled).unwrap(),
322            expected,
323        );
324        assert_eq!(
325            parse_order_status(
326                BinanceOrderStatus::ExpiredInMatch,
327                treat_expired_as_canceled,
328            )
329            .unwrap(),
330            expected,
331        );
332    }
333
334    #[rstest]
335    fn test_parse_execution_report_to_order_status_report() {
336        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
337        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
338        let account_id = AccountId::from("BINANCE-001");
339        let ts_init = UnixNanos::from(1_000_000_000u64);
340
341        let report = parse_spot_exec_report_to_order_status(
342            &msg,
343            instrument_id(),
344            PRICE_PRECISION,
345            SIZE_PRECISION,
346            account_id,
347            false,
348            ts_init,
349        )
350        .unwrap();
351
352        assert_eq!(report.account_id, account_id);
353        assert_eq!(report.instrument_id, instrument_id());
354        assert_eq!(report.order_side, OrderSide::Buy.into());
355        assert_eq!(report.order_status, OrderStatus::Accepted);
356        assert_eq!(report.order_type, OrderType::Limit);
357        assert_eq!(report.time_in_force, TimeInForce::Gtc);
358        assert_eq!(report.venue_order_id, VenueOrderId::new("12345678"));
359        assert_eq!(
360            report.client_order_id,
361            Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
362        );
363        assert_eq!(report.quantity, Quantity::new(1.0, SIZE_PRECISION));
364        assert_eq!(report.filled_qty, Quantity::new(0.0, SIZE_PRECISION));
365        assert_eq!(report.price, Some(Price::new(2500.0, PRICE_PRECISION)));
366        assert!(report.avg_px.is_none());
367        assert!(!report.post_only);
368        assert!(report.trigger_price.is_none());
369        assert_eq!(
370            report.ts_accepted,
371            UnixNanos::from(1_709_654_400_000_000_000u64)
372        );
373        assert_eq!(
374            report.ts_last,
375            UnixNanos::from(1_709_654_400_000_000_000u64)
376        );
377        assert_eq!(report.ts_init, ts_init);
378    }
379
380    #[rstest]
381    fn test_parse_execution_report_to_order_status_rejects_invalid_quantity() {
382        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
383        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
384        msg.original_qty = "not-a-number".to_string();
385        let account_id = AccountId::from("BINANCE-001");
386        let ts_init = UnixNanos::from(1_000_000_000u64);
387
388        let result = parse_spot_exec_report_to_order_status(
389            &msg,
390            instrument_id(),
391            PRICE_PRECISION,
392            SIZE_PRECISION,
393            account_id,
394            false,
395            ts_init,
396        );
397
398        let error = result.unwrap_err().to_string();
399        assert!(error.contains("original_qty"));
400    }
401
402    #[rstest]
403    #[case::negative(-1)]
404    #[case::overflow(i64::MAX)]
405    fn test_parse_execution_report_falls_back_for_invalid_timestamp(#[case] event_time: i64) {
406        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
407        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
408        msg.event_time = event_time;
409
410        let ts_init = UnixNanos::from(1);
411        let report = parse_spot_exec_report_to_order_status(
412            &msg,
413            instrument_id(),
414            PRICE_PRECISION,
415            SIZE_PRECISION,
416            AccountId::from("BINANCE-001"),
417            false,
418            ts_init,
419        )
420        .unwrap();
421
422        assert_eq!(report.ts_accepted, ts_init);
423        assert_eq!(report.ts_last, ts_init);
424        assert_eq!(report.ts_init, ts_init);
425    }
426
427    #[rstest]
428    #[case::empty("", "invalid Binance client order ID ''")]
429    #[case::whitespace("   ", "invalid Binance client order ID '   '")]
430    #[case::non_ascii("client-é", "invalid Binance client order ID 'client-é'")]
431    #[case::malformed_prefixed("x-TD67BGP9-R", "missing raw broker client order ID payload")]
432    fn test_parse_execution_report_to_order_status_rejects_invalid_client_order_id(
433        #[case] client_order_id: &str,
434        #[case] expected: &str,
435    ) {
436        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
437        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
438        msg.client_order_id = client_order_id.to_string();
439
440        let result = parse_spot_exec_report_to_order_status(
441            &msg,
442            instrument_id(),
443            PRICE_PRECISION,
444            SIZE_PRECISION,
445            AccountId::from("BINANCE-001"),
446            false,
447            UnixNanos::from(1_000_000_000u64),
448        );
449
450        assert_eq!(result.unwrap_err().to_string(), expected);
451    }
452
453    #[rstest]
454    #[case::orig_set(Some("x-TD67BGP9-T0000000000000"), "O-20200101-000000-000-000-0")]
455    #[case::orig_empty(Some(""), "web_9f8e7d6c5b4a")]
456    #[case::orig_missing(None, "web_9f8e7d6c5b4a")]
457    fn test_parse_execution_report_to_order_status_canceled_prefers_orig_client_order_id(
458        #[case] original_client_order_id: Option<&str>,
459        #[case] expected: &str,
460    ) {
461        let json = load_fixture_string("spot/user_data_json/execution_report_canceled.json");
462        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
463        msg.client_order_id = "web_9f8e7d6c5b4a".to_string();
464        msg.original_client_order_id = original_client_order_id.map(str::to_string);
465
466        let report = parse_spot_exec_report_to_order_status(
467            &msg,
468            instrument_id(),
469            PRICE_PRECISION,
470            SIZE_PRECISION,
471            AccountId::from("BINANCE-001"),
472            false,
473            UnixNanos::from(1_000_000_000u64),
474        )
475        .unwrap();
476
477        assert_eq!(report.client_order_id, Some(ClientOrderId::from(expected)));
478    }
479
480    #[rstest]
481    fn test_parse_execution_report_limit_maker_sets_post_only() {
482        let json = r#"{
483            "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
484            "c":"x-TD67BGP9-T0000000000000","S":"SELL","o":"LIMIT_MAKER",
485            "f":"GTC","q":"0.5","p":"2600.00","P":"0",
486            "x":"NEW","X":"NEW","r":"NONE","i":12345679,
487            "l":"0","z":"0","L":"0","n":"0","N":null,
488            "T":1709654400000,"t":-1,"w":true,"m":false,
489            "O":1709654400000,"Z":"0","C":""
490        }"#;
491        let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
492        let account_id = AccountId::from("BINANCE-001");
493        let ts_init = UnixNanos::from(1_000_000_000u64);
494
495        let report = parse_spot_exec_report_to_order_status(
496            &msg,
497            instrument_id(),
498            PRICE_PRECISION,
499            SIZE_PRECISION,
500            account_id,
501            false,
502            ts_init,
503        )
504        .unwrap();
505
506        assert_eq!(report.order_type, OrderType::Limit);
507        assert!(report.post_only, "LIMIT_MAKER must set post_only");
508    }
509
510    #[rstest]
511    fn test_parse_execution_report_partial_fill_computes_avg_px() {
512        let json = r#"{
513            "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
514            "c":"x-TD67BGP9-T0000000000000","S":"BUY","o":"LIMIT",
515            "f":"GTC","q":"2.0","p":"2500.00","P":"0",
516            "x":"TRADE","X":"PARTIALLY_FILLED","r":"NONE","i":12345678,
517            "l":"0.5","z":"0.5","L":"2499.00","n":"0.00100000","N":"ETH",
518            "T":1709654400000,"t":98765432,"w":true,"m":false,
519            "O":1709654400000,"Z":"1249.50","C":""
520        }"#;
521        let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
522        let account_id = AccountId::from("BINANCE-001");
523        let ts_init = UnixNanos::from(1_000_000_000u64);
524
525        let report = parse_spot_exec_report_to_order_status(
526            &msg,
527            instrument_id(),
528            PRICE_PRECISION,
529            SIZE_PRECISION,
530            account_id,
531            false,
532            ts_init,
533        )
534        .unwrap();
535
536        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
537        assert_eq!(report.quantity, Quantity::new(2.0, SIZE_PRECISION));
538        assert_eq!(report.filled_qty, Quantity::new(0.5, SIZE_PRECISION));
539
540        // avg_px = cum_quote / filled_qty = 1249.50 / 0.5 = 2499.00
541        assert_eq!(report.avg_px.unwrap().to_string(), "2499.00");
542    }
543
544    #[rstest]
545    fn test_parse_execution_report_rejects_overflowing_avg_px() {
546        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
547        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
548        msg.cumulative_quote_qty = Decimal::MAX.to_string();
549        msg.cumulative_filled_qty = "0.00000001".to_string();
550        let account_id = AccountId::from("BINANCE-001");
551        let ts_init = UnixNanos::from(1_000_000_000u64);
552
553        let result = parse_spot_exec_report_to_order_status(
554            &msg,
555            instrument_id(),
556            PRICE_PRECISION,
557            SIZE_PRECISION,
558            account_id,
559            false,
560            ts_init,
561        );
562
563        let error = result.unwrap_err().to_string();
564        assert!(error.contains("cumulative_quote_qty"));
565        assert!(error.contains("division overflow"));
566    }
567
568    #[rstest]
569    fn test_parse_execution_report_stop_loss_has_trigger_price() {
570        let json = load_fixture_string("spot/user_data_json/execution_report_stop_loss.json");
571        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
572        let account_id = AccountId::from("BINANCE-001");
573        let ts_init = UnixNanos::from(1_000_000_000u64);
574
575        let report = parse_spot_exec_report_to_order_status(
576            &msg,
577            instrument_id(),
578            PRICE_PRECISION,
579            SIZE_PRECISION,
580            account_id,
581            false,
582            ts_init,
583        )
584        .unwrap();
585
586        assert_eq!(report.order_type, OrderType::StopLimit);
587        assert_eq!(report.order_side, OrderSide::Sell.into());
588        assert_eq!(
589            report.client_order_id,
590            Some(ClientOrderId::from("O-20200101-000000-000-000-1")),
591        );
592        assert_eq!(
593            report.trigger_price,
594            Some(Price::new(2450.0, PRICE_PRECISION))
595        );
596        assert_eq!(report.price, Some(Price::new(2400.0, PRICE_PRECISION)));
597    }
598
599    #[rstest]
600    fn test_parse_execution_report_to_fill_report() {
601        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
602        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
603        let account_id = AccountId::from("BINANCE-001");
604        let ts_init = UnixNanos::from(1_000_000_000u64);
605
606        let report = parse_spot_exec_report_to_fill(
607            &msg,
608            instrument_id(),
609            PRICE_PRECISION,
610            SIZE_PRECISION,
611            account_id,
612            ts_init,
613        )
614        .unwrap();
615
616        assert_eq!(report.account_id, account_id);
617        assert_eq!(report.instrument_id, instrument_id());
618        assert_eq!(report.order_side, OrderSide::Buy);
619        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
620        assert_eq!(report.trade_id, TradeId::new("98765432"));
621        assert_eq!(
622            report.client_order_id,
623            Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
624        );
625    }
626
627    #[rstest]
628    fn test_parse_execution_report_to_fill_rejects_invalid_commission() {
629        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
630        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
631        msg.commission = "not-a-number".to_string();
632        let account_id = AccountId::from("BINANCE-001");
633        let ts_init = UnixNanos::from(1_000_000_000u64);
634
635        let result = parse_spot_exec_report_to_fill(
636            &msg,
637            instrument_id(),
638            PRICE_PRECISION,
639            SIZE_PRECISION,
640            account_id,
641            ts_init,
642        );
643
644        let error = result.unwrap_err().to_string();
645        assert!(error.contains("commission"));
646    }
647
648    #[rstest]
649    fn test_parse_execution_report_to_fill_rejects_invalid_client_order_id() {
650        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
651        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
652        msg.client_order_id = "x-TD67BGP9-Tinvalid".to_string();
653
654        let result = parse_spot_exec_report_to_fill(
655            &msg,
656            instrument_id(),
657            PRICE_PRECISION,
658            SIZE_PRECISION,
659            AccountId::from("BINANCE-001"),
660            UnixNanos::from(1_000_000_000u64),
661        );
662
663        assert_eq!(
664            result.unwrap_err().to_string(),
665            "invalid O-format broker client order ID payload length"
666        );
667    }
668
669    #[rstest]
670    fn test_parse_account_position() {
671        let json = load_fixture_string("spot/user_data_json/account_position.json");
672        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(&json).unwrap();
673        let account_id = AccountId::from("BINANCE-001");
674        let ts_init = UnixNanos::from(1_000_000_000u64);
675
676        let state = parse_spot_account_position(&msg, account_id, ts_init).unwrap();
677
678        assert_eq!(state.account_id, account_id);
679        assert_eq!(state.account_type, AccountType::Cash);
680        assert!(state.is_reported);
681        assert_eq!(state.balances.len(), 2);
682        assert_eq!(state.balances[0].total.as_decimal(), Decimal::from(12_500));
683        assert_eq!(state.balances[0].locked.as_decimal(), Decimal::from(2_500));
684        assert_eq!(state.balances[1].total.as_decimal(), Decimal::from(5));
685        assert_eq!(state.balances[1].locked.as_decimal(), Decimal::ZERO);
686    }
687
688    #[rstest]
689    fn test_parse_account_position_rejects_unrepresentable_sibling() {
690        let json = r#"{
691            "e": "outboundAccountPosition",
692            "E": 1700000000000,
693            "u": 1700000000000,
694            "B": [
695                {"a": "BTC", "f": "1.00000000", "l": "0.00000000"},
696                {"a": "ETH", "f": "9223372036854775807", "l": "0"}
697            ]
698        }"#;
699        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(json).unwrap();
700
701        let err = parse_spot_account_position(
702            &msg,
703            AccountId::from("BINANCE-001"),
704            UnixNanos::from(1_000_000_000u64),
705        )
706        .unwrap_err();
707
708        assert!(err.to_string().contains("ETH"));
709    }
710
711    #[rstest]
712    fn test_parse_account_position_rejects_addition_overflow() {
713        let json = r#"{
714            "e": "outboundAccountPosition",
715            "E": 1700000000000,
716            "u": 1700000000000,
717            "B": [{
718                "a": "BTC",
719                "f": "79228162514264337593543950335",
720                "l": "1"
721            }]
722        }"#;
723        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(json).unwrap();
724
725        let err = parse_spot_account_position(
726            &msg,
727            AccountId::from("BINANCE-001"),
728            UnixNanos::from(1_000_000_000u64),
729        )
730        .unwrap_err();
731
732        assert!(err.to_string().contains("BTC"));
733        assert!(err.to_string().contains("overflow"));
734    }
735
736    // Regression for the #3867 bug class: WS `free` and `locked` with more decimal places
737    // than the asset's currency precision used to trip the invariant when Money::new rounded
738    // each side independently.
739    #[rstest]
740    fn test_parse_account_position_precision_drift() {
741        let json = r#"{
742            "e": "outboundAccountPosition",
743            "E": 1700000000000,
744            "u": 1700000000000,
745            "B": [{
746                "a": "ETH",
747                "f": "9.999999994999",
748                "l": "0.000000040000"
749            }]
750        }"#;
751        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(json).unwrap();
752        let account_id = AccountId::from("BINANCE-001");
753        let ts_init = UnixNanos::from(1_000_000_000u64);
754
755        let state = parse_spot_account_position(&msg, account_id, ts_init).unwrap();
756
757        assert_eq!(state.balances.len(), 1);
758        let balance = &state.balances[0];
759        assert_eq!(balance.total, balance.locked + balance.free);
760    }
761}