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