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