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