1use nautilus_core::{UUID4, UnixNanos};
22use nautilus_model::{
23 enums::{AccountType, LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
24 events::AccountState,
25 identifiers::{AccountId, ClientOrderId, 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_broker_id,
35 enums::{BinanceOrderStatus, BinanceSide, BinanceTimeInForce},
36 parse::{
37 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 = ClientOrderId::new(decode_broker_id(
57 &msg.client_order_id,
58 BINANCE_NAUTILUS_SPOT_BROKER_ID,
59 ));
60 let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
61 let ts_event = UnixNanos::from_millis(msg.event_time as u64);
62
63 let order_side = match msg.side {
64 BinanceSide::Buy => OrderSide::Buy,
65 BinanceSide::Sell => OrderSide::Sell,
66 };
67
68 let order_status = parse_order_status(msg.order_status, treat_expired_as_canceled);
69 let order_type = parse_spot_order_type(&msg.order_type);
70 let time_in_force = parse_time_in_force(msg.time_in_force);
71
72 let quantity =
73 parse_required_quantity_at_precision(&msg.original_qty, size_precision, "original_qty")?;
74 let filled_qty = parse_required_quantity_at_precision(
75 &msg.cumulative_filled_qty,
76 size_precision,
77 "cumulative_filled_qty",
78 )?;
79 let price = parse_required_price_at_precision(&msg.price, price_precision, "price")?;
80
81 let filled_qty_decimal =
82 parse_required_decimal(&msg.cumulative_filled_qty, "cumulative_filled_qty")?;
83 let avg_px = if filled_qty_decimal > Decimal::ZERO {
84 let cum_quote = parse_required_decimal(&msg.cumulative_quote_qty, "cumulative_quote_qty")?;
85 let avg_px = cum_quote.checked_div(filled_qty_decimal).ok_or_else(|| {
86 anyhow::anyhow!(
87 "invalid cumulative_quote_qty='{}' for cumulative_filled_qty='{}': division overflow",
88 msg.cumulative_quote_qty,
89 msg.cumulative_filled_qty,
90 )
91 })?;
92 Some(Price::from_decimal_dp(avg_px, price_precision)?)
93 } else {
94 None
95 };
96
97 let mut report = OrderStatusReport::new(
98 account_id,
99 instrument_id,
100 Some(client_order_id),
101 venue_order_id,
102 order_side,
103 order_type,
104 time_in_force,
105 order_status,
106 quantity,
107 filled_qty,
108 ts_event,
109 ts_event,
110 ts_init,
111 None, );
113
114 report.price = Some(price);
115 report.post_only = msg.order_type == "LIMIT_MAKER";
116
117 let stop_price = parse_required_decimal(&msg.stop_price, "stop_price")?;
118 if stop_price > Decimal::ZERO {
119 report.trigger_price = Some(parse_required_price_at_precision(
120 &msg.stop_price,
121 price_precision,
122 "stop_price",
123 )?);
124 }
125
126 if let Some(avg) = avg_px {
127 report.avg_px = Some(avg.as_decimal());
128 }
129
130 Ok(report)
131}
132
133pub fn parse_spot_exec_report_to_fill(
139 msg: &BinanceSpotExecutionReport,
140 instrument_id: InstrumentId,
141 price_precision: u8,
142 size_precision: u8,
143 account_id: AccountId,
144 ts_init: UnixNanos,
145) -> anyhow::Result<FillReport> {
146 let client_order_id = ClientOrderId::new(decode_broker_id(
147 &msg.client_order_id,
148 BINANCE_NAUTILUS_SPOT_BROKER_ID,
149 ));
150 let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
151 let trade_id = TradeId::new(msg.trade_id.to_string());
152 let ts_event = UnixNanos::from_millis(msg.event_time as u64);
153
154 let order_side = match msg.side {
155 BinanceSide::Buy => OrderSide::Buy,
156 BinanceSide::Sell => OrderSide::Sell,
157 };
158
159 let liquidity_side = if msg.is_maker {
160 LiquiditySide::Maker
161 } else {
162 LiquiditySide::Taker
163 };
164
165 let last_qty = parse_required_quantity_at_precision(
166 &msg.last_filled_qty,
167 size_precision,
168 "last_filled_qty",
169 )?;
170 let last_px = parse_required_price_at_precision(
171 &msg.last_filled_price,
172 price_precision,
173 "last_filled_price",
174 )?;
175 let commission = parse_required_decimal(&msg.commission, "commission")?;
176 let commission_currency = msg
177 .commission_asset
178 .as_ref()
179 .map_or_else(Currency::USDT, |a| {
180 Currency::get_or_create_crypto(a.as_str())
181 });
182
183 Ok(FillReport::new(
184 account_id,
185 instrument_id,
186 venue_order_id,
187 trade_id,
188 order_side,
189 last_qty,
190 last_px,
191 Money::from_decimal(commission, commission_currency)?,
192 liquidity_side,
193 Some(client_order_id),
194 None, ts_event,
196 ts_init,
197 None, ))
199}
200
201pub fn parse_spot_account_position(
203 msg: &BinanceSpotAccountPositionMsg,
204 account_id: AccountId,
205 ts_init: UnixNanos,
206) -> AccountState {
207 let ts_event = UnixNanos::from_millis(msg.event_time as u64);
208
209 let balances: Vec<AccountBalance> = msg
210 .balances
211 .iter()
212 .filter_map(|b| {
213 let total = b.free + b.locked;
214 let currency = Currency::get_or_create_crypto(b.asset.as_str());
215 AccountBalance::from_total_and_locked(total, b.locked, currency).ok()
216 })
217 .collect();
218
219 AccountState::new(
220 account_id,
221 AccountType::Cash,
222 balances,
223 vec![], true, UUID4::new(),
226 ts_event,
227 ts_init,
228 None, )
230}
231
232fn parse_order_status(status: BinanceOrderStatus, treat_expired_as_canceled: bool) -> OrderStatus {
233 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 => OrderStatus::Accepted,
249 }
250}
251
252fn parse_spot_order_type(order_type: &str) -> OrderType {
253 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 _ => OrderType::Market,
261 }
262}
263
264fn parse_time_in_force(tif: BinanceTimeInForce) -> TimeInForce {
265 match tif {
266 BinanceTimeInForce::Gtc | BinanceTimeInForce::Gtx => TimeInForce::Gtc,
267 BinanceTimeInForce::Ioc | BinanceTimeInForce::Rpi => TimeInForce::Ioc,
268 BinanceTimeInForce::Fok => TimeInForce::Fok,
269 BinanceTimeInForce::Gtd => TimeInForce::Gtd,
270 BinanceTimeInForce::Unknown => TimeInForce::Gtc,
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use nautilus_model::types::Quantity;
277 use rstest::rstest;
278
279 use super::*;
280 use crate::{
281 common::testing::load_fixture_string,
282 spot::websocket::trading::user_data::BinanceSpotExecutionReport,
283 };
284
285 const PRICE_PRECISION: u8 = 2;
286 const SIZE_PRECISION: u8 = 5;
287
288 fn instrument_id() -> InstrumentId {
289 InstrumentId::from("ETHUSDT.BINANCE")
290 }
291
292 #[rstest]
293 #[case::as_expired(false, OrderStatus::Expired)]
294 #[case::as_canceled(true, OrderStatus::Canceled)]
295 fn test_parse_order_status_expired_respects_treat_as_canceled(
296 #[case] treat_expired_as_canceled: bool,
297 #[case] expected: OrderStatus,
298 ) {
299 assert_eq!(
300 parse_order_status(BinanceOrderStatus::Expired, treat_expired_as_canceled),
301 expected,
302 );
303 assert_eq!(
304 parse_order_status(
305 BinanceOrderStatus::ExpiredInMatch,
306 treat_expired_as_canceled,
307 ),
308 expected,
309 );
310 }
311
312 #[rstest]
313 fn test_parse_execution_report_to_order_status_report() {
314 let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
315 let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
316 let account_id = AccountId::from("BINANCE-001");
317 let ts_init = UnixNanos::from(1_000_000_000u64);
318
319 let report = parse_spot_exec_report_to_order_status(
320 &msg,
321 instrument_id(),
322 PRICE_PRECISION,
323 SIZE_PRECISION,
324 account_id,
325 false,
326 ts_init,
327 )
328 .unwrap();
329
330 assert_eq!(report.account_id, account_id);
331 assert_eq!(report.instrument_id, instrument_id());
332 assert_eq!(report.order_side, OrderSide::Buy);
333 assert_eq!(report.order_status, OrderStatus::Accepted);
334 assert_eq!(report.order_type, OrderType::Limit);
335 assert_eq!(report.time_in_force, TimeInForce::Gtc);
336 assert_eq!(report.venue_order_id, VenueOrderId::new("12345678"));
337 assert_eq!(
338 report.client_order_id,
339 Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
340 );
341 assert_eq!(report.quantity, Quantity::new(1.0, SIZE_PRECISION));
342 assert_eq!(report.filled_qty, Quantity::new(0.0, SIZE_PRECISION));
343 assert_eq!(report.price, Some(Price::new(2500.0, PRICE_PRECISION)));
344 assert!(report.avg_px.is_none());
345 assert!(!report.post_only);
346 assert!(report.trigger_price.is_none());
347 assert_eq!(
348 report.ts_accepted,
349 UnixNanos::from(1_709_654_400_000_000_000u64)
350 );
351 assert_eq!(
352 report.ts_last,
353 UnixNanos::from(1_709_654_400_000_000_000u64)
354 );
355 assert_eq!(report.ts_init, ts_init);
356 }
357
358 #[rstest]
359 fn test_parse_execution_report_to_order_status_rejects_invalid_quantity() {
360 let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
361 let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
362 msg.original_qty = "not-a-number".to_string();
363 let account_id = AccountId::from("BINANCE-001");
364 let ts_init = UnixNanos::from(1_000_000_000u64);
365
366 let result = parse_spot_exec_report_to_order_status(
367 &msg,
368 instrument_id(),
369 PRICE_PRECISION,
370 SIZE_PRECISION,
371 account_id,
372 false,
373 ts_init,
374 );
375
376 let error = result.unwrap_err().to_string();
377 assert!(error.contains("original_qty"));
378 }
379
380 #[rstest]
381 fn test_parse_execution_report_limit_maker_sets_post_only() {
382 let json = r#"{
383 "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
384 "c":"x-TD67BGP9-T0000000000000","S":"SELL","o":"LIMIT_MAKER",
385 "f":"GTC","q":"0.5","p":"2600.00","P":"0",
386 "x":"NEW","X":"NEW","r":"NONE","i":12345679,
387 "l":"0","z":"0","L":"0","n":"0","N":null,
388 "T":1709654400000,"t":-1,"w":true,"m":false,
389 "O":1709654400000,"Z":"0","C":""
390 }"#;
391 let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
392 let account_id = AccountId::from("BINANCE-001");
393 let ts_init = UnixNanos::from(1_000_000_000u64);
394
395 let report = parse_spot_exec_report_to_order_status(
396 &msg,
397 instrument_id(),
398 PRICE_PRECISION,
399 SIZE_PRECISION,
400 account_id,
401 false,
402 ts_init,
403 )
404 .unwrap();
405
406 assert_eq!(report.order_type, OrderType::Limit);
407 assert!(report.post_only, "LIMIT_MAKER must set post_only");
408 }
409
410 #[rstest]
411 fn test_parse_execution_report_partial_fill_computes_avg_px() {
412 let json = r#"{
413 "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
414 "c":"x-TD67BGP9-T0000000000000","S":"BUY","o":"LIMIT",
415 "f":"GTC","q":"2.0","p":"2500.00","P":"0",
416 "x":"TRADE","X":"PARTIALLY_FILLED","r":"NONE","i":12345678,
417 "l":"0.5","z":"0.5","L":"2499.00","n":"0.00100000","N":"ETH",
418 "T":1709654400000,"t":98765432,"w":true,"m":false,
419 "O":1709654400000,"Z":"1249.50","C":""
420 }"#;
421 let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
422 let account_id = AccountId::from("BINANCE-001");
423 let ts_init = UnixNanos::from(1_000_000_000u64);
424
425 let report = parse_spot_exec_report_to_order_status(
426 &msg,
427 instrument_id(),
428 PRICE_PRECISION,
429 SIZE_PRECISION,
430 account_id,
431 false,
432 ts_init,
433 )
434 .unwrap();
435
436 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
437 assert_eq!(report.quantity, Quantity::new(2.0, SIZE_PRECISION));
438 assert_eq!(report.filled_qty, Quantity::new(0.5, SIZE_PRECISION));
439
440 assert_eq!(report.avg_px.unwrap().to_string(), "2499.00");
442 }
443
444 #[rstest]
445 fn test_parse_execution_report_rejects_overflowing_avg_px() {
446 let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
447 let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
448 msg.cumulative_quote_qty = Decimal::MAX.to_string();
449 msg.cumulative_filled_qty = "0.00000001".to_string();
450 let account_id = AccountId::from("BINANCE-001");
451 let ts_init = UnixNanos::from(1_000_000_000u64);
452
453 let result = parse_spot_exec_report_to_order_status(
454 &msg,
455 instrument_id(),
456 PRICE_PRECISION,
457 SIZE_PRECISION,
458 account_id,
459 false,
460 ts_init,
461 );
462
463 let error = result.unwrap_err().to_string();
464 assert!(error.contains("cumulative_quote_qty"));
465 assert!(error.contains("division overflow"));
466 }
467
468 #[rstest]
469 fn test_parse_execution_report_stop_loss_has_trigger_price() {
470 let json = load_fixture_string("spot/user_data_json/execution_report_stop_loss.json");
471 let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
472 let account_id = AccountId::from("BINANCE-001");
473 let ts_init = UnixNanos::from(1_000_000_000u64);
474
475 let report = parse_spot_exec_report_to_order_status(
476 &msg,
477 instrument_id(),
478 PRICE_PRECISION,
479 SIZE_PRECISION,
480 account_id,
481 false,
482 ts_init,
483 )
484 .unwrap();
485
486 assert_eq!(report.order_type, OrderType::StopLimit);
487 assert_eq!(report.order_side, OrderSide::Sell);
488 assert_eq!(
489 report.client_order_id,
490 Some(ClientOrderId::from("O-20200101-000000-000-000-1")),
491 );
492 assert_eq!(
493 report.trigger_price,
494 Some(Price::new(2450.0, PRICE_PRECISION))
495 );
496 assert_eq!(report.price, Some(Price::new(2400.0, PRICE_PRECISION)));
497 }
498
499 #[rstest]
500 fn test_parse_execution_report_to_fill_report() {
501 let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
502 let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
503 let account_id = AccountId::from("BINANCE-001");
504 let ts_init = UnixNanos::from(1_000_000_000u64);
505
506 let report = parse_spot_exec_report_to_fill(
507 &msg,
508 instrument_id(),
509 PRICE_PRECISION,
510 SIZE_PRECISION,
511 account_id,
512 ts_init,
513 )
514 .unwrap();
515
516 assert_eq!(report.account_id, account_id);
517 assert_eq!(report.instrument_id, instrument_id());
518 assert_eq!(report.order_side, OrderSide::Buy);
519 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
520 assert_eq!(report.trade_id, TradeId::new("98765432"));
521 assert_eq!(
522 report.client_order_id,
523 Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
524 );
525 }
526
527 #[rstest]
528 fn test_parse_execution_report_to_fill_rejects_invalid_commission() {
529 let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
530 let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
531 msg.commission = "not-a-number".to_string();
532 let account_id = AccountId::from("BINANCE-001");
533 let ts_init = UnixNanos::from(1_000_000_000u64);
534
535 let result = parse_spot_exec_report_to_fill(
536 &msg,
537 instrument_id(),
538 PRICE_PRECISION,
539 SIZE_PRECISION,
540 account_id,
541 ts_init,
542 );
543
544 let error = result.unwrap_err().to_string();
545 assert!(error.contains("commission"));
546 }
547
548 #[rstest]
549 fn test_parse_account_position() {
550 let json = load_fixture_string("spot/user_data_json/account_position.json");
551 let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(&json).unwrap();
552 let account_id = AccountId::from("BINANCE-001");
553 let ts_init = UnixNanos::from(1_000_000_000u64);
554
555 let state = parse_spot_account_position(&msg, account_id, ts_init);
556
557 assert_eq!(state.account_id, account_id);
558 assert_eq!(state.account_type, AccountType::Cash);
559 assert!(state.is_reported);
560 assert_eq!(state.balances.len(), 2);
561 }
562
563 #[rstest]
567 fn test_parse_account_position_precision_drift() {
568 let json = r#"{
569 "e": "outboundAccountPosition",
570 "E": 1700000000000,
571 "u": 1700000000000,
572 "B": [{
573 "a": "ETH",
574 "f": "9.999999994999",
575 "l": "0.000000040000"
576 }]
577 }"#;
578 let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(json).unwrap();
579 let account_id = AccountId::from("BINANCE-001");
580 let ts_init = UnixNanos::from(1_000_000_000u64);
581
582 let state = parse_spot_account_position(&msg, account_id, ts_init);
583
584 assert_eq!(state.balances.len(), 1);
585 let balance = &state.balances[0];
586 assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
587 }
588}