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