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::{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
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(
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![], true, UUID4::new(),
230 ts_event,
231 ts_init,
232 None, ))
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 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 #[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}