Skip to main content

nautilus_binance/futures/
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 Futures adapter.
17
18use std::{
19    future::Future,
20    sync::{
21        Arc,
22        atomic::{AtomicBool, Ordering},
23    },
24    time::Duration,
25};
26
27use ahash::AHashSet;
28use anyhow::Context;
29use async_trait::async_trait;
30use dashmap::DashMap;
31use nautilus_common::{
32    cache::fifo::FifoCache,
33    clients::ExecutionClient,
34    enums::LogLevel,
35    live::runner::get_exec_event_sender,
36    messages::execution::{
37        BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
38        GenerateFillReportsBuilder, GenerateOrderStatusReport, GenerateOrderStatusReports,
39        GeneratePositionStatusReports, GeneratePositionStatusReportsBuilder, ModifyOrder,
40        PARAMS_CLOSE_POSITION, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
41    },
42};
43use nautilus_core::{
44    AtomicSet, DurationNanos, Params, UUID4, UnixNanos,
45    datetime::{NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
46    string::secret::SecretString,
47    time::{AtomicTime, get_atomic_clock_realtime},
48};
49use nautilus_live::{
50    ExecutionClientCore, ExecutionEventEmitter, SocketControlFactory,
51    execution::failure::CommandFailure,
52    task::{TaskGroup, TaskGroupGuard, TaskJoinOutcome, TaskShutdownError, TaskSlot, finish_task},
53};
54use nautilus_model::{
55    accounts::AccountAny,
56    enums::{
57        AccountType, OmsType, OrderType, PositionSide, TimeInForce, TrailingOffsetType, TriggerType,
58    },
59    events::{
60        AccountState, OrderCancelRejected, OrderCanceled, OrderDeniedReason, OrderEventAny,
61        OrderModifyRejected, OrderRejected, OrderUpdated,
62    },
63    identifiers::{
64        AccountId, ClientId, ClientOrderId, InstrumentId, PositionId, Venue, VenueOrderId,
65    },
66    instruments::{Instrument, InstrumentAny},
67    orders::{Order, OrderAny},
68    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
69    types::{AccountBalance, Currency, MarginBalance, Money, Quantity},
70};
71use parking_lot::{Mutex, RwLock};
72use rust_decimal::Decimal;
73use tokio_util::sync::CancellationToken;
74
75use super::{
76    http::{
77        BinanceFuturesHttpError,
78        client::{
79            BinanceFuturesAlgoOrderQueryResult, BinanceFuturesHttpClient, BinanceFuturesInstrument,
80            is_algo_order_type,
81        },
82        models::{BatchOrderResult, BinanceFuturesAlgoOrder, BinancePositionRisk},
83        query::{
84            BatchCancelItem, BinanceAllOrdersParamsBuilder, BinanceOpenOrdersParamsBuilder,
85            BinanceOrderQueryParamsBuilder, BinancePositionRiskParamsBuilder,
86            BinanceSetLeverageParams, BinanceSetMarginTypeParams, BinanceUserTradesParamsBuilder,
87        },
88    },
89    websocket::{
90        streams::{
91            client::BinanceFuturesWebSocketClient,
92            dispatch::{
93                DispatchCtx, dispatch_user_stream_message, make_venue_position_id,
94                run_user_stream_dispatch, with_venue_position_id,
95            },
96            recovery::{
97                RecoveryCtx, WsBuildParams, build_and_connect_user_stream, run_recovery_driver,
98            },
99        },
100        trading::{client::BinanceFuturesWsTradingClient, dispatch::dispatch_ws_trading_message},
101    },
102};
103use crate::{
104    common::{
105        consts::{
106            BINANCE_FUTURES_DUAL_SIDE_SYNC_REJECT_CODE, BINANCE_FUTURES_USD_WS_API_TESTNET_URL,
107            BINANCE_FUTURES_USD_WS_API_URL, BINANCE_GTX_ORDER_REJECT_CODE,
108            BINANCE_NAUTILUS_FUTURES_BROKER_ID, BINANCE_VENUE, BINANCE_WS_HEARTBEAT_SECS,
109        },
110        credential::resolve_credentials,
111        dispatch::{OrderIdentity, PendingOperation, PendingRequest, WsDispatchState},
112        encoder::encode_broker_id,
113        enums::{
114            BinanceEnvironment, BinanceFuturesOrderType, BinancePositionSide, BinancePriceMatch,
115            BinanceProductType, BinanceSide, BinanceTimeInForce, BinanceWorkingType,
116        },
117        failure::{classify_futures_http_failure, sanitize_reason},
118        symbol::{format_binance_symbol, format_instrument_id},
119        urls::{get_usdm_ws_route_base_url, get_ws_private_base_url},
120    },
121    config::BinanceExecutionClientConfig,
122    futures::{
123        conversions::{
124            determine_position_side, normalize_futures_asset, reduce_only_param,
125            trailing_offset_to_callback_rate, trailing_offset_to_callback_rate_string,
126        },
127        http::{
128            client::order_type_to_binance_futures,
129            models::BinanceFuturesAccountInfo,
130            query::{
131                BatchOrderItem, BinanceCancelOrderParamsBuilder, BinanceModifyOrderParamsBuilder,
132                BinanceNewOrderParams,
133            },
134        },
135    },
136};
137
138/// Listen key keepalive interval (30 minutes).
139const LISTEN_KEY_KEEPALIVE_SECS: u64 = 30 * 60;
140
141/// Consecutive keepalive failures before a listenKey rotation is triggered.
142const MAX_KEEPALIVE_FAILURES: u32 = 1;
143
144const USER_TRADES_MAX_INTERVAL_MS: i64 = 7 * 24 * 60 * 60 * 1_000;
145
146const USER_TRADES_PAGE_LIMIT: u32 = 1_000;
147
148// Binance does not define whether its three-month retention is calendar-based or fixed-duration
149// An 88-day interval leaves at least one day inside either interpretation
150// https://developers.binance.com/en/docs/products/derivatives-trading-usds-futures/change-log
151const USER_TRADES_COMPLETE_INTERVAL: DurationNanos = DurationNanos::from_days(88);
152
153// Keep half the one-day retention safety margin when a command waits before execution
154const USER_TRADES_MAX_TS_INIT_AGE: DurationNanos = DurationNanos::from_hours(12);
155
156const BINANCE_GTD_MIN_LEAD_SECS: u64 = 600;
157
158const BINANCE_GTD_MAX_MILLIS: u64 = 253_402_300_799_000;
159
160/// Query parameter declaring that the command's venue order ID is a Binance Algo Service
161/// `algoId`, rather than a regular matching-engine `orderId` or triggered `actualOrderId`.
162pub const BINANCE_VENUE_ORDER_ID_IS_ALGO_ID_PARAM: &str = "venue_order_id_is_algo_id";
163
164/// Live execution client for Binance Futures trading.
165///
166/// Implements the [`ExecutionClient`] trait for order management on Binance
167/// USD-M and COIN-M Futures markets. Uses HTTP API for order operations and
168/// WebSocket for real-time order updates via user data stream.
169///
170/// Uses a two-tier architecture with an execution handler that maintains
171/// pending order maps for correlating WebSocket updates with order context.
172#[derive(Debug)]
173pub struct BinanceFuturesExecutionClient {
174    core: ExecutionClientCore,
175    clock: &'static AtomicTime,
176    config: BinanceExecutionClientConfig,
177    emitter: ExecutionEventEmitter,
178    dispatch_state: Arc<WsDispatchState>,
179    product_type: BinanceProductType,
180    http_client: BinanceFuturesHttpClient,
181    ws_client: Arc<Mutex<Option<BinanceFuturesWebSocketClient>>>,
182    socket_factory: SocketControlFactory,
183    ws_trading_client: Option<BinanceFuturesWsTradingClient>,
184    listen_key: Arc<RwLock<Option<SecretString>>>,
185    recovery_listen_key: Arc<RwLock<Option<SecretString>>>,
186    cancellation_token: CancellationToken,
187    triggered_algo_order_ids: Arc<AtomicSet<ClientOrderId>>,
188    ws_task: Arc<tokio::sync::Mutex<TaskSlot<()>>>,
189    recovery_lock: Arc<tokio::sync::Mutex<()>>,
190    recovery_tx: Option<tokio::sync::mpsc::UnboundedSender<()>>,
191    session_tasks: TaskGroup,
192    pending_tasks: TaskGroup,
193    is_hedge_mode: AtomicBool,
194    shutdown_errors: Vec<String>,
195}
196
197impl BinanceFuturesExecutionClient {
198    /// Creates a new [`BinanceFuturesExecutionClient`].
199    ///
200    /// # Errors
201    ///
202    /// Returns an error if the HTTP client fails to initialize, credentials are
203    /// missing, or the product type is not a futures type (UsdM or CoinM).
204    pub fn new(
205        core: ExecutionClientCore,
206        config: BinanceExecutionClientConfig,
207    ) -> anyhow::Result<Self> {
208        config.validate()?;
209        let product_type = config.product_type;
210        match product_type {
211            BinanceProductType::UsdM | BinanceProductType::CoinM => {}
212            _ => {
213                anyhow::bail!(
214                    "BinanceFuturesExecutionClient requires UsdM or CoinM product type, was {product_type:?}"
215                );
216            }
217        }
218
219        let (api_key, api_secret) = resolve_credentials(
220            config
221                .api_key
222                .as_ref()
223                .map(|value| value.expose_secret().to_owned()),
224            config
225                .api_secret
226                .as_ref()
227                .map(|value| value.expose_secret().to_owned()),
228            config.environment,
229            product_type,
230        )?;
231        let proxy_url = config
232            .proxy_url
233            .as_ref()
234            .map(|value| value.expose_secret().to_owned());
235
236        let clock = get_atomic_clock_realtime();
237        let socket_factory = SocketControlFactory::new(core.client_id, Some(*BINANCE_VENUE));
238
239        let http_client = BinanceFuturesHttpClient::new(
240            product_type,
241            config.environment,
242            clock,
243            Some(api_key.clone()),
244            Some(api_secret.clone()),
245            config.base_url_http.clone(),
246            Some(config.recv_window_ms),
247            None, // timeout_secs
248            proxy_url.clone(),
249            config.treat_expired_as_canceled,
250        )
251        .context("failed to construct Binance Futures HTTP client")?
252        .with_retry_config(config.retry_config());
253
254        let ws_trading_client = if config.use_ws_trading && product_type == BinanceProductType::UsdM
255        {
256            let ws_trading_url =
257                config
258                    .base_url_ws_trading
259                    .clone()
260                    .or_else(|| match config.environment {
261                        BinanceEnvironment::Testnet | BinanceEnvironment::Demo => {
262                            Some(BINANCE_FUTURES_USD_WS_API_TESTNET_URL.to_string())
263                        }
264                        _ => Some(BINANCE_FUTURES_USD_WS_API_URL.to_string()),
265                    });
266
267            Some(
268                BinanceFuturesWsTradingClient::new(
269                    ws_trading_url,
270                    api_key,
271                    api_secret,
272                    Some(BINANCE_WS_HEARTBEAT_SECS),
273                    config.transport_backend,
274                )
275                .with_proxy(proxy_url)
276                .with_recv_window(Some(config.recv_window_ms))
277                .with_socket_control(socket_factory.control("binance-futures-trading")),
278            )
279        } else {
280            None
281        };
282
283        let emitter = ExecutionEventEmitter::new(
284            clock,
285            core.trader_id,
286            core.account_id,
287            core.account_type,
288            core.base_currency,
289        );
290
291        let session_tasks = TaskGroup::new();
292        let pending_tasks = TaskGroup::new();
293
294        Ok(Self {
295            core,
296            clock,
297            config,
298            emitter,
299            dispatch_state: Arc::new(WsDispatchState::default()),
300            product_type,
301            http_client,
302            ws_client: Arc::new(Mutex::new(None)),
303            socket_factory,
304            ws_trading_client,
305            listen_key: Arc::new(RwLock::new(None)),
306            recovery_listen_key: Arc::new(RwLock::new(None)),
307            cancellation_token: CancellationToken::new(),
308            triggered_algo_order_ids: Arc::new(AtomicSet::new()),
309            ws_task: Arc::new(tokio::sync::Mutex::new(TaskSlot::new())),
310            recovery_lock: Arc::new(tokio::sync::Mutex::new(())),
311            recovery_tx: None,
312            session_tasks,
313            pending_tasks,
314            is_hedge_mode: AtomicBool::new(false),
315            shutdown_errors: Vec::new(),
316        })
317    }
318
319    /// Returns whether the account is in hedge mode (dual side position).
320    #[must_use]
321    pub fn is_hedge_mode(&self) -> bool {
322        self.is_hedge_mode.load(Ordering::Acquire)
323    }
324
325    fn resolve_algo_lookup(
326        &self,
327        client_order_id: Option<ClientOrderId>,
328        params: Option<&Params>,
329    ) -> BinanceFuturesAlgoLookup {
330        if params.and_then(|p| p.get_bool(BINANCE_VENUE_ORDER_ID_IS_ALGO_ID_PARAM)) == Some(true) {
331            return BinanceFuturesAlgoLookup::AlgoId;
332        }
333
334        let Some(client_order_id) = client_order_id else {
335            return BinanceFuturesAlgoLookup::ClientAlgoId;
336        };
337        let cache = self.core.cache();
338        match cache.order(&client_order_id) {
339            Some(order) if !is_algo_order_type(order.order_type()) => {
340                BinanceFuturesAlgoLookup::Skip
341            }
342            _ => BinanceFuturesAlgoLookup::ClientAlgoId,
343        }
344    }
345
346    /// Returns a clone of the HTTP client's instruments cache Arc.
347    #[doc(hidden)]
348    #[must_use]
349    pub fn instruments_cache(&self) -> Arc<DashMap<ustr::Ustr, BinanceFuturesInstrument>> {
350        self.http_client.instruments_cache()
351    }
352
353    /// Converts Binance futures account info to Nautilus account state.
354    fn create_account_state(&self, account_info: &BinanceFuturesAccountInfo) -> AccountState {
355        Self::create_account_state_from(
356            account_info,
357            self.core.account_id,
358            self.core.account_type,
359            self.config.bnfcr_currency,
360            self.clock,
361        )
362    }
363
364    fn create_account_state_from(
365        account_info: &BinanceFuturesAccountInfo,
366        account_id: AccountId,
367        account_type: AccountType,
368        bnfcr_currency: Currency,
369        clock: &'static AtomicTime,
370    ) -> AccountState {
371        let ts_now = clock.get_time_ns();
372
373        let balances: Vec<AccountBalance> = account_info
374            .assets
375            .iter()
376            .filter_map(|b| {
377                if b.wallet_balance.is_zero() {
378                    return None;
379                }
380
381                let currency = normalize_futures_asset(b.asset, bnfcr_currency);
382                AccountBalance::from_total_and_free(b.wallet_balance, b.available_balance, currency)
383                    .ok()
384            })
385            .collect();
386
387        // Emit account-wide (cross-margin) margin balances per collateral asset.
388        // Binance reports per-asset `initialMargin` / `maintMargin` which covers both
389        // USDT-M (typically USDT, or USDT+BNB under multi-assets mode) and COIN-M
390        // (one entry per base coin, e.g. BTC / ETH).
391        let mut margins: Vec<MarginBalance> = Vec::new();
392
393        for asset in &account_info.assets {
394            let initial_dec = asset.initial_margin.unwrap_or_default();
395            let maint_dec = asset.maint_margin.unwrap_or_default();
396
397            if initial_dec.is_zero() && maint_dec.is_zero() {
398                continue;
399            }
400
401            let currency = normalize_futures_asset(asset.asset, bnfcr_currency);
402            let initial = Money::from_decimal(initial_dec, currency)
403                .unwrap_or_else(|_| Money::zero(currency));
404            let maintenance =
405                Money::from_decimal(maint_dec, currency).unwrap_or_else(|_| Money::zero(currency));
406            margins.push(MarginBalance::new(initial, maintenance, None));
407        }
408
409        let mut info = Params::new();
410        let mut push_decimal = |key: &str, val: Option<Decimal>| {
411            if let Some(decimal) = val {
412                info.insert(
413                    key.to_string(),
414                    serde_json::Value::from(decimal.to_string()),
415                );
416            }
417        };
418        push_decimal("total_wallet_balance", account_info.total_wallet_balance);
419        push_decimal("total_margin_balance", account_info.total_margin_balance);
420        push_decimal("total_initial_margin", account_info.total_initial_margin);
421        push_decimal("total_maint_margin", account_info.total_maint_margin);
422        push_decimal(
423            "total_unrealized_profit",
424            account_info.total_unrealized_profit,
425        );
426        push_decimal(
427            "total_cross_wallet_balance",
428            account_info.total_cross_wallet_balance,
429        );
430        push_decimal("total_cross_unpnl", account_info.total_cross_un_pnl);
431        push_decimal("available_balance", account_info.available_balance);
432        push_decimal("max_withdraw_amount", account_info.max_withdraw_amount);
433        let info = if info.is_empty() { None } else { Some(info) };
434
435        AccountState::new(
436            account_id,
437            account_type,
438            balances,
439            margins,
440            true, // reported
441            UUID4::new(),
442            ts_now,
443            ts_now,
444            None, // base currency
445        )
446        .with_info(info)
447    }
448
449    async fn refresh_account_state(&self) -> anyhow::Result<AccountState> {
450        let account_info = match self.http_client.query_account().await {
451            Ok(info) => info,
452            Err(e) => {
453                log::error!("Binance Futures account state request failed: {e}");
454                anyhow::bail!("Binance Futures account state request failed: {e}");
455            }
456        };
457
458        Ok(self.create_account_state(&account_info))
459    }
460
461    fn update_account_state(&self) {
462        let http_client = self.http_client.clone();
463        let account_id = self.core.account_id;
464        let account_type = self.core.account_type;
465        let bnfcr_currency = self.config.bnfcr_currency;
466        let emitter = self.emitter.clone();
467        let clock = self.clock;
468
469        self.spawn_task("query_account", async move {
470            let account_info = http_client
471                .query_account()
472                .await
473                .context("Binance Futures account state request failed")?;
474            let account_state = Self::create_account_state_from(
475                &account_info,
476                account_id,
477                account_type,
478                bnfcr_currency,
479                clock,
480            );
481            let ts_now = clock.get_time_ns();
482            emitter.emit_account_state(
483                account_state.balances.clone(),
484                account_state.margins.clone(),
485                account_state.is_reported,
486                ts_now,
487                account_state.info,
488            );
489            Ok(())
490        });
491    }
492
493    async fn init_hedge_mode(&self) -> anyhow::Result<bool> {
494        let response = self.http_client.query_hedge_mode().await?;
495        Ok(response.dual_side_position)
496    }
497
498    /// Returns whether the WS trading client is connected and active.
499    fn ws_trading_active(&self) -> bool {
500        self.ws_trading_client
501            .as_ref()
502            .is_some_and(|c| c.is_active())
503    }
504
505    fn submit_order_internal(
506        &self,
507        cmd: &SubmitOrder,
508        lifetime: FuturesOrderLifetime,
509        position_side: Option<BinancePositionSide>,
510        venue_position_id: Option<PositionId>,
511    ) -> anyhow::Result<()> {
512        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
513
514        let emitter = self.emitter.clone();
515        let trader_id = self.core.trader_id;
516        let account_id = self.core.account_id;
517        let clock = self.clock;
518        let client_order_id = order.client_order_id();
519        let strategy_id = order.strategy_id();
520        let instrument_id = order.instrument_id();
521        let order_side = order.order_side();
522        let order_type = order.order_type();
523        let quantity = order.quantity();
524        let time_in_force = lifetime.time_in_force;
525        let good_till_date = lifetime.good_till_date;
526        let price = order.price();
527        let trigger_price = order.trigger_price();
528        let reduce_only = order.is_reduce_only();
529        let post_only = order.is_post_only();
530        let activation_price = order.activation_price();
531        let trailing_offset = order.trailing_offset();
532        let trigger_type = order.trigger_type();
533
534        let close_position = cmd
535            .params
536            .as_ref()
537            .and_then(|p| p.get_bool(PARAMS_CLOSE_POSITION))
538            .unwrap_or(false);
539        let rpi = cmd
540            .params
541            .as_ref()
542            .and_then(|p| p.get_bool("rpi"))
543            .unwrap_or(false);
544
545        let use_algo_api = is_algo_order_type(order_type);
546        let use_ws = self.ws_trading_active() && !use_algo_api;
547        if !use_ws
548            && let Err(e) = self
549                .http_client
550                .instrument_metadata(instrument_id)
551                .and_then(|metadata| Ok(metadata.precisions()?))
552        {
553            let reason = OrderDeniedReason::ValidationFailed {
554                detail: e.to_string(),
555            };
556            self.emitter.emit_order_denied(&order, &reason.to_string());
557            return Ok(());
558        }
559
560        // Register identity for tracked/external dispatch routing
561        self.dispatch_state.order_identities.insert(
562            client_order_id,
563            OrderIdentity {
564                instrument_id,
565                strategy_id,
566                order_side,
567                order_type,
568                price,
569                quantity,
570                venue_position_id,
571            },
572        );
573
574        let price_match = cmd
575            .params
576            .as_ref()
577            .and_then(|p| p.get_str("price_match"))
578            .map(BinancePriceMatch::from_param)
579            .transpose()?;
580
581        let callback_rate = trailing_offset
582            .map(trailing_offset_to_callback_rate_string)
583            .transpose()?;
584
585        let working_type = match trigger_type {
586            Some(TriggerType::MarkPrice) => Some(BinanceWorkingType::MarkPrice),
587            Some(TriggerType::LastPrice | TriggerType::Default) => {
588                Some(BinanceWorkingType::ContractPrice)
589            }
590            _ => None,
591        };
592
593        // Non-algo orders can route through WS trading API when active
594        if use_ws {
595            let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
596            let dispatch_state = self.dispatch_state.clone();
597
598            let symbol = format_binance_symbol(&instrument_id);
599            let binance_side = BinanceSide::try_from(order_side)?;
600            let binance_order_type = order_type_to_binance_futures(order_type)?;
601            let binance_tif = if rpi {
602                BinanceTimeInForce::Rpi
603            } else if post_only {
604                BinanceTimeInForce::Gtx
605            } else {
606                BinanceTimeInForce::try_from(time_in_force)?
607            };
608
609            let requires_time_in_force = matches!(
610                order_type,
611                OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
612            );
613
614            let client_id_str =
615                encode_broker_id(&client_order_id, BINANCE_NAUTILUS_FUTURES_BROKER_ID);
616
617            let params = BinanceNewOrderParams {
618                symbol,
619                side: binance_side,
620                order_type: binance_order_type,
621                time_in_force: if requires_time_in_force {
622                    Some(binance_tif)
623                } else {
624                    None
625                },
626                quantity: Some(quantity.to_string()),
627                price: if price_match.is_some() {
628                    None
629                } else {
630                    price.map(|p| p.to_string())
631                },
632                new_client_order_id: Some(client_id_str),
633                stop_price: trigger_price.map(|p| p.to_string()),
634                reduce_only: reduce_only_param(reduce_only, position_side),
635                position_side,
636                close_position: None,
637                activation_price: activation_price.map(|p| p.to_string()),
638                callback_rate,
639                working_type,
640                price_protect: None,
641                new_order_resp_type: None,
642                good_till_date,
643                recv_window: None,
644                price_match,
645                self_trade_prevention_mode: None,
646            };
647
648            self.emitter.emit_order_submitted(&order);
649
650            // Pre-register before sending to avoid response racing the insert
651            let request_id = ws_client.next_request_id();
652            dispatch_state.pending_requests.insert(
653                request_id.clone(),
654                PendingRequest {
655                    client_order_id,
656                    venue_order_id: None,
657                    operation: PendingOperation::Place,
658                },
659            );
660
661            self.spawn_task("submit_order_ws", async move {
662                if let Err(e) = ws_client
663                    .place_order_with_id(request_id.clone(), params)
664                    .await
665                {
666                    dispatch_state.pending_requests.remove(&request_id);
667                    log::error!("WS submit request failed for {client_order_id}: {e}");
668                    anyhow::bail!("WS submit order failed: {e}");
669                }
670                Ok(())
671            });
672
673            return Ok(());
674        }
675
676        let http_client = self.http_client.clone();
677        let dispatch_state = self.dispatch_state.clone();
678
679        self.emitter.emit_order_submitted(&order);
680
681        self.spawn_task("submit_order", async move {
682            let result = if use_algo_api {
683                http_client
684                    .submit_algo_order(
685                        account_id,
686                        instrument_id,
687                        client_order_id,
688                        order_side,
689                        order_type,
690                        quantity,
691                        time_in_force,
692                        price,
693                        trigger_price,
694                        reduce_only,
695                        close_position,
696                        position_side,
697                        activation_price,
698                        callback_rate,
699                        working_type,
700                        good_till_date,
701                    )
702                    .await
703            } else {
704                http_client
705                    .submit_order(
706                        account_id,
707                        instrument_id,
708                        client_order_id,
709                        order_side,
710                        order_type,
711                        quantity,
712                        time_in_force,
713                        price,
714                        trigger_price,
715                        reduce_only,
716                        post_only,
717                        rpi,
718                        position_side,
719                        price_match,
720                        good_till_date,
721                    )
722                    .await
723            };
724
725            match result {
726                Ok(report) => {
727                    log::debug!(
728                        "Order submit accepted: client_order_id={}, venue_order_id={}",
729                        client_order_id,
730                        report.venue_order_id
731                    );
732                }
733                Err(e) => {
734                    // Keep order registered on ambiguous evidence - if HTTP failed due to
735                    // timeout but the order reached Binance, WebSocket updates will still
736                    // arrive. The order resolves via the stream or reconciliation.
737                    let http_error = e.downcast_ref::<BinanceFuturesHttpError>();
738                    let failure = http_error.map_or_else(
739                        || CommandFailure::Ambiguous(e.to_string()),
740                        classify_futures_http_failure,
741                    );
742
743                    match failure {
744                        CommandFailure::Ambiguous(reason) => {
745                            log::warn!(
746                                "Ambiguous submit failure for {client_order_id}, awaiting reconciliation: {reason}"
747                            );
748                        }
749                        CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
750                            let due_post_only = classify_submit_order_error(&e);
751                            let ts_now = clock.get_time_ns();
752
753                            let rejected = OrderRejected::new(
754                                trader_id,
755                                strategy_id,
756                                instrument_id,
757                                client_order_id,
758                                account_id,
759                                format!("submit-order-error: {}", sanitize_reason(&reason)).into(),
760                                UUID4::new(),
761                                ts_now,
762                                ts_now,
763                                false,
764                                due_post_only,
765                            );
766
767                            dispatch_state.cleanup_terminal(client_order_id);
768                            emitter.send_order_event(OrderEventAny::Rejected(rejected));
769                        }
770                    }
771
772                    return Err(e);
773                }
774            }
775
776            Ok(())
777        });
778
779        Ok(())
780    }
781
782    fn cancel_order_internal(&self, cmd: &CancelOrder) {
783        let command = cmd.clone();
784
785        // Non-triggered algo orders use algo cancel endpoint, triggered use regular
786        let is_algo = self
787            .core
788            .cache()
789            .order(&command.client_order_id)
790            .is_some_and(|order| is_algo_order_type(order.order_type()));
791        let promoted_venue_order_id = self
792            .dispatch_state
793            .promoted_algo_order_id(&command.client_order_id);
794        let use_algo_cancel = should_use_algo_cancel(
795            is_algo,
796            self.triggered_algo_order_ids
797                .contains(&command.client_order_id),
798            promoted_venue_order_id.is_some(),
799        );
800
801        let emitter = self.emitter.clone();
802        let trader_id = self.core.trader_id;
803        let account_id = self.core.account_id;
804        let clock = self.clock;
805        let instrument_id = command.instrument_id;
806        let venue_order_id =
807            cancel_venue_order_id(is_algo, command.venue_order_id, promoted_venue_order_id);
808        let client_order_id = command.client_order_id;
809
810        // Non-algo cancels can route through WS trading API when active
811        if self.ws_trading_active() && !use_algo_cancel {
812            let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
813            let dispatch_state = self.dispatch_state.clone();
814
815            let mut cancel_builder = BinanceCancelOrderParamsBuilder::default();
816            cancel_builder.symbol(format_binance_symbol(&instrument_id));
817
818            if let Some(venue_id) = venue_order_id {
819                match venue_id.inner().parse::<i64>() {
820                    Ok(order_id) => {
821                        cancel_builder.order_id(order_id);
822                    }
823                    Err(e) => {
824                        log::warn!(
825                            "Unable to parse venue_order_id {venue_id} for cancel {client_order_id}, canceling by client_order_id: {e}"
826                        );
827                    }
828                }
829            }
830
831            cancel_builder.orig_client_order_id(encode_broker_id(
832                &client_order_id,
833                BINANCE_NAUTILUS_FUTURES_BROKER_ID,
834            ));
835
836            let params = cancel_builder.build().unwrap();
837
838            // Pre-register before sending to avoid response racing the insert
839            let request_id = ws_client.next_request_id();
840            dispatch_state.pending_requests.insert(
841                request_id.clone(),
842                PendingRequest {
843                    client_order_id,
844                    venue_order_id,
845                    operation: PendingOperation::Cancel,
846                },
847            );
848
849            self.spawn_task("cancel_order_ws", async move {
850                if let Err(e) = ws_client
851                    .cancel_order_with_id(request_id.clone(), params)
852                    .await
853                {
854                    dispatch_state.pending_requests.remove(&request_id);
855                    log::error!("WS cancel request failed for {client_order_id}: {e}");
856                    anyhow::bail!("WS cancel order failed: {e}");
857                }
858                Ok(())
859            });
860
861            return;
862        }
863
864        let http_client = self.http_client.clone();
865
866        self.spawn_task("cancel_order", async move {
867            let result = if use_algo_cancel {
868                // Try algo cancel first; if it fails, the order may have been triggered
869                // before this session started, so fall back to regular cancel
870                match http_client.cancel_algo_order(client_order_id).await {
871                    Ok(()) => Ok(()),
872                    Err(algo_err) => {
873                        log::debug!("Algo cancel failed, trying regular cancel: {algo_err}");
874                        http_client
875                            .cancel_order(instrument_id, venue_order_id, Some(client_order_id))
876                            .await
877                            .map(|_| ())
878                    }
879                }
880            } else {
881                http_client
882                    .cancel_order(instrument_id, venue_order_id, Some(client_order_id))
883                    .await
884                    .map(|_| ())
885            };
886
887            match result {
888                Ok(()) => {
889                    log::debug!("Cancel request accepted: client_order_id={client_order_id}");
890                }
891                Err(e) => {
892                    let failure = e.downcast_ref::<BinanceFuturesHttpError>().map_or_else(
893                        || CommandFailure::Ambiguous(e.to_string()),
894                        classify_futures_http_failure,
895                    );
896
897                    match failure {
898                        CommandFailure::Ambiguous(reason) => {
899                            log::warn!(
900                                "Ambiguous cancel failure for {client_order_id}, awaiting reconciliation: {reason}"
901                            );
902                        }
903                        CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
904                            let ts_now = clock.get_time_ns();
905
906                            let rejected = OrderCancelRejected::new(
907                                trader_id,
908                                command.strategy_id,
909                                command.instrument_id,
910                                client_order_id,
911                                format!("cancel-order-error: {}", sanitize_reason(&reason)).into(),
912                                UUID4::new(),
913                                ts_now,
914                                ts_now,
915                                false,
916                                command.venue_order_id,
917                                Some(account_id),
918                            );
919
920                            emitter.send_order_event(OrderEventAny::CancelRejected(rejected));
921                        }
922                    }
923
924                    return Err(e);
925                }
926            }
927
928            Ok(())
929        });
930    }
931
932    fn cancel_all_orders_for_side(&self, cmd: &CancelAllOrders) -> anyhow::Result<()> {
933        // Cancel-all has no side parameter, and batch covers regular orders only
934        let (regular_cancels, algo_cancels): (Vec<CancelOrder>, Vec<CancelOrder>) = {
935            let cache = self.core.cache();
936            let mut regular = Vec::new();
937            let mut algo = Vec::new();
938
939            for order in
940                cache.orders_open(None, Some(&cmd.instrument_id), None, None, cmd.order_side)
941            {
942                let cancel = CancelOrder {
943                    trader_id: order.trader_id(),
944                    client_id: cmd.client_id,
945                    strategy_id: order.strategy_id(),
946                    instrument_id: order.instrument_id(),
947                    client_order_id: order.client_order_id(),
948                    venue_order_id: order.venue_order_id(),
949                    command_id: cmd.command_id,
950                    ts_init: cmd.ts_init,
951                    params: cmd.params.clone(),
952                    correlation_id: cmd.correlation_id,
953                    causation_id: cmd.causation_id,
954                };
955
956                if is_algo_order_type(order.order_type()) {
957                    algo.push(cancel);
958                } else {
959                    regular.push(cancel);
960                }
961            }
962
963            (regular, algo)
964        };
965
966        if regular_cancels.is_empty() && algo_cancels.is_empty() {
967            log::debug!("No open orders to cancel for {}", cmd.instrument_id);
968            return Ok(());
969        }
970
971        if !regular_cancels.is_empty() {
972            self.batch_cancel_orders(BatchCancelOrders {
973                trader_id: cmd.trader_id,
974                client_id: cmd.client_id,
975                strategy_id: cmd.strategy_id,
976                instrument_id: cmd.instrument_id,
977                cancels: regular_cancels,
978                command_id: cmd.command_id,
979                ts_init: cmd.ts_init,
980                params: cmd.params.clone(),
981                correlation_id: cmd.correlation_id,
982                causation_id: cmd.causation_id,
983            })?;
984        }
985
986        for cancel in &algo_cancels {
987            self.cancel_order_internal(cancel);
988        }
989
990        Ok(())
991    }
992
993    fn spawn_task<F>(&self, description: &'static str, fut: F)
994    where
995        F: Future<Output = anyhow::Result<()>> + Send + 'static,
996    {
997        crate::common::execution::spawn_task(&self.pending_tasks, description, fut);
998    }
999
1000    fn begin_generation_shutdown(&mut self) {
1001        self.cancellation_token.cancel();
1002        self.abort_session_tasks();
1003
1004        if let Some(client) = self.ws_client.lock().as_ref() {
1005            client.begin_shutdown();
1006        }
1007
1008        if let Some(client) = self.ws_trading_client.as_ref() {
1009            client.begin_shutdown();
1010        }
1011
1012        if let Ok(mut task_slot) = self.ws_task.try_lock() {
1013            task_slot.abort();
1014        }
1015
1016        self.recovery_tx.take();
1017
1018        self.abort_pending_tasks();
1019        self.core.set_disconnected();
1020    }
1021
1022    fn abort_pending_tasks(&self) {
1023        crate::common::execution::abort_pending_tasks(&self.pending_tasks);
1024    }
1025
1026    fn abort_session_tasks(&self) {
1027        self.session_tasks.begin_shutdown();
1028    }
1029
1030    async fn await_pending_tasks(&self) -> anyhow::Result<()> {
1031        crate::common::execution::await_pending_tasks(&self.pending_tasks).await
1032    }
1033
1034    async fn await_session_tasks(&self) -> anyhow::Result<()> {
1035        self.finish_session_tasks().await.map_err(|e| {
1036            anyhow::anyhow!("Failed to terminate Binance Futures session tasks: {e}")
1037        })?;
1038        Ok(())
1039    }
1040
1041    async fn finish_session_tasks(&self) -> Result<(), TaskShutdownError> {
1042        self.session_tasks.begin_shutdown();
1043        self.session_tasks
1044            .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
1045            .await?;
1046        Ok(())
1047    }
1048
1049    async fn await_dispatch_task(&self) -> anyhow::Result<()> {
1050        let _recovery_guard = self.recovery_lock.lock().await;
1051        let mut task_slot = self.ws_task.lock().await;
1052        let Some(outcome) = finish_task(
1053            &mut task_slot,
1054            Duration::from_secs(1),
1055            Duration::from_secs(2),
1056        )
1057        .await
1058        else {
1059            return Ok(());
1060        };
1061
1062        match outcome {
1063            TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => Ok(()),
1064            TaskJoinOutcome::Failed(e) => {
1065                Err(anyhow::anyhow!("Binance Futures dispatch task failed: {e}"))
1066            }
1067            TaskJoinOutcome::Incomplete => Err(anyhow::anyhow!(
1068                "Binance Futures dispatch task did not stop after abort"
1069            )),
1070        }
1071    }
1072
1073    async fn close_listen_key_slot(
1074        &self,
1075        slot: &RwLock<Option<SecretString>>,
1076        context: &str,
1077    ) -> anyhow::Result<()> {
1078        let key = slot.read().clone();
1079        let Some(key) = key else {
1080            return Ok(());
1081        };
1082
1083        self.http_client
1084            .close_listen_key(key.expose_secret())
1085            .await
1086            .with_context(|| context.to_string())?;
1087        let mut owned = slot.write();
1088        if owned.as_ref().map(SecretString::expose_secret) == Some(key.expose_secret()) {
1089            *owned = None;
1090        }
1091        Ok(())
1092    }
1093
1094    /// Returns the (price_precision, size_precision) for an instrument.
1095    fn get_instrument_precision(&self, instrument_id: InstrumentId) -> anyhow::Result<(u8, u8)> {
1096        self.http_client
1097            .instrument_reconciliation(&instrument_id)
1098            .map(|instrument| (instrument.price_precision(), instrument.size_precision()))
1099            .ok_or_else(|| {
1100                anyhow::anyhow!(
1101                    "Binance Futures instrument {instrument_id} is not loaded for reconciliation"
1102                )
1103            })
1104    }
1105
1106    fn is_instrument_out_of_scope(&self, instrument_id: InstrumentId) -> bool {
1107        self.config.instrument_provider.excludes(instrument_id)
1108    }
1109
1110    /// Creates a position status report from Binance position risk data.
1111    fn create_position_report(
1112        &self,
1113        position: &BinancePositionRisk,
1114        instrument_id: InstrumentId,
1115        size_precision: u8,
1116    ) -> anyhow::Result<PositionStatusReport> {
1117        let position_amount: Decimal = position
1118            .position_amt
1119            .parse()
1120            .context("invalid position_amt")?;
1121
1122        if position_amount.is_zero() {
1123            anyhow::bail!("Position is flat");
1124        }
1125
1126        let entry_price: Decimal = position
1127            .entry_price
1128            .parse()
1129            .context("invalid entry_price")?;
1130
1131        let position_side = if position_amount > Decimal::ZERO {
1132            PositionSide::Long
1133        } else {
1134            PositionSide::Short
1135        };
1136
1137        if self.config.use_position_ids {
1138            match position.position_side {
1139                Some(BinancePositionSide::Long) => anyhow::ensure!(
1140                    position_side == PositionSide::Long,
1141                    "position_side LONG conflicts with negative position_amt"
1142                ),
1143                Some(BinancePositionSide::Short) => anyhow::ensure!(
1144                    position_side == PositionSide::Short,
1145                    "position_side SHORT conflicts with positive position_amt"
1146                ),
1147                _ => {}
1148            }
1149        }
1150
1151        let venue_position_id = make_venue_position_id(
1152            self.config.use_position_ids,
1153            instrument_id,
1154            position.position_side,
1155        )?;
1156
1157        if let Some(venue_position_id) = venue_position_id {
1158            self.ensure_cached_position_id_compatible(
1159                instrument_id,
1160                position_side,
1161                venue_position_id,
1162            )?;
1163        }
1164
1165        let ts_now = self.clock.get_time_ns();
1166
1167        Ok(PositionStatusReport::new(
1168            self.core.account_id,
1169            instrument_id,
1170            position_side,
1171            Quantity::from_decimal_dp(position_amount.abs(), size_precision)?,
1172            ts_now,
1173            ts_now,
1174            Some(UUID4::new()),
1175            venue_position_id,
1176            Some(entry_price),
1177        ))
1178    }
1179
1180    fn ensure_cached_position_id_compatible(
1181        &self,
1182        instrument_id: InstrumentId,
1183        position_side: PositionSide,
1184        venue_position_id: PositionId,
1185    ) -> anyhow::Result<()> {
1186        let cache = self.core.cache();
1187        let mut incompatible_ids: Vec<_> = cache
1188            .positions_open(
1189                Some(&BINANCE_VENUE),
1190                Some(&instrument_id),
1191                None,
1192                Some(&self.core.account_id),
1193                Some(position_side),
1194            )
1195            .into_iter()
1196            .filter(|position| position.id != venue_position_id)
1197            .map(|position| position.id.to_string())
1198            .collect();
1199        incompatible_ids.sort_unstable();
1200
1201        anyhow::ensure!(
1202            incompatible_ids.is_empty(),
1203            "incompatible cached {position_side:?} position IDs for {instrument_id}: {}; expected {venue_position_id}",
1204            incompatible_ids.join(", "),
1205        );
1206        Ok(())
1207    }
1208
1209    async fn generate_open_order_status_reports(
1210        &self,
1211        instrument_id: Option<InstrumentId>,
1212        ts_init: UnixNanos,
1213    ) -> anyhow::Result<Vec<OpenOrderStatusReport>> {
1214        if let Some(instrument_id) = instrument_id
1215            && self
1216                .http_client
1217                .instrument_reconciliation(&instrument_id)
1218                .is_none()
1219        {
1220            if self.is_instrument_out_of_scope(instrument_id) {
1221                log::debug!(
1222                    "Dropping out-of-scope Binance Futures order request for instrument {instrument_id}"
1223                );
1224                return Ok(Vec::new());
1225            }
1226
1227            anyhow::bail!(
1228                "Binance Futures open order request has unresolved instrument {instrument_id}"
1229            );
1230        }
1231
1232        let symbol = instrument_id.map(|id| format_binance_symbol(&id));
1233        let mut builder = BinanceOpenOrdersParamsBuilder::default();
1234
1235        if let Some(symbol) = symbol {
1236            builder.symbol(symbol);
1237        }
1238        let params = builder.build().map_err(|e| anyhow::anyhow!("{e}"))?;
1239
1240        let (orders, algo_orders) = tokio::try_join!(
1241            self.http_client.query_open_orders(&params),
1242            self.http_client.query_open_algo_orders(instrument_id),
1243        )?;
1244        let mut reports = Vec::with_capacity(orders.len() + algo_orders.len());
1245
1246        for order in orders {
1247            let instrument_id = instrument_id
1248                .unwrap_or_else(|| format_instrument_id(&order.symbol, self.product_type));
1249            let Some(instrument) = self.http_client.instrument_reconciliation(&instrument_id)
1250            else {
1251                if self.is_instrument_out_of_scope(instrument_id) {
1252                    log::debug!(
1253                        "Dropping out-of-scope Binance Futures open order for instrument {instrument_id}"
1254                    );
1255                    continue;
1256                }
1257                anyhow::bail!(
1258                    "Binance Futures open order has unresolved instrument {instrument_id}"
1259                );
1260            };
1261
1262            let report = order.to_order_status_report(
1263                self.core.account_id,
1264                instrument.id(),
1265                instrument.price_precision(),
1266                instrument.size_precision(),
1267                self.config.treat_expired_as_canceled,
1268                ts_init,
1269            )?;
1270            let venue_position_id = make_venue_position_id(
1271                self.config.use_position_ids,
1272                instrument.id(),
1273                order.position_side,
1274            )?;
1275            reports.push(OpenOrderStatusReport {
1276                report: with_venue_position_id(report, venue_position_id),
1277                quantity_free_close_position_side: None,
1278            });
1279        }
1280
1281        for algo_order in algo_orders {
1282            let instrument_id = instrument_id
1283                .unwrap_or_else(|| format_instrument_id(&algo_order.symbol, self.product_type));
1284            let Some(instrument) = self.http_client.instrument_reconciliation(&instrument_id)
1285            else {
1286                if self.is_instrument_out_of_scope(instrument_id) {
1287                    log::debug!(
1288                        "Dropping out-of-scope Binance Futures open algo order for instrument {instrument_id}"
1289                    );
1290                    continue;
1291                }
1292                anyhow::bail!(
1293                    "Binance Futures open algo order has unresolved instrument {instrument_id}"
1294                );
1295            };
1296
1297            let report = algo_order.to_order_status_report(
1298                self.core.account_id,
1299                instrument.id(),
1300                instrument.price_precision(),
1301                instrument.size_precision(),
1302                ts_init,
1303            )?;
1304            let venue_position_id = make_venue_position_id(
1305                self.config.use_position_ids,
1306                instrument.id(),
1307                algo_order.position_side,
1308            )?;
1309            reports.push(OpenOrderStatusReport {
1310                report: with_venue_position_id(report, venue_position_id),
1311                quantity_free_close_position_side: quantity_free_close_position_side(&algo_order),
1312            });
1313        }
1314
1315        Ok(reports)
1316    }
1317
1318    async fn apply_futures_config(&self) -> anyhow::Result<()> {
1319        if let Some(ref leverages) = self.config.futures_leverages {
1320            for (symbol, leverage) in leverages {
1321                let params = BinanceSetLeverageParams {
1322                    symbol: symbol.clone(),
1323                    leverage: *leverage,
1324                    recv_window: None,
1325                };
1326                // Best-effort: a venue reject is non-fatal, but transport errors
1327                // propagate so an unhealthy connection still surfaces.
1328                match self.http_client.set_leverage(&params).await {
1329                    Ok(response) => {
1330                        log::info!("Set leverage {} {}X", response.symbol, response.leverage);
1331                    }
1332                    Err(BinanceFuturesHttpError::BinanceError { code, message, .. }) => {
1333                        log::warn!(
1334                            "Unable to set leverage for {symbol} to {leverage}x: [{code}] {message}; skipping (leverage init is best-effort)"
1335                        );
1336                    }
1337                    Err(e) => {
1338                        return Err(e).context(format!("failed to set leverage for {symbol}"));
1339                    }
1340                }
1341            }
1342        }
1343
1344        if let Some(ref margin_types) = self.config.futures_margin_types {
1345            for (symbol, margin_type) in margin_types {
1346                let params = BinanceSetMarginTypeParams {
1347                    symbol: symbol.clone(),
1348                    margin_type: *margin_type,
1349                    recv_window: None,
1350                };
1351
1352                match self.http_client.set_margin_type(&params).await {
1353                    Ok(_) => {
1354                        log::info!("Set {symbol} margin type to {margin_type:?}");
1355                    }
1356                    Err(BinanceFuturesHttpError::BinanceError { code: -4046, .. }) => {
1357                        log::debug!("{symbol} margin type already {margin_type:?}");
1358                    }
1359                    Err(e) => {
1360                        return Err(e).context(format!("failed to set margin type for {symbol}"));
1361                    }
1362                }
1363            }
1364        }
1365
1366        Ok(())
1367    }
1368}
1369
1370fn quantity_free_close_position_side(order: &BinanceFuturesAlgoOrder) -> Option<PositionSide> {
1371    let quantity_free = match order.quantity.as_deref() {
1372        None => true,
1373        Some(quantity) => quantity
1374            .parse::<Decimal>()
1375            .is_ok_and(|quantity| quantity.is_zero()),
1376    };
1377
1378    if order.close_position != Some(true) || !quantity_free {
1379        return None;
1380    }
1381
1382    match (order.side, order.position_side) {
1383        (BinanceSide::Sell, Some(BinancePositionSide::Long | BinancePositionSide::Both) | None) => {
1384            Some(PositionSide::Long)
1385        }
1386        (BinanceSide::Buy, Some(BinancePositionSide::Short | BinancePositionSide::Both) | None) => {
1387            Some(PositionSide::Short)
1388        }
1389        _ => None,
1390    }
1391}
1392
1393fn restore_close_position_quantities(
1394    order_reports: &mut [OpenOrderStatusReport],
1395    position_reports: &[PositionStatusReport],
1396) {
1397    // Binance accepts close-all orders without quantity and reports the omitted value as either
1398    // absent or zero. Core reconciliation needs a positive quantity to materialize the order, so
1399    // the current position leg is the narrowest valid source.
1400    for order_report in order_reports {
1401        let Some(position_side) = order_report.quantity_free_close_position_side else {
1402            continue;
1403        };
1404
1405        if order_report.report.quantity.is_positive() {
1406            continue;
1407        }
1408
1409        let mut matching_positions = position_reports.iter().filter(|position| {
1410            position.account_id == order_report.report.account_id
1411                && position.instrument_id == order_report.report.instrument_id
1412                && position.position_side == position_side
1413        });
1414        let Some(position) = matching_positions.next() else {
1415            continue;
1416        };
1417
1418        if matching_positions.next().is_some() {
1419            log::warn!(
1420                "Cannot restore close-position quantity for order {}: multiple {:?} position reports for {}",
1421                order_report.report.venue_order_id,
1422                position_side,
1423                order_report.report.instrument_id,
1424            );
1425            continue;
1426        }
1427
1428        order_report.report.quantity = position.quantity;
1429    }
1430}
1431
1432fn resolve_order_position_identity(
1433    is_hedge_mode: bool,
1434    use_position_ids: bool,
1435    order: &OrderAny,
1436) -> Result<(Option<BinancePositionSide>, Option<PositionId>), OrderDeniedReason> {
1437    let position_side =
1438        determine_position_side(is_hedge_mode, order.order_side(), order.is_reduce_only());
1439    let venue_position_id = make_venue_position_id(
1440        use_position_ids,
1441        order.instrument_id(),
1442        Some(position_side.unwrap_or(BinancePositionSide::Both)),
1443    )
1444    .map_err(|e| OrderDeniedReason::ValidationFailed {
1445        detail: e.to_string(),
1446    })?;
1447    Ok((position_side, venue_position_id))
1448}
1449
1450fn validate_submit_position_id(
1451    submitted_position_id: Option<PositionId>,
1452    venue_position_id: Option<PositionId>,
1453) -> Result<(), OrderDeniedReason> {
1454    if let (Some(submitted_position_id), Some(venue_position_id)) =
1455        (submitted_position_id, venue_position_id)
1456        && submitted_position_id != venue_position_id
1457    {
1458        return Err(OrderDeniedReason::InvalidPositionId {
1459            position_id: submitted_position_id,
1460            detail: format!(
1461                "conflicts with canonical Binance Futures venue position ID {venue_position_id}; omit position_id while use_position_ids=true, or set use_position_ids=false for virtual hedging"
1462            ),
1463        });
1464    }
1465    Ok(())
1466}
1467
1468fn build_futures_order_list_batch(
1469    orders: &[OrderAny],
1470    is_hedge_mode: bool,
1471    close_position: bool,
1472    price_match: Option<BinancePriceMatch>,
1473    product_type: BinanceProductType,
1474    use_gtd: bool,
1475    ts_now: UnixNanos,
1476) -> Result<Vec<BatchOrderItem>, String> {
1477    if orders.len() > 5 {
1478        return Err(format!(
1479            "Binance Futures batch order submission supports at most 5 orders, was {}",
1480            orders.len()
1481        ));
1482    }
1483
1484    if close_position {
1485        return Err(
1486            "`close_position` is not supported for Binance Futures batch order submission"
1487                .to_string(),
1488        );
1489    }
1490
1491    if orders.iter().any(is_grouped_order) {
1492        return Err(
1493            "Binance Futures linked order-list contingencies require adapter-level OCO-on-fill state"
1494                .to_string(),
1495        );
1496    }
1497
1498    if let Some(order) = orders
1499        .iter()
1500        .find(|order| is_algo_order_type(order.order_type()))
1501    {
1502        return Err(format!(
1503            "Binance Futures batch order submission does not support conditional order type {:?}",
1504            order.order_type()
1505        ));
1506    }
1507
1508    let mut batch_items = Vec::with_capacity(orders.len());
1509    for order in orders {
1510        if price_match.is_some() && order.is_post_only() {
1511            return Err("price_match cannot be combined with post-only orders".to_string());
1512        }
1513
1514        if price_match.is_some() && order.order_type() != OrderType::Limit {
1515            return Err(format!(
1516                "price_match is not supported for order type {:?}",
1517                order.order_type()
1518            ));
1519        }
1520
1521        let binance_side = BinanceSide::try_from(order.order_side()).map_err(|e| e.to_string())?;
1522        let binance_order_type =
1523            order_type_to_binance_futures(order.order_type()).map_err(|e| e.to_string())?;
1524        let lifetime = determine_futures_order_lifetime(
1525            product_type,
1526            order.order_type(),
1527            order.time_in_force(),
1528            order.expire_time(),
1529            order.is_post_only(),
1530            use_gtd,
1531            ts_now,
1532        )
1533        .map_err(|e| e.to_string())?;
1534        let binance_tif = if order.is_post_only() {
1535            BinanceTimeInForce::Gtx
1536        } else {
1537            BinanceTimeInForce::try_from(lifetime.time_in_force).map_err(|e| e.to_string())?
1538        };
1539        let position_side =
1540            determine_position_side(is_hedge_mode, order.order_side(), order.is_reduce_only());
1541        let requires_time_in_force = matches!(order.order_type(), OrderType::Limit);
1542
1543        batch_items.push(BatchOrderItem {
1544            symbol: format_binance_symbol(&order.instrument_id()),
1545            side: binance_side_wire(binance_side).to_string(),
1546            order_type: binance_futures_order_type_wire(binance_order_type).to_string(),
1547            time_in_force: if requires_time_in_force {
1548                Some(binance_time_in_force_wire(binance_tif).to_string())
1549            } else {
1550                None
1551            },
1552            quantity: Some(order.quantity().to_string()),
1553            price: if price_match.is_some() {
1554                None
1555            } else {
1556                order.price().map(|price| price.to_string())
1557            },
1558            reduce_only: reduce_only_param(order.is_reduce_only(), position_side),
1559            new_client_order_id: Some(encode_broker_id(
1560                &order.client_order_id(),
1561                BINANCE_NAUTILUS_FUTURES_BROKER_ID,
1562            )),
1563            stop_price: None,
1564            position_side: position_side.map(|side| binance_position_side_wire(side).to_string()),
1565            activation_price: None,
1566            callback_rate: None,
1567            working_type: None,
1568            price_protect: None,
1569            close_position: None,
1570            good_till_date: lifetime.good_till_date,
1571            price_match: price_match
1572                .and_then(binance_price_match_wire)
1573                .map(str::to_string),
1574            self_trade_prevention_mode: None,
1575        });
1576    }
1577
1578    Ok(batch_items)
1579}
1580
1581fn determine_futures_order_lifetime(
1582    product_type: BinanceProductType,
1583    order_type: OrderType,
1584    time_in_force: TimeInForce,
1585    expire_time: Option<UnixNanos>,
1586    post_only: bool,
1587    use_gtd: bool,
1588    ts_now: UnixNanos,
1589) -> Result<FuturesOrderLifetime, OrderDeniedReason> {
1590    if time_in_force != TimeInForce::Gtd {
1591        return Ok(FuturesOrderLifetime {
1592            time_in_force,
1593            good_till_date: None,
1594        });
1595    }
1596
1597    if !use_gtd {
1598        log::warn!(
1599            "Binance Futures GTD submitted as GTC because use_gtd=false. Enable manage_gtd_expiry on the submitting strategy"
1600        );
1601        return Ok(FuturesOrderLifetime {
1602            time_in_force: TimeInForce::Gtc,
1603            good_till_date: None,
1604        });
1605    }
1606
1607    if !matches!(
1608        order_type,
1609        OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
1610    ) {
1611        return Err(OrderDeniedReason::ValidationFailed {
1612            detail: format!("Binance Futures does not support GTD for order type {order_type:?}"),
1613        });
1614    }
1615
1616    if post_only {
1617        return Err(OrderDeniedReason::ValidationFailed {
1618            detail: "Binance Futures GTD cannot be post-only".to_string(),
1619        });
1620    }
1621
1622    if product_type != BinanceProductType::UsdM {
1623        return Err(OrderDeniedReason::ValidationFailed {
1624            detail: format!("Binance {product_type:?} Futures does not support native GTD"),
1625        });
1626    }
1627
1628    let expire_time = expire_time.ok_or(OrderDeniedReason::MissingExpireTime)?;
1629    let expire_ns = expire_time.as_u64();
1630    if !expire_ns.is_multiple_of(NANOSECONDS_IN_SECOND) {
1631        return Err(OrderDeniedReason::ValidationFailed {
1632            detail: "Binance Futures goodTillDate requires whole-second precision".to_string(),
1633        });
1634    }
1635
1636    let minimum_ns = ts_now
1637        .as_u64()
1638        .checked_add(BINANCE_GTD_MIN_LEAD_SECS * NANOSECONDS_IN_SECOND)
1639        .ok_or_else(|| OrderDeniedReason::ValidationFailed {
1640            detail: "Binance Futures GTD minimum timestamp overflow".to_string(),
1641        })?;
1642
1643    if expire_ns <= minimum_ns {
1644        return Err(OrderDeniedReason::ValidationFailed {
1645            detail: format!(
1646                "Binance Futures goodTillDate must be strictly greater than current time plus {BINANCE_GTD_MIN_LEAD_SECS} seconds"
1647            ),
1648        });
1649    }
1650
1651    let good_till_date = expire_ns / NANOSECONDS_IN_MILLISECOND;
1652    if good_till_date >= BINANCE_GTD_MAX_MILLIS {
1653        return Err(OrderDeniedReason::ValidationFailed {
1654            detail: format!(
1655                "Binance Futures goodTillDate must be smaller than {BINANCE_GTD_MAX_MILLIS}"
1656            ),
1657        });
1658    }
1659
1660    Ok(FuturesOrderLifetime {
1661        time_in_force,
1662        good_till_date: Some(i64::try_from(good_till_date).map_err(|e| {
1663            OrderDeniedReason::ValidationFailed {
1664                detail: e.to_string(),
1665            }
1666        })?),
1667    })
1668}
1669
1670fn is_grouped_order(order: &OrderAny) -> bool {
1671    order.contingency_type().is_some()
1672        || order
1673            .linked_order_ids()
1674            .is_some_and(|linked_order_ids| !linked_order_ids.is_empty())
1675}
1676
1677fn binance_side_wire(side: BinanceSide) -> &'static str {
1678    match side {
1679        BinanceSide::Buy => "BUY",
1680        BinanceSide::Sell => "SELL",
1681    }
1682}
1683
1684fn binance_futures_order_type_wire(order_type: BinanceFuturesOrderType) -> &'static str {
1685    match order_type {
1686        BinanceFuturesOrderType::Limit => "LIMIT",
1687        BinanceFuturesOrderType::Market => "MARKET",
1688        BinanceFuturesOrderType::Stop => "STOP",
1689        BinanceFuturesOrderType::StopMarket => "STOP_MARKET",
1690        BinanceFuturesOrderType::TakeProfit => "TAKE_PROFIT",
1691        BinanceFuturesOrderType::TakeProfitMarket => "TAKE_PROFIT_MARKET",
1692        BinanceFuturesOrderType::TrailingStopMarket => "TRAILING_STOP_MARKET",
1693        BinanceFuturesOrderType::Liquidation => "LIQUIDATION",
1694        BinanceFuturesOrderType::Adl => "ADL",
1695        BinanceFuturesOrderType::Unknown => "UNKNOWN",
1696    }
1697}
1698
1699fn binance_time_in_force_wire(time_in_force: BinanceTimeInForce) -> &'static str {
1700    match time_in_force {
1701        BinanceTimeInForce::Gtc => "GTC",
1702        BinanceTimeInForce::Ioc => "IOC",
1703        BinanceTimeInForce::Fok => "FOK",
1704        BinanceTimeInForce::Gtx => "GTX",
1705        BinanceTimeInForce::Gtd => "GTD",
1706        BinanceTimeInForce::Rpi => "RPI",
1707        BinanceTimeInForce::Unknown => "UNKNOWN",
1708    }
1709}
1710
1711fn binance_position_side_wire(position_side: BinancePositionSide) -> &'static str {
1712    match position_side {
1713        BinancePositionSide::Both => "BOTH",
1714        BinancePositionSide::Long => "LONG",
1715        BinancePositionSide::Short => "SHORT",
1716        BinancePositionSide::Unknown => "UNKNOWN",
1717    }
1718}
1719
1720fn binance_price_match_wire(price_match: BinancePriceMatch) -> Option<&'static str> {
1721    match price_match {
1722        BinancePriceMatch::None | BinancePriceMatch::Unknown => None,
1723        BinancePriceMatch::Opponent => Some("OPPONENT"),
1724        BinancePriceMatch::Opponent5 => Some("OPPONENT_5"),
1725        BinancePriceMatch::Opponent10 => Some("OPPONENT_10"),
1726        BinancePriceMatch::Opponent20 => Some("OPPONENT_20"),
1727        BinancePriceMatch::Queue => Some("QUEUE"),
1728        BinancePriceMatch::Queue5 => Some("QUEUE_5"),
1729        BinancePriceMatch::Queue10 => Some("QUEUE_10"),
1730        BinancePriceMatch::Queue20 => Some("QUEUE_20"),
1731    }
1732}
1733
1734/// Classifies a submit-order error for the rejection event.
1735///
1736/// Returns `true` when the venue indicated a post-only (GTX) rejection. Logs
1737/// a hint when the error matches the UM/CM `dualSidePosition` sync rejection
1738/// (`-4531`), which is an account/setup mismatch rather than a routing fault.
1739pub(crate) fn classify_submit_order_error(err: &anyhow::Error) -> bool {
1740    let venue_code = err
1741        .downcast_ref::<BinanceFuturesHttpError>()
1742        .and_then(|be| match be {
1743            BinanceFuturesHttpError::BinanceError { code, .. } => Some(*code),
1744            _ => None,
1745        });
1746
1747    if venue_code == Some(BINANCE_FUTURES_DUAL_SIDE_SYNC_REJECT_CODE) {
1748        log::warn!(
1749            "Order rejected by Binance Futures with code -4531 \
1750             (UM/CM dualSidePosition sync); confirm Portfolio Margin hedge mode \
1751             matches the order positionSide before resubmitting"
1752        );
1753    }
1754    venue_code == Some(BINANCE_GTX_ORDER_REJECT_CODE)
1755}
1756
1757#[async_trait(?Send)]
1758impl ExecutionClient for BinanceFuturesExecutionClient {
1759    fn is_connected(&self) -> bool {
1760        self.core.is_connected()
1761    }
1762
1763    fn client_id(&self) -> ClientId {
1764        self.core.client_id
1765    }
1766
1767    fn account_id(&self) -> AccountId {
1768        self.core.account_id
1769    }
1770
1771    fn venue(&self) -> Venue {
1772        *BINANCE_VENUE
1773    }
1774
1775    fn oms_type(&self) -> OmsType {
1776        self.core.oms_type
1777    }
1778
1779    fn get_account(&self) -> Option<AccountAny> {
1780        self.core.cache().account_owned(&self.core.account_id)
1781    }
1782
1783    async fn connect(&mut self) -> anyhow::Result<()> {
1784        if self.core.is_connected() && self.session_tasks.is_open() && self.pending_tasks.is_open()
1785        {
1786            return Ok(());
1787        }
1788
1789        if !self.pending_tasks.is_open() || !self.session_tasks.is_open() {
1790            self.disconnect().await?;
1791        }
1792
1793        if !self.pending_tasks.is_open() {
1794            self.await_pending_tasks().await?;
1795            self.pending_tasks.start_generation().map_err(|e| {
1796                anyhow::anyhow!("Failed to start Binance Futures task generation: {e}")
1797            })?;
1798        }
1799
1800        if !self.session_tasks.is_open() {
1801            self.await_session_tasks().await?;
1802            self.await_dispatch_task().await?;
1803            self.session_tasks.start_generation().map_err(|e| {
1804                anyhow::anyhow!("Failed to start Binance Futures session generation: {e}")
1805            })?;
1806        }
1807
1808        self.cancellation_token = CancellationToken::new();
1809        let cancellation_token = self.cancellation_token.clone();
1810        let ws_client = Arc::clone(&self.ws_client);
1811        let ws_trading_client = self.ws_trading_client.clone();
1812        let setup_guard =
1813            TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
1814                cancellation_token.cancel();
1815
1816                if let Some(client) = ws_client.lock().as_ref() {
1817                    client.begin_shutdown();
1818                }
1819
1820                if let Some(client) = ws_trading_client {
1821                    client.begin_shutdown();
1822                }
1823            });
1824
1825        let connect_result: anyhow::Result<()> = async {
1826        // Check hedge mode
1827        let is_hedge_mode = self
1828            .init_hedge_mode()
1829            .await
1830            .context("failed to query hedge mode")?;
1831        self.is_hedge_mode.store(is_hedge_mode, Ordering::Release);
1832        log::info!("Hedge mode (dual side position): {is_hedge_mode}");
1833        if is_hedge_mode != (self.core.oms_type == OmsType::Hedging) {
1834            log::warn!(
1835                "Binance Futures account position mode does not match the configured OMS type: \
1836                 dual_side_position={is_hedge_mode}, oms_type={:?}; set oms_type to match the account position mode",
1837                self.core.oms_type,
1838            );
1839        }
1840
1841        let instruments = self
1842            .http_client
1843            .request_instruments_with_config(&self.config.instrument_provider)
1844            .await
1845            .context("failed to request Binance Futures instruments")?;
1846
1847        if instruments.is_empty() {
1848            log::warn!("No instruments returned for Binance Futures");
1849        } else {
1850            log::debug!("Loaded {} Futures instruments", instruments.len());
1851        }
1852        self.core.set_instruments_initialized();
1853
1854        // Apply configured leverage and margin types
1855        self.apply_futures_config()
1856            .await
1857            .context("failed to apply futures config")?;
1858
1859        // Create listen key for user data stream
1860        log::debug!("Creating listen key for user data stream...");
1861        let listen_key_response = self
1862            .http_client
1863            .create_listen_key()
1864            .await
1865            .context("failed to create listen key")?;
1866        let listen_key = listen_key_response.into_listen_key();
1867        log::debug!("Listen key created successfully");
1868
1869        {
1870            let mut key_guard = self.listen_key.write();
1871            *key_guard = Some(listen_key.clone());
1872        }
1873
1874        let (api_key, api_secret) = resolve_credentials(
1875            self.config
1876                .api_key
1877                .as_ref()
1878                .map(|value| value.expose_secret().to_owned()),
1879            self.config
1880                .api_secret
1881                .as_ref()
1882                .map(|value| value.expose_secret().to_owned()),
1883            self.config.environment,
1884            self.product_type,
1885        )?;
1886
1887        let private_base_url = self.config.base_url_ws.clone().map_or_else(
1888            || get_ws_private_base_url(self.product_type, self.config.environment).to_string(),
1889            |url| {
1890                if self.product_type == BinanceProductType::UsdM
1891                    && self.config.environment == BinanceEnvironment::Live
1892                {
1893                    get_usdm_ws_route_base_url(&url, "private")
1894                } else {
1895                    url
1896                }
1897            },
1898        );
1899
1900        let (recovery_tx, recovery_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
1901        self.recovery_tx = Some(recovery_tx.clone());
1902
1903        let seen_trade_ids: Arc<Mutex<FifoCache<(ustr::Ustr, i64), 10_000>>> =
1904            Arc::new(Mutex::new(FifoCache::new()));
1905
1906        let dispatch_ctx = Arc::new(DispatchCtx {
1907            emitter: self.emitter.clone(),
1908            http_client: self.http_client.clone(),
1909            account_id: self.core.account_id,
1910            product_type: self.product_type,
1911            clock: self.clock,
1912            dispatch_state: self.dispatch_state.clone(),
1913            triggered_algo_ids: self.triggered_algo_order_ids.clone(),
1914            use_position_ids: self.config.use_position_ids,
1915            default_taker_fee: self.config.default_taker_fee,
1916            bnfcr_currency: self.config.bnfcr_currency,
1917            treat_expired_as_canceled: self.config.treat_expired_as_canceled,
1918            use_trade_lite: self.config.use_trade_lite,
1919            seen_trade_ids,
1920            cancellation_token: self.cancellation_token.clone(),
1921        });
1922
1923        let ws_build_params = WsBuildParams {
1924            product_type: self.product_type,
1925            environment: self.config.environment,
1926            api_key: SecretString::from(api_key.clone()),
1927            api_secret: SecretString::from(api_secret.clone()),
1928            private_base_url: private_base_url.clone(),
1929            transport_backend: self.config.transport_backend,
1930            proxy_url: self.config.proxy_url.clone(),
1931            socket_factory: self.socket_factory.clone(),
1932        };
1933
1934        let ws_client =
1935            build_and_connect_user_stream(&ws_build_params, listen_key.expose_secret()).await?;
1936        let stream = ws_client.stream();
1937        *self.ws_client.lock() = Some(ws_client);
1938
1939        self.ws_task
1940            .lock()
1941            .await
1942            .spawn(run_user_stream_dispatch(
1943                stream,
1944                dispatch_ctx.clone(),
1945                recovery_tx.clone(),
1946                dispatch_user_stream_message,
1947            ))
1948            .map_err(|e| anyhow::anyhow!("failed to start user stream dispatch task: {e}"))?;
1949
1950        // Start listen key keepalive task
1951        {
1952            let http_client = self.http_client.clone();
1953            let listen_key_ref = self.listen_key.clone();
1954            let cancel = self.cancellation_token.clone();
1955            let recovery_tx = recovery_tx.clone();
1956
1957            self.session_tasks.spawn(async move {
1958                let mut interval =
1959                    tokio::time::interval(Duration::from_secs(LISTEN_KEY_KEEPALIVE_SECS));
1960                let mut consecutive_failures: u32 = 0;
1961
1962                loop {
1963                    tokio::select! {
1964                        _ = interval.tick() => {
1965                            let key = {
1966                                let guard = listen_key_ref.read();
1967                                guard.clone()
1968                            };
1969
1970                            if let Some(ref key) = key {
1971                                match http_client.keepalive_listen_key(key.expose_secret()).await {
1972                                    Ok(()) => {
1973                                        log::debug!("Listen key keepalive sent successfully");
1974                                        consecutive_failures = 0;
1975                                    }
1976                                    Err(e) => {
1977                                        consecutive_failures += 1;
1978                                        log::warn!(
1979                                            "Listen key keepalive failed ({consecutive_failures}/{MAX_KEEPALIVE_FAILURES}): {e}",
1980                                        );
1981
1982                                        if consecutive_failures >= MAX_KEEPALIVE_FAILURES
1983                                            && recovery_tx.send(()).is_err()
1984                                        {
1985                                            log::warn!(
1986                                                "Recovery channel closed, keepalive exiting",
1987                                            );
1988                                            break;
1989                                        }
1990                                    }
1991                                }
1992                            }
1993                        }
1994                        () = cancel.cancelled() => {
1995                            log::debug!("Listen key keepalive task cancelled");
1996                            break;
1997                        }
1998                    }
1999                }
2000            })?;
2001        }
2002
2003        // Start listen key recovery driver task
2004        {
2005            let recovery_ctx = RecoveryCtx {
2006                http_client: self.http_client.clone(),
2007                listen_key: self.listen_key.clone(),
2008                recovery_listen_key: self.recovery_listen_key.clone(),
2009                ws_client: self.ws_client.clone(),
2010                ws_task: self.ws_task.clone(),
2011                recovery_lock: self.recovery_lock.clone(),
2012                ws_build_params,
2013                dispatch_ctx,
2014                recovery_tx: recovery_tx.clone(),
2015            };
2016            let cancel = self.cancellation_token.clone();
2017
2018            self.session_tasks.spawn(async move {
2019                run_recovery_driver(
2020                    recovery_ctx,
2021                    recovery_rx,
2022                    cancel,
2023                    dispatch_user_stream_message,
2024                )
2025                .await;
2026            })?;
2027        }
2028
2029        // Request initial account state
2030        let account_state = self
2031            .refresh_account_state()
2032            .await
2033            .context("failed to request Binance Futures account state")?;
2034
2035        if !account_state.balances.is_empty() {
2036            log::debug!(
2037                "Received account state with {} balance(s) and {} margin(s)",
2038                account_state.balances.len(),
2039                account_state.margins.len()
2040            );
2041        }
2042
2043        self.emitter.send_account_state(account_state);
2044
2045        crate::common::execution::await_account_registered(&self.core, self.core.account_id, 30.0)
2046            .await?;
2047
2048        // Connect WS trading client (primary order transport for USD-M)
2049        if let Some(ref mut ws_trading) = self.ws_trading_client {
2050            match ws_trading.connect().await {
2051                Ok(()) => {
2052                    log::debug!("Connected to Binance Futures WS trading API");
2053
2054                    let ws_trading_clone = ws_trading.clone();
2055                    let emitter = self.emitter.clone();
2056                    let account_id = self.core.account_id;
2057                    let clock = self.clock;
2058                    let dispatch_state = self.dispatch_state.clone();
2059
2060                    self.session_tasks.spawn(async move {
2061                        while let Some(msg) = ws_trading_clone.recv().await {
2062                            dispatch_ws_trading_message(
2063                                msg,
2064                                &emitter,
2065                                account_id,
2066                                clock,
2067                                &dispatch_state,
2068                            );
2069                        }
2070                    })?;
2071                }
2072                Err(e) => {
2073                    log::error!(
2074                        "Failed to connect WS trading API: {e}. \
2075                         Order operations will use HTTP fallback"
2076                    );
2077                }
2078            }
2079        }
2080
2081        let refresh_secs = self.config.instrument_refresh_interval_secs;
2082        if refresh_secs > 0 {
2083            let http_client = self.http_client.clone();
2084            let provider = self.config.instrument_provider.clone();
2085
2086            self.session_tasks.spawn(async move {
2087                let mut interval = tokio::time::interval(Duration::from_secs(refresh_secs));
2088                interval.tick().await;
2089
2090                loop {
2091                    interval.tick().await;
2092
2093                    match http_client.request_instruments_with_config(&provider).await {
2094                        Ok(instruments) => log::debug!(
2095                            "Refreshed Binance Futures execution instruments: count={}",
2096                            instruments.len()
2097                        ),
2098                        Err(e) => {
2099                            log::warn!("Binance Futures execution instrument refresh failed: {e}");
2100                        }
2101                    }
2102                }
2103            })?;
2104        }
2105
2106            Ok(())
2107        }
2108        .await;
2109
2110        if let Err(e) = connect_result {
2111            self.recovery_tx.take();
2112            self.cancellation_token.cancel();
2113            self.abort_session_tasks();
2114            self.abort_pending_tasks();
2115
2116            if let Some(client) = self.ws_client.lock().as_ref() {
2117                client.begin_shutdown();
2118            }
2119
2120            if let Some(client) = self.ws_trading_client.as_ref() {
2121                client.begin_shutdown();
2122            }
2123
2124            if let Some(ref mut ws_trading) = self.ws_trading_client
2125                && let Err(e) = ws_trading.disconnect().await
2126            {
2127                self.shutdown_errors.push(format!(
2128                    "Binance Futures trading WebSocket shutdown failed: {e}"
2129                ));
2130            }
2131
2132            let session_drained = match self.finish_session_tasks().await {
2133                Ok(()) => true,
2134                Err(e) => {
2135                    let drained = matches!(e, TaskShutdownError::Join(_));
2136                    self.shutdown_errors.push(format!(
2137                        "Failed to terminate Binance Futures session tasks: {e}"
2138                    ));
2139                    drained
2140                }
2141            };
2142
2143            if session_drained && let Err(e) = self.await_dispatch_task().await {
2144                self.shutdown_errors.push(e.to_string());
2145            }
2146
2147            let ws_client = self.ws_client.lock().clone();
2148            if let Some(mut ws_client) = ws_client {
2149                match ws_client.close().await {
2150                    Ok(()) => *self.ws_client.lock() = None,
2151                    Err(e) => self.shutdown_errors.push(format!(
2152                        "Binance Futures stream close after failed startup failed: {e}"
2153                    )),
2154                }
2155            }
2156
2157            if let Err(e) = self
2158                .close_listen_key_slot(
2159                    &self.listen_key,
2160                    "failed to close listen key after failed startup",
2161                )
2162                .await
2163            {
2164                self.shutdown_errors.push(e.to_string());
2165            }
2166
2167            if let Err(e) = self
2168                .close_listen_key_slot(
2169                    &self.recovery_listen_key,
2170                    "failed to close recovery listen key after failed startup",
2171                )
2172                .await
2173            {
2174                self.shutdown_errors.push(e.to_string());
2175            }
2176
2177            if let Err(e) = self.await_pending_tasks().await {
2178                self.shutdown_errors.push(e.to_string());
2179            }
2180
2181            if self.shutdown_errors.is_empty() {
2182                return Err(e);
2183            }
2184            let shutdown_errors = std::mem::take(&mut self.shutdown_errors);
2185            return Err(e.context(format!(
2186                "Binance Futures startup teardown failed: {}",
2187                shutdown_errors.join("; ")
2188            )));
2189        }
2190
2191        setup_guard.disarm();
2192        self.core.set_connected();
2193        log::info!("Connected: client_id={}", self.core.client_id);
2194        Ok(())
2195    }
2196
2197    async fn disconnect(&mut self) -> anyhow::Result<()> {
2198        // Drop the recovery tx so the driver exits its recv loop
2199        self.recovery_tx.take();
2200
2201        // Cancel all background tasks
2202        self.cancellation_token.cancel();
2203        self.abort_session_tasks();
2204        self.abort_pending_tasks();
2205
2206        if let Some(client) = self.ws_client.lock().as_ref() {
2207            client.begin_shutdown();
2208        }
2209
2210        if let Some(client) = self.ws_trading_client.as_ref() {
2211            client.begin_shutdown();
2212        }
2213
2214        if let Some(ref mut ws_trading) = self.ws_trading_client
2215            && let Err(e) = ws_trading.disconnect().await
2216        {
2217            self.shutdown_errors.push(format!(
2218                "Binance Futures trading WebSocket shutdown failed: {e}"
2219            ));
2220        }
2221
2222        let session_drained = match self.finish_session_tasks().await {
2223            Ok(()) => true,
2224            Err(e) => {
2225                let drained = matches!(e, TaskShutdownError::Join(_));
2226                self.shutdown_errors.push(format!(
2227                    "Failed to terminate Binance Futures session tasks: {e}"
2228                ));
2229                drained
2230            }
2231        };
2232
2233        if session_drained && let Err(e) = self.await_dispatch_task().await {
2234            self.shutdown_errors.push(e.to_string());
2235        }
2236
2237        // Close WebSocket
2238        let ws_client = self.ws_client.lock().clone();
2239        if let Some(mut ws_client) = ws_client {
2240            match ws_client.close().await {
2241                Ok(()) => *self.ws_client.lock() = None,
2242                Err(e) => {
2243                    self.shutdown_errors
2244                        .push(format!("Binance Futures stream close failed: {e}"));
2245                }
2246            }
2247        }
2248
2249        // Close listen key
2250        if let Err(e) = self
2251            .close_listen_key_slot(&self.listen_key, "failed to close listen key")
2252            .await
2253        {
2254            self.shutdown_errors.push(e.to_string());
2255        }
2256
2257        if let Err(e) = self
2258            .close_listen_key_slot(
2259                &self.recovery_listen_key,
2260                "failed to close recovery listen key",
2261            )
2262            .await
2263        {
2264            self.shutdown_errors.push(e.to_string());
2265        }
2266
2267        if let Err(e) = self.await_pending_tasks().await {
2268            self.shutdown_errors.push(e.to_string());
2269        }
2270
2271        self.core.set_disconnected();
2272
2273        if !self.shutdown_errors.is_empty() {
2274            let shutdown_errors = std::mem::take(&mut self.shutdown_errors);
2275            anyhow::bail!(
2276                "Binance Futures shutdown failed: {}",
2277                shutdown_errors.join("; ")
2278            );
2279        }
2280        log::info!("Disconnected: client_id={}", self.core.client_id);
2281        Ok(())
2282    }
2283
2284    async fn generate_order_status_report(
2285        &self,
2286        cmd: &GenerateOrderStatusReport,
2287    ) -> anyhow::Result<Option<OrderStatusReport>> {
2288        let Some(instrument_id) = cmd.instrument_id else {
2289            log::warn!("generate_order_status_report requires instrument_id: {cmd}");
2290            return Ok(None);
2291        };
2292        let Some(instrument) = self.http_client.instrument_reconciliation(&instrument_id) else {
2293            if self.is_instrument_out_of_scope(instrument_id) {
2294                log::debug!(
2295                    "Dropping out-of-scope historical Binance Futures order for instrument {instrument_id}"
2296                );
2297                return Ok(None);
2298            }
2299
2300            anyhow::bail!(
2301                "Binance Futures order request has unresolved instrument {instrument_id}"
2302            );
2303        };
2304
2305        let symbol = format_binance_symbol(&instrument_id);
2306        let order_id = cmd
2307            .venue_order_id
2308            .as_ref()
2309            .map(|id| {
2310                id.inner()
2311                    .parse::<i64>()
2312                    .context("failed to parse venue_order_id as numeric")
2313            })
2314            .transpose()?;
2315        let orig_client_order_id = cmd
2316            .client_order_id
2317            .map(|id| encode_broker_id(&id, BINANCE_NAUTILUS_FUTURES_BROKER_ID));
2318
2319        let mut builder = BinanceOrderQueryParamsBuilder::default();
2320        builder.symbol(symbol);
2321
2322        if let Some(oid) = order_id {
2323            builder.order_id(oid);
2324        }
2325
2326        if let Some(ref coid) = orig_client_order_id {
2327            builder.orig_client_order_id(coid.clone());
2328        }
2329        let params = builder.build().map_err(|e| anyhow::anyhow!("{e}"))?;
2330
2331        let price_precision = instrument.price_precision();
2332        let size_precision = instrument.size_precision();
2333        let ts_init = self.clock.get_time_ns();
2334        let algo_lookup = self.resolve_algo_lookup(cmd.client_order_id, cmd.params.as_ref());
2335
2336        if algo_lookup == BinanceFuturesAlgoLookup::AlgoId {
2337            let algo_order = self
2338                .http_client
2339                .query_algo_order_with_history(
2340                    instrument_id,
2341                    cmd.client_order_id,
2342                    cmd.venue_order_id,
2343                )
2344                .await?;
2345
2346            return match algo_order {
2347                Some(result) => Ok(Some(create_algo_order_status_report(
2348                    &result,
2349                    self.core.account_id,
2350                    instrument_id,
2351                    price_precision,
2352                    size_precision,
2353                    self.config.treat_expired_as_canceled,
2354                    self.config.use_position_ids,
2355                    ts_init,
2356                )?)),
2357                None => {
2358                    log::debug!("Algo order query returned no matching order");
2359                    Ok(None)
2360                }
2361            };
2362        }
2363
2364        match self.http_client.query_order(&params).await {
2365            Ok(order) => {
2366                let report = order.to_order_status_report(
2367                    self.core.account_id,
2368                    instrument_id,
2369                    price_precision,
2370                    size_precision,
2371                    self.config.treat_expired_as_canceled,
2372                    ts_init,
2373                )?;
2374                let venue_position_id = make_venue_position_id(
2375                    self.config.use_position_ids,
2376                    instrument_id,
2377                    order.position_side,
2378                )?;
2379                Ok(Some(with_venue_position_id(report, venue_position_id)))
2380            }
2381            Err(BinanceFuturesHttpError::BinanceError { code: -2013, .. }) => {
2382                if algo_lookup == BinanceFuturesAlgoLookup::Skip {
2383                    log::debug!("Skipping Algo Service fallback for known regular order");
2384                    return Ok(None);
2385                }
2386
2387                // A conditional order may expose its Algo Service `algoId` before triggering and
2388                // its matching-engine `actualOrderId` afterwards. Only an explicit ID-kind hint
2389                // makes a venue ID safe for Algo Service lookup; cached conditional orders retain
2390                // their existing clientAlgoId fallback.
2391                let algo_venue_order_id = if algo_lookup == BinanceFuturesAlgoLookup::AlgoId {
2392                    cmd.venue_order_id
2393                } else {
2394                    None
2395                };
2396                let algo_order = self
2397                    .http_client
2398                    .query_algo_order_with_history(
2399                        instrument_id,
2400                        cmd.client_order_id,
2401                        algo_venue_order_id,
2402                    )
2403                    .await?;
2404
2405                match algo_order {
2406                    Some(result) => Ok(Some(create_algo_order_status_report(
2407                        &result,
2408                        self.core.account_id,
2409                        instrument_id,
2410                        price_precision,
2411                        size_precision,
2412                        self.config.treat_expired_as_canceled,
2413                        self.config.use_position_ids,
2414                        ts_init,
2415                    )?)),
2416                    None => {
2417                        log::debug!("Algo order query returned no matching order");
2418                        Ok(None)
2419                    }
2420                }
2421            }
2422            Err(e) => Err(e.into()),
2423        }
2424    }
2425
2426    async fn generate_order_status_reports(
2427        &self,
2428        cmd: &GenerateOrderStatusReports,
2429    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2430        let ts_init = self.clock.get_time_ns();
2431
2432        if cmd.open_only {
2433            let mut reports = self
2434                .generate_open_order_status_reports(cmd.instrument_id, ts_init)
2435                .await?;
2436
2437            if reports.iter().any(|report| {
2438                report.quantity_free_close_position_side.is_some()
2439                    && !report.report.quantity.is_positive()
2440            }) {
2441                let position_cmd = GeneratePositionStatusReportsBuilder::default()
2442                    .log_receipt_level(cmd.log_receipt_level)
2443                    .ts_init(ts_init)
2444                    .instrument_id(cmd.instrument_id)
2445                    .build()
2446                    .map_err(|e| anyhow::anyhow!("{e}"))?;
2447                let position_reports = self.generate_position_status_reports(&position_cmd).await?;
2448                restore_close_position_quantities(&mut reports, &position_reports);
2449            }
2450
2451            crate::common::execution::log_report_receipt(
2452                reports.len(),
2453                "OrderStatusReport",
2454                cmd.log_receipt_level,
2455            );
2456            return Ok(reports.into_iter().map(|report| report.report).collect());
2457        }
2458
2459        let mut reports = Vec::new();
2460
2461        if let Some(instrument_id) = cmd.instrument_id
2462            && self
2463                .http_client
2464                .instrument_reconciliation(&instrument_id)
2465                .is_none()
2466        {
2467            if self.is_instrument_out_of_scope(instrument_id) {
2468                log::debug!(
2469                    "Dropping out-of-scope Binance Futures order request for instrument {instrument_id}"
2470                );
2471                return Ok(reports);
2472            }
2473
2474            log::warn!(
2475                "Dropping historical Binance Futures orders for unresolved instrument {instrument_id}"
2476            );
2477            return Ok(reports);
2478        }
2479
2480        if let Some(instrument_id) = cmd.instrument_id {
2481            let Some(instrument) = self.http_client.instrument_reconciliation(&instrument_id)
2482            else {
2483                if self.is_instrument_out_of_scope(instrument_id) {
2484                    log::debug!(
2485                        "Dropping out-of-scope historical Binance Futures orders for instrument {instrument_id}"
2486                    );
2487                } else {
2488                    log::warn!(
2489                        "Dropping historical Binance Futures orders for unresolved instrument {instrument_id}"
2490                    );
2491                }
2492                return Ok(reports);
2493            };
2494            let symbol = format_binance_symbol(&instrument_id);
2495            let start_time = cmd
2496                .start
2497                .map(|t| t.as_i64() / NANOSECONDS_IN_MILLISECOND as i64);
2498            let end_time = cmd
2499                .end
2500                .map(|t| t.as_i64() / NANOSECONDS_IN_MILLISECOND as i64);
2501
2502            let mut builder = BinanceAllOrdersParamsBuilder::default();
2503            builder.symbol(symbol);
2504
2505            if let Some(st) = start_time {
2506                builder.start_time(st);
2507            }
2508
2509            if let Some(et) = end_time {
2510                builder.end_time(et);
2511            }
2512            let params = builder.build().map_err(|e| anyhow::anyhow!("{e}"))?;
2513
2514            let orders = self.http_client.query_all_orders(&params).await?;
2515
2516            for order in orders {
2517                let report = order.to_order_status_report(
2518                    self.core.account_id,
2519                    instrument.id(),
2520                    instrument.price_precision(),
2521                    instrument.size_precision(),
2522                    self.config.treat_expired_as_canceled,
2523                    ts_init,
2524                )?;
2525                let venue_position_id = make_venue_position_id(
2526                    self.config.use_position_ids,
2527                    instrument.id(),
2528                    order.position_side,
2529                )?;
2530                reports.push(with_venue_position_id(report, venue_position_id));
2531            }
2532        }
2533
2534        crate::common::execution::log_report_receipt(
2535            reports.len(),
2536            "OrderStatusReport",
2537            cmd.log_receipt_level,
2538        );
2539        Ok(reports)
2540    }
2541
2542    async fn generate_fill_reports(
2543        &self,
2544        cmd: GenerateFillReports,
2545    ) -> anyhow::Result<Vec<FillReport>> {
2546        let Some(instrument_id) = cmd.instrument_id else {
2547            log::warn!("generate_fill_reports requires instrument_id for Binance Futures");
2548            return Ok(Vec::new());
2549        };
2550        let Some(instrument) = self.http_client.instrument_reconciliation(&instrument_id) else {
2551            if self.is_instrument_out_of_scope(instrument_id) {
2552                log::debug!(
2553                    "Dropping out-of-scope historical Binance Futures fills for instrument {instrument_id}"
2554                );
2555            } else {
2556                log::warn!(
2557                    "Dropping historical Binance Futures fills for unresolved instrument {instrument_id}"
2558                );
2559            }
2560            return Ok(Vec::new());
2561        };
2562
2563        let symbol = format_binance_symbol(&instrument_id);
2564        let mut trades = Vec::new();
2565        let mut seen_trade_ids = AHashSet::new();
2566        let order_id = cmd
2567            .venue_order_id
2568            .map(|id| {
2569                id.inner()
2570                    .parse::<i64>()
2571                    .context("invalid fill report venue order ID")
2572            })
2573            .transpose()?;
2574        let requested_end_time = cmd
2575            .end
2576            .map(|end| end.as_i64() / NANOSECONDS_IN_MILLISECOND as i64);
2577
2578        if let Some(start) = cmd.start {
2579            let query_start_time = start.as_i64() / NANOSECONDS_IN_MILLISECOND as i64;
2580            let query_end_time = requested_end_time.unwrap_or_else(|| {
2581                self.clock.get_time_ns().as_i64() / NANOSECONDS_IN_MILLISECOND as i64
2582            });
2583            anyhow::ensure!(
2584                query_start_time <= query_end_time,
2585                "fill report start time must not exceed end time"
2586            );
2587            let complete_start = user_trades_complete_start(cmd.ts_init, self.clock.get_time_ns());
2588            anyhow::ensure!(
2589                start >= complete_start,
2590                "Binance Futures fill report range is incomplete: start {start} precedes complete-history boundary {complete_start}"
2591            );
2592            let mut window_start = query_start_time;
2593
2594            loop {
2595                let window_end = window_start
2596                    .saturating_add(USER_TRADES_MAX_INTERVAL_MS)
2597                    .min(query_end_time);
2598                let mut from_id = None;
2599
2600                loop {
2601                    let mut builder = BinanceUserTradesParamsBuilder::default();
2602                    builder.symbol(symbol.clone());
2603                    builder.limit(USER_TRADES_PAGE_LIMIT);
2604                    if let Some(order_id) = order_id {
2605                        builder.order_id(order_id);
2606                    }
2607
2608                    if let Some(cursor) = from_id {
2609                        builder.from_id(cursor);
2610                    } else {
2611                        builder.start_time(window_start);
2612                        builder.end_time(window_end);
2613                    }
2614                    let params = builder.build().map_err(|e| anyhow::anyhow!("{e}"))?;
2615                    let page = self.http_client.query_user_trades(&params).await?;
2616
2617                    if page.is_empty() {
2618                        break;
2619                    }
2620
2621                    let page_len = page.len();
2622                    let max_trade_id = page.iter().map(|trade| trade.id).max().unwrap();
2623                    let passed_window_end = page.iter().any(|trade| trade.time > window_end);
2624
2625                    trades.extend(page.into_iter().filter(|trade| {
2626                        trade.time >= window_start
2627                            && trade.time <= window_end
2628                            && seen_trade_ids.insert(trade.id)
2629                    }));
2630
2631                    if page_len < USER_TRADES_PAGE_LIMIT as usize || passed_window_end {
2632                        break;
2633                    }
2634
2635                    let next_from_id = max_trade_id
2636                        .checked_add(1)
2637                        .context("Binance user trade ID overflow during pagination")?;
2638                    anyhow::ensure!(
2639                        from_id.is_none_or(|cursor| next_from_id > cursor),
2640                        "Binance user-trades pagination made no progress"
2641                    );
2642                    from_id = Some(next_from_id);
2643                }
2644
2645                if window_end >= query_end_time {
2646                    break;
2647                }
2648                window_start = window_end.saturating_add(1);
2649            }
2650        } else {
2651            anyhow::ensure!(
2652                cmd.end.is_none(),
2653                "Binance Futures fill report end time requires start time for a complete range"
2654            );
2655            let mut from_id = 0;
2656
2657            loop {
2658                let mut builder = BinanceUserTradesParamsBuilder::default();
2659                builder.symbol(symbol.clone());
2660                builder.from_id(from_id);
2661                builder.limit(USER_TRADES_PAGE_LIMIT);
2662                if let Some(order_id) = order_id {
2663                    builder.order_id(order_id);
2664                }
2665                let params = builder.build().map_err(|e| anyhow::anyhow!("{e}"))?;
2666                let page = self.http_client.query_user_trades(&params).await?;
2667
2668                if page.is_empty() {
2669                    break;
2670                }
2671
2672                let page_len = page.len();
2673                let max_trade_id = page.iter().map(|trade| trade.id).max().unwrap();
2674                let passed_end = requested_end_time
2675                    .is_some_and(|end_time| page.iter().any(|trade| trade.time > end_time));
2676
2677                trades.extend(page.into_iter().filter(|trade| {
2678                    requested_end_time.is_none_or(|end_time| trade.time <= end_time)
2679                        && seen_trade_ids.insert(trade.id)
2680                }));
2681
2682                if page_len < USER_TRADES_PAGE_LIMIT as usize || passed_end {
2683                    break;
2684                }
2685
2686                let next_from_id = max_trade_id
2687                    .checked_add(1)
2688                    .context("Binance user trade ID overflow during pagination")?;
2689                anyhow::ensure!(
2690                    next_from_id > from_id,
2691                    "Binance user-trades pagination made no progress"
2692                );
2693                from_id = next_from_id;
2694            }
2695        }
2696
2697        trades.sort_unstable_by_key(|trade| (trade.time, trade.id));
2698        let ts_init = self.clock.get_time_ns();
2699
2700        let mut reports = Vec::new();
2701
2702        for trade in trades {
2703            if order_id.is_some_and(|order_id| trade.order_id != order_id) {
2704                continue;
2705            }
2706            let venue_position_id = make_venue_position_id(
2707                self.config.use_position_ids,
2708                instrument.id(),
2709                trade.position_side,
2710            )?;
2711            let mut report = trade.to_fill_report(
2712                self.core.account_id,
2713                instrument.id(),
2714                instrument.price_precision(),
2715                instrument.size_precision(),
2716                self.config.bnfcr_currency,
2717                ts_init,
2718            )?;
2719            report.venue_position_id = venue_position_id;
2720            reports.push(report);
2721        }
2722
2723        crate::common::execution::log_report_receipt(
2724            reports.len(),
2725            "FillReport",
2726            cmd.log_receipt_level,
2727        );
2728        Ok(reports)
2729    }
2730
2731    async fn generate_position_status_reports(
2732        &self,
2733        cmd: &GeneratePositionStatusReports,
2734    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2735        if let Some(instrument_id) = cmd.instrument_id
2736            && self
2737                .http_client
2738                .instrument_reconciliation(&instrument_id)
2739                .is_none()
2740        {
2741            if self.is_instrument_out_of_scope(instrument_id) {
2742                log::debug!(
2743                    "Dropping out-of-scope Binance Futures position request for instrument {instrument_id}"
2744                );
2745                return Ok(Vec::new());
2746            }
2747            anyhow::bail!(
2748                "Binance Futures position request has unresolved instrument {instrument_id}"
2749            );
2750        }
2751        let symbol = cmd.instrument_id.map(|id| format_binance_symbol(&id));
2752
2753        let mut builder = BinancePositionRiskParamsBuilder::default();
2754
2755        if let Some(s) = symbol {
2756            builder.symbol(s);
2757        }
2758        let params = builder.build().map_err(|e| anyhow::anyhow!("{e}"))?;
2759
2760        let positions = self.http_client.query_positions(&params).await?;
2761
2762        let mut reports = Vec::new();
2763        let mut position_reports_failed = 0usize;
2764
2765        for position in positions {
2766            let instrument_id = format_instrument_id(&position.symbol, self.product_type);
2767
2768            if self.is_instrument_out_of_scope(instrument_id) {
2769                log::debug!(
2770                    "Dropping out-of-scope Binance Futures position for instrument {instrument_id}"
2771                );
2772                continue;
2773            }
2774
2775            let position_amt = match position.position_amt.parse::<Decimal>() {
2776                Ok(value) => value,
2777                Err(e) => {
2778                    log::warn!(
2779                        "Failed to parse Futures position_amt for symbol={}: {e}",
2780                        position.symbol
2781                    );
2782                    position_reports_failed += 1;
2783                    continue;
2784                }
2785            };
2786
2787            if position_amt.is_zero() {
2788                if self.config.use_position_ids {
2789                    let position_side = match position.position_side {
2790                        Some(BinancePositionSide::Long) => PositionSide::Long,
2791                        Some(BinancePositionSide::Short) => PositionSide::Short,
2792                        _ => continue,
2793                    };
2794
2795                    let venue_position_id =
2796                        make_venue_position_id(true, instrument_id, position.position_side)?
2797                            .expect("hedge position sides always produce an ID");
2798
2799                    if let Err(e) = self.ensure_cached_position_id_compatible(
2800                        instrument_id,
2801                        position_side,
2802                        venue_position_id,
2803                    ) {
2804                        log::warn!(
2805                            "Failed to create Futures position report for symbol={}: {e}",
2806                            position.symbol
2807                        );
2808                        position_reports_failed += 1;
2809                    }
2810                }
2811                continue;
2812            }
2813
2814            let Some(instrument) = self.http_client.instrument_reconciliation(&instrument_id)
2815            else {
2816                log::warn!(
2817                    "Failed to create Futures position report for symbol={}: instrument {instrument_id} is unresolved",
2818                    position.symbol
2819                );
2820                position_reports_failed += 1;
2821                continue;
2822            };
2823
2824            match self.create_position_report(
2825                &position,
2826                instrument.id(),
2827                instrument.size_precision(),
2828            ) {
2829                Ok(report) => reports.push(report),
2830                Err(e) => {
2831                    log::warn!(
2832                        "Failed to create Futures position report for symbol={}: {e}",
2833                        position.symbol
2834                    );
2835                    position_reports_failed += 1;
2836                }
2837            }
2838        }
2839
2840        anyhow::ensure!(
2841            position_reports_failed == 0,
2842            "Failed to process {position_reports_failed} Binance Futures position reports",
2843        );
2844
2845        crate::common::execution::log_report_receipt(
2846            reports.len(),
2847            "PositionStatusReport",
2848            cmd.log_receipt_level,
2849        );
2850        Ok(reports)
2851    }
2852
2853    async fn generate_mass_status(
2854        &self,
2855        lookback_mins: Option<u64>,
2856    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
2857        log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
2858
2859        let ts_now = self.clock.get_time_ns();
2860
2861        let requested_start = lookback_mins
2862            .map(DurationNanos::try_from_mins)
2863            .transpose()?
2864            .map(|lookback| ts_now.saturating_sub(lookback));
2865        let complete_start = user_trades_complete_start(ts_now, ts_now);
2866        let (report_start, fill_start, mut reports_complete) = match requested_start {
2867            Some(start) if start < complete_start => (complete_start, Some(complete_start), false),
2868            Some(start) => (start, Some(start), true),
2869            None => (complete_start, None, false),
2870        };
2871
2872        let position_cmd = GeneratePositionStatusReportsBuilder::default()
2873            .log_receipt_level(LogLevel::Off)
2874            .ts_init(ts_now)
2875            .start(Some(report_start))
2876            .build()
2877            .map_err(|e| anyhow::anyhow!("{e}"))?;
2878
2879        let (mut open_order_reports, position_reports) = tokio::try_join!(
2880            self.generate_open_order_status_reports(None, ts_now),
2881            self.generate_position_status_reports(&position_cmd),
2882        )?;
2883        restore_close_position_quantities(&mut open_order_reports, &position_reports);
2884        let order_reports: Vec<_> = open_order_reports
2885            .into_iter()
2886            .map(|report| report.report)
2887            .collect();
2888
2889        let mut instrument_ids: Vec<_> = order_reports
2890            .iter()
2891            .map(|report| report.instrument_id)
2892            .chain(position_reports.iter().map(|report| report.instrument_id))
2893            .collect();
2894        {
2895            let cache = self.core.cache();
2896            instrument_ids.extend(
2897                cache
2898                    .orders_open(
2899                        Some(&BINANCE_VENUE),
2900                        None,
2901                        None,
2902                        Some(&self.core.account_id),
2903                        None,
2904                    )
2905                    .into_iter()
2906                    .map(|order| order.instrument_id()),
2907            );
2908            instrument_ids.extend(
2909                cache
2910                    .orders_inflight(
2911                        Some(&BINANCE_VENUE),
2912                        None,
2913                        None,
2914                        Some(&self.core.account_id),
2915                        None,
2916                    )
2917                    .into_iter()
2918                    .map(|order| order.instrument_id()),
2919            );
2920            instrument_ids.extend(
2921                cache
2922                    .positions_open(
2923                        Some(&BINANCE_VENUE),
2924                        None,
2925                        None,
2926                        Some(&self.core.account_id),
2927                        None,
2928                    )
2929                    .into_iter()
2930                    .map(|position| position.instrument_id),
2931            );
2932            instrument_ids.retain(|instrument_id| {
2933                cache.instrument(instrument_id).is_none_or(|instrument| {
2934                    is_instrument_for_product(instrument, self.product_type)
2935                })
2936            });
2937        }
2938        instrument_ids.sort_unstable();
2939        instrument_ids.dedup();
2940
2941        let mut fill_reports = Vec::new();
2942
2943        for instrument_id in instrument_ids {
2944            if self
2945                .http_client
2946                .instrument_reconciliation(&instrument_id)
2947                .is_none()
2948            {
2949                if self.is_instrument_out_of_scope(instrument_id) {
2950                    log::debug!(
2951                        "Dropping out-of-scope historical Binance Futures fills for instrument {instrument_id}"
2952                    );
2953                } else {
2954                    log::warn!(
2955                        "Dropping historical Binance Futures fills for unresolved instrument {instrument_id}"
2956                    );
2957                    reports_complete = false;
2958                }
2959                continue;
2960            }
2961            let fill_cmd = GenerateFillReportsBuilder::default()
2962                .log_receipt_level(LogLevel::Off)
2963                .ts_init(ts_now)
2964                .instrument_id(Some(instrument_id))
2965                .start(fill_start)
2966                .build()
2967                .map_err(|e| anyhow::anyhow!("{e}"))?;
2968            fill_reports.extend(
2969                self.generate_fill_reports(fill_cmd)
2970                    .await?
2971                    .into_iter()
2972                    .filter(|report| report.ts_event >= report_start),
2973            );
2974        }
2975
2976        log::info!("Received {} OrderStatusReports", order_reports.len());
2977        log::info!("Received {} FillReports", fill_reports.len());
2978        log::info!("Received {} PositionReports", position_reports.len());
2979
2980        let mut mass_status = ExecutionMassStatus::new(
2981            self.core.client_id,
2982            self.core.account_id,
2983            *BINANCE_VENUE,
2984            ts_now,
2985            None,
2986        );
2987
2988        mass_status.add_order_reports(order_reports);
2989        mass_status.add_fill_reports(fill_reports);
2990        mass_status.add_position_reports(position_reports);
2991        mass_status.set_report_window(Some(report_start), reports_complete);
2992
2993        Ok(Some(mass_status))
2994    }
2995
2996    fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
2997        self.update_account_state();
2998        Ok(())
2999    }
3000
3001    fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
3002        log::debug!("query_order: client_order_id={}", cmd.client_order_id);
3003
3004        let algo_lookup = self.resolve_algo_lookup(Some(cmd.client_order_id), cmd.params.as_ref());
3005        let http_client = self.http_client.clone();
3006        let command = cmd;
3007        let emitter = self.emitter.clone();
3008        let account_id = self.core.account_id;
3009        let clock = self.clock;
3010
3011        let symbol = format_binance_symbol(&command.instrument_id);
3012        let order_id = command
3013            .venue_order_id
3014            .map(|id| {
3015                id.inner()
3016                    .parse::<i64>()
3017                    .map_err(|e| anyhow::anyhow!("failed to parse venue_order_id: {e}"))
3018            })
3019            .transpose()?;
3020        let orig_client_order_id = Some(encode_broker_id(
3021            &command.client_order_id,
3022            BINANCE_NAUTILUS_FUTURES_BROKER_ID,
3023        ));
3024        let (price_precision, size_precision) =
3025            self.get_instrument_precision(command.instrument_id)?;
3026        let treat_expired_as_canceled = self.config.treat_expired_as_canceled;
3027        let use_position_ids = self.config.use_position_ids;
3028
3029        self.spawn_task("query_order", async move {
3030            if algo_lookup == BinanceFuturesAlgoLookup::AlgoId {
3031                match http_client
3032                    .query_algo_order_with_history(
3033                        command.instrument_id,
3034                        Some(command.client_order_id),
3035                        command.venue_order_id,
3036                    )
3037                    .await
3038                {
3039                    Ok(Some(result)) => {
3040                        let report = create_algo_order_status_report(
3041                            &result,
3042                            account_id,
3043                            command.instrument_id,
3044                            price_precision,
3045                            size_precision,
3046                            treat_expired_as_canceled,
3047                            use_position_ids,
3048                            clock.get_time_ns(),
3049                        )?;
3050                        emitter.send_order_status_report(report);
3051                    }
3052                    Ok(None) => log::warn!("Algo order query returned no matching order"),
3053                    Err(e) => log::warn!("Failed to query algo order status: {e}"),
3054                }
3055
3056                return Ok(());
3057            }
3058
3059            let mut builder = BinanceOrderQueryParamsBuilder::default();
3060            builder.symbol(symbol.clone());
3061
3062            if let Some(oid) = order_id {
3063                builder.order_id(oid);
3064            }
3065
3066            if let Some(coid) = orig_client_order_id {
3067                builder.orig_client_order_id(coid);
3068            }
3069            let params = builder
3070                .build()
3071                .map_err(|e| anyhow::anyhow!("failed to build order query params: {e}"))?;
3072
3073            let result = http_client.query_order(&params).await;
3074
3075            match result {
3076                Ok(order) => {
3077                    let ts_init = clock.get_time_ns();
3078                    let report = order.to_order_status_report(
3079                        account_id,
3080                        command.instrument_id,
3081                        price_precision,
3082                        size_precision,
3083                        treat_expired_as_canceled,
3084                        ts_init,
3085                    )?;
3086                    let venue_position_id = make_venue_position_id(
3087                        use_position_ids,
3088                        command.instrument_id,
3089                        order.position_side,
3090                    )?;
3091
3092                    emitter.send_order_status_report(with_venue_position_id(
3093                        report,
3094                        venue_position_id,
3095                    ));
3096                }
3097                Err(BinanceFuturesHttpError::BinanceError { code: -2013, .. }) => {
3098                    if algo_lookup == BinanceFuturesAlgoLookup::Skip {
3099                        log::debug!("Skipping Algo Service fallback for known regular order");
3100                        return Ok(());
3101                    }
3102
3103                    // Untriggered algo orders (STOP_MARKET, STOP_LIMIT, MIT, LIT,
3104                    // TRAILING_STOP_MARKET) live in the Binance Futures Algo Service
3105                    // and return -2013 on the regular order endpoint. Mirror the
3106                    // reconciliation path (`generate_order_status_report`) so live
3107                    // inflight checks resolve them instead of exhausting retries into
3108                    // `OrderRejected(reason='INFLIGHT_TIMEOUT')`.
3109                    let algo_venue_order_id = if algo_lookup == BinanceFuturesAlgoLookup::AlgoId {
3110                        command.venue_order_id
3111                    } else {
3112                        None
3113                    };
3114
3115                    match http_client
3116                        .query_algo_order_with_history(
3117                            command.instrument_id,
3118                            Some(command.client_order_id),
3119                            algo_venue_order_id,
3120                        )
3121                        .await
3122                    {
3123                        Ok(Some(result)) => {
3124                            let report = create_algo_order_status_report(
3125                                &result,
3126                                account_id,
3127                                command.instrument_id,
3128                                price_precision,
3129                                size_precision,
3130                                treat_expired_as_canceled,
3131                                use_position_ids,
3132                                clock.get_time_ns(),
3133                            )?;
3134                            emitter.send_order_status_report(report);
3135                        }
3136                        Ok(None) => log::warn!("Algo order query returned no matching order"),
3137                        Err(e) => log::warn!("Algo order query also failed: {e}"),
3138                    }
3139                }
3140                Err(e) => log::warn!("Failed to query order status: {e}"),
3141            }
3142
3143            Ok(())
3144        });
3145
3146        Ok(())
3147    }
3148
3149    fn generate_account_state(
3150        &self,
3151        balances: Vec<AccountBalance>,
3152        margins: Vec<MarginBalance>,
3153        reported: bool,
3154        ts_event: UnixNanos,
3155        info: Option<Params>,
3156    ) -> anyhow::Result<()> {
3157        self.emitter
3158            .emit_account_state(balances, margins, reported, ts_event, info);
3159        Ok(())
3160    }
3161
3162    fn start(&mut self) -> anyhow::Result<()> {
3163        if self.core.is_started() {
3164            return Ok(());
3165        }
3166
3167        self.emitter.set_sender(get_exec_event_sender());
3168        self.core.set_started();
3169
3170        log::info!(
3171            "Started: client_id={}, account_id={}, account_type={:?}, environment={:?}",
3172            self.core.client_id,
3173            self.core.account_id,
3174            self.core.account_type,
3175            self.config.environment,
3176        );
3177        Ok(())
3178    }
3179
3180    fn stop(&mut self) -> anyhow::Result<()> {
3181        let was_started = self.core.is_started();
3182        self.core.set_stopped();
3183        self.begin_generation_shutdown();
3184
3185        if was_started {
3186            log::info!("Stopped: client_id={}", self.core.client_id);
3187        }
3188        Ok(())
3189    }
3190
3191    fn reset(&mut self) -> anyhow::Result<()> {
3192        self.begin_generation_shutdown();
3193        Ok(())
3194    }
3195
3196    fn dispose(&mut self) -> anyhow::Result<()> {
3197        self.begin_generation_shutdown();
3198        Ok(())
3199    }
3200
3201    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
3202        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
3203
3204        if order.is_closed() {
3205            let client_order_id = order.client_order_id();
3206            log::warn!("Cannot submit closed order {client_order_id}");
3207            return Ok(());
3208        }
3209
3210        let validated = match validate_order(self, &cmd, &order) {
3211            Ok(validated) => validated,
3212            Err(reason) => {
3213                self.emitter.emit_order_denied(&order, &reason.to_string());
3214                return Ok(());
3215            }
3216        };
3217
3218        self.submit_order_internal(
3219            &cmd,
3220            validated.lifetime,
3221            validated.position_side,
3222            validated.venue_position_id,
3223        )
3224    }
3225
3226    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
3227        if cmd.order_list.client_order_ids.is_empty() {
3228            log::debug!("submit_order_list called with empty order list");
3229            return Ok(());
3230        }
3231
3232        let orders = self.core.get_orders_for_list(&cmd.order_list)?;
3233
3234        if let Some(order) = orders.iter().find(|order| order.is_closed()) {
3235            let reason = format!("Cannot submit closed order {}", order.client_order_id());
3236            for order in &orders {
3237                self.emitter.emit_order_denied(order, &reason);
3238            }
3239            return Ok(());
3240        }
3241
3242        let rpi = cmd
3243            .params
3244            .as_ref()
3245            .and_then(|params| params.get_bool("rpi"))
3246            .unwrap_or(false);
3247
3248        if rpi {
3249            let reason = "rpi is only supported for individual Binance Futures order submission";
3250            for order in &orders {
3251                self.emitter.emit_order_denied(order, reason);
3252            }
3253            return Ok(());
3254        }
3255
3256        let close_position = cmd
3257            .params
3258            .as_ref()
3259            .and_then(|p| p.get_bool(PARAMS_CLOSE_POSITION))
3260            .unwrap_or(false);
3261        let price_match = match cmd
3262            .params
3263            .as_ref()
3264            .and_then(|p| p.get_str("price_match"))
3265            .map(BinancePriceMatch::from_param)
3266            .transpose()
3267        {
3268            Ok(price_match) => price_match,
3269            Err(e) => {
3270                for order in &orders {
3271                    self.emitter.emit_order_denied(order, &e.to_string());
3272                }
3273                return Ok(());
3274            }
3275        };
3276
3277        let batch_items = match build_futures_order_list_batch(
3278            &orders,
3279            self.is_hedge_mode(),
3280            close_position,
3281            price_match,
3282            self.product_type,
3283            self.config.use_gtd,
3284            self.clock.get_time_ns(),
3285        ) {
3286            Ok(batch_items) => batch_items,
3287            Err(reason) => {
3288                for order in &orders {
3289                    self.emitter.emit_order_denied(order, &reason);
3290                }
3291                return Ok(());
3292            }
3293        };
3294
3295        let venue_position_ids = orders
3296            .iter()
3297            .map(|order| {
3298                resolve_order_position_identity(
3299                    self.is_hedge_mode(),
3300                    self.config.use_position_ids,
3301                    order,
3302                )
3303                .map(|(_, venue_position_id)| venue_position_id)
3304            })
3305            .collect::<Result<Vec<_>, OrderDeniedReason>>()
3306            .and_then(|ids| {
3307                for id in &ids {
3308                    validate_submit_position_id(cmd.position_id, *id)?;
3309                }
3310                Ok(ids)
3311            });
3312        let venue_position_ids = match venue_position_ids {
3313            Ok(ids) => ids,
3314            Err(reason) => {
3315                for order in &orders {
3316                    self.emitter.emit_order_denied(order, &reason.to_string());
3317                }
3318                return Ok(());
3319            }
3320        };
3321
3322        for (order, venue_position_id) in orders.iter().zip(venue_position_ids) {
3323            self.dispatch_state.order_identities.insert(
3324                order.client_order_id(),
3325                OrderIdentity {
3326                    instrument_id: order.instrument_id(),
3327                    strategy_id: order.strategy_id(),
3328                    order_side: order.order_side(),
3329                    order_type: order.order_type(),
3330                    price: order.price(),
3331                    quantity: order.quantity(),
3332                    venue_position_id,
3333                },
3334            );
3335            self.emitter.emit_order_submitted(order);
3336        }
3337
3338        let http_client = self.http_client.clone();
3339        let emitter = self.emitter.clone();
3340        let trader_id = self.core.trader_id;
3341        let account_id = self.core.account_id;
3342        let clock = self.clock;
3343        let dispatch_state = self.dispatch_state.clone();
3344
3345        self.spawn_task("submit_order_list", async move {
3346            match http_client.submit_order_list(&batch_items).await {
3347                Ok(results) => {
3348                    for (order, result) in orders.iter().zip(results.iter()) {
3349                        match result {
3350                            BatchOrderResult::Success(response) => {
3351                                log::debug!(
3352                                    "Order-list leg submit accepted: client_order_id={}, venue_order_id={}",
3353                                    order.client_order_id(),
3354                                    response.order_id
3355                                );
3356                            }
3357                            BatchOrderResult::Error(error) => {
3358                                let ts_now = clock.get_time_ns();
3359                                let client_order_id = order.client_order_id();
3360                                let rejected = OrderRejected::new(
3361                                    trader_id,
3362                                    order.strategy_id(),
3363                                    order.instrument_id(),
3364                                    client_order_id,
3365                                    account_id,
3366                                    format!(
3367                                        "submit-order-list-error: code={}, msg={}",
3368                                        error.code, error.msg
3369                                    )
3370                                    .into(),
3371                                    UUID4::new(),
3372                                    ts_now,
3373                                    ts_now,
3374                                    false,
3375                                    false,
3376                                );
3377                                dispatch_state.cleanup_terminal(client_order_id);
3378                                emitter.send_order_event(OrderEventAny::Rejected(rejected));
3379                            }
3380                        }
3381                    }
3382                }
3383                Err(e) => {
3384                    let e = anyhow::Error::new(e);
3385                    let failure = e.downcast_ref::<BinanceFuturesHttpError>().map_or_else(
3386                        || CommandFailure::Ambiguous(e.to_string()),
3387                        classify_futures_http_failure,
3388                    );
3389
3390                    match failure {
3391                        CommandFailure::Ambiguous(reason) => {
3392                            log::warn!(
3393                                "Ambiguous order-list submit failure, awaiting reconciliation: {reason}"
3394                            );
3395                        }
3396                        CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
3397                            let ts_now = clock.get_time_ns();
3398                            let due_post_only = classify_submit_order_error(&e);
3399                            let reason = sanitize_reason(&reason);
3400
3401                            for order in &orders {
3402                                let client_order_id = order.client_order_id();
3403                                let rejected = OrderRejected::new(
3404                                    trader_id,
3405                                    order.strategy_id(),
3406                                    order.instrument_id(),
3407                                    client_order_id,
3408                                    account_id,
3409                                    format!("submit-order-list-error: {reason}").into(),
3410                                    UUID4::new(),
3411                                    ts_now,
3412                                    ts_now,
3413                                    false,
3414                                    due_post_only,
3415                                );
3416                                dispatch_state.cleanup_terminal(client_order_id);
3417                                emitter.send_order_event(OrderEventAny::Rejected(rejected));
3418                            }
3419                        }
3420                    }
3421
3422                    return Err(e);
3423                }
3424            }
3425            Ok(())
3426        });
3427
3428        Ok(())
3429    }
3430
3431    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
3432        let order = {
3433            let cache = self.core.cache();
3434            cache.order(&cmd.client_order_id).map(|o| o.clone())
3435        };
3436
3437        let Some(order) = order else {
3438            log::warn!(
3439                "Cannot modify order {}: not found in cache",
3440                cmd.client_order_id
3441            );
3442            let ts_init = self.clock.get_time_ns();
3443
3444            let rejected = OrderModifyRejected::new(
3445                self.core.trader_id,
3446                cmd.strategy_id,
3447                cmd.instrument_id,
3448                cmd.client_order_id,
3449                "Order not found in cache for modify".into(),
3450                UUID4::new(),
3451                ts_init, // no venue timestamp, rejected locally
3452                ts_init,
3453                false,
3454                cmd.venue_order_id,
3455                Some(self.core.account_id),
3456            );
3457
3458            self.emitter
3459                .send_order_event(OrderEventAny::ModifyRejected(rejected));
3460            return Ok(());
3461        };
3462
3463        let http_client = self.http_client.clone();
3464        let emitter = self.emitter.clone();
3465        let trader_id = self.core.trader_id;
3466        let account_id = self.core.account_id;
3467        let instrument_id = cmd.instrument_id;
3468        let venue_order_id = cmd.venue_order_id;
3469        let client_order_id = Some(cmd.client_order_id);
3470        let order_side = order.order_side();
3471        let quantity = cmd.quantity.unwrap_or_else(|| order.quantity());
3472        let price = cmd.price.or_else(|| order.price());
3473
3474        let Some(price) = price else {
3475            log::warn!(
3476                "Cannot modify order {}: price required",
3477                cmd.client_order_id
3478            );
3479            let ts_init = self.clock.get_time_ns();
3480
3481            let rejected = OrderModifyRejected::new(
3482                self.core.trader_id,
3483                cmd.strategy_id,
3484                cmd.instrument_id,
3485                cmd.client_order_id,
3486                "Price required for order modification".into(),
3487                UUID4::new(),
3488                ts_init, // no venue timestamp, rejected locally
3489                ts_init,
3490                false,
3491                cmd.venue_order_id,
3492                Some(self.core.account_id),
3493            );
3494
3495            self.emitter
3496                .send_order_event(OrderEventAny::ModifyRejected(rejected));
3497            return Ok(());
3498        };
3499        let command = cmd;
3500        let clock = self.clock;
3501
3502        if self.ws_trading_active() {
3503            let ws_client = self.ws_trading_client.as_ref().unwrap().clone();
3504            let dispatch_state = self.dispatch_state.clone();
3505
3506            let binance_side = BinanceSide::try_from(order_side)?;
3507            let orig_client_order_id =
3508                client_order_id.map(|id| encode_broker_id(&id, BINANCE_NAUTILUS_FUTURES_BROKER_ID));
3509
3510            let mut modify_builder = BinanceModifyOrderParamsBuilder::default();
3511            modify_builder
3512                .symbol(format_binance_symbol(&instrument_id))
3513                .side(binance_side)
3514                .quantity(quantity.to_string())
3515                .price(price.to_string());
3516
3517            if let Some(venue_id) = venue_order_id {
3518                let order_id: i64 = venue_id
3519                    .inner()
3520                    .parse()
3521                    .context("failed to parse venue_order_id as numeric")?;
3522                modify_builder.order_id(order_id);
3523            }
3524
3525            if let Some(client_id) = orig_client_order_id {
3526                modify_builder.orig_client_order_id(client_id);
3527            }
3528
3529            let params = modify_builder
3530                .build()
3531                .context("failed to build modify params")?;
3532
3533            // Pre-register before sending to avoid response racing the insert
3534            let request_id = ws_client.next_request_id();
3535            dispatch_state.pending_requests.insert(
3536                request_id.clone(),
3537                PendingRequest {
3538                    client_order_id: command.client_order_id,
3539                    venue_order_id,
3540                    operation: PendingOperation::Modify,
3541                },
3542            );
3543
3544            self.spawn_task("modify_order_ws", async move {
3545                if let Err(e) = ws_client
3546                    .modify_order_with_id(request_id.clone(), params)
3547                    .await
3548                {
3549                    dispatch_state.pending_requests.remove(&request_id);
3550                    log::error!(
3551                        "WS modify request failed for {}: {e}",
3552                        command.client_order_id
3553                    );
3554                    anyhow::bail!("WS modify order failed: {e}");
3555                }
3556                Ok(())
3557            });
3558
3559            return Ok(());
3560        }
3561
3562        let dispatch_state = self.dispatch_state.clone();
3563        self.spawn_task("modify_order", async move {
3564            let result = http_client
3565                .modify_order(
3566                    account_id,
3567                    instrument_id,
3568                    venue_order_id,
3569                    client_order_id,
3570                    order_side,
3571                    quantity,
3572                    price,
3573                )
3574                .await;
3575
3576            match result {
3577                Ok(report) => {
3578                    if !dispatch_state.record_order_update(command.client_order_id, report.venue_order_id, quantity, price, None) {
3579                        return Ok(());
3580                    }
3581                    let ts_now = clock.get_time_ns();
3582                    let updated_event = OrderUpdated::new(
3583                        trader_id,
3584                        command.strategy_id,
3585                        command.instrument_id,
3586                        command.client_order_id,
3587                        quantity,
3588                        UUID4::new(),
3589                        ts_now,
3590                        ts_now,
3591                        false,
3592                        Some(report.venue_order_id),
3593                        Some(account_id),
3594                        Some(price),
3595                        None,
3596                        None,
3597                        false, // is_quote_quantity
3598                    );
3599
3600                    emitter.send_order_event(OrderEventAny::Updated(updated_event));
3601                }
3602                Err(e) => {
3603                    let failure = e.downcast_ref::<BinanceFuturesHttpError>().map_or_else(
3604                        || CommandFailure::Ambiguous(e.to_string()),
3605                        classify_futures_http_failure,
3606                    );
3607
3608                    match failure {
3609                        CommandFailure::Ambiguous(reason) => {
3610                            log::warn!(
3611                                "Ambiguous modify failure for {}, awaiting reconciliation: {reason}",
3612                                command.client_order_id
3613                            );
3614                        }
3615                        CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
3616                            let ts_now = clock.get_time_ns();
3617
3618                            let rejected = OrderModifyRejected::new(
3619                                trader_id,
3620                                command.strategy_id,
3621                                command.instrument_id,
3622                                command.client_order_id,
3623                                format!("modify-order-error: {}", sanitize_reason(&reason)).into(),
3624                                UUID4::new(),
3625                                ts_now,
3626                                ts_now,
3627                                false,
3628                                command.venue_order_id,
3629                                Some(account_id),
3630                            );
3631
3632                            emitter.send_order_event(OrderEventAny::ModifyRejected(rejected));
3633                        }
3634                    }
3635
3636                    anyhow::bail!("Modify order failed: {e}");
3637                }
3638            }
3639
3640            Ok(())
3641        });
3642
3643        Ok(())
3644    }
3645
3646    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
3647        self.cancel_order_internal(&cmd);
3648        Ok(())
3649    }
3650
3651    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
3652        if cmd.order_side.is_some() {
3653            return self.cancel_all_orders_for_side(&cmd);
3654        }
3655
3656        let http_client = self.http_client.clone();
3657        let instrument_id = cmd.instrument_id;
3658
3659        // USD-M Futures WS Trading API does not expose an openOrders.cancelAll
3660        // method, so regular and algo cancel-all both go through HTTP.
3661        self.spawn_task("cancel_all_orders", async move {
3662            match http_client.cancel_all_orders(instrument_id).await {
3663                Ok(_) => {
3664                    log::debug!("Cancel all regular orders request accepted for {instrument_id}");
3665                }
3666                Err(e) => {
3667                    log::error!("Failed to cancel all regular orders for {instrument_id}: {e}");
3668                }
3669            }
3670
3671            match http_client.cancel_all_algo_orders(instrument_id).await {
3672                Ok(()) => {
3673                    log::debug!("Cancel all algo orders request accepted for {instrument_id}");
3674                }
3675                Err(e) => {
3676                    log::error!("Failed to cancel all algo orders for {instrument_id}: {e}");
3677                }
3678            }
3679
3680            Ok(())
3681        });
3682
3683        Ok(())
3684    }
3685
3686    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
3687        const BATCH_SIZE: usize = 10;
3688
3689        if cmd.cancels.is_empty() {
3690            return Ok(());
3691        }
3692
3693        let http_client = self.http_client.clone();
3694        let command = cmd;
3695
3696        let emitter = self.emitter.clone();
3697        let trader_id = self.core.trader_id;
3698        let account_id = self.core.account_id;
3699        let clock = self.clock;
3700
3701        self.spawn_task("batch_cancel_orders", async move {
3702            let symbol = format_binance_symbol(&command.instrument_id);
3703
3704            for chunk in command.cancels.chunks(BATCH_SIZE) {
3705                let mut order_id_batch = Vec::new();
3706                let mut client_order_id_batch = Vec::new();
3707
3708                for cancel in chunk {
3709                    if let Some(venue_order_id) = cancel.venue_order_id {
3710                        let order_id = venue_order_id.inner().parse::<i64>().unwrap_or(0);
3711                        if order_id != 0 {
3712                            order_id_batch.push((
3713                                BatchCancelItem::by_order_id(symbol.clone(), order_id),
3714                                cancel.clone(),
3715                            ));
3716                            continue;
3717                        }
3718                    }
3719
3720                    client_order_id_batch.push((
3721                        BatchCancelItem::by_client_order_id(
3722                            symbol.clone(),
3723                            encode_broker_id(
3724                                &cancel.client_order_id,
3725                                BINANCE_NAUTILUS_FUTURES_BROKER_ID,
3726                            ),
3727                        ),
3728                        cancel.clone(),
3729                    ));
3730                }
3731
3732                for batch in [order_id_batch, client_order_id_batch] {
3733                    if batch.is_empty() {
3734                        continue;
3735                    }
3736
3737                    let batch_len = batch.len();
3738                    let (batch_items, batch_cancels): (Vec<_>, Vec<_>) =
3739                        batch.into_iter().unzip();
3740
3741                    match http_client.batch_cancel_orders(&batch_items).await {
3742                        Ok(results) => {
3743                            for (cancel, result) in batch_cancels.iter().zip(results.iter()) {
3744                                match result {
3745                                    BatchOrderResult::Success(response) => {
3746                                        let venue_order_id =
3747                                            VenueOrderId::new(response.order_id.to_string());
3748                                        let canceled_event = OrderCanceled::new(
3749                                            trader_id,
3750                                            cancel.strategy_id,
3751                                            cancel.instrument_id,
3752                                            cancel.client_order_id,
3753                                            UUID4::new(),
3754                                            cancel.ts_init,
3755                                            clock.get_time_ns(),
3756                                            false,
3757                                            Some(venue_order_id),
3758                                            Some(account_id),
3759                                            None,
3760                                        );
3761
3762                                        emitter.send_order_event(OrderEventAny::Canceled(
3763                                            canceled_event,
3764                                        ));
3765                                    }
3766                                    BatchOrderResult::Error(error) => {
3767                                        let rejected = OrderCancelRejected::new(
3768                                            trader_id,
3769                                            cancel.strategy_id,
3770                                            cancel.instrument_id,
3771                                            cancel.client_order_id,
3772                                            format!(
3773                                                "batch-cancel-error: code={}, msg={}",
3774                                                error.code, error.msg
3775                                            )
3776                                            .into(),
3777                                            UUID4::new(),
3778                                            clock.get_time_ns(),
3779                                            cancel.ts_init,
3780                                            false,
3781                                            cancel.venue_order_id,
3782                                            Some(account_id),
3783                                        );
3784
3785                                        emitter.send_order_event(OrderEventAny::CancelRejected(
3786                                            rejected,
3787                                        ));
3788                                    }
3789                                }
3790                            }
3791                        }
3792                        Err(e) => {
3793                            // Log per classification and continue with the remaining chunks:
3794                            // a whole-request failure says nothing about unsent sibling chunks
3795                            match classify_futures_http_failure(&e) {
3796                                CommandFailure::NotSent(reason) => {
3797                                    log::warn!(
3798                                        "Batch cancel command failed before sending for {batch_len} orders: {reason}",
3799                                    );
3800                                }
3801                                CommandFailure::VenueRejected(reason)
3802                                | CommandFailure::Ambiguous(reason) => {
3803                                    log::warn!(
3804                                        "Batch cancel request failure for {batch_len} orders, awaiting reconciliation: {reason}",
3805                                    );
3806                                }
3807                            }
3808                        }
3809                    }
3810                }
3811            }
3812
3813            Ok(())
3814        });
3815
3816        Ok(())
3817    }
3818}
3819
3820fn validate_order(
3821    client: &BinanceFuturesExecutionClient,
3822    cmd: &SubmitOrder,
3823    order: &OrderAny,
3824) -> Result<ValidatedOrder, OrderDeniedReason> {
3825    order_type_to_binance_futures(order.order_type()).map_err(|_| {
3826        OrderDeniedReason::UnsupportedOrderType {
3827            order_type: order.order_type(),
3828        }
3829    })?;
3830
3831    if let Some(offset_type) = order.trailing_offset_type() {
3832        if offset_type != TrailingOffsetType::BasisPoints {
3833            return Err(OrderDeniedReason::UnsupportedTrailingOffsetType { offset_type });
3834        }
3835
3836        if let Some(offset) = order.trailing_offset() {
3837            trailing_offset_to_callback_rate(offset).map_err(|e| {
3838                OrderDeniedReason::ValidationFailed {
3839                    detail: format!("invalid trailing offset {offset}: {e}"),
3840                }
3841            })?;
3842        }
3843    }
3844
3845    let close_position = cmd
3846        .params
3847        .as_ref()
3848        .and_then(|params| params.get_bool(PARAMS_CLOSE_POSITION))
3849        .unwrap_or(false);
3850
3851    let rpi = cmd
3852        .params
3853        .as_ref()
3854        .and_then(|params| params.get_bool("rpi"))
3855        .unwrap_or(false);
3856
3857    if rpi {
3858        if client.product_type != BinanceProductType::UsdM {
3859            return Err(OrderDeniedReason::ValidationFailed {
3860                detail: "rpi is only supported for Binance USD-M Futures".to_string(),
3861            });
3862        }
3863
3864        if order.order_type() != OrderType::Limit {
3865            return Err(OrderDeniedReason::ValidationFailed {
3866                detail: "rpi is only supported for LIMIT orders".to_string(),
3867            });
3868        }
3869
3870        if !order.is_post_only() {
3871            return Err(OrderDeniedReason::ValidationFailed {
3872                detail: "rpi requires post_only=true".to_string(),
3873            });
3874        }
3875    }
3876
3877    if close_position {
3878        let order_type = order.order_type();
3879
3880        if !order.is_reduce_only() {
3881            return Err(OrderDeniedReason::ValidationFailed {
3882                detail: "`close_position` requires `reduce_only=true` on the Nautilus order"
3883                    .to_string(),
3884            });
3885        }
3886
3887        if !matches!(
3888            order_type,
3889            OrderType::StopMarket | OrderType::MarketIfTouched
3890        ) {
3891            return Err(OrderDeniedReason::ValidationFailed {
3892                detail: format!(
3893                    "`close_position` is not supported for order type {order_type:?} on Binance"
3894                ),
3895            });
3896        }
3897    }
3898
3899    if let Some(price_match) = cmd
3900        .params
3901        .as_ref()
3902        .and_then(|params| params.get_str("price_match"))
3903    {
3904        BinancePriceMatch::from_param(price_match).map_err(|e| {
3905            OrderDeniedReason::ValidationFailed {
3906                detail: format!("invalid price_match '{price_match}': {e}"),
3907            }
3908        })?;
3909        let order_type = order.order_type();
3910
3911        if order.is_post_only() {
3912            return Err(OrderDeniedReason::ValidationFailed {
3913                detail: "price_match cannot be combined with post-only orders".to_string(),
3914            });
3915        }
3916
3917        if order_type != OrderType::Limit {
3918            return Err(OrderDeniedReason::ValidationFailed {
3919                detail: format!("price_match is not supported for order type {order_type:?}"),
3920            });
3921        }
3922    }
3923
3924    let lifetime = determine_futures_order_lifetime(
3925        client.product_type,
3926        order.order_type(),
3927        order.time_in_force(),
3928        order.expire_time(),
3929        order.is_post_only(),
3930        client.config.use_gtd,
3931        client.clock.get_time_ns(),
3932    )?;
3933
3934    if !order.is_post_only() || is_algo_order_type(order.order_type()) {
3935        BinanceTimeInForce::try_from(lifetime.time_in_force)
3936            .map_err(|_| OrderDeniedReason::UnsupportedTimeInForce(order.time_in_force()))?;
3937    }
3938    let (position_side, venue_position_id) = resolve_order_position_identity(
3939        client.is_hedge_mode(),
3940        client.config.use_position_ids,
3941        order,
3942    )?;
3943    validate_submit_position_id(cmd.position_id, venue_position_id)?;
3944
3945    Ok(ValidatedOrder {
3946        lifetime,
3947        position_side,
3948        venue_position_id,
3949    })
3950}
3951
3952struct ValidatedOrder {
3953    lifetime: FuturesOrderLifetime,
3954    position_side: Option<BinancePositionSide>,
3955    venue_position_id: Option<PositionId>,
3956}
3957
3958#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3959enum BinanceFuturesAlgoLookup {
3960    Skip,
3961    AlgoId,
3962    ClientAlgoId,
3963}
3964
3965#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3966struct FuturesOrderLifetime {
3967    time_in_force: TimeInForce,
3968    good_till_date: Option<i64>,
3969}
3970
3971struct OpenOrderStatusReport {
3972    report: OrderStatusReport,
3973    quantity_free_close_position_side: Option<PositionSide>,
3974}
3975
3976#[allow(
3977    clippy::too_many_arguments,
3978    reason = "the converter receives one cohesive set of report conversion inputs"
3979)]
3980fn create_algo_order_status_report(
3981    result: &BinanceFuturesAlgoOrderQueryResult,
3982    account_id: AccountId,
3983    instrument_id: InstrumentId,
3984    price_precision: u8,
3985    size_precision: u8,
3986    treat_expired_as_canceled: bool,
3987    use_position_ids: bool,
3988    ts_init: UnixNanos,
3989) -> anyhow::Result<OrderStatusReport> {
3990    let position_side = result
3991        .actual
3992        .as_ref()
3993        .and_then(|actual| actual.position_side)
3994        .or(result.algo.position_side);
3995    let venue_position_id = make_venue_position_id(use_position_ids, instrument_id, position_side)?;
3996
3997    if let Some(actual) = result.actual.as_ref() {
3998        match result.algo.to_order_status_report_with_actual(
3999            actual,
4000            account_id,
4001            instrument_id,
4002            price_precision,
4003            size_precision,
4004            treat_expired_as_canceled,
4005            ts_init,
4006        ) {
4007            Ok(report) => return Ok(with_venue_position_id(report, venue_position_id)),
4008            Err(e) => {
4009                log::warn!(
4010                    "Failed to convert matching-engine enrichment for algo order {}: {e}; falling back to Algo Service report",
4011                    result.algo.algo_id
4012                );
4013            }
4014        }
4015    }
4016
4017    let report = result.algo.to_order_status_report(
4018        account_id,
4019        instrument_id,
4020        price_precision,
4021        size_precision,
4022        ts_init,
4023    )?;
4024    Ok(with_venue_position_id(report, venue_position_id))
4025}
4026
4027fn user_trades_complete_start(ts_init: UnixNanos, ts_now: UnixNanos) -> UnixNanos {
4028    let oldest_reference = ts_now.saturating_sub(USER_TRADES_MAX_TS_INIT_AGE);
4029    ts_init
4030        .max(oldest_reference)
4031        .saturating_sub(USER_TRADES_COMPLETE_INTERVAL)
4032}
4033
4034fn should_use_algo_cancel(is_algo: bool, is_triggered: bool, has_promoted_id: bool) -> bool {
4035    is_algo && !is_triggered && !has_promoted_id
4036}
4037
4038fn cancel_venue_order_id(
4039    is_algo: bool,
4040    venue_order_id: Option<VenueOrderId>,
4041    promoted_algo_order_id: Option<VenueOrderId>,
4042) -> Option<VenueOrderId> {
4043    if is_algo {
4044        promoted_algo_order_id
4045    } else {
4046        venue_order_id
4047    }
4048}
4049
4050fn is_instrument_for_product(instrument: &InstrumentAny, product_type: BinanceProductType) -> bool {
4051    match product_type {
4052        BinanceProductType::UsdM => {
4053            matches!(
4054                instrument,
4055                InstrumentAny::CryptoFuture(_)
4056                    | InstrumentAny::CryptoPerpetual(_)
4057                    | InstrumentAny::PerpetualContract(_)
4058            ) && !instrument.is_inverse()
4059        }
4060        BinanceProductType::CoinM => {
4061            matches!(
4062                instrument,
4063                InstrumentAny::CryptoFuture(_) | InstrumentAny::CryptoPerpetual(_)
4064            ) && instrument.is_inverse()
4065        }
4066        _ => false,
4067    }
4068}
4069
4070#[cfg(test)]
4071mod tests {
4072    use std::{
4073        cell::RefCell,
4074        collections::HashMap,
4075        rc::Rc,
4076        sync::{
4077            Arc,
4078            atomic::{AtomicUsize, Ordering},
4079        },
4080    };
4081
4082    use nautilus_common::{cache::Cache, clients::ExecutionClient, messages::ExecutionEvent};
4083    use nautilus_live::ExecutionClientCore;
4084    use nautilus_model::{
4085        enums::{AccountType, OmsType, OrderSide, OrderType},
4086        events::OrderEventAny,
4087        identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
4088        instruments::stubs::{
4089            crypto_future_btcusdt, crypto_perpetual_ethusdt, currency_pair_btcusdt,
4090            perpetual_contract_eurusd, xbtusd_bitmex,
4091        },
4092        orders::{OrderTestBuilder, stubs::TestOrderEventStubs},
4093        types::{Price, Quantity},
4094    };
4095    use rstest::rstest;
4096
4097    use super::*;
4098    use crate::{
4099        common::{
4100            consts::{
4101                BINANCE_CLIENT_ID, BINANCE_STATUS_UNKNOWN_CODE, BINANCE_UNEXPECTED_RESPONSE_CODE,
4102                BINANCE_VENUE,
4103            },
4104            enums::BinanceProductType,
4105            testing::load_fixture_string,
4106        },
4107        config::BinanceExecutionClientConfig,
4108    };
4109
4110    fn http_error(code: i64) -> anyhow::Error {
4111        anyhow::Error::new(BinanceFuturesHttpError::BinanceError {
4112            code,
4113            message: format!("test error {code}"),
4114            status: 400,
4115            retry_after: None,
4116        })
4117    }
4118
4119    #[rstest]
4120    #[case(None, Some("BTCUSDT-PERP.BINANCE-LONG"))]
4121    #[case(Some("BTCUSDT-PERP.BINANCE-LONG"), Some("BTCUSDT-PERP.BINANCE-LONG"))]
4122    #[case(Some("P-VIRTUAL-LONG"), None)]
4123    fn test_validate_submit_position_id_accepts_compatible_identity(
4124        #[case] submitted_position_id: Option<&str>,
4125        #[case] venue_position_id: Option<&str>,
4126    ) {
4127        validate_submit_position_id(
4128            submitted_position_id.map(PositionId::from),
4129            venue_position_id.map(PositionId::from),
4130        )
4131        .unwrap();
4132    }
4133
4134    #[rstest]
4135    fn test_validate_submit_position_id_rejects_custom_venue_identity() {
4136        let error = validate_submit_position_id(
4137            Some(PositionId::from("P-VIRTUAL-LONG")),
4138            Some(PositionId::from("BTCUSDT-PERP.BINANCE-LONG")),
4139        )
4140        .unwrap_err();
4141
4142        assert_eq!(
4143            error.to_string(),
4144            "INVALID_POSITION_ID: P-VIRTUAL-LONG; conflicts with canonical Binance Futures venue position ID BTCUSDT-PERP.BINANCE-LONG; omit position_id while use_position_ids=true, or set use_position_ids=false for virtual hedging",
4145        );
4146    }
4147
4148    #[rstest]
4149    fn test_create_account_state_preserves_info_decimal_values() {
4150        let json = load_fixture_string("futures/http_json/account_info_v2.json");
4151        let mut account_info: BinanceFuturesAccountInfo = serde_json::from_str(&json).unwrap();
4152        account_info.total_wallet_balance = Some("1.0000000000000001".parse().unwrap());
4153        account_info.total_margin_balance = Some("2.0000000000000002".parse().unwrap());
4154        account_info.total_initial_margin = Some("3.0000000000000003".parse().unwrap());
4155        account_info.total_maint_margin = Some("4.0000000000000004".parse().unwrap());
4156        account_info.total_unrealized_profit = Some("5.0000000000000005".parse().unwrap());
4157        account_info.total_cross_wallet_balance = Some("6.0000000000000006".parse().unwrap());
4158        account_info.total_cross_un_pnl = Some("7.0000000000000007".parse().unwrap());
4159        account_info.available_balance = Some("8.0000000000000008".parse().unwrap());
4160        account_info.max_withdraw_amount = Some("9.0000000000000009".parse().unwrap());
4161
4162        let state = BinanceFuturesExecutionClient::create_account_state_from(
4163            &account_info,
4164            AccountId::from("BINANCE-001"),
4165            AccountType::Margin,
4166            Currency::USDT(),
4167            get_atomic_clock_realtime(),
4168        );
4169
4170        let info = state.info.as_ref().unwrap();
4171        assert_eq!(info.len(), 9);
4172        assert_eq!(
4173            info.get_str("total_wallet_balance"),
4174            Some("1.0000000000000001")
4175        );
4176        assert_eq!(
4177            info.get_str("total_margin_balance"),
4178            Some("2.0000000000000002")
4179        );
4180        assert_eq!(
4181            info.get_str("total_initial_margin"),
4182            Some("3.0000000000000003")
4183        );
4184        assert_eq!(
4185            info.get_str("total_maint_margin"),
4186            Some("4.0000000000000004")
4187        );
4188        assert_eq!(
4189            info.get_str("total_unrealized_profit"),
4190            Some("5.0000000000000005")
4191        );
4192        assert_eq!(
4193            info.get_str("total_cross_wallet_balance"),
4194            Some("6.0000000000000006")
4195        );
4196        assert_eq!(
4197            info.get_str("total_cross_unpnl"),
4198            Some("7.0000000000000007")
4199        );
4200        assert_eq!(
4201            info.get_str("available_balance"),
4202            Some("8.0000000000000008")
4203        );
4204        assert_eq!(
4205            info.get_str("max_withdraw_amount"),
4206            Some("9.0000000000000009")
4207        );
4208    }
4209
4210    #[rstest]
4211    #[case::regular(false, false, false, false)]
4212    #[case::untriggered_algo(true, false, false, true)]
4213    #[case::triggered_algo(true, true, false, false)]
4214    #[case::promoted_algo(true, false, true, false)]
4215    fn test_should_use_algo_cancel(
4216        #[case] is_algo: bool,
4217        #[case] is_triggered: bool,
4218        #[case] has_promoted_id: bool,
4219        #[case] expected: bool,
4220    ) {
4221        assert_eq!(
4222            should_use_algo_cancel(is_algo, is_triggered, has_promoted_id),
4223            expected
4224        );
4225    }
4226
4227    #[rstest]
4228    #[case::not_close(BinanceSide::Sell, Some(BinancePositionSide::Long), false, None, None)]
4229    #[case::positive_quantity(
4230        BinanceSide::Sell,
4231        Some(BinancePositionSide::Long),
4232        true,
4233        Some("0.001"),
4234        None
4235    )]
4236    #[case::hedge_long(
4237        BinanceSide::Sell,
4238        Some(BinancePositionSide::Long),
4239        true,
4240        None,
4241        Some(PositionSide::Long)
4242    )]
4243    #[case::hedge_long_zero(
4244        BinanceSide::Sell,
4245        Some(BinancePositionSide::Long),
4246        true,
4247        Some("0.0000"),
4248        Some(PositionSide::Long)
4249    )]
4250    #[case::hedge_short(
4251        BinanceSide::Buy,
4252        Some(BinancePositionSide::Short),
4253        true,
4254        None,
4255        Some(PositionSide::Short)
4256    )]
4257    #[case::invalid_hedge_long(BinanceSide::Buy, Some(BinancePositionSide::Long), true, None, None)]
4258    #[case::invalid_hedge_short(
4259        BinanceSide::Sell,
4260        Some(BinancePositionSide::Short),
4261        true,
4262        None,
4263        None
4264    )]
4265    #[case::one_way_long(
4266        BinanceSide::Sell,
4267        Some(BinancePositionSide::Both),
4268        true,
4269        None,
4270        Some(PositionSide::Long)
4271    )]
4272    #[case::one_way_short(
4273        BinanceSide::Buy,
4274        Some(BinancePositionSide::Both),
4275        true,
4276        None,
4277        Some(PositionSide::Short)
4278    )]
4279    #[case::missing_side(BinanceSide::Buy, None, true, None, Some(PositionSide::Short))]
4280    #[case::unknown_side(
4281        BinanceSide::Sell,
4282        Some(BinancePositionSide::Unknown),
4283        true,
4284        None,
4285        None
4286    )]
4287    fn test_quantity_free_close_position_side(
4288        #[case] side: BinanceSide,
4289        #[case] position_side: Option<BinancePositionSide>,
4290        #[case] close_position: bool,
4291        #[case] quantity: Option<&str>,
4292        #[case] expected: Option<PositionSide>,
4293    ) {
4294        let json = load_fixture_string("futures/http_json/open_algo_orders.json");
4295        let mut orders: Vec<BinanceFuturesAlgoOrder> = serde_json::from_str(&json).unwrap();
4296        let mut order = orders.remove(0);
4297        order.side = side;
4298        order.position_side = position_side;
4299        order.close_position = Some(close_position);
4300        order.quantity = quantity.map(str::to_string);
4301
4302        assert_eq!(quantity_free_close_position_side(&order), expected);
4303    }
4304
4305    #[rstest]
4306    #[case::regular(
4307        false,
4308        Some(VenueOrderId::from("8886774")),
4309        None,
4310        Some(VenueOrderId::from("8886774"))
4311    )]
4312    #[case::unpromoted_algo(true, Some(VenueOrderId::from("2148719")), None, None)]
4313    #[case::promoted(
4314        true,
4315        Some(VenueOrderId::from("2148719")),
4316        Some(VenueOrderId::from("22542179")),
4317        Some(VenueOrderId::from("22542179"))
4318    )]
4319    fn test_cancel_venue_order_id(
4320        #[case] is_algo: bool,
4321        #[case] venue_order_id: Option<VenueOrderId>,
4322        #[case] promoted_algo_order_id: Option<VenueOrderId>,
4323        #[case] expected: Option<VenueOrderId>,
4324    ) {
4325        assert_eq!(
4326            cancel_venue_order_id(is_algo, venue_order_id, promoted_algo_order_id),
4327            expected
4328        );
4329    }
4330
4331    #[rstest]
4332    fn test_classify_submit_order_error_gtx_is_post_only() {
4333        let err = http_error(BINANCE_GTX_ORDER_REJECT_CODE);
4334        assert!(classify_submit_order_error(&err));
4335    }
4336
4337    #[rstest]
4338    fn test_futures_order_lifetime_encodes_valid_usdm_gtd() {
4339        let ts_now = UnixNanos::from_seconds(1_700_000_000);
4340        let expire_time = UnixNanos::from_seconds(1_700_000_601);
4341
4342        let lifetime = determine_futures_order_lifetime(
4343            BinanceProductType::UsdM,
4344            OrderType::Limit,
4345            TimeInForce::Gtd,
4346            Some(expire_time),
4347            false,
4348            true,
4349            ts_now,
4350        )
4351        .unwrap();
4352
4353        assert_eq!(lifetime.time_in_force, TimeInForce::Gtd);
4354        assert_eq!(lifetime.good_till_date, Some(1_700_000_601_000));
4355    }
4356
4357    #[rstest]
4358    #[case::minimum(
4359        BinanceProductType::UsdM,
4360        OrderType::Limit,
4361        UnixNanos::from_seconds(1_700_000_600),
4362        false,
4363        "strictly greater"
4364    )]
4365    #[case::subsecond(
4366        BinanceProductType::UsdM,
4367        OrderType::Limit,
4368        UnixNanos::from(1_700_000_601_000_000_001),
4369        false,
4370        "whole-second precision"
4371    )]
4372    #[case::coin_m(
4373        BinanceProductType::CoinM,
4374        OrderType::Limit,
4375        UnixNanos::from_seconds(1_700_000_601),
4376        false,
4377        "does not support native GTD"
4378    )]
4379    #[case::market(
4380        BinanceProductType::UsdM,
4381        OrderType::Market,
4382        UnixNanos::from_seconds(1_700_000_601),
4383        false,
4384        "does not support GTD for order type Market"
4385    )]
4386    #[case::post_only(
4387        BinanceProductType::UsdM,
4388        OrderType::Limit,
4389        UnixNanos::from_seconds(1_700_000_601),
4390        true,
4391        "cannot be post-only"
4392    )]
4393    fn test_futures_order_lifetime_rejects_invalid_gtd(
4394        #[case] product_type: BinanceProductType,
4395        #[case] order_type: OrderType,
4396        #[case] expire_time: UnixNanos,
4397        #[case] post_only: bool,
4398        #[case] expected: &str,
4399    ) {
4400        let error = determine_futures_order_lifetime(
4401            product_type,
4402            order_type,
4403            TimeInForce::Gtd,
4404            Some(expire_time),
4405            post_only,
4406            true,
4407            UnixNanos::from_seconds(1_700_000_000),
4408        )
4409        .unwrap_err();
4410
4411        assert!(error.to_string().contains(expected));
4412    }
4413
4414    #[rstest]
4415    #[case::coin_m_limit(BinanceProductType::CoinM, OrderType::Limit, false)]
4416    #[case::usd_m_market(BinanceProductType::UsdM, OrderType::Market, false)]
4417    #[case::usd_m_post_only(BinanceProductType::UsdM, OrderType::Limit, true)]
4418    fn test_futures_order_lifetime_maps_locally_managed_gtd_to_gtc(
4419        #[case] product_type: BinanceProductType,
4420        #[case] order_type: OrderType,
4421        #[case] post_only: bool,
4422    ) {
4423        let lifetime = determine_futures_order_lifetime(
4424            product_type,
4425            order_type,
4426            TimeInForce::Gtd,
4427            Some(UnixNanos::from_seconds(1_700_000_601)),
4428            post_only,
4429            false,
4430            UnixNanos::from_seconds(1_700_000_000),
4431        )
4432        .unwrap();
4433
4434        assert_eq!(lifetime.time_in_force, TimeInForce::Gtc);
4435        assert_eq!(lifetime.good_till_date, None);
4436    }
4437
4438    #[rstest]
4439    fn test_futures_order_lifetime_requires_expire_time() {
4440        let error = determine_futures_order_lifetime(
4441            BinanceProductType::UsdM,
4442            OrderType::Limit,
4443            TimeInForce::Gtd,
4444            None,
4445            false,
4446            true,
4447            UnixNanos::from_seconds(1_700_000_000),
4448        )
4449        .unwrap_err();
4450
4451        assert_eq!(error, OrderDeniedReason::MissingExpireTime);
4452    }
4453
4454    #[rstest]
4455    fn test_futures_order_lifetime_maximum_exceeds_unix_nanos_range() {
4456        let maximum_unix_nanos_millis = u64::MAX / NANOSECONDS_IN_MILLISECOND;
4457
4458        assert!(maximum_unix_nanos_millis < BINANCE_GTD_MAX_MILLIS);
4459    }
4460
4461    #[rstest]
4462    fn test_classify_submit_order_error_dual_side_sync_is_not_post_only() {
4463        // -4531 is a hedge-mode/account-setup issue, not a post-only rejection.
4464        // Make sure the new classifier branch does not mark it as post-only.
4465        let err = http_error(BINANCE_FUTURES_DUAL_SIDE_SYNC_REJECT_CODE);
4466        assert!(!classify_submit_order_error(&err));
4467    }
4468
4469    #[rstest]
4470    fn test_classify_submit_order_error_other_venue_code_is_not_post_only() {
4471        let err = http_error(-2010);
4472        assert!(!classify_submit_order_error(&err));
4473    }
4474
4475    #[rstest]
4476    fn test_classify_submit_order_error_non_binance_error_is_not_post_only() {
4477        let err = anyhow::anyhow!("network failure");
4478        assert!(!classify_submit_order_error(&err));
4479    }
4480
4481    #[rstest]
4482    #[case(BINANCE_UNEXPECTED_RESPONSE_CODE)]
4483    #[case(BINANCE_STATUS_UNKNOWN_CODE)]
4484    fn test_unknown_status_submit_error_is_ambiguous(#[case] code: i64) {
4485        let err = http_error(code);
4486        assert!(matches!(
4487            classify_futures_http_failure(err.downcast_ref().unwrap()),
4488            CommandFailure::Ambiguous(_)
4489        ));
4490    }
4491
4492    #[rstest]
4493    fn test_other_structured_submit_error_is_not_ambiguous() {
4494        let err = http_error(BINANCE_GTX_ORDER_REJECT_CODE);
4495        assert!(matches!(
4496            classify_futures_http_failure(err.downcast_ref().unwrap()),
4497            CommandFailure::VenueRejected(_)
4498        ));
4499    }
4500
4501    #[rstest]
4502    fn test_instrument_product_matching_distinguishes_futures_products_from_spot() {
4503        let usdm = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
4504        let generic_perpetual = InstrumentAny::PerpetualContract(perpetual_contract_eurusd());
4505        let coinm = InstrumentAny::CryptoPerpetual(xbtusd_bitmex());
4506        let delivery =
4507            || crypto_future_btcusdt(2, 6, Price::from("0.01"), Quantity::from("0.000001"));
4508        let usdm_delivery = InstrumentAny::CryptoFuture(delivery());
4509        let mut coinm_delivery = delivery();
4510        coinm_delivery.is_inverse = true;
4511        let coinm_delivery = InstrumentAny::CryptoFuture(coinm_delivery);
4512        let spot = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4513
4514        assert!(is_instrument_for_product(&usdm, BinanceProductType::UsdM));
4515        assert!(!is_instrument_for_product(&usdm, BinanceProductType::CoinM));
4516        assert!(is_instrument_for_product(
4517            &generic_perpetual,
4518            BinanceProductType::UsdM
4519        ));
4520        assert!(!is_instrument_for_product(
4521            &generic_perpetual,
4522            BinanceProductType::CoinM
4523        ));
4524        assert!(is_instrument_for_product(&coinm, BinanceProductType::CoinM));
4525        assert!(!is_instrument_for_product(&coinm, BinanceProductType::UsdM));
4526        assert!(is_instrument_for_product(
4527            &usdm_delivery,
4528            BinanceProductType::UsdM
4529        ));
4530        assert!(!is_instrument_for_product(
4531            &usdm_delivery,
4532            BinanceProductType::CoinM
4533        ));
4534        assert!(is_instrument_for_product(
4535            &coinm_delivery,
4536            BinanceProductType::CoinM
4537        ));
4538        assert!(!is_instrument_for_product(
4539            &coinm_delivery,
4540            BinanceProductType::UsdM
4541        ));
4542        assert!(!is_instrument_for_product(&spot, BinanceProductType::UsdM));
4543        assert!(!is_instrument_for_product(&spot, BinanceProductType::CoinM));
4544    }
4545
4546    fn test_execution_client(
4547        base_url_http: String,
4548    ) -> (BinanceFuturesExecutionClient, Rc<RefCell<Cache>>) {
4549        let cache = Rc::new(RefCell::new(Cache::default()));
4550        let core = ExecutionClientCore::new(
4551            TraderId::from("TESTER-001"),
4552            *BINANCE_CLIENT_ID,
4553            *BINANCE_VENUE,
4554            OmsType::Hedging,
4555            AccountId::from("BINANCE-001"),
4556            AccountType::Margin,
4557            None,
4558            cache.clone(),
4559        );
4560        let config = BinanceExecutionClientConfig {
4561            product_type: BinanceProductType::UsdM,
4562            base_url_http: Some(base_url_http),
4563            use_ws_trading: false,
4564            api_key: Some("test_api_key".into()),
4565            api_secret: Some("test_api_secret".into()),
4566            ..Default::default()
4567        };
4568
4569        (
4570            BinanceFuturesExecutionClient::new(core, config).unwrap(),
4571            cache,
4572        )
4573    }
4574
4575    async fn wait_for_spawned_tasks(client: &BinanceFuturesExecutionClient) {
4576        for _ in 0..40 {
4577            if client.pending_tasks.all_finished() {
4578                return;
4579            }
4580
4581            tokio::time::sleep(Duration::from_millis(25)).await;
4582        }
4583
4584        panic!("timed out waiting for spawned Binance Futures execution tasks");
4585    }
4586
4587    struct MockVenueHits {
4588        batch: Arc<AtomicUsize>,
4589        algo_cancel: Arc<AtomicUsize>,
4590        cancel_all: Arc<AtomicUsize>,
4591    }
4592
4593    async fn start_batch_reject_server() -> (String, MockVenueHits) {
4594        let batch_hits = Arc::new(AtomicUsize::new(0));
4595        let batch_hits_clone = batch_hits.clone();
4596        let algo_hits = Arc::new(AtomicUsize::new(0));
4597        let algo_hits_clone = algo_hits.clone();
4598        let cancel_all_hits = Arc::new(AtomicUsize::new(0));
4599        let cancel_all_hits_clone = cancel_all_hits.clone();
4600        let app = axum::Router::new()
4601            .route(
4602                "/fapi/v1/batchOrders",
4603                axum::routing::delete(
4604                    move |axum::extract::Query(params): axum::extract::Query<
4605                        HashMap<String, String>,
4606                    >| {
4607                        let hits = batch_hits_clone.clone();
4608                        async move {
4609                            hits.fetch_add(1, Ordering::Relaxed);
4610                            let count = ["orderIdList", "origClientOrderIdList"]
4611                                .into_iter()
4612                                .filter_map(|key| params.get(key))
4613                                .filter_map(|value| {
4614                                    serde_json::from_str::<Vec<serde_json::Value>>(value).ok()
4615                                })
4616                                .map(|values| values.len())
4617                                .sum::<usize>()
4618                                .max(1);
4619                            let errors = (0..count)
4620                                .map(|_| {
4621                                    serde_json::json!({"code": -2011, "msg": "Unknown order sent"})
4622                                })
4623                                .collect::<Vec<_>>();
4624
4625                            axum::Json(errors)
4626                        }
4627                    },
4628                ),
4629            )
4630            .route(
4631                "/fapi/v1/algoOrder",
4632                axum::routing::delete(move || {
4633                    let hits = algo_hits_clone.clone();
4634                    async move {
4635                        hits.fetch_add(1, Ordering::Relaxed);
4636
4637                        (
4638                            axum::http::StatusCode::BAD_REQUEST,
4639                            axum::Json(
4640                                serde_json::json!({"code": -2011, "msg": "Unknown algo order sent"}),
4641                            ),
4642                        )
4643                    }
4644                }),
4645            )
4646            .route(
4647                "/fapi/v1/order",
4648                axum::routing::delete(|| async {
4649                    (
4650                        axum::http::StatusCode::BAD_REQUEST,
4651                        axum::Json(
4652                            serde_json::json!({"code": -2011, "msg": "Unknown order sent"}),
4653                        ),
4654                    )
4655                }),
4656            )
4657            .route(
4658                "/fapi/v1/allOpenOrders",
4659                axum::routing::delete(move || {
4660                    let hits = cancel_all_hits_clone.clone();
4661                    async move {
4662                        hits.fetch_add(1, Ordering::Relaxed);
4663
4664                        axum::Json(serde_json::json!({"code": 200, "msg": "success"}))
4665                    }
4666                }),
4667            );
4668        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4669        let addr = listener.local_addr().unwrap();
4670
4671        tokio::spawn(async move {
4672            axum::serve(listener, app.into_make_service())
4673                .await
4674                .unwrap();
4675        });
4676
4677        (
4678            format!("http://{addr}"),
4679            MockVenueHits {
4680                batch: batch_hits,
4681                algo_cancel: algo_hits,
4682                cancel_all: cancel_all_hits,
4683            },
4684        )
4685    }
4686
4687    #[allow(
4688        clippy::too_many_arguments,
4689        reason = "test helper takes one cohesive set of order construction inputs"
4690    )]
4691    fn add_open_order(
4692        cache: &Rc<RefCell<Cache>>,
4693        account_id: AccountId,
4694        index: usize,
4695        instrument_id: InstrumentId,
4696        owner: &str,
4697        side: OrderSide,
4698        order_type: OrderType,
4699        open: bool,
4700    ) -> OrderAny {
4701        let client_order_id = ClientOrderId::from(format!("O-CANCEL-ALL-{index}"));
4702        let mut builder = OrderTestBuilder::new(order_type);
4703        let order = builder
4704            .instrument_id(instrument_id)
4705            .client_order_id(client_order_id)
4706            .strategy_id(StrategyId::from(owner))
4707            .side(side)
4708            .quantity(Quantity::from("1"))
4709            .price(Price::from("10000.00"))
4710            .trigger_price(Price::from("9900.00"))
4711            .build();
4712        let venue_order_id = VenueOrderId::from(format!("{}", 1000 + index));
4713        let accepted = TestOrderEventStubs::accepted(&order, account_id, venue_order_id);
4714        cache
4715            .borrow_mut()
4716            .add_order(order, None, Some(*BINANCE_CLIENT_ID), false)
4717            .unwrap();
4718        let order = cache.borrow_mut().update_order(&accepted).unwrap();
4719
4720        if !open {
4721            let canceled = TestOrderEventStubs::canceled(&order, account_id, Some(venue_order_id));
4722            cache.borrow_mut().update_order(&canceled).unwrap();
4723        }
4724
4725        order
4726    }
4727
4728    #[rstest]
4729    #[case::buy(Some(OrderSide::Buy), vec![0, 2])]
4730    #[case::sell(Some(OrderSide::Sell), vec![1, 3])]
4731    #[tokio::test]
4732    async fn test_cancel_all_orders_filters_by_side_and_preserves_owners(
4733        #[case] order_side: Option<OrderSide>,
4734        #[case] expected_indices: Vec<usize>,
4735    ) {
4736        let (base_url, hits) = start_batch_reject_server().await;
4737        let (mut client, cache) = test_execution_client(base_url);
4738        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4739        client.emitter.set_sender(tx);
4740
4741        let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
4742        let other_instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
4743        let account_id = client.core.account_id;
4744        let mut orders = Vec::new();
4745
4746        for (index, (instrument, owner, side, open)) in [
4747            (instrument_id, "S-001", OrderSide::Buy, true),
4748            (instrument_id, "S-001", OrderSide::Sell, true),
4749            (instrument_id, "S-002", OrderSide::Buy, true),
4750            (instrument_id, "S-002", OrderSide::Sell, true),
4751            (other_instrument_id, "S-001", OrderSide::Buy, true),
4752            (other_instrument_id, "S-002", OrderSide::Sell, true),
4753            (instrument_id, "S-001", OrderSide::Buy, false),
4754            (instrument_id, "S-002", OrderSide::Sell, false),
4755        ]
4756        .into_iter()
4757        .enumerate()
4758        {
4759            orders.push(add_open_order(
4760                &cache,
4761                account_id,
4762                index,
4763                instrument,
4764                owner,
4765                side,
4766                OrderType::Limit,
4767                open,
4768            ));
4769        }
4770
4771        client
4772            .cancel_all_orders(CancelAllOrders::new(
4773                TraderId::from("TESTER-001"),
4774                Some(*BINANCE_CLIENT_ID),
4775                StrategyId::from("S-001"),
4776                instrument_id,
4777                order_side,
4778                UUID4::new(),
4779                UnixNanos::default(),
4780                None,
4781                None,
4782            ))
4783            .unwrap();
4784        wait_for_spawned_tasks(&client).await;
4785
4786        // Rejections expose per-order ownership in the outcomes
4787        assert_eq!(hits.batch.load(Ordering::Relaxed), 1);
4788        assert_eq!(hits.algo_cancel.load(Ordering::Relaxed), 0);
4789        assert_eq!(hits.cancel_all.load(Ordering::Relaxed), 0);
4790
4791        let mut actual = Vec::new();
4792
4793        for _ in &expected_indices {
4794            let event = rx.try_recv().expect("expected OrderCancelRejected event");
4795            match event {
4796                ExecutionEvent::Order(OrderEventAny::CancelRejected(rejected)) => {
4797                    actual.push((rejected.client_order_id, rejected.strategy_id));
4798                }
4799                event => panic!("expected OrderCancelRejected, was {event:?}"),
4800            }
4801        }
4802
4803        let mut expected: Vec<_> = expected_indices
4804            .iter()
4805            .map(|&index| {
4806                let order = &orders[index];
4807                (order.client_order_id(), order.strategy_id())
4808            })
4809            .collect();
4810
4811        actual.sort();
4812        expected.sort();
4813
4814        assert_eq!(actual, expected);
4815        assert!(rx.try_recv().is_err());
4816    }
4817
4818    #[rstest]
4819    #[tokio::test]
4820    async fn test_cancel_all_orders_routes_algo_orders_individually() {
4821        let (base_url, hits) = start_batch_reject_server().await;
4822        let (mut client, cache) = test_execution_client(base_url);
4823        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4824        client.emitter.set_sender(tx);
4825
4826        let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
4827        let account_id = client.core.account_id;
4828
4829        // Two algo types cover the split; opposite-side algo must be ignored
4830        let algo_buy_owner_a = add_open_order(
4831            &cache,
4832            account_id,
4833            0,
4834            instrument_id,
4835            "S-001",
4836            OrderSide::Buy,
4837            OrderType::StopMarket,
4838            true,
4839        );
4840        let algo_buy_owner_b = add_open_order(
4841            &cache,
4842            account_id,
4843            1,
4844            instrument_id,
4845            "S-002",
4846            OrderSide::Buy,
4847            OrderType::StopLimit,
4848            true,
4849        );
4850        let regular_buy = add_open_order(
4851            &cache,
4852            account_id,
4853            2,
4854            instrument_id,
4855            "S-002",
4856            OrderSide::Buy,
4857            OrderType::Limit,
4858            true,
4859        );
4860        add_open_order(
4861            &cache,
4862            account_id,
4863            3,
4864            instrument_id,
4865            "S-001",
4866            OrderSide::Sell,
4867            OrderType::StopMarket,
4868            true,
4869        );
4870
4871        client
4872            .cancel_all_orders(CancelAllOrders::new(
4873                TraderId::from("TESTER-001"),
4874                Some(*BINANCE_CLIENT_ID),
4875                StrategyId::from("S-001"),
4876                instrument_id,
4877                Some(OrderSide::Buy),
4878                UUID4::new(),
4879                UnixNanos::default(),
4880                None,
4881                None,
4882            ))
4883            .unwrap();
4884        wait_for_spawned_tasks(&client).await;
4885
4886        assert_eq!(hits.batch.load(Ordering::Relaxed), 1);
4887        assert_eq!(hits.algo_cancel.load(Ordering::Relaxed), 2);
4888        assert_eq!(hits.cancel_all.load(Ordering::Relaxed), 0);
4889
4890        let mut actual = Vec::new();
4891
4892        for _ in 0..3 {
4893            let event = rx.try_recv().expect("expected OrderCancelRejected event");
4894            match event {
4895                ExecutionEvent::Order(OrderEventAny::CancelRejected(rejected)) => {
4896                    actual.push((rejected.client_order_id, rejected.strategy_id));
4897                }
4898                event => panic!("expected OrderCancelRejected, was {event:?}"),
4899            }
4900        }
4901
4902        let mut expected = [algo_buy_owner_a, algo_buy_owner_b, regular_buy]
4903            .iter()
4904            .map(|order| (order.client_order_id(), order.strategy_id()))
4905            .collect::<Vec<_>>();
4906
4907        actual.sort();
4908        expected.sort();
4909
4910        assert_eq!(actual, expected);
4911        assert!(rx.try_recv().is_err());
4912    }
4913
4914    #[rstest]
4915    #[tokio::test]
4916    async fn test_cancel_all_orders_with_side_and_empty_cache_sends_nothing() {
4917        let (base_url, hits) = start_batch_reject_server().await;
4918        let (mut client, _cache) = test_execution_client(base_url);
4919        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4920        client.emitter.set_sender(tx);
4921
4922        client
4923            .cancel_all_orders(CancelAllOrders::new(
4924                TraderId::from("TESTER-001"),
4925                Some(*BINANCE_CLIENT_ID),
4926                StrategyId::from("S-001"),
4927                InstrumentId::from("BTCUSDT-PERP.BINANCE"),
4928                Some(OrderSide::Buy),
4929                UUID4::new(),
4930                UnixNanos::default(),
4931                None,
4932                None,
4933            ))
4934            .unwrap();
4935        wait_for_spawned_tasks(&client).await;
4936
4937        assert_eq!(hits.batch.load(Ordering::Relaxed), 0);
4938        assert_eq!(hits.algo_cancel.load(Ordering::Relaxed), 0);
4939        assert_eq!(hits.cancel_all.load(Ordering::Relaxed), 0);
4940        assert!(rx.try_recv().is_err());
4941        assert!(client.dispatch_state.pending_requests.is_empty());
4942    }
4943}