1use 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
139const LISTEN_KEY_KEEPALIVE_SECS: u64 = 30 * 60;
141
142const 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
149const USER_TRADES_COMPLETE_INTERVAL: DurationNanos = DurationNanos::from_days(88);
153
154const 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
161pub const BINANCE_VENUE_ORDER_ID_IS_ALGO_ID_PARAM: &str = "venue_order_id_is_algo_id";
164
165#[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 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, 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 #[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 #[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 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 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, UUID4::new(),
480 ts_now,
481 ts_now,
482 None, )
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 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 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 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 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 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 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 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 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 ¶ms.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 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 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 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 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(¶ms),
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 match self.http_client.set_leverage(¶ms).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(¶ms).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 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
1804pub(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 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 self.apply_futures_config()
1928 .await
1929 .context("failed to apply futures config")?;
1930
1931 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 {
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 {
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 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 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 self.recovery_tx.take();
2272
2273 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 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 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(¶ms).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 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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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 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, 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, 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 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 ¶ms.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, );
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 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 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 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 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}