1use 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
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.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, );
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.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(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 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 #[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}