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