1use std::{
19 future::Future,
20 sync::{Arc, Mutex},
21 time::Duration,
22};
23
24use ahash::AHashMap;
25use anyhow::Context;
26use async_trait::async_trait;
27use nautilus_common::{
28 cache::fifo::FifoCache,
29 clients::ExecutionClient,
30 live::{get_runtime, runner::get_exec_event_sender},
31 messages::execution::{
32 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
33 GenerateOrderStatusReport, GenerateOrderStatusReports, GenerateOrderStatusReportsBuilder,
34 GeneratePositionStatusReports, GeneratePositionStatusReportsBuilder, ModifyOrder,
35 QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
36 },
37};
38use nautilus_core::{
39 MUTEX_POISONED, UUID4, UnixNanos,
40 datetime::mins_to_nanos,
41 time::{AtomicTime, get_atomic_clock_realtime},
42};
43use nautilus_live::{ExecutionClientCore, ExecutionEventEmitter};
44use nautilus_model::{
45 accounts::AccountAny,
46 enums::{ContingencyType, LiquiditySide, OmsType, OrderStatus, OrderType},
47 events::{
48 AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderEventAny,
49 OrderExpired, OrderFilled, OrderModifyRejected, OrderRejected, OrderUpdated,
50 },
51 identifiers::{
52 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, Venue,
53 VenueOrderId,
54 },
55 instruments::Instrument,
56 orders::{Order, OrderAny},
57 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
58 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
59};
60use rust_decimal::Decimal;
61use tokio::task::JoinHandle;
62use ustr::Ustr;
63
64use super::websocket::trading::{
65 client::BinanceSpotWsTradingClient,
66 messages::BinanceSpotWsTradingMessage,
67 parse::{
68 parse_spot_account_position, parse_spot_exec_report_to_fill,
69 parse_spot_exec_report_to_order_status,
70 },
71 user_data::{BinanceSpotExecutionReport, BinanceSpotExecutionType},
72};
73use crate::{
74 common::{
75 consts::{
76 BINANCE_GTX_ORDER_REJECT_CODE, BINANCE_NAUTILUS_SPOT_BROKER_ID,
77 BINANCE_NEW_ORDER_REJECTED_CODE, BINANCE_SPOT_POST_ONLY_REJECT_MSG,
78 BINANCE_STATUS_UNKNOWN_CODE, BINANCE_UNEXPECTED_RESPONSE_CODE, BINANCE_VENUE,
79 },
80 credential::resolve_credentials,
81 dispatch::{
82 OrderIdentity, PendingOperation, PendingRequest, WsDispatchState,
83 ensure_accepted_emitted,
84 },
85 encoder::{decode_broker_id, encode_broker_id},
86 enums::{BinanceSide, BinanceTimeInForce},
87 parse::{
88 parse_required_decimal, parse_required_price_at_precision,
89 parse_required_quantity_at_precision,
90 },
91 },
92 config::BinanceExecClientConfig,
93 spot::{
94 enums::{
95 BinanceCancelReplaceMode, BinanceOrderResponseType, BinanceSpotOrderType,
96 order_type_to_binance_spot, time_in_force_to_binance_spot,
97 },
98 http::{
99 client::BinanceSpotHttpClient,
100 error::BinanceSpotHttpError,
101 models::BatchCancelResult,
102 query::{
103 BatchCancelItem, CancelOrderParams, CancelReplaceOrderParams,
104 NewOcoOrderListParams, NewOrderParams,
105 },
106 },
107 },
108};
109
110#[derive(Debug)]
117pub struct BinanceSpotExecutionClient {
118 core: ExecutionClientCore,
119 clock: &'static AtomicTime,
120 config: BinanceExecClientConfig,
121 emitter: ExecutionEventEmitter,
122 dispatch_state: Arc<WsDispatchState>,
123 http_client: BinanceSpotHttpClient,
124 ws_trading_client: Option<BinanceSpotWsTradingClient>,
125 ws_trading_handle: Mutex<Option<JoinHandle<()>>>,
126 ws_authenticated: Arc<tokio::sync::Notify>,
127 ws_user_data_subscribed: Arc<tokio::sync::Notify>,
128 pending_tasks: Mutex<Vec<JoinHandle<()>>>,
129}
130
131impl BinanceSpotExecutionClient {
132 pub fn new(core: ExecutionClientCore, config: BinanceExecClientConfig) -> anyhow::Result<Self> {
138 let (api_key, api_secret) = resolve_credentials(
139 config.api_key.clone(),
140 config.api_secret.clone(),
141 config.environment,
142 config.product_type,
143 )?;
144
145 let clock = get_atomic_clock_realtime();
146
147 let http_client = BinanceSpotHttpClient::new(
148 config.environment,
149 clock,
150 Some(api_key.clone()),
151 Some(api_secret.clone()),
152 config.base_url_http.clone(),
153 None, None, None, )
157 .context("failed to construct Binance Spot HTTP client")?;
158 let emitter = ExecutionEventEmitter::new(
159 clock,
160 core.trader_id,
161 core.account_id,
162 core.account_type,
163 core.base_currency,
164 );
165
166 let ws_trading_client = if config.use_ws_trading {
167 Some(BinanceSpotWsTradingClient::new(
168 config.base_url_ws_trading.clone(),
169 api_key,
170 api_secret,
171 None, config.transport_backend,
173 ))
174 } else {
175 None
176 };
177
178 Ok(Self {
179 core,
180 clock,
181 config,
182 emitter,
183 dispatch_state: Arc::new(WsDispatchState::default()),
184 http_client,
185 ws_trading_client,
186 ws_trading_handle: Mutex::new(None),
187 ws_authenticated: Arc::new(tokio::sync::Notify::new()),
188 ws_user_data_subscribed: Arc::new(tokio::sync::Notify::new()),
189 pending_tasks: Mutex::new(Vec::new()),
190 })
191 }
192
193 async fn refresh_account_state(&self) -> anyhow::Result<AccountState> {
194 self.http_client
195 .request_account_state(self.core.account_id)
196 .await
197 }
198
199 fn update_account_state(&self) {
200 let http_client = self.http_client.clone();
201 let account_id = self.core.account_id;
202 let emitter = self.emitter.clone();
203 let clock = self.clock;
204
205 self.spawn_task("query_account", async move {
206 let account_state = http_client.request_account_state(account_id).await?;
207 let ts_now = clock.get_time_ns();
208 emitter.emit_account_state(
209 account_state.balances.clone(),
210 account_state.margins.clone(),
211 account_state.is_reported,
212 ts_now,
213 );
214 Ok(())
215 });
216 }
217
218 fn ws_trading_active(&self) -> bool {
220 let dispatch_running = self
221 .ws_trading_handle
222 .lock()
223 .expect(MUTEX_POISONED)
224 .as_ref()
225 .is_some_and(|handle| !handle.is_finished());
226
227 self.ws_trading_client
228 .as_ref()
229 .is_some_and(|client| client.is_active())
230 && dispatch_running
231 }
232
233 fn submit_order_internal(&self, cmd: &SubmitOrder) -> anyhow::Result<()> {
234 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
235
236 let event_emitter = self.emitter.clone();
237 let trader_id = self.core.trader_id;
238 let account_id = self.core.account_id;
239 let client_order_id = order.client_order_id();
240 let strategy_id = order.strategy_id();
241 let instrument_id = order.instrument_id();
242 let order_side = order.order_side();
243 let order_type = order.order_type();
244 let quantity = order.quantity();
245 let time_in_force = order.time_in_force();
246 let price = order.price();
247 let trigger_price = order.trigger_price();
248 let is_post_only = order.is_post_only();
249 let is_quote_quantity = order.is_quote_quantity();
250 let display_qty = order.display_qty();
251 let clock = self.clock;
252 let ts_init = self.clock.get_time_ns();
253
254 self.dispatch_state.order_identities.insert(
256 client_order_id,
257 OrderIdentity {
258 instrument_id,
259 strategy_id,
260 order_side,
261 order_type,
262 price,
263 quantity,
264 },
265 );
266
267 if self.ws_trading_active() {
268 let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
269 let dispatch_state = self.dispatch_state.clone();
270 let params =
271 build_new_order_params(&order, client_order_id, is_post_only, is_quote_quantity)?;
272
273 let request_id = ws_client.next_request_id();
275 dispatch_state.pending_requests.insert(
276 request_id.clone(),
277 PendingRequest {
278 client_order_id,
279 venue_order_id: None,
280 operation: PendingOperation::Place,
281 },
282 );
283
284 self.spawn_task("submit_order_ws", async move {
285 if let Err(e) = ws_client
286 .place_order_with_id(request_id.clone(), params)
287 .await
288 {
289 dispatch_state.pending_requests.remove(&request_id);
290 log::warn!(
291 "WS submit request failed for {client_order_id}, awaiting reconciliation: {e}"
292 );
293 anyhow::bail!("WS submit order failed: {e}");
294 }
295 Ok(())
296 });
297 } else {
298 let http_client = self.http_client.clone();
299 let dispatch_state = self.dispatch_state.clone();
300 log::debug!("WS trading not active, falling back to HTTP for submit_order");
301
302 self.spawn_task("submit_order_http", async move {
303 let result = http_client
304 .submit_order(
305 account_id,
306 instrument_id,
307 client_order_id,
308 order_side,
309 order_type,
310 quantity,
311 time_in_force,
312 price,
313 trigger_price,
314 is_post_only,
315 is_quote_quantity,
316 display_qty,
317 )
318 .await;
319
320 match result {
321 Ok(report) => {
322 dispatch_state.insert_accepted(client_order_id);
323 let accepted = OrderAccepted::new(
324 trader_id,
325 strategy_id,
326 instrument_id,
327 client_order_id,
328 report.venue_order_id,
329 account_id,
330 UUID4::new(),
331 ts_init,
332 ts_init,
333 false,
334 );
335 event_emitter.send_order_event(OrderEventAny::Accepted(accepted));
336 }
337 Err(e) => {
338 if is_ambiguous_submit_error(&e) {
339 log::warn!(
340 "Ambiguous submit failure for {client_order_id}, awaiting reconciliation: {e}"
341 );
342 } else if is_structured_venue_rejection(&e)
343 || is_local_command_failure(&e)
344 {
345 let due_post_only = e
346 .downcast_ref::<BinanceSpotHttpError>()
347 .is_some_and(is_spot_post_only_rejection);
348 dispatch_state.cleanup_terminal(client_order_id);
349 let rejected = OrderRejected::new(
350 trader_id,
351 strategy_id,
352 instrument_id,
353 client_order_id,
354 account_id,
355 format!("submit-order-error: {e}").into(),
356 UUID4::new(),
357 ts_init,
358 clock.get_time_ns(),
359 false,
360 due_post_only,
361 );
362 event_emitter.send_order_event(OrderEventAny::Rejected(rejected));
363 } else {
364 log::warn!(
365 "Ambiguous submit failure for {client_order_id}, awaiting reconciliation: {e}"
366 );
367 }
368 return Err(e);
369 }
370 }
371 Ok(())
372 });
373 }
374
375 Ok(())
376 }
377
378 fn cancel_order_internal(&self, cmd: &CancelOrder) {
379 let event_emitter = self.emitter.clone();
380 let trader_id = self.core.trader_id;
381 let account_id = self.core.account_id;
382 let clock = self.clock;
383 let command = cmd.clone();
384
385 if self.ws_trading_active() {
386 let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
387 let dispatch_state = self.dispatch_state.clone();
388 let params = build_cancel_order_params(&command);
389
390 let request_id = ws_client.next_request_id();
392 dispatch_state.pending_requests.insert(
393 request_id.clone(),
394 PendingRequest {
395 client_order_id: command.client_order_id,
396 venue_order_id: command.venue_order_id,
397 operation: PendingOperation::Cancel,
398 },
399 );
400
401 self.spawn_task("cancel_order_ws", async move {
402 if let Err(e) = ws_client
403 .cancel_order_with_id(request_id.clone(), params)
404 .await
405 {
406 dispatch_state.pending_requests.remove(&request_id);
407 log::warn!(
408 "WS cancel request failed for {}, awaiting reconciliation: {e}",
409 command.client_order_id
410 );
411 anyhow::bail!("WS cancel order failed: {e}");
412 }
413 Ok(())
414 });
415 } else {
416 let http_client = self.http_client.clone();
417 let dispatch_state = self.dispatch_state.clone();
418 log::debug!("WS trading not active, falling back to HTTP for cancel_order");
419
420 self.spawn_task("cancel_order_http", async move {
421 let result = http_client
422 .cancel_order(
423 command.instrument_id,
424 command.venue_order_id,
425 Some(command.client_order_id),
426 )
427 .await;
428
429 match result {
430 Ok(venue_order_id) => {
431 dispatch_state.cleanup_terminal(command.client_order_id);
432 let ts_now = clock.get_time_ns();
433 let canceled_event = OrderCanceled::new(
434 trader_id,
435 command.strategy_id,
436 command.instrument_id,
437 command.client_order_id,
438 UUID4::new(),
439 ts_now,
440 ts_now,
441 false,
442 Some(venue_order_id),
443 Some(account_id),
444 );
445 event_emitter.send_order_event(OrderEventAny::Canceled(canceled_event));
446 }
447 Err(e) => {
448 if is_structured_venue_rejection(&e) {
449 let ts_now = clock.get_time_ns();
450 let rejected_event = OrderCancelRejected::new(
451 trader_id,
452 command.strategy_id,
453 command.instrument_id,
454 command.client_order_id,
455 format!("cancel-order-error: {e}").into(),
456 UUID4::new(),
457 ts_now,
458 ts_now,
459 false,
460 command.venue_order_id,
461 Some(account_id),
462 );
463 event_emitter
464 .send_order_event(OrderEventAny::CancelRejected(rejected_event));
465 } else if is_local_command_failure(&e) {
466 log::warn!(
467 "Cancel command failed local validation for {}: {e}",
468 command.client_order_id
469 );
470 } else {
471 log::warn!(
472 "Ambiguous cancel failure for {}, awaiting reconciliation: {e}",
473 command.client_order_id
474 );
475 }
476 return Err(e);
477 }
478 }
479 Ok(())
480 });
481 }
482 }
483
484 fn spawn_task<F>(&self, description: &'static str, fut: F)
485 where
486 F: Future<Output = anyhow::Result<()>> + Send + 'static,
487 {
488 crate::common::execution::spawn_task(&self.pending_tasks, description, fut);
489 }
490
491 fn abort_pending_tasks(&self) {
492 crate::common::execution::abort_pending_tasks(&self.pending_tasks);
493 }
494
495 async fn enter_http_only_execution_mode(
496 &mut self,
497 mut ws_trading: BinanceSpotWsTradingClient,
498 reason: &str,
499 ) {
500 log::error!(
501 "{reason}; entering Spot HTTP-only execution mode. Order commands use HTTP responses; execution reconciliation requires explicit queries until WS trading is re-enabled"
502 );
503
504 if let Some(handle) = self.ws_trading_handle.lock().expect(MUTEX_POISONED).take() {
505 handle.abort();
506 }
507 ws_trading.disconnect().await;
508 self.ws_trading_client = Some(ws_trading);
509 }
510}
511
512#[async_trait(?Send)]
513impl ExecutionClient for BinanceSpotExecutionClient {
514 fn is_connected(&self) -> bool {
515 self.core.is_connected()
516 }
517
518 fn client_id(&self) -> ClientId {
519 self.core.client_id
520 }
521
522 fn account_id(&self) -> AccountId {
523 self.core.account_id
524 }
525
526 fn venue(&self) -> Venue {
527 *BINANCE_VENUE
528 }
529
530 fn oms_type(&self) -> OmsType {
531 self.core.oms_type
532 }
533
534 fn get_account(&self) -> Option<AccountAny> {
535 self.core.cache().account_owned(&self.core.account_id)
536 }
537
538 async fn connect(&mut self) -> anyhow::Result<()> {
539 if self.core.is_connected() {
540 return Ok(());
541 }
542
543 if !self.core.instruments_initialized() {
545 let instruments = self
546 .http_client
547 .request_instruments()
548 .await
549 .context("failed to request Binance Spot instruments")?;
550
551 if instruments.is_empty() {
552 log::warn!("No instruments returned for Binance Spot");
553 } else {
554 log::debug!("Loaded {} Spot instruments", instruments.len());
555 self.http_client.cache_instruments(instruments);
556 }
557
558 self.core.set_instruments_initialized();
559 }
560
561 let account_state = self
563 .refresh_account_state()
564 .await
565 .context("failed to request Binance account state")?;
566
567 if !account_state.balances.is_empty() {
568 log::debug!(
569 "Received account state with {} balance(s)",
570 account_state.balances.len()
571 );
572 }
573
574 self.emitter.send_account_state(account_state);
575
576 crate::common::execution::await_account_registered(&self.core, self.core.account_id, 30.0)
578 .await?;
579
580 if let Some(mut ws_trading) = self.ws_trading_client.take() {
582 match ws_trading.connect().await {
583 Ok(()) => {
584 log::debug!("Connected to Binance Spot WS trading API");
585
586 let ws_trading_clone = ws_trading.clone();
587 let emitter = self.emitter.clone();
588 let account_id = self.core.account_id;
589 let clock = self.clock;
590 let http_client = self.http_client.clone();
591 let dispatch_state = self.dispatch_state.clone();
592 let treat_expired_as_canceled = self.config.treat_expired_as_canceled;
593 let ws_authenticated = self.ws_authenticated.clone();
594 let ws_user_data_subscribed = self.ws_user_data_subscribed.clone();
595 let (ws_setup_error_tx, mut ws_setup_error_rx) =
596 tokio::sync::mpsc::unbounded_channel();
597 let seen_trade_ids = std::sync::Arc::new(Mutex::new(FifoCache::new()));
598
599 let handle = get_runtime().spawn(async move {
600 loop {
601 match ws_trading_clone.recv().await {
602 Some(msg) => {
603 dispatch_ws_trading_message(
604 msg,
605 &emitter,
606 &http_client,
607 account_id,
608 treat_expired_as_canceled,
609 clock,
610 &dispatch_state,
611 &ws_authenticated,
612 &ws_user_data_subscribed,
613 &ws_setup_error_tx,
614 &seen_trade_ids,
615 );
616 }
617 None => {
618 log::warn!("WS trading dispatch loop ended");
619 break;
620 }
621 }
622 }
623 });
624
625 *self.ws_trading_handle.lock().expect(MUTEX_POISONED) = Some(handle);
626
627 if let Err(e) = ws_trading.session_logon().await {
628 let reason = format!("WS session logon failed: {e}");
629 self.enter_http_only_execution_mode(ws_trading, &reason)
630 .await;
631 } else {
632 let auth_result = wait_for_ws_setup_response(
633 Duration::from_secs(10),
634 self.ws_authenticated.notified(),
635 &mut ws_setup_error_rx,
636 "WS session authentication timed out",
637 )
638 .await;
639
640 if let Err(e) = auth_result {
641 self.enter_http_only_execution_mode(ws_trading, &e.to_string())
642 .await;
643 } else if let Err(e) = ws_trading.subscribe_user_data().await {
644 let reason = format!("WS user data subscribe failed: {e}");
645 self.enter_http_only_execution_mode(ws_trading, &reason)
646 .await;
647 } else {
648 let subscribe_result = wait_for_ws_setup_response(
649 Duration::from_secs(10),
650 self.ws_user_data_subscribed.notified(),
651 &mut ws_setup_error_rx,
652 "WS user data subscription timed out",
653 )
654 .await;
655
656 if let Err(e) = subscribe_result {
657 self.enter_http_only_execution_mode(ws_trading, &e.to_string())
658 .await;
659 } else {
660 self.ws_trading_client = Some(ws_trading);
661 }
662 }
663 }
664 }
665 Err(e) => {
666 let reason = format!("Failed to connect WS trading API: {e}");
667 self.enter_http_only_execution_mode(ws_trading, &reason)
668 .await;
669 }
670 }
671 }
672
673 self.core.set_connected();
674 log::info!("Connected: client_id={}", self.core.client_id);
675 Ok(())
676 }
677
678 async fn disconnect(&mut self) -> anyhow::Result<()> {
679 if self.core.is_disconnected() {
680 return Ok(());
681 }
682
683 if let Some(handle) = self.ws_trading_handle.lock().expect(MUTEX_POISONED).take() {
685 handle.abort();
686 }
687
688 if let Some(ref mut ws_trading) = self.ws_trading_client {
689 ws_trading.disconnect().await;
690 }
691
692 self.abort_pending_tasks();
693
694 self.core.set_disconnected();
695 log::info!("Disconnected: client_id={}", self.core.client_id);
696 Ok(())
697 }
698
699 fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
700 self.update_account_state();
701 Ok(())
702 }
703
704 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
705 log::debug!("query_order: client_order_id={}", cmd.client_order_id);
706
707 let http_client = self.http_client.clone();
708 let command = cmd;
709 let event_emitter = self.emitter.clone();
710 let account_id = self.core.account_id;
711 let treat_expired_as_canceled = self.config.treat_expired_as_canceled;
712
713 self.spawn_task("query_order", async move {
714 let result = http_client
715 .request_order_status_report(
716 account_id,
717 command.instrument_id,
718 command.venue_order_id,
719 Some(command.client_order_id),
720 )
721 .await;
722
723 match result {
724 Ok(Some(mut report)) => {
725 normalize_spot_order_status_report(&mut report, treat_expired_as_canceled);
726 event_emitter.send_order_status_report(report);
727 }
728 Ok(None) => log::debug!(
729 "No order status report returned: client_order_id={}",
730 command.client_order_id
731 ),
732 Err(e) => log::warn!("Failed to query order status: {e}"),
733 }
734
735 Ok(())
736 });
737
738 Ok(())
739 }
740
741 fn generate_account_state(
742 &self,
743 balances: Vec<AccountBalance>,
744 margins: Vec<MarginBalance>,
745 reported: bool,
746 ts_event: UnixNanos,
747 ) -> anyhow::Result<()> {
748 self.emitter
749 .emit_account_state(balances, margins, reported, ts_event);
750 Ok(())
751 }
752
753 fn start(&mut self) -> anyhow::Result<()> {
754 if self.core.is_started() {
755 return Ok(());
756 }
757
758 self.emitter.set_sender(get_exec_event_sender());
759 self.core.set_started();
760
761 let http_client = self.http_client.clone();
763
764 get_runtime().spawn(async move {
765 match http_client.request_instruments().await {
766 Ok(instruments) => {
767 if instruments.is_empty() {
768 log::warn!("No instruments returned for Binance Spot");
769 } else {
770 http_client.cache_instruments(instruments);
771 log::debug!("Instruments initialized");
772 }
773 }
774 Err(e) => {
775 log::error!("Failed to request Binance Spot instruments: {e}");
776 }
777 }
778 });
779
780 log::info!(
781 "Started: client_id={}, account_id={}, account_type={:?}, environment={:?}, product_type={:?}",
782 self.core.client_id,
783 self.core.account_id,
784 self.core.account_type,
785 self.config.environment,
786 self.config.product_type,
787 );
788 Ok(())
789 }
790
791 fn stop(&mut self) -> anyhow::Result<()> {
792 if self.core.is_stopped() {
793 return Ok(());
794 }
795
796 if let Some(handle) = self.ws_trading_handle.lock().expect(MUTEX_POISONED).take() {
798 handle.abort();
799 }
800
801 self.core.set_stopped();
802 self.core.set_disconnected();
803 self.abort_pending_tasks();
804 log::info!("Stopped: client_id={}", self.core.client_id);
805 Ok(())
806 }
807
808 async fn generate_order_status_report(
809 &self,
810 cmd: &GenerateOrderStatusReport,
811 ) -> anyhow::Result<Option<OrderStatusReport>> {
812 let Some(instrument_id) = cmd.instrument_id else {
813 log::warn!("generate_order_status_report requires instrument_id: {cmd:?}");
814 return Ok(None);
815 };
816
817 let venue_order_id = cmd
819 .venue_order_id
820 .as_ref()
821 .map(|id| VenueOrderId::new(id.inner()));
822
823 let report = self
824 .http_client
825 .request_order_status_report(
826 self.core.account_id,
827 instrument_id,
828 venue_order_id,
829 cmd.client_order_id,
830 )
831 .await?;
832
833 Ok(report.map(|mut report| {
834 normalize_spot_order_status_report(&mut report, self.config.treat_expired_as_canceled);
835 report
836 }))
837 }
838
839 async fn generate_order_status_reports(
840 &self,
841 cmd: &GenerateOrderStatusReports,
842 ) -> anyhow::Result<Vec<OrderStatusReport>> {
843 let start_dt = cmd.start.map(|nanos| nanos.to_datetime_utc());
844 let end_dt = cmd.end.map(|nanos| nanos.to_datetime_utc());
845
846 let mut reports = self
847 .http_client
848 .request_order_status_reports(
849 self.core.account_id,
850 cmd.instrument_id,
851 start_dt,
852 end_dt,
853 cmd.open_only,
854 None, )
856 .await?;
857
858 normalize_spot_order_status_reports(&mut reports, self.config.treat_expired_as_canceled);
859
860 Ok(reports)
861 }
862
863 async fn generate_fill_reports(
864 &self,
865 cmd: GenerateFillReports,
866 ) -> anyhow::Result<Vec<FillReport>> {
867 let Some(instrument_id) = cmd.instrument_id else {
868 log::warn!("generate_fill_reports requires instrument_id for Binance Spot");
869 return Ok(Vec::new());
870 };
871
872 let venue_order_id = cmd
874 .venue_order_id
875 .as_ref()
876 .map(|id| VenueOrderId::new(id.inner()));
877
878 let start_dt = cmd.start.map(|nanos| nanos.to_datetime_utc());
879 let end_dt = cmd.end.map(|nanos| nanos.to_datetime_utc());
880
881 let reports = self
882 .http_client
883 .request_fill_reports(
884 self.core.account_id,
885 instrument_id,
886 venue_order_id,
887 start_dt,
888 end_dt,
889 None, )
891 .await?;
892
893 Ok(reports)
894 }
895
896 async fn generate_position_status_reports(
897 &self,
898 _cmd: &GeneratePositionStatusReports,
899 ) -> anyhow::Result<Vec<PositionStatusReport>> {
900 Ok(Vec::new())
903 }
904
905 async fn generate_mass_status(
906 &self,
907 lookback_mins: Option<u64>,
908 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
909 log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
910
911 let ts_now = self.clock.get_time_ns();
912
913 let start = lookback_mins.map(|mins| {
914 let lookback_ns = mins_to_nanos(mins);
915 UnixNanos::from(ts_now.as_u64().saturating_sub(lookback_ns))
916 });
917
918 let order_cmd = GenerateOrderStatusReportsBuilder::default()
921 .ts_init(ts_now)
922 .open_only(true)
923 .start(start)
924 .build()
925 .map_err(|e| anyhow::anyhow!("{e}"))?;
926
927 let position_cmd = GeneratePositionStatusReportsBuilder::default()
928 .ts_init(ts_now)
929 .start(start)
930 .build()
931 .map_err(|e| anyhow::anyhow!("{e}"))?;
932
933 let (order_reports, position_reports) = tokio::try_join!(
934 self.generate_order_status_reports(&order_cmd),
935 self.generate_position_status_reports(&position_cmd),
936 )?;
937
938 log::info!("Received {} OrderStatusReports", order_reports.len());
942 log::info!("Received {} PositionReports", position_reports.len());
943
944 let mut mass_status = ExecutionMassStatus::new(
945 self.core.client_id,
946 self.core.account_id,
947 *BINANCE_VENUE,
948 ts_now,
949 None,
950 );
951
952 mass_status.add_order_reports(order_reports);
953 mass_status.add_position_reports(position_reports);
954
955 Ok(Some(mass_status))
956 }
957
958 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
959 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
960
961 if order.is_closed() {
962 let client_order_id = order.client_order_id();
963 log::warn!("Cannot submit closed order {client_order_id}");
964 return Ok(());
965 }
966
967 log::debug!("OrderSubmitted client_order_id={}", order.client_order_id());
968 self.emitter.emit_order_submitted(&order);
969
970 self.submit_order_internal(&cmd)
971 }
972
973 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
974 if cmd.order_list.client_order_ids.is_empty() {
975 log::debug!("submit_order_list called with empty order list");
976 return Ok(());
977 }
978
979 let orders = self.core.get_orders_for_list(&cmd.order_list)?;
980
981 if let Some(order) = orders.iter().find(|order| order.is_closed()) {
982 let reason = format!("Cannot submit closed order {}", order.client_order_id());
983 for order in &orders {
984 self.emitter.emit_order_denied(order, &reason);
985 }
986 return Ok(());
987 }
988
989 let params = match build_spot_order_list_params(cmd.order_list.id.as_ref(), &orders) {
990 Ok(request) => request,
991 Err(reason) => {
992 for order in &orders {
993 self.emitter.emit_order_denied(order, &reason);
994 }
995 return Ok(());
996 }
997 };
998
999 for order in &orders {
1000 self.dispatch_state.order_identities.insert(
1001 order.client_order_id(),
1002 OrderIdentity {
1003 instrument_id: order.instrument_id(),
1004 strategy_id: order.strategy_id(),
1005 order_side: order.order_side(),
1006 order_type: order.order_type(),
1007 price: order.price(),
1008 quantity: order.quantity(),
1009 },
1010 );
1011 self.emitter.emit_order_submitted(order);
1012 }
1013
1014 let event_emitter = self.emitter.clone();
1015 let trader_id = self.core.trader_id;
1016 let account_id = self.core.account_id;
1017 let clock = self.clock;
1018 let ts_init = self.clock.get_time_ns();
1019 let http_client = self.http_client.clone();
1020 let dispatch_state = self.dispatch_state.clone();
1021
1022 self.spawn_task("submit_order_list_http", async move {
1023 let response = http_client.submit_oco_order_list(¶ms).await;
1024
1025 match response {
1026 Ok(response) => {
1027 for order in &orders {
1028 let encoded_client_order_id = encode_broker_id(
1029 &order.client_order_id(),
1030 BINANCE_NAUTILUS_SPOT_BROKER_ID,
1031 );
1032 let Some(report) = response
1033 .orders
1034 .iter()
1035 .find(|report| report.client_order_id == encoded_client_order_id)
1036 else {
1037 log::warn!(
1038 "OCO response missing leg for {}, awaiting reconciliation",
1039 order.client_order_id()
1040 );
1041 continue;
1042 };
1043
1044 let client_order_id = order.client_order_id();
1045 dispatch_state.insert_accepted(client_order_id);
1046 let accepted = OrderAccepted::new(
1047 trader_id,
1048 order.strategy_id(),
1049 order.instrument_id(),
1050 client_order_id,
1051 VenueOrderId::from(report.order_id.to_string().as_str()),
1052 account_id,
1053 UUID4::new(),
1054 ts_init,
1055 clock.get_time_ns(),
1056 false,
1057 );
1058 event_emitter.send_order_event(OrderEventAny::Accepted(accepted));
1059 }
1060 }
1061 Err(e) => {
1062 handle_spot_order_list_submit_error(
1063 &event_emitter,
1064 &dispatch_state,
1065 trader_id,
1066 account_id,
1067 clock,
1068 &orders,
1069 e,
1070 )?;
1071 }
1072 }
1073
1074 Ok(())
1075 });
1076
1077 Ok(())
1078 }
1079
1080 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1081 let order = self
1085 .core
1086 .cache()
1087 .order(&cmd.client_order_id)
1088 .map(|o| o.clone());
1089
1090 let Some(order) = order else {
1091 log::warn!(
1092 "Cannot modify order {}: not found in cache",
1093 cmd.client_order_id
1094 );
1095 let ts_init = self.clock.get_time_ns();
1096 let rejected_event = OrderModifyRejected::new(
1097 self.core.trader_id,
1098 cmd.strategy_id,
1099 cmd.instrument_id,
1100 cmd.client_order_id,
1101 "Order not found in cache for modify".into(),
1102 UUID4::new(),
1103 ts_init, ts_init,
1105 false,
1106 cmd.venue_order_id,
1107 Some(self.core.account_id),
1108 );
1109
1110 self.emitter
1111 .send_order_event(OrderEventAny::ModifyRejected(rejected_event));
1112 return Ok(());
1113 };
1114
1115 let event_emitter = self.emitter.clone();
1116 let trader_id = self.core.trader_id;
1117 let account_id = self.core.account_id;
1118 let clock = self.clock;
1119
1120 let order_side = order.order_side();
1121 let order_type = order.order_type();
1122 let time_in_force = order.time_in_force();
1123 let quantity = cmd.quantity.unwrap_or_else(|| order.quantity());
1124
1125 if self.ws_trading_active() {
1126 let command = cmd;
1127 let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
1128 let dispatch_state = self.dispatch_state.clone();
1129 let params = build_cancel_replace_params(&command, &order, quantity)?;
1130
1131 let request_id = ws_client.next_request_id();
1133 dispatch_state.pending_requests.insert(
1134 request_id.clone(),
1135 PendingRequest {
1136 client_order_id: command.client_order_id,
1137 venue_order_id: command.venue_order_id,
1138 operation: PendingOperation::Modify,
1139 },
1140 );
1141
1142 self.spawn_task("modify_order_ws", async move {
1143 if let Err(e) = ws_client
1144 .cancel_replace_order_with_id(request_id.clone(), params)
1145 .await
1146 {
1147 dispatch_state.pending_requests.remove(&request_id);
1148 log::warn!(
1149 "WS modify request failed for {}, awaiting reconciliation: {e}",
1150 command.client_order_id
1151 );
1152 anyhow::bail!("WS modify order failed: {e}");
1153 }
1154 Ok(())
1155 });
1156 } else {
1157 let command = cmd;
1158 let http_client = self.http_client.clone();
1159 log::debug!("WS trading not active, falling back to HTTP for modify_order");
1160
1161 self.spawn_task("modify_order_http", async move {
1162 let result = match command.venue_order_id {
1163 Some(venue_order_id) => {
1164 http_client
1165 .modify_order(
1166 account_id,
1167 command.instrument_id,
1168 venue_order_id,
1169 command.client_order_id,
1170 order_side,
1171 order_type,
1172 quantity,
1173 time_in_force,
1174 command.price,
1175 )
1176 .await
1177 }
1178 None => Err(anyhow::anyhow!(BinanceSpotHttpError::ValidationError(
1179 "venue_order_id required for modify".to_string()
1180 ))),
1181 };
1182
1183 match result {
1184 Ok(report) => {
1185 let ts_now = clock.get_time_ns();
1186 let updated_event = OrderUpdated::new(
1187 trader_id,
1188 command.strategy_id,
1189 command.instrument_id,
1190 command.client_order_id,
1191 report.quantity,
1192 UUID4::new(),
1193 ts_now,
1194 ts_now,
1195 false,
1196 Some(report.venue_order_id),
1197 Some(account_id),
1198 report.price,
1199 None, None, false, );
1203 event_emitter.send_order_event(OrderEventAny::Updated(updated_event));
1204 }
1205 Err(e) => {
1206 if is_structured_venue_rejection(&e) || is_local_command_failure(&e) {
1207 let ts_now = clock.get_time_ns();
1208 let rejected_event = OrderModifyRejected::new(
1209 trader_id,
1210 command.strategy_id,
1211 command.instrument_id,
1212 command.client_order_id,
1213 format!("modify-order-error: {e}").into(),
1214 UUID4::new(),
1215 ts_now,
1216 ts_now,
1217 false,
1218 command.venue_order_id,
1219 Some(account_id),
1220 );
1221 event_emitter
1222 .send_order_event(OrderEventAny::ModifyRejected(rejected_event));
1223 } else {
1224 log::warn!(
1225 "Ambiguous modify failure for {}, awaiting reconciliation: {e}",
1226 command.client_order_id
1227 );
1228 }
1229 return Err(e);
1230 }
1231 }
1232 Ok(())
1233 });
1234 }
1235
1236 Ok(())
1237 }
1238
1239 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1240 self.cancel_order_internal(&cmd);
1241 Ok(())
1242 }
1243
1244 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
1245 let event_emitter = self.emitter.clone();
1246 let trader_id = self.core.trader_id;
1247 let account_id = self.core.account_id;
1248 let clock = self.clock;
1249
1250 if self.ws_trading_active() {
1251 let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
1252 let symbol = cmd.instrument_id.symbol.to_string();
1253
1254 self.spawn_task("cancel_all_orders_ws", async move {
1255 if let Err(e) = ws_client.cancel_all_orders(symbol).await {
1256 log::error!("WS cancel_all_orders failed: {e}");
1257 }
1258 Ok(())
1260 });
1261
1262 return Ok(());
1263 }
1264
1265 log::debug!("WS trading not active, falling back to HTTP for cancel_all_orders");
1266 let http_client = self.http_client.clone();
1267
1268 let strategy_lookup: AHashMap<ClientOrderId, StrategyId> = {
1270 let cache = self.core.cache();
1271 cache
1272 .orders_open(None, Some(&cmd.instrument_id), None, None, None)
1273 .into_iter()
1274 .map(|order| (order.client_order_id(), order.strategy_id()))
1275 .collect()
1276 };
1277
1278 let command = cmd;
1279 self.spawn_task("cancel_all_orders_http", async move {
1280 let canceled_orders = http_client.cancel_all_orders(command.instrument_id).await?;
1281
1282 for (venue_order_id, client_order_id) in canceled_orders {
1283 let strategy_id = strategy_lookup
1284 .get(&client_order_id)
1285 .copied()
1286 .unwrap_or(command.strategy_id);
1287
1288 let canceled_event = OrderCanceled::new(
1289 trader_id,
1290 strategy_id,
1291 command.instrument_id,
1292 client_order_id,
1293 UUID4::new(),
1294 command.ts_init,
1295 clock.get_time_ns(),
1296 false,
1297 Some(venue_order_id),
1298 Some(account_id),
1299 );
1300
1301 event_emitter.send_order_event(OrderEventAny::Canceled(canceled_event));
1302 }
1303
1304 Ok(())
1305 });
1306
1307 Ok(())
1308 }
1309
1310 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1311 const BATCH_SIZE: usize = 5;
1312
1313 if cmd.cancels.is_empty() {
1314 return Ok(());
1315 }
1316
1317 let http_client = self.http_client.clone();
1318 let command = cmd;
1319
1320 let event_emitter = self.emitter.clone();
1321 let trader_id = self.core.trader_id;
1322 let account_id = self.core.account_id;
1323 let clock = self.clock;
1324
1325 self.spawn_task("batch_cancel_orders", async move {
1326 for chunk in command.cancels.chunks(BATCH_SIZE) {
1327 let batch_items: Vec<BatchCancelItem> = chunk
1328 .iter()
1329 .map(|cancel| {
1330 if let Some(venue_order_id) = cancel.venue_order_id {
1331 let order_id = venue_order_id.inner().parse::<i64>().unwrap_or(0);
1332 if order_id != 0 {
1333 BatchCancelItem::by_order_id(
1334 command.instrument_id.symbol.to_string(),
1335 order_id,
1336 )
1337 } else {
1338 BatchCancelItem::by_client_order_id(
1339 command.instrument_id.symbol.to_string(),
1340 encode_broker_id(
1341 &cancel.client_order_id,
1342 BINANCE_NAUTILUS_SPOT_BROKER_ID,
1343 ),
1344 )
1345 }
1346 } else {
1347 BatchCancelItem::by_client_order_id(
1348 command.instrument_id.symbol.to_string(),
1349 encode_broker_id(
1350 &cancel.client_order_id,
1351 BINANCE_NAUTILUS_SPOT_BROKER_ID,
1352 ),
1353 )
1354 }
1355 })
1356 .collect();
1357
1358 match http_client.batch_cancel_orders(&batch_items).await {
1359 Ok(results) => {
1360 for (i, result) in results.iter().enumerate() {
1361 let cancel = &chunk[i];
1362
1363 match result {
1364 BatchCancelResult::Success(success) => {
1365 let venue_order_id =
1366 VenueOrderId::new(success.order_id.to_string());
1367 let canceled_event = OrderCanceled::new(
1368 trader_id,
1369 cancel.strategy_id,
1370 cancel.instrument_id,
1371 cancel.client_order_id,
1372 UUID4::new(),
1373 cancel.ts_init,
1374 clock.get_time_ns(),
1375 false,
1376 Some(venue_order_id),
1377 Some(account_id),
1378 );
1379
1380 event_emitter
1381 .send_order_event(OrderEventAny::Canceled(canceled_event));
1382 }
1383 BatchCancelResult::Error(error) => {
1384 let rejected_event = OrderCancelRejected::new(
1385 trader_id,
1386 cancel.strategy_id,
1387 cancel.instrument_id,
1388 cancel.client_order_id,
1389 format!(
1390 "batch-cancel-error: code={}, msg={}",
1391 error.code, error.msg
1392 )
1393 .into(),
1394 UUID4::new(),
1395 clock.get_time_ns(),
1396 cancel.ts_init,
1397 false,
1398 cancel.venue_order_id,
1399 Some(account_id),
1400 );
1401
1402 event_emitter.send_order_event(OrderEventAny::CancelRejected(
1403 rejected_event,
1404 ));
1405 }
1406 }
1407 }
1408 }
1409 Err(e) => {
1410 if is_local_http_command_failure(&e) {
1411 log::warn!(
1412 "Batch cancel command failed local validation for {} orders: {e}",
1413 chunk.len()
1414 );
1415 } else {
1416 log::warn!(
1417 "Ambiguous batch cancel failure for {} orders, awaiting reconciliation: {e}",
1418 chunk.len()
1419 );
1420 }
1421 }
1422 }
1423 }
1424
1425 Ok(())
1426 });
1427
1428 Ok(())
1429 }
1430}
1431
1432fn normalize_spot_order_status_report(
1433 report: &mut OrderStatusReport,
1434 treat_expired_as_canceled: bool,
1435) {
1436 if treat_expired_as_canceled && report.order_status == OrderStatus::Expired {
1437 report.order_status = OrderStatus::Canceled;
1438 }
1439}
1440
1441fn normalize_spot_order_status_reports(
1442 reports: &mut [OrderStatusReport],
1443 treat_expired_as_canceled: bool,
1444) {
1445 for report in reports {
1446 normalize_spot_order_status_report(report, treat_expired_as_canceled);
1447 }
1448}
1449
1450async fn wait_for_ws_setup_response(
1451 timeout: Duration,
1452 success: impl Future<Output = ()>,
1453 setup_errors: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
1454 timeout_message: &'static str,
1455) -> anyhow::Result<()> {
1456 tokio::pin!(success);
1457
1458 let result = tokio::time::timeout(timeout, async {
1459 tokio::select! {
1460 () = &mut success => Ok(()),
1461 err = setup_errors.recv() => {
1462 anyhow::bail!(
1463 "{}",
1464 err.unwrap_or_else(|| "WS setup error channel closed".to_string()),
1465 )
1466 }
1467 }
1468 })
1469 .await;
1470
1471 result.map_err(|_| anyhow::anyhow!(timeout_message))?
1472}
1473
1474#[expect(clippy::too_many_arguments)]
1475fn dispatch_ws_trading_message(
1476 msg: BinanceSpotWsTradingMessage,
1477 emitter: &ExecutionEventEmitter,
1478 http_client: &BinanceSpotHttpClient,
1479 account_id: AccountId,
1480 treat_expired_as_canceled: bool,
1481 clock: &'static AtomicTime,
1482 dispatch_state: &WsDispatchState,
1483 ws_authenticated: &tokio::sync::Notify,
1484 ws_user_data_subscribed: &tokio::sync::Notify,
1485 ws_setup_error_tx: &tokio::sync::mpsc::UnboundedSender<String>,
1486 seen_trade_ids: &std::sync::Arc<Mutex<FifoCache<(Ustr, i64), 10_000>>>,
1487) {
1488 match msg {
1489 BinanceSpotWsTradingMessage::OrderAccepted {
1490 request_id,
1491 response,
1492 } => {
1493 dispatch_state.pending_requests.remove(&request_id);
1494 log::debug!(
1495 "WS order accepted: request_id={request_id}, order_id={}",
1496 response.order_id
1497 );
1498 }
1500 BinanceSpotWsTradingMessage::OrderRejected {
1501 request_id,
1502 code,
1503 msg,
1504 } => {
1505 log::debug!("WS order rejected: request_id={request_id}, code={code}, msg={msg}");
1506 if let Some((_, pending)) = dispatch_state.pending_requests.remove(&request_id) {
1507 let code_i64 = i64::from(code);
1508 if matches!(
1509 code_i64,
1510 BINANCE_UNEXPECTED_RESPONSE_CODE | BINANCE_STATUS_UNKNOWN_CODE
1511 ) {
1512 log::warn!(
1513 "Ambiguous WS submit failure for {}, awaiting reconciliation: code={code}, msg={msg}",
1514 pending.client_order_id,
1515 );
1516 return;
1517 }
1518
1519 let identity = dispatch_state
1521 .order_identities
1522 .get(&pending.client_order_id)
1523 .map(|r| r.clone());
1524
1525 if let Some(identity) = identity {
1526 let due_post_only = code_i64 == BINANCE_GTX_ORDER_REJECT_CODE
1527 || (code_i64 == BINANCE_NEW_ORDER_REJECTED_CODE
1528 && msg == BINANCE_SPOT_POST_ONLY_REJECT_MSG);
1529 let ts_now = clock.get_time_ns();
1530 let rejected = OrderRejected::new(
1531 emitter.trader_id(),
1532 identity.strategy_id,
1533 identity.instrument_id,
1534 pending.client_order_id,
1535 account_id,
1536 Ustr::from(&format!("code={code}: {msg}")),
1537 UUID4::new(),
1538 ts_now,
1539 ts_now,
1540 false,
1541 due_post_only,
1542 );
1543 dispatch_state.cleanup_terminal(pending.client_order_id);
1544 emitter.send_order_event(OrderEventAny::Rejected(rejected));
1545 } else {
1546 log::warn!(
1547 "No order identity for {}, cannot emit OrderRejected",
1548 pending.client_order_id
1549 );
1550 }
1551 } else {
1552 log::warn!("No pending request for {request_id}, cannot emit OrderRejected");
1553 }
1554 }
1555 BinanceSpotWsTradingMessage::OrderCanceled {
1556 request_id,
1557 response,
1558 } => {
1559 dispatch_state.pending_requests.remove(&request_id);
1560 log::debug!(
1561 "WS order canceled: request_id={request_id}, order_id={}",
1562 response.order_id
1563 );
1564 }
1566 BinanceSpotWsTradingMessage::CancelRejected {
1567 request_id,
1568 code,
1569 msg,
1570 } => {
1571 log::warn!("WS cancel rejected: request_id={request_id}, code={code}, msg={msg}");
1572 if let Some((_, pending)) = dispatch_state.pending_requests.remove(&request_id)
1573 && let Some(identity) = dispatch_state
1574 .order_identities
1575 .get(&pending.client_order_id)
1576 {
1577 let ts_now = clock.get_time_ns();
1578 let rejected = OrderCancelRejected::new(
1579 emitter.trader_id(),
1580 identity.strategy_id,
1581 identity.instrument_id,
1582 pending.client_order_id,
1583 Ustr::from(&format!("code={code}: {msg}")),
1584 UUID4::new(),
1585 ts_now,
1586 ts_now,
1587 false,
1588 pending.venue_order_id,
1589 Some(account_id),
1590 );
1591 emitter.send_order_event(OrderEventAny::CancelRejected(rejected));
1592 }
1593 }
1594 BinanceSpotWsTradingMessage::CancelReplaceAccepted {
1595 request_id,
1596 cancel_response,
1597 new_order_response,
1598 } => {
1599 dispatch_state.pending_requests.remove(&request_id);
1600 log::debug!(
1601 "WS cancel-replace accepted: request_id={request_id}, \
1602 canceled_id={}, new_id={}",
1603 cancel_response.order_id,
1604 new_order_response.order_id,
1605 );
1606 }
1608 BinanceSpotWsTradingMessage::CancelReplaceRejected {
1609 request_id,
1610 code,
1611 msg,
1612 } => {
1613 log::warn!(
1614 "WS cancel-replace rejected: request_id={request_id}, code={code}, msg={msg}"
1615 );
1616
1617 if let Some((_, pending)) = dispatch_state.pending_requests.remove(&request_id)
1618 && let Some(identity) = dispatch_state
1619 .order_identities
1620 .get(&pending.client_order_id)
1621 {
1622 let ts_now = clock.get_time_ns();
1623 let rejected = OrderModifyRejected::new(
1624 emitter.trader_id(),
1625 identity.strategy_id,
1626 identity.instrument_id,
1627 pending.client_order_id,
1628 Ustr::from(&format!("code={code}: {msg}")),
1629 UUID4::new(),
1630 ts_now,
1631 ts_now,
1632 false,
1633 pending.venue_order_id,
1634 Some(account_id),
1635 );
1636 emitter.send_order_event(OrderEventAny::ModifyRejected(rejected));
1637 }
1638 }
1639 BinanceSpotWsTradingMessage::RequestFailed { request_id, msg } => {
1640 dispatch_state.pending_requests.remove(&request_id);
1641 log::error!(
1642 "WS trading request failed without structured venue response: request_id={request_id}, {msg}"
1643 );
1644 }
1645 BinanceSpotWsTradingMessage::AllOrdersCanceled {
1646 request_id,
1647 responses,
1648 } => {
1649 dispatch_state.pending_requests.remove(&request_id);
1650 log::debug!(
1651 "WS all orders canceled: request_id={request_id}, count={}",
1652 responses.len()
1653 );
1654 }
1656 BinanceSpotWsTradingMessage::UserDataSubscribed { subscription_id } => {
1657 log::debug!("User data stream subscribed: id={subscription_id}");
1658 ws_user_data_subscribed.notify_one();
1659 }
1660 BinanceSpotWsTradingMessage::ExecutionReport(report) => {
1661 let ts_init = clock.get_time_ns();
1662 dispatch_execution_report(
1663 &report,
1664 emitter,
1665 http_client,
1666 account_id,
1667 treat_expired_as_canceled,
1668 dispatch_state,
1669 seen_trade_ids,
1670 ts_init,
1671 );
1672 }
1673 BinanceSpotWsTradingMessage::AccountPosition(position) => {
1674 let ts_init = clock.get_time_ns();
1675 let state = parse_spot_account_position(&position, account_id, ts_init);
1676 emitter.send_account_state(state);
1677 }
1678 BinanceSpotWsTradingMessage::BalanceUpdate(update) => {
1679 log::debug!(
1680 "Balance update: asset={}, delta={}",
1681 update.asset,
1682 update.delta,
1683 );
1684 let http_client = http_client.clone();
1685 let emitter = emitter.clone();
1686
1687 get_runtime().spawn(async move {
1688 match http_client.request_account_state(account_id).await {
1689 Ok(state) => emitter.send_account_state(state),
1690 Err(e) => {
1691 log::error!("Failed to refresh account state after balance update: {e}");
1692 }
1693 }
1694 });
1695 }
1696 BinanceSpotWsTradingMessage::Connected => {
1697 log::debug!("WS trading API connected");
1698 }
1699 BinanceSpotWsTradingMessage::Authenticated => {
1700 log::debug!("WS trading API authenticated");
1701 ws_authenticated.notify_one();
1702 }
1703 BinanceSpotWsTradingMessage::Reconnected => {
1704 log::info!("WS trading API reconnected");
1705 }
1706 BinanceSpotWsTradingMessage::ServerShutdown { event_time } => {
1707 log::warn!(
1708 "WS trading API server shutdown notice (event_time={event_time}); reconnect expected within ~10 minutes"
1709 );
1710 }
1711 BinanceSpotWsTradingMessage::Error(err) => {
1712 log::error!("WS trading API error: {err}");
1713 let _ = ws_setup_error_tx.send(err);
1714 }
1715 }
1716}
1717
1718fn build_new_order_params(
1719 order: &impl Order,
1720 client_order_id: ClientOrderId,
1721 is_post_only: bool,
1722 is_quote_quantity: bool,
1723) -> anyhow::Result<NewOrderParams> {
1724 let binance_side = BinanceSide::try_from(order.order_side())?;
1725 let binance_order_type = order_type_to_binance_spot(order.order_type(), is_post_only)?;
1726
1727 let requires_trigger = matches!(
1728 order.order_type(),
1729 OrderType::StopMarket
1730 | OrderType::StopLimit
1731 | OrderType::MarketIfTouched
1732 | OrderType::LimitIfTouched
1733 );
1734
1735 if requires_trigger && order.trigger_price().is_none() {
1736 anyhow::bail!("Conditional orders require a trigger price");
1737 }
1738
1739 let supports_tif = matches!(
1740 binance_order_type,
1741 BinanceSpotOrderType::Limit
1742 | BinanceSpotOrderType::StopLossLimit
1743 | BinanceSpotOrderType::TakeProfitLimit
1744 );
1745 let binance_tif = if supports_tif {
1746 Some(time_in_force_to_binance_spot(order.time_in_force())?)
1747 } else {
1748 None
1749 };
1750
1751 let qty_str = order.quantity().to_string();
1752 let (base_qty, quote_qty) = if is_quote_quantity {
1753 (None, Some(qty_str))
1754 } else {
1755 (Some(qty_str), None)
1756 };
1757
1758 let client_id_str = encode_broker_id(&client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
1759
1760 Ok(NewOrderParams {
1761 symbol: order.instrument_id().symbol.to_string(),
1762 side: binance_side,
1763 order_type: binance_order_type,
1764 time_in_force: binance_tif,
1765 quantity: base_qty,
1766 quote_order_qty: quote_qty,
1767 price: order.price().map(|p| p.to_string()),
1768 new_client_order_id: Some(client_id_str),
1769 stop_price: order.trigger_price().map(|p| p.to_string()),
1770 trailing_delta: None,
1771 iceberg_qty: order.display_qty().map(|q| q.to_string()),
1772 new_order_resp_type: Some(BinanceOrderResponseType::Full),
1773 self_trade_prevention_mode: None,
1774 strategy_id: None,
1775 strategy_type: None,
1776 })
1777}
1778
1779fn build_spot_order_list_params(
1780 order_list_id: &str,
1781 orders: &[OrderAny],
1782) -> Result<NewOcoOrderListParams, String> {
1783 let has_grouped_order = orders.iter().any(is_grouped_order);
1784
1785 if has_grouped_order {
1786 return build_spot_oco_order_list_params(order_list_id, orders);
1787 }
1788
1789 Err("Binance Spot order-list submission currently supports only OCO lists".to_string())
1790}
1791
1792fn build_spot_oco_order_list_params(
1793 order_list_id: &str,
1794 orders: &[OrderAny],
1795) -> Result<NewOcoOrderListParams, String> {
1796 if orders.len() != 2 {
1797 return Err(format!(
1798 "Binance Spot OCO order-list submission requires exactly 2 orders, was {}",
1799 orders.len()
1800 ));
1801 }
1802
1803 if orders
1804 .iter()
1805 .any(|order| order.contingency_type() != Some(ContingencyType::Oco))
1806 {
1807 return Err(
1808 "Binance Spot grouped order-list submission currently supports only OCO lists"
1809 .to_string(),
1810 );
1811 }
1812
1813 let first = &orders[0];
1814 let second = &orders[1];
1815 if first.instrument_id() != second.instrument_id() {
1816 return Err("Binance Spot OCO order-list legs must use the same instrument".to_string());
1817 }
1818
1819 if first.order_side() != second.order_side() {
1820 return Err("Binance Spot OCO order-list legs must use the same side".to_string());
1821 }
1822
1823 if first.quantity() != second.quantity() {
1824 return Err("Binance Spot OCO order-list legs must use the same quantity".to_string());
1825 }
1826
1827 if first.is_quote_quantity() || second.is_quote_quantity() {
1828 return Err("Binance Spot OCO order-list legs do not support quote quantity".to_string());
1829 }
1830
1831 let mut above = None;
1832 let mut below = None;
1833
1834 for order in orders {
1835 let params =
1836 build_new_order_params(order, order.client_order_id(), order.is_post_only(), false)
1837 .map_err(|e| e.to_string())?;
1838
1839 match spot_oco_leg_position(params.side, params.order_type)? {
1840 SpotOcoLegPosition::Above => {
1841 if above.replace(params).is_some() {
1842 return Err(
1843 "Binance Spot OCO order-list resolved more than one above leg".to_string(),
1844 );
1845 }
1846 }
1847 SpotOcoLegPosition::Below => {
1848 if below.replace(params).is_some() {
1849 return Err(
1850 "Binance Spot OCO order-list resolved more than one below leg".to_string(),
1851 );
1852 }
1853 }
1854 }
1855 }
1856
1857 let above = above.ok_or_else(|| "Binance Spot OCO order-list missing above leg".to_string())?;
1858 let below = below.ok_or_else(|| "Binance Spot OCO order-list missing below leg".to_string())?;
1859 let quantity = above
1860 .quantity
1861 .clone()
1862 .ok_or_else(|| "Binance Spot OCO order-list requires base quantity".to_string())?;
1863
1864 Ok(NewOcoOrderListParams {
1865 symbol: first.instrument_id().symbol.to_string(),
1866 list_client_order_id: Some(order_list_id.to_string()),
1867 side: above.side,
1868 quantity,
1869 above_type: above.order_type,
1870 above_client_order_id: above.new_client_order_id,
1871 above_iceberg_qty: above.iceberg_qty,
1872 above_price: above.price,
1873 above_stop_price: above.stop_price,
1874 above_time_in_force: above.time_in_force,
1875 below_type: below.order_type,
1876 below_client_order_id: below.new_client_order_id,
1877 below_iceberg_qty: below.iceberg_qty,
1878 below_price: below.price,
1879 below_stop_price: below.stop_price,
1880 below_time_in_force: below.time_in_force,
1881 new_order_resp_type: Some(BinanceOrderResponseType::Full),
1882 self_trade_prevention_mode: None,
1883 })
1884}
1885
1886enum SpotOcoLegPosition {
1887 Above,
1888 Below,
1889}
1890
1891fn spot_oco_leg_position(
1892 side: BinanceSide,
1893 order_type: BinanceSpotOrderType,
1894) -> Result<SpotOcoLegPosition, String> {
1895 match (side, order_type) {
1896 (
1897 BinanceSide::Sell,
1898 BinanceSpotOrderType::LimitMaker
1899 | BinanceSpotOrderType::TakeProfit
1900 | BinanceSpotOrderType::TakeProfitLimit,
1901 )
1902 | (
1903 BinanceSide::Buy,
1904 BinanceSpotOrderType::StopLoss | BinanceSpotOrderType::StopLossLimit,
1905 ) => Ok(SpotOcoLegPosition::Above),
1906 (
1907 BinanceSide::Sell,
1908 BinanceSpotOrderType::StopLoss | BinanceSpotOrderType::StopLossLimit,
1909 )
1910 | (
1911 BinanceSide::Buy,
1912 BinanceSpotOrderType::LimitMaker
1913 | BinanceSpotOrderType::TakeProfit
1914 | BinanceSpotOrderType::TakeProfitLimit,
1915 ) => Ok(SpotOcoLegPosition::Below),
1916 (_, unsupported) => Err(format!(
1917 "Unsupported Binance Spot OCO leg order type: {unsupported:?}"
1918 )),
1919 }
1920}
1921
1922fn is_grouped_order(order: &OrderAny) -> bool {
1923 matches!(
1924 order.contingency_type(),
1925 Some(contingency_type) if contingency_type != ContingencyType::NoContingency
1926 ) || order
1927 .linked_order_ids()
1928 .is_some_and(|linked_order_ids| !linked_order_ids.is_empty())
1929}
1930
1931fn handle_spot_order_list_submit_error(
1932 event_emitter: &ExecutionEventEmitter,
1933 dispatch_state: &WsDispatchState,
1934 trader_id: TraderId,
1935 account_id: AccountId,
1936 clock: &'static AtomicTime,
1937 orders: &[OrderAny],
1938 error: BinanceSpotHttpError,
1939) -> anyhow::Result<()> {
1940 let ambiguous = matches!(
1941 error,
1942 BinanceSpotHttpError::BinanceError {
1943 code: BINANCE_UNEXPECTED_RESPONSE_CODE | BINANCE_STATUS_UNKNOWN_CODE,
1944 ..
1945 }
1946 );
1947
1948 if ambiguous {
1949 log::error!("Ambiguous order-list submit failure, awaiting reconciliation: {error}");
1950 return Err(error.into());
1951 }
1952
1953 let reject_orders = matches!(
1954 error,
1955 BinanceSpotHttpError::BinanceError { .. }
1956 | BinanceSpotHttpError::MissingCredentials
1957 | BinanceSpotHttpError::ValidationError(_)
1958 );
1959
1960 if reject_orders {
1961 let ts_now = clock.get_time_ns();
1962 let reason = format!("submit-order-list-error: {error}");
1963 for order in orders {
1964 let client_order_id = order.client_order_id();
1965 dispatch_state.cleanup_terminal(client_order_id);
1966 let rejected = OrderRejected::new(
1967 trader_id,
1968 order.strategy_id(),
1969 order.instrument_id(),
1970 client_order_id,
1971 account_id,
1972 reason.clone().into(),
1973 UUID4::new(),
1974 ts_now,
1975 ts_now,
1976 false,
1977 false,
1978 );
1979 event_emitter.send_order_event(OrderEventAny::Rejected(rejected));
1980 }
1981 } else {
1982 log::error!("Order-list submit failed, awaiting reconciliation: {error}");
1983 }
1984
1985 Err(error.into())
1986}
1987
1988fn build_cancel_order_params(cmd: &CancelOrder) -> CancelOrderParams {
1989 let order_id = cmd
1990 .venue_order_id
1991 .and_then(|id| id.inner().parse::<i64>().ok());
1992
1993 if let Some(order_id) = order_id {
1994 CancelOrderParams::by_order_id(cmd.instrument_id.symbol.to_string(), order_id)
1995 } else {
1996 let client_id_str = encode_broker_id(&cmd.client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
1997 CancelOrderParams::by_client_order_id(cmd.instrument_id.symbol.to_string(), client_id_str)
1998 }
1999}
2000
2001fn build_cancel_replace_params(
2002 cmd: &ModifyOrder,
2003 order: &impl Order,
2004 quantity: Quantity,
2005) -> anyhow::Result<CancelReplaceOrderParams> {
2006 let binance_side = BinanceSide::try_from(order.order_side())?;
2007 let binance_order_type = order_type_to_binance_spot(order.order_type(), false)?;
2008 let binance_tif = time_in_force_to_binance_spot(order.time_in_force())?;
2009
2010 let cancel_order_id: Option<i64> = cmd
2011 .venue_order_id
2012 .map(|id| {
2013 id.inner()
2014 .parse::<i64>()
2015 .map_err(|_| anyhow::anyhow!("Invalid venue order ID: {id}"))
2016 })
2017 .transpose()?;
2018
2019 let client_id_str = encode_broker_id(&cmd.client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
2020
2021 Ok(CancelReplaceOrderParams {
2022 symbol: cmd.instrument_id.symbol.to_string(),
2023 side: binance_side,
2024 order_type: binance_order_type,
2025 cancel_replace_mode: BinanceCancelReplaceMode::StopOnFailure,
2026 time_in_force: Some(binance_tif),
2027 quantity: Some(quantity.to_string()),
2028 quote_order_qty: None,
2029 price: cmd.price.map(|p| p.to_string()),
2030 cancel_order_id,
2031 cancel_orig_client_order_id: if cancel_order_id.is_none() {
2032 Some(client_id_str.clone())
2033 } else {
2034 None
2035 },
2036 new_client_order_id: Some(client_id_str),
2037 stop_price: None,
2038 trailing_delta: None,
2039 iceberg_qty: None,
2040 new_order_resp_type: Some(BinanceOrderResponseType::Full),
2041 self_trade_prevention_mode: None,
2042 })
2043}
2044
2045#[expect(clippy::too_many_arguments)]
2050fn dispatch_execution_report(
2051 report: &BinanceSpotExecutionReport,
2052 emitter: &ExecutionEventEmitter,
2053 http_client: &BinanceSpotHttpClient,
2054 account_id: AccountId,
2055 treat_expired_as_canceled: bool,
2056 dispatch_state: &WsDispatchState,
2057 seen_trade_ids: &std::sync::Arc<Mutex<FifoCache<(Ustr, i64), 10_000>>>,
2058 ts_init: UnixNanos,
2059) {
2060 let symbol = report.symbol;
2061 let instrument_id = InstrumentId::new(symbol.into(), *BINANCE_VENUE);
2062 let (price_precision, size_precision) = http_client
2063 .get_instrument(&symbol)
2064 .map_or((8, 8), |i| (i.price_precision(), i.size_precision()));
2065
2066 let client_order_id = ClientOrderId::new(decode_broker_id(
2067 &report.client_order_id,
2068 BINANCE_NAUTILUS_SPOT_BROKER_ID,
2069 ));
2070
2071 let identity = dispatch_state
2072 .order_identities
2073 .get(&client_order_id)
2074 .map(|r| r.clone());
2075
2076 if let Some(identity) = identity {
2077 dispatch_tracked_execution_report(
2078 report,
2079 emitter,
2080 account_id,
2081 treat_expired_as_canceled,
2082 dispatch_state,
2083 seen_trade_ids,
2084 client_order_id,
2085 &identity,
2086 instrument_id,
2087 price_precision,
2088 size_precision,
2089 ts_init,
2090 );
2091 } else {
2092 dispatch_untracked_execution_report(
2093 report,
2094 emitter,
2095 http_client,
2096 account_id,
2097 treat_expired_as_canceled,
2098 seen_trade_ids,
2099 instrument_id,
2100 price_precision,
2101 size_precision,
2102 ts_init,
2103 );
2104 }
2105}
2106
2107#[expect(clippy::too_many_arguments)]
2109fn dispatch_tracked_execution_report(
2110 report: &BinanceSpotExecutionReport,
2111 emitter: &ExecutionEventEmitter,
2112 account_id: AccountId,
2113 treat_expired_as_canceled: bool,
2114 state: &WsDispatchState,
2115 seen_trade_ids: &std::sync::Arc<Mutex<FifoCache<(Ustr, i64), 10_000>>>,
2116 client_order_id: ClientOrderId,
2117 identity: &OrderIdentity,
2118 instrument_id: InstrumentId,
2119 price_precision: u8,
2120 size_precision: u8,
2121 ts_init: UnixNanos,
2122) {
2123 let venue_order_id = VenueOrderId::new(report.order_id.to_string());
2124 let ts_event = UnixNanos::from_millis(report.event_time as u64);
2125
2126 match report.execution_type {
2127 BinanceSpotExecutionType::New => {
2128 if state.has_filled(&client_order_id) {
2129 log::debug!("Skipping New for already-filled {client_order_id}");
2130 return;
2131 }
2132
2133 if state.has_emitted_accepted(&client_order_id) {
2134 let Some(price) = parse_spot_execution_report_price(
2136 report,
2137 &report.price,
2138 price_precision,
2139 "price",
2140 ) else {
2141 return;
2142 };
2143 let Some(quantity) = parse_spot_execution_report_quantity(
2144 report,
2145 &report.original_qty,
2146 size_precision,
2147 "original_qty",
2148 ) else {
2149 return;
2150 };
2151 let Some(stop_price) =
2152 parse_spot_execution_report_decimal(report, &report.stop_price, "stop_price")
2153 else {
2154 return;
2155 };
2156 let trigger = if stop_price > Decimal::ZERO {
2157 let Some(trigger_price) = parse_spot_execution_report_price(
2158 report,
2159 &report.stop_price,
2160 price_precision,
2161 "stop_price",
2162 ) else {
2163 return;
2164 };
2165 Some(trigger_price)
2166 } else {
2167 None
2168 };
2169 let updated = OrderUpdated::new(
2170 emitter.trader_id(),
2171 identity.strategy_id,
2172 identity.instrument_id,
2173 client_order_id,
2174 quantity,
2175 UUID4::new(),
2176 ts_event,
2177 ts_init,
2178 false,
2179 Some(venue_order_id),
2180 Some(account_id),
2181 Some(price),
2182 trigger,
2183 None, false, );
2186 emitter.send_order_event(OrderEventAny::Updated(updated));
2187 return;
2188 }
2189 state.insert_accepted(client_order_id);
2190 let accepted = OrderAccepted::new(
2191 emitter.trader_id(),
2192 identity.strategy_id,
2193 identity.instrument_id,
2194 client_order_id,
2195 venue_order_id,
2196 account_id,
2197 UUID4::new(),
2198 ts_event,
2199 ts_init,
2200 false,
2201 );
2202 emitter.send_order_event(OrderEventAny::Accepted(accepted));
2203 }
2204 BinanceSpotExecutionType::Trade => {
2205 let dedup_key = (report.symbol, report.trade_id);
2206 let mut guard = seen_trade_ids.lock().expect(MUTEX_POISONED);
2207 let is_duplicate = guard.contains(&dedup_key);
2208 guard.add(dedup_key);
2209 drop(guard);
2210
2211 if is_duplicate {
2212 log::debug!(
2213 "Duplicate trade_id={} for {}, skipping",
2214 report.trade_id,
2215 report.symbol
2216 );
2217 return;
2218 }
2219
2220 ensure_accepted_emitted(
2221 client_order_id,
2222 account_id,
2223 venue_order_id,
2224 identity,
2225 emitter,
2226 state,
2227 ts_init,
2228 );
2229
2230 let Some(last_qty) = parse_spot_execution_report_quantity(
2231 report,
2232 &report.last_filled_qty,
2233 size_precision,
2234 "last_filled_qty",
2235 ) else {
2236 return;
2237 };
2238 let Some(last_px) = parse_spot_execution_report_price(
2239 report,
2240 &report.last_filled_price,
2241 price_precision,
2242 "last_filled_price",
2243 ) else {
2244 return;
2245 };
2246 let Some(commission) =
2247 parse_spot_execution_report_decimal(report, &report.commission, "commission")
2248 else {
2249 return;
2250 };
2251 let commission_currency = report
2252 .commission_asset
2253 .as_ref()
2254 .map_or_else(Currency::USDT, |a| {
2255 Currency::get_or_create_crypto(a.as_str())
2256 });
2257 let commission_money = match Money::from_decimal(commission, commission_currency) {
2258 Ok(money) => money,
2259 Err(e) => {
2260 log::warn!(
2261 "Failed to build Spot commission money for symbol={}, order_id={}, \
2262 trade_id={}: {e}",
2263 report.symbol,
2264 report.order_id,
2265 report.trade_id,
2266 );
2267 return;
2268 }
2269 };
2270
2271 let liquidity_side = if report.is_maker {
2272 LiquiditySide::Maker
2273 } else {
2274 LiquiditySide::Taker
2275 };
2276
2277 let filled = OrderFilled::new(
2278 emitter.trader_id(),
2279 identity.strategy_id,
2280 instrument_id,
2281 client_order_id,
2282 venue_order_id,
2283 account_id,
2284 TradeId::new(report.trade_id.to_string()),
2285 identity.order_side,
2286 identity.order_type,
2287 last_qty,
2288 last_px,
2289 commission_currency,
2290 liquidity_side,
2291 UUID4::new(),
2292 ts_event,
2293 ts_init,
2294 false,
2295 None,
2296 Some(commission_money),
2297 );
2298
2299 state.insert_filled(client_order_id);
2300 emitter.send_order_event(OrderEventAny::Filled(filled));
2301
2302 let cumulative_qty = parse_spot_execution_report_decimal(
2303 report,
2304 &report.cumulative_filled_qty,
2305 "cumulative_filled_qty",
2306 );
2307 let original_qty =
2308 parse_spot_execution_report_decimal(report, &report.original_qty, "original_qty");
2309 if let (Some(original_qty), Some(cumulative_qty)) = (original_qty, cumulative_qty)
2310 && original_qty <= cumulative_qty
2311 {
2312 state.cleanup_terminal(client_order_id);
2313 }
2314 }
2315 BinanceSpotExecutionType::Replaced => {
2316 log::debug!(
2319 "Order replaced: client_order_id={client_order_id}, venue_order_id={venue_order_id}"
2320 );
2321 }
2322 BinanceSpotExecutionType::Canceled | BinanceSpotExecutionType::TradePrevention => {
2323 ensure_accepted_emitted(
2324 client_order_id,
2325 account_id,
2326 venue_order_id,
2327 identity,
2328 emitter,
2329 state,
2330 ts_init,
2331 );
2332 let canceled = OrderCanceled::new(
2333 emitter.trader_id(),
2334 identity.strategy_id,
2335 identity.instrument_id,
2336 client_order_id,
2337 UUID4::new(),
2338 ts_event,
2339 ts_init,
2340 false,
2341 Some(venue_order_id),
2342 Some(account_id),
2343 );
2344 state.cleanup_terminal(client_order_id);
2345 emitter.send_order_event(OrderEventAny::Canceled(canceled));
2346 }
2347 BinanceSpotExecutionType::Expired => {
2348 ensure_accepted_emitted(
2349 client_order_id,
2350 account_id,
2351 venue_order_id,
2352 identity,
2353 emitter,
2354 state,
2355 ts_init,
2356 );
2357 state.cleanup_terminal(client_order_id);
2358
2359 if treat_expired_as_canceled {
2360 let canceled = OrderCanceled::new(
2361 emitter.trader_id(),
2362 identity.strategy_id,
2363 identity.instrument_id,
2364 client_order_id,
2365 UUID4::new(),
2366 ts_event,
2367 ts_init,
2368 false,
2369 Some(venue_order_id),
2370 Some(account_id),
2371 );
2372 emitter.send_order_event(OrderEventAny::Canceled(canceled));
2373 } else {
2374 let expired = OrderExpired::new(
2375 emitter.trader_id(),
2376 identity.strategy_id,
2377 identity.instrument_id,
2378 client_order_id,
2379 UUID4::new(),
2380 ts_event,
2381 ts_init,
2382 false,
2383 Some(venue_order_id),
2384 Some(account_id),
2385 );
2386 emitter.send_order_event(OrderEventAny::Expired(expired));
2387 }
2388 }
2389 BinanceSpotExecutionType::Rejected => {
2390 let reason = if report.reject_reason.is_empty() {
2391 Ustr::from("Order rejected by venue")
2392 } else {
2393 Ustr::from(&report.reject_reason)
2394 };
2395 let due_post_only = report.time_in_force == BinanceTimeInForce::Gtx
2396 || (report.order_type == "LIMIT_MAKER"
2397 && (report.reject_reason.is_empty() || report.reject_reason == "NONE"));
2398 state.cleanup_terminal(client_order_id);
2399 emitter.emit_order_rejected_event(
2400 identity.strategy_id,
2401 identity.instrument_id,
2402 client_order_id,
2403 reason.as_str(),
2404 ts_init,
2405 due_post_only,
2406 );
2407 }
2408 }
2409}
2410
2411fn parse_spot_execution_report_quantity(
2412 report: &BinanceSpotExecutionReport,
2413 raw: &str,
2414 precision: u8,
2415 field: &str,
2416) -> Option<Quantity> {
2417 match parse_required_quantity_at_precision(raw, precision, field) {
2418 Ok(value) => Some(value),
2419 Err(e) => {
2420 warn_invalid_spot_execution_report_field(report, field, &e);
2421 None
2422 }
2423 }
2424}
2425
2426fn parse_spot_execution_report_price(
2427 report: &BinanceSpotExecutionReport,
2428 raw: &str,
2429 precision: u8,
2430 field: &str,
2431) -> Option<Price> {
2432 match parse_required_price_at_precision(raw, precision, field) {
2433 Ok(value) => Some(value),
2434 Err(e) => {
2435 warn_invalid_spot_execution_report_field(report, field, &e);
2436 None
2437 }
2438 }
2439}
2440
2441fn parse_spot_execution_report_decimal(
2442 report: &BinanceSpotExecutionReport,
2443 raw: &str,
2444 field: &str,
2445) -> Option<Decimal> {
2446 match parse_required_decimal(raw, field) {
2447 Ok(value) => Some(value),
2448 Err(e) => {
2449 warn_invalid_spot_execution_report_field(report, field, &e);
2450 None
2451 }
2452 }
2453}
2454
2455fn warn_invalid_spot_execution_report_field(
2456 report: &BinanceSpotExecutionReport,
2457 field: &str,
2458 error: &anyhow::Error,
2459) {
2460 log::warn!(
2461 "Failed to parse Spot execution report {field} for symbol={}, order_id={}, \
2462 trade_id={}, client_order_id={}: {error}",
2463 report.symbol,
2464 report.order_id,
2465 report.trade_id,
2466 report.client_order_id,
2467 );
2468}
2469
2470#[expect(clippy::too_many_arguments)]
2472fn dispatch_untracked_execution_report(
2473 report: &BinanceSpotExecutionReport,
2474 emitter: &ExecutionEventEmitter,
2475 _http_client: &BinanceSpotHttpClient,
2476 account_id: AccountId,
2477 treat_expired_as_canceled: bool,
2478 seen_trade_ids: &std::sync::Arc<Mutex<FifoCache<(Ustr, i64), 10_000>>>,
2479 instrument_id: InstrumentId,
2480 price_precision: u8,
2481 size_precision: u8,
2482 ts_init: UnixNanos,
2483) {
2484 match report.execution_type {
2485 BinanceSpotExecutionType::Trade => {
2486 let dedup_key = (report.symbol, report.trade_id);
2487 let mut guard = seen_trade_ids.lock().expect(MUTEX_POISONED);
2488 let is_duplicate = guard.contains(&dedup_key);
2489 guard.add(dedup_key);
2490 drop(guard);
2491
2492 if is_duplicate {
2493 log::debug!(
2494 "Duplicate trade_id={} for {}, skipping",
2495 report.trade_id,
2496 report.symbol
2497 );
2498 return;
2499 }
2500
2501 match parse_spot_exec_report_to_order_status(
2502 report,
2503 instrument_id,
2504 price_precision,
2505 size_precision,
2506 account_id,
2507 treat_expired_as_canceled,
2508 ts_init,
2509 ) {
2510 Ok(status) => emitter.send_order_status_report(status),
2511 Err(e) => log::error!("Failed to parse order status report: {e}"),
2512 }
2513
2514 match parse_spot_exec_report_to_fill(
2515 report,
2516 instrument_id,
2517 price_precision,
2518 size_precision,
2519 account_id,
2520 ts_init,
2521 ) {
2522 Ok(fill) => emitter.send_fill_report(fill),
2523 Err(e) => log::error!("Failed to parse fill report: {e}"),
2524 }
2525 }
2526 BinanceSpotExecutionType::New
2527 | BinanceSpotExecutionType::Canceled
2528 | BinanceSpotExecutionType::Replaced
2529 | BinanceSpotExecutionType::Rejected
2530 | BinanceSpotExecutionType::Expired
2531 | BinanceSpotExecutionType::TradePrevention => {
2532 match parse_spot_exec_report_to_order_status(
2533 report,
2534 instrument_id,
2535 price_precision,
2536 size_precision,
2537 account_id,
2538 treat_expired_as_canceled,
2539 ts_init,
2540 ) {
2541 Ok(status) => emitter.send_order_status_report(status),
2542 Err(e) => log::error!("Failed to parse order status report: {e}"),
2543 }
2544 }
2545 }
2546}
2547
2548fn is_spot_post_only_rejection(error: &BinanceSpotHttpError) -> bool {
2550 match error {
2551 BinanceSpotHttpError::BinanceError { code, message } => {
2552 *code == BINANCE_GTX_ORDER_REJECT_CODE
2553 || (*code == BINANCE_NEW_ORDER_REJECTED_CODE
2554 && message == BINANCE_SPOT_POST_ONLY_REJECT_MSG)
2555 }
2556 _ => false,
2557 }
2558}
2559
2560fn is_structured_venue_rejection(err: &anyhow::Error) -> bool {
2561 err.downcast_ref::<BinanceSpotHttpError>()
2562 .is_some_and(|be| matches!(be, BinanceSpotHttpError::BinanceError { .. }))
2563}
2564
2565fn is_ambiguous_submit_error(err: &anyhow::Error) -> bool {
2566 err.downcast_ref::<BinanceSpotHttpError>()
2567 .is_some_and(|be| {
2568 matches!(
2569 be,
2570 BinanceSpotHttpError::BinanceError {
2571 code: BINANCE_UNEXPECTED_RESPONSE_CODE | BINANCE_STATUS_UNKNOWN_CODE,
2572 ..
2573 }
2574 )
2575 })
2576}
2577
2578fn is_local_command_failure(err: &anyhow::Error) -> bool {
2579 err.downcast_ref::<BinanceSpotHttpError>()
2580 .is_some_and(is_local_http_command_failure)
2581}
2582
2583fn is_local_http_command_failure(err: &BinanceSpotHttpError) -> bool {
2584 matches!(
2585 err,
2586 BinanceSpotHttpError::MissingCredentials | BinanceSpotHttpError::ValidationError(_)
2587 )
2588}
2589
2590#[cfg(test)]
2591mod tests {
2592 use nautilus_common::messages::ExecutionEvent;
2593 use nautilus_core::time::get_atomic_clock_realtime;
2594 use nautilus_model::{
2595 enums::{AccountType, LiquiditySide, OrderSide},
2596 identifiers::{StrategyId, TraderId},
2597 };
2598 use rstest::rstest;
2599
2600 use super::*;
2601 use crate::common::enums::BinanceEnvironment;
2602
2603 #[rstest]
2604 fn test_dispatch_ws_trading_message_emits_cancel_rejected_and_clears_pending_request() {
2605 let clock = get_atomic_clock_realtime();
2606 let (emitter, mut rx) = create_test_emitter(clock);
2607 let http_client = create_test_http_client(clock);
2608 let dispatch_state = create_tracked_dispatch_state(
2609 ClientOrderId::from("TEST"),
2610 InstrumentId::from("BTCUSDT.BINANCE"),
2611 );
2612 let ws_authenticated = tokio::sync::Notify::new();
2613 let ws_user_data_subscribed = tokio::sync::Notify::new();
2614 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
2615 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
2616
2617 dispatch_state.pending_requests.insert(
2618 "req-cancel".to_string(),
2619 PendingRequest {
2620 client_order_id: ClientOrderId::from("TEST"),
2621 venue_order_id: Some(VenueOrderId::from("12345")),
2622 operation: PendingOperation::Cancel,
2623 },
2624 );
2625
2626 dispatch_ws_trading_message(
2627 BinanceSpotWsTradingMessage::CancelRejected {
2628 request_id: "req-cancel".to_string(),
2629 code: -2011,
2630 msg: "Unknown order sent".to_string(),
2631 },
2632 &emitter,
2633 &http_client,
2634 AccountId::from("BINANCE-001"),
2635 false,
2636 clock,
2637 &dispatch_state,
2638 &ws_authenticated,
2639 &ws_user_data_subscribed,
2640 &ws_setup_error_tx,
2641 &seen_trade_ids,
2642 );
2643
2644 assert!(dispatch_state.pending_requests.get("req-cancel").is_none());
2645
2646 match rx
2647 .try_recv()
2648 .expect("Cancel rejection event should be emitted")
2649 {
2650 ExecutionEvent::Order(OrderEventAny::CancelRejected(event)) => {
2651 assert_eq!(event.client_order_id, ClientOrderId::from("TEST"));
2652 assert_eq!(event.account_id, Some(AccountId::from("BINANCE-001")));
2653 assert!(event.reason.as_str().contains("code=-2011"));
2654 }
2655 other => panic!("Expected CancelRejected event, was {other:?}"),
2656 }
2657 }
2658
2659 #[rstest]
2660 #[case(
2661 BINANCE_UNEXPECTED_RESPONSE_CODE,
2662 "An unexpected response was received from the message bus"
2663 )]
2664 #[case(
2665 BINANCE_STATUS_UNKNOWN_CODE,
2666 "Timeout waiting for response from backend server"
2667 )]
2668 fn test_dispatch_ws_trading_message_unknown_status_keeps_order_registered(
2669 #[case] code: i64,
2670 #[case] msg: &str,
2671 ) {
2672 let clock = get_atomic_clock_realtime();
2673 let (emitter, mut rx) = create_test_emitter(clock);
2674 let http_client = create_test_http_client(clock);
2675 let client_order_id = ClientOrderId::from("TEST");
2676 let dispatch_state =
2677 create_tracked_dispatch_state(client_order_id, InstrumentId::from("BTCUSDT.BINANCE"));
2678 let ws_authenticated = tokio::sync::Notify::new();
2679 let ws_user_data_subscribed = tokio::sync::Notify::new();
2680 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
2681 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
2682
2683 dispatch_state.pending_requests.insert(
2684 "req-submit".to_string(),
2685 PendingRequest {
2686 client_order_id,
2687 venue_order_id: None,
2688 operation: PendingOperation::Place,
2689 },
2690 );
2691
2692 dispatch_ws_trading_message(
2693 BinanceSpotWsTradingMessage::OrderRejected {
2694 request_id: "req-submit".to_string(),
2695 code: code as i32,
2696 msg: msg.to_string(),
2697 },
2698 &emitter,
2699 &http_client,
2700 AccountId::from("BINANCE-001"),
2701 false,
2702 clock,
2703 &dispatch_state,
2704 &ws_authenticated,
2705 &ws_user_data_subscribed,
2706 &ws_setup_error_tx,
2707 &seen_trade_ids,
2708 );
2709
2710 assert!(dispatch_state.pending_requests.get("req-submit").is_none());
2711 assert!(
2712 dispatch_state
2713 .order_identities
2714 .get(&client_order_id)
2715 .is_some()
2716 );
2717 assert!(rx.try_recv().is_err());
2718 }
2719
2720 #[rstest]
2721 fn test_dispatch_ws_trading_message_definite_submit_rejection_emits_order_rejected() {
2722 let clock = get_atomic_clock_realtime();
2723 let (emitter, mut rx) = create_test_emitter(clock);
2724 let http_client = create_test_http_client(clock);
2725 let client_order_id = ClientOrderId::from("TEST");
2726 let dispatch_state =
2727 create_tracked_dispatch_state(client_order_id, InstrumentId::from("BTCUSDT.BINANCE"));
2728 let ws_authenticated = tokio::sync::Notify::new();
2729 let ws_user_data_subscribed = tokio::sync::Notify::new();
2730 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
2731 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
2732
2733 dispatch_state.pending_requests.insert(
2734 "req-submit".to_string(),
2735 PendingRequest {
2736 client_order_id,
2737 venue_order_id: None,
2738 operation: PendingOperation::Place,
2739 },
2740 );
2741
2742 dispatch_ws_trading_message(
2743 BinanceSpotWsTradingMessage::OrderRejected {
2744 request_id: "req-submit".to_string(),
2745 code: BINANCE_NEW_ORDER_REJECTED_CODE as i32,
2746 msg: BINANCE_SPOT_POST_ONLY_REJECT_MSG.to_string(),
2747 },
2748 &emitter,
2749 &http_client,
2750 AccountId::from("BINANCE-001"),
2751 false,
2752 clock,
2753 &dispatch_state,
2754 &ws_authenticated,
2755 &ws_user_data_subscribed,
2756 &ws_setup_error_tx,
2757 &seen_trade_ids,
2758 );
2759
2760 assert!(dispatch_state.pending_requests.get("req-submit").is_none());
2761 assert!(
2762 dispatch_state
2763 .order_identities
2764 .get(&client_order_id)
2765 .is_none()
2766 );
2767
2768 match rx
2769 .try_recv()
2770 .expect("OrderRejected event should be emitted")
2771 {
2772 ExecutionEvent::Order(OrderEventAny::Rejected(event)) => {
2773 assert_eq!(event.client_order_id, client_order_id);
2774 assert_eq!(event.account_id, AccountId::from("BINANCE-001"));
2775 assert!(event.reason.as_str().contains("code=-2010"));
2776 assert!(event.due_post_only);
2777 }
2778 other => panic!("Expected OrderRejected event, was {other:?}"),
2779 }
2780 }
2781
2782 #[rstest]
2783 fn test_dispatch_ws_trading_message_emits_modify_rejected_and_clears_pending_request() {
2784 let clock = get_atomic_clock_realtime();
2785 let (emitter, mut rx) = create_test_emitter(clock);
2786 let http_client = create_test_http_client(clock);
2787 let dispatch_state = create_tracked_dispatch_state(
2788 ClientOrderId::from("TEST"),
2789 InstrumentId::from("BTCUSDT.BINANCE"),
2790 );
2791 let ws_authenticated = tokio::sync::Notify::new();
2792 let ws_user_data_subscribed = tokio::sync::Notify::new();
2793 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
2794 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
2795
2796 dispatch_state.pending_requests.insert(
2797 "req-modify".to_string(),
2798 PendingRequest {
2799 client_order_id: ClientOrderId::from("TEST"),
2800 venue_order_id: Some(VenueOrderId::from("12345")),
2801 operation: PendingOperation::Modify,
2802 },
2803 );
2804
2805 dispatch_ws_trading_message(
2806 BinanceSpotWsTradingMessage::CancelReplaceRejected {
2807 request_id: "req-modify".to_string(),
2808 code: -2021,
2809 msg: "Order cancel-replace partially failed".to_string(),
2810 },
2811 &emitter,
2812 &http_client,
2813 AccountId::from("BINANCE-001"),
2814 false,
2815 clock,
2816 &dispatch_state,
2817 &ws_authenticated,
2818 &ws_user_data_subscribed,
2819 &ws_setup_error_tx,
2820 &seen_trade_ids,
2821 );
2822
2823 assert!(dispatch_state.pending_requests.get("req-modify").is_none());
2824
2825 match rx
2826 .try_recv()
2827 .expect("Modify rejection event should be emitted")
2828 {
2829 ExecutionEvent::Order(OrderEventAny::ModifyRejected(event)) => {
2830 assert_eq!(event.client_order_id, ClientOrderId::from("TEST"));
2831 assert_eq!(event.account_id, Some(AccountId::from("BINANCE-001")));
2832 assert!(event.reason.as_str().contains("code=-2021"));
2833 }
2834 other => panic!("Expected ModifyRejected event, was {other:?}"),
2835 }
2836 }
2837
2838 fn create_test_emitter(
2839 clock: &'static AtomicTime,
2840 ) -> (
2841 ExecutionEventEmitter,
2842 tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2843 ) {
2844 let mut emitter = ExecutionEventEmitter::new(
2845 clock,
2846 TraderId::from("TESTER-001"),
2847 AccountId::from("BINANCE-001"),
2848 AccountType::Cash,
2849 None,
2850 );
2851 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2852 emitter.set_sender(tx);
2853 (emitter, rx)
2854 }
2855
2856 fn create_test_http_client(clock: &'static AtomicTime) -> BinanceSpotHttpClient {
2857 BinanceSpotHttpClient::new(
2858 BinanceEnvironment::Live,
2859 clock,
2860 None,
2861 None,
2862 None,
2863 None,
2864 None,
2865 None,
2866 )
2867 .expect("Test HTTP client should be created")
2868 }
2869
2870 fn create_tracked_dispatch_state(
2871 client_order_id: ClientOrderId,
2872 instrument_id: InstrumentId,
2873 ) -> WsDispatchState {
2874 let dispatch_state = WsDispatchState::default();
2875 dispatch_state.order_identities.insert(
2876 client_order_id,
2877 OrderIdentity {
2878 instrument_id,
2879 strategy_id: StrategyId::from("TEST-STRATEGY"),
2880 order_side: OrderSide::Buy,
2881 order_type: OrderType::Limit,
2882 price: None,
2883 quantity: Quantity::from("1"),
2884 },
2885 );
2886 dispatch_state
2887 }
2888
2889 #[rstest]
2890 #[case::gtx(
2891 BinanceSpotHttpError::BinanceError {
2892 code: BINANCE_GTX_ORDER_REJECT_CODE,
2893 message: "Order would immediately trigger.".to_string(),
2894 },
2895 true,
2896 )]
2897 #[case::spot_post_only(
2898 BinanceSpotHttpError::BinanceError {
2899 code: BINANCE_NEW_ORDER_REJECTED_CODE,
2900 message: BINANCE_SPOT_POST_ONLY_REJECT_MSG.to_string(),
2901 },
2902 true,
2903 )]
2904 #[case::new_order_rejected_other_message(
2905 BinanceSpotHttpError::BinanceError {
2906 code: BINANCE_NEW_ORDER_REJECTED_CODE,
2907 message: "Insufficient balance.".to_string(),
2908 },
2909 false,
2910 )]
2911 #[case::unrelated_code(
2912 BinanceSpotHttpError::BinanceError {
2913 code: -2011,
2914 message: "Unknown order sent.".to_string(),
2915 },
2916 false,
2917 )]
2918 #[case::non_binance_error(
2919 BinanceSpotHttpError::NetworkError("connection reset".to_string()),
2920 false,
2921 )]
2922 fn test_is_spot_post_only_rejection(
2923 #[case] error: BinanceSpotHttpError,
2924 #[case] expected: bool,
2925 ) {
2926 assert_eq!(is_spot_post_only_rejection(&error), expected);
2927 }
2928
2929 #[rstest]
2930 #[case(BINANCE_UNEXPECTED_RESPONSE_CODE)]
2931 #[case(BINANCE_STATUS_UNKNOWN_CODE)]
2932 fn test_unknown_status_submit_error_is_ambiguous(#[case] code: i64) {
2933 let err = anyhow::Error::new(BinanceSpotHttpError::BinanceError {
2934 code,
2935 message: "test error".to_string(),
2936 });
2937 assert!(is_ambiguous_submit_error(&err));
2938 assert!(is_structured_venue_rejection(&err));
2939 }
2940
2941 #[rstest]
2942 fn test_other_structured_submit_error_is_not_ambiguous() {
2943 let err = anyhow::Error::new(BinanceSpotHttpError::BinanceError {
2944 code: BINANCE_GTX_ORDER_REJECT_CODE,
2945 message: "test error".to_string(),
2946 });
2947 assert!(!is_ambiguous_submit_error(&err));
2948 assert!(is_structured_venue_rejection(&err));
2949 }
2950
2951 #[rstest]
2952 fn test_dispatch_tracked_execution_report_trade_dedup() {
2953 let clock = get_atomic_clock_realtime();
2954 let (emitter, mut rx) = create_test_emitter(clock);
2955 let http_client = create_test_http_client(clock);
2956 let client_order_id = ClientOrderId::from("x-TD67BGP9-T0000000000000");
2957 let dispatch_state = create_tracked_dispatch_state(
2958 ClientOrderId::from("O-20200101-000000-000-000-0"),
2959 InstrumentId::from("ETHUSDT.BINANCE"),
2960 );
2961 let ws_authenticated = tokio::sync::Notify::new();
2962 let ws_user_data_subscribed = tokio::sync::Notify::new();
2963 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
2964 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
2965
2966 let trade_json = crate::common::testing::load_fixture_string(
2967 "spot/user_data_json/execution_report_trade.json",
2968 );
2969 let report: BinanceSpotExecutionReport = serde_json::from_str(&trade_json).unwrap();
2970
2971 dispatch_ws_trading_message(
2972 BinanceSpotWsTradingMessage::ExecutionReport(Box::new(report.clone())),
2973 &emitter,
2974 &http_client,
2975 AccountId::from("BINANCE-001"),
2976 false,
2977 clock,
2978 &dispatch_state,
2979 &ws_authenticated,
2980 &ws_user_data_subscribed,
2981 &ws_setup_error_tx,
2982 &seen_trade_ids,
2983 );
2984 dispatch_ws_trading_message(
2985 BinanceSpotWsTradingMessage::ExecutionReport(Box::new(report)),
2986 &emitter,
2987 &http_client,
2988 AccountId::from("BINANCE-001"),
2989 false,
2990 clock,
2991 &dispatch_state,
2992 &ws_authenticated,
2993 &ws_user_data_subscribed,
2994 &ws_setup_error_tx,
2995 &seen_trade_ids,
2996 );
2997
2998 let mut events = Vec::new();
2999 while let Ok(event) = rx.try_recv() {
3000 events.push(event);
3001 }
3002
3003 let fills: Vec<_> = events
3004 .iter()
3005 .filter(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Filled(_))))
3006 .collect();
3007 assert_eq!(fills.len(), 1, "duplicate trade should be deduped");
3008
3009 match fills[0] {
3010 ExecutionEvent::Order(OrderEventAny::Filled(fill)) => {
3011 assert_eq!(
3012 fill.client_order_id,
3013 ClientOrderId::from("O-20200101-000000-000-000-0"),
3014 );
3015 assert_eq!(fill.trade_id, TradeId::new("98765432"));
3016 assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
3017 }
3018 _ => unreachable!(),
3019 }
3020 let _ = client_order_id;
3021 }
3022
3023 #[rstest]
3024 fn test_dispatch_tracked_execution_report_invalid_fill_qty_skips_filled_event() {
3025 let clock = get_atomic_clock_realtime();
3026 let (emitter, mut rx) = create_test_emitter(clock);
3027 let http_client = create_test_http_client(clock);
3028 let dispatch_state = create_tracked_dispatch_state(
3029 ClientOrderId::from("O-20200101-000000-000-000-0"),
3030 InstrumentId::from("ETHUSDT.BINANCE"),
3031 );
3032 let ws_authenticated = tokio::sync::Notify::new();
3033 let ws_user_data_subscribed = tokio::sync::Notify::new();
3034 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
3035 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
3036
3037 let trade_json = crate::common::testing::load_fixture_string(
3038 "spot/user_data_json/execution_report_trade.json",
3039 );
3040 let mut report: BinanceSpotExecutionReport = serde_json::from_str(&trade_json).unwrap();
3041 report.last_filled_qty = "not-a-number".to_string();
3042
3043 dispatch_ws_trading_message(
3044 BinanceSpotWsTradingMessage::ExecutionReport(Box::new(report)),
3045 &emitter,
3046 &http_client,
3047 AccountId::from("BINANCE-001"),
3048 false,
3049 clock,
3050 &dispatch_state,
3051 &ws_authenticated,
3052 &ws_user_data_subscribed,
3053 &ws_setup_error_tx,
3054 &seen_trade_ids,
3055 );
3056
3057 let mut events = Vec::new();
3058 while let Ok(event) = rx.try_recv() {
3059 events.push(event);
3060 }
3061
3062 assert!(
3063 events
3064 .iter()
3065 .all(|e| !matches!(e, ExecutionEvent::Order(OrderEventAny::Filled(_)))),
3066 "invalid fill quantity must not emit OrderFilled",
3067 );
3068 }
3069
3070 #[rstest]
3071 #[case::as_expired(false, OrderStatus::Expired)]
3072 #[case::as_canceled(true, OrderStatus::Canceled)]
3073 fn test_normalize_spot_order_status_report_expired_respects_config(
3074 #[case] treat_expired_as_canceled: bool,
3075 #[case] expected: OrderStatus,
3076 ) {
3077 let clock = get_atomic_clock_realtime();
3078 let json = crate::common::testing::load_fixture_string(
3079 "spot/user_data_json/execution_report_expired.json",
3080 );
3081 let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
3082 let mut report = parse_spot_exec_report_to_order_status(
3083 &msg,
3084 InstrumentId::from("ETHUSDT.BINANCE"),
3085 2,
3086 5,
3087 AccountId::from("BINANCE-001"),
3088 false,
3089 clock.get_time_ns(),
3090 )
3091 .unwrap();
3092 let mut reports = vec![report.clone()];
3093
3094 normalize_spot_order_status_report(&mut report, treat_expired_as_canceled);
3095 normalize_spot_order_status_reports(&mut reports, treat_expired_as_canceled);
3096
3097 assert_eq!(report.order_status, expected);
3098 assert_eq!(reports[0].order_status, expected);
3099 }
3100
3101 #[rstest]
3102 #[case::as_expired(false)]
3103 #[case::as_canceled(true)]
3104 fn test_dispatch_tracked_execution_report_expired_respects_config(
3105 #[case] treat_expired_as_canceled: bool,
3106 ) {
3107 let clock = get_atomic_clock_realtime();
3108 let (emitter, mut rx) = create_test_emitter(clock);
3109 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
3110 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
3111 let dispatch_state = WsDispatchState::default();
3112 dispatch_state.insert_accepted(client_order_id);
3113 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
3114 let identity = OrderIdentity {
3115 instrument_id,
3116 strategy_id: StrategyId::from("TEST-STRATEGY"),
3117 order_side: OrderSide::Buy,
3118 order_type: OrderType::Limit,
3119 price: None,
3120 quantity: Quantity::from("1"),
3121 };
3122
3123 let json = crate::common::testing::load_fixture_string(
3124 "spot/user_data_json/execution_report_expired.json",
3125 );
3126 let report: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
3127
3128 dispatch_tracked_execution_report(
3129 &report,
3130 &emitter,
3131 AccountId::from("BINANCE-001"),
3132 treat_expired_as_canceled,
3133 &dispatch_state,
3134 &seen_trade_ids,
3135 client_order_id,
3136 &identity,
3137 instrument_id,
3138 2,
3139 5,
3140 clock.get_time_ns(),
3141 );
3142
3143 let event = rx.try_recv().expect("terminal order event expected");
3144 match (treat_expired_as_canceled, event) {
3145 (true, ExecutionEvent::Order(OrderEventAny::Canceled(event))) => {
3146 assert_eq!(event.client_order_id, client_order_id);
3147 }
3148 (false, ExecutionEvent::Order(OrderEventAny::Expired(event))) => {
3149 assert_eq!(event.client_order_id, client_order_id);
3150 }
3151 (_, other) => panic!("Expected terminal expired/canceled event, was {other:?}"),
3152 }
3153 assert!(rx.try_recv().is_err());
3154 }
3155
3156 #[rstest]
3157 fn test_dispatch_tracked_execution_report_rejected_gtx_sets_post_only() {
3158 let clock = get_atomic_clock_realtime();
3159 let (emitter, mut rx) = create_test_emitter(clock);
3160 let http_client = create_test_http_client(clock);
3161 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-1");
3162 let dispatch_state =
3163 create_tracked_dispatch_state(client_order_id, InstrumentId::from("ETHUSDT.BINANCE"));
3164 let ws_authenticated = tokio::sync::Notify::new();
3165 let ws_user_data_subscribed = tokio::sync::Notify::new();
3166 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
3167 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
3168
3169 let encoded = encode_broker_id(&client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
3170 let report_json = format!(
3171 r#"{{
3172 "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
3173 "c":"{encoded}","S":"BUY","o":"LIMIT","f":"GTX",
3174 "q":"1.00000000","p":"2500.00000000","P":"0.00000000",
3175 "x":"REJECTED","X":"REJECTED","r":"NONE","i":12345678,
3176 "l":"0.00000000","z":"0.00000000","L":"0.00000000",
3177 "n":"0","N":null,"T":1709654400000,"t":-1,"w":false,"m":false,
3178 "O":1709654400000,"Z":"0.00000000","C":""
3179 }}"#,
3180 );
3181 let report: BinanceSpotExecutionReport = serde_json::from_str(&report_json).unwrap();
3182
3183 dispatch_ws_trading_message(
3184 BinanceSpotWsTradingMessage::ExecutionReport(Box::new(report)),
3185 &emitter,
3186 &http_client,
3187 AccountId::from("BINANCE-001"),
3188 false,
3189 clock,
3190 &dispatch_state,
3191 &ws_authenticated,
3192 &ws_user_data_subscribed,
3193 &ws_setup_error_tx,
3194 &seen_trade_ids,
3195 );
3196
3197 match rx.try_recv().expect("OrderRejected event expected") {
3198 ExecutionEvent::Order(OrderEventAny::Rejected(event)) => {
3199 assert_eq!(event.client_order_id, client_order_id);
3200 assert_eq!(event.account_id, AccountId::from("BINANCE-001"));
3201 assert!(event.due_post_only);
3202 }
3203 other => panic!("Expected OrderRejected event, was {other:?}"),
3204 }
3205 }
3206}