Skip to main content

nautilus_binance/spot/
execution.rs

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