1use 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
42pub 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, );
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
131pub 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, ts_event,
192 ts_init,
193 None, ))
195}
196
197pub 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![], true, UUID4::new(),
223 ts_event,
224 ts_init,
225 None, )
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 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 #[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}