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