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::{AccountBalance, 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        parse_millis_or_init, parse_required_decimal, parse_required_price_at_precision,
38        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.
198pub fn parse_spot_account_position(
199    msg: &BinanceSpotAccountPositionMsg,
200    account_id: AccountId,
201    ts_init: UnixNanos,
202) -> AccountState {
203    let ts_event =
204        parse_millis_or_init(msg.event_time, "Spot account position event time", ts_init);
205
206    let balances: Vec<AccountBalance> = msg
207        .balances
208        .iter()
209        .filter_map(|b| {
210            let total = b.free + b.locked;
211            let currency = Currency::get_or_create_crypto(b.asset.as_str());
212            AccountBalance::from_total_and_locked(total, b.locked, currency).ok()
213        })
214        .collect();
215
216    AccountState::new(
217        account_id,
218        AccountType::Cash,
219        balances,
220        vec![], // No margins for spot
221        true,   // is_reported
222        UUID4::new(),
223        ts_event,
224        ts_init,
225        None, // base_currency
226    )
227}
228
229fn parse_order_status(
230    status: BinanceOrderStatus,
231    treat_expired_as_canceled: bool,
232) -> anyhow::Result<OrderStatus> {
233    Ok(match status {
234        BinanceOrderStatus::New | BinanceOrderStatus::PendingNew => OrderStatus::Accepted,
235        BinanceOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
236        BinanceOrderStatus::Filled
237        | BinanceOrderStatus::NewAdl
238        | BinanceOrderStatus::NewInsurance => OrderStatus::Filled,
239        BinanceOrderStatus::Canceled | BinanceOrderStatus::PendingCancel => OrderStatus::Canceled,
240        BinanceOrderStatus::Rejected => OrderStatus::Rejected,
241        BinanceOrderStatus::Expired | BinanceOrderStatus::ExpiredInMatch => {
242            if treat_expired_as_canceled {
243                OrderStatus::Canceled
244            } else {
245                OrderStatus::Expired
246            }
247        }
248        BinanceOrderStatus::Unknown => anyhow::bail!("unknown Binance Spot order status"),
249    })
250}
251
252fn parse_spot_order_type(order_type: &str) -> anyhow::Result<OrderType> {
253    Ok(match order_type {
254        "LIMIT" | "LIMIT_MAKER" => OrderType::Limit,
255        "MARKET" => OrderType::Market,
256        "STOP_LOSS" => OrderType::StopMarket,
257        "STOP_LOSS_LIMIT" => OrderType::StopLimit,
258        "TAKE_PROFIT" => OrderType::MarketIfTouched,
259        "TAKE_PROFIT_LIMIT" => OrderType::LimitIfTouched,
260        _ => anyhow::bail!("unknown Binance Spot order type: {order_type}"),
261    })
262}
263
264#[cfg(test)]
265mod tests {
266    use nautilus_model::{enums::TimeInForce, identifiers::ClientOrderId, types::Quantity};
267    use rstest::rstest;
268
269    use super::*;
270    use crate::{
271        common::testing::load_fixture_string,
272        spot::websocket::trading::user_data::BinanceSpotExecutionReport,
273    };
274
275    const PRICE_PRECISION: u8 = 2;
276    const SIZE_PRECISION: u8 = 5;
277
278    fn instrument_id() -> InstrumentId {
279        InstrumentId::from("ETHUSDT.BINANCE")
280    }
281
282    #[rstest]
283    #[case::status("X", "unknown Binance Spot order status")]
284    #[case::order_type("o", "unknown Binance Spot order type: UNRECOGNIZED")]
285    #[case::tif("f", "unknown Binance time in force")]
286    fn test_order_report_rejects_unknown_values(#[case] field: &str, #[case] expected: &str) {
287        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
288        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
289        value[field] = serde_json::Value::String("UNRECOGNIZED".to_string());
290        let msg: BinanceSpotExecutionReport = serde_json::from_value(value).unwrap();
291
292        let error = parse_spot_exec_report_to_order_status(
293            &msg,
294            InstrumentId::from("ETHUSDT.BINANCE"),
295            2,
296            5,
297            AccountId::from("BINANCE-001"),
298            false,
299            UnixNanos::default(),
300        )
301        .unwrap_err();
302
303        assert_eq!(error.to_string(), expected);
304    }
305
306    #[rstest]
307    #[case::as_expired(false, OrderStatus::Expired)]
308    #[case::as_canceled(true, OrderStatus::Canceled)]
309    fn test_parse_order_status_expired_respects_treat_as_canceled(
310        #[case] treat_expired_as_canceled: bool,
311        #[case] expected: OrderStatus,
312    ) {
313        assert_eq!(
314            parse_order_status(BinanceOrderStatus::Expired, treat_expired_as_canceled).unwrap(),
315            expected,
316        );
317        assert_eq!(
318            parse_order_status(
319                BinanceOrderStatus::ExpiredInMatch,
320                treat_expired_as_canceled,
321            )
322            .unwrap(),
323            expected,
324        );
325    }
326
327    #[rstest]
328    fn test_parse_execution_report_to_order_status_report() {
329        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
330        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
331        let account_id = AccountId::from("BINANCE-001");
332        let ts_init = UnixNanos::from(1_000_000_000u64);
333
334        let report = parse_spot_exec_report_to_order_status(
335            &msg,
336            instrument_id(),
337            PRICE_PRECISION,
338            SIZE_PRECISION,
339            account_id,
340            false,
341            ts_init,
342        )
343        .unwrap();
344
345        assert_eq!(report.account_id, account_id);
346        assert_eq!(report.instrument_id, instrument_id());
347        assert_eq!(report.order_side, OrderSide::Buy.into());
348        assert_eq!(report.order_status, OrderStatus::Accepted);
349        assert_eq!(report.order_type, OrderType::Limit);
350        assert_eq!(report.time_in_force, TimeInForce::Gtc);
351        assert_eq!(report.venue_order_id, VenueOrderId::new("12345678"));
352        assert_eq!(
353            report.client_order_id,
354            Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
355        );
356        assert_eq!(report.quantity, Quantity::new(1.0, SIZE_PRECISION));
357        assert_eq!(report.filled_qty, Quantity::new(0.0, SIZE_PRECISION));
358        assert_eq!(report.price, Some(Price::new(2500.0, PRICE_PRECISION)));
359        assert!(report.avg_px.is_none());
360        assert!(!report.post_only);
361        assert!(report.trigger_price.is_none());
362        assert_eq!(
363            report.ts_accepted,
364            UnixNanos::from(1_709_654_400_000_000_000u64)
365        );
366        assert_eq!(
367            report.ts_last,
368            UnixNanos::from(1_709_654_400_000_000_000u64)
369        );
370        assert_eq!(report.ts_init, ts_init);
371    }
372
373    #[rstest]
374    fn test_parse_execution_report_to_order_status_rejects_invalid_quantity() {
375        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
376        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
377        msg.original_qty = "not-a-number".to_string();
378        let account_id = AccountId::from("BINANCE-001");
379        let ts_init = UnixNanos::from(1_000_000_000u64);
380
381        let result = parse_spot_exec_report_to_order_status(
382            &msg,
383            instrument_id(),
384            PRICE_PRECISION,
385            SIZE_PRECISION,
386            account_id,
387            false,
388            ts_init,
389        );
390
391        let error = result.unwrap_err().to_string();
392        assert!(error.contains("original_qty"));
393    }
394
395    #[rstest]
396    #[case::negative(-1)]
397    #[case::overflow(i64::MAX)]
398    fn test_parse_execution_report_falls_back_for_invalid_timestamp(#[case] event_time: i64) {
399        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
400        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
401        msg.event_time = event_time;
402
403        let ts_init = UnixNanos::from(1);
404        let report = parse_spot_exec_report_to_order_status(
405            &msg,
406            instrument_id(),
407            PRICE_PRECISION,
408            SIZE_PRECISION,
409            AccountId::from("BINANCE-001"),
410            false,
411            ts_init,
412        )
413        .unwrap();
414
415        assert_eq!(report.ts_accepted, ts_init);
416        assert_eq!(report.ts_last, ts_init);
417        assert_eq!(report.ts_init, ts_init);
418    }
419
420    #[rstest]
421    #[case::empty("", "invalid Binance client order ID ''")]
422    #[case::whitespace("   ", "invalid Binance client order ID '   '")]
423    #[case::non_ascii("client-é", "invalid Binance client order ID 'client-é'")]
424    #[case::malformed_prefixed("x-TD67BGP9-R", "missing raw broker client order ID payload")]
425    fn test_parse_execution_report_to_order_status_rejects_invalid_client_order_id(
426        #[case] client_order_id: &str,
427        #[case] expected: &str,
428    ) {
429        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
430        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
431        msg.client_order_id = client_order_id.to_string();
432
433        let result = parse_spot_exec_report_to_order_status(
434            &msg,
435            instrument_id(),
436            PRICE_PRECISION,
437            SIZE_PRECISION,
438            AccountId::from("BINANCE-001"),
439            false,
440            UnixNanos::from(1_000_000_000u64),
441        );
442
443        assert_eq!(result.unwrap_err().to_string(), expected);
444    }
445
446    #[rstest]
447    #[case::orig_set(Some("x-TD67BGP9-T0000000000000"), "O-20200101-000000-000-000-0")]
448    #[case::orig_empty(Some(""), "web_9f8e7d6c5b4a")]
449    #[case::orig_missing(None, "web_9f8e7d6c5b4a")]
450    fn test_parse_execution_report_to_order_status_canceled_prefers_orig_client_order_id(
451        #[case] original_client_order_id: Option<&str>,
452        #[case] expected: &str,
453    ) {
454        let json = load_fixture_string("spot/user_data_json/execution_report_canceled.json");
455        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
456        msg.client_order_id = "web_9f8e7d6c5b4a".to_string();
457        msg.original_client_order_id = original_client_order_id.map(str::to_string);
458
459        let report = parse_spot_exec_report_to_order_status(
460            &msg,
461            instrument_id(),
462            PRICE_PRECISION,
463            SIZE_PRECISION,
464            AccountId::from("BINANCE-001"),
465            false,
466            UnixNanos::from(1_000_000_000u64),
467        )
468        .unwrap();
469
470        assert_eq!(report.client_order_id, Some(ClientOrderId::from(expected)));
471    }
472
473    #[rstest]
474    fn test_parse_execution_report_limit_maker_sets_post_only() {
475        let json = r#"{
476            "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
477            "c":"x-TD67BGP9-T0000000000000","S":"SELL","o":"LIMIT_MAKER",
478            "f":"GTC","q":"0.5","p":"2600.00","P":"0",
479            "x":"NEW","X":"NEW","r":"NONE","i":12345679,
480            "l":"0","z":"0","L":"0","n":"0","N":null,
481            "T":1709654400000,"t":-1,"w":true,"m":false,
482            "O":1709654400000,"Z":"0","C":""
483        }"#;
484        let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
485        let account_id = AccountId::from("BINANCE-001");
486        let ts_init = UnixNanos::from(1_000_000_000u64);
487
488        let report = parse_spot_exec_report_to_order_status(
489            &msg,
490            instrument_id(),
491            PRICE_PRECISION,
492            SIZE_PRECISION,
493            account_id,
494            false,
495            ts_init,
496        )
497        .unwrap();
498
499        assert_eq!(report.order_type, OrderType::Limit);
500        assert!(report.post_only, "LIMIT_MAKER must set post_only");
501    }
502
503    #[rstest]
504    fn test_parse_execution_report_partial_fill_computes_avg_px() {
505        let json = r#"{
506            "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
507            "c":"x-TD67BGP9-T0000000000000","S":"BUY","o":"LIMIT",
508            "f":"GTC","q":"2.0","p":"2500.00","P":"0",
509            "x":"TRADE","X":"PARTIALLY_FILLED","r":"NONE","i":12345678,
510            "l":"0.5","z":"0.5","L":"2499.00","n":"0.00100000","N":"ETH",
511            "T":1709654400000,"t":98765432,"w":true,"m":false,
512            "O":1709654400000,"Z":"1249.50","C":""
513        }"#;
514        let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
515        let account_id = AccountId::from("BINANCE-001");
516        let ts_init = UnixNanos::from(1_000_000_000u64);
517
518        let report = parse_spot_exec_report_to_order_status(
519            &msg,
520            instrument_id(),
521            PRICE_PRECISION,
522            SIZE_PRECISION,
523            account_id,
524            false,
525            ts_init,
526        )
527        .unwrap();
528
529        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
530        assert_eq!(report.quantity, Quantity::new(2.0, SIZE_PRECISION));
531        assert_eq!(report.filled_qty, Quantity::new(0.5, SIZE_PRECISION));
532
533        // avg_px = cum_quote / filled_qty = 1249.50 / 0.5 = 2499.00
534        assert_eq!(report.avg_px.unwrap().to_string(), "2499.00");
535    }
536
537    #[rstest]
538    fn test_parse_execution_report_rejects_overflowing_avg_px() {
539        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
540        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
541        msg.cumulative_quote_qty = Decimal::MAX.to_string();
542        msg.cumulative_filled_qty = "0.00000001".to_string();
543        let account_id = AccountId::from("BINANCE-001");
544        let ts_init = UnixNanos::from(1_000_000_000u64);
545
546        let result = parse_spot_exec_report_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_quote_qty"));
558        assert!(error.contains("division overflow"));
559    }
560
561    #[rstest]
562    fn test_parse_execution_report_stop_loss_has_trigger_price() {
563        let json = load_fixture_string("spot/user_data_json/execution_report_stop_loss.json");
564        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
565        let account_id = AccountId::from("BINANCE-001");
566        let ts_init = UnixNanos::from(1_000_000_000u64);
567
568        let report = parse_spot_exec_report_to_order_status(
569            &msg,
570            instrument_id(),
571            PRICE_PRECISION,
572            SIZE_PRECISION,
573            account_id,
574            false,
575            ts_init,
576        )
577        .unwrap();
578
579        assert_eq!(report.order_type, OrderType::StopLimit);
580        assert_eq!(report.order_side, OrderSide::Sell.into());
581        assert_eq!(
582            report.client_order_id,
583            Some(ClientOrderId::from("O-20200101-000000-000-000-1")),
584        );
585        assert_eq!(
586            report.trigger_price,
587            Some(Price::new(2450.0, PRICE_PRECISION))
588        );
589        assert_eq!(report.price, Some(Price::new(2400.0, PRICE_PRECISION)));
590    }
591
592    #[rstest]
593    fn test_parse_execution_report_to_fill_report() {
594        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
595        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
596        let account_id = AccountId::from("BINANCE-001");
597        let ts_init = UnixNanos::from(1_000_000_000u64);
598
599        let report = parse_spot_exec_report_to_fill(
600            &msg,
601            instrument_id(),
602            PRICE_PRECISION,
603            SIZE_PRECISION,
604            account_id,
605            ts_init,
606        )
607        .unwrap();
608
609        assert_eq!(report.account_id, account_id);
610        assert_eq!(report.instrument_id, instrument_id());
611        assert_eq!(report.order_side, OrderSide::Buy);
612        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
613        assert_eq!(report.trade_id, TradeId::new("98765432"));
614        assert_eq!(
615            report.client_order_id,
616            Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
617        );
618    }
619
620    #[rstest]
621    fn test_parse_execution_report_to_fill_rejects_invalid_commission() {
622        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
623        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
624        msg.commission = "not-a-number".to_string();
625        let account_id = AccountId::from("BINANCE-001");
626        let ts_init = UnixNanos::from(1_000_000_000u64);
627
628        let result = parse_spot_exec_report_to_fill(
629            &msg,
630            instrument_id(),
631            PRICE_PRECISION,
632            SIZE_PRECISION,
633            account_id,
634            ts_init,
635        );
636
637        let error = result.unwrap_err().to_string();
638        assert!(error.contains("commission"));
639    }
640
641    #[rstest]
642    fn test_parse_execution_report_to_fill_rejects_invalid_client_order_id() {
643        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
644        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
645        msg.client_order_id = "x-TD67BGP9-Tinvalid".to_string();
646
647        let result = parse_spot_exec_report_to_fill(
648            &msg,
649            instrument_id(),
650            PRICE_PRECISION,
651            SIZE_PRECISION,
652            AccountId::from("BINANCE-001"),
653            UnixNanos::from(1_000_000_000u64),
654        );
655
656        assert_eq!(
657            result.unwrap_err().to_string(),
658            "invalid O-format broker client order ID payload length"
659        );
660    }
661
662    #[rstest]
663    fn test_parse_account_position() {
664        let json = load_fixture_string("spot/user_data_json/account_position.json");
665        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(&json).unwrap();
666        let account_id = AccountId::from("BINANCE-001");
667        let ts_init = UnixNanos::from(1_000_000_000u64);
668
669        let state = parse_spot_account_position(&msg, account_id, ts_init);
670
671        assert_eq!(state.account_id, account_id);
672        assert_eq!(state.account_type, AccountType::Cash);
673        assert!(state.is_reported);
674        assert_eq!(state.balances.len(), 2);
675    }
676
677    // Regression for the #3867 bug class: WS `free` and `locked` with more decimal places
678    // than the asset's currency precision used to trip the invariant when Money::new rounded
679    // each side independently.
680    #[rstest]
681    fn test_parse_account_position_precision_drift() {
682        let json = r#"{
683            "e": "outboundAccountPosition",
684            "E": 1700000000000,
685            "u": 1700000000000,
686            "B": [{
687                "a": "ETH",
688                "f": "9.999999994999",
689                "l": "0.000000040000"
690            }]
691        }"#;
692        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(json).unwrap();
693        let account_id = AccountId::from("BINANCE-001");
694        let ts_init = UnixNanos::from(1_000_000_000u64);
695
696        let state = parse_spot_account_position(&msg, account_id, ts_init);
697
698        assert_eq!(state.balances.len(), 1);
699        let balance = &state.balances[0];
700        assert_eq!(balance.total, balance.locked + balance.free);
701    }
702}