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, TimeInForce},
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, BinanceTimeInForce},
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.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 = parse_time_in_force(msg.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.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(status: BinanceOrderStatus, treat_expired_as_canceled: bool) -> OrderStatus {
230    match status {
231        BinanceOrderStatus::New | BinanceOrderStatus::PendingNew => OrderStatus::Accepted,
232        BinanceOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
233        BinanceOrderStatus::Filled
234        | BinanceOrderStatus::NewAdl
235        | BinanceOrderStatus::NewInsurance => OrderStatus::Filled,
236        BinanceOrderStatus::Canceled | BinanceOrderStatus::PendingCancel => OrderStatus::Canceled,
237        BinanceOrderStatus::Rejected => OrderStatus::Rejected,
238        BinanceOrderStatus::Expired | BinanceOrderStatus::ExpiredInMatch => {
239            if treat_expired_as_canceled {
240                OrderStatus::Canceled
241            } else {
242                OrderStatus::Expired
243            }
244        }
245        BinanceOrderStatus::Unknown => OrderStatus::Accepted,
246    }
247}
248
249fn parse_spot_order_type(order_type: &str) -> OrderType {
250    match order_type {
251        "LIMIT" | "LIMIT_MAKER" => OrderType::Limit,
252        "MARKET" => OrderType::Market,
253        "STOP_LOSS" => OrderType::StopMarket,
254        "STOP_LOSS_LIMIT" => OrderType::StopLimit,
255        "TAKE_PROFIT" => OrderType::MarketIfTouched,
256        "TAKE_PROFIT_LIMIT" => OrderType::LimitIfTouched,
257        _ => OrderType::Market,
258    }
259}
260
261fn parse_time_in_force(tif: BinanceTimeInForce) -> TimeInForce {
262    match tif {
263        BinanceTimeInForce::Gtc | BinanceTimeInForce::Gtx => TimeInForce::Gtc,
264        BinanceTimeInForce::Ioc | BinanceTimeInForce::Rpi => TimeInForce::Ioc,
265        BinanceTimeInForce::Fok => TimeInForce::Fok,
266        BinanceTimeInForce::Gtd => TimeInForce::Gtd,
267        BinanceTimeInForce::Unknown => TimeInForce::Gtc,
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use nautilus_model::{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::as_expired(false, OrderStatus::Expired)]
291    #[case::as_canceled(true, OrderStatus::Canceled)]
292    fn test_parse_order_status_expired_respects_treat_as_canceled(
293        #[case] treat_expired_as_canceled: bool,
294        #[case] expected: OrderStatus,
295    ) {
296        assert_eq!(
297            parse_order_status(BinanceOrderStatus::Expired, treat_expired_as_canceled),
298            expected,
299        );
300        assert_eq!(
301            parse_order_status(
302                BinanceOrderStatus::ExpiredInMatch,
303                treat_expired_as_canceled,
304            ),
305            expected,
306        );
307    }
308
309    #[rstest]
310    fn test_parse_execution_report_to_order_status_report() {
311        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
312        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
313        let account_id = AccountId::from("BINANCE-001");
314        let ts_init = UnixNanos::from(1_000_000_000u64);
315
316        let report = parse_spot_exec_report_to_order_status(
317            &msg,
318            instrument_id(),
319            PRICE_PRECISION,
320            SIZE_PRECISION,
321            account_id,
322            false,
323            ts_init,
324        )
325        .unwrap();
326
327        assert_eq!(report.account_id, account_id);
328        assert_eq!(report.instrument_id, instrument_id());
329        assert_eq!(report.order_side, OrderSide::Buy.into());
330        assert_eq!(report.order_status, OrderStatus::Accepted);
331        assert_eq!(report.order_type, OrderType::Limit);
332        assert_eq!(report.time_in_force, TimeInForce::Gtc);
333        assert_eq!(report.venue_order_id, VenueOrderId::new("12345678"));
334        assert_eq!(
335            report.client_order_id,
336            Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
337        );
338        assert_eq!(report.quantity, Quantity::new(1.0, SIZE_PRECISION));
339        assert_eq!(report.filled_qty, Quantity::new(0.0, SIZE_PRECISION));
340        assert_eq!(report.price, Some(Price::new(2500.0, PRICE_PRECISION)));
341        assert!(report.avg_px.is_none());
342        assert!(!report.post_only);
343        assert!(report.trigger_price.is_none());
344        assert_eq!(
345            report.ts_accepted,
346            UnixNanos::from(1_709_654_400_000_000_000u64)
347        );
348        assert_eq!(
349            report.ts_last,
350            UnixNanos::from(1_709_654_400_000_000_000u64)
351        );
352        assert_eq!(report.ts_init, ts_init);
353    }
354
355    #[rstest]
356    fn test_parse_execution_report_to_order_status_rejects_invalid_quantity() {
357        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
358        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
359        msg.original_qty = "not-a-number".to_string();
360        let account_id = AccountId::from("BINANCE-001");
361        let ts_init = UnixNanos::from(1_000_000_000u64);
362
363        let result = parse_spot_exec_report_to_order_status(
364            &msg,
365            instrument_id(),
366            PRICE_PRECISION,
367            SIZE_PRECISION,
368            account_id,
369            false,
370            ts_init,
371        );
372
373        let error = result.unwrap_err().to_string();
374        assert!(error.contains("original_qty"));
375    }
376
377    #[rstest]
378    #[case::negative(-1)]
379    #[case::overflow(i64::MAX)]
380    fn test_parse_execution_report_falls_back_for_invalid_timestamp(#[case] event_time: i64) {
381        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
382        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
383        msg.event_time = event_time;
384
385        let ts_init = UnixNanos::from(1);
386        let report = parse_spot_exec_report_to_order_status(
387            &msg,
388            instrument_id(),
389            PRICE_PRECISION,
390            SIZE_PRECISION,
391            AccountId::from("BINANCE-001"),
392            false,
393            ts_init,
394        )
395        .unwrap();
396
397        assert_eq!(report.ts_accepted, ts_init);
398        assert_eq!(report.ts_last, ts_init);
399        assert_eq!(report.ts_init, ts_init);
400    }
401
402    #[rstest]
403    #[case::empty("", "invalid Binance client order ID ''")]
404    #[case::whitespace("   ", "invalid Binance client order ID '   '")]
405    #[case::non_ascii("client-é", "invalid Binance client order ID 'client-é'")]
406    #[case::malformed_prefixed("x-TD67BGP9-R", "missing raw broker client order ID payload")]
407    fn test_parse_execution_report_to_order_status_rejects_invalid_client_order_id(
408        #[case] client_order_id: &str,
409        #[case] expected: &str,
410    ) {
411        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
412        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
413        msg.client_order_id = client_order_id.to_string();
414
415        let result = parse_spot_exec_report_to_order_status(
416            &msg,
417            instrument_id(),
418            PRICE_PRECISION,
419            SIZE_PRECISION,
420            AccountId::from("BINANCE-001"),
421            false,
422            UnixNanos::from(1_000_000_000u64),
423        );
424
425        assert_eq!(result.unwrap_err().to_string(), expected);
426    }
427
428    #[rstest]
429    fn test_parse_execution_report_limit_maker_sets_post_only() {
430        let json = r#"{
431            "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
432            "c":"x-TD67BGP9-T0000000000000","S":"SELL","o":"LIMIT_MAKER",
433            "f":"GTC","q":"0.5","p":"2600.00","P":"0",
434            "x":"NEW","X":"NEW","r":"NONE","i":12345679,
435            "l":"0","z":"0","L":"0","n":"0","N":null,
436            "T":1709654400000,"t":-1,"w":true,"m":false,
437            "O":1709654400000,"Z":"0","C":""
438        }"#;
439        let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
440        let account_id = AccountId::from("BINANCE-001");
441        let ts_init = UnixNanos::from(1_000_000_000u64);
442
443        let report = parse_spot_exec_report_to_order_status(
444            &msg,
445            instrument_id(),
446            PRICE_PRECISION,
447            SIZE_PRECISION,
448            account_id,
449            false,
450            ts_init,
451        )
452        .unwrap();
453
454        assert_eq!(report.order_type, OrderType::Limit);
455        assert!(report.post_only, "LIMIT_MAKER must set post_only");
456    }
457
458    #[rstest]
459    fn test_parse_execution_report_partial_fill_computes_avg_px() {
460        let json = r#"{
461            "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
462            "c":"x-TD67BGP9-T0000000000000","S":"BUY","o":"LIMIT",
463            "f":"GTC","q":"2.0","p":"2500.00","P":"0",
464            "x":"TRADE","X":"PARTIALLY_FILLED","r":"NONE","i":12345678,
465            "l":"0.5","z":"0.5","L":"2499.00","n":"0.00100000","N":"ETH",
466            "T":1709654400000,"t":98765432,"w":true,"m":false,
467            "O":1709654400000,"Z":"1249.50","C":""
468        }"#;
469        let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
470        let account_id = AccountId::from("BINANCE-001");
471        let ts_init = UnixNanos::from(1_000_000_000u64);
472
473        let report = parse_spot_exec_report_to_order_status(
474            &msg,
475            instrument_id(),
476            PRICE_PRECISION,
477            SIZE_PRECISION,
478            account_id,
479            false,
480            ts_init,
481        )
482        .unwrap();
483
484        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
485        assert_eq!(report.quantity, Quantity::new(2.0, SIZE_PRECISION));
486        assert_eq!(report.filled_qty, Quantity::new(0.5, SIZE_PRECISION));
487
488        // avg_px = cum_quote / filled_qty = 1249.50 / 0.5 = 2499.00
489        assert_eq!(report.avg_px.unwrap().to_string(), "2499.00");
490    }
491
492    #[rstest]
493    fn test_parse_execution_report_rejects_overflowing_avg_px() {
494        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
495        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
496        msg.cumulative_quote_qty = Decimal::MAX.to_string();
497        msg.cumulative_filled_qty = "0.00000001".to_string();
498        let account_id = AccountId::from("BINANCE-001");
499        let ts_init = UnixNanos::from(1_000_000_000u64);
500
501        let result = parse_spot_exec_report_to_order_status(
502            &msg,
503            instrument_id(),
504            PRICE_PRECISION,
505            SIZE_PRECISION,
506            account_id,
507            false,
508            ts_init,
509        );
510
511        let error = result.unwrap_err().to_string();
512        assert!(error.contains("cumulative_quote_qty"));
513        assert!(error.contains("division overflow"));
514    }
515
516    #[rstest]
517    fn test_parse_execution_report_stop_loss_has_trigger_price() {
518        let json = load_fixture_string("spot/user_data_json/execution_report_stop_loss.json");
519        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
520        let account_id = AccountId::from("BINANCE-001");
521        let ts_init = UnixNanos::from(1_000_000_000u64);
522
523        let report = parse_spot_exec_report_to_order_status(
524            &msg,
525            instrument_id(),
526            PRICE_PRECISION,
527            SIZE_PRECISION,
528            account_id,
529            false,
530            ts_init,
531        )
532        .unwrap();
533
534        assert_eq!(report.order_type, OrderType::StopLimit);
535        assert_eq!(report.order_side, OrderSide::Sell.into());
536        assert_eq!(
537            report.client_order_id,
538            Some(ClientOrderId::from("O-20200101-000000-000-000-1")),
539        );
540        assert_eq!(
541            report.trigger_price,
542            Some(Price::new(2450.0, PRICE_PRECISION))
543        );
544        assert_eq!(report.price, Some(Price::new(2400.0, PRICE_PRECISION)));
545    }
546
547    #[rstest]
548    fn test_parse_execution_report_to_fill_report() {
549        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
550        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
551        let account_id = AccountId::from("BINANCE-001");
552        let ts_init = UnixNanos::from(1_000_000_000u64);
553
554        let report = parse_spot_exec_report_to_fill(
555            &msg,
556            instrument_id(),
557            PRICE_PRECISION,
558            SIZE_PRECISION,
559            account_id,
560            ts_init,
561        )
562        .unwrap();
563
564        assert_eq!(report.account_id, account_id);
565        assert_eq!(report.instrument_id, instrument_id());
566        assert_eq!(report.order_side, OrderSide::Buy);
567        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
568        assert_eq!(report.trade_id, TradeId::new("98765432"));
569        assert_eq!(
570            report.client_order_id,
571            Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
572        );
573    }
574
575    #[rstest]
576    fn test_parse_execution_report_to_fill_rejects_invalid_commission() {
577        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
578        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
579        msg.commission = "not-a-number".to_string();
580        let account_id = AccountId::from("BINANCE-001");
581        let ts_init = UnixNanos::from(1_000_000_000u64);
582
583        let result = parse_spot_exec_report_to_fill(
584            &msg,
585            instrument_id(),
586            PRICE_PRECISION,
587            SIZE_PRECISION,
588            account_id,
589            ts_init,
590        );
591
592        let error = result.unwrap_err().to_string();
593        assert!(error.contains("commission"));
594    }
595
596    #[rstest]
597    fn test_parse_execution_report_to_fill_rejects_invalid_client_order_id() {
598        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
599        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
600        msg.client_order_id = "x-TD67BGP9-Tinvalid".to_string();
601
602        let result = parse_spot_exec_report_to_fill(
603            &msg,
604            instrument_id(),
605            PRICE_PRECISION,
606            SIZE_PRECISION,
607            AccountId::from("BINANCE-001"),
608            UnixNanos::from(1_000_000_000u64),
609        );
610
611        assert_eq!(
612            result.unwrap_err().to_string(),
613            "invalid O-format broker client order ID payload length"
614        );
615    }
616
617    #[rstest]
618    fn test_parse_account_position() {
619        let json = load_fixture_string("spot/user_data_json/account_position.json");
620        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(&json).unwrap();
621        let account_id = AccountId::from("BINANCE-001");
622        let ts_init = UnixNanos::from(1_000_000_000u64);
623
624        let state = parse_spot_account_position(&msg, account_id, ts_init);
625
626        assert_eq!(state.account_id, account_id);
627        assert_eq!(state.account_type, AccountType::Cash);
628        assert!(state.is_reported);
629        assert_eq!(state.balances.len(), 2);
630    }
631
632    // Regression for the #3867 bug class: WS `free` and `locked` with more decimal places
633    // than the asset's currency precision used to trip the invariant when Money::new rounded
634    // each side independently.
635    #[rstest]
636    fn test_parse_account_position_precision_drift() {
637        let json = r#"{
638            "e": "outboundAccountPosition",
639            "E": 1700000000000,
640            "u": 1700000000000,
641            "B": [{
642                "a": "ETH",
643                "f": "9.999999994999",
644                "l": "0.000000040000"
645            }]
646        }"#;
647        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(json).unwrap();
648        let account_id = AccountId::from("BINANCE-001");
649        let ts_init = UnixNanos::from(1_000_000_000u64);
650
651        let state = parse_spot_account_position(&msg, account_id, ts_init);
652
653        assert_eq!(state.balances.len(), 1);
654        let balance = &state.balances[0];
655        assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
656    }
657}