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