1use std::{future::Future, sync::Arc, time::Duration};
19
20use ahash::{AHashMap, AHashSet};
21use anyhow::Context;
22use async_trait::async_trait;
23use jiff::Timestamp;
24use nautilus_common::{
25 cache::fifo::FifoCache,
26 clients::ExecutionClient,
27 enums::LogLevel,
28 live::runner::get_exec_event_sender,
29 messages::execution::{
30 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
31 GenerateFillReportsBuilder, GenerateOrderStatusReport, GenerateOrderStatusReports,
32 GenerateOrderStatusReportsBuilder, GeneratePositionStatusReports,
33 GeneratePositionStatusReportsBuilder, ModifyOrder, QueryAccount, QueryOrder, SubmitOrder,
34 SubmitOrderList,
35 },
36};
37use nautilus_core::{
38 DurationNanos, Params, UUID4, UnixNanos,
39 datetime::NANOSECONDS_IN_MILLISECOND,
40 string::secret::SecretString,
41 time::{AtomicTime, get_atomic_clock_realtime},
42};
43use nautilus_live::{
44 ExecutionClientCore, ExecutionEventEmitter, SocketControlFactory,
45 execution::failure::CommandFailure,
46 task::{TaskGroup, TaskGroupGuard, TaskRef, TaskSpawner},
47};
48use nautilus_model::{
49 accounts::AccountAny,
50 enums::{ContingencyType, LiquiditySide, OmsType, OrderStatus, OrderType},
51 events::{
52 AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDeniedReason,
53 OrderEventAny, OrderExpired, OrderFilled, OrderModifyRejected, OrderRejected, OrderUpdated,
54 },
55 identifiers::{
56 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, Venue,
57 VenueOrderId,
58 },
59 instruments::Instrument,
60 orders::{Order, OrderAny},
61 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
62 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
63};
64use parking_lot::Mutex;
65use rust_decimal::Decimal;
66use ustr::Ustr;
67
68use super::websocket::trading::{
69 client::BinanceSpotWsTradingClient,
70 messages::BinanceSpotWsTradingMessage,
71 parse::{
72 parse_spot_account_position, parse_spot_exec_report_to_fill,
73 parse_spot_exec_report_to_order_status,
74 },
75 user_data::{BinanceSpotExecutionReport, BinanceSpotExecutionType},
76};
77use crate::{
78 common::{
79 consts::{
80 BINANCE_GTX_ORDER_REJECT_CODE, BINANCE_NAUTILUS_SPOT_BROKER_ID,
81 BINANCE_NEW_ORDER_REJECTED_CODE, BINANCE_SPOT_POST_ONLY_REJECT_MSG,
82 BINANCE_SPOT_SBE_WS_API_DEMO_URL, BINANCE_SPOT_SBE_WS_API_TESTNET_URL,
83 BINANCE_SPOT_SBE_WS_API_URL, BINANCE_VENUE, BINANCE_WS_HEARTBEAT_SECS,
84 },
85 credential::resolve_credentials,
86 dispatch::{
87 OrderIdentity, PendingOperation, PendingRequest, WsDispatchState,
88 ensure_accepted_emitted,
89 },
90 encoder::{decode_client_order_id, encode_broker_id},
91 enums::{BinanceEnvironment, BinanceOrderStatus, BinanceSide, BinanceTimeInForce},
92 failure::{classify_spot_http_failure, classify_venue_failure, sanitize_reason},
93 parse::{
94 parse_micros_or_init, parse_millis_or_init, parse_required_decimal,
95 parse_required_price_at_precision, parse_required_quantity_at_precision,
96 },
97 urls::{get_http_base_url_with_us, get_spot_user_stream_url},
98 },
99 config::BinanceExecutionClientConfig,
100 spot::{
101 enums::{
102 BinanceCancelReplaceMode, BinanceOrderResponseType, BinanceSpotOrderType,
103 order_type_to_binance_spot, time_in_force_to_binance_spot,
104 },
105 http::{
106 client::BinanceSpotHttpClient,
107 error::BinanceSpotHttpError,
108 models::{
109 BinanceCancelOpenOrdersResponse, BinanceCancelOrderListResponse,
110 BinanceCancelOrderResponse,
111 },
112 query::{
113 CancelOrderParams, CancelReplaceOrderParams, NewOcoOrderListParams, NewOrderParams,
114 },
115 },
116 sbe::spot::{
117 list_order_status::ListOrderStatus as SbeListOrderStatus,
118 list_status_type::ListStatusType as SbeListStatusType,
119 order_status::OrderStatus as SbeOrderStatus,
120 },
121 },
122};
123
124const ACCOUNT_TRADES_MAX_INTERVAL_MS: i64 = 24 * 60 * 60 * 1_000;
125
126const ACCOUNT_TRADES_PAGE_LIMIT: u32 = 1_000;
127
128const WS_RECONNECT_SETUP_RETRY_DELAY: Duration = Duration::from_secs(1);
129
130#[derive(Debug)]
136pub struct BinanceSpotExecutionClient {
137 core: ExecutionClientCore,
138 clock: &'static AtomicTime,
139 config: BinanceExecutionClientConfig,
140 emitter: ExecutionEventEmitter,
141 dispatch_state: Arc<WsDispatchState>,
142 lifecycle_lock: Arc<Mutex<()>>,
143 http_client: BinanceSpotHttpClient,
144 socket_factory: SocketControlFactory,
145 ws_trading_client: Option<BinanceSpotWsTradingClient>,
146 ws_trading_dispatch: Option<TaskRef>,
147 ws_user_data_client: Arc<Mutex<Option<BinanceSpotWsTradingClient>>>,
148 ws_user_data_dispatch: Option<TaskRef>,
149 listen_key: Option<SecretString>,
150 us_credentials: Option<(SecretString, SecretString)>,
151 ws_authenticated: Arc<tokio::sync::Notify>,
152 ws_user_data_subscribed: Arc<tokio::sync::Notify>,
153 session_tasks: TaskGroup,
154 pending_tasks: TaskGroup,
155 shutdown_errors: Vec<String>,
156}
157
158impl BinanceSpotExecutionClient {
159 pub fn new(
165 core: ExecutionClientCore,
166 config: BinanceExecutionClientConfig,
167 ) -> anyhow::Result<Self> {
168 config.validate()?;
169 let (api_key, api_secret) = resolve_credentials(
170 config
171 .api_key
172 .as_ref()
173 .map(|value| value.expose_secret().to_owned()),
174 config
175 .api_secret
176 .as_ref()
177 .map(|value| value.expose_secret().to_owned()),
178 config.environment,
179 config.product_type,
180 )?;
181
182 let clock = get_atomic_clock_realtime();
183 let socket_factory = SocketControlFactory::new(core.client_id, Some(*BINANCE_VENUE));
184 let base_url_http = config.base_url_http.clone().or_else(|| {
185 config.us.then(|| {
186 get_http_base_url_with_us(config.product_type, config.environment, true).to_string()
187 })
188 });
189 let proxy_url = config
190 .proxy_url
191 .as_ref()
192 .map(|value| value.expose_secret().to_owned());
193
194 let http_client = BinanceSpotHttpClient::new_with_json_responses(
195 config.environment,
196 clock,
197 Some(api_key.clone()),
198 Some(api_secret.clone()),
199 base_url_http,
200 Some(config.recv_window_ms),
201 None, proxy_url.clone(),
203 config.us,
204 )
205 .context("failed to construct Binance Spot HTTP client")?
206 .with_retry_config(config.retry_config());
207 let emitter = ExecutionEventEmitter::new(
208 clock,
209 core.trader_id,
210 core.account_id,
211 core.account_type,
212 core.base_currency,
213 );
214
215 let ws_trading_client = if config.us {
216 None
217 } else {
218 let url = Some(Self::resolve_ws_trading_url(
219 config.base_url_ws_trading.clone(),
220 config.environment,
221 ));
222 Some(
223 BinanceSpotWsTradingClient::new(
224 url,
225 api_key.clone(),
226 api_secret.clone(),
227 Some(BINANCE_WS_HEARTBEAT_SECS),
228 config.transport_backend,
229 )
230 .with_proxy(proxy_url)
231 .with_recv_window(Some(config.recv_window_ms))
232 .with_socket_control(socket_factory.control("binance-spot-trading")),
233 )
234 };
235 let us_credentials = config
236 .us
237 .then_some((SecretString::from(api_key), SecretString::from(api_secret)));
238
239 let session_tasks = TaskGroup::new();
240 let pending_tasks = TaskGroup::new();
241
242 Ok(Self {
243 core,
244 clock,
245 config,
246 emitter,
247 dispatch_state: Arc::new(WsDispatchState::default()),
248 lifecycle_lock: Arc::new(Mutex::new(())),
249 http_client,
250 socket_factory,
251 ws_trading_client,
252 ws_trading_dispatch: None,
253 ws_user_data_client: Arc::new(Mutex::new(None)),
254 ws_user_data_dispatch: None,
255 listen_key: None,
256 us_credentials,
257 ws_authenticated: Arc::new(tokio::sync::Notify::new()),
258 ws_user_data_subscribed: Arc::new(tokio::sync::Notify::new()),
259 session_tasks,
260 pending_tasks,
261 shutdown_errors: Vec::new(),
262 })
263 }
264
265 fn resolve_ws_trading_url(base_url: Option<String>, environment: BinanceEnvironment) -> String {
266 base_url.unwrap_or_else(|| {
267 match environment {
268 BinanceEnvironment::Live => BINANCE_SPOT_SBE_WS_API_URL,
269 BinanceEnvironment::Testnet => BINANCE_SPOT_SBE_WS_API_TESTNET_URL,
270 BinanceEnvironment::Demo => BINANCE_SPOT_SBE_WS_API_DEMO_URL,
271 }
272 .to_string()
273 })
274 }
275
276 async fn refresh_account_state(&self) -> anyhow::Result<AccountState> {
277 self.http_client
278 .request_account_state(self.core.account_id)
279 .await
280 }
281
282 fn update_account_state(&self) {
283 let http_client = self.http_client.clone();
284 let account_id = self.core.account_id;
285 let emitter = self.emitter.clone();
286 let clock = self.clock;
287
288 self.spawn_task("query_account", async move {
289 let account_state = http_client.request_account_state(account_id).await?;
290 let ts_now = clock.get_time_ns();
291 emitter.emit_account_state(
292 account_state.balances.clone(),
293 account_state.margins.clone(),
294 account_state.is_reported,
295 ts_now,
296 account_state.info,
297 );
298 Ok(())
299 });
300 }
301
302 fn ws_user_data_active(&self) -> bool {
303 let dispatch_running = if self.config.us {
304 self.ws_user_data_dispatch
305 .as_ref()
306 .is_some_and(TaskRef::is_active)
307 } else {
308 self.ws_trading_dispatch
309 .as_ref()
310 .is_some_and(TaskRef::is_active)
311 };
312 let user_data_active = if self.config.us {
313 self.ws_user_data_client
314 .lock()
315 .as_ref()
316 .is_some_and(BinanceSpotWsTradingClient::is_user_data_active)
317 } else {
318 self.ws_trading_client
319 .as_ref()
320 .is_some_and(BinanceSpotWsTradingClient::is_user_data_active)
321 };
322
323 user_data_active && dispatch_running
324 }
325
326 fn ensure_ws_user_data_active(&self) -> anyhow::Result<()> {
327 anyhow::ensure!(
328 self.ws_user_data_active(),
329 "Binance Spot user data stream is not active",
330 );
331 Ok(())
332 }
333
334 fn ws_order_transport_active(&self) -> bool {
335 self.config.use_ws_trading && self.ws_trading_client.is_some() && self.ws_user_data_active()
336 }
337
338 fn submit_order_internal(
339 &self,
340 cmd: &SubmitOrder,
341 params: Option<NewOrderParams>,
342 ) -> anyhow::Result<()> {
343 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
344
345 let event_emitter = self.emitter.clone();
346 let trader_id = self.core.trader_id;
347 let account_id = self.core.account_id;
348 let client_order_id = order.client_order_id();
349 let strategy_id = order.strategy_id();
350 let instrument_id = order.instrument_id();
351 let order_side = order.order_side();
352 let order_type = order.order_type();
353 let quantity = order.quantity();
354 let time_in_force = order.time_in_force();
355 let price = order.price();
356 let trigger_price = order.trigger_price();
357 let is_post_only = order.is_post_only();
358 let is_quote_quantity = order.is_quote_quantity();
359 let display_qty = order.display_qty();
360 let use_gtd = self.config.use_gtd;
361 let clock = self.clock;
362 let ts_init = self.clock.get_time_ns();
363
364 self.dispatch_state.order_identities.insert(
366 client_order_id,
367 OrderIdentity {
368 instrument_id,
369 strategy_id,
370 order_side,
371 order_type,
372 price,
373 quantity,
374 is_quote_quantity,
375 venue_position_id: None,
376 },
377 );
378
379 if let Some(params) = params {
380 let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
381 let dispatch_state = self.dispatch_state.clone();
382
383 let request_id = ws_client.next_request_id();
385 dispatch_state.pending_requests.insert(
386 request_id.clone(),
387 PendingRequest {
388 client_order_id,
389 venue_order_id: None,
390 operation: PendingOperation::Place,
391 },
392 );
393
394 self.spawn_task("submit_order_ws", async move {
395 if let Err(e) = ws_client
396 .place_order_with_id(request_id.clone(), params)
397 .await
398 {
399 dispatch_state.pending_requests.remove(&request_id);
400 log::warn!(
401 "WS submit request failed for {client_order_id}, awaiting reconciliation: {e}"
402 );
403 anyhow::bail!("WS submit order failed: {e}");
404 }
405 Ok(())
406 });
407 } else {
408 let http_client = self.http_client.clone();
409 let dispatch_state = self.dispatch_state.clone();
410 log::debug!("WS trading not active, falling back to HTTP for submit_order");
411
412 self.spawn_task("submit_order_http", async move {
413 let result = http_client
414 .submit_order(
415 account_id,
416 instrument_id,
417 client_order_id,
418 order_side,
419 order_type,
420 quantity,
421 time_in_force,
422 price,
423 trigger_price,
424 is_post_only,
425 is_quote_quantity,
426 display_qty,
427 use_gtd,
428 )
429 .await;
430
431 match result {
432 Ok(report) => handle_spot_order_submit_success(
433 client_order_id,
434 report.venue_order_id,
435 ),
436 Err(e) => {
437 let http_error = e.downcast_ref::<BinanceSpotHttpError>();
438 let failure = http_error.map_or_else(
439 || CommandFailure::Ambiguous(e.to_string()),
440 classify_spot_http_failure,
441 );
442
443 match failure {
444 CommandFailure::Ambiguous(reason) => {
445 log::warn!(
446 "Ambiguous submit failure for {client_order_id}, awaiting reconciliation: {reason}"
447 );
448 }
449 CommandFailure::NotSent(reason)
450 | CommandFailure::VenueRejected(reason) => {
451 let due_post_only =
452 http_error.is_some_and(is_spot_post_only_rejection);
453 dispatch_state.cleanup_terminal(client_order_id);
454 let rejected = OrderRejected::new(
455 trader_id,
456 strategy_id,
457 instrument_id,
458 client_order_id,
459 account_id,
460 format!("submit-order-error: {}", sanitize_reason(&reason))
461 .into(),
462 UUID4::new(),
463 ts_init,
464 clock.get_time_ns(),
465 false,
466 due_post_only,
467 );
468 event_emitter.send_order_event(OrderEventAny::Rejected(rejected));
469 }
470 }
471 return Err(e);
472 }
473 }
474 Ok(())
475 });
476 }
477
478 Ok(())
479 }
480
481 fn cancel_order_internal(&self, cmd: &CancelOrder) {
482 let event_emitter = self.emitter.clone();
483 let trader_id = self.core.trader_id;
484 let account_id = self.core.account_id;
485 let clock = self.clock;
486 let command = cmd.clone();
487 let prefer_client_order_id = self
488 .dispatch_state
489 .order_identities
490 .contains_key(&cmd.client_order_id);
491
492 if self.ws_order_transport_active() {
493 let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
494 let dispatch_state = self.dispatch_state.clone();
495 let params = build_cancel_order_params(&command, prefer_client_order_id);
496
497 let request_id = ws_client.next_request_id();
499 dispatch_state.pending_requests.insert(
500 request_id.clone(),
501 PendingRequest {
502 client_order_id: command.client_order_id,
503 venue_order_id: command.venue_order_id,
504 operation: PendingOperation::Cancel,
505 },
506 );
507
508 let http_client = self.http_client.clone();
509 self.spawn_task("cancel_order_ws", async move {
510 let mut params = params;
511 http_client
512 .inner()
513 .resolve_cancel_order(&mut params)
514 .await
515 .inspect_err(|e| {
516 dispatch_state.pending_requests.remove(&request_id);
517 let ts_now = clock.get_time_ns();
518
519 let rejected = OrderCancelRejected::new(
520 trader_id,
521 command.strategy_id,
522 command.instrument_id,
523 command.client_order_id,
524 format!(
525 "cancel identity lookup failed before submission: {}",
526 sanitize_reason(&e.to_string())
527 )
528 .into(),
529 UUID4::new(),
530 ts_now,
531 ts_now,
532 false,
533 command.venue_order_id,
534 Some(account_id),
535 );
536 event_emitter.send_order_event(OrderEventAny::CancelRejected(rejected));
537 })?;
538
539 let result = ws_client
540 .cancel_order_with_id(request_id.clone(), params)
541 .await;
542
543 if let Err(e) = result {
544 dispatch_state.pending_requests.remove(&request_id);
545 log::warn!(
546 "WS cancel request failed for {}, awaiting reconciliation: {e}",
547 command.client_order_id
548 );
549 anyhow::bail!("WS cancel order failed: {e}");
550 }
551 Ok(())
552 });
553 } else {
554 let http_client = self.http_client.clone();
555 let dispatch_state = self.dispatch_state.clone();
556 log::debug!("WS trading not active, falling back to HTTP for cancel_order");
557
558 self.spawn_task("cancel_order_http", async move {
559 let result = http_client
560 .cancel_order(
561 command.instrument_id,
562 if prefer_client_order_id { None } else { command.venue_order_id },
563 Some(command.client_order_id),
564 )
565 .await;
566
567 match result {
568 Ok(venue_order_id) => {
569 dispatch_state.cleanup_terminal(command.client_order_id);
570 let ts_now = clock.get_time_ns();
571 let canceled_event = OrderCanceled::new(
572 trader_id,
573 command.strategy_id,
574 command.instrument_id,
575 command.client_order_id,
576 UUID4::new(),
577 ts_now,
578 ts_now,
579 false,
580 Some(venue_order_id),
581 Some(account_id),
582 None,
583 );
584 event_emitter.send_order_event(OrderEventAny::Canceled(canceled_event));
585 }
586 Err(e) => {
587 let failure = e.downcast_ref::<BinanceSpotHttpError>().map_or_else(
588 || CommandFailure::Ambiguous(e.to_string()),
589 classify_spot_http_failure,
590 );
591
592 match failure {
593 CommandFailure::Ambiguous(reason) => {
594 log::warn!(
595 "Ambiguous cancel failure for {}, awaiting reconciliation: {reason}",
596 command.client_order_id
597 );
598 }
599 CommandFailure::NotSent(reason)
600 | CommandFailure::VenueRejected(reason) => {
601 let ts_now = clock.get_time_ns();
602 let rejected_event = OrderCancelRejected::new(
603 trader_id,
604 command.strategy_id,
605 command.instrument_id,
606 command.client_order_id,
607 format!("cancel-order-error: {}", sanitize_reason(&reason))
608 .into(),
609 UUID4::new(),
610 ts_now,
611 ts_now,
612 false,
613 command.venue_order_id,
614 Some(account_id),
615 );
616 event_emitter
617 .send_order_event(OrderEventAny::CancelRejected(rejected_event));
618 }
619 }
620 return Err(e);
621 }
622 }
623 Ok(())
624 });
625 }
626 }
627
628 fn spawn_task<F>(&self, description: &'static str, fut: F)
629 where
630 F: Future<Output = anyhow::Result<()>> + Send + 'static,
631 {
632 crate::common::execution::spawn_task(&self.pending_tasks, description, fut);
633 }
634
635 fn begin_generation_shutdown(&self) {
636 if let Some(client) = self.ws_trading_client.as_ref() {
637 client.mark_user_data_inactive();
638 client.begin_shutdown();
639 }
640
641 if let Some(client) = self.ws_user_data_client.lock().as_ref() {
642 client.mark_user_data_inactive();
643 client.begin_shutdown();
644 }
645
646 self.core.set_disconnected();
647 self.abort_session_tasks();
648 self.abort_pending_tasks();
649 }
650
651 fn abort_pending_tasks(&self) {
652 crate::common::execution::abort_pending_tasks(&self.pending_tasks);
653 }
654
655 fn abort_session_tasks(&self) {
656 self.session_tasks.begin_shutdown();
657 }
658
659 async fn await_pending_tasks(&self) -> anyhow::Result<()> {
660 crate::common::execution::await_pending_tasks(&self.pending_tasks).await
661 }
662
663 async fn await_session_tasks(&self) -> anyhow::Result<()> {
664 self.session_tasks.begin_shutdown();
665 self.session_tasks
666 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
667 .await
668 .map_err(|e| anyhow::anyhow!("Failed to terminate Binance Spot session tasks: {e}"))?;
669 Ok(())
670 }
671
672 async fn ws_setup_failure(
673 &mut self,
674 mut ws_trading: BinanceSpotWsTradingClient,
675 reason: String,
676 ) -> anyhow::Error {
677 ws_trading.mark_user_data_inactive();
678 log::debug!("{reason}; Binance Spot private user data is required for execution");
679
680 self.abort_session_tasks();
681
682 if let Err(e) = ws_trading.disconnect().await {
683 log::warn!("Failed to stop Binance Spot trading WebSocket after setup failure: {e}");
684 }
685 self.disconnect_us_user_data().await;
686
687 if let Err(e) = self.await_session_tasks().await {
688 log::warn!("Failed to drain Binance Spot session tasks after setup failure: {e}");
689 }
690 self.ws_trading_client = Some(ws_trading);
691 anyhow::anyhow!(reason)
692 }
693
694 async fn connect_us_user_data(&mut self) -> anyhow::Result<()> {
695 let (api_key, api_secret) = self
696 .us_credentials
697 .clone()
698 .context("Binance US user data credentials are unavailable")?;
699 let response = self
700 .http_client
701 .inner()
702 .create_listen_key()
703 .await
704 .context("failed to create Binance US listen key")?;
705 let listen_key = response.into_listen_key();
706 self.listen_key = Some(listen_key.clone());
707 let url = get_spot_user_stream_url(
708 self.config.base_url_ws.as_deref(),
709 listen_key.expose_secret(),
710 );
711 let mut ws_user_data = BinanceSpotWsTradingClient::new(
712 Some(url),
713 api_key.expose_secret().to_owned(),
714 api_secret.expose_secret().to_owned(),
715 Some(BINANCE_WS_HEARTBEAT_SECS),
716 self.config.transport_backend,
717 )
718 .with_proxy(
719 self.config
720 .proxy_url
721 .as_ref()
722 .map(|value| value.expose_secret().to_owned()),
723 )
724 .with_socket_control(self.socket_factory.control("binance-spot-user-streams"));
725 *self.ws_user_data_client.lock() = Some(ws_user_data.clone());
726 ws_user_data
727 .connect()
728 .await
729 .context("failed to connect Binance US user data stream")?;
730
731 let ws_clone = ws_user_data.clone();
732 let emitter = self.emitter.clone();
733 let account_id = self.core.account_id;
734 let clock = self.clock;
735 let http_client = self.http_client.clone();
736 let dispatch_state = self.dispatch_state.clone();
737 let lifecycle_lock = self.lifecycle_lock.clone();
738 let treat_expired_as_canceled = self.config.treat_expired_as_canceled;
739 let ws_authenticated = self.ws_authenticated.clone();
740 let ws_user_data_subscribed = self.ws_user_data_subscribed.clone();
741 let (setup_error_tx, _setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
742 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
743 let task_spawner = self
744 .session_tasks
745 .spawner()
746 .context("Binance Spot session task admission is closed")?;
747
748 let future = async move {
749 while let Some(message) = ws_clone.recv().await {
750 if matches!(&message, BinanceSpotWsTradingMessage::Reconnected) {
751 ws_clone.mark_user_data_active();
752 }
753 let _lifecycle_guard = lifecycle_lock.lock();
754 dispatch_ws_trading_message(
755 message,
756 &emitter,
757 &http_client,
758 account_id,
759 treat_expired_as_canceled,
760 clock,
761 &dispatch_state,
762 &ws_authenticated,
763 &ws_user_data_subscribed,
764 &setup_error_tx,
765 &seen_trade_ids,
766 &task_spawner,
767 );
768 }
769
770 log::debug!("Data dispatch loop ended");
771 };
772 let dispatch = self
773 .session_tasks
774 .spawn_named("binance-spot-user-data-dispatch", future)?;
775 self.ws_user_data_dispatch = Some(dispatch);
776
777 let keepalive_http = self.http_client.clone();
778 let keepalive_key = listen_key.clone();
779
780 if let Err(e) = self.session_tasks.spawn(async move {
781 let mut interval = tokio::time::interval(Duration::from_secs(30 * 60));
782 interval.tick().await;
783
784 loop {
785 interval.tick().await;
786
787 if let Err(e) = keepalive_http
788 .inner()
789 .extend_listen_key(keepalive_key.expose_secret())
790 .await
791 {
792 log::warn!("Binance US listen key keepalive failed: {e}");
793 }
794 }
795 }) {
796 return Err(e.into());
797 }
798
799 ws_user_data.mark_user_data_active();
800 *self.ws_user_data_client.lock() = Some(ws_user_data);
801 Ok(())
802 }
803
804 async fn disconnect_us_user_data(&mut self) {
805 let mut client_drained = true;
806 let client = self.ws_user_data_client.lock().clone();
807
808 if let Some(mut client) = client {
809 client.mark_user_data_inactive();
810 if let Err(e) = client.disconnect().await {
811 client_drained = false;
812 self.shutdown_errors.push(format!(
813 "failed to stop Binance US user data WebSocket: {e}"
814 ));
815 }
816 }
817
818 if client_drained {
819 *self.ws_user_data_client.lock() = None;
820 }
821
822 if let Some(listen_key) = self.listen_key.clone() {
823 match self
824 .http_client
825 .inner()
826 .close_listen_key(listen_key.expose_secret())
827 .await
828 {
829 Ok(())
830 if self.listen_key.as_ref().map(SecretString::expose_secret)
831 == Some(listen_key.expose_secret()) =>
832 {
833 self.listen_key = None;
834 }
835 Ok(()) => {}
836 Err(e) => self
837 .shutdown_errors
838 .push(format!("failed to close Binance US listen key: {e}")),
839 }
840 }
841 }
842
843 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
844 if let Some(client) = self.ws_trading_client.as_ref() {
845 client.mark_user_data_inactive();
846 client.begin_shutdown();
847 }
848
849 if let Some(client) = self.ws_user_data_client.lock().as_ref() {
850 client.mark_user_data_inactive();
851 client.begin_shutdown();
852 }
853
854 self.abort_session_tasks();
855 self.abort_pending_tasks();
856
857 if let Some(ref mut ws_trading) = self.ws_trading_client
858 && let Err(e) = ws_trading.disconnect().await
859 {
860 self.shutdown_errors
861 .push(format!("trading WebSocket shutdown failed: {e}"));
862 }
863 self.disconnect_us_user_data().await;
864
865 let (session_result, pending_result) =
866 tokio::join!(self.await_session_tasks(), self.await_pending_tasks());
867 self.core.set_disconnected();
868
869 if let Err(e) = session_result {
870 self.shutdown_errors.push(e.to_string());
871 }
872
873 if let Err(e) = pending_result {
874 self.shutdown_errors.push(e.to_string());
875 }
876
877 if !self.shutdown_errors.is_empty() {
878 let errors = std::mem::take(&mut self.shutdown_errors);
879 anyhow::bail!("Binance Spot shutdown failed: {}", errors.join("; "));
880 }
881 Ok(())
882 }
883}
884
885#[async_trait(?Send)]
886impl ExecutionClient for BinanceSpotExecutionClient {
887 fn is_connected(&self) -> bool {
888 self.core.is_connected()
889 }
890
891 fn client_id(&self) -> ClientId {
892 self.core.client_id
893 }
894
895 fn account_id(&self) -> AccountId {
896 self.core.account_id
897 }
898
899 fn venue(&self) -> Venue {
900 *BINANCE_VENUE
901 }
902
903 fn oms_type(&self) -> OmsType {
904 self.core.oms_type
905 }
906
907 fn get_account(&self) -> Option<AccountAny> {
908 self.core.cache().account_owned(&self.core.account_id)
909 }
910
911 async fn connect(&mut self) -> anyhow::Result<()> {
912 if self.core.is_connected() && self.session_tasks.is_open() && self.pending_tasks.is_open()
913 {
914 return Ok(());
915 }
916
917 if !self.pending_tasks.is_open() || !self.session_tasks.is_open() {
918 self.teardown_partial_connect().await?;
919 }
920
921 if !self.pending_tasks.is_open() {
922 self.await_pending_tasks().await?;
923 self.pending_tasks.start_generation().map_err(|e| {
924 anyhow::anyhow!("Failed to start Binance Spot task generation: {e}")
925 })?;
926 }
927
928 if !self.session_tasks.is_open() {
929 self.await_session_tasks().await?;
930 self.session_tasks.start_generation().map_err(|e| {
931 anyhow::anyhow!("Failed to start Binance Spot session generation: {e}")
932 })?;
933 }
934 let ws_trading_client = self.ws_trading_client.clone();
935 let ws_user_data_client = Arc::clone(&self.ws_user_data_client);
936 let setup_guard =
937 TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
938 if let Some(client) = ws_trading_client {
939 client.begin_shutdown();
940 }
941
942 if let Some(client) = ws_user_data_client.lock().as_ref() {
943 client.begin_shutdown();
944 }
945 });
946
947 let ws_setup_timeout = Duration::from_millis(self.config.ws_trading_setup_timeout_ms);
948
949 if !self.core.instruments_initialized() {
951 let instruments = self
952 .http_client
953 .request_instruments_with_config(&self.config.instrument_provider, self.config.us)
954 .await
955 .context("failed to request Binance Spot instruments")?;
956
957 if instruments.is_empty() {
958 log::warn!("No instruments returned for Binance Spot");
959 } else {
960 log::debug!("Loaded {} Spot instruments", instruments.len());
961 self.http_client.cache_instruments(instruments);
962 }
963
964 self.core.set_instruments_initialized();
965 }
966
967 let account_state = self
969 .refresh_account_state()
970 .await
971 .context("failed to request Binance account state")?;
972
973 if !account_state.balances.is_empty() {
974 log::debug!(
975 "Received account state with {} balance(s)",
976 account_state.balances.len()
977 );
978 }
979
980 self.emitter.send_account_state(account_state);
981
982 crate::common::execution::await_account_registered(&self.core, self.core.account_id, 30.0)
984 .await?;
985
986 let session_result = async {
987 if self.config.us {
988 self.connect_us_user_data().await?;
989 }
990
991 if let Some(mut ws_trading) = self.ws_trading_client.clone() {
992 match ws_trading.connect().await {
993 Ok(()) => {
994 log::debug!("Connected to Binance Spot WS trading API");
995
996 let ws_trading_clone = ws_trading.clone();
997 let emitter = self.emitter.clone();
998 let account_id = self.core.account_id;
999 let clock = self.clock;
1000 let http_client = self.http_client.clone();
1001 let dispatch_state = self.dispatch_state.clone();
1002 let lifecycle_lock = self.lifecycle_lock.clone();
1003 let treat_expired_as_canceled = self.config.treat_expired_as_canceled;
1004 let ws_authenticated = self.ws_authenticated.clone();
1005 let ws_user_data_subscribed = self.ws_user_data_subscribed.clone();
1006 let (ws_setup_error_tx, mut ws_setup_error_rx) =
1007 tokio::sync::mpsc::unbounded_channel();
1008 let seen_trade_ids = std::sync::Arc::new(Mutex::new(FifoCache::new()));
1009 let task_spawner = self
1010 .session_tasks
1011 .spawner()
1012 .context("Binance Spot session task admission is closed")?;
1013
1014 let future = async move {
1015 let mut resubscribing = false;
1016
1017 loop {
1018 match ws_trading_clone.recv().await {
1019 Some(msg) => {
1020 match &msg {
1021 BinanceSpotWsTradingMessage::Reconnected => {
1022 ws_trading_clone.mark_user_data_inactive();
1023 resubscribing = true;
1024 if let Err(e) = ws_trading_clone.session_logon().await {
1025 resubscribing = false;
1026 log::error!(
1027 "Failed to re-authenticate Binance Spot user data stream: {e}"
1028 );
1029 }
1030 }
1031 BinanceSpotWsTradingMessage::Authenticated if resubscribing => {
1032 if let Err(e) =
1033 ws_trading_clone.subscribe_user_data().await
1034 {
1035 resubscribing = false;
1036 log::error!(
1037 "Failed to resubscribe Binance Spot user data stream: {e}"
1038 );
1039 }
1040 continue;
1041 }
1042 BinanceSpotWsTradingMessage::UserDataSubscribed { .. } => {
1043 let was_resubscribing = resubscribing;
1044 resubscribing = false;
1045 ws_trading_clone.mark_user_data_active();
1046
1047 if was_resubscribing {
1048 continue;
1049 }
1050 }
1051 BinanceSpotWsTradingMessage::AuthenticationRejected(reason)
1052 if resubscribing =>
1053 {
1054 log::warn!(
1055 "Binance Spot reconnect authentication failed; retrying: {reason}"
1056 );
1057 tokio::time::sleep(WS_RECONNECT_SETUP_RETRY_DELAY).await;
1058
1059 if let Err(e) = ws_trading_clone.session_logon().await {
1060 resubscribing = false;
1061 log::error!(
1062 "Failed to retry Binance Spot reconnect authentication: {e}"
1063 );
1064 }
1065 continue;
1066 }
1067 BinanceSpotWsTradingMessage::UserDataSubscriptionRejected(reason)
1068 if resubscribing =>
1069 {
1070 log::warn!(
1071 "Binance Spot reconnect user data subscription failed; retrying: {reason}"
1072 );
1073 tokio::time::sleep(WS_RECONNECT_SETUP_RETRY_DELAY).await;
1074
1075 if let Err(e) =
1076 ws_trading_clone.subscribe_user_data().await
1077 {
1078 resubscribing = false;
1079 log::error!(
1080 "Failed to retry Binance Spot user data subscription: {e}"
1081 );
1082 }
1083 continue;
1084 }
1085 _ => {}
1086 }
1087
1088 let _lifecycle_guard = lifecycle_lock.lock();
1089 dispatch_ws_trading_message(
1090 msg,
1091 &emitter,
1092 &http_client,
1093 account_id,
1094 treat_expired_as_canceled,
1095 clock,
1096 &dispatch_state,
1097 &ws_authenticated,
1098 &ws_user_data_subscribed,
1099 &ws_setup_error_tx,
1100 &seen_trade_ids,
1101 &task_spawner,
1102 );
1103 }
1104 None => {
1105 log::debug!("WS trading dispatch loop ended");
1106 break;
1107 }
1108 }
1109 }
1110 };
1111 let dispatch = self
1112 .session_tasks
1113 .spawn_named("binance-spot-trading-dispatch", future)?;
1114 self.ws_trading_dispatch = Some(dispatch);
1115
1116 if let Err(e) = ws_trading.session_logon().await {
1117 let reason = format!("WS session logon failed: {e}");
1118 return Err(self.ws_setup_failure(ws_trading, reason).await);
1119 } else {
1120 let auth_result = wait_for_ws_setup_response(
1121 ws_setup_timeout,
1122 self.ws_authenticated.notified(),
1123 &mut ws_setup_error_rx,
1124 "WS session authentication timed out",
1125 )
1126 .await;
1127
1128 if let Err(e) = auth_result {
1129 return Err(self.ws_setup_failure(ws_trading, e.to_string()).await);
1130 } else if let Err(e) = ws_trading.subscribe_user_data().await {
1131 let reason = format!("WS user data subscribe failed: {e}");
1132 return Err(self.ws_setup_failure(ws_trading, reason).await);
1133 } else {
1134 let subscribe_result = wait_for_ws_setup_response(
1135 ws_setup_timeout,
1136 self.ws_user_data_subscribed.notified(),
1137 &mut ws_setup_error_rx,
1138 "WS user data subscription timed out",
1139 )
1140 .await;
1141
1142 if let Err(e) = subscribe_result {
1143 return Err(self.ws_setup_failure(ws_trading, e.to_string()).await);
1144 } else {
1145 self.ws_trading_client = Some(ws_trading);
1146 }
1147 }
1148 }
1149 }
1150 Err(e) => {
1151 let reason = format!("Failed to connect WS trading API: {e}");
1152 return Err(self.ws_setup_failure(ws_trading, reason).await);
1153 }
1154 }
1155 }
1156
1157 let refresh_secs = self.config.instrument_refresh_interval_secs;
1158 if refresh_secs > 0 {
1159 let http_client = self.http_client.clone();
1160 let provider = self.config.instrument_provider.clone();
1161 let us = self.config.us;
1162
1163 self.session_tasks.spawn(async move {
1164 let mut interval = tokio::time::interval(Duration::from_secs(refresh_secs));
1165 interval.tick().await;
1166
1167 loop {
1168 interval.tick().await;
1169
1170 match http_client
1171 .request_instruments_with_config(&provider, us)
1172 .await
1173 {
1174 Ok(instruments) => log::debug!(
1175 "Refreshed Binance Spot execution instruments: count={}",
1176 instruments.len()
1177 ),
1178 Err(e) => {
1179 log::warn!("Binance Spot execution instrument refresh failed: {e}");
1180 }
1181 }
1182 }
1183 })?;
1184 }
1185
1186 Ok::<(), anyhow::Error>(())
1187 }
1188 .await;
1189
1190 if let Err(e) = session_result {
1191 if let Err(teardown_error) = self.teardown_partial_connect().await {
1192 return Err(e.context(format!(
1193 "Binance Spot startup teardown failed: {teardown_error}"
1194 )));
1195 }
1196 return Err(e);
1197 }
1198
1199 setup_guard.disarm();
1200 self.core.set_connected();
1201 log::info!("Connected: client_id={}", self.core.client_id);
1202 Ok(())
1203 }
1204
1205 async fn disconnect(&mut self) -> anyhow::Result<()> {
1206 self.teardown_partial_connect().await?;
1207 log::info!("Disconnected: client_id={}", self.core.client_id);
1208 Ok(())
1209 }
1210
1211 fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
1212 self.update_account_state();
1213 Ok(())
1214 }
1215
1216 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
1217 log::debug!("query_order: client_order_id={}", cmd.client_order_id);
1218
1219 let http_client = self.http_client.clone();
1220 let command = cmd;
1221 let event_emitter = self.emitter.clone();
1222 let account_id = self.core.account_id;
1223 let treat_expired_as_canceled = self.config.treat_expired_as_canceled;
1224
1225 self.spawn_task("query_order", async move {
1226 let result = http_client
1227 .request_order_status_report(
1228 account_id,
1229 command.instrument_id,
1230 command.venue_order_id,
1231 Some(command.client_order_id),
1232 )
1233 .await;
1234
1235 match result {
1236 Ok(Some(mut report)) => {
1237 normalize_spot_order_status_report(&mut report, treat_expired_as_canceled);
1238 event_emitter.send_order_status_report(report);
1239 }
1240 Ok(None) => log::debug!(
1241 "No order status report returned: client_order_id={}",
1242 command.client_order_id
1243 ),
1244 Err(e) => log::warn!("Failed to query order status: {e}"),
1245 }
1246
1247 Ok(())
1248 });
1249
1250 Ok(())
1251 }
1252
1253 fn generate_account_state(
1254 &self,
1255 balances: Vec<AccountBalance>,
1256 margins: Vec<MarginBalance>,
1257 reported: bool,
1258 ts_event: UnixNanos,
1259 info: Option<Params>,
1260 ) -> anyhow::Result<()> {
1261 self.emitter
1262 .emit_account_state(balances, margins, reported, ts_event, info);
1263 Ok(())
1264 }
1265
1266 fn start(&mut self) -> anyhow::Result<()> {
1267 if self.core.is_started() {
1268 return Ok(());
1269 }
1270
1271 self.emitter.set_sender(get_exec_event_sender());
1272 self.core.set_started();
1273
1274 let http_client = self.http_client.clone();
1276 let provider = self.config.instrument_provider.clone();
1277 let us = self.config.us;
1278
1279 self.session_tasks.spawn(async move {
1280 match http_client
1281 .request_instruments_with_config(&provider, us)
1282 .await
1283 {
1284 Ok(instruments) => {
1285 if instruments.is_empty() {
1286 log::warn!("No instruments returned for Binance Spot");
1287 } else {
1288 http_client.cache_instruments(instruments);
1289 log::debug!("Instruments initialized");
1290 }
1291 }
1292 Err(e) => {
1293 log::warn!("Failed to request Binance Spot instruments: {e}");
1294 }
1295 }
1296 })?;
1297
1298 log::info!(
1299 "Started: client_id={}, account_id={}, account_type={:?}, environment={:?}, product_type={:?}",
1300 self.core.client_id,
1301 self.core.account_id,
1302 self.core.account_type,
1303 self.config.environment,
1304 self.config.product_type,
1305 );
1306 Ok(())
1307 }
1308
1309 fn stop(&mut self) -> anyhow::Result<()> {
1310 let was_started = self.core.is_started();
1311 self.core.set_stopped();
1312 self.begin_generation_shutdown();
1313
1314 if was_started {
1315 log::info!("Stopped: client_id={}", self.core.client_id);
1316 }
1317 Ok(())
1318 }
1319
1320 fn reset(&mut self) -> anyhow::Result<()> {
1321 self.begin_generation_shutdown();
1322 Ok(())
1323 }
1324
1325 fn dispose(&mut self) -> anyhow::Result<()> {
1326 self.begin_generation_shutdown();
1327 Ok(())
1328 }
1329
1330 async fn generate_order_status_report(
1331 &self,
1332 cmd: &GenerateOrderStatusReport,
1333 ) -> anyhow::Result<Option<OrderStatusReport>> {
1334 let Some(instrument_id) = cmd.instrument_id else {
1335 log::warn!("generate_order_status_report requires instrument_id: {cmd}");
1336 return Ok(None);
1337 };
1338
1339 anyhow::ensure!(
1340 !self.config.instrument_provider.excludes(instrument_id),
1341 "Cannot query Binance Spot order for excluded instrument {instrument_id}"
1342 );
1343
1344 let venue_order_id = cmd
1346 .venue_order_id
1347 .as_ref()
1348 .map(|id| VenueOrderId::new(id.inner()));
1349
1350 let report = self
1351 .http_client
1352 .request_order_status_report(
1353 self.core.account_id,
1354 instrument_id,
1355 venue_order_id,
1356 cmd.client_order_id,
1357 )
1358 .await?;
1359
1360 Ok(report.map(|mut report| {
1361 normalize_spot_order_status_report(&mut report, self.config.treat_expired_as_canceled);
1362 report
1363 }))
1364 }
1365
1366 async fn generate_order_status_reports(
1367 &self,
1368 cmd: &GenerateOrderStatusReports,
1369 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1370 let start_dt = cmd.start.map(|nanos| nanos.to_datetime_utc());
1371 let end_dt = cmd.end.map(|nanos| nanos.to_datetime_utc());
1372
1373 let mut reports = self
1374 .http_client
1375 .request_order_status_reports_scoped(
1376 self.core.account_id,
1377 cmd.instrument_id,
1378 start_dt,
1379 end_dt,
1380 cmd.open_only,
1381 None, Some(&self.config.instrument_provider),
1383 )
1384 .await?;
1385
1386 normalize_spot_order_status_reports(&mut reports, self.config.treat_expired_as_canceled);
1387
1388 crate::common::execution::log_report_receipt(
1389 reports.len(),
1390 "OrderStatusReport",
1391 cmd.log_receipt_level,
1392 );
1393 Ok(reports)
1394 }
1395
1396 async fn generate_fill_reports(
1397 &self,
1398 cmd: GenerateFillReports,
1399 ) -> anyhow::Result<Vec<FillReport>> {
1400 let Some(instrument_id) = cmd.instrument_id else {
1401 log::warn!("generate_fill_reports requires instrument_id for Binance Spot");
1402 return Ok(Vec::new());
1403 };
1404
1405 if self.config.instrument_provider.excludes(instrument_id) {
1406 log::debug!("Dropping out-of-scope Binance Spot report request for {instrument_id}");
1407 return Ok(Vec::new());
1408 }
1409
1410 let venue_order_id = cmd
1412 .venue_order_id
1413 .as_ref()
1414 .map(|id| VenueOrderId::new(id.inner()));
1415 let requested_start_time = cmd
1416 .start
1417 .map(|start| start.as_i64() / NANOSECONDS_IN_MILLISECOND as i64);
1418 let requested_end_time = cmd
1419 .end
1420 .map(|end| end.as_i64() / NANOSECONDS_IN_MILLISECOND as i64);
1421 if let (Some(start), Some(end)) = (requested_start_time, requested_end_time) {
1422 anyhow::ensure!(
1423 start <= end,
1424 "fill report start time must not exceed end time"
1425 );
1426 }
1427
1428 let mut reports = Vec::new();
1429 let mut seen_trade_ids = AHashSet::new();
1430
1431 if venue_order_id.is_some() {
1432 let mut from_id = 0;
1433
1434 loop {
1435 let page = self
1436 .http_client
1437 .request_fill_reports_with_cursor(
1438 self.core.account_id,
1439 instrument_id,
1440 venue_order_id,
1441 None,
1442 None,
1443 Some(from_id),
1444 Some(ACCOUNT_TRADES_PAGE_LIMIT),
1445 )
1446 .await?;
1447
1448 if page.is_empty() {
1449 break;
1450 }
1451
1452 let page_len = page.len();
1453 let max_trade_id = max_trade_id(&page)?;
1454 let passed_end = requested_end_time.is_some_and(|end_time| {
1455 page.iter().any(|report| report_time_ms(report) > end_time)
1456 });
1457
1458 reports.extend(page.into_iter().filter(|report| {
1459 requested_start_time
1460 .is_none_or(|start_time| report_time_ms(report) >= start_time)
1461 && requested_end_time
1462 .is_none_or(|end_time| report_time_ms(report) <= end_time)
1463 && seen_trade_ids.insert(report.trade_id)
1464 }));
1465
1466 if page_len < ACCOUNT_TRADES_PAGE_LIMIT as usize || passed_end {
1467 break;
1468 }
1469
1470 let next_from_id = max_trade_id
1471 .checked_add(1)
1472 .context("Binance Spot trade ID overflow during pagination")?;
1473 anyhow::ensure!(
1474 next_from_id > from_id,
1475 "Binance Spot account-trades pagination made no progress"
1476 );
1477 from_id = next_from_id;
1478 }
1479 } else if let Some(query_start_time) = requested_start_time {
1480 let query_end_time = requested_end_time.unwrap_or_else(|| {
1481 self.clock.get_time_ns().as_i64() / NANOSECONDS_IN_MILLISECOND as i64
1482 });
1483 anyhow::ensure!(
1484 query_start_time <= query_end_time,
1485 "fill report start time must not exceed end time"
1486 );
1487 let mut window_start = query_start_time;
1488
1489 loop {
1490 let window_end = window_start
1491 .saturating_add(ACCOUNT_TRADES_MAX_INTERVAL_MS)
1492 .min(query_end_time);
1493 let mut from_id = None;
1494
1495 loop {
1496 let start = if from_id.is_none() {
1497 Some(
1498 Timestamp::from_millisecond(window_start)
1499 .context("invalid Binance Spot account-trades start time")?,
1500 )
1501 } else {
1502 None
1503 };
1504 let end = if from_id.is_none() {
1505 Some(
1506 Timestamp::from_millisecond(window_end)
1507 .context("invalid Binance Spot account-trades end time")?,
1508 )
1509 } else {
1510 None
1511 };
1512 let page = self
1513 .http_client
1514 .request_fill_reports_with_cursor(
1515 self.core.account_id,
1516 instrument_id,
1517 None,
1518 start,
1519 end,
1520 from_id,
1521 Some(ACCOUNT_TRADES_PAGE_LIMIT),
1522 )
1523 .await?;
1524
1525 if page.is_empty() {
1526 break;
1527 }
1528
1529 let page_len = page.len();
1530 let max_trade_id = max_trade_id(&page)?;
1531 let passed_window_end = page
1532 .iter()
1533 .any(|report| report_time_ms(report) > window_end);
1534
1535 reports.extend(page.into_iter().filter(|report| {
1536 let report_time = report_time_ms(report);
1537 report_time >= window_start
1538 && report_time <= window_end
1539 && seen_trade_ids.insert(report.trade_id)
1540 }));
1541
1542 if page_len < ACCOUNT_TRADES_PAGE_LIMIT as usize || passed_window_end {
1543 break;
1544 }
1545
1546 let next_from_id = max_trade_id
1547 .checked_add(1)
1548 .context("Binance Spot trade ID overflow during pagination")?;
1549 anyhow::ensure!(
1550 from_id.is_none_or(|cursor| next_from_id > cursor),
1551 "Binance Spot account-trades pagination made no progress"
1552 );
1553 from_id = Some(next_from_id);
1554 }
1555
1556 if window_end >= query_end_time {
1557 break;
1558 }
1559 window_start = window_end.saturating_add(1);
1560 }
1561 } else {
1562 let mut from_id = 0;
1563
1564 loop {
1565 let page = self
1566 .http_client
1567 .request_fill_reports_with_cursor(
1568 self.core.account_id,
1569 instrument_id,
1570 None,
1571 None,
1572 None,
1573 Some(from_id),
1574 Some(ACCOUNT_TRADES_PAGE_LIMIT),
1575 )
1576 .await?;
1577
1578 if page.is_empty() {
1579 break;
1580 }
1581
1582 let page_len = page.len();
1583 let max_trade_id = max_trade_id(&page)?;
1584 let passed_end = requested_end_time.is_some_and(|end_time| {
1585 page.iter().any(|report| report_time_ms(report) > end_time)
1586 });
1587
1588 reports.extend(page.into_iter().filter(|report| {
1589 requested_end_time.is_none_or(|end_time| report_time_ms(report) <= end_time)
1590 && seen_trade_ids.insert(report.trade_id)
1591 }));
1592
1593 if page_len < ACCOUNT_TRADES_PAGE_LIMIT as usize || passed_end {
1594 break;
1595 }
1596
1597 let next_from_id = max_trade_id
1598 .checked_add(1)
1599 .context("Binance Spot trade ID overflow during pagination")?;
1600 anyhow::ensure!(
1601 next_from_id > from_id,
1602 "Binance Spot account-trades pagination made no progress"
1603 );
1604 from_id = next_from_id;
1605 }
1606 }
1607
1608 let mut reports_with_trade_ids = reports
1609 .into_iter()
1610 .map(|report| parse_trade_id(&report).map(|trade_id| (report, trade_id)))
1611 .collect::<anyhow::Result<Vec<_>>>()?;
1612 reports_with_trade_ids
1613 .sort_unstable_by_key(|(report, trade_id)| (report.ts_event, *trade_id));
1614 crate::common::execution::log_report_receipt(
1615 reports_with_trade_ids.len(),
1616 "FillReport",
1617 cmd.log_receipt_level,
1618 );
1619 Ok(reports_with_trade_ids
1620 .into_iter()
1621 .map(|(report, _)| report)
1622 .collect())
1623 }
1624
1625 async fn generate_position_status_reports(
1626 &self,
1627 cmd: &GeneratePositionStatusReports,
1628 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1629 crate::common::execution::log_report_receipt(
1632 0,
1633 "PositionStatusReport",
1634 cmd.log_receipt_level,
1635 );
1636 Ok(Vec::new())
1637 }
1638
1639 async fn generate_mass_status(
1640 &self,
1641 lookback_mins: Option<u64>,
1642 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
1643 log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
1644
1645 let ts_now = self.clock.get_time_ns();
1646
1647 let start = lookback_mins
1648 .map(DurationNanos::try_from_mins)
1649 .transpose()?
1650 .map(|lookback| ts_now.saturating_sub(lookback));
1651
1652 let order_cmd = GenerateOrderStatusReportsBuilder::default()
1655 .log_receipt_level(LogLevel::Off)
1656 .ts_init(ts_now)
1657 .open_only(true)
1658 .start(start)
1659 .build()
1660 .map_err(|e| anyhow::anyhow!("{e}"))?;
1661
1662 let position_cmd = GeneratePositionStatusReportsBuilder::default()
1663 .log_receipt_level(LogLevel::Off)
1664 .ts_init(ts_now)
1665 .start(start)
1666 .build()
1667 .map_err(|e| anyhow::anyhow!("{e}"))?;
1668
1669 let (order_reports, position_reports) = tokio::try_join!(
1670 self.generate_order_status_reports(&order_cmd),
1671 self.generate_position_status_reports(&position_cmd),
1672 )?;
1673
1674 let mut instrument_ids: Vec<_> = order_reports
1675 .iter()
1676 .map(|report| report.instrument_id)
1677 .collect();
1678 {
1679 let cache = self.core.cache();
1680 instrument_ids.extend(
1681 cache
1682 .orders_open(
1683 Some(&BINANCE_VENUE),
1684 None,
1685 None,
1686 Some(&self.core.account_id),
1687 None,
1688 )
1689 .into_iter()
1690 .chain(cache.orders_inflight(
1691 Some(&BINANCE_VENUE),
1692 None,
1693 None,
1694 Some(&self.core.account_id),
1695 None,
1696 ))
1697 .map(|order| order.instrument_id())
1698 .filter(|instrument_id| {
1699 self.http_client
1700 .get_instrument(&instrument_id.symbol.inner())
1701 .is_some_and(|instrument| instrument.id() == *instrument_id)
1702 }),
1703 );
1704 }
1705 instrument_ids.sort_unstable();
1706 instrument_ids.dedup();
1707
1708 let mut fill_reports = Vec::new();
1709
1710 for instrument_id in instrument_ids {
1711 let fill_cmd = GenerateFillReportsBuilder::default()
1712 .log_receipt_level(LogLevel::Off)
1713 .ts_init(ts_now)
1714 .instrument_id(Some(instrument_id))
1715 .start(start)
1716 .end(start.map(|_| ts_now))
1717 .build()
1718 .map_err(|e| anyhow::anyhow!("{e}"))?;
1719 fill_reports.extend(self.generate_fill_reports(fill_cmd).await?);
1720 }
1721
1722 log::info!("Received {} OrderStatusReports", order_reports.len());
1723 log::info!("Received {} FillReports", fill_reports.len());
1724 log::info!("Received {} PositionReports", position_reports.len());
1725
1726 let mut mass_status = ExecutionMassStatus::new(
1727 self.core.client_id,
1728 self.core.account_id,
1729 *BINANCE_VENUE,
1730 ts_now,
1731 None,
1732 );
1733
1734 let reported_order_ids: AHashSet<_> = order_reports
1735 .iter()
1736 .map(|report| report.venue_order_id)
1737 .collect();
1738 let cache = self.core.cache();
1739 let reports_complete = fill_reports.iter().all(|fill| {
1740 reported_order_ids.contains(&fill.venue_order_id)
1741 || cache
1742 .client_order_id(&fill.venue_order_id)
1743 .and_then(|client_order_id| cache.order(client_order_id))
1744 .is_some_and(|order| {
1745 order.instrument_id() == fill.instrument_id
1746 && order.account_id() == Some(fill.account_id)
1747 && order.order_side() == fill.order_side
1748 })
1749 });
1750 mass_status.set_report_window(start, reports_complete);
1751 mass_status.add_order_reports(order_reports);
1752 mass_status.add_fill_reports(fill_reports);
1753 mass_status.add_position_reports(position_reports);
1754
1755 Ok(Some(mass_status))
1756 }
1757
1758 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
1759 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
1760
1761 if order.is_closed() {
1762 let client_order_id = order.client_order_id();
1763 log::warn!("Cannot submit closed order {client_order_id}");
1764 return Ok(());
1765 }
1766
1767 if let Err(reason) = validate_order(&order, self.config.use_gtd) {
1768 self.emitter.emit_order_denied(&order, &reason.to_string());
1769 return Ok(());
1770 }
1771
1772 self.ensure_ws_user_data_active()?;
1773
1774 let params = if self.ws_order_transport_active() {
1775 match build_new_order_params(
1776 &order,
1777 order.client_order_id(),
1778 order.is_post_only(),
1779 order.is_quote_quantity(),
1780 self.config.use_gtd,
1781 ) {
1782 Ok(params) => Some(params),
1783 Err(e) => {
1784 let reason = OrderDeniedReason::ValidationFailed {
1785 detail: e.to_string(),
1786 };
1787 self.emitter.emit_order_denied(&order, &reason.to_string());
1788 return Ok(());
1789 }
1790 }
1791 } else {
1792 None
1793 };
1794
1795 log::debug!("OrderSubmitted client_order_id={}", order.client_order_id());
1796 self.emitter.emit_order_submitted(&order);
1797
1798 self.submit_order_internal(&cmd, params)
1799 }
1800
1801 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
1802 if cmd.order_list.client_order_ids.is_empty() {
1803 log::debug!("submit_order_list called with empty order list");
1804 return Ok(());
1805 }
1806
1807 self.ensure_ws_user_data_active()?;
1808
1809 let orders = self.core.get_orders_for_list(&cmd.order_list)?;
1810
1811 if let Some(reason) = orders
1812 .iter()
1813 .find_map(|order| validate_order(order, self.config.use_gtd).err())
1814 {
1815 let reason = reason.to_string();
1816
1817 for order in &orders {
1818 self.emitter.emit_order_denied(order, &reason);
1819 }
1820 return Ok(());
1821 }
1822
1823 if let Some(order) = orders.iter().find(|order| order.is_closed()) {
1824 let reason = format!("Cannot submit closed order {}", order.client_order_id());
1825 for order in &orders {
1826 self.emitter.emit_order_denied(order, &reason);
1827 }
1828 return Ok(());
1829 }
1830
1831 let params = match build_spot_order_list_params(
1832 cmd.order_list.id.as_ref(),
1833 &orders,
1834 self.config.use_gtd,
1835 ) {
1836 Ok(request) => request,
1837 Err(reason) => {
1838 for order in &orders {
1839 self.emitter.emit_order_denied(order, &reason);
1840 }
1841 return Ok(());
1842 }
1843 };
1844
1845 for order in &orders {
1846 self.dispatch_state.order_identities.insert(
1847 order.client_order_id(),
1848 OrderIdentity {
1849 instrument_id: order.instrument_id(),
1850 strategy_id: order.strategy_id(),
1851 order_side: order.order_side(),
1852 order_type: order.order_type(),
1853 price: order.price(),
1854 quantity: order.quantity(),
1855 is_quote_quantity: order.is_quote_quantity(),
1856 venue_position_id: None,
1857 },
1858 );
1859 self.emitter.emit_order_submitted(order);
1860 }
1861
1862 let event_emitter = self.emitter.clone();
1863 let trader_id = self.core.trader_id;
1864 let account_id = self.core.account_id;
1865 let clock = self.clock;
1866 let http_client = self.http_client.clone();
1867 let dispatch_state = self.dispatch_state.clone();
1868
1869 self.spawn_task("submit_order_list_http", async move {
1870 if let Err(e) = submit_spot_order_list(&http_client, ¶ms).await {
1871 handle_spot_order_list_submit_error(
1872 &event_emitter,
1873 &dispatch_state,
1874 trader_id,
1875 account_id,
1876 clock,
1877 &orders,
1878 e,
1879 )?;
1880 }
1881
1882 Ok(())
1883 });
1884
1885 Ok(())
1886 }
1887
1888 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1889 let order = self
1893 .core
1894 .cache()
1895 .order(&cmd.client_order_id)
1896 .map(|o| o.clone());
1897
1898 let Some(order) = order else {
1899 log::warn!(
1900 "Cannot modify order {}: not found in cache",
1901 cmd.client_order_id
1902 );
1903 let ts_init = self.clock.get_time_ns();
1904 let rejected_event = OrderModifyRejected::new(
1905 self.core.trader_id,
1906 cmd.strategy_id,
1907 cmd.instrument_id,
1908 cmd.client_order_id,
1909 "Order not found in cache for modify".into(),
1910 UUID4::new(),
1911 ts_init, ts_init,
1913 false,
1914 cmd.venue_order_id,
1915 Some(self.core.account_id),
1916 );
1917
1918 self.emitter
1919 .send_order_event(OrderEventAny::ModifyRejected(rejected_event));
1920 return Ok(());
1921 };
1922
1923 let event_emitter = self.emitter.clone();
1924 let trader_id = self.core.trader_id;
1925 let account_id = self.core.account_id;
1926 let clock = self.clock;
1927
1928 let order_side = order.order_side();
1929 let order_type = order.order_type();
1930 let time_in_force = order.time_in_force();
1931 let quantity = cmd.quantity.unwrap_or_else(|| order.quantity());
1932 let use_gtd = self.config.use_gtd;
1933 let dispatch_state = self.dispatch_state.clone();
1934
1935 if self.ws_order_transport_active() {
1936 let command = cmd;
1937 let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
1938 let cancel_id = cancel_replace_cancel_id();
1939 dispatch_state.insert_cancel_replace(cancel_id.clone());
1940 let params = build_cancel_replace_params(
1941 &command,
1942 &order,
1943 quantity,
1944 use_gtd,
1945 cancel_id.clone(),
1946 )?;
1947
1948 if let Some(venue_order_id) = command.venue_order_id {
1949 dispatch_state.begin_replace(command.client_order_id, venue_order_id);
1950 }
1951
1952 let request_id = ws_client.next_request_id();
1954 dispatch_state.pending_requests.insert(
1955 request_id.clone(),
1956 PendingRequest {
1957 client_order_id: command.client_order_id,
1958 venue_order_id: command.venue_order_id,
1959 operation: PendingOperation::Modify,
1960 },
1961 );
1962 dispatch_state
1963 .cancel_replace_request_ids
1964 .insert(request_id.clone(), cancel_id.clone());
1965
1966 let http_client = self.http_client.clone();
1967 self.spawn_task("modify_order_ws", async move {
1968 let mut params = params;
1969 http_client
1970 .inner()
1971 .resolve_cancel_replace(&mut params)
1972 .await
1973 .inspect_err(|e| {
1974 dispatch_state.pending_requests.remove(&request_id);
1975 dispatch_state
1976 .cancel_replace_request_ids
1977 .remove(&request_id);
1978 handle_http_modify_failure(
1979 &anyhow::anyhow!(BinanceSpotHttpError::ValidationError(e.to_string())),
1980 &command,
1981 &cancel_id,
1982 &event_emitter,
1983 &dispatch_state,
1984 account_id,
1985 clock.get_time_ns(),
1986 );
1987 })?;
1988
1989 let result = ws_client
1990 .cancel_replace_order_with_id(request_id.clone(), params)
1991 .await;
1992
1993 if let Err(e) = result {
1994 dispatch_state.pending_requests.remove(&request_id);
1995 dispatch_state
1996 .cancel_replace_request_ids
1997 .remove(&request_id);
1998 log::warn!(
1999 "WS modify request failed for {}, awaiting reconciliation: {e}",
2000 command.client_order_id
2001 );
2002 anyhow::bail!("WS modify order failed: {e}");
2003 }
2004 Ok(())
2005 });
2006 } else {
2007 let command = cmd;
2008 let http_client = self.http_client.clone();
2009 log::debug!("WS trading not active, falling back to HTTP for modify_order");
2010 let dispatch_state = self.dispatch_state.clone();
2011 let cancel_id = cancel_replace_cancel_id();
2012 dispatch_state.insert_cancel_replace(cancel_id.clone());
2013
2014 if let Some(venue_order_id) = command.venue_order_id {
2015 dispatch_state.begin_replace(command.client_order_id, venue_order_id);
2016 }
2017
2018 self.spawn_task("modify_order_http", async move {
2019 let result = match command.venue_order_id {
2020 Some(venue_order_id) => {
2021 http_client
2022 .modify_order(
2023 account_id,
2024 command.instrument_id,
2025 venue_order_id,
2026 command.client_order_id,
2027 order_side,
2028 order_type,
2029 quantity,
2030 time_in_force,
2031 command.price,
2032 use_gtd,
2033 &cancel_id,
2034 )
2035 .await
2036 }
2037 None => Err(anyhow::anyhow!(BinanceSpotHttpError::ValidationError(
2038 "venue_order_id required for modify".to_string()
2039 ))),
2040 };
2041
2042 match result {
2043 Ok(report) => {
2044 let Some(price) = report.price else {
2045 anyhow::bail!("Spot replacement response has no price");
2046 };
2047
2048 if !dispatch_state.record_order_update(
2049 command.client_order_id,
2050 report.venue_order_id,
2051 report.quantity,
2052 price,
2053 report.trigger_price,
2054 ) {
2055 return Ok(());
2056 }
2057 let ts_now = clock.get_time_ns();
2058 let updated_event = OrderUpdated::new(
2059 trader_id,
2060 command.strategy_id,
2061 command.instrument_id,
2062 command.client_order_id,
2063 report.quantity,
2064 UUID4::new(),
2065 ts_now,
2066 ts_now,
2067 false,
2068 Some(report.venue_order_id),
2069 Some(account_id),
2070 report.price,
2071 None, None, false, );
2075 event_emitter.send_order_event(OrderEventAny::Updated(updated_event));
2076 }
2077 Err(e) => {
2078 handle_http_modify_failure(
2079 &e,
2080 &command,
2081 &cancel_id,
2082 &event_emitter,
2083 &dispatch_state,
2084 account_id,
2085 clock.get_time_ns(),
2086 );
2087 return Err(e);
2088 }
2089 }
2090 Ok(())
2091 });
2092 }
2093
2094 Ok(())
2095 }
2096
2097 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
2098 self.cancel_order_internal(&cmd);
2099 Ok(())
2100 }
2101
2102 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
2103 if cmd.order_side.is_some() {
2104 let cancels: Vec<CancelOrder> = {
2106 let cache = self.core.cache();
2107 cache
2108 .orders_open(None, Some(&cmd.instrument_id), None, None, cmd.order_side)
2109 .into_iter()
2110 .map(|order| CancelOrder {
2111 trader_id: order.trader_id(),
2112 client_id: cmd.client_id,
2113 strategy_id: order.strategy_id(),
2114 instrument_id: order.instrument_id(),
2115 client_order_id: order.client_order_id(),
2116 venue_order_id: order.venue_order_id(),
2117 command_id: cmd.command_id,
2118 ts_init: cmd.ts_init,
2119 params: cmd.params.clone(),
2120 correlation_id: cmd.correlation_id,
2121 causation_id: cmd.causation_id,
2122 })
2123 .collect()
2124 };
2125
2126 if cancels.is_empty() {
2127 log::debug!("No open orders to cancel for {}", cmd.instrument_id);
2128 return Ok(());
2129 }
2130
2131 return self.batch_cancel_orders(BatchCancelOrders {
2132 trader_id: cmd.trader_id,
2133 client_id: cmd.client_id,
2134 strategy_id: cmd.strategy_id,
2135 instrument_id: cmd.instrument_id,
2136 cancels,
2137 command_id: cmd.command_id,
2138 ts_init: cmd.ts_init,
2139 params: cmd.params,
2140 correlation_id: cmd.correlation_id,
2141 causation_id: cmd.causation_id,
2142 });
2143 }
2144
2145 let event_emitter = self.emitter.clone();
2146 let trader_id = self.core.trader_id;
2147 let account_id = self.core.account_id;
2148 let clock = self.clock;
2149
2150 if self.ws_order_transport_active() {
2151 let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
2152 let symbol = cmd.instrument_id.symbol.to_string();
2153
2154 self.spawn_task("cancel_all_orders_ws", async move {
2155 if let Err(e) = ws_client.cancel_all_orders(symbol).await {
2156 log::error!("WS cancel_all_orders failed: {e}");
2157 }
2158 Ok(())
2160 });
2161
2162 return Ok(());
2163 }
2164
2165 log::debug!("WS trading not active, falling back to HTTP for cancel_all_orders");
2166 let http_client = self.http_client.clone();
2167 let dispatch_state = self.dispatch_state.clone();
2168 let lifecycle_lock = self.lifecycle_lock.clone();
2169
2170 let strategy_lookup: AHashMap<ClientOrderId, StrategyId> = {
2172 let cache = self.core.cache();
2173 cache
2174 .orders_open(None, Some(&cmd.instrument_id), None, None, None)
2175 .into_iter()
2176 .map(|order| (order.client_order_id(), order.strategy_id()))
2177 .collect()
2178 };
2179
2180 let command = cmd;
2181 self.spawn_task("cancel_all_orders_http", async move {
2182 let responses = http_client
2183 .cancel_all_order_responses(command.instrument_id)
2184 .await?;
2185 let canceled_orders = prepare_cancel_all_orders(responses)?;
2186 anyhow::ensure!(
2187 canceled_orders
2188 .iter()
2189 .all(|order| order.instrument_id == command.instrument_id),
2190 "cancel-all response contains an order for a different instrument",
2191 );
2192 let _lifecycle_guard = lifecycle_lock.lock();
2193
2194 for canceled_order in canceled_orders {
2195 let client_order_id = canceled_order.client_order_id;
2196 if canceled_order.order_list {
2197 dispatch_order_list_canceled(
2198 &canceled_order,
2199 &event_emitter,
2200 account_id,
2201 &dispatch_state,
2202 clock.get_time_ns(),
2203 );
2204 continue;
2205 }
2206 let strategy_id = strategy_lookup
2207 .get(&client_order_id)
2208 .copied()
2209 .unwrap_or(command.strategy_id);
2210
2211 let canceled_event = OrderCanceled::new(
2212 trader_id,
2213 strategy_id,
2214 command.instrument_id,
2215 client_order_id,
2216 UUID4::new(),
2217 command.ts_init,
2218 clock.get_time_ns(),
2219 false,
2220 Some(canceled_order.venue_order_id),
2221 Some(account_id),
2222 None,
2223 );
2224
2225 event_emitter.send_order_event(OrderEventAny::Canceled(canceled_event));
2226 }
2227
2228 Ok(())
2229 });
2230
2231 Ok(())
2232 }
2233
2234 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
2235 if cmd.cancels.is_empty() {
2236 return Ok(());
2237 }
2238
2239 for cancel in &cmd.cancels {
2241 self.cancel_order_internal(cancel);
2242 }
2243
2244 Ok(())
2245 }
2246}
2247
2248fn validate_order(order: &impl Order, use_gtd: bool) -> Result<(), OrderDeniedReason> {
2249 if order.is_reduce_only() {
2250 return Err(OrderDeniedReason::UnsupportedReduceOnly);
2251 }
2252
2253 let order_type = order.order_type();
2254 let venue_type = order_type_to_binance_spot(order_type, order.is_post_only())
2255 .map_err(|_| OrderDeniedReason::UnsupportedOrderType { order_type })?;
2256
2257 if matches!(
2258 venue_type,
2259 BinanceSpotOrderType::Limit
2260 | BinanceSpotOrderType::StopLossLimit
2261 | BinanceSpotOrderType::TakeProfitLimit
2262 ) {
2263 time_in_force_to_binance_spot(order.time_in_force(), use_gtd)
2264 .map_err(|_| OrderDeniedReason::UnsupportedTimeInForce(order.time_in_force()))?;
2265 }
2266
2267 if matches!(
2268 order_type,
2269 OrderType::StopMarket
2270 | OrderType::StopLimit
2271 | OrderType::MarketIfTouched
2272 | OrderType::LimitIfTouched
2273 ) && order.trigger_price().is_none()
2274 {
2275 return Err(OrderDeniedReason::ValidationFailed {
2276 detail: "Conditional orders require a trigger price".to_string(),
2277 });
2278 }
2279
2280 if order.is_quote_quantity() && order_type != OrderType::Market {
2281 return Err(OrderDeniedReason::ValidationFailed {
2282 detail: "Quote quantity requires a MARKET order on Binance Spot".to_string(),
2283 });
2284 }
2285
2286 Ok(())
2287}
2288
2289fn max_trade_id(reports: &[FillReport]) -> anyhow::Result<i64> {
2290 let mut max_trade_id = None;
2291
2292 for report in reports {
2293 let trade_id = parse_trade_id(report)?;
2294 max_trade_id = Some(max_trade_id.map_or(trade_id, |current: i64| current.max(trade_id)));
2295 }
2296
2297 max_trade_id.context("Binance Spot account-trades page was empty")
2298}
2299
2300fn parse_trade_id(report: &FillReport) -> anyhow::Result<i64> {
2301 report
2302 .trade_id
2303 .to_string()
2304 .parse::<i64>()
2305 .with_context(|| format!("invalid Binance Spot trade ID {}", report.trade_id))
2306}
2307
2308fn report_time_ms(report: &FillReport) -> i64 {
2309 report.ts_event.as_i64() / NANOSECONDS_IN_MILLISECOND as i64
2310}
2311
2312fn normalize_spot_order_status_report(
2313 report: &mut OrderStatusReport,
2314 treat_expired_as_canceled: bool,
2315) {
2316 if treat_expired_as_canceled && report.order_status == OrderStatus::Expired {
2317 report.order_status = OrderStatus::Canceled;
2318 }
2319}
2320
2321fn normalize_spot_order_status_reports(
2322 reports: &mut [OrderStatusReport],
2323 treat_expired_as_canceled: bool,
2324) {
2325 for report in reports {
2326 normalize_spot_order_status_report(report, treat_expired_as_canceled);
2327 }
2328}
2329
2330async fn wait_for_ws_setup_response(
2331 timeout: Duration,
2332 success: impl Future<Output = ()>,
2333 setup_errors: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
2334 timeout_message: &'static str,
2335) -> anyhow::Result<()> {
2336 tokio::pin!(success);
2337
2338 let result = tokio::time::timeout(timeout, async {
2339 tokio::select! {
2340 () = &mut success => Ok(()),
2341 err = setup_errors.recv() => {
2342 anyhow::bail!(
2343 "{}",
2344 err.unwrap_or_else(|| "WS setup error channel closed".to_string()),
2345 )
2346 }
2347 }
2348 })
2349 .await;
2350
2351 result.map_err(|_| anyhow::anyhow!(timeout_message))?
2352}
2353
2354#[expect(clippy::too_many_arguments)]
2355fn dispatch_ws_trading_message(
2356 msg: BinanceSpotWsTradingMessage,
2357 emitter: &ExecutionEventEmitter,
2358 http_client: &BinanceSpotHttpClient,
2359 account_id: AccountId,
2360 treat_expired_as_canceled: bool,
2361 clock: &'static AtomicTime,
2362 dispatch_state: &WsDispatchState,
2363 ws_authenticated: &tokio::sync::Notify,
2364 ws_user_data_subscribed: &tokio::sync::Notify,
2365 ws_setup_error_tx: &tokio::sync::mpsc::UnboundedSender<String>,
2366 seen_trade_ids: &std::sync::Arc<Mutex<FifoCache<(Ustr, i64), 10_000>>>,
2367 task_spawner: &TaskSpawner,
2368) {
2369 match msg {
2370 BinanceSpotWsTradingMessage::OrderAccepted {
2371 request_id,
2372 response,
2373 } => {
2374 dispatch_state.pending_requests.remove(&request_id);
2375 log::debug!(
2376 "WS order accepted: request_id={request_id}, order_id={}",
2377 response.order_id
2378 );
2379 }
2381 BinanceSpotWsTradingMessage::OrderRejected {
2382 request_id,
2383 status,
2384 code,
2385 msg,
2386 } => {
2387 log::debug!(
2388 "WS order rejected: request_id={request_id}, status={status}, code={code}, msg={msg}"
2389 );
2390
2391 if let Some((_, pending)) = dispatch_state.pending_requests.remove(&request_id) {
2392 let code_i64 = i64::from(code);
2393 let reason = format!("code={code}: {msg}");
2394
2395 match classify_venue_failure(Some(code_i64), Some(status), &reason) {
2396 CommandFailure::Ambiguous(_) => {
2397 log::warn!(
2398 "Ambiguous WS submit failure for {}, awaiting reconciliation: {reason}",
2399 pending.client_order_id,
2400 );
2401 return;
2402 }
2403 CommandFailure::NotSent(_) | CommandFailure::VenueRejected(_) => {}
2404 }
2405
2406 let identity = dispatch_state
2408 .order_identities
2409 .get(&pending.client_order_id)
2410 .map(|r| r.clone());
2411
2412 if let Some(identity) = identity {
2413 let due_post_only = code_i64 == BINANCE_GTX_ORDER_REJECT_CODE
2414 || (code_i64 == BINANCE_NEW_ORDER_REJECTED_CODE
2415 && msg == BINANCE_SPOT_POST_ONLY_REJECT_MSG);
2416 let ts_now = clock.get_time_ns();
2417 let rejected = OrderRejected::new(
2418 emitter.trader_id(),
2419 identity.strategy_id,
2420 identity.instrument_id,
2421 pending.client_order_id,
2422 account_id,
2423 Ustr::from(&sanitize_reason(&reason)),
2424 UUID4::new(),
2425 ts_now,
2426 ts_now,
2427 false,
2428 due_post_only,
2429 );
2430 dispatch_state.cleanup_terminal(pending.client_order_id);
2431 emitter.send_order_event(OrderEventAny::Rejected(rejected));
2432 } else {
2433 log::warn!(
2434 "No order identity for {}, cannot emit OrderRejected",
2435 pending.client_order_id
2436 );
2437 }
2438 } else {
2439 log::warn!("No pending request for {request_id}, cannot emit OrderRejected");
2440 }
2441 }
2442 BinanceSpotWsTradingMessage::OrderCanceled {
2443 request_id,
2444 response,
2445 } => {
2446 dispatch_state.pending_requests.remove(&request_id);
2447 log::debug!(
2448 "WS order canceled: request_id={request_id}, order_id={}",
2449 response.order_id
2450 );
2451 }
2453 BinanceSpotWsTradingMessage::CancelRejected {
2454 request_id,
2455 status,
2456 code,
2457 msg,
2458 } => {
2459 log::debug!(
2460 "WS cancel rejected: request_id={request_id}, status={status}, code={code}, msg={msg}"
2461 );
2462
2463 if let Some((_, pending)) = dispatch_state.pending_requests.remove(&request_id) {
2464 let reason = format!("code={code}: {msg}");
2465
2466 match classify_venue_failure(Some(i64::from(code)), Some(status), &reason) {
2467 CommandFailure::Ambiguous(_) => {
2468 log::warn!(
2469 "Ambiguous WS cancel failure for {}, awaiting reconciliation: {reason}",
2470 pending.client_order_id,
2471 );
2472 return;
2473 }
2474 CommandFailure::NotSent(_) | CommandFailure::VenueRejected(_) => {}
2475 }
2476
2477 if let Some(identity) = dispatch_state
2478 .order_identities
2479 .get(&pending.client_order_id)
2480 {
2481 let ts_now = clock.get_time_ns();
2482 let rejected = OrderCancelRejected::new(
2483 emitter.trader_id(),
2484 identity.strategy_id,
2485 identity.instrument_id,
2486 pending.client_order_id,
2487 Ustr::from(&sanitize_reason(&reason)),
2488 UUID4::new(),
2489 ts_now,
2490 ts_now,
2491 false,
2492 pending.venue_order_id,
2493 Some(account_id),
2494 );
2495 emitter.send_order_event(OrderEventAny::CancelRejected(rejected));
2496 }
2497 }
2498 }
2499 BinanceSpotWsTradingMessage::CancelReplaceAccepted {
2500 request_id,
2501 cancel_response,
2502 new_order_response,
2503 } => {
2504 dispatch_state.pending_requests.remove(&request_id);
2505 dispatch_state
2506 .cancel_replace_request_ids
2507 .remove(&request_id);
2508 log::debug!(
2509 "WS cancel-replace accepted: request_id={request_id}, \
2510 canceled_id={}, new_id={}",
2511 cancel_response.order_id,
2512 new_order_response.order_id,
2513 );
2514 }
2516 BinanceSpotWsTradingMessage::CancelReplaceRejected {
2517 request_id,
2518 status,
2519 code,
2520 msg,
2521 } => {
2522 log::debug!(
2523 "WS cancel-replace rejected: request_id={request_id}, status={status}, code={code}, msg={msg}"
2524 );
2525
2526 let reason = format!("code={code}: {msg}");
2527 let is_ambiguous = matches!(
2528 classify_venue_failure(Some(i64::from(code)), Some(status), &reason),
2529 CommandFailure::Ambiguous(_)
2530 );
2531 let confirmed_cancel = dispatch_state
2532 .cancel_replace_request_ids
2533 .remove(&request_id)
2534 .filter(|_| !is_ambiguous)
2535 .and_then(|(_, cancel_id)| dispatch_state.on_cancel_replace_rejected(&cancel_id));
2536 let pending = dispatch_state
2537 .pending_requests
2538 .remove(&request_id)
2539 .map(|(_, pending)| pending);
2540
2541 if is_ambiguous {
2542 log::warn!("Ambiguous WS modify failure, awaiting reconciliation: {reason}");
2543 return;
2544 }
2545
2546 if let Some(pending) = &pending {
2547 if let Some(canceled) = dispatch_state.reject_replace(pending.client_order_id) {
2548 dispatch_state.cleanup_terminal(pending.client_order_id);
2549 emitter.send_order_event(OrderEventAny::Canceled(canceled));
2550 return;
2551 }
2552
2553 if let Some(identity) = dispatch_state
2554 .order_identities
2555 .get(&pending.client_order_id)
2556 {
2557 let ts_now = clock.get_time_ns();
2558 let rejected = OrderModifyRejected::new(
2559 emitter.trader_id(),
2560 identity.strategy_id,
2561 identity.instrument_id,
2562 pending.client_order_id,
2563 Ustr::from(&sanitize_reason(&reason)),
2564 UUID4::new(),
2565 ts_now,
2566 ts_now,
2567 false,
2568 pending.venue_order_id,
2569 Some(account_id),
2570 );
2571 emitter.send_order_event(OrderEventAny::ModifyRejected(rejected));
2572 }
2573 }
2574
2575 if let Some(report) = confirmed_cancel {
2576 emit_canceled_after_rejected_replace(
2577 emitter,
2578 dispatch_state,
2579 account_id,
2580 report,
2581 clock.get_time_ns(),
2582 );
2583 }
2584 }
2585 BinanceSpotWsTradingMessage::RequestFailed { request_id, msg } => {
2586 dispatch_state.pending_requests.remove(&request_id);
2587 dispatch_state
2588 .cancel_replace_request_ids
2589 .remove(&request_id);
2590 log::warn!(
2591 "WS trading request failed without structured venue response: request_id={request_id}, {msg}"
2592 );
2593 }
2594 BinanceSpotWsTradingMessage::AllOrdersCanceled {
2595 request_id,
2596 responses,
2597 } => {
2598 dispatch_state.pending_requests.remove(&request_id);
2599 log::debug!(
2600 "WS all orders canceled: request_id={request_id}, count={}",
2601 responses.len()
2602 );
2603
2604 match prepare_cancel_all_orders(responses) {
2605 Ok(canceled_orders) => {
2606 let ts_init = clock.get_time_ns();
2607
2608 for canceled_order in canceled_orders
2609 .iter()
2610 .filter(|canceled_order| canceled_order.order_list)
2611 {
2612 dispatch_order_list_canceled(
2613 canceled_order,
2614 emitter,
2615 account_id,
2616 dispatch_state,
2617 ts_init,
2618 );
2619 }
2620 }
2621 Err(e) => log::error!(
2622 "Ignoring invalid WS cancel-all response {request_id} to avoid false terminal events: {e}"
2623 ),
2624 }
2625 }
2626 BinanceSpotWsTradingMessage::UserDataSubscribed { subscription_id } => {
2627 log::debug!("User data stream subscribed: id={subscription_id}");
2628 ws_user_data_subscribed.notify_one();
2629 }
2630 BinanceSpotWsTradingMessage::ExecutionReport(report) => {
2631 let ts_init = clock.get_time_ns();
2632 dispatch_execution_report(
2633 &report,
2634 emitter,
2635 http_client,
2636 account_id,
2637 treat_expired_as_canceled,
2638 dispatch_state,
2639 seen_trade_ids,
2640 ts_init,
2641 );
2642 }
2643 BinanceSpotWsTradingMessage::AccountPosition(position) => {
2644 let ts_init = clock.get_time_ns();
2645 let state = parse_spot_account_position(&position, account_id, ts_init);
2646 emitter.send_account_state(state);
2647 }
2648 BinanceSpotWsTradingMessage::BalanceUpdate(update) => {
2649 log::debug!(
2650 "Balance update: asset={}, delta={}",
2651 update.asset,
2652 update.delta,
2653 );
2654 let http_client = http_client.clone();
2655 let emitter = emitter.clone();
2656
2657 if let Err(e) = task_spawner.spawn(async move {
2658 match http_client.request_account_state(account_id).await {
2659 Ok(state) => emitter.send_account_state(state),
2660 Err(e) => {
2661 log::warn!("Failed to refresh account state after balance update: {e}");
2662 }
2663 }
2664 }) {
2665 log::warn!("Skipping Binance Spot balance refresh after shutdown began: {e}");
2666 }
2667 }
2668 BinanceSpotWsTradingMessage::Connected => {
2669 log::debug!("WS trading API connected");
2670 }
2671 BinanceSpotWsTradingMessage::Authenticated => {
2672 log::debug!("WS trading API authenticated");
2673 ws_authenticated.notify_one();
2674 }
2675 BinanceSpotWsTradingMessage::AuthenticationRejected(reason) => {
2676 log::error!("WS trading API authentication failed: {reason}");
2677 let _ = ws_setup_error_tx.send(reason);
2678 }
2679 BinanceSpotWsTradingMessage::Reconnected => {
2680 log::info!("WS trading API reconnected");
2681 }
2682 BinanceSpotWsTradingMessage::ServerShutdown { event_time } => {
2683 log::warn!(
2684 "WS trading API server shutdown notice (event_time={event_time}); reconnect expected within ~10 minutes"
2685 );
2686 }
2687 BinanceSpotWsTradingMessage::Error(err) => {
2688 log::error!("WS trading API error: {err}");
2689 let _ = ws_setup_error_tx.send(err);
2690 }
2691 BinanceSpotWsTradingMessage::UserDataSubscriptionRejected(reason) => {
2692 log::error!("WS trading API user data subscription failed: {reason}");
2693 let _ = ws_setup_error_tx.send(reason);
2694 }
2695 }
2696}
2697
2698#[derive(Debug)]
2699struct PreparedCancelOrder {
2700 venue_order_id: VenueOrderId,
2701 transaction_time: i64,
2702 client_order_id: ClientOrderId,
2703 instrument_id: InstrumentId,
2704 order_list: bool,
2705}
2706
2707fn prepare_cancel_all_orders(
2708 responses: Vec<BinanceCancelOpenOrdersResponse>,
2709) -> anyhow::Result<Vec<PreparedCancelOrder>> {
2710 let mut prepared = Vec::new();
2711 let mut order_ids = AHashSet::new();
2712 let mut client_order_ids = AHashSet::new();
2713
2714 for response in responses {
2715 match response {
2716 BinanceCancelOpenOrdersResponse::Order(response) => prepare_cancel_order(
2717 &response,
2718 false,
2719 &mut order_ids,
2720 &mut client_order_ids,
2721 &mut prepared,
2722 )?,
2723 BinanceCancelOpenOrdersResponse::OrderList(response) => {
2724 prepare_cancel_order_list(
2725 response,
2726 &mut order_ids,
2727 &mut client_order_ids,
2728 &mut prepared,
2729 )?;
2730 }
2731 }
2732 }
2733
2734 Ok(prepared)
2735}
2736
2737fn prepare_cancel_order_list(
2738 response: BinanceCancelOrderListResponse,
2739 order_ids: &mut AHashSet<(InstrumentId, i64)>,
2740 client_order_ids: &mut AHashSet<ClientOrderId>,
2741 prepared: &mut Vec<PreparedCancelOrder>,
2742) -> anyhow::Result<()> {
2743 anyhow::ensure!(
2744 response.order_list_id >= 0 && !response.symbol.is_empty(),
2745 "order list has an invalid list ID or empty symbol",
2746 );
2747 anyhow::ensure!(
2748 response.list_status_type == SbeListStatusType::AllDone
2749 && response.list_order_status == SbeListOrderStatus::AllDone,
2750 "order list {} was not fully canceled: status={:?}, order_status={:?}",
2751 response.order_list_id,
2752 response.list_status_type,
2753 response.list_order_status,
2754 );
2755 anyhow::ensure!(
2756 !response.orders.is_empty() && response.orders.len() == response.order_reports.len(),
2757 "order list {} has {} orders and {} reports",
2758 response.order_list_id,
2759 response.orders.len(),
2760 response.order_reports.len(),
2761 );
2762
2763 let mut list_orders = AHashMap::with_capacity(response.orders.len());
2764 for order in response.orders {
2765 anyhow::ensure!(
2766 order.symbol == response.symbol,
2767 "order list {} contains order {} for symbol {}, expected {}",
2768 response.order_list_id,
2769 order.order_id,
2770 order.symbol,
2771 response.symbol,
2772 );
2773 anyhow::ensure!(
2774 list_orders.insert(order.order_id, order).is_none(),
2775 "order list {} contains a duplicate order ID",
2776 response.order_list_id,
2777 );
2778 }
2779
2780 for report in response.order_reports {
2781 anyhow::ensure!(
2782 report.order_list_id == Some(response.order_list_id),
2783 "order {} reports order-list ID {:?}, expected {}",
2784 report.order_id,
2785 report.order_list_id,
2786 response.order_list_id,
2787 );
2788 let order = list_orders.remove(&report.order_id).with_context(|| {
2789 format!(
2790 "order-list report {} is absent from list {}",
2791 report.order_id, response.order_list_id
2792 )
2793 })?;
2794 anyhow::ensure!(
2795 report.symbol == response.symbol
2796 && report.symbol == order.symbol
2797 && report.orig_client_order_id == order.client_order_id,
2798 "order-list report {} does not match its order identity",
2799 report.order_id,
2800 );
2801 prepare_cancel_order(&report, true, order_ids, client_order_ids, prepared)?;
2802 }
2803
2804 anyhow::ensure!(
2805 list_orders.is_empty(),
2806 "order list {} is missing {} child reports",
2807 response.order_list_id,
2808 list_orders.len(),
2809 );
2810 Ok(())
2811}
2812
2813fn prepare_cancel_order(
2814 response: &BinanceCancelOrderResponse,
2815 order_list: bool,
2816 order_ids: &mut AHashSet<(InstrumentId, i64)>,
2817 client_order_ids: &mut AHashSet<ClientOrderId>,
2818 prepared: &mut Vec<PreparedCancelOrder>,
2819) -> anyhow::Result<()> {
2820 anyhow::ensure!(
2821 response.order_id >= 0
2822 && !response.symbol.is_empty()
2823 && !response.orig_client_order_id.is_empty(),
2824 "cancel-all response has an invalid order ID, symbol, or original client order ID",
2825 );
2826 anyhow::ensure!(
2827 response.status == SbeOrderStatus::Canceled,
2828 "order {} reports status {:?}, expected Canceled",
2829 response.order_id,
2830 response.status,
2831 );
2832 let client_order_id = decode_client_order_id(
2833 &response.orig_client_order_id,
2834 BINANCE_NAUTILUS_SPOT_BROKER_ID,
2835 )?;
2836 let instrument_id = InstrumentId::new(response.symbol.as_str().into(), *BINANCE_VENUE);
2837 anyhow::ensure!(
2838 order_ids.insert((instrument_id, response.order_id)),
2839 "cancel-all response contains duplicate order ID {} for {}",
2840 response.order_id,
2841 instrument_id,
2842 );
2843 anyhow::ensure!(
2844 client_order_ids.insert(client_order_id),
2845 "cancel-all response contains duplicate client order ID {client_order_id}",
2846 );
2847 prepared.push(PreparedCancelOrder {
2848 venue_order_id: VenueOrderId::new(response.order_id.to_string()),
2849 transaction_time: response.transact_time,
2850 client_order_id,
2851 instrument_id,
2852 order_list,
2853 });
2854 Ok(())
2855}
2856
2857fn dispatch_order_list_canceled(
2858 canceled_order: &PreparedCancelOrder,
2859 emitter: &ExecutionEventEmitter,
2860 account_id: AccountId,
2861 state: &WsDispatchState,
2862 ts_init: UnixNanos,
2863) {
2864 let client_order_id = canceled_order.client_order_id;
2865 let identity = state
2866 .order_identities
2867 .remove_if(&client_order_id, |_, identity| {
2868 identity.instrument_id == canceled_order.instrument_id
2869 });
2870
2871 let Some((_, identity)) = identity else {
2872 if state.order_identities.contains_key(&client_order_id) {
2873 log::error!(
2874 "Ignoring cancel-all result for {client_order_id}: tracked instrument does not match {}",
2875 canceled_order.instrument_id,
2876 );
2877 } else {
2878 state
2879 .pending_requests
2880 .retain(|_, pending| pending.client_order_id != client_order_id);
2881 log::debug!("Skipping duplicate cancel-all result for {client_order_id}");
2882 }
2883 return;
2884 };
2885
2886 state
2887 .pending_requests
2888 .retain(|_, pending| pending.client_order_id != client_order_id);
2889 let ts_event = parse_micros_or_init(
2890 canceled_order.transaction_time,
2891 "Spot cancel-all transaction time",
2892 ts_init,
2893 );
2894 ensure_accepted_emitted(
2895 client_order_id,
2896 account_id,
2897 canceled_order.venue_order_id,
2898 &identity,
2899 emitter,
2900 state,
2901 ts_event,
2902 );
2903 state.cleanup_terminal(client_order_id);
2904 let canceled = OrderCanceled::new(
2905 emitter.trader_id(),
2906 identity.strategy_id,
2907 identity.instrument_id,
2908 client_order_id,
2909 UUID4::new(),
2910 ts_event,
2911 ts_init,
2912 false,
2913 Some(canceled_order.venue_order_id),
2914 Some(account_id),
2915 None,
2916 );
2917 emitter.send_order_event(OrderEventAny::Canceled(canceled));
2918}
2919
2920fn build_new_order_params(
2921 order: &impl Order,
2922 client_order_id: ClientOrderId,
2923 is_post_only: bool,
2924 is_quote_quantity: bool,
2925 use_gtd: bool,
2926) -> anyhow::Result<NewOrderParams> {
2927 let binance_side = BinanceSide::try_from(order.order_side())?;
2928 let binance_order_type = order_type_to_binance_spot(order.order_type(), is_post_only)?;
2929
2930 let requires_trigger = matches!(
2931 order.order_type(),
2932 OrderType::StopMarket
2933 | OrderType::StopLimit
2934 | OrderType::MarketIfTouched
2935 | OrderType::LimitIfTouched
2936 );
2937
2938 if requires_trigger && order.trigger_price().is_none() {
2939 anyhow::bail!("Conditional orders require a trigger price");
2940 }
2941
2942 let supports_tif = matches!(
2943 binance_order_type,
2944 BinanceSpotOrderType::Limit
2945 | BinanceSpotOrderType::StopLossLimit
2946 | BinanceSpotOrderType::TakeProfitLimit
2947 );
2948 let binance_tif = if supports_tif {
2949 Some(time_in_force_to_binance_spot(
2950 order.time_in_force(),
2951 use_gtd,
2952 )?)
2953 } else {
2954 None
2955 };
2956
2957 let qty_str = order.quantity().to_string();
2958 let (base_qty, quote_qty) = if is_quote_quantity {
2959 (None, Some(qty_str))
2960 } else {
2961 (Some(qty_str), None)
2962 };
2963
2964 let client_id_str = encode_broker_id(&client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
2965
2966 Ok(NewOrderParams {
2967 symbol: order.instrument_id().symbol.to_string(),
2968 side: binance_side,
2969 order_type: binance_order_type,
2970 time_in_force: binance_tif,
2971 quantity: base_qty,
2972 quote_order_qty: quote_qty,
2973 price: order.price().map(|p| p.to_string()),
2974 new_client_order_id: Some(client_id_str),
2975 stop_price: order.trigger_price().map(|p| p.to_string()),
2976 trailing_delta: None,
2977 iceberg_qty: order.display_qty().map(|q| q.to_string()),
2978 new_order_resp_type: Some(BinanceOrderResponseType::Full),
2979 self_trade_prevention_mode: None,
2980 strategy_id: None,
2981 strategy_type: None,
2982 })
2983}
2984
2985fn build_spot_order_list_params(
2986 order_list_id: &str,
2987 orders: &[OrderAny],
2988 use_gtd: bool,
2989) -> Result<NewOcoOrderListParams, String> {
2990 let has_grouped_order = orders.iter().any(is_grouped_order);
2991
2992 if has_grouped_order {
2993 return build_spot_oco_order_list_params(order_list_id, orders, use_gtd);
2994 }
2995
2996 Err("Binance Spot order-list submission currently supports only OCO lists".to_string())
2997}
2998
2999fn build_spot_oco_order_list_params(
3000 order_list_id: &str,
3001 orders: &[OrderAny],
3002 use_gtd: bool,
3003) -> Result<NewOcoOrderListParams, String> {
3004 if orders.len() != 2 {
3005 return Err(format!(
3006 "Binance Spot OCO order-list submission requires exactly 2 orders, was {}",
3007 orders.len()
3008 ));
3009 }
3010
3011 if orders
3012 .iter()
3013 .any(|order| order.contingency_type() != Some(ContingencyType::Oco))
3014 {
3015 return Err(
3016 "Binance Spot grouped order-list submission currently supports only OCO lists"
3017 .to_string(),
3018 );
3019 }
3020
3021 let first = &orders[0];
3022 let second = &orders[1];
3023 if first.instrument_id() != second.instrument_id() {
3024 return Err("Binance Spot OCO order-list legs must use the same instrument".to_string());
3025 }
3026
3027 if first.order_side() != second.order_side() {
3028 return Err("Binance Spot OCO order-list legs must use the same side".to_string());
3029 }
3030
3031 if first.quantity() != second.quantity() {
3032 return Err("Binance Spot OCO order-list legs must use the same quantity".to_string());
3033 }
3034
3035 if first.is_quote_quantity() || second.is_quote_quantity() {
3036 return Err("Binance Spot OCO order-list legs do not support quote quantity".to_string());
3037 }
3038
3039 let mut above = None;
3040 let mut below = None;
3041
3042 for order in orders {
3043 let params = build_new_order_params(
3044 order,
3045 order.client_order_id(),
3046 order.is_post_only(),
3047 false,
3048 use_gtd,
3049 )
3050 .map_err(|e| e.to_string())?;
3051
3052 match spot_oco_leg_position(params.side, params.order_type)? {
3053 SpotOcoLegPosition::Above => {
3054 if above.replace(params).is_some() {
3055 return Err(
3056 "Binance Spot OCO order-list resolved more than one above leg".to_string(),
3057 );
3058 }
3059 }
3060 SpotOcoLegPosition::Below => {
3061 if below.replace(params).is_some() {
3062 return Err(
3063 "Binance Spot OCO order-list resolved more than one below leg".to_string(),
3064 );
3065 }
3066 }
3067 }
3068 }
3069
3070 let above = above.ok_or_else(|| "Binance Spot OCO order-list missing above leg".to_string())?;
3071 let below = below.ok_or_else(|| "Binance Spot OCO order-list missing below leg".to_string())?;
3072 let quantity = above
3073 .quantity
3074 .clone()
3075 .ok_or_else(|| "Binance Spot OCO order-list requires base quantity".to_string())?;
3076
3077 Ok(NewOcoOrderListParams {
3078 symbol: first.instrument_id().symbol.to_string(),
3079 list_client_order_id: Some(order_list_id.to_string()),
3080 side: above.side,
3081 quantity,
3082 above_type: above.order_type,
3083 above_client_order_id: above.new_client_order_id,
3084 above_iceberg_qty: above.iceberg_qty,
3085 above_price: above.price,
3086 above_stop_price: above.stop_price,
3087 above_time_in_force: above.time_in_force,
3088 below_type: below.order_type,
3089 below_client_order_id: below.new_client_order_id,
3090 below_iceberg_qty: below.iceberg_qty,
3091 below_price: below.price,
3092 below_stop_price: below.stop_price,
3093 below_time_in_force: below.time_in_force,
3094 new_order_resp_type: Some(BinanceOrderResponseType::Full),
3095 self_trade_prevention_mode: None,
3096 })
3097}
3098
3099enum SpotOcoLegPosition {
3100 Above,
3101 Below,
3102}
3103
3104fn spot_oco_leg_position(
3105 side: BinanceSide,
3106 order_type: BinanceSpotOrderType,
3107) -> Result<SpotOcoLegPosition, String> {
3108 match (side, order_type) {
3109 (
3110 BinanceSide::Sell,
3111 BinanceSpotOrderType::LimitMaker
3112 | BinanceSpotOrderType::TakeProfit
3113 | BinanceSpotOrderType::TakeProfitLimit,
3114 )
3115 | (
3116 BinanceSide::Buy,
3117 BinanceSpotOrderType::StopLoss | BinanceSpotOrderType::StopLossLimit,
3118 ) => Ok(SpotOcoLegPosition::Above),
3119 (
3120 BinanceSide::Sell,
3121 BinanceSpotOrderType::StopLoss | BinanceSpotOrderType::StopLossLimit,
3122 )
3123 | (
3124 BinanceSide::Buy,
3125 BinanceSpotOrderType::LimitMaker
3126 | BinanceSpotOrderType::TakeProfit
3127 | BinanceSpotOrderType::TakeProfitLimit,
3128 ) => Ok(SpotOcoLegPosition::Below),
3129 (_, unsupported) => Err(format!(
3130 "Unsupported Binance Spot OCO leg order type: {unsupported:?}"
3131 )),
3132 }
3133}
3134
3135fn is_grouped_order(order: &OrderAny) -> bool {
3136 order.contingency_type().is_some()
3137 || order
3138 .linked_order_ids()
3139 .is_some_and(|linked_order_ids| !linked_order_ids.is_empty())
3140}
3141
3142fn handle_spot_order_submit_success(client_order_id: ClientOrderId, venue_order_id: VenueOrderId) {
3143 log::debug!(
3144 "Order submit succeeded: client_order_id={client_order_id}, venue_order_id={venue_order_id}",
3145 );
3146}
3147
3148async fn submit_spot_order_list(
3149 http_client: &BinanceSpotHttpClient,
3150 params: &NewOcoOrderListParams,
3151) -> Result<(), BinanceSpotHttpError> {
3152 let response = http_client.submit_oco_order_list(params).await?;
3153 log::debug!(
3154 "Order list submit succeeded: order_list_id={}, order_count={}",
3155 response.order_list_id,
3156 response.orders.len(),
3157 );
3158 Ok(())
3159}
3160
3161fn handle_spot_order_list_submit_error(
3162 event_emitter: &ExecutionEventEmitter,
3163 dispatch_state: &WsDispatchState,
3164 trader_id: TraderId,
3165 account_id: AccountId,
3166 clock: &'static AtomicTime,
3167 orders: &[OrderAny],
3168 error: BinanceSpotHttpError,
3169) -> anyhow::Result<()> {
3170 match classify_spot_http_failure(&error) {
3171 CommandFailure::Ambiguous(reason) => {
3172 log::warn!("Ambiguous order-list submit failure, awaiting reconciliation: {reason}");
3173 }
3174 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
3175 let ts_now = clock.get_time_ns();
3178 let reason = format!("submit-order-list-error: {}", sanitize_reason(&reason));
3179 for order in orders {
3180 let client_order_id = order.client_order_id();
3181 dispatch_state.cleanup_terminal(client_order_id);
3182 let rejected = OrderRejected::new(
3183 trader_id,
3184 order.strategy_id(),
3185 order.instrument_id(),
3186 client_order_id,
3187 account_id,
3188 reason.clone().into(),
3189 UUID4::new(),
3190 ts_now,
3191 ts_now,
3192 false,
3193 false,
3194 );
3195 event_emitter.send_order_event(OrderEventAny::Rejected(rejected));
3196 }
3197 }
3198 }
3199
3200 Err(error.into())
3201}
3202
3203fn build_cancel_order_params(cmd: &CancelOrder, prefer_client_order_id: bool) -> CancelOrderParams {
3204 let order_id = cmd
3205 .venue_order_id
3206 .and_then(|id| id.inner().parse::<i64>().ok());
3207
3208 if let Some(order_id) = order_id
3209 && !prefer_client_order_id
3210 {
3211 CancelOrderParams::by_order_id(cmd.instrument_id.symbol.to_string(), order_id)
3212 } else {
3213 let client_id_str = encode_broker_id(&cmd.client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
3214 CancelOrderParams::by_client_order_id(cmd.instrument_id.symbol.to_string(), client_id_str)
3215 }
3216}
3217
3218fn cancel_replace_cancel_id() -> String {
3223 format!("CR-{}", UUID4::new().as_str().replace('-', ""))
3224}
3225
3226fn emit_canceled_after_rejected_replace(
3231 emitter: &ExecutionEventEmitter,
3232 state: &WsDispatchState,
3233 account_id: AccountId,
3234 report: OrderStatusReport,
3235 ts_init: UnixNanos,
3236) {
3237 let identity = report
3238 .client_order_id
3239 .and_then(|cid| state.order_identities.get(&cid).map(|r| r.clone()));
3240 if let Some(client_order_id) = report.client_order_id {
3241 state.cleanup_terminal(client_order_id);
3242 }
3243 let (Some(client_order_id), Some(identity)) = (report.client_order_id, identity) else {
3244 emitter.send_order_status_report(report);
3245 return;
3246 };
3247
3248 let canceled = OrderCanceled::new(
3249 emitter.trader_id(),
3250 identity.strategy_id,
3251 identity.instrument_id,
3252 client_order_id,
3253 UUID4::new(),
3254 report.ts_last,
3255 ts_init,
3256 false,
3257 Some(report.venue_order_id),
3258 Some(account_id),
3259 None,
3260 );
3261 emitter.send_order_event(OrderEventAny::Canceled(canceled));
3262}
3263
3264fn handle_http_modify_failure(
3268 error: &anyhow::Error,
3269 command: &ModifyOrder,
3270 cancel_id: &str,
3271 emitter: &ExecutionEventEmitter,
3272 state: &WsDispatchState,
3273 account_id: AccountId,
3274 ts_now: UnixNanos,
3275) {
3276 let failure = error.downcast_ref::<BinanceSpotHttpError>().map_or_else(
3277 || CommandFailure::Ambiguous(error.to_string()),
3278 classify_spot_http_failure,
3279 );
3280
3281 match failure {
3282 CommandFailure::Ambiguous(reason) => {
3283 log::warn!(
3284 "Ambiguous modify failure for {}, awaiting reconciliation: {reason}",
3285 command.client_order_id
3286 );
3287 }
3288 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
3289 if let Some(canceled) = state.reject_replace(command.client_order_id) {
3290 state.cleanup_terminal(command.client_order_id);
3291 emitter.send_order_event(OrderEventAny::Canceled(canceled));
3292 return;
3293 }
3294
3295 let rejected_event = OrderModifyRejected::new(
3296 emitter.trader_id(),
3297 command.strategy_id,
3298 command.instrument_id,
3299 command.client_order_id,
3300 format!("modify-order-error: {}", sanitize_reason(&reason)).into(),
3301 UUID4::new(),
3302 ts_now,
3303 ts_now,
3304 false,
3305 command.venue_order_id,
3306 Some(account_id),
3307 );
3308 emitter.send_order_event(OrderEventAny::ModifyRejected(rejected_event));
3309
3310 if let Some(report) = state.on_cancel_replace_rejected(cancel_id) {
3311 emit_canceled_after_rejected_replace(emitter, state, account_id, report, ts_now);
3312 }
3313 }
3314 }
3315}
3316
3317fn build_cancel_replace_params(
3318 cmd: &ModifyOrder,
3319 order: &impl Order,
3320 quantity: Quantity,
3321 use_gtd: bool,
3322 cancel_new_client_order_id: String,
3323) -> anyhow::Result<CancelReplaceOrderParams> {
3324 let binance_side = BinanceSide::try_from(order.order_side())?;
3325 let binance_order_type = order_type_to_binance_spot(order.order_type(), false)?;
3326 let binance_tif = time_in_force_to_binance_spot(order.time_in_force(), use_gtd)?;
3327
3328 let cancel_order_id: Option<i64> = cmd
3329 .venue_order_id
3330 .map(|id| {
3331 id.inner()
3332 .parse::<i64>()
3333 .map_err(|_| anyhow::anyhow!("Invalid venue order ID: {id}"))
3334 })
3335 .transpose()?;
3336
3337 let client_id_str = encode_broker_id(&cmd.client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
3338
3339 Ok(CancelReplaceOrderParams {
3340 symbol: cmd.instrument_id.symbol.to_string(),
3341 side: binance_side,
3342 order_type: binance_order_type,
3343 cancel_replace_mode: BinanceCancelReplaceMode::StopOnFailure,
3344 time_in_force: Some(binance_tif),
3345 quantity: Some(quantity.to_string()),
3346 quote_order_qty: None,
3347 price: cmd.price.map(|p| p.to_string()),
3348 cancel_order_id,
3349 cancel_orig_client_order_id: if cancel_order_id.is_none() {
3350 Some(client_id_str.clone())
3351 } else {
3352 None
3353 },
3354 cancel_new_client_order_id: Some(cancel_new_client_order_id),
3355 new_client_order_id: Some(client_id_str),
3356 stop_price: None,
3357 trailing_delta: None,
3358 iceberg_qty: None,
3359 new_order_resp_type: Some(BinanceOrderResponseType::Full),
3360 self_trade_prevention_mode: None,
3361 })
3362}
3363
3364#[expect(clippy::too_many_arguments)]
3369fn dispatch_execution_report(
3370 report: &BinanceSpotExecutionReport,
3371 emitter: &ExecutionEventEmitter,
3372 http_client: &BinanceSpotHttpClient,
3373 account_id: AccountId,
3374 treat_expired_as_canceled: bool,
3375 dispatch_state: &WsDispatchState,
3376 seen_trade_ids: &std::sync::Arc<Mutex<FifoCache<(Ustr, i64), 10_000>>>,
3377 ts_init: UnixNanos,
3378) {
3379 let symbol = report.symbol;
3380 let instrument_id = InstrumentId::new(symbol.into(), *BINANCE_VENUE);
3381 let Some(instrument) = http_client.get_instrument(&symbol) else {
3382 log::warn!("Cannot dispatch Spot execution report for uncached instrument {instrument_id}");
3383 return;
3384 };
3385 let (price_precision, size_precision) =
3386 (instrument.price_precision(), instrument.size_precision());
3387
3388 if report.execution_type == BinanceSpotExecutionType::Canceled
3389 && dispatch_state.has_cancel_replace(&report.client_order_id)
3390 {
3391 match parse_spot_exec_report_to_order_status(
3392 report,
3393 instrument_id,
3394 price_precision,
3395 size_precision,
3396 account_id,
3397 treat_expired_as_canceled,
3398 ts_init,
3399 ) {
3400 Ok(status) => {
3401 if dispatch_state.on_cancel_replace_canceled(&report.client_order_id, status) {
3402 log::debug!(
3404 "Withholding cancel-replace cancel report: client_order_id={}, venue_order_id={}",
3405 report.order_client_order_id(),
3406 report.order_id
3407 );
3408 return;
3409 }
3410 }
3411 Err(e) => log::warn!(
3412 "Cannot withhold cancel-replace cancel report, dispatching normally: {e}"
3413 ),
3414 }
3415 }
3416
3417 let client_order_id = match decode_client_order_id(
3418 report.order_client_order_id(),
3419 BINANCE_NAUTILUS_SPOT_BROKER_ID,
3420 ) {
3421 Ok(client_order_id) => client_order_id,
3422 Err(e) => {
3423 log::warn!("Skipping Spot execution report with invalid client order ID: {e}");
3424 return;
3425 }
3426 };
3427
3428 let identity = dispatch_state
3429 .order_identities
3430 .get(&client_order_id)
3431 .map(|r| r.clone());
3432
3433 if let Some(identity) = identity {
3434 dispatch_tracked_execution_report(
3435 report,
3436 emitter,
3437 account_id,
3438 treat_expired_as_canceled,
3439 dispatch_state,
3440 seen_trade_ids,
3441 client_order_id,
3442 &identity,
3443 instrument_id,
3444 price_precision,
3445 size_precision,
3446 instrument.quote_currency(),
3447 ts_init,
3448 );
3449 } else {
3450 dispatch_untracked_execution_report(
3451 report,
3452 emitter,
3453 http_client,
3454 account_id,
3455 treat_expired_as_canceled,
3456 seen_trade_ids,
3457 instrument_id,
3458 price_precision,
3459 size_precision,
3460 ts_init,
3461 );
3462 }
3463}
3464
3465#[expect(clippy::too_many_arguments)]
3467fn dispatch_tracked_execution_report(
3468 report: &BinanceSpotExecutionReport,
3469 emitter: &ExecutionEventEmitter,
3470 account_id: AccountId,
3471 treat_expired_as_canceled: bool,
3472 state: &WsDispatchState,
3473 seen_trade_ids: &std::sync::Arc<Mutex<FifoCache<(Ustr, i64), 10_000>>>,
3474 client_order_id: ClientOrderId,
3475 identity: &OrderIdentity,
3476 instrument_id: InstrumentId,
3477 price_precision: u8,
3478 size_precision: u8,
3479 quote_currency: Currency,
3480 ts_init: UnixNanos,
3481) {
3482 let venue_order_id = VenueOrderId::new(report.order_id.to_string());
3483 let ts_event = parse_millis_or_init(report.event_time, "Spot execution event time", ts_init);
3484
3485 match report.execution_type {
3486 BinanceSpotExecutionType::Unknown => {
3487 log::warn!("Skipping unknown Spot execution type for {}", report.symbol);
3488 }
3489 BinanceSpotExecutionType::New => {
3490 if state.has_filled(&client_order_id) {
3491 log::debug!("Skipping New for already-filled {client_order_id}");
3492 return;
3493 }
3494
3495 let Some(price) =
3496 parse_spot_execution_report_price(report, &report.price, price_precision, "price")
3497 else {
3498 return;
3499 };
3500 let Some(quantity) = parse_spot_execution_report_quantity(
3501 report,
3502 &report.original_qty,
3503 size_precision,
3504 "original_qty",
3505 ) else {
3506 return;
3507 };
3508 let Some(stop_price) =
3509 parse_spot_execution_report_decimal(report, &report.stop_price, "stop_price")
3510 else {
3511 return;
3512 };
3513 let trigger = if stop_price > Decimal::ZERO {
3514 let Some(trigger_price) = parse_spot_execution_report_price(
3515 report,
3516 &report.stop_price,
3517 price_precision,
3518 "stop_price",
3519 ) else {
3520 return;
3521 };
3522 Some(trigger_price)
3523 } else {
3524 None
3525 };
3526
3527 let changed = !identity.is_quote_quantity
3528 && state.record_order_update(
3529 client_order_id,
3530 venue_order_id,
3531 quantity,
3532 price,
3533 trigger,
3534 );
3535
3536 if state.has_emitted_accepted(&client_order_id) {
3537 if !changed {
3538 return;
3539 }
3540 let updated = OrderUpdated::new(
3541 emitter.trader_id(),
3542 identity.strategy_id,
3543 identity.instrument_id,
3544 client_order_id,
3545 quantity,
3546 UUID4::new(),
3547 ts_event,
3548 ts_init,
3549 false,
3550 Some(venue_order_id),
3551 Some(account_id),
3552 Some(price),
3553 trigger,
3554 None, false, );
3557 emitter.send_order_event(OrderEventAny::Updated(updated));
3558 return;
3559 }
3560 state.insert_accepted(client_order_id);
3561 let accepted = OrderAccepted::new(
3562 emitter.trader_id(),
3563 identity.strategy_id,
3564 identity.instrument_id,
3565 client_order_id,
3566 venue_order_id,
3567 account_id,
3568 UUID4::new(),
3569 ts_event,
3570 ts_init,
3571 false,
3572 );
3573 emitter.send_order_event(OrderEventAny::Accepted(accepted));
3574 }
3575 BinanceSpotExecutionType::Trade => {
3576 let dedup_key = (report.symbol, report.trade_id);
3577 let is_duplicate = seen_trade_ids.lock().contains(&dedup_key);
3578
3579 if is_duplicate {
3580 log::debug!(
3581 "Duplicate trade_id={} for {}, skipping",
3582 report.trade_id,
3583 report.symbol
3584 );
3585 return;
3586 }
3587
3588 ensure_accepted_emitted(
3589 client_order_id,
3590 account_id,
3591 venue_order_id,
3592 identity,
3593 emitter,
3594 state,
3595 ts_init,
3596 );
3597
3598 let Some(last_qty) = parse_spot_execution_report_quantity(
3599 report,
3600 &report.last_filled_qty,
3601 size_precision,
3602 "last_filled_qty",
3603 ) else {
3604 return;
3605 };
3606 let Some(last_px) = parse_spot_execution_report_price(
3607 report,
3608 &report.last_filled_price,
3609 price_precision,
3610 "last_filled_price",
3611 ) else {
3612 return;
3613 };
3614 let Some(commission) =
3615 parse_spot_execution_report_decimal(report, &report.commission, "commission")
3616 else {
3617 return;
3618 };
3619 let commission_currency = report
3620 .commission_asset
3621 .as_ref()
3622 .map_or_else(Currency::USDT, |a| {
3623 Currency::get_or_create_crypto(a.as_str())
3624 });
3625 let commission_money = match Money::from_decimal(commission, commission_currency) {
3626 Ok(money) => money,
3627 Err(e) => {
3628 log::warn!(
3629 "Failed to build Spot commission money for symbol={}, order_id={}, \
3630 trade_id={}: {e}",
3631 report.symbol,
3632 report.order_id,
3633 report.trade_id,
3634 );
3635 return;
3636 }
3637 };
3638
3639 let liquidity_side = if report.is_maker {
3640 LiquiditySide::Maker
3641 } else {
3642 LiquiditySide::Taker
3643 };
3644
3645 if identity.is_quote_quantity {
3646 let Some(quantity) = parse_spot_execution_report_quantity(
3647 report,
3648 &report.original_qty,
3649 size_precision,
3650 "original_qty",
3651 ) else {
3652 return;
3653 };
3654
3655 let Some(cumulative_qty) = parse_spot_execution_report_decimal(
3656 report,
3657 &report.cumulative_filled_qty,
3658 "cumulative_filled_qty",
3659 ) else {
3660 return;
3661 };
3662
3663 if quantity.is_zero() || quantity.as_decimal() < cumulative_qty {
3664 log::warn!(
3665 "Cannot normalize Spot quote quantity for {client_order_id}: \
3666 original_qty={}, cumulative_filled_qty={cumulative_qty}",
3667 report.original_qty,
3668 );
3669 return;
3670 }
3671
3672 if state.record_order_update(
3673 client_order_id,
3674 venue_order_id,
3675 quantity,
3676 Price::zero(price_precision),
3677 None,
3678 ) {
3679 let updated = OrderUpdated::new(
3680 emitter.trader_id(),
3681 identity.strategy_id,
3682 identity.instrument_id,
3683 client_order_id,
3684 quantity,
3685 UUID4::new(),
3686 ts_event,
3687 ts_init,
3688 false,
3689 Some(venue_order_id),
3690 Some(account_id),
3691 None,
3692 None,
3693 None,
3694 false,
3695 );
3696 emitter.send_order_event(OrderEventAny::Updated(updated));
3697 }
3698 }
3699
3700 let filled = OrderFilled::new(
3701 emitter.trader_id(),
3702 identity.strategy_id,
3703 instrument_id,
3704 client_order_id,
3705 venue_order_id,
3706 account_id,
3707 TradeId::new(report.trade_id.to_string()),
3708 identity.order_side,
3709 identity.order_type,
3710 last_qty,
3711 last_px,
3712 quote_currency,
3713 liquidity_side,
3714 UUID4::new(),
3715 ts_event,
3716 ts_init,
3717 false,
3718 None,
3719 Some(commission_money),
3720 None,
3721 );
3722
3723 state.insert_filled(client_order_id);
3724 emitter.send_order_event(OrderEventAny::Filled(filled));
3725 seen_trade_ids.lock().add(dedup_key);
3726
3727 let cumulative_qty = parse_spot_execution_report_decimal(
3728 report,
3729 &report.cumulative_filled_qty,
3730 "cumulative_filled_qty",
3731 );
3732 let original_qty =
3733 parse_spot_execution_report_decimal(report, &report.original_qty, "original_qty");
3734
3735 if let (Some(original_qty), Some(cumulative_qty)) = (original_qty, cumulative_qty)
3736 && original_qty <= cumulative_qty
3737 && (!identity.is_quote_quantity
3738 || report.order_status == BinanceOrderStatus::Filled)
3739 {
3740 state.cleanup_terminal(client_order_id);
3741 }
3742 }
3743 BinanceSpotExecutionType::Replaced => {
3744 log::debug!(
3747 "Order replaced: client_order_id={client_order_id}, venue_order_id={venue_order_id}"
3748 );
3749 }
3750 BinanceSpotExecutionType::Canceled | BinanceSpotExecutionType::TradePrevention => {
3751 ensure_accepted_emitted(
3752 client_order_id,
3753 account_id,
3754 venue_order_id,
3755 identity,
3756 emitter,
3757 state,
3758 ts_init,
3759 );
3760 let canceled = OrderCanceled::new(
3761 emitter.trader_id(),
3762 identity.strategy_id,
3763 identity.instrument_id,
3764 client_order_id,
3765 UUID4::new(),
3766 ts_event,
3767 ts_init,
3768 false,
3769 Some(venue_order_id),
3770 Some(account_id),
3771 None,
3772 );
3773
3774 if state.defer_replace_cancel(canceled) {
3775 return;
3776 }
3777 state.cleanup_terminal(client_order_id);
3778 emitter.send_order_event(OrderEventAny::Canceled(canceled));
3779 }
3780 BinanceSpotExecutionType::Expired => {
3781 ensure_accepted_emitted(
3782 client_order_id,
3783 account_id,
3784 venue_order_id,
3785 identity,
3786 emitter,
3787 state,
3788 ts_init,
3789 );
3790 state.cleanup_terminal(client_order_id);
3791
3792 if treat_expired_as_canceled {
3793 let canceled = OrderCanceled::new(
3794 emitter.trader_id(),
3795 identity.strategy_id,
3796 identity.instrument_id,
3797 client_order_id,
3798 UUID4::new(),
3799 ts_event,
3800 ts_init,
3801 false,
3802 Some(venue_order_id),
3803 Some(account_id),
3804 None,
3805 );
3806 emitter.send_order_event(OrderEventAny::Canceled(canceled));
3807 } else {
3808 let expired = OrderExpired::new(
3809 emitter.trader_id(),
3810 identity.strategy_id,
3811 identity.instrument_id,
3812 client_order_id,
3813 UUID4::new(),
3814 ts_event,
3815 ts_init,
3816 false,
3817 Some(venue_order_id),
3818 Some(account_id),
3819 );
3820 emitter.send_order_event(OrderEventAny::Expired(expired));
3821 }
3822 }
3823 BinanceSpotExecutionType::Rejected => {
3824 let reason = if report.reject_reason.is_empty() {
3825 Ustr::from("Order rejected by venue")
3826 } else {
3827 Ustr::from(&report.reject_reason)
3828 };
3829 let due_post_only = report.time_in_force == BinanceTimeInForce::Gtx
3830 || (report.order_type == "LIMIT_MAKER"
3831 && (report.reject_reason.is_empty() || report.reject_reason == "NONE"));
3832 state.cleanup_terminal(client_order_id);
3833 emitter.emit_order_rejected_event(
3834 identity.strategy_id,
3835 identity.instrument_id,
3836 client_order_id,
3837 reason.as_str(),
3838 ts_init,
3839 due_post_only,
3840 );
3841 }
3842 }
3843}
3844
3845fn parse_spot_execution_report_quantity(
3846 report: &BinanceSpotExecutionReport,
3847 raw: &str,
3848 precision: u8,
3849 field: &str,
3850) -> Option<Quantity> {
3851 match parse_required_quantity_at_precision(raw, precision, field) {
3852 Ok(value) => Some(value),
3853 Err(e) => {
3854 warn_invalid_spot_execution_report_field(report, field, &e);
3855 None
3856 }
3857 }
3858}
3859
3860fn parse_spot_execution_report_price(
3861 report: &BinanceSpotExecutionReport,
3862 raw: &str,
3863 precision: u8,
3864 field: &str,
3865) -> Option<Price> {
3866 match parse_required_price_at_precision(raw, precision, field) {
3867 Ok(value) => Some(value),
3868 Err(e) => {
3869 warn_invalid_spot_execution_report_field(report, field, &e);
3870 None
3871 }
3872 }
3873}
3874
3875fn parse_spot_execution_report_decimal(
3876 report: &BinanceSpotExecutionReport,
3877 raw: &str,
3878 field: &str,
3879) -> Option<Decimal> {
3880 match parse_required_decimal(raw, field) {
3881 Ok(value) => Some(value),
3882 Err(e) => {
3883 warn_invalid_spot_execution_report_field(report, field, &e);
3884 None
3885 }
3886 }
3887}
3888
3889fn warn_invalid_spot_execution_report_field(
3890 report: &BinanceSpotExecutionReport,
3891 field: &str,
3892 error: &anyhow::Error,
3893) {
3894 log::warn!(
3895 "Failed to parse Spot execution report {field} for symbol={}, order_id={}, \
3896 trade_id={}, client_order_id={}: {error}",
3897 report.symbol,
3898 report.order_id,
3899 report.trade_id,
3900 report.client_order_id,
3901 );
3902}
3903
3904#[expect(clippy::too_many_arguments)]
3906fn dispatch_untracked_execution_report(
3907 report: &BinanceSpotExecutionReport,
3908 emitter: &ExecutionEventEmitter,
3909 _http_client: &BinanceSpotHttpClient,
3910 account_id: AccountId,
3911 treat_expired_as_canceled: bool,
3912 seen_trade_ids: &std::sync::Arc<Mutex<FifoCache<(Ustr, i64), 10_000>>>,
3913 instrument_id: InstrumentId,
3914 price_precision: u8,
3915 size_precision: u8,
3916 ts_init: UnixNanos,
3917) {
3918 match report.execution_type {
3919 BinanceSpotExecutionType::Unknown => {
3920 log::warn!("Skipping unknown Spot execution type for {}", report.symbol);
3921 }
3922 BinanceSpotExecutionType::Trade => {
3923 let dedup_key = (report.symbol, report.trade_id);
3924 let is_duplicate = seen_trade_ids.lock().contains(&dedup_key);
3925
3926 if is_duplicate {
3927 log::debug!(
3928 "Duplicate trade_id={} for {}, skipping",
3929 report.trade_id,
3930 report.symbol
3931 );
3932 return;
3933 }
3934
3935 match parse_spot_exec_report_to_order_status(
3936 report,
3937 instrument_id,
3938 price_precision,
3939 size_precision,
3940 account_id,
3941 treat_expired_as_canceled,
3942 ts_init,
3943 ) {
3944 Ok(status) => emitter.send_order_status_report(status),
3945 Err(e) => log::error!("Failed to parse order status report: {e}"),
3946 }
3947
3948 match parse_spot_exec_report_to_fill(
3949 report,
3950 instrument_id,
3951 price_precision,
3952 size_precision,
3953 account_id,
3954 ts_init,
3955 ) {
3956 Ok(fill) => {
3957 emitter.send_fill_report(fill);
3958 seen_trade_ids.lock().add(dedup_key);
3959 }
3960 Err(e) => log::error!("Failed to parse fill report: {e}"),
3961 }
3962 }
3963 BinanceSpotExecutionType::New
3964 | BinanceSpotExecutionType::Canceled
3965 | BinanceSpotExecutionType::Replaced
3966 | BinanceSpotExecutionType::Rejected
3967 | BinanceSpotExecutionType::Expired
3968 | BinanceSpotExecutionType::TradePrevention => {
3969 match parse_spot_exec_report_to_order_status(
3970 report,
3971 instrument_id,
3972 price_precision,
3973 size_precision,
3974 account_id,
3975 treat_expired_as_canceled,
3976 ts_init,
3977 ) {
3978 Ok(status) => emitter.send_order_status_report(status),
3979 Err(e) => log::error!("Failed to parse order status report: {e}"),
3980 }
3981 }
3982 }
3983}
3984
3985fn is_spot_post_only_rejection(error: &BinanceSpotHttpError) -> bool {
3987 match error {
3988 BinanceSpotHttpError::BinanceError { code, message, .. } => {
3989 *code == BINANCE_GTX_ORDER_REJECT_CODE
3990 || (*code == BINANCE_NEW_ORDER_REJECTED_CODE
3991 && message == BINANCE_SPOT_POST_ONLY_REJECT_MSG)
3992 }
3993 _ => false,
3994 }
3995}
3996
3997#[cfg(test)]
3998mod tests {
3999 use std::{
4000 cell::RefCell,
4001 rc::Rc,
4002 sync::{
4003 Arc,
4004 atomic::{AtomicUsize, Ordering},
4005 },
4006 };
4007
4008 use nautilus_common::{
4009 cache::Cache,
4010 clients::ExecutionClient,
4011 clock::VirtualClock,
4012 messages::{
4013 ExecutionEvent,
4014 execution::{CancelAllOrders, ExecutionReport},
4015 },
4016 };
4017 use nautilus_core::{UUID4, UnixNanos, time::get_atomic_clock_realtime};
4018 use nautilus_execution::engine::ExecutionEngine;
4019 use nautilus_live::ExecutionClientCore;
4020 use nautilus_model::{
4021 enums::{
4022 AccountType, LiquiditySide, OmsType, OrderSide, OrderStatus, OrderType, TimeInForce,
4023 },
4024 events::OrderEventAny,
4025 identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
4026 orders::{Order, OrderTestBuilder, stubs::TestOrderEventStubs},
4027 types::{Price, Quantity},
4028 };
4029 use rstest::rstest;
4030
4031 use super::*;
4032 use crate::{
4033 common::{
4034 consts::{
4035 BINANCE_CLIENT_ID, BINANCE_STATUS_UNKNOWN_CODE, BINANCE_UNEXPECTED_RESPONSE_CODE,
4036 BINANCE_VENUE,
4037 },
4038 enums::BinanceEnvironment,
4039 },
4040 config::BinanceExecutionClientConfig,
4041 spot::{
4042 http::models::BinanceCancelOrderListOrder,
4043 sbe::spot::{
4044 contingency_type::ContingencyType as SbeContingencyType,
4045 order_side::OrderSide as SbeOrderSide, order_type::OrderType as SbeOrderType,
4046 self_trade_prevention_mode::SelfTradePreventionMode as SbeStp,
4047 time_in_force::TimeInForce as SbeTimeInForce,
4048 },
4049 },
4050 };
4051
4052 #[rstest]
4053 #[case::unsupported_type(OrderType::MarketToLimit, false, false, Some(OrderDeniedReason::UnsupportedOrderType { order_type: OrderType::MarketToLimit }))]
4054 #[case::reduce_only(
4055 OrderType::Market,
4056 true,
4057 false,
4058 Some(OrderDeniedReason::UnsupportedReduceOnly)
4059 )]
4060 #[case::limit_quote_quantity(OrderType::Limit, false, true, Some(OrderDeniedReason::ValidationFailed { detail: "Quote quantity requires a MARKET order on Binance Spot".to_string() }))]
4061 #[case::market_quote_quantity(OrderType::Market, false, true, None)]
4062 fn test_validate_order_fields(
4063 #[case] order_type: OrderType,
4064 #[case] reduce_only: bool,
4065 #[case] quote_quantity: bool,
4066 #[case] expected: Option<OrderDeniedReason>,
4067 ) {
4068 let order = OrderTestBuilder::new(order_type)
4069 .instrument_id(InstrumentId::from("BTCUSDT.BINANCE"))
4070 .quantity(Quantity::from("1"))
4071 .price(Price::from("100"))
4072 .reduce_only(reduce_only)
4073 .quote_quantity(quote_quantity)
4074 .build();
4075 assert_eq!(validate_order(&order, false).err(), expected);
4076 }
4077
4078 #[rstest]
4079 #[case::limit(
4080 false,
4081 Some(OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Day))
4082 )]
4083 #[case::maker(true, None)]
4084 fn test_validate_order_time_in_force_only_when_sent(
4085 #[case] post_only: bool,
4086 #[case] expected: Option<OrderDeniedReason>,
4087 ) {
4088 let order = OrderTestBuilder::new(OrderType::Limit)
4089 .instrument_id(InstrumentId::from("BTCUSDT.BINANCE"))
4090 .quantity(Quantity::from("1"))
4091 .price(Price::from("100"))
4092 .post_only(post_only)
4093 .time_in_force(TimeInForce::Day)
4094 .build();
4095 assert_eq!(validate_order(&order, false).err(), expected);
4096
4097 if post_only {
4098 let params =
4099 build_new_order_params(&order, order.client_order_id(), true, false, false)
4100 .unwrap();
4101 assert_eq!(params.time_in_force, None);
4102 }
4103 }
4104
4105 #[rstest]
4106 #[case::live(BinanceEnvironment::Live, BINANCE_SPOT_SBE_WS_API_URL)]
4107 #[case::testnet(BinanceEnvironment::Testnet, BINANCE_SPOT_SBE_WS_API_TESTNET_URL)]
4108 #[case::demo(BinanceEnvironment::Demo, BINANCE_SPOT_SBE_WS_API_DEMO_URL)]
4109 fn test_resolve_ws_trading_url_uses_environment_default(
4110 #[case] environment: BinanceEnvironment,
4111 #[case] expected: &str,
4112 ) {
4113 assert_eq!(
4114 BinanceSpotExecutionClient::resolve_ws_trading_url(None, environment),
4115 expected
4116 );
4117 }
4118
4119 #[rstest]
4120 fn test_resolve_ws_trading_url_preserves_override() {
4121 let expected = "wss://example.com/ws-api/v3";
4122
4123 assert_eq!(
4124 BinanceSpotExecutionClient::resolve_ws_trading_url(
4125 Some(expected.to_string()),
4126 BinanceEnvironment::Testnet,
4127 ),
4128 expected
4129 );
4130 }
4131
4132 #[rstest]
4133 fn test_dispatch_ws_trading_message_emits_cancel_rejected_and_clears_pending_request() {
4134 let tasks = TaskGroup::new();
4135 let task_spawner = tasks.spawner().expect("task spawner");
4136 let clock = get_atomic_clock_realtime();
4137 let (emitter, mut rx) = create_test_emitter(clock);
4138 let http_client = create_test_http_client(clock);
4139 let dispatch_state = create_tracked_dispatch_state(
4140 ClientOrderId::from("TEST"),
4141 InstrumentId::from("BTCUSDT.BINANCE"),
4142 );
4143 let ws_authenticated = tokio::sync::Notify::new();
4144 let ws_user_data_subscribed = tokio::sync::Notify::new();
4145 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
4146 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
4147
4148 dispatch_state.pending_requests.insert(
4149 "req-cancel".to_string(),
4150 PendingRequest {
4151 client_order_id: ClientOrderId::from("TEST"),
4152 venue_order_id: Some(VenueOrderId::from("12345")),
4153 operation: PendingOperation::Cancel,
4154 },
4155 );
4156
4157 dispatch_ws_trading_message(
4158 BinanceSpotWsTradingMessage::CancelRejected {
4159 request_id: "req-cancel".to_string(),
4160 status: 400,
4161 code: -2011,
4162 msg: "Unknown order sent".to_string(),
4163 },
4164 &emitter,
4165 &http_client,
4166 AccountId::from("BINANCE-001"),
4167 false,
4168 clock,
4169 &dispatch_state,
4170 &ws_authenticated,
4171 &ws_user_data_subscribed,
4172 &ws_setup_error_tx,
4173 &seen_trade_ids,
4174 &task_spawner,
4175 );
4176
4177 assert!(dispatch_state.pending_requests.get("req-cancel").is_none());
4178
4179 match rx
4180 .try_recv()
4181 .expect("Cancel rejection event should be emitted")
4182 {
4183 ExecutionEvent::Order(OrderEventAny::CancelRejected(event)) => {
4184 assert_eq!(event.client_order_id, ClientOrderId::from("TEST"));
4185 assert_eq!(event.account_id, Some(AccountId::from("BINANCE-001")));
4186 assert!(event.reason.contains("code=-2011"));
4187 }
4188 other => panic!("Expected CancelRejected event, was {other:?}"),
4189 }
4190 }
4191
4192 #[rstest]
4193 #[case(
4194 BINANCE_UNEXPECTED_RESPONSE_CODE,
4195 "An unexpected response was received from the message bus"
4196 )]
4197 #[case(
4198 BINANCE_STATUS_UNKNOWN_CODE,
4199 "Timeout waiting for response from backend server"
4200 )]
4201 fn test_dispatch_ws_trading_message_unknown_status_keeps_order_registered(
4202 #[case] code: i64,
4203 #[case] msg: &str,
4204 ) {
4205 let tasks = TaskGroup::new();
4206 let task_spawner = tasks.spawner().expect("task spawner");
4207 let clock = get_atomic_clock_realtime();
4208 let (emitter, mut rx) = create_test_emitter(clock);
4209 let http_client = create_test_http_client(clock);
4210 let client_order_id = ClientOrderId::from("TEST");
4211 let dispatch_state =
4212 create_tracked_dispatch_state(client_order_id, InstrumentId::from("BTCUSDT.BINANCE"));
4213 let ws_authenticated = tokio::sync::Notify::new();
4214 let ws_user_data_subscribed = tokio::sync::Notify::new();
4215 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
4216 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
4217
4218 dispatch_state.pending_requests.insert(
4219 "req-submit".to_string(),
4220 PendingRequest {
4221 client_order_id,
4222 venue_order_id: None,
4223 operation: PendingOperation::Place,
4224 },
4225 );
4226
4227 dispatch_ws_trading_message(
4228 BinanceSpotWsTradingMessage::OrderRejected {
4229 request_id: "req-submit".to_string(),
4230 status: 400,
4231 code: code as i32,
4232 msg: msg.to_string(),
4233 },
4234 &emitter,
4235 &http_client,
4236 AccountId::from("BINANCE-001"),
4237 false,
4238 clock,
4239 &dispatch_state,
4240 &ws_authenticated,
4241 &ws_user_data_subscribed,
4242 &ws_setup_error_tx,
4243 &seen_trade_ids,
4244 &task_spawner,
4245 );
4246
4247 assert!(dispatch_state.pending_requests.get("req-submit").is_none());
4248 assert!(
4249 dispatch_state
4250 .order_identities
4251 .get(&client_order_id)
4252 .is_some()
4253 );
4254 assert!(rx.try_recv().is_err());
4255 }
4256
4257 #[rstest]
4258 fn test_dispatch_ws_trading_message_definite_submit_rejection_emits_order_rejected() {
4259 let tasks = TaskGroup::new();
4260 let task_spawner = tasks.spawner().expect("task spawner");
4261 let clock = get_atomic_clock_realtime();
4262 let (emitter, mut rx) = create_test_emitter(clock);
4263 let http_client = create_test_http_client(clock);
4264 let client_order_id = ClientOrderId::from("TEST");
4265 let dispatch_state =
4266 create_tracked_dispatch_state(client_order_id, InstrumentId::from("BTCUSDT.BINANCE"));
4267 let ws_authenticated = tokio::sync::Notify::new();
4268 let ws_user_data_subscribed = tokio::sync::Notify::new();
4269 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
4270 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
4271
4272 dispatch_state.pending_requests.insert(
4273 "req-submit".to_string(),
4274 PendingRequest {
4275 client_order_id,
4276 venue_order_id: None,
4277 operation: PendingOperation::Place,
4278 },
4279 );
4280
4281 dispatch_ws_trading_message(
4282 BinanceSpotWsTradingMessage::OrderRejected {
4283 request_id: "req-submit".to_string(),
4284 status: 400,
4285 code: BINANCE_NEW_ORDER_REJECTED_CODE as i32,
4286 msg: BINANCE_SPOT_POST_ONLY_REJECT_MSG.to_string(),
4287 },
4288 &emitter,
4289 &http_client,
4290 AccountId::from("BINANCE-001"),
4291 false,
4292 clock,
4293 &dispatch_state,
4294 &ws_authenticated,
4295 &ws_user_data_subscribed,
4296 &ws_setup_error_tx,
4297 &seen_trade_ids,
4298 &task_spawner,
4299 );
4300
4301 assert!(dispatch_state.pending_requests.get("req-submit").is_none());
4302 assert!(
4303 dispatch_state
4304 .order_identities
4305 .get(&client_order_id)
4306 .is_none()
4307 );
4308
4309 match rx
4310 .try_recv()
4311 .expect("OrderRejected event should be emitted")
4312 {
4313 ExecutionEvent::Order(OrderEventAny::Rejected(event)) => {
4314 assert_eq!(event.client_order_id, client_order_id);
4315 assert_eq!(event.account_id, AccountId::from("BINANCE-001"));
4316 assert!(event.reason.contains("code=-2010"));
4317 assert!(event.due_post_only);
4318 }
4319 other => panic!("Expected OrderRejected event, was {other:?}"),
4320 }
4321 }
4322
4323 #[rstest]
4324 fn test_dispatch_ws_trading_message_emits_modify_rejected_and_clears_pending_request() {
4325 let tasks = TaskGroup::new();
4326 let task_spawner = tasks.spawner().expect("task spawner");
4327 let clock = get_atomic_clock_realtime();
4328 let (emitter, mut rx) = create_test_emitter(clock);
4329 let http_client = create_test_http_client(clock);
4330 let dispatch_state = create_tracked_dispatch_state(
4331 ClientOrderId::from("TEST"),
4332 InstrumentId::from("BTCUSDT.BINANCE"),
4333 );
4334 let ws_authenticated = tokio::sync::Notify::new();
4335 let ws_user_data_subscribed = tokio::sync::Notify::new();
4336 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
4337 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
4338
4339 dispatch_state.pending_requests.insert(
4340 "req-modify".to_string(),
4341 PendingRequest {
4342 client_order_id: ClientOrderId::from("TEST"),
4343 venue_order_id: Some(VenueOrderId::from("12345")),
4344 operation: PendingOperation::Modify,
4345 },
4346 );
4347
4348 dispatch_ws_trading_message(
4349 BinanceSpotWsTradingMessage::CancelReplaceRejected {
4350 request_id: "req-modify".to_string(),
4351 status: 400,
4352 code: -2021,
4353 msg: "Order cancel-replace partially failed".to_string(),
4354 },
4355 &emitter,
4356 &http_client,
4357 AccountId::from("BINANCE-001"),
4358 false,
4359 clock,
4360 &dispatch_state,
4361 &ws_authenticated,
4362 &ws_user_data_subscribed,
4363 &ws_setup_error_tx,
4364 &seen_trade_ids,
4365 &task_spawner,
4366 );
4367
4368 assert!(dispatch_state.pending_requests.get("req-modify").is_none());
4369
4370 match rx
4371 .try_recv()
4372 .expect("Modify rejection event should be emitted")
4373 {
4374 ExecutionEvent::Order(OrderEventAny::ModifyRejected(event)) => {
4375 assert_eq!(event.client_order_id, ClientOrderId::from("TEST"));
4376 assert_eq!(event.account_id, Some(AccountId::from("BINANCE-001")));
4377 assert!(event.reason.contains("code=-2021"));
4378 }
4379 other => panic!("Expected ModifyRejected event, was {other:?}"),
4380 }
4381 }
4382
4383 fn create_test_emitter(
4384 clock: &'static AtomicTime,
4385 ) -> (
4386 ExecutionEventEmitter,
4387 tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
4388 ) {
4389 let mut emitter = ExecutionEventEmitter::new(
4390 clock,
4391 TraderId::from("TESTER-001"),
4392 AccountId::from("BINANCE-001"),
4393 AccountType::Cash,
4394 None,
4395 );
4396 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
4397 emitter.set_sender(tx);
4398 (emitter, rx)
4399 }
4400
4401 fn create_test_http_client(clock: &'static AtomicTime) -> BinanceSpotHttpClient {
4402 let client = BinanceSpotHttpClient::new(
4403 BinanceEnvironment::Live,
4404 clock,
4405 None,
4406 None,
4407 None,
4408 None,
4409 None,
4410 None,
4411 )
4412 .expect("Test HTTP client should be created");
4413 client.cache_instruments(vec![
4414 nautilus_model::instruments::stubs::currency_pair_ethusdt().into(),
4415 nautilus_model::instruments::stubs::currency_pair_btcusdt().into(),
4416 ]);
4417 client
4418 }
4419
4420 fn create_tracked_dispatch_state(
4421 client_order_id: ClientOrderId,
4422 instrument_id: InstrumentId,
4423 ) -> WsDispatchState {
4424 let dispatch_state = WsDispatchState::default();
4425 dispatch_state.order_identities.insert(
4426 client_order_id,
4427 OrderIdentity {
4428 instrument_id,
4429 strategy_id: StrategyId::from("TEST-STRATEGY"),
4430 order_side: OrderSide::Buy,
4431 order_type: OrderType::Limit,
4432 price: None,
4433 quantity: Quantity::from("1"),
4434 is_quote_quantity: false,
4435 venue_position_id: None,
4436 },
4437 );
4438 dispatch_state
4439 }
4440
4441 fn create_cancel_order_list_response(
4442 children: &[(ClientOrderId, i64)],
4443 ) -> BinanceCancelOpenOrdersResponse {
4444 let symbol = "BTCUSDT";
4445 let order_list_id = 44;
4446 let orders = children
4447 .iter()
4448 .map(|(client_order_id, order_id)| BinanceCancelOrderListOrder {
4449 symbol: symbol.to_string(),
4450 order_id: *order_id,
4451 client_order_id: encode_broker_id(client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID),
4452 })
4453 .collect();
4454 let order_reports = children
4455 .iter()
4456 .map(|(client_order_id, order_id)| BinanceCancelOrderResponse {
4457 price_exponent: -8,
4458 qty_exponent: -8,
4459 order_id: *order_id,
4460 order_list_id: Some(order_list_id),
4461 transact_time: 1_734_300_000_000_000,
4462 price_mantissa: 100_000_000_000,
4463 orig_qty_mantissa: 10_000_000,
4464 executed_qty_mantissa: 0,
4465 cummulative_quote_qty_mantissa: 0,
4466 status: SbeOrderStatus::Canceled,
4467 time_in_force: SbeTimeInForce::Gtc,
4468 order_type: SbeOrderType::Limit,
4469 side: SbeOrderSide::Sell,
4470 self_trade_prevention_mode: SbeStp::None,
4471 client_order_id: "cancel-list".to_string(),
4472 orig_client_order_id: encode_broker_id(
4473 client_order_id,
4474 BINANCE_NAUTILUS_SPOT_BROKER_ID,
4475 ),
4476 symbol: symbol.to_string(),
4477 })
4478 .collect();
4479 BinanceCancelOpenOrdersResponse::OrderList(BinanceCancelOrderListResponse {
4480 order_list_id,
4481 contingency_type: SbeContingencyType::Oco,
4482 list_status_type: SbeListStatusType::AllDone,
4483 list_order_status: SbeListOrderStatus::AllDone,
4484 transaction_time: 1_734_300_000_000_000,
4485 list_client_order_id: "list-44".to_string(),
4486 symbol: symbol.to_string(),
4487 orders,
4488 order_reports,
4489 })
4490 }
4491
4492 #[rstest]
4493 fn test_dispatch_order_list_canceled_emits_ordered_events_once_and_cleans_state() {
4494 let clock = get_atomic_clock_realtime();
4495 let (emitter, mut rx) = create_test_emitter(clock);
4496 let account_id = AccountId::from("BINANCE-001");
4497 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
4498 let first = ClientOrderId::from("O-20200101-000000-001-001-1");
4499 let second = ClientOrderId::from("O-20200101-000000-002-002-2");
4500 let state = create_tracked_dispatch_state(first, instrument_id);
4501 state.order_identities.insert(
4502 second,
4503 OrderIdentity {
4504 instrument_id,
4505 strategy_id: StrategyId::from("TEST-STRATEGY"),
4506 order_side: OrderSide::Sell,
4507 order_type: OrderType::Limit,
4508 price: None,
4509 quantity: Quantity::from("1"),
4510 is_quote_quantity: false,
4511 venue_position_id: None,
4512 },
4513 );
4514
4515 for (request_id, client_order_id) in [("cancel-1", first), ("cancel-2", second)] {
4516 state.pending_requests.insert(
4517 request_id.to_string(),
4518 PendingRequest {
4519 client_order_id,
4520 venue_order_id: None,
4521 operation: PendingOperation::Cancel,
4522 },
4523 );
4524 }
4525
4526 let prepared = prepare_cancel_all_orders(vec![create_cancel_order_list_response(&[
4527 (first, 111),
4528 (second, 222),
4529 ])])
4530 .unwrap();
4531
4532 for canceled_order in &prepared {
4533 dispatch_order_list_canceled(
4534 canceled_order,
4535 &emitter,
4536 account_id,
4537 &state,
4538 clock.get_time_ns(),
4539 );
4540 }
4541
4542 for canceled_order in &prepared {
4543 dispatch_order_list_canceled(
4544 canceled_order,
4545 &emitter,
4546 account_id,
4547 &state,
4548 clock.get_time_ns(),
4549 );
4550 }
4551
4552 let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
4553 assert_eq!(events.len(), 4);
4554
4555 for (accepted_index, canceled_index, client_order_id) in [(0, 1, first), (2, 3, second)] {
4556 assert!(matches!(
4557 &events[accepted_index],
4558 ExecutionEvent::Order(OrderEventAny::Accepted(event))
4559 if event.client_order_id == client_order_id
4560 ));
4561 assert!(matches!(
4562 &events[canceled_index],
4563 ExecutionEvent::Order(OrderEventAny::Canceled(event))
4564 if event.client_order_id == client_order_id
4565 ));
4566 }
4567 assert!(state.order_identities.is_empty());
4568 assert!(state.pending_requests.is_empty());
4569 assert!(!state.has_emitted_accepted(&first));
4570 assert!(!state.has_emitted_accepted(&second));
4571 }
4572
4573 #[rstest]
4574 fn test_prepare_cancel_order_list_rejects_mismatched_child_without_events() {
4575 let clock = get_atomic_clock_realtime();
4576 let (_emitter, mut rx) = create_test_emitter(clock);
4577 let client_order_id = ClientOrderId::from("O-20200101-000000-001-001-1");
4578 let mut response = create_cancel_order_list_response(&[(client_order_id, 111)]);
4579 let BinanceCancelOpenOrdersResponse::OrderList(order_list) = &mut response else {
4580 unreachable!();
4581 };
4582 order_list.order_reports[0].orig_client_order_id = encode_broker_id(
4583 &ClientOrderId::from("OTHER"),
4584 BINANCE_NAUTILUS_SPOT_BROKER_ID,
4585 );
4586
4587 let error = prepare_cancel_all_orders(vec![response]).unwrap_err();
4588
4589 assert!(
4590 error
4591 .to_string()
4592 .contains("does not match its order identity")
4593 );
4594 assert!(rx.try_recv().is_err());
4595 }
4596
4597 #[rstest]
4598 fn test_prepare_cancel_order_list_rejects_non_canceled_child_without_state_change() {
4599 let clock = get_atomic_clock_realtime();
4600 let (_emitter, mut rx) = create_test_emitter(clock);
4601 let client_order_id = ClientOrderId::from("O-20200101-000000-001-001-1");
4602 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
4603 let state = create_tracked_dispatch_state(client_order_id, instrument_id);
4604 let mut response = create_cancel_order_list_response(&[(client_order_id, 111)]);
4605 let BinanceCancelOpenOrdersResponse::OrderList(order_list) = &mut response else {
4606 unreachable!();
4607 };
4608 order_list.order_reports[0].status = SbeOrderStatus::Filled;
4609
4610 let error = prepare_cancel_all_orders(vec![response]).unwrap_err();
4611
4612 assert!(error.to_string().contains("reports status Filled"));
4613 assert!(state.order_identities.contains_key(&client_order_id));
4614 assert!(rx.try_recv().is_err());
4615 }
4616
4617 #[rstest]
4618 fn test_dispatch_order_list_canceled_concurrent_duplicate_emits_once() {
4619 let clock = get_atomic_clock_realtime();
4620 let (emitter, mut rx) = create_test_emitter(clock);
4621 let account_id = AccountId::from("BINANCE-001");
4622 let client_order_id = ClientOrderId::from("O-20200101-000000-001-001-1");
4623 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
4624 let state = create_tracked_dispatch_state(client_order_id, instrument_id);
4625 let prepared = prepare_cancel_all_orders(vec![create_cancel_order_list_response(&[(
4626 client_order_id,
4627 111,
4628 )])])
4629 .unwrap();
4630
4631 std::thread::scope(|scope| {
4632 for _ in 0..2 {
4633 scope.spawn(|| {
4634 dispatch_order_list_canceled(
4635 &prepared[0],
4636 &emitter,
4637 account_id,
4638 &state,
4639 clock.get_time_ns(),
4640 );
4641 });
4642 }
4643 });
4644
4645 let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
4646 assert_eq!(events.len(), 2);
4647 assert!(matches!(
4648 &events[0],
4649 ExecutionEvent::Order(OrderEventAny::Accepted(event))
4650 if event.client_order_id == client_order_id
4651 ));
4652 assert!(matches!(
4653 &events[1],
4654 ExecutionEvent::Order(OrderEventAny::Canceled(event))
4655 if event.client_order_id == client_order_id
4656 ));
4657 assert!(state.order_identities.is_empty());
4658 }
4659
4660 #[rstest]
4661 fn test_http_submit_success_defers_acceptance_to_user_stream() {
4662 let clock = get_atomic_clock_realtime();
4663 let (emitter, mut rx) = create_test_emitter(clock);
4664 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
4665 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
4666 let dispatch_state = Arc::new(create_tracked_dispatch_state(
4667 client_order_id,
4668 instrument_id,
4669 ));
4670 handle_spot_order_submit_success(client_order_id, VenueOrderId::from("12345678"));
4671
4672 assert!(!dispatch_state.has_emitted_accepted(&client_order_id));
4673 assert!(rx.try_recv().is_err());
4674 let new_json = crate::common::testing::load_fixture_string(
4675 "spot/user_data_json/execution_report_new.json",
4676 );
4677 let report: BinanceSpotExecutionReport = serde_json::from_str(&new_json).unwrap();
4678 let identity = dispatch_state
4679 .order_identities
4680 .get(&client_order_id)
4681 .unwrap()
4682 .clone();
4683 dispatch_tracked_execution_report(
4684 &report,
4685 &emitter,
4686 AccountId::from("BINANCE-001"),
4687 false,
4688 &dispatch_state,
4689 &Arc::new(Mutex::new(FifoCache::new())),
4690 client_order_id,
4691 &identity,
4692 instrument_id,
4693 2,
4694 8,
4695 Currency::USDT(),
4696 clock.get_time_ns(),
4697 );
4698
4699 assert!(matches!(
4700 rx.try_recv(),
4701 Ok(ExecutionEvent::Order(OrderEventAny::Accepted(event)))
4702 if event.client_order_id == client_order_id
4703 && event.venue_order_id == VenueOrderId::from("12345678")
4704 ));
4705 assert!(rx.try_recv().is_err());
4706 }
4707
4708 #[rstest]
4709 #[case::gtx(
4710 BinanceSpotHttpError::BinanceError {
4711 code: BINANCE_GTX_ORDER_REJECT_CODE,
4712 message: "Order would immediately trigger.".to_string(),
4713 status: 400,
4714 retry_after: None,
4715 },
4716 true,
4717 )]
4718 #[case::spot_post_only(
4719 BinanceSpotHttpError::BinanceError {
4720 code: BINANCE_NEW_ORDER_REJECTED_CODE,
4721 message: BINANCE_SPOT_POST_ONLY_REJECT_MSG.to_string(),
4722 status: 400,
4723 retry_after: None,
4724 },
4725 true,
4726 )]
4727 #[case::new_order_rejected_other_message(
4728 BinanceSpotHttpError::BinanceError {
4729 code: BINANCE_NEW_ORDER_REJECTED_CODE,
4730 message: "Insufficient balance.".to_string(),
4731 status: 400,
4732 retry_after: None,
4733 },
4734 false,
4735 )]
4736 #[case::unrelated_code(
4737 BinanceSpotHttpError::BinanceError {
4738 code: -2011,
4739 message: "Unknown order sent.".to_string(),
4740 status: 400,
4741 retry_after: None,
4742 },
4743 false,
4744 )]
4745 #[case::non_binance_error(
4746 BinanceSpotHttpError::NetworkError("connection reset".to_string()),
4747 false,
4748 )]
4749 fn test_is_spot_post_only_rejection(
4750 #[case] error: BinanceSpotHttpError,
4751 #[case] expected: bool,
4752 ) {
4753 assert_eq!(is_spot_post_only_rejection(&error), expected);
4754 }
4755
4756 #[rstest]
4757 fn test_dispatch_tracked_execution_report_trade_dedup() {
4758 let tasks = TaskGroup::new();
4759 let task_spawner = tasks.spawner().expect("task spawner");
4760 let clock = get_atomic_clock_realtime();
4761 let (emitter, mut rx) = create_test_emitter(clock);
4762 let http_client = create_test_http_client(clock);
4763 let client_order_id = ClientOrderId::from("x-TD67BGP9-T0000000000000");
4764 let dispatch_state = create_tracked_dispatch_state(
4765 ClientOrderId::from("O-20200101-000000-000-000-0"),
4766 InstrumentId::from("ETHUSDT.BINANCE"),
4767 );
4768 let ws_authenticated = tokio::sync::Notify::new();
4769 let ws_user_data_subscribed = tokio::sync::Notify::new();
4770 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
4771 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
4772
4773 let trade_json = crate::common::testing::load_fixture_string(
4774 "spot/user_data_json/execution_report_trade.json",
4775 );
4776 let report: BinanceSpotExecutionReport = serde_json::from_str(&trade_json).unwrap();
4777
4778 dispatch_ws_trading_message(
4779 BinanceSpotWsTradingMessage::ExecutionReport(Box::new(report.clone())),
4780 &emitter,
4781 &http_client,
4782 AccountId::from("BINANCE-001"),
4783 false,
4784 clock,
4785 &dispatch_state,
4786 &ws_authenticated,
4787 &ws_user_data_subscribed,
4788 &ws_setup_error_tx,
4789 &seen_trade_ids,
4790 &task_spawner,
4791 );
4792 dispatch_ws_trading_message(
4793 BinanceSpotWsTradingMessage::ExecutionReport(Box::new(report)),
4794 &emitter,
4795 &http_client,
4796 AccountId::from("BINANCE-001"),
4797 false,
4798 clock,
4799 &dispatch_state,
4800 &ws_authenticated,
4801 &ws_user_data_subscribed,
4802 &ws_setup_error_tx,
4803 &seen_trade_ids,
4804 &task_spawner,
4805 );
4806
4807 let mut events = Vec::new();
4808 while let Ok(event) = rx.try_recv() {
4809 events.push(event);
4810 }
4811
4812 let fills: Vec<_> = events
4813 .iter()
4814 .filter(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Filled(_))))
4815 .collect();
4816 assert_eq!(fills.len(), 1, "duplicate trade should be deduped");
4817
4818 match fills[0] {
4819 ExecutionEvent::Order(OrderEventAny::Filled(fill)) => {
4820 assert_eq!(
4821 fill.client_order_id,
4822 ClientOrderId::from("O-20200101-000000-000-000-0"),
4823 );
4824 assert_eq!(fill.trade_id, TradeId::new("98765432"));
4825 assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
4826 assert_eq!(fill.currency, Currency::USDT());
4827 assert_eq!(fill.last_px, Price::from("2500.00"));
4828 assert_eq!(fill.last_qty, Quantity::from("1.00000"));
4829 assert_eq!(
4830 fill.commission,
4831 Some(Money::from_decimal(Decimal::new(1, 3), Currency::ETH()).unwrap()),
4832 );
4833 }
4834 _ => unreachable!(),
4835 }
4836 let _ = client_order_id;
4837 }
4838
4839 #[rstest]
4840 #[case::single(false, false)]
4841 #[case::multiple_stable(true, false)]
4842 #[case::multiple_changed(true, true)]
4843 fn test_dispatch_quote_quantity_market_order(
4844 #[values(false, true)] new_before_trade: bool,
4845 #[values(false, true)] http_before_trade: bool,
4846 #[case] multiple_fills: bool,
4847 #[case] quantity_changes: bool,
4848 #[values(false, true)] quote_quantity: bool,
4849 ) {
4850 let clock = get_atomic_clock_realtime();
4851 let (emitter, mut rx) = create_test_emitter(clock);
4852 let http_client = create_test_http_client(clock);
4853 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
4854 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
4855 let account_id = AccountId::from("BINANCE-001");
4856 let venue_order_id = VenueOrderId::from("12345678");
4857 let base_qty = Quantity::from("0.00300");
4858
4859 let mut order = OrderTestBuilder::new(OrderType::Market)
4860 .trader_id(TraderId::from("TESTER-001"))
4861 .strategy_id(StrategyId::from("TEST-STRATEGY"))
4862 .client_order_id(client_order_id)
4863 .instrument_id(instrument_id)
4864 .side(OrderSide::Buy)
4865 .quantity(if quote_quantity {
4866 Quantity::from("13.00000")
4867 } else {
4868 base_qty
4869 })
4870 .quote_quantity(quote_quantity)
4871 .build();
4872
4873 order
4874 .apply(TestOrderEventStubs::submitted(&order, account_id))
4875 .unwrap();
4876 let state = create_tracked_dispatch_state(client_order_id, instrument_id);
4877 {
4878 let mut identity = state.order_identities.get_mut(&client_order_id).unwrap();
4879 identity.order_type = OrderType::Market;
4880 identity.quantity = order.quantity();
4881 identity.is_quote_quantity = quote_quantity;
4882 }
4883
4884 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
4885 let json = crate::common::testing::load_fixture_string(
4886 "spot/user_data_json/execution_report_new.json",
4887 );
4888 let mut new: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
4889 new.order_type = "MARKET".to_string();
4890 new.original_qty = "0.00300000".to_string();
4891 new.price = "0.00000000".to_string();
4892
4893 let dispatch = |report: &BinanceSpotExecutionReport| {
4894 dispatch_execution_report(
4895 report,
4896 &emitter,
4897 &http_client,
4898 account_id,
4899 false,
4900 &state,
4901 &seen_trade_ids,
4902 UnixNanos::from(1709654402000000000),
4903 );
4904 };
4905
4906 if http_before_trade {
4907 handle_spot_order_submit_success(client_order_id, venue_order_id);
4908 }
4909
4910 if new_before_trade {
4911 dispatch(&new);
4912 dispatch(&new);
4913 }
4914
4915 let json = crate::common::testing::load_fixture_string(
4916 "spot/user_data_json/execution_report_trade.json",
4917 );
4918 let mut trade: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
4919 trade.order_type = "MARKET".to_string();
4920 trade.price = new.price.clone();
4921
4922 trade.original_qty = if quote_quantity && quantity_changes {
4923 "0.00400000".to_string()
4924 } else {
4925 new.original_qty.clone()
4926 };
4927
4928 trade.is_maker = false;
4929 trade.last_filled_price = "4000.00".to_string();
4930 trade.last_filled_qty = if multiple_fills {
4931 "0.00100000"
4932 } else {
4933 "0.00300000"
4934 }
4935 .to_string();
4936
4937 trade.cumulative_filled_qty = trade.last_filled_qty.clone();
4938
4939 trade.order_status = if multiple_fills {
4940 BinanceOrderStatus::PartiallyFilled
4941 } else {
4942 BinanceOrderStatus::Filled
4943 };
4944
4945 trade.commission = "0.01200000".to_string();
4946 trade.commission_asset = Some(Ustr::from("USDT"));
4947 dispatch(&trade);
4948 dispatch(&trade);
4949
4950 if !http_before_trade {
4951 handle_spot_order_submit_success(client_order_id, venue_order_id);
4952 }
4953
4954 if !new_before_trade && multiple_fills {
4955 dispatch(&new);
4956 }
4957
4958 if multiple_fills {
4959 trade.original_qty = new.original_qty.clone();
4960 trade.trade_id += 1;
4961 trade.event_time += 1;
4962 trade.last_filled_qty = "0.00200000".to_string();
4963 trade.cumulative_filled_qty = "0.00300000".to_string();
4964 trade.last_filled_price = "4500.00".to_string();
4965 trade.commission = "0.00000200".to_string();
4966 trade.commission_asset = Some(Ustr::from("ETH"));
4967 trade.order_status = BinanceOrderStatus::Filled;
4968 dispatch(&trade);
4969 dispatch(&trade);
4970 }
4971
4972 let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
4973 for event in &events {
4974 let ExecutionEvent::Order(event) = event else {
4975 panic!("Expected typed order event, was {event:?}")
4976 };
4977
4978 order.apply(event.clone()).unwrap();
4979 if let OrderEventAny::Filled(fill) = event {
4980 assert_eq!(
4981 order.status(),
4982 if multiple_fills && fill.trade_id == TradeId::from("98765432") {
4983 OrderStatus::PartiallyFilled
4984 } else {
4985 OrderStatus::Filled
4986 },
4987 );
4988 }
4989 }
4990
4991 let fills: Vec<_> = events
4992 .iter()
4993 .filter_map(|event| match event {
4994 ExecutionEvent::Order(OrderEventAny::Filled(fill)) => Some(fill),
4995 _ => None,
4996 })
4997 .collect();
4998
4999 assert_eq!(
5000 events.len(),
5001 2 + usize::from(quote_quantity)
5002 + usize::from(multiple_fills)
5003 + usize::from(quote_quantity && quantity_changes)
5004 );
5005 assert!(matches!(
5006 events[0],
5007 ExecutionEvent::Order(OrderEventAny::Accepted(_))
5008 ));
5009
5010 if quote_quantity {
5011 let ExecutionEvent::Order(OrderEventAny::Updated(update)) = &events[1] else {
5012 panic!("Expected quantity update")
5013 };
5014
5015 assert_eq!(
5016 update.quantity,
5017 if quantity_changes {
5018 Quantity::from("0.00400")
5019 } else {
5020 base_qty
5021 },
5022 );
5023 assert!(!update.is_quote_quantity);
5024 assert_eq!(update.price, None);
5025 assert_eq!(update.trigger_price, None);
5026 assert_eq!(update.account_id, Some(account_id));
5027 assert_eq!(update.venue_order_id, Some(venue_order_id));
5028 }
5029
5030 assert_eq!(fills.len(), 1 + usize::from(multiple_fills));
5031 assert_eq!(
5032 fills[0].last_qty,
5033 Quantity::from(if multiple_fills { "0.00100" } else { "0.00300" })
5034 );
5035 assert_eq!(fills[0].last_px, Price::from("4000.00"));
5036 assert_eq!(fills[0].commission, Some(Money::from("0.012 USDT")));
5037 assert_eq!(fills[0].trade_id, TradeId::from("98765432"));
5038
5039 if multiple_fills {
5040 assert_eq!(fills[1].last_qty, Quantity::from("0.00200"));
5041 assert_eq!(fills[1].last_px, Price::from("4500.00"));
5042 assert_eq!(fills[1].commission, Some(Money::from("0.000002 ETH")));
5043 assert_eq!(fills[1].trade_id, TradeId::from("98765433"));
5044 }
5045
5046 for fill in fills {
5047 assert_eq!(fill.client_order_id, client_order_id);
5048 assert_eq!(fill.venue_order_id, venue_order_id);
5049 assert_eq!(fill.account_id, account_id);
5050 assert_eq!(fill.instrument_id, instrument_id);
5051 assert_eq!(fill.strategy_id, order.strategy_id());
5052 assert_eq!(fill.trader_id, order.trader_id());
5053 assert_eq!(fill.order_side, OrderSide::Buy);
5054 assert_eq!(fill.order_type, OrderType::Market);
5055 assert_eq!(fill.currency, Currency::USDT());
5056 assert_eq!(fill.liquidity_side, LiquiditySide::Taker);
5057 assert_eq!(fill.position_id, None);
5058 assert!(!fill.reconciliation);
5059 }
5060
5061 assert_eq!(order.status(), OrderStatus::Filled);
5062 assert_eq!(order.quantity(), base_qty);
5063 assert_eq!(order.filled_qty(), base_qty);
5064 assert_eq!(order.leaves_qty(), Quantity::zero(5));
5065 assert!(!order.is_quote_quantity());
5066 assert_eq!(order.venue_order_id(), Some(venue_order_id));
5067 assert_eq!(
5068 order.commissions().get(&Currency::USDT()),
5069 Some(&Money::from("0.012 USDT"))
5070 );
5071 assert_eq!(
5072 order.commissions().get(&Currency::ETH()),
5073 multiple_fills.then_some(&Money::from("0.000002 ETH"))
5074 );
5075 assert!(state.order_identities.is_empty());
5076
5077 let cache = Rc::new(RefCell::new(Cache::default()));
5078 cache
5079 .borrow_mut()
5080 .add_instrument(nautilus_model::instruments::stubs::currency_pair_ethusdt().into())
5081 .unwrap();
5082 cache
5083 .borrow_mut()
5084 .add_order(order.clone(), None, None, false)
5085 .unwrap();
5086
5087 let mut engine = ExecutionEngine::new(
5088 Rc::new(RefCell::new(VirtualClock::new())),
5089 Rc::clone(&cache),
5090 None,
5091 );
5092 dispatch(&new);
5093 let late_events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5094 assert_eq!(late_events.len(), 1);
5095
5096 let ExecutionEvent::Report(ExecutionReport::Order(report)) = &late_events[0] else {
5097 panic!("Expected untracked order report");
5098 };
5099
5100 engine.reconcile_order_status_report(report);
5101 assert_eq!(
5102 cache.borrow().try_order_owned(&client_order_id).unwrap(),
5103 order
5104 );
5105 }
5106
5107 #[rstest]
5108 #[case::malformed("invalid")]
5109 #[case::zero("0")]
5110 #[case::below_filled("0.001")]
5111 fn test_dispatch_quote_quantity_invalid_base_quantity_recovers(#[case] original_qty: &str) {
5112 let clock = get_atomic_clock_realtime();
5113 let (emitter, mut rx) = create_test_emitter(clock);
5114 let http_client = create_test_http_client(clock);
5115 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
5116 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
5117 let state = create_tracked_dispatch_state(client_order_id, instrument_id);
5118 {
5119 let mut identity = state.order_identities.get_mut(&client_order_id).unwrap();
5120 identity.order_type = OrderType::Market;
5121 identity.is_quote_quantity = true;
5122 identity.quantity = Quantity::from("13.00000");
5123 }
5124
5125 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
5126 let json = crate::common::testing::load_fixture_string(
5127 "spot/user_data_json/execution_report_trade.json",
5128 );
5129 let mut trade: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
5130 trade.original_qty = original_qty.to_string();
5131 trade.last_filled_qty = "0.003".to_string();
5132 trade.cumulative_filled_qty = "0.003".to_string();
5133
5134 let dispatch = |report: &BinanceSpotExecutionReport| {
5135 dispatch_execution_report(
5136 report,
5137 &emitter,
5138 &http_client,
5139 AccountId::from("BINANCE-001"),
5140 false,
5141 &state,
5142 &seen_trade_ids,
5143 clock.get_time_ns(),
5144 );
5145 };
5146
5147 dispatch(&trade);
5148 let rejected_events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5149 let tracked_after_error = state.order_identities.contains_key(&client_order_id);
5150 let consumed_after_error = seen_trade_ids
5151 .lock()
5152 .contains(&(trade.symbol, trade.trade_id));
5153 trade.original_qty = "0.003".to_string();
5154 dispatch(&trade);
5155 let recovered_events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5156
5157 assert_eq!(rejected_events.len(), 1);
5158 assert!(matches!(
5159 rejected_events[0],
5160 ExecutionEvent::Order(OrderEventAny::Accepted(_))
5161 ));
5162 assert!(tracked_after_error);
5163 assert!(!consumed_after_error);
5164 assert_eq!(recovered_events.len(), 2);
5165 assert!(
5166 matches!(&recovered_events[0], ExecutionEvent::Order(OrderEventAny::Updated(event)) if event.quantity == Quantity::from("0.00300") && !event.is_quote_quantity)
5167 );
5168 assert!(
5169 matches!(&recovered_events[1], ExecutionEvent::Order(OrderEventAny::Filled(event)) if event.last_qty == Quantity::from("0.00300"))
5170 );
5171 assert!(state.order_identities.is_empty());
5172 }
5173
5174 #[rstest]
5175 fn test_dispatch_tracked_execution_report_invalid_fill_qty_skips_filled_event() {
5176 let tasks = TaskGroup::new();
5177 let task_spawner = tasks.spawner().expect("task spawner");
5178 let clock = get_atomic_clock_realtime();
5179 let (emitter, mut rx) = create_test_emitter(clock);
5180 let http_client = create_test_http_client(clock);
5181 let dispatch_state = create_tracked_dispatch_state(
5182 ClientOrderId::from("O-20200101-000000-000-000-0"),
5183 InstrumentId::from("ETHUSDT.BINANCE"),
5184 );
5185 let ws_authenticated = tokio::sync::Notify::new();
5186 let ws_user_data_subscribed = tokio::sync::Notify::new();
5187 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
5188 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
5189
5190 let trade_json = crate::common::testing::load_fixture_string(
5191 "spot/user_data_json/execution_report_trade.json",
5192 );
5193 let mut report: BinanceSpotExecutionReport = serde_json::from_str(&trade_json).unwrap();
5194 report.last_filled_qty = "not-a-number".to_string();
5195
5196 dispatch_ws_trading_message(
5197 BinanceSpotWsTradingMessage::ExecutionReport(Box::new(report)),
5198 &emitter,
5199 &http_client,
5200 AccountId::from("BINANCE-001"),
5201 false,
5202 clock,
5203 &dispatch_state,
5204 &ws_authenticated,
5205 &ws_user_data_subscribed,
5206 &ws_setup_error_tx,
5207 &seen_trade_ids,
5208 &task_spawner,
5209 );
5210
5211 let mut events = Vec::new();
5212 while let Ok(event) = rx.try_recv() {
5213 events.push(event);
5214 }
5215
5216 assert!(
5217 events
5218 .iter()
5219 .all(|e| !matches!(e, ExecutionEvent::Order(OrderEventAny::Filled(_)))),
5220 "invalid fill quantity must not emit OrderFilled",
5221 );
5222 }
5223
5224 #[rstest]
5225 fn test_dispatch_execution_report_invalid_client_order_id_emits_nothing() {
5226 let clock = get_atomic_clock_realtime();
5227 let (emitter, mut rx) = create_test_emitter(clock);
5228 let http_client = create_test_http_client(clock);
5229 let dispatch_state = WsDispatchState::default();
5230 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
5231 let json = crate::common::testing::load_fixture_string(
5232 "spot/user_data_json/execution_report_new.json",
5233 );
5234 let mut report: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
5235 report.client_order_id = "x-TD67BGP9-R".to_string();
5236
5237 dispatch_execution_report(
5238 &report,
5239 &emitter,
5240 &http_client,
5241 AccountId::from("BINANCE-001"),
5242 false,
5243 &dispatch_state,
5244 &seen_trade_ids,
5245 clock.get_time_ns(),
5246 );
5247
5248 assert!(rx.try_recv().is_err());
5249 assert!(dispatch_state.order_identities.is_empty());
5250 }
5251
5252 #[rstest]
5253 fn test_dispatch_execution_report_canceled_resolves_order_by_orig_client_order_id() {
5254 let clock = get_atomic_clock_realtime();
5255 let (emitter, mut rx) = create_test_emitter(clock);
5256 let http_client = create_test_http_client(clock);
5257 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
5258 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
5259 let dispatch_state = WsDispatchState::default();
5260 dispatch_state.insert_accepted(client_order_id);
5261 dispatch_state.order_identities.insert(
5262 client_order_id,
5263 OrderIdentity {
5264 instrument_id,
5265 strategy_id: StrategyId::from("TEST-STRATEGY"),
5266 order_side: OrderSide::Buy,
5267 order_type: OrderType::Limit,
5268 price: None,
5269 quantity: Quantity::from("1"),
5270 is_quote_quantity: false,
5271 venue_position_id: None,
5272 },
5273 );
5274 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
5275 let json = crate::common::testing::load_fixture_string(
5276 "spot/user_data_json/execution_report_canceled.json",
5277 );
5278 let mut report: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
5279 report.original_client_order_id = Some(report.client_order_id.clone());
5280 report.client_order_id = "web_9f8e7d6c5b4a".to_string();
5281
5282 dispatch_execution_report(
5283 &report,
5284 &emitter,
5285 &http_client,
5286 AccountId::from("BINANCE-001"),
5287 false,
5288 &dispatch_state,
5289 &seen_trade_ids,
5290 clock.get_time_ns(),
5291 );
5292
5293 match rx.try_recv().expect("OrderCanceled expected") {
5294 ExecutionEvent::Order(OrderEventAny::Canceled(event)) => {
5295 assert_eq!(event.client_order_id, client_order_id);
5296 }
5297 other => panic!("Expected OrderCanceled, was {other:?}"),
5298 }
5299 assert!(rx.try_recv().is_err());
5300 assert!(dispatch_state.order_identities.is_empty());
5301 }
5302
5303 #[rstest]
5304 fn test_build_cancel_replace_params_sends_cancel_new_client_order_id() {
5305 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
5306 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
5307 let mut builder = OrderTestBuilder::new(OrderType::Limit);
5308 let order = builder
5309 .instrument_id(instrument_id)
5310 .client_order_id(client_order_id)
5311 .side(OrderSide::Buy)
5312 .quantity(Quantity::from("1"))
5313 .price(Price::from("2500.00"))
5314 .build();
5315 let cmd = ModifyOrder::new(
5316 TraderId::from("TESTER-001"),
5317 None,
5318 StrategyId::from("TEST-STRATEGY"),
5319 instrument_id,
5320 client_order_id,
5321 Some(VenueOrderId::from("12345678")),
5322 Some(Quantity::from("2")),
5323 Some(Price::from("2400.00")),
5324 None,
5325 UUID4::new(),
5326 UnixNanos::default(),
5327 None,
5328 None,
5329 );
5330 let cancel_id = cancel_replace_cancel_id();
5331
5332 let params = build_cancel_replace_params(
5333 &cmd,
5334 &order,
5335 Quantity::from("2"),
5336 false,
5337 cancel_id.clone(),
5338 )
5339 .unwrap();
5340
5341 assert!(cancel_id.len() <= 36);
5342 assert_eq!(params.cancel_order_id, Some(12345678));
5343 assert_eq!(
5344 params.cancel_new_client_order_id.as_deref(),
5345 Some(cancel_id.as_str())
5346 );
5347 let json = serde_json::to_value(¶ms).unwrap();
5348 assert_eq!(json["cancelNewClientOrderId"], cancel_id);
5349 }
5350
5351 #[rstest]
5352 fn test_dispatch_execution_report_cancel_replace_skips_cancel_then_updates_on_new() {
5353 let clock = get_atomic_clock_realtime();
5354 let (emitter, mut rx) = create_test_emitter(clock);
5355 let http_client = create_test_http_client(clock);
5356 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
5357 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
5358 let dispatch_state = WsDispatchState::default();
5359 dispatch_state.insert_accepted(client_order_id);
5360 dispatch_state.order_identities.insert(
5361 client_order_id,
5362 OrderIdentity {
5363 instrument_id,
5364 strategy_id: StrategyId::from("TEST-STRATEGY"),
5365 order_side: OrderSide::Buy,
5366 order_type: OrderType::Limit,
5367 price: None,
5368 quantity: Quantity::from("1"),
5369 is_quote_quantity: false,
5370 venue_position_id: None,
5371 },
5372 );
5373 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
5374 let account_id = AccountId::from("BINANCE-001");
5375
5376 let json = crate::common::testing::load_fixture_string(
5377 "spot/user_data_json/execution_report_canceled.json",
5378 );
5379 let mut canceled: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
5380 canceled.original_client_order_id = Some(canceled.client_order_id.clone());
5381 let cancel_id = cancel_replace_cancel_id();
5382 dispatch_state.insert_cancel_replace(cancel_id.clone());
5383 canceled.client_order_id = cancel_id;
5384
5385 dispatch_execution_report(
5386 &canceled,
5387 &emitter,
5388 &http_client,
5389 account_id,
5390 false,
5391 &dispatch_state,
5392 &seen_trade_ids,
5393 clock.get_time_ns(),
5394 );
5395
5396 assert!(rx.try_recv().is_err(), "cancel half must not emit");
5397 assert!(
5398 dispatch_state
5399 .order_identities
5400 .contains_key(&client_order_id)
5401 );
5402
5403 let json = crate::common::testing::load_fixture_string(
5404 "spot/user_data_json/execution_report_new.json",
5405 );
5406 let mut new: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
5407 new.order_id = canceled.order_id + 1;
5408
5409 dispatch_execution_report(
5410 &new,
5411 &emitter,
5412 &http_client,
5413 account_id,
5414 false,
5415 &dispatch_state,
5416 &seen_trade_ids,
5417 clock.get_time_ns(),
5418 );
5419
5420 match rx.try_recv().expect("OrderUpdated expected") {
5421 ExecutionEvent::Order(OrderEventAny::Updated(event)) => {
5422 assert_eq!(event.client_order_id, client_order_id);
5423 assert_eq!(
5424 event.venue_order_id,
5425 Some(VenueOrderId::new(new.order_id.to_string()))
5426 );
5427 }
5428 other => panic!("Expected OrderUpdated, was {other:?}"),
5429 }
5430 assert!(rx.try_recv().is_err());
5431 }
5432
5433 fn cancel_replace_cancel_report(cancel_id: &str) -> BinanceSpotExecutionReport {
5434 let json = crate::common::testing::load_fixture_string(
5435 "spot/user_data_json/execution_report_canceled.json",
5436 );
5437 let mut report: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
5438 report.original_client_order_id = Some(report.client_order_id.clone());
5439 report.client_order_id = cancel_id.to_string();
5440 report
5441 }
5442
5443 fn reject_cancel_replace(
5444 request_id: &str,
5445 code: i32,
5446 emitter: &ExecutionEventEmitter,
5447 http_client: &BinanceSpotHttpClient,
5448 dispatch_state: &WsDispatchState,
5449 task_spawner: &TaskSpawner,
5450 clock: &'static AtomicTime,
5451 ) {
5452 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
5453 let ws_authenticated = tokio::sync::Notify::new();
5454 let ws_user_data_subscribed = tokio::sync::Notify::new();
5455 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
5456
5457 dispatch_ws_trading_message(
5458 BinanceSpotWsTradingMessage::CancelReplaceRejected {
5459 request_id: request_id.to_string(),
5460 status: 400,
5461 code,
5462 msg: "Order cancel-replace partially failed".to_string(),
5463 },
5464 emitter,
5465 http_client,
5466 AccountId::from("BINANCE-001"),
5467 false,
5468 clock,
5469 dispatch_state,
5470 &ws_authenticated,
5471 &ws_user_data_subscribed,
5472 &ws_setup_error_tx,
5473 &seen_trade_ids,
5474 task_spawner,
5475 );
5476 }
5477
5478 #[rstest]
5479 #[case::cancel_report_first(true)]
5480 #[case::rejection_first(false)]
5481 fn test_cancel_replace_rejected_after_confirmed_cancel_emits_canceled(
5482 #[case] cancel_report_first: bool,
5483 ) {
5484 let tasks = TaskGroup::new();
5485 let task_spawner = tasks.spawner().expect("task spawner");
5486 let clock = get_atomic_clock_realtime();
5487 let (emitter, mut rx) = create_test_emitter(clock);
5488 let http_client = create_test_http_client(clock);
5489 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
5490 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
5491 let dispatch_state = create_tracked_dispatch_state(client_order_id, instrument_id);
5492 dispatch_state.insert_accepted(client_order_id);
5493 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
5494 let account_id = AccountId::from("BINANCE-001");
5495
5496 let cancel_id = cancel_replace_cancel_id();
5497 dispatch_state.insert_cancel_replace(cancel_id.clone());
5498 dispatch_state.pending_requests.insert(
5499 "req-modify".to_string(),
5500 PendingRequest {
5501 client_order_id,
5502 venue_order_id: Some(VenueOrderId::from("12345678")),
5503 operation: PendingOperation::Modify,
5504 },
5505 );
5506 dispatch_state
5507 .cancel_replace_request_ids
5508 .insert("req-modify".to_string(), cancel_id.clone());
5509 dispatch_state.begin_replace(client_order_id, VenueOrderId::from("12345678"));
5510 let canceled = cancel_replace_cancel_report(&cancel_id);
5511
5512 if cancel_report_first {
5513 dispatch_execution_report(
5514 &canceled,
5515 &emitter,
5516 &http_client,
5517 account_id,
5518 false,
5519 &dispatch_state,
5520 &seen_trade_ids,
5521 clock.get_time_ns(),
5522 );
5523 assert!(rx.try_recv().is_err(), "cancel report must be withheld");
5524 }
5525
5526 reject_cancel_replace(
5527 "req-modify",
5528 -2021,
5529 &emitter,
5530 &http_client,
5531 &dispatch_state,
5532 &task_spawner,
5533 clock,
5534 );
5535
5536 match rx.try_recv().expect("OrderModifyRejected expected") {
5537 ExecutionEvent::Order(OrderEventAny::ModifyRejected(event)) => {
5538 assert_eq!(event.client_order_id, client_order_id);
5539 }
5540 other => panic!("Expected OrderModifyRejected, was {other:?}"),
5541 }
5542
5543 if !cancel_report_first {
5544 assert!(rx.try_recv().is_err(), "no cancel confirmed yet");
5545 dispatch_execution_report(
5546 &canceled,
5547 &emitter,
5548 &http_client,
5549 account_id,
5550 false,
5551 &dispatch_state,
5552 &seen_trade_ids,
5553 clock.get_time_ns(),
5554 );
5555 }
5556
5557 match rx.try_recv().expect("OrderCanceled expected") {
5558 ExecutionEvent::Order(OrderEventAny::Canceled(event)) => {
5559 assert_eq!(event.client_order_id, client_order_id);
5560 assert_eq!(event.venue_order_id, Some(VenueOrderId::from("12345678")));
5561 assert_eq!(
5562 event.ts_event,
5563 UnixNanos::from(1_709_654_402_000_000_000u64)
5564 );
5565 }
5566 other => panic!("Expected OrderCanceled, was {other:?}"),
5567 }
5568 assert!(rx.try_recv().is_err());
5569 assert!(dispatch_state.order_identities.is_empty());
5570 assert!(dispatch_state.pending_requests.is_empty());
5571 assert!(dispatch_state.cancel_replace_request_ids.is_empty());
5572 }
5573
5574 #[rstest]
5575 #[case::partial_failure(-2021, 409, true)]
5576 #[case::ambiguous(BINANCE_STATUS_UNKNOWN_CODE, 500, false)]
5577 fn test_http_modify_failure_replays_withheld_cancel_without_identity(
5578 #[case] code: i64,
5579 #[case] status: u16,
5580 #[case] definitive: bool,
5581 ) {
5582 let clock = get_atomic_clock_realtime();
5583 let (emitter, mut rx) = create_test_emitter(clock);
5584 let http_client = create_test_http_client(clock);
5585 let dispatch_state = WsDispatchState::default();
5586 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
5587 let account_id = AccountId::from("BINANCE-001");
5588 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
5589 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
5590
5591 let cancel_id = cancel_replace_cancel_id();
5592 dispatch_state.insert_cancel_replace(cancel_id.clone());
5593 let canceled = cancel_replace_cancel_report(&cancel_id);
5594
5595 dispatch_execution_report(
5596 &canceled,
5597 &emitter,
5598 &http_client,
5599 account_id,
5600 false,
5601 &dispatch_state,
5602 &seen_trade_ids,
5603 clock.get_time_ns(),
5604 );
5605 assert!(rx.try_recv().is_err(), "cancel report must be withheld");
5606
5607 let command = ModifyOrder::new(
5608 TraderId::from("TESTER-001"),
5609 None,
5610 StrategyId::from("TEST-STRATEGY"),
5611 instrument_id,
5612 client_order_id,
5613 Some(VenueOrderId::from("12345678")),
5614 Some(Quantity::from("2")),
5615 Some(Price::from("2400.00")),
5616 None,
5617 UUID4::new(),
5618 UnixNanos::default(),
5619 None,
5620 None,
5621 );
5622 let error = anyhow::anyhow!(BinanceSpotHttpError::BinanceError {
5623 code,
5624 message: "Order cancel-replace partially failed.".to_string(),
5625 status,
5626 retry_after: None,
5627 });
5628
5629 handle_http_modify_failure(
5630 &error,
5631 &command,
5632 &cancel_id,
5633 &emitter,
5634 &dispatch_state,
5635 account_id,
5636 clock.get_time_ns(),
5637 );
5638
5639 if !definitive {
5640 assert!(
5641 rx.try_recv().is_err(),
5642 "ambiguous outcome must remain pending"
5643 );
5644 return;
5645 }
5646
5647 match rx.try_recv().expect("OrderModifyRejected expected") {
5648 ExecutionEvent::Order(OrderEventAny::ModifyRejected(event)) => {
5649 assert_eq!(event.client_order_id, client_order_id);
5650 }
5651 other => panic!("Expected OrderModifyRejected, was {other:?}"),
5652 }
5653
5654 match rx.try_recv().expect("cancel status report expected") {
5655 ExecutionEvent::Report(ExecutionReport::Order(report)) => {
5656 assert_eq!(report.client_order_id, Some(client_order_id));
5657 assert_eq!(report.venue_order_id, VenueOrderId::from("12345678"));
5658 assert_eq!(report.order_status, OrderStatus::Canceled);
5659 assert_eq!(
5660 report.ts_last,
5661 UnixNanos::from(1_709_654_402_000_000_000u64)
5662 );
5663 }
5664 other => panic!("Expected order status report, was {other:?}"),
5665 }
5666 assert!(rx.try_recv().is_err());
5667 }
5668
5669 #[rstest]
5670 #[case::unexpected_response(BINANCE_UNEXPECTED_RESPONSE_CODE)]
5671 #[case::status_unknown(BINANCE_STATUS_UNKNOWN_CODE)]
5672 fn test_cancel_replace_ambiguous_rejection_keeps_withholding_cancel(#[case] code: i64) {
5673 let tasks = TaskGroup::new();
5674 let task_spawner = tasks.spawner().expect("task spawner");
5675 let clock = get_atomic_clock_realtime();
5676 let (emitter, mut rx) = create_test_emitter(clock);
5677 let http_client = create_test_http_client(clock);
5678 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
5679 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
5680 let dispatch_state = create_tracked_dispatch_state(client_order_id, instrument_id);
5681 dispatch_state.insert_accepted(client_order_id);
5682 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
5683 let account_id = AccountId::from("BINANCE-001");
5684
5685 let cancel_id = cancel_replace_cancel_id();
5686 dispatch_state.insert_cancel_replace(cancel_id.clone());
5687 dispatch_state.pending_requests.insert(
5688 "req-modify".to_string(),
5689 PendingRequest {
5690 client_order_id,
5691 venue_order_id: Some(VenueOrderId::from("12345678")),
5692 operation: PendingOperation::Modify,
5693 },
5694 );
5695 dispatch_state
5696 .cancel_replace_request_ids
5697 .insert("req-modify".to_string(), cancel_id.clone());
5698 dispatch_state.begin_replace(client_order_id, VenueOrderId::from("12345678"));
5699 let canceled = cancel_replace_cancel_report(&cancel_id);
5700
5701 dispatch_execution_report(
5702 &canceled,
5703 &emitter,
5704 &http_client,
5705 account_id,
5706 false,
5707 &dispatch_state,
5708 &seen_trade_ids,
5709 clock.get_time_ns(),
5710 );
5711 reject_cancel_replace(
5712 "req-modify",
5713 i32::try_from(code).unwrap(),
5714 &emitter,
5715 &http_client,
5716 &dispatch_state,
5717 &task_spawner,
5718 clock,
5719 );
5720
5721 assert!(
5722 rx.try_recv().is_err(),
5723 "ambiguous outcome must remain pending"
5724 );
5725
5726 let json = crate::common::testing::load_fixture_string(
5728 "spot/user_data_json/execution_report_new.json",
5729 );
5730 let mut new: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
5731 new.order_id = canceled.order_id + 1;
5732
5733 dispatch_execution_report(
5734 &new,
5735 &emitter,
5736 &http_client,
5737 account_id,
5738 false,
5739 &dispatch_state,
5740 &seen_trade_ids,
5741 clock.get_time_ns(),
5742 );
5743
5744 match rx.try_recv().expect("OrderUpdated expected") {
5745 ExecutionEvent::Order(OrderEventAny::Updated(event)) => {
5746 assert_eq!(event.client_order_id, client_order_id);
5747 assert_eq!(
5748 event.venue_order_id,
5749 Some(VenueOrderId::new(new.order_id.to_string()))
5750 );
5751 }
5752 other => panic!("Expected OrderUpdated, was {other:?}"),
5753 }
5754 assert!(rx.try_recv().is_err());
5755 }
5756
5757 #[rstest]
5758 #[case::as_expired(false, OrderStatus::Expired)]
5759 #[case::as_canceled(true, OrderStatus::Canceled)]
5760 fn test_normalize_spot_order_status_report_expired_respects_config(
5761 #[case] treat_expired_as_canceled: bool,
5762 #[case] expected: OrderStatus,
5763 ) {
5764 let clock = get_atomic_clock_realtime();
5765 let json = crate::common::testing::load_fixture_string(
5766 "spot/user_data_json/execution_report_expired.json",
5767 );
5768 let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
5769 let mut report = parse_spot_exec_report_to_order_status(
5770 &msg,
5771 InstrumentId::from("ETHUSDT.BINANCE"),
5772 2,
5773 5,
5774 AccountId::from("BINANCE-001"),
5775 false,
5776 clock.get_time_ns(),
5777 )
5778 .unwrap();
5779 let mut reports = vec![report.clone()];
5780
5781 normalize_spot_order_status_report(&mut report, treat_expired_as_canceled);
5782 normalize_spot_order_status_reports(&mut reports, treat_expired_as_canceled);
5783
5784 assert_eq!(report.order_status, expected);
5785 assert_eq!(reports[0].order_status, expected);
5786 }
5787
5788 #[rstest]
5789 #[case::tracked(true)]
5790 #[case::external(false)]
5791 fn test_cancel_identifier_preserves_external_orders(#[case] tracked: bool) {
5792 let client_order_id = ClientOrderId::from("cancel-target");
5793 let cmd = CancelOrder::new(
5794 TraderId::from("TESTER-001"),
5795 None,
5796 StrategyId::from("TEST-STRATEGY"),
5797 InstrumentId::from("ETHUSDT.BINANCE"),
5798 client_order_id,
5799 Some(VenueOrderId::from("12345")),
5800 UUID4::new(),
5801 UnixNanos::default(),
5802 None,
5803 None,
5804 );
5805
5806 let params = build_cancel_order_params(&cmd, tracked);
5807
5808 assert_eq!(params.symbol, "ETHUSDT");
5809 assert_eq!(params.order_id, (!tracked).then_some(12345));
5810 assert_eq!(
5811 params.orig_client_order_id,
5812 tracked.then(|| encode_broker_id(&client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID))
5813 );
5814 assert_eq!(params.new_client_order_id, None);
5815 }
5816
5817 #[rstest]
5818 #[case::stream_first(false, false)]
5819 #[case::http_first(true, false)]
5820 #[case::replacement_rejected(false, true)]
5821 fn test_cancel_replace_preserves_logical_order(
5822 #[case] http_first: bool,
5823 #[case] rejected: bool,
5824 ) {
5825 let clock = get_atomic_clock_realtime();
5826 let (emitter, mut rx) = create_test_emitter(clock);
5827 let client = create_test_http_client(clock);
5828 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
5829 let state =
5830 create_tracked_dispatch_state(client_order_id, InstrumentId::from("ETHUSDT.BINANCE"));
5831 let seen = Arc::new(Mutex::new(FifoCache::new()));
5832 let json = crate::common::testing::load_fixture_string(
5833 "spot/user_data_json/execution_report_new.json",
5834 );
5835 let mut original: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
5836 original.client_order_id =
5837 encode_broker_id(&client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
5838 let dispatch = |report: &BinanceSpotExecutionReport| {
5839 dispatch_execution_report(
5840 report,
5841 &emitter,
5842 &client,
5843 AccountId::from("BINANCE-001"),
5844 false,
5845 &state,
5846 &seen,
5847 clock.get_time_ns(),
5848 );
5849 };
5850 dispatch(&original);
5851 let old_id = VenueOrderId::from(original.order_id.to_string());
5852 let new_id = VenueOrderId::from((original.order_id + 1).to_string());
5853 state.begin_replace(client_order_id, old_id);
5854 let mut replacement = original.clone();
5855 replacement.order_id += 1;
5856 replacement.price = "2501.00".to_string();
5857 replacement.original_qty = "2.00000".to_string();
5858
5859 if http_first {
5860 assert!(state.record_order_update(
5861 client_order_id,
5862 new_id,
5863 Quantity::from("2.00000"),
5864 Price::from("2501.00"),
5865 None
5866 ));
5867 }
5868 let mut canceled = original.clone();
5869 canceled.execution_type = BinanceSpotExecutionType::Canceled;
5870 canceled.original_client_order_id = Some(original.client_order_id);
5871 canceled.client_order_id = "venue-cancel-request".to_string();
5872 dispatch(&canceled);
5873
5874 if rejected {
5875 let event = state
5876 .reject_replace(client_order_id)
5877 .expect("Cancellation must be retained");
5878 assert_eq!(event.venue_order_id, Some(old_id));
5879 state.cleanup_terminal(client_order_id);
5880 emitter.send_order_event(OrderEventAny::Canceled(event));
5881 } else {
5882 dispatch(&replacement);
5883 dispatch(&replacement);
5884 dispatch(&canceled);
5885 assert!(state.order_identities.contains_key(&client_order_id));
5886 assert!(!state.record_order_update(
5887 client_order_id,
5888 new_id,
5889 Quantity::from("2.00000"),
5890 Price::from("2501.00"),
5891 None
5892 ));
5893 }
5894 assert!(matches!(
5895 rx.try_recv().unwrap(),
5896 ExecutionEvent::Order(OrderEventAny::Accepted(_))
5897 ));
5898
5899 if rejected {
5900 assert!(matches!(
5901 rx.try_recv().unwrap(),
5902 ExecutionEvent::Order(OrderEventAny::Canceled(_))
5903 ));
5904 } else if !http_first {
5905 let ExecutionEvent::Order(OrderEventAny::Updated(event)) = rx.try_recv().unwrap()
5906 else {
5907 panic!("Expected replacement update");
5908 };
5909 assert_eq!(event.client_order_id, client_order_id);
5910 assert_eq!(event.venue_order_id, Some(new_id));
5911 assert_eq!(event.quantity, Quantity::from("2.00000"));
5912 assert_eq!(event.price, Some(Price::from("2501.00")));
5913 }
5914 assert!(rx.try_recv().is_err());
5915 }
5916
5917 #[rstest]
5918 #[case::duplicate("duplicate")]
5919 #[case::quantity("quantity")]
5920 #[case::price("price")]
5921 #[case::trigger("trigger")]
5922 #[case::replacement("replacement")]
5923 fn test_dispatch_new_emits_only_changed_order_terms(#[case] change: &str) {
5924 let clock = get_atomic_clock_realtime();
5925 let (emitter, mut rx) = create_test_emitter(clock);
5926 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
5927 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
5928 let state = create_tracked_dispatch_state(client_order_id, instrument_id);
5929 let identity = state
5930 .order_identities
5931 .get(&client_order_id)
5932 .unwrap()
5933 .clone();
5934 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
5935 let json = crate::common::testing::load_fixture_string(
5936 "spot/user_data_json/execution_report_new.json",
5937 );
5938 let mut report: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
5939 let dispatch = |report: &BinanceSpotExecutionReport| {
5940 dispatch_tracked_execution_report(
5941 report,
5942 &emitter,
5943 AccountId::from("BINANCE-001"),
5944 false,
5945 &state,
5946 &seen_trade_ids,
5947 client_order_id,
5948 &identity,
5949 instrument_id,
5950 2,
5951 5,
5952 Currency::USDT(),
5953 clock.get_time_ns(),
5954 );
5955 };
5956 dispatch(&report);
5957 match change {
5958 "quantity" => report.original_qty = "2.00000".to_string(),
5959 "price" => report.price = "2501.00".to_string(),
5960 "trigger" => report.stop_price = "2499.00".to_string(),
5961 "replacement" => report.order_id += 1,
5962 "duplicate" => {}
5963 _ => unreachable!(),
5964 }
5965 dispatch(&report);
5966 dispatch(&report);
5967
5968 assert!(matches!(
5969 rx.try_recv().unwrap(),
5970 ExecutionEvent::Order(OrderEventAny::Accepted(_))
5971 ));
5972
5973 if change != "duplicate" {
5974 let ExecutionEvent::Order(OrderEventAny::Updated(updated)) = rx.try_recv().unwrap()
5975 else {
5976 panic!("expected changed order update");
5977 };
5978 assert_eq!(updated.client_order_id, client_order_id);
5979 assert_eq!(
5980 updated.venue_order_id,
5981 Some(VenueOrderId::from(report.order_id.to_string()))
5982 );
5983 assert_eq!(
5984 updated.quantity,
5985 Quantity::from(report.original_qty.as_str())
5986 );
5987 assert_eq!(updated.price, Some(Price::from(report.price.as_str())));
5988 assert_eq!(
5989 updated.trigger_price,
5990 (change == "trigger").then(|| Price::from("2499.00"))
5991 );
5992 }
5993 assert!(rx.try_recv().is_err());
5994 }
5995
5996 #[rstest]
5997 #[case::as_expired(false)]
5998 #[case::as_canceled(true)]
5999 fn test_dispatch_tracked_execution_report_expired_respects_config(
6000 #[case] treat_expired_as_canceled: bool,
6001 ) {
6002 let clock = get_atomic_clock_realtime();
6003 let (emitter, mut rx) = create_test_emitter(clock);
6004 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-0");
6005 let instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
6006 let dispatch_state = WsDispatchState::default();
6007 dispatch_state.insert_accepted(client_order_id);
6008 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
6009 let identity = OrderIdentity {
6010 instrument_id,
6011 strategy_id: StrategyId::from("TEST-STRATEGY"),
6012 order_side: OrderSide::Buy,
6013 order_type: OrderType::Limit,
6014 price: None,
6015 quantity: Quantity::from("1"),
6016 is_quote_quantity: false,
6017 venue_position_id: None,
6018 };
6019
6020 let json = crate::common::testing::load_fixture_string(
6021 "spot/user_data_json/execution_report_expired.json",
6022 );
6023 let report: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
6024
6025 dispatch_tracked_execution_report(
6026 &report,
6027 &emitter,
6028 AccountId::from("BINANCE-001"),
6029 treat_expired_as_canceled,
6030 &dispatch_state,
6031 &seen_trade_ids,
6032 client_order_id,
6033 &identity,
6034 instrument_id,
6035 2,
6036 5,
6037 Currency::USDT(),
6038 clock.get_time_ns(),
6039 );
6040
6041 let event = rx.try_recv().expect("terminal order event expected");
6042 match (treat_expired_as_canceled, event) {
6043 (true, ExecutionEvent::Order(OrderEventAny::Canceled(event))) => {
6044 assert_eq!(event.client_order_id, client_order_id);
6045 }
6046 (false, ExecutionEvent::Order(OrderEventAny::Expired(event))) => {
6047 assert_eq!(event.client_order_id, client_order_id);
6048 }
6049 (_, other) => panic!("Expected terminal expired/canceled event, was {other:?}"),
6050 }
6051 assert!(rx.try_recv().is_err());
6052 }
6053
6054 #[rstest]
6055 fn test_dispatch_tracked_execution_report_rejected_gtx_sets_post_only() {
6056 let tasks = TaskGroup::new();
6057 let task_spawner = tasks.spawner().expect("task spawner");
6058 let clock = get_atomic_clock_realtime();
6059 let (emitter, mut rx) = create_test_emitter(clock);
6060 let http_client = create_test_http_client(clock);
6061 let client_order_id = ClientOrderId::from("O-20200101-000000-000-000-1");
6062 let dispatch_state =
6063 create_tracked_dispatch_state(client_order_id, InstrumentId::from("ETHUSDT.BINANCE"));
6064 let ws_authenticated = tokio::sync::Notify::new();
6065 let ws_user_data_subscribed = tokio::sync::Notify::new();
6066 let (ws_setup_error_tx, _ws_setup_error_rx) = tokio::sync::mpsc::unbounded_channel();
6067 let seen_trade_ids = Arc::new(Mutex::new(FifoCache::new()));
6068
6069 let encoded = encode_broker_id(&client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
6070 let report_json = format!(
6071 r#"{{
6072 "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
6073 "c":"{encoded}","S":"BUY","o":"LIMIT","f":"GTX",
6074 "q":"1.00000000","p":"2500.00000000","P":"0.00000000",
6075 "x":"REJECTED","X":"REJECTED","r":"NONE","i":12345678,
6076 "l":"0.00000000","z":"0.00000000","L":"0.00000000",
6077 "n":"0","N":null,"T":1709654400000,"t":-1,"w":false,"m":false,
6078 "O":1709654400000,"Z":"0.00000000","C":""
6079 }}"#,
6080 );
6081 let report: BinanceSpotExecutionReport = serde_json::from_str(&report_json).unwrap();
6082
6083 dispatch_ws_trading_message(
6084 BinanceSpotWsTradingMessage::ExecutionReport(Box::new(report)),
6085 &emitter,
6086 &http_client,
6087 AccountId::from("BINANCE-001"),
6088 false,
6089 clock,
6090 &dispatch_state,
6091 &ws_authenticated,
6092 &ws_user_data_subscribed,
6093 &ws_setup_error_tx,
6094 &seen_trade_ids,
6095 &task_spawner,
6096 );
6097
6098 match rx.try_recv().expect("OrderRejected event expected") {
6099 ExecutionEvent::Order(OrderEventAny::Rejected(event)) => {
6100 assert_eq!(event.client_order_id, client_order_id);
6101 assert_eq!(event.account_id, AccountId::from("BINANCE-001"));
6102 assert!(event.due_post_only);
6103 }
6104 other => panic!("Expected OrderRejected event, was {other:?}"),
6105 }
6106 }
6107
6108 fn test_execution_client(
6109 base_url_http: String,
6110 ) -> (BinanceSpotExecutionClient, Rc<RefCell<Cache>>) {
6111 let cache = Rc::new(RefCell::new(Cache::default()));
6112 let core = ExecutionClientCore::new(
6113 TraderId::from("TESTER-001"),
6114 *BINANCE_CLIENT_ID,
6115 *BINANCE_VENUE,
6116 OmsType::Hedging,
6117 AccountId::from("BINANCE-001"),
6118 AccountType::Cash,
6119 None,
6120 cache.clone(),
6121 );
6122 let config = BinanceExecutionClientConfig {
6123 base_url_http: Some(base_url_http),
6124 use_ws_trading: false,
6125 api_key: Some("test_api_key".into()),
6126 api_secret: Some("test_api_secret".into()),
6127 ..Default::default()
6128 };
6129
6130 (
6131 BinanceSpotExecutionClient::new(core, config).unwrap(),
6132 cache,
6133 )
6134 }
6135
6136 async fn wait_for_spawned_tasks(client: &BinanceSpotExecutionClient) {
6137 for _ in 0..40 {
6138 if client.pending_tasks.all_finished() {
6139 return;
6140 }
6141
6142 tokio::time::sleep(Duration::from_millis(25)).await;
6143 }
6144
6145 panic!("timed out waiting for spawned Binance Spot execution tasks");
6146 }
6147
6148 struct MockVenueHits {
6149 single_cancel: Arc<AtomicUsize>,
6150 batch: Arc<AtomicUsize>,
6151 cancel_all: Arc<AtomicUsize>,
6152 }
6153
6154 async fn start_cancel_reject_server() -> (String, MockVenueHits) {
6155 let single_hits = Arc::new(AtomicUsize::new(0));
6156 let single_hits_clone = single_hits.clone();
6157 let batch_hits = Arc::new(AtomicUsize::new(0));
6158 let batch_hits_clone = batch_hits.clone();
6159 let cancel_all_hits = Arc::new(AtomicUsize::new(0));
6160 let cancel_all_hits_clone = cancel_all_hits.clone();
6161 let app = axum::Router::new()
6162 .route(
6163 "/api/v3/order",
6164 axum::routing::delete(move || {
6165 let hits = single_hits_clone.clone();
6166 async move {
6167 hits.fetch_add(1, Ordering::Relaxed);
6168
6169 (
6170 axum::http::StatusCode::BAD_REQUEST,
6171 axum::Json(
6172 serde_json::json!({"code": -2011, "msg": "Unknown order sent"}),
6173 ),
6174 )
6175 }
6176 }),
6177 )
6178 .route(
6179 "/api/v3/batchOrders",
6180 axum::routing::delete(move || {
6181 let hits = batch_hits_clone.clone();
6182 async move {
6183 hits.fetch_add(1, Ordering::Relaxed);
6184
6185 axum::Json(serde_json::json!([]))
6186 }
6187 }),
6188 )
6189 .route(
6190 "/api/v3/openOrders",
6191 axum::routing::delete(move || {
6192 let hits = cancel_all_hits_clone.clone();
6193 async move {
6194 hits.fetch_add(1, Ordering::Relaxed);
6195
6196 axum::Json(serde_json::json!([]))
6197 }
6198 }),
6199 );
6200 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6201 let addr = listener.local_addr().unwrap();
6202
6203 tokio::spawn(async move {
6204 axum::serve(listener, app.into_make_service())
6205 .await
6206 .unwrap();
6207 });
6208
6209 (
6210 format!("http://{addr}"),
6211 MockVenueHits {
6212 single_cancel: single_hits,
6213 batch: batch_hits,
6214 cancel_all: cancel_all_hits,
6215 },
6216 )
6217 }
6218
6219 #[rstest]
6220 #[case::buy(Some(OrderSide::Buy), vec![0, 2])]
6221 #[case::sell(Some(OrderSide::Sell), vec![1, 3])]
6222 #[tokio::test]
6223 async fn test_cancel_all_orders_filters_by_side_and_preserves_owners(
6224 #[case] order_side: Option<OrderSide>,
6225 #[case] expected_indices: Vec<usize>,
6226 ) {
6227 let (base_url, hits) = start_cancel_reject_server().await;
6228 let (mut client, cache) = test_execution_client(base_url);
6229 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6230 client.emitter.set_sender(tx);
6231
6232 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
6233 let other_instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
6234 let account_id = client.core.account_id;
6235 let mut orders = Vec::new();
6236
6237 for (index, (instrument, owner, side, open)) in [
6238 (instrument_id, "S-001", OrderSide::Buy, true),
6239 (instrument_id, "S-001", OrderSide::Sell, true),
6240 (instrument_id, "S-002", OrderSide::Buy, true),
6241 (instrument_id, "S-002", OrderSide::Sell, true),
6242 (other_instrument_id, "S-001", OrderSide::Buy, true),
6243 (other_instrument_id, "S-002", OrderSide::Sell, true),
6244 (instrument_id, "S-001", OrderSide::Buy, false),
6245 (instrument_id, "S-002", OrderSide::Sell, false),
6246 ]
6247 .into_iter()
6248 .enumerate()
6249 {
6250 let client_order_id = ClientOrderId::from(format!("O-CANCEL-ALL-{index}"));
6251 let mut builder = OrderTestBuilder::new(OrderType::Limit);
6252 let order = builder
6253 .instrument_id(instrument)
6254 .client_order_id(client_order_id)
6255 .strategy_id(StrategyId::from(owner))
6256 .side(side)
6257 .quantity(Quantity::from("1"))
6258 .price(Price::from("10000.00"))
6259 .build();
6260 let venue_order_id = VenueOrderId::from(format!("{}", 1000 + index));
6261 let accepted = TestOrderEventStubs::accepted(&order, account_id, venue_order_id);
6262 cache
6263 .borrow_mut()
6264 .add_order(order, None, Some(*BINANCE_CLIENT_ID), false)
6265 .unwrap();
6266 let order = cache.borrow_mut().update_order(&accepted).unwrap();
6267
6268 if !open {
6269 let canceled =
6270 TestOrderEventStubs::canceled(&order, account_id, Some(venue_order_id));
6271 cache.borrow_mut().update_order(&canceled).unwrap();
6272 }
6273
6274 orders.push(order);
6275 }
6276
6277 client
6278 .cancel_all_orders(CancelAllOrders::new(
6279 TraderId::from("TESTER-001"),
6280 Some(*BINANCE_CLIENT_ID),
6281 StrategyId::from("S-001"),
6282 instrument_id,
6283 order_side,
6284 UUID4::new(),
6285 UnixNanos::default(),
6286 None,
6287 None,
6288 ))
6289 .unwrap();
6290 wait_for_spawned_tasks(&client).await;
6291
6292 assert_eq!(hits.single_cancel.load(Ordering::Relaxed), 2);
6294 assert_eq!(hits.batch.load(Ordering::Relaxed), 0);
6295 assert_eq!(hits.cancel_all.load(Ordering::Relaxed), 0);
6296
6297 let mut actual = Vec::new();
6298
6299 for _ in &expected_indices {
6300 let event = rx.try_recv().expect("expected OrderCancelRejected event");
6301 match event {
6302 ExecutionEvent::Order(OrderEventAny::CancelRejected(rejected)) => {
6303 actual.push((rejected.client_order_id, rejected.strategy_id));
6304 }
6305 event => panic!("expected OrderCancelRejected, was {event:?}"),
6306 }
6307 }
6308
6309 let mut expected: Vec<_> = expected_indices
6310 .iter()
6311 .map(|&index| {
6312 let order = &orders[index];
6313 (order.client_order_id(), order.strategy_id())
6314 })
6315 .collect();
6316
6317 actual.sort();
6318 expected.sort();
6319
6320 assert_eq!(actual, expected);
6321 assert!(rx.try_recv().is_err());
6322 }
6323
6324 #[rstest]
6325 #[tokio::test]
6326 async fn test_cancel_all_orders_with_side_and_empty_cache_sends_nothing() {
6327 let (base_url, hits) = start_cancel_reject_server().await;
6328 let (mut client, _cache) = test_execution_client(base_url);
6329 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6330 client.emitter.set_sender(tx);
6331
6332 client
6333 .cancel_all_orders(CancelAllOrders::new(
6334 TraderId::from("TESTER-001"),
6335 Some(*BINANCE_CLIENT_ID),
6336 StrategyId::from("S-001"),
6337 InstrumentId::from("BTCUSDT.BINANCE"),
6338 Some(OrderSide::Buy),
6339 UUID4::new(),
6340 UnixNanos::default(),
6341 None,
6342 None,
6343 ))
6344 .unwrap();
6345 wait_for_spawned_tasks(&client).await;
6346
6347 assert_eq!(hits.single_cancel.load(Ordering::Relaxed), 0);
6348 assert_eq!(hits.batch.load(Ordering::Relaxed), 0);
6349 assert_eq!(hits.cancel_all.load(Ordering::Relaxed), 0);
6350 assert!(rx.try_recv().is_err());
6351 assert!(client.dispatch_state.pending_requests.is_empty());
6352 }
6353}