1use std::{
42 sync::{Arc, Mutex},
43 time::{Duration, Instant},
44};
45
46use ahash::AHashMap;
47use anyhow::Context;
48use async_trait::async_trait;
49use dashmap::DashMap;
50use futures_util::{Stream, StreamExt, pin_mut};
51use nautilus_common::{
52 clients::ExecutionClient,
53 live::{get_runtime, runner::get_exec_event_sender},
54 messages::execution::{
55 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
56 GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
57 ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
58 },
59};
60use nautilus_core::{
61 MUTEX_POISONED, UUID4, UnixNanos,
62 time::{AtomicTime, get_atomic_clock_realtime},
63};
64use nautilus_live::{ExecutionClientCore, ExecutionEventEmitter};
65use nautilus_model::{
66 accounts::AccountAny,
67 enums::{AccountType, OmsType, OrderSide, OrderStatus, OrderType, TimeInForce},
68 events::{AccountState, OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired},
69 identifiers::{
70 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, Symbol, Venue, VenueOrderId,
71 },
72 instruments::{Instrument, InstrumentAny},
73 orders::{Order, OrderAny},
74 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
75 types::{AccountBalance, Currency, MarginBalance, Money},
76};
77use nautilus_network::retry::RetryConfig;
78use rust_decimal::Decimal;
79use tokio::task::JoinHandle;
80
81use crate::{
82 common::{
83 consts::DYDX_VENUE,
84 credential::{DydxCredential, credential_env_vars},
85 instrument_cache::InstrumentCache,
86 parse::nanos_to_secs_i64,
87 },
88 config::DydxAdapterConfig,
89 error::DydxError,
90 execution::{
91 broadcaster::TxBroadcaster,
92 encoder::ClientOrderIdEncoder,
93 order_builder::OrderMessageBuilder,
94 tx_manager::TransactionManager,
95 types::{LimitOrderParams, OrderContext},
96 },
97 grpc::{DydxGrpcClient, SHORT_TERM_ORDER_MAXIMUM_LIFETIME, types::ChainId},
98 http::{
99 client::DydxHttpClient,
100 parse::{
101 parse_account_state, parse_fill_report, parse_order_status_report,
102 parse_position_status_report,
103 },
104 },
105 websocket::{
106 DydxWsDispatchState, OrderIdentity,
107 client::DydxWebSocketClient,
108 enums::DydxWsOutputMessage,
109 fill_report_to_order_filled,
110 parse::{parse_ws_fill_report, parse_ws_order_report, parse_ws_position_report},
111 },
112};
113
114pub mod block_time;
115pub mod broadcaster;
116pub mod encoder;
117pub mod order_builder;
118pub mod submitter;
119pub mod tx_manager;
120pub mod types;
121pub mod wallet;
122
123use block_time::BlockTimeMonitor;
124
125const DYDX_INDEXER_REPORT_LIMIT: u32 = 1_000;
126
127type CancelAllOrderData = (
128 StrategyId,
129 ClientOrderId,
130 Option<VenueOrderId>,
131 TimeInForce,
132 Option<UnixNanos>,
133);
134
135#[derive(Clone, Copy)]
136struct DydxCancelOrderRequest {
137 instrument_id: InstrumentId,
138 client_id: u32,
139 order_flags: u32,
140 strategy_id: StrategyId,
141 client_order_id: ClientOrderId,
142 venue_order_id: Option<VenueOrderId>,
143}
144
145fn apply_avg_px_from_fills(order_reports: &mut [OrderStatusReport], fill_reports: &[FillReport]) {
146 let mut totals: AHashMap<VenueOrderId, (Decimal, Decimal)> = AHashMap::new();
147 for fill in fill_reports {
148 let entry = totals.entry(fill.venue_order_id).or_default();
149 let qty = fill.last_qty.as_decimal();
150 entry.0 += fill.last_px.as_decimal() * qty;
151 entry.1 += qty;
152 }
153
154 for report in order_reports {
155 if let Some((notional, total_qty)) = totals.get(&report.venue_order_id)
156 && !total_qty.is_zero()
157 {
158 report.avg_px = Some(notional / total_qty);
159 }
160 }
161}
162
163#[derive(Debug)]
178pub struct DydxExecutionClient {
179 core: ExecutionClientCore,
180 clock: &'static AtomicTime,
181 config: DydxAdapterConfig,
182 emitter: ExecutionEventEmitter,
183 http_client: DydxHttpClient,
184 ws_client: DydxWebSocketClient,
185 grpc_client: Arc<tokio::sync::RwLock<Option<DydxGrpcClient>>>,
186 instrument_cache: Arc<InstrumentCache>,
187 block_time_monitor: Arc<BlockTimeMonitor>,
188 oracle_prices: Arc<DashMap<InstrumentId, Decimal>>,
189 encoder: Arc<ClientOrderIdEncoder>,
190 dispatch_state: Arc<DydxWsDispatchState>,
191 order_contexts: Arc<DashMap<u32, OrderContext>>,
192 order_id_map: Arc<DashMap<String, (u32, u32)>>,
193 wallet_address: String,
194 subaccount_number: u32,
195 tx_manager: Option<Arc<TransactionManager>>,
196 broadcaster: Option<Arc<TxBroadcaster>>,
197 order_builder: Option<Arc<OrderMessageBuilder>>,
198 ws_stream_handle: Option<JoinHandle<()>>,
199 pending_tasks: Mutex<Vec<(&'static str, JoinHandle<()>)>>,
200}
201
202impl DydxExecutionClient {
203 pub fn new(
209 core: ExecutionClientCore,
210 config: DydxAdapterConfig,
211 wallet_address: String,
212 subaccount_number: u32,
213 ) -> anyhow::Result<Self> {
214 let trader_id = core.trader_id;
215 let account_id = core.account_id;
216 let clock = get_atomic_clock_realtime();
217 let emitter =
218 ExecutionEventEmitter::new(clock, trader_id, account_id, AccountType::Margin, None);
219
220 let retry_config = RetryConfig {
221 max_retries: config.max_retries,
222 initial_delay_ms: config.retry_delay_initial_ms,
223 max_delay_ms: config.retry_delay_max_ms,
224 ..Default::default()
225 };
226 let http_client = DydxHttpClient::new(
227 Some(config.base_url.clone()),
228 config.timeout_secs,
229 config.proxy_url.clone(),
230 config.network,
231 Some(retry_config),
232 )?;
233
234 let instrument_cache = http_client.instrument_cache().clone();
236
237 let credential = DydxCredential::resolve(
239 config.private_key.as_deref(),
240 config.network,
241 config.authenticator_ids.clone(),
242 )?
243 .ok_or_else(|| anyhow::anyhow!("Credentials required for execution client"))?;
244
245 let ws_client = DydxWebSocketClient::new_private_with_cache(
247 config.ws_url.clone(),
248 credential,
249 core.account_id,
250 instrument_cache.clone(),
251 Some(20),
252 config.transport_backend,
253 config.proxy_url.clone(),
254 );
255
256 let grpc_client = Arc::new(tokio::sync::RwLock::new(None));
257
258 Ok(Self {
259 core,
260 clock,
261 config,
262 emitter,
263 http_client,
264 ws_client,
265 grpc_client,
266 instrument_cache,
267 block_time_monitor: Arc::new(BlockTimeMonitor::new()),
268 oracle_prices: Arc::new(DashMap::new()),
269 encoder: Arc::new(ClientOrderIdEncoder::new()),
270 dispatch_state: Arc::new(DydxWsDispatchState::default()),
271 order_contexts: Arc::new(DashMap::new()),
272 order_id_map: Arc::new(DashMap::new()),
273 wallet_address,
274 subaccount_number,
275 tx_manager: None,
276 broadcaster: None,
277 order_builder: None,
278 ws_stream_handle: None,
279 pending_tasks: Mutex::new(Vec::new()),
280 })
281 }
282
283 fn resolve_private_key(config: &DydxAdapterConfig) -> anyhow::Result<String> {
284 let (private_key_env, _) = credential_env_vars(config.network);
285
286 if let Some(ref pk) = config.private_key
288 && !pk.trim().is_empty()
289 {
290 return Ok(pk.clone());
291 }
292
293 if let Some(pk) = std::env::var(private_key_env)
295 .ok()
296 .filter(|s| !s.trim().is_empty())
297 {
298 return Ok(pk);
299 }
300
301 anyhow::bail!("{private_key_env} not found in config or environment")
302 }
303
304 fn register_order_context(&self, client_id_u32: u32, context: OrderContext) {
305 self.order_contexts.insert(client_id_u32, context);
306 }
307
308 fn get_order_context(&self, client_id_u32: u32) -> Option<OrderContext> {
309 self.order_contexts
310 .get(&client_id_u32)
311 .map(|r| r.value().clone())
312 }
313
314 fn get_chain_id(&self) -> ChainId {
315 self.config.get_chain_id()
316 }
317
318 fn spawn_ws_stream_handler(
319 &mut self,
320 stream: impl Stream<Item = DydxWsOutputMessage> + Send + 'static,
321 ) {
322 if self.ws_stream_handle.is_some() {
323 return;
324 }
325
326 log::debug!("Starting execution WebSocket message processing task");
327
328 let trader_id = self.core.trader_id;
330 let account_id = self.core.account_id;
331 let instrument_cache = self.instrument_cache.clone();
332 let oracle_prices = self.oracle_prices.clone();
333 let encoder = self.encoder.clone();
334 let order_contexts = self.order_contexts.clone();
335 let order_id_map = self.order_id_map.clone();
336 let dispatch_state = self.dispatch_state.clone();
337 let block_time_monitor = self.block_time_monitor.clone();
338 let emitter = self.emitter.clone();
339 let clock = self.clock;
340
341 let handle = get_runtime().spawn(async move {
342 log::debug!("Execution WebSocket message loop started");
343
344 let mut cum_fill_totals: AHashMap<VenueOrderId, (Decimal, Decimal)> =
346 AHashMap::new();
347
348 pin_mut!(stream);
349 while let Some(msg) = stream.next().await {
350 match msg {
351 DydxWsOutputMessage::SubaccountSubscribed(msg) => {
352 log::debug!("Parsing subaccount subscription with full context");
353
354 let inst_map = instrument_cache.to_instrument_id_map();
355
356 let oracle_map: std::collections::HashMap<_, _> = oracle_prices
357 .iter()
358 .map(|entry| (*entry.key(), *entry.value()))
359 .collect();
360
361 let ts_init = clock.get_time_ns();
362 let ts_event = ts_init;
363
364 if let Some(ref subaccount) = msg.contents.subaccount {
365 match parse_account_state(
366 subaccount,
367 account_id,
368 &inst_map,
369 &oracle_map,
370 ts_event,
371 ts_init,
372 ) {
373 Ok(account_state) => {
374 log::debug!(
375 "Parsed account state: {} balance(s), {} margin(s)",
376 account_state.balances.len(),
377 account_state.margins.len()
378 );
379 emitter.send_account_state(account_state);
380 }
381 Err(e) => {
382 log::error!("Failed to parse account state: {e}");
383 }
384 }
385
386 if let Some(ref positions) =
387 subaccount.open_perpetual_positions
388 {
389 log::debug!(
390 "Parsing {} position(s) from subscription",
391 positions.len()
392 );
393
394 for (market, ws_position) in positions {
395 match parse_ws_position_report(
396 ws_position,
397 &instrument_cache,
398 account_id,
399 ts_init,
400 ) {
401 Ok(report) => {
402 log::debug!(
403 "Parsed position report: {} {} {} {}",
404 report.instrument_id,
405 report.position_side,
406 report.quantity,
407 market
408 );
409 emitter.send_position_report(report);
410 }
411 Err(e) => {
412 log::error!(
413 "Failed to parse WebSocket position for {market}: {e}"
414 );
415 }
416 }
417 }
418 }
419 } else {
420 log::warn!("Subaccount subscription without initial state (new/empty subaccount)");
421
422 let currency = Currency::get_or_create_crypto_with_context("USDC", None);
423 let zero = Money::zero(currency);
424 let balance = AccountBalance::new_checked(zero, zero, zero)
425 .expect("zero balance should always be valid");
426 let account_state = AccountState::new(
427 account_id,
428 AccountType::Margin,
429 vec![balance],
430 vec![],
431 true,
432 UUID4::new(),
433 ts_init,
434 ts_init,
435 None,
436 );
437 emitter.send_account_state(account_state);
438 }
439 }
440 DydxWsOutputMessage::SubaccountsChannelData(data) => {
441 log::debug!(
442 "Processing subaccounts channel data (orders={:?}, fills={:?})",
443 data.contents.orders.as_ref().map(|o| o.len()),
444 data.contents.fills.as_ref().map(|f| f.len())
445 );
446 let ts_init = clock.get_time_ns();
447
448 let mut terminal_orders: Vec<(u32, u32, String)> = Vec::new();
449 let mut pending_order_reports = Vec::new();
450
451 if let Some(ref orders) = data.contents.orders {
453 for ws_order in orders {
454 log::debug!(
455 "Parsing WS order: clob_pair_id={}, status={:?}, client_id={}",
456 ws_order.clob_pair_id,
457 ws_order.status,
458 ws_order.client_id
459 );
460
461 if let Ok(client_id_u32) = ws_order.client_id.parse::<u32>() {
462 let client_meta = ws_order.client_metadata
463 .as_ref()
464 .and_then(|s| s.parse::<u32>().ok())
465 .unwrap_or(crate::grpc::DEFAULT_RUST_CLIENT_METADATA);
466 order_id_map.insert(ws_order.id.clone(), (client_id_u32, client_meta));
467 }
468
469 match parse_ws_order_report(
470 ws_order,
471 &instrument_cache,
472 &order_contexts,
473 &encoder,
474 account_id,
475 ts_init,
476 ) {
477 Ok(report) => {
478 if !report.order_status.is_open()
479 && let Ok(cid) = ws_order.client_id.parse::<u32>()
480 {
481 let meta = ws_order.client_metadata
482 .as_ref()
483 .and_then(|s| s.parse::<u32>().ok())
484 .unwrap_or(crate::grpc::DEFAULT_RUST_CLIENT_METADATA);
485 terminal_orders.push((cid, meta, ws_order.id.clone()));
486 }
487 log::debug!(
488 "Parsed order report: {} {} {:?} qty={} client_order_id={:?}",
489 report.instrument_id,
490 report.order_side,
491 report.order_status,
492 report.quantity,
493 report.client_order_id
494 );
495 pending_order_reports.push(report);
496 }
497 Err(e) => {
498 log::error!("Failed to parse WebSocket order: {e}");
499 }
500 }
501 }
502 }
503
504 if let Some(ref fills) = data.contents.fills {
506 for ws_fill in fills {
507 match parse_ws_fill_report(
508 ws_fill,
509 &instrument_cache,
510 &order_id_map,
511 &order_contexts,
512 &encoder,
513 account_id,
514 ts_init,
515 ) {
516 Ok(report) => {
517 log::debug!(
518 "Parsed fill report: {} {} {} @ {} client_order_id={:?}",
519 report.instrument_id,
520 report.venue_order_id,
521 report.last_qty,
522 report.last_px,
523 report.client_order_id
524 );
525
526 let identity = report.client_order_id.and_then(|cid| {
527 dispatch_state.order_identities.get(&cid).map(|r| (cid, r.clone()))
528 });
529
530 if let Some((cid, ident)) = identity {
531 if !dispatch_state.emitted_accepted.contains(&cid) {
533 dispatch_state.insert_accepted(cid);
534 let accepted = OrderAccepted::new(
535 trader_id,
536 ident.strategy_id,
537 ident.instrument_id,
538 cid,
539 report.venue_order_id,
540 account_id,
541 UUID4::new(),
542 ts_init,
543 ts_init,
544 false,
545 );
546 emitter.send_order_event(OrderEventAny::Accepted(accepted));
547 }
548
549 dispatch_state.insert_filled(cid);
550 let instrument = instrument_cache.get(&report.instrument_id);
551 let quote_currency = instrument
552 .map_or_else(Currency::USD, |i: InstrumentAny| i.quote_currency());
553 let filled = fill_report_to_order_filled(
554 &report, trader_id, &ident, quote_currency,
555 );
556 emitter.send_order_event(OrderEventAny::Filled(filled));
557 } else {
558 let entry = cum_fill_totals
560 .entry(report.venue_order_id)
561 .or_default();
562 let qty = report.last_qty.as_decimal();
563 entry.0 += report.last_px.as_decimal() * qty;
564 entry.1 += qty;
565 emitter.send_fill_report(report);
566 }
567 }
568 Err(e) => {
569 log::error!("Failed to parse WebSocket fill: {e}");
570 }
571 }
572 }
573 }
574
575 for report in &mut pending_order_reports {
578 if let Some((notional, total_qty)) =
579 cum_fill_totals.get(&report.venue_order_id)
580 && !total_qty.is_zero()
581 {
582 report.avg_px = Some(notional / total_qty);
583 }
584 }
585
586 for report in pending_order_reports {
587 let identity = report.client_order_id.and_then(|cid| {
588 dispatch_state.order_identities.get(&cid).map(|r| (cid, r.clone()))
589 });
590
591 if let Some((cid, ident)) = identity {
592 match report.order_status {
594 OrderStatus::Accepted => {
595 if dispatch_state.emitted_accepted.contains(&cid)
596 || dispatch_state.filled_orders.contains(&cid)
597 {
598 log::debug!("Skipping duplicate Accepted for {cid}");
599 continue;
600 }
601 dispatch_state.insert_accepted(cid);
602 let accepted = OrderAccepted::new(
603 trader_id,
604 ident.strategy_id,
605 ident.instrument_id,
606 cid,
607 report.venue_order_id,
608 account_id,
609 UUID4::new(),
610 report.ts_last,
611 ts_init,
612 false,
613 );
614 emitter.send_order_event(OrderEventAny::Accepted(accepted));
615 }
616 OrderStatus::Canceled => {
617 if !dispatch_state.emitted_accepted.contains(&cid) {
619 dispatch_state.insert_accepted(cid);
620 let accepted = OrderAccepted::new(
621 trader_id,
622 ident.strategy_id,
623 ident.instrument_id,
624 cid,
625 report.venue_order_id,
626 account_id,
627 UUID4::new(),
628 ts_init,
629 ts_init,
630 false,
631 );
632 emitter.send_order_event(OrderEventAny::Accepted(accepted));
633 }
634 let is_expiry = report
637 .expire_time
638 .is_some_and(|exp| report.ts_last >= exp);
639 if is_expiry {
640 let expired = OrderExpired::new(
641 trader_id,
642 ident.strategy_id,
643 ident.instrument_id,
644 cid,
645 UUID4::new(),
646 report.ts_last,
647 ts_init,
648 false,
649 Some(report.venue_order_id),
650 Some(account_id),
651 );
652 emitter.send_order_event(OrderEventAny::Expired(expired));
653 } else {
654 let canceled = OrderCanceled::new(
655 trader_id,
656 ident.strategy_id,
657 ident.instrument_id,
658 cid,
659 UUID4::new(),
660 report.ts_last,
661 ts_init,
662 false,
663 Some(report.venue_order_id),
664 Some(account_id),
665 );
666 emitter.send_order_event(OrderEventAny::Canceled(canceled));
667 }
668 dispatch_state.cleanup_terminal(&cid);
669 }
670 OrderStatus::Filled => {
671 dispatch_state.cleanup_terminal(&cid);
673 }
674 OrderStatus::Expired => {
675 if !dispatch_state.emitted_accepted.contains(&cid) {
681 dispatch_state.insert_accepted(cid);
682 let accepted = OrderAccepted::new(
683 trader_id,
684 ident.strategy_id,
685 ident.instrument_id,
686 cid,
687 report.venue_order_id,
688 account_id,
689 UUID4::new(),
690 ts_init,
691 ts_init,
692 false,
693 );
694 emitter.send_order_event(OrderEventAny::Accepted(accepted));
695 }
696 let expired = OrderExpired::new(
697 trader_id,
698 ident.strategy_id,
699 ident.instrument_id,
700 cid,
701 UUID4::new(),
702 report.ts_last,
703 ts_init,
704 false,
705 Some(report.venue_order_id),
706 Some(account_id),
707 );
708 emitter.send_order_event(OrderEventAny::Expired(expired));
709 dispatch_state.cleanup_terminal(&cid);
710 }
711 _ => {
712 emitter.send_order_status_report(report);
714 }
715 }
716 } else {
717 emitter.send_order_status_report(report);
719 }
720 }
721
722 for (client_id, client_metadata, order_id) in terminal_orders {
724 order_contexts.remove(&client_id);
725 encoder.remove(client_id, client_metadata);
726 order_id_map.remove(&order_id);
727 cum_fill_totals.remove(&VenueOrderId::new(&order_id));
728 }
729 }
730 DydxWsOutputMessage::Markets(contents) => {
731 if let Some(ref oracle_map) = contents.oracle_prices {
732 for (symbol_str, oracle_market) in oracle_map {
733 let instrument_id = {
734 let symbol = format!("{symbol_str}-PERP");
735 InstrumentId::new(
736 Symbol::new(&symbol),
737 *crate::common::consts::DYDX_VENUE,
738 )
739 };
740
741 if instrument_cache.get(&instrument_id).is_some()
742 && let Ok(price_dec) = oracle_market.oracle_price.parse::<Decimal>()
743 {
744 oracle_prices.insert(instrument_id, price_dec);
745 log::trace!("Updated oracle price for {instrument_id}: {price_dec}");
746 }
747 }
748 }
749
750 if let Some(ref markets) = contents.markets {
751 for (symbol_str, market_data) in markets {
752 if let Some(oracle_price_str) = &market_data.oracle_price {
753 let instrument_id = {
754 let symbol = format!("{symbol_str}-PERP");
755 InstrumentId::new(
756 Symbol::new(&symbol),
757 *crate::common::consts::DYDX_VENUE,
758 )
759 };
760
761 if instrument_cache.get(&instrument_id).is_some()
762 && let Ok(price_dec) = oracle_price_str.parse::<Decimal>()
763 {
764 oracle_prices.insert(instrument_id, price_dec);
765 }
766 }
767 }
768 }
769 }
770 DydxWsOutputMessage::BlockHeight { height, time } => {
771 log::debug!("Block height update: {height} at {time}");
772 block_time_monitor.record_block(height, time);
773 }
774 DydxWsOutputMessage::Error(err) => {
775 log::warn!("WebSocket error: {err:?}");
776 }
777 DydxWsOutputMessage::Reconnected { .. } => {
778 log::info!("WebSocket reconnected");
779 }
780 _ => {}
781 }
782 }
783 log::debug!("WebSocket message processing task ended");
784 });
785
786 self.ws_stream_handle = Some(handle);
787 log::debug!("WebSocket stream handler started");
788 }
789
790 fn mark_instruments_initialized(&self) {
795 let count = self.instrument_cache.len();
796 self.core.set_instruments_initialized();
797 log::debug!("Instruments initialized: {count} instruments in shared cache");
798 }
799
800 fn get_instrument_by_market(&self, market: &str) -> Option<InstrumentAny> {
801 self.instrument_cache.get_by_market(market)
802 }
803
804 fn get_instrument_by_clob_pair_id(&self, clob_pair_id: u32) -> Option<InstrumentAny> {
805 let instrument = self.instrument_cache.get_by_clob_id(clob_pair_id);
806
807 if instrument.is_none() {
808 self.instrument_cache.log_missing_clob_pair_id(clob_pair_id);
809 }
810
811 instrument
812 }
813
814 fn get_execution_components(
818 &self,
819 ) -> anyhow::Result<(
820 Arc<TransactionManager>,
821 Arc<TxBroadcaster>,
822 Arc<OrderMessageBuilder>,
823 )> {
824 let tx_manager = self
825 .tx_manager
826 .as_ref()
827 .ok_or_else(|| {
828 anyhow::anyhow!("TransactionManager not initialized - call connect() first")
829 })?
830 .clone();
831 let broadcaster = self
832 .broadcaster
833 .as_ref()
834 .ok_or_else(|| anyhow::anyhow!("TxBroadcaster not initialized - call connect() first"))?
835 .clone();
836 let order_builder = self
837 .order_builder
838 .as_ref()
839 .ok_or_else(|| {
840 anyhow::anyhow!("OrderMessageBuilder not initialized - call connect() first")
841 })?
842 .clone();
843 Ok((tx_manager, broadcaster, order_builder))
844 }
845
846 fn spawn_task<F>(&self, label: &'static str, fut: F)
847 where
848 F: Future<Output = anyhow::Result<()>> + Send + 'static,
849 {
850 let handle = get_runtime().spawn(async move {
851 if let Err(e) = fut.await {
852 log::error!("{label}: {e:?}");
853 }
854 });
855
856 self.pending_tasks
857 .lock()
858 .expect(MUTEX_POISONED)
859 .push((label, handle));
860 }
861
862 fn spawn_order_task<F>(
868 &self,
869 label: &'static str,
870 strategy_id: StrategyId,
871 instrument_id: InstrumentId,
872 client_order_id: ClientOrderId,
873 fut: F,
874 ) where
875 F: Future<Output = anyhow::Result<()>> + Send + 'static,
876 {
877 let emitter = self.emitter.clone();
878 let clock = self.clock;
879
880 let handle = get_runtime().spawn(async move {
881 if let Err(e) = fut.await {
882 if is_definitive_broadcast_rejection(&e) {
883 let error_msg = format!("{label} failed: {e:?}");
884 log::error!("{error_msg}");
885
886 let ts_event = clock.get_time_ns();
887 emitter.emit_order_rejected_event(
888 strategy_id,
889 instrument_id,
890 client_order_id,
891 &error_msg,
892 ts_event,
893 false,
894 );
895 } else {
896 log::warn!(
897 "Ambiguous dYdX {label} failure for {client_order_id}, awaiting reconciliation: {e:?}"
898 );
899 }
900 }
901 });
902
903 self.pending_tasks
904 .lock()
905 .expect(MUTEX_POISONED)
906 .push((label, handle));
907 }
908
909 fn abort_pending_tasks(&self) {
910 let mut guard = self.pending_tasks.lock().expect(MUTEX_POISONED);
911 let mut aborted_cancels = 0usize;
912 let mut aborted_other = 0usize;
913
914 for (label, handle) in guard.drain(..) {
915 if !handle.is_finished() {
916 if label.contains("cancel") {
917 aborted_cancels += 1;
918 } else {
919 aborted_other += 1;
920 }
921 }
922 handle.abort();
923 }
924
925 if aborted_cancels > 0 {
926 log::error!(
927 "Aborted {aborted_cancels} in-flight cancel task(s) before completion; \
928 orders may still be open on the venue; \
929 increase shutdown grace period (`timeout_post_stop`) to allow cancels to finish",
930 );
931 }
932
933 if aborted_other > 0 {
934 log::warn!("Aborted {aborted_other} other in-flight task(s) on disconnect");
935 }
936 }
937
938 fn send_modify_rejected(
940 &self,
941 strategy_id: StrategyId,
942 instrument_id: InstrumentId,
943 client_order_id: ClientOrderId,
944 venue_order_id: Option<VenueOrderId>,
945 reason: &str,
946 ) {
947 let ts_event = self.clock.get_time_ns();
948 self.emitter.emit_order_modify_rejected_event(
949 strategy_id,
950 instrument_id,
951 client_order_id,
952 venue_order_id,
953 reason,
954 ts_event,
955 );
956 }
957
958 async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
968 let account_id = self.core.account_id;
969
970 if self.core.cache().account(&account_id).is_some() {
971 log::info!("Account {account_id} registered");
972 return Ok(());
973 }
974
975 let start = Instant::now();
976 let timeout = Duration::from_secs_f64(timeout_secs);
977 let interval = Duration::from_millis(10);
978
979 loop {
980 tokio::time::sleep(interval).await;
981
982 if self.core.cache().account(&account_id).is_some() {
983 log::info!("Account {account_id} registered");
984 return Ok(());
985 }
986
987 if start.elapsed() >= timeout {
988 anyhow::bail!(
989 "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
990 );
991 }
992 }
993 }
994}
995
996async fn broadcast_partitioned_cancels(
1004 orders: Vec<DydxCancelOrderRequest>,
1005 block_height: u32,
1006 tx_manager: Arc<TransactionManager>,
1007 broadcaster: Arc<TxBroadcaster>,
1008 order_builder: Arc<OrderMessageBuilder>,
1009 emitter: ExecutionEventEmitter,
1010 clock: &'static AtomicTime,
1011) -> anyhow::Result<()> {
1012 if orders.is_empty() {
1013 return Ok(());
1014 }
1015
1016 let (short_term_orders, long_term_orders): (Vec<_>, Vec<_>) = orders
1017 .into_iter()
1018 .partition(|order| order.order_flags == types::ORDER_FLAG_SHORT_TERM);
1019
1020 let mut errors = Vec::new();
1021
1022 if !short_term_orders.is_empty() {
1024 let st_pairs: Vec<_> = short_term_orders
1025 .iter()
1026 .map(|order| (order.instrument_id, order.client_id))
1027 .collect();
1028
1029 log::debug!(
1030 "Batch cancelling {} short-term orders with MsgBatchCancel",
1031 st_pairs.len()
1032 );
1033
1034 match order_builder.build_batch_cancel_short_term(&st_pairs, block_height) {
1035 Ok(msg) => {
1036 let operation = format!("BatchCancel {} short-term orders", st_pairs.len());
1037 match broadcaster
1038 .broadcast_short_term(&tx_manager, vec![msg], &operation)
1039 .await
1040 {
1041 Ok(tx_hash) => {
1042 log::debug!(
1043 "Successfully batch cancelled {} short-term orders, tx_hash: {}",
1044 st_pairs.len(),
1045 tx_hash
1046 );
1047 }
1048 Err(e) => {
1049 let msg = format!("Short-term batch cancel failed: {e:?}");
1050 log::error!("{msg}");
1051 if e.is_definitive_broadcast_rejection() {
1054 emit_partitioned_cancel_rejections(
1055 &short_term_orders,
1056 &emitter,
1057 clock,
1058 &msg,
1059 );
1060 }
1061 errors.push(msg);
1062 }
1063 }
1064 }
1065 Err(e) => {
1066 let msg = format!("Failed to build MsgBatchCancel: {e:?}");
1067 log::error!("{msg}");
1068 errors.push(msg);
1069 }
1070 }
1071 }
1072
1073 if !long_term_orders.is_empty() {
1075 let lt_tuples: Vec<_> = long_term_orders
1076 .iter()
1077 .map(|order| (order.instrument_id, order.client_id, order.order_flags))
1078 .collect();
1079
1080 log::debug!(
1081 "Batch cancelling {} long-term orders",
1082 long_term_orders.len(),
1083 );
1084
1085 match order_builder.build_cancel_orders_batch_with_flags(<_tuples, block_height) {
1086 Ok(cancel_msgs) => {
1087 let operation = format!("BatchCancel {} long-term orders", long_term_orders.len());
1088 match broadcaster
1089 .broadcast_with_retry(&tx_manager, cancel_msgs, &operation)
1090 .await
1091 {
1092 Ok(tx_hash) => {
1093 log::debug!(
1094 "Successfully batch cancelled {} long-term orders, tx_hash: {}",
1095 long_term_orders.len(),
1096 tx_hash
1097 );
1098 }
1099 Err(e) => {
1100 let msg = format!("Long-term batch cancel failed: {e:?}");
1101 log::error!("{msg}");
1102
1103 if e.is_definitive_broadcast_rejection() {
1104 emit_partitioned_cancel_rejections(
1105 &long_term_orders,
1106 &emitter,
1107 clock,
1108 &msg,
1109 );
1110 }
1111 errors.push(msg);
1112 }
1113 }
1114 }
1115 Err(e) => {
1116 let msg = format!("Failed to build long-term cancel messages: {e:?}");
1117 log::error!("{msg}");
1118 errors.push(msg);
1119 }
1120 }
1121 }
1122
1123 if !errors.is_empty() {
1124 anyhow::bail!("partitioned cancel failed: {}", errors.join("; "));
1125 }
1126
1127 Ok(())
1128}
1129
1130fn emit_partitioned_cancel_rejections(
1131 orders: &[DydxCancelOrderRequest],
1132 emitter: &ExecutionEventEmitter,
1133 clock: &AtomicTime,
1134 reason: &str,
1135) {
1136 for order in orders {
1137 emitter.emit_order_cancel_rejected_event(
1138 order.strategy_id,
1139 order.instrument_id,
1140 order.client_order_id,
1141 order.venue_order_id,
1142 reason,
1143 clock.get_time_ns(),
1144 );
1145 }
1146}
1147
1148fn is_definitive_broadcast_rejection(err: &anyhow::Error) -> bool {
1149 err.downcast_ref::<DydxError>()
1150 .is_some_and(DydxError::is_definitive_broadcast_rejection)
1151}
1152
1153#[async_trait(?Send)]
1154impl ExecutionClient for DydxExecutionClient {
1155 fn is_connected(&self) -> bool {
1156 self.core.is_connected()
1157 }
1158
1159 fn client_id(&self) -> ClientId {
1160 self.core.client_id
1161 }
1162
1163 fn account_id(&self) -> AccountId {
1164 self.core.account_id
1165 }
1166
1167 fn venue(&self) -> Venue {
1168 *DYDX_VENUE
1169 }
1170
1171 fn oms_type(&self) -> OmsType {
1172 self.core.oms_type
1173 }
1174
1175 fn get_account(&self) -> Option<AccountAny> {
1176 self.core.cache().account_owned(&self.core.account_id)
1177 }
1178
1179 fn generate_account_state(
1180 &self,
1181 balances: Vec<AccountBalance>,
1182 margins: Vec<MarginBalance>,
1183 reported: bool,
1184 ts_event: UnixNanos,
1185 ) -> anyhow::Result<()> {
1186 self.emitter
1187 .emit_account_state(balances, margins, reported, ts_event);
1188 Ok(())
1189 }
1190
1191 fn start(&mut self) -> anyhow::Result<()> {
1192 if self.core.is_started() {
1193 log::warn!("dYdX execution client already started");
1194 return Ok(());
1195 }
1196
1197 let sender = get_exec_event_sender();
1198 self.emitter.set_sender(sender);
1199 log::info!("Starting dYdX execution client");
1200 self.core.set_started();
1201 Ok(())
1202 }
1203
1204 fn stop(&mut self) -> anyhow::Result<()> {
1205 if self.core.is_stopped() {
1206 log::warn!("dYdX execution client not started");
1207 return Ok(());
1208 }
1209
1210 log::info!("Stopping dYdX execution client");
1211 self.abort_pending_tasks();
1212 self.core.set_stopped();
1213 self.core.set_disconnected();
1214 Ok(())
1215 }
1216
1217 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
1234 if !self.is_connected() {
1236 let reason = "Cannot submit order: execution client not connected";
1237 log::error!("{reason}");
1238 anyhow::bail!(reason);
1239 }
1240
1241 let current_block = self.block_time_monitor.current_block_height();
1243 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
1244
1245 let client_order_id = order.client_order_id();
1246 let instrument_id = order.instrument_id();
1247 let strategy_id = order.strategy_id();
1248
1249 if current_block == 0 {
1250 let reason = "Block height not initialized";
1251 log::warn!("Cannot submit order {client_order_id}: {reason}");
1252 self.emitter.emit_order_denied(&order, reason);
1253 return Ok(());
1254 }
1255
1256 if order.is_closed() {
1258 log::warn!("Cannot submit closed order {client_order_id}");
1259 return Ok(());
1260 }
1261
1262 if order.is_quote_quantity() {
1263 let reason = "Quote quantity orders are not supported by dYdX";
1264 log::error!("{reason}");
1265 self.emitter.emit_order_denied(&order, reason);
1266 return Ok(());
1267 }
1268
1269 let unsupported_tif_reason = match order.time_in_force() {
1273 TimeInForce::Fok => Some(
1274 "Fill-or-kill (FOK) orders are deprecated by dYdX v4 (chain rejects with code=48)",
1275 ),
1276 TimeInForce::Day => Some("DAY time-in-force is not supported by dYdX v4"),
1277 _ => None,
1278 };
1279
1280 if let Some(reason) = unsupported_tif_reason {
1281 log::error!("{reason}");
1282 self.emitter.emit_order_denied(&order, reason);
1283 return Ok(());
1284 }
1285
1286 match order.order_type() {
1288 OrderType::Market
1289 | OrderType::Limit
1290 | OrderType::StopMarket
1291 | OrderType::StopLimit
1292 | OrderType::MarketIfTouched
1293 | OrderType::LimitIfTouched => {}
1294 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit => {
1296 let reason = "Trailing stop orders not supported by dYdX v4 protocol";
1297 log::error!("{reason}");
1298 self.emitter.emit_order_denied(&order, reason);
1299 return Ok(());
1300 }
1301 order_type => {
1302 let reason = format!("Order type {order_type:?} not supported by dYdX");
1303 log::error!("{reason}");
1304 self.emitter.emit_order_denied(&order, &reason);
1305 return Ok(());
1306 }
1307 }
1308
1309 let (tx_manager, broadcaster, order_builder) = match self.get_execution_components() {
1311 Ok(components) => components,
1312 Err(e) => {
1313 log::error!("Failed to get execution components: {e}");
1314 self.emitter.emit_order_denied(&order, &e.to_string());
1315 return Ok(());
1316 }
1317 };
1318
1319 let block_height = self.block_time_monitor.current_block_height() as u32;
1320
1321 let encoded = match self.encoder.encode(client_order_id) {
1323 Ok(enc) => enc,
1324 Err(e) => {
1325 log::error!("Failed to generate client order ID: {e}");
1326 self.emitter.emit_order_denied(&order, &e.to_string());
1327 return Ok(());
1328 }
1329 };
1330
1331 self.emitter.emit_order_submitted(&order);
1332 let client_id_u32 = encoded.client_id;
1333 let client_metadata = encoded.client_metadata;
1334
1335 log::debug!(
1336 "[SUBMIT_ORDER] Nautilus '{}' -> dYdX u32={} meta={:#x} | instrument={} side={:?} qty={} type={:?}",
1337 client_order_id,
1338 client_id_u32,
1339 client_metadata,
1340 instrument_id,
1341 order.order_side(),
1342 order.quantity(),
1343 order.order_type()
1344 );
1345
1346 let expire_time = order.expire_time().map(nanos_to_secs_i64);
1348
1349 let order_flags = match order.order_type() {
1351 OrderType::StopMarket
1353 | OrderType::StopLimit
1354 | OrderType::MarketIfTouched
1355 | OrderType::LimitIfTouched => types::ORDER_FLAG_CONDITIONAL,
1356 OrderType::Market => types::ORDER_FLAG_SHORT_TERM,
1358 OrderType::Limit => {
1360 let lifetime = types::OrderLifetime::from_time_in_force(
1361 order.time_in_force(),
1362 expire_time,
1363 false,
1364 order_builder.max_short_term_secs(),
1365 );
1366 lifetime.order_flags()
1367 }
1368 _ => types::ORDER_FLAG_LONG_TERM,
1370 };
1371
1372 let ts_submitted = self.clock.get_time_ns();
1374 let trader_id = order.trader_id();
1375 self.register_order_context(
1376 client_id_u32,
1377 OrderContext {
1378 client_order_id,
1379 trader_id,
1380 strategy_id,
1381 instrument_id,
1382 submitted_at: ts_submitted,
1383 order_flags,
1384 },
1385 );
1386
1387 self.dispatch_state.order_identities.insert(
1390 client_order_id,
1391 OrderIdentity {
1392 instrument_id,
1393 strategy_id,
1394 order_side: order.order_side(),
1395 order_type: order.order_type(),
1396 },
1397 );
1398
1399 self.spawn_order_task(
1400 "submit_order",
1401 strategy_id,
1402 instrument_id,
1403 client_order_id,
1404 async move {
1405 let (msg, order_type_str) = match order.order_type() {
1407 OrderType::Market => {
1408 let msg = order_builder.build_market_order(
1409 instrument_id,
1410 client_id_u32,
1411 client_metadata,
1412 order.order_side(),
1413 order.quantity(),
1414 block_height,
1415 )?;
1416 (msg, "market")
1417 }
1418 OrderType::Limit => {
1419 let msg = order_builder.build_limit_order(
1421 instrument_id,
1422 client_id_u32,
1423 client_metadata,
1424 order.order_side(),
1425 order
1426 .price()
1427 .ok_or_else(|| anyhow::anyhow!("Limit order missing price"))?,
1428 order.quantity(),
1429 order.time_in_force(),
1430 order.is_post_only(),
1431 order.is_reduce_only(),
1432 block_height,
1433 expire_time, )?;
1435 (msg, "limit")
1436 }
1437 OrderType::StopMarket => {
1440 let trigger_price = order.trigger_price().ok_or_else(|| {
1441 anyhow::anyhow!("Stop market order missing trigger_price")
1442 })?;
1443 let cond_expire = order.expire_time().map(nanos_to_secs_i64);
1444 let msg = order_builder.build_stop_market_order(
1445 instrument_id,
1446 client_id_u32,
1447 client_metadata,
1448 order.order_side(),
1449 trigger_price,
1450 order.quantity(),
1451 order.is_reduce_only(),
1452 cond_expire,
1453 )?;
1454 (msg, "stop_market")
1455 }
1456 OrderType::StopLimit => {
1457 let trigger_price = order.trigger_price().ok_or_else(|| {
1458 anyhow::anyhow!("Stop limit order missing trigger_price")
1459 })?;
1460 let limit_price = order.price().ok_or_else(|| {
1461 anyhow::anyhow!("Stop limit order missing limit price")
1462 })?;
1463 let cond_expire = order.expire_time().map(nanos_to_secs_i64);
1464 let msg = order_builder.build_stop_limit_order(
1465 instrument_id,
1466 client_id_u32,
1467 client_metadata,
1468 order.order_side(),
1469 trigger_price,
1470 limit_price,
1471 order.quantity(),
1472 order.time_in_force(),
1473 order.is_post_only(),
1474 order.is_reduce_only(),
1475 cond_expire,
1476 )?;
1477 (msg, "stop_limit")
1478 }
1479 OrderType::MarketIfTouched => {
1481 let trigger_price = order.trigger_price().ok_or_else(|| {
1482 anyhow::anyhow!("Take profit market order missing trigger_price")
1483 })?;
1484 let cond_expire = order.expire_time().map(nanos_to_secs_i64);
1485 let msg = order_builder.build_take_profit_market_order(
1486 instrument_id,
1487 client_id_u32,
1488 client_metadata,
1489 order.order_side(),
1490 trigger_price,
1491 order.quantity(),
1492 order.is_reduce_only(),
1493 cond_expire,
1494 )?;
1495 (msg, "take_profit_market")
1496 }
1497 OrderType::LimitIfTouched => {
1499 let trigger_price = order.trigger_price().ok_or_else(|| {
1500 anyhow::anyhow!("Take profit limit order missing trigger_price")
1501 })?;
1502 let limit_price = order.price().ok_or_else(|| {
1503 anyhow::anyhow!("Take profit limit order missing limit price")
1504 })?;
1505 let cond_expire = order.expire_time().map(nanos_to_secs_i64);
1506 let msg = order_builder.build_take_profit_limit_order(
1507 instrument_id,
1508 client_id_u32,
1509 client_metadata,
1510 order.order_side(),
1511 trigger_price,
1512 limit_price,
1513 order.quantity(),
1514 order.time_in_force(),
1515 order.is_post_only(),
1516 order.is_reduce_only(),
1517 cond_expire,
1518 )?;
1519 (msg, "take_profit_limit")
1520 }
1521 _ => unreachable!("Order type already validated"),
1522 };
1523
1524 let operation = format!("Submit {order_type_str} order {client_order_id}");
1527
1528 if order_flags == types::ORDER_FLAG_SHORT_TERM {
1529 broadcaster
1530 .broadcast_short_term(&tx_manager, vec![msg], &operation)
1531 .await?;
1532 } else {
1533 broadcaster
1534 .broadcast_with_retry(&tx_manager, vec![msg], &operation)
1535 .await?;
1536 }
1537 log::debug!("Successfully submitted {order_type_str} order: {client_order_id}");
1538
1539 Ok(())
1540 },
1541 );
1542
1543 Ok(())
1544 }
1545
1546 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
1547 let orders = self.core.get_orders_for_list(&cmd.order_list)?;
1548 let order_count = orders.len();
1549
1550 if !self.is_connected() {
1552 let reason = "Cannot submit order list: execution client not connected";
1553 log::error!("{reason}");
1554 anyhow::bail!(reason);
1555 }
1556
1557 let unsupported_tif_reason = |order: &OrderAny| match order.time_in_force() {
1562 TimeInForce::Fok => Some(
1563 "Fill-or-kill (FOK) orders are deprecated by dYdX v4 (chain rejects with code=48)",
1564 ),
1565 TimeInForce::Day => Some("DAY time-in-force is not supported by dYdX v4"),
1566 _ => None,
1567 };
1568
1569 if orders
1570 .iter()
1571 .any(|order| unsupported_tif_reason(order).is_some())
1572 {
1573 for order in &orders {
1576 let reason = unsupported_tif_reason(order)
1577 .unwrap_or("Order list denied: sibling order has unsupported time in force");
1578 self.emitter.emit_order_denied(order, reason);
1579 }
1580 return Ok(());
1581 }
1582
1583 let current_block = self.block_time_monitor.current_block_height();
1585 if current_block == 0 {
1586 let reason = "Block height not initialized";
1587 log::warn!("Cannot submit order list: {reason}");
1588 for order in &orders {
1589 self.emitter.emit_order_denied(order, reason);
1590 }
1591 return Ok(());
1592 }
1593
1594 let (tx_manager, broadcaster, order_builder) = match self.get_execution_components() {
1596 Ok(components) => components,
1597 Err(e) => {
1598 log::error!("Failed to get execution components for batch: {e}");
1599 for order in &orders {
1600 self.emitter.emit_order_denied(order, &e.to_string());
1601 }
1602 return Ok(());
1603 }
1604 };
1605
1606 let mut order_params: Vec<LimitOrderParams> = Vec::with_capacity(order_count);
1608 let mut order_info: Vec<(ClientOrderId, InstrumentId, StrategyId)> =
1609 Vec::with_capacity(order_count);
1610
1611 for order in &orders {
1612 if order.order_type() != OrderType::Limit {
1614 log::warn!(
1615 "Order {} has type {:?}, falling back to individual submission",
1616 order.client_order_id(),
1617 order.order_type()
1618 );
1619 let submit_cmd = SubmitOrder::new(
1621 cmd.trader_id,
1622 cmd.client_id,
1623 cmd.strategy_id,
1624 order.instrument_id(),
1625 order.client_order_id(),
1626 order.init_event().clone(),
1627 cmd.exec_algorithm_id,
1628 cmd.position_id,
1629 cmd.params.clone(),
1630 UUID4::new(),
1631 cmd.ts_init,
1632 cmd.correlation_id,
1633 );
1634
1635 if let Err(e) = self.submit_order(submit_cmd) {
1636 log::error!(
1637 "Failed to submit order {} from order list: {e}",
1638 order.client_order_id()
1639 );
1640 }
1641 continue;
1642 }
1643
1644 if order.is_quote_quantity() {
1645 self.emitter
1646 .emit_order_denied(order, "Quote quantity orders are not supported by dYdX");
1647 continue;
1648 }
1649
1650 let Some(price) = order.price() else {
1652 self.emitter
1653 .emit_order_denied(order, "Limit order missing price");
1654 continue;
1655 };
1656
1657 let encoded = match self.encoder.encode(order.client_order_id()) {
1659 Ok(enc) => enc,
1660 Err(e) => {
1661 log::error!("Failed to generate client order ID: {e}");
1662 self.emitter.emit_order_denied(order, &e.to_string());
1663 continue;
1664 }
1665 };
1666 let client_id_u32 = encoded.client_id;
1667 let client_metadata = encoded.client_metadata;
1668
1669 self.emitter.emit_order_submitted(order);
1671
1672 let expire_time_secs = order.expire_time().map(nanos_to_secs_i64);
1674 let lifetime = types::OrderLifetime::from_time_in_force(
1675 order.time_in_force(),
1676 expire_time_secs,
1677 false,
1678 order_builder.max_short_term_secs(),
1679 );
1680
1681 let ts_submitted = self.clock.get_time_ns();
1683 self.register_order_context(
1684 client_id_u32,
1685 OrderContext {
1686 client_order_id: order.client_order_id(),
1687 trader_id: order.trader_id(),
1688 strategy_id: order.strategy_id(),
1689 instrument_id: order.instrument_id(),
1690 submitted_at: ts_submitted,
1691 order_flags: lifetime.order_flags(),
1692 },
1693 );
1694
1695 self.dispatch_state.order_identities.insert(
1697 order.client_order_id(),
1698 OrderIdentity {
1699 instrument_id: order.instrument_id(),
1700 strategy_id: order.strategy_id(),
1701 order_side: order.order_side(),
1702 order_type: order.order_type(),
1703 },
1704 );
1705
1706 order_params.push(LimitOrderParams {
1708 instrument_id: order.instrument_id(),
1709 client_order_id: client_id_u32,
1710 client_metadata,
1711 side: order.order_side(),
1712 price,
1713 quantity: order.quantity(),
1714 time_in_force: order.time_in_force(),
1715 post_only: order.is_post_only(),
1716 reduce_only: order.is_reduce_only(),
1717 expire_time_ns: order.expire_time(),
1718 });
1719 order_info.push((
1720 order.client_order_id(),
1721 order.instrument_id(),
1722 order.strategy_id(),
1723 ));
1724 }
1725
1726 if order_params.is_empty() {
1728 return Ok(());
1729 }
1730
1731 let has_short_term = order_params
1735 .iter()
1736 .any(|params| order_builder.is_short_term_order(params));
1737
1738 let block_height = current_block as u32;
1739 let emitter = self.emitter.clone();
1740 let clock = self.clock;
1741
1742 if has_short_term {
1743 log::debug!(
1745 "Submitting {} short-term limit orders concurrently (sequence not consumed)",
1746 order_params.len()
1747 );
1748
1749 let order_count = order_params.len();
1750
1751 let handle = get_runtime().spawn(async move {
1752 let mut handles = Vec::with_capacity(order_count);
1755
1756 for (params, (client_order_id, instrument_id, strategy_id)) in
1757 order_params.into_iter().zip(order_info)
1758 {
1759 let tx_manager = tx_manager.clone();
1760 let broadcaster = broadcaster.clone();
1761 let order_builder = order_builder.clone();
1762 let emitter = emitter.clone();
1763
1764 let handle = get_runtime().spawn(async move {
1765 let msg = match order_builder
1767 .build_limit_order_from_params(¶ms, block_height)
1768 {
1769 Ok(m) => m,
1770 Err(e) => {
1771 log::error!(
1774 "Failed to build order message for {client_order_id}: {e:?}"
1775 );
1776 return;
1777 }
1778 };
1779
1780 let operation = format!("Submit short-term order {client_order_id}");
1782
1783 if let Err(e) = broadcaster
1784 .broadcast_short_term(&tx_manager, vec![msg], &operation)
1785 .await
1786 {
1787 if e.is_definitive_broadcast_rejection() {
1788 let error_msg = format!("Order submission failed: {e:?}");
1789 log::error!("{error_msg}");
1790 let ts_event = clock.get_time_ns();
1791 emitter.emit_order_rejected_event(
1792 strategy_id,
1793 instrument_id,
1794 client_order_id,
1795 &error_msg,
1796 ts_event,
1797 false,
1798 );
1799 } else {
1800 log::warn!(
1801 "Ambiguous dYdX submit failure for {client_order_id}, awaiting reconciliation: {e:?}"
1802 );
1803 }
1804 }
1805 });
1806
1807 handles.push(handle);
1808 }
1809
1810 for handle in handles {
1812 let _ = handle.await;
1813 }
1814 });
1815
1816 self.pending_tasks
1818 .lock()
1819 .expect(MUTEX_POISONED)
1820 .push(("batch_submit_short_term", handle));
1821 } else {
1822 log::debug!(
1824 "Batch submitting {} long-term limit orders in single transaction",
1825 order_params.len()
1826 );
1827
1828 let handle = get_runtime().spawn(async move {
1829 let msgs: Result<Vec<_>, _> = order_params
1831 .iter()
1832 .map(|params| order_builder.build_limit_order_from_params(params, block_height))
1833 .collect();
1834
1835 let msgs = match msgs {
1836 Ok(m) => m,
1837 Err(e) => {
1838 log::error!("Failed to build batch order messages: {e:?}");
1841 return;
1842 }
1843 };
1844
1845 let operation = format!("Submit batch of {} limit orders", msgs.len());
1847
1848 if let Err(e) = broadcaster
1849 .broadcast_with_retry(&tx_manager, msgs, &operation)
1850 .await
1851 {
1852 if e.is_definitive_broadcast_rejection() {
1853 let error_msg = format!("Batch order submission failed: {e:?}");
1856 log::error!("{error_msg}");
1857 let ts_event = clock.get_time_ns();
1858
1859 for (client_order_id, instrument_id, strategy_id) in order_info {
1860 emitter.emit_order_rejected_event(
1861 strategy_id,
1862 instrument_id,
1863 client_order_id,
1864 &error_msg,
1865 ts_event,
1866 false,
1867 );
1868 }
1869 } else {
1870 log::warn!(
1871 "Ambiguous dYdX batch submit failure, awaiting reconciliation: {e:?}"
1872 );
1873 }
1874 }
1875 });
1876
1877 self.pending_tasks
1879 .lock()
1880 .expect(MUTEX_POISONED)
1881 .push(("batch_submit_long_term", handle));
1882 }
1883
1884 Ok(())
1885 }
1886
1887 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1891 let reason = "dYdX does not support order modification. Use cancel and resubmit instead.";
1892 log::error!("{reason}");
1893
1894 self.send_modify_rejected(
1895 cmd.strategy_id,
1896 cmd.instrument_id,
1897 cmd.client_order_id,
1898 cmd.venue_order_id,
1899 reason,
1900 );
1901 Ok(())
1902 }
1903
1904 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1923 if !self.is_connected() {
1924 anyhow::bail!("Cannot cancel order: not connected");
1925 }
1926
1927 let client_order_id = cmd.client_order_id;
1928 let instrument_id = cmd.instrument_id;
1929 let strategy_id = cmd.strategy_id;
1930 let venue_order_id = cmd.venue_order_id;
1931
1932 let (order_time_in_force, order_expire_time) = {
1933 let cache = self.core.cache();
1934
1935 let order = match cache.order(&client_order_id) {
1936 Some(order) => order,
1937 None => {
1938 log::error!("Cannot cancel order {client_order_id}: not found in cache");
1939 return Ok(()); }
1941 };
1942
1943 if order.is_closed() {
1945 log::warn!(
1946 "CancelOrder command for {} when order already {} (will not send to exchange)",
1947 client_order_id,
1948 order.status()
1949 );
1950 return Ok(());
1951 }
1952
1953 if cache.instrument(&instrument_id).is_none() {
1955 log::error!(
1956 "Cannot cancel order {client_order_id}: instrument {instrument_id} not found in cache"
1957 );
1958 return Ok(()); }
1960
1961 (
1963 order.time_in_force(),
1964 order.expire_time().map(nanos_to_secs_i64),
1965 )
1966 }; log::debug!("Cancelling order {client_order_id} for instrument {instrument_id}");
1969
1970 let (tx_manager, broadcaster, order_builder) = match self.get_execution_components() {
1972 Ok(components) => components,
1973 Err(e) => {
1974 log::error!("Failed to get execution components for cancel: {e}");
1975 return Ok(());
1976 }
1977 };
1978
1979 let block_height = self.block_time_monitor.current_block_height() as u32;
1980
1981 let encoded = match self.encoder.get(&client_order_id) {
1983 Some(enc) => enc,
1984 None => {
1985 log::error!("Client order ID {client_order_id} not found in cache");
1986 anyhow::bail!("Client order ID not found in cache")
1987 }
1988 };
1989 let client_id_u32 = encoded.client_id;
1990
1991 log::debug!(
1992 "[CANCEL_ORDER] Nautilus '{client_order_id}' -> dYdX u32={client_id_u32} | instrument={instrument_id}"
1993 );
1994
1995 let order_flags = self.get_order_context(client_id_u32).map_or_else(
1998 || {
1999 log::warn!(
2001 "Order context not found for {client_order_id}, deriving flags from order"
2002 );
2003 types::OrderLifetime::from_time_in_force(
2004 order_time_in_force, order_expire_time, false,
2007 order_builder.max_short_term_secs(),
2008 )
2009 .order_flags()
2010 },
2011 |ctx| ctx.order_flags,
2012 );
2013
2014 let clock = self.clock;
2015 let emitter = self.emitter.clone();
2016
2017 self.spawn_task("cancel_order", async move {
2018 let cancel_msg = match order_builder.build_cancel_order_with_flags(
2020 instrument_id,
2021 client_id_u32,
2022 order_flags,
2023 block_height,
2024 ) {
2025 Ok(msg) => msg,
2026 Err(e) => {
2027 log::warn!(
2030 "Cancel command failed local validation for {client_order_id}: {e:?}"
2031 );
2032 return Ok(());
2033 }
2034 };
2035
2036 let cancel_op = format!("Cancel order {client_order_id}");
2038 let result = if order_flags == types::ORDER_FLAG_SHORT_TERM {
2039 broadcaster
2040 .broadcast_short_term(&tx_manager, vec![cancel_msg], &cancel_op)
2041 .await
2042 } else {
2043 broadcaster
2044 .broadcast_with_retry(&tx_manager, vec![cancel_msg], &cancel_op)
2045 .await
2046 };
2047
2048 match result {
2049 Ok(_) => {
2050 log::debug!("Successfully cancelled order: {client_order_id}");
2051 }
2052 Err(e) if e.is_definitive_broadcast_rejection() => {
2053 log::error!("Failed to cancel order {client_order_id}: {e:?}");
2054
2055 let ts_event = clock.get_time_ns();
2056 emitter.emit_order_cancel_rejected_event(
2057 strategy_id,
2058 instrument_id,
2059 client_order_id,
2060 venue_order_id,
2061 &format!("Cancel order failed: {e:?}"),
2062 ts_event,
2063 );
2064 }
2065 Err(e) => {
2066 log::warn!(
2067 "Ambiguous dYdX cancel failure for {client_order_id}, awaiting reconciliation: {e:?}"
2068 );
2069 }
2070 }
2071
2072 Ok(())
2073 });
2074
2075 Ok(())
2076 }
2077
2078 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
2079 if !self.is_connected() {
2080 anyhow::bail!("Cannot cancel orders: not connected");
2081 }
2082
2083 let instrument_id = cmd.instrument_id;
2084 let order_side_filter = cmd.order_side;
2085
2086 let order_data: Vec<CancelAllOrderData> = {
2087 let cache = self.core.cache();
2088 let side_filter =
2089 (order_side_filter != OrderSide::NoOrderSide).then_some(order_side_filter);
2090 cache
2091 .orders_open(None, Some(&instrument_id), None, None, side_filter)
2092 .into_iter()
2093 .map(|order| {
2094 (
2095 order.strategy_id(),
2096 order.client_order_id(),
2097 order.venue_order_id(),
2098 order.time_in_force(),
2099 order.expire_time(),
2100 )
2101 })
2102 .collect()
2103 }; let short_term_count = order_data
2107 .iter()
2108 .filter(|(_, _, _, tif, _)| matches!(tif, TimeInForce::Ioc | TimeInForce::Fok))
2109 .count();
2110 let long_term_count = order_data.len() - short_term_count;
2111
2112 log::debug!(
2113 "Cancel all orders: total={}, short_term={}, long_term={}, instrument_id={instrument_id}, order_side={order_side_filter:?}",
2114 order_data.len(),
2115 short_term_count,
2116 long_term_count
2117 );
2118
2119 let (tx_manager, broadcaster, order_builder) = match self.get_execution_components() {
2121 Ok(components) => components,
2122 Err(e) => {
2123 log::error!("Failed to get execution components for cancel_all: {e}");
2124 return Ok(());
2125 }
2126 };
2127
2128 let block_height = self.block_time_monitor.current_block_height() as u32;
2129
2130 let mut orders_to_cancel = Vec::new();
2133
2134 for (strategy_id, client_order_id, venue_order_id, _time_in_force, _expire_time) in
2135 &order_data
2136 {
2137 let Some(encoded) = self.encoder.get(client_order_id) else {
2138 log::warn!("Cannot cancel order {client_order_id}: not found in encoder");
2139 continue;
2140 };
2141 let client_id_u32 = encoded.client_id;
2142
2143 let Some(ctx) = self.get_order_context(client_id_u32) else {
2145 log::debug!(
2146 "Skipping cancel for {client_order_id}: order context already cleaned up (terminal)"
2147 );
2148 continue;
2149 };
2150 orders_to_cancel.push(DydxCancelOrderRequest {
2151 instrument_id,
2152 client_id: client_id_u32,
2153 order_flags: ctx.order_flags,
2154 strategy_id: *strategy_id,
2155 client_order_id: *client_order_id,
2156 venue_order_id: *venue_order_id,
2157 });
2158 }
2159
2160 if orders_to_cancel.is_empty() {
2161 return Ok(());
2162 }
2163
2164 log::debug!(
2165 "Cancel all: {} orders (short_term={}, long_term={}), instrument_id={instrument_id}, order_side={order_side_filter:?}",
2166 orders_to_cancel.len(),
2167 orders_to_cancel
2168 .iter()
2169 .filter(|order| order.order_flags == types::ORDER_FLAG_SHORT_TERM)
2170 .count(),
2171 orders_to_cancel
2172 .iter()
2173 .filter(|order| order.order_flags != types::ORDER_FLAG_SHORT_TERM)
2174 .count(),
2175 );
2176
2177 let clock = self.clock;
2178 let emitter = self.emitter.clone();
2179
2180 self.spawn_task("cancel_all_orders", async move {
2181 broadcast_partitioned_cancels(
2182 orders_to_cancel,
2183 block_height,
2184 tx_manager,
2185 broadcaster,
2186 order_builder,
2187 emitter,
2188 clock,
2189 )
2190 .await
2191 });
2192
2193 Ok(())
2194 }
2195
2196 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
2197 if cmd.cancels.is_empty() {
2198 return Ok(());
2199 }
2200
2201 if !self.is_connected() {
2202 anyhow::bail!("Cannot cancel orders: not connected");
2203 }
2204
2205 let (tx_manager, broadcaster, order_builder) = match self.get_execution_components() {
2207 Ok(components) => components,
2208 Err(e) => {
2209 log::error!("Failed to get execution components for batch cancel: {e}");
2210 return Ok(());
2211 }
2212 };
2213
2214 let mut orders_to_cancel = Vec::with_capacity(cmd.cancels.len());
2216 for cancel in &cmd.cancels {
2217 let client_order_id = cancel.client_order_id;
2218 let encoded = match self.encoder.get(&client_order_id) {
2219 Some(enc) => enc,
2220 None => {
2221 log::warn!(
2222 "No u32 mapping found for client_order_id={client_order_id}, skipping cancel"
2223 );
2224 continue;
2225 }
2226 };
2227 let client_id_u32 = encoded.client_id;
2228
2229 let Some(ctx) = self.get_order_context(client_id_u32) else {
2231 log::debug!(
2232 "Skipping cancel for {client_order_id}: order context already cleaned up (terminal)"
2233 );
2234 continue;
2235 };
2236
2237 orders_to_cancel.push(DydxCancelOrderRequest {
2238 instrument_id: cancel.instrument_id,
2239 client_id: client_id_u32,
2240 order_flags: ctx.order_flags,
2241 strategy_id: cancel.strategy_id,
2242 client_order_id,
2243 venue_order_id: cancel.venue_order_id,
2244 });
2245 }
2246
2247 if orders_to_cancel.is_empty() {
2248 log::warn!("No valid orders to cancel in batch");
2249 return Ok(());
2250 }
2251
2252 let block_height = self.block_time_monitor.current_block_height() as u32;
2253 let clock = self.clock;
2254 let emitter = self.emitter.clone();
2255
2256 log::debug!(
2257 "Batch cancelling {} orders via partitioned strategy",
2258 orders_to_cancel.len(),
2259 );
2260
2261 self.spawn_task("batch_cancel_orders", async move {
2262 broadcast_partitioned_cancels(
2263 orders_to_cancel,
2264 block_height,
2265 tx_manager,
2266 broadcaster,
2267 order_builder,
2268 emitter,
2269 clock,
2270 )
2271 .await
2272 });
2273
2274 Ok(())
2275 }
2276
2277 fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
2278 let http_client = self.http_client.clone();
2279 let wallet_address = self.wallet_address.clone();
2280 let subaccount_number = self.subaccount_number;
2281 let account_id = self.core.account_id;
2282 let emitter = self.emitter.clone();
2283
2284 self.spawn_task("query_account", async move {
2285 let account_state = http_client
2286 .request_account_state(&wallet_address, subaccount_number, account_id)
2287 .await
2288 .context("failed to query account state")?;
2289
2290 emitter.emit_account_state(
2291 account_state.balances.clone(),
2292 account_state.margins.clone(),
2293 account_state.is_reported,
2294 account_state.ts_event,
2295 );
2296 Ok(())
2297 });
2298
2299 Ok(())
2300 }
2301
2302 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
2303 log::debug!("Querying order: client_order_id={}", cmd.client_order_id);
2304
2305 let http_client = self.http_client.clone();
2306 let wallet_address = self.wallet_address.clone();
2307 let subaccount_number = self.subaccount_number;
2308 let account_id = self.core.account_id;
2309 let emitter = self.emitter.clone();
2310 let client_order_id = cmd.client_order_id;
2311 let venue_order_id = cmd.venue_order_id;
2312 let instrument_id = cmd.instrument_id;
2313
2314 self.spawn_task("query_order", async move {
2315 let reports = http_client
2316 .request_order_status_reports(
2317 &wallet_address,
2318 subaccount_number,
2319 account_id,
2320 Some(instrument_id),
2321 )
2322 .await
2323 .context("failed to query order status")?;
2324
2325 let report = reports.into_iter().find(|r| {
2327 if venue_order_id.is_some_and(|vid| r.venue_order_id == vid) {
2328 return true;
2329 }
2330 r.client_order_id.is_some_and(|cid| cid == client_order_id)
2331 });
2332
2333 if let Some(report) = report {
2334 emitter.send_order_status_report(report);
2335 } else {
2336 log::warn!(
2337 "No order found for client_order_id={client_order_id}, venue_order_id={venue_order_id:?}"
2338 );
2339 }
2340
2341 Ok(())
2342 });
2343
2344 Ok(())
2345 }
2346
2347 async fn connect(&mut self) -> anyhow::Result<()> {
2348 if self.core.is_connected() {
2349 log::warn!("dYdX execution client already connected");
2350 return Ok(());
2351 }
2352
2353 log::info!("Connecting to dYdX");
2354
2355 log::debug!("Loading instruments from HTTP API");
2356 self.http_client.fetch_and_cache_instruments().await?;
2357 log::debug!(
2358 "Loaded {} instruments from HTTP into shared cache",
2359 self.http_client.cached_instruments_count()
2360 );
2361 self.mark_instruments_initialized();
2362
2363 let grpc_urls = self.config.get_grpc_urls();
2365 let mut grpc_client = DydxGrpcClient::new_with_fallback(&grpc_urls)
2366 .await
2367 .context("failed to construct dYdX gRPC client")?;
2368 log::debug!("gRPC client initialized");
2369
2370 let initial_height = grpc_client
2372 .latest_block_height()
2373 .await
2374 .context("failed to fetch initial block height")?;
2375 self.block_time_monitor
2377 .record_block(initial_height.0 as u64, chrono::Utc::now());
2378 log::debug!("Initial block height: {}", initial_height.0);
2379
2380 *self.grpc_client.write().await = Some(grpc_client.clone());
2381
2382 let private_key =
2384 Self::resolve_private_key(&self.config).context("failed to resolve private key")?;
2385 let tx_manager = Arc::new(
2386 TransactionManager::new(
2387 grpc_client.clone(),
2388 &private_key,
2389 self.wallet_address.clone(),
2390 self.get_chain_id(),
2391 )
2392 .context("failed to create TransactionManager")?,
2393 );
2394
2395 tx_manager
2396 .resolve_authenticators()
2397 .await
2398 .context("failed to resolve authenticators")?;
2399
2400 tx_manager
2403 .initialize_sequence()
2404 .await
2405 .context("failed to initialize sequence")?;
2406
2407 self.tx_manager = Some(tx_manager);
2408 self.broadcaster = Some(Arc::new(TxBroadcaster::new(
2409 grpc_client,
2410 self.config.grpc_quota(),
2411 )));
2412 self.order_builder = Some(Arc::new(OrderMessageBuilder::new(
2413 self.http_client.clone(),
2414 self.wallet_address.clone(),
2415 self.subaccount_number,
2416 self.block_time_monitor.clone(),
2417 )));
2418 log::debug!(
2419 "OrderMessageBuilder initialized (block_time_monitor ready: {}, max_short_term: {:.1}s)",
2420 self.block_time_monitor.is_ready(),
2421 SHORT_TERM_ORDER_MAXIMUM_LIFETIME as f64
2422 * self.block_time_monitor.seconds_per_block_or_default()
2423 );
2424
2425 self.ws_client.connect().await?;
2427 log::debug!("WebSocket connected");
2428
2429 self.ws_client.subscribe_block_height().await?;
2431 log::debug!("Subscribed to block height updates");
2432
2433 self.ws_client.subscribe_markets().await?;
2435 log::debug!("Subscribed to markets");
2436
2437 log::debug!(
2439 "Using wallet address for queries: {} (subaccount {})",
2440 self.wallet_address,
2441 self.subaccount_number
2442 );
2443 self.ws_client
2444 .subscribe_subaccount(&self.wallet_address, self.subaccount_number)
2445 .await?;
2446 log::debug!(
2447 "Subscribed to subaccount updates: {}/{}",
2448 self.wallet_address,
2449 self.subaccount_number
2450 );
2451
2452 let stream = self.ws_client.stream();
2453 self.spawn_ws_stream_handler(stream);
2454
2455 self.await_account_registered(30.0).await?;
2459
2460 self.core.set_connected();
2461 log::info!("Connected: client_id={}", self.core.client_id);
2462 Ok(())
2463 }
2464
2465 async fn disconnect(&mut self) -> anyhow::Result<()> {
2466 if self.core.is_disconnected() {
2467 log::warn!("dYdX execution client not connected");
2468 return Ok(());
2469 }
2470
2471 log::info!("Disconnecting from dYdX");
2472
2473 let _ = self
2475 .ws_client
2476 .unsubscribe_subaccount(&self.wallet_address, self.subaccount_number)
2477 .await
2478 .map_err(|e| log::warn!("Failed to unsubscribe from subaccount: {e}"));
2479
2480 let _ = self
2482 .ws_client
2483 .unsubscribe_markets()
2484 .await
2485 .map_err(|e| log::warn!("Failed to unsubscribe from markets: {e}"));
2486
2487 let _ = self
2489 .ws_client
2490 .unsubscribe_block_height()
2491 .await
2492 .map_err(|e| log::warn!("Failed to unsubscribe from block height: {e}"));
2493
2494 self.ws_client.disconnect().await?;
2496
2497 if let Some(handle) = self.ws_stream_handle.take() {
2499 handle.abort();
2500 log::debug!("Aborted WebSocket message processing task");
2501 }
2502
2503 self.abort_pending_tasks();
2505
2506 self.core.set_disconnected();
2507 log::info!("Disconnected: client_id={}", self.core.client_id);
2508 Ok(())
2509 }
2510
2511 async fn generate_order_status_report(
2512 &self,
2513 cmd: &GenerateOrderStatusReport,
2514 ) -> anyhow::Result<Option<OrderStatusReport>> {
2515 let market = cmd
2520 .instrument_id
2521 .map(|id| id.symbol.as_str().trim_end_matches("-PERP").to_string());
2522
2523 let response = self
2524 .http_client
2525 .inner
2526 .get_orders(
2527 &self.wallet_address,
2528 self.subaccount_number,
2529 market.as_deref(),
2530 Some(DYDX_INDEXER_REPORT_LIMIT),
2531 )
2532 .await
2533 .context("failed to fetch order from dYdX API")?;
2534
2535 if response.is_empty() {
2536 log::debug!(
2537 "No orders returned for {}/subaccount={} (market_filter={:?})",
2538 self.wallet_address,
2539 self.subaccount_number,
2540 market,
2541 );
2542 return Ok(None);
2543 }
2544
2545 let ts_init = UnixNanos::default();
2546 let scanned_count = response.len();
2547
2548 let report = find_matching_order_report(
2549 &response,
2550 cmd.instrument_id,
2551 cmd.client_order_id,
2552 cmd.venue_order_id,
2553 |clob_pair_id| self.get_instrument_by_clob_pair_id(clob_pair_id),
2554 &self.encoder,
2555 self.core.account_id,
2556 ts_init,
2557 )?;
2558
2559 if report.is_none() {
2560 let page_full = scanned_count == DYDX_INDEXER_REPORT_LIMIT as usize;
2564 log::debug!(
2565 "No order matched filters for {}/subaccount={} \
2566 (client_order_id={:?}, venue_order_id={:?}, instrument_id={:?}, \
2567 scanned={scanned_count}, page_full={page_full}, limit={DYDX_INDEXER_REPORT_LIMIT})",
2568 self.wallet_address,
2569 self.subaccount_number,
2570 cmd.client_order_id,
2571 cmd.venue_order_id,
2572 cmd.instrument_id,
2573 );
2574 }
2575
2576 Ok(report)
2577 }
2578
2579 async fn generate_order_status_reports(
2580 &self,
2581 cmd: &GenerateOrderStatusReports,
2582 ) -> anyhow::Result<Vec<OrderStatusReport>> {
2583 let response = self
2585 .http_client
2586 .inner
2587 .get_orders(
2588 &self.wallet_address,
2589 self.subaccount_number,
2590 None, Some(DYDX_INDEXER_REPORT_LIMIT),
2592 )
2593 .await
2594 .context("failed to fetch orders from dYdX API")?;
2595
2596 let mut reports = Vec::new();
2597 let ts_init = UnixNanos::default();
2598
2599 for order in response {
2600 let instrument = match self.get_instrument_by_clob_pair_id(order.clob_pair_id) {
2601 Some(inst) => inst,
2602 None => continue,
2603 };
2604
2605 if let Some(filter_id) = cmd.instrument_id
2606 && instrument.id() != filter_id
2607 {
2608 continue;
2609 }
2610
2611 match parse_order_status_report(&order, &instrument, self.core.account_id, ts_init) {
2612 Ok(mut r) => {
2613 if !order.client_id.is_empty()
2614 && let Ok(client_id_u32) = order.client_id.parse::<u32>()
2615 {
2616 self.encoder.register_known_client_id(client_id_u32);
2617
2618 if let Some(decoded) = self
2619 .encoder
2620 .decode_if_known(client_id_u32, order.client_metadata)
2621 {
2622 log::debug!(
2623 "Decoded order: dYdX client_id={} meta={:#x} -> '{}'",
2624 client_id_u32,
2625 order.client_metadata,
2626 decoded,
2627 );
2628 r.client_order_id = Some(decoded);
2629 }
2630 }
2631 reports.push(r);
2632 }
2633 Err(e) => {
2634 log::warn!("Failed to parse order status report: {e}");
2635 }
2636 }
2637 }
2638
2639 if cmd.open_only {
2641 reports.retain(|r| r.order_status.is_open());
2642 }
2643
2644 if let Some(start) = cmd.start {
2646 reports.retain(|r| r.ts_last >= start);
2647 }
2648
2649 if let Some(end) = cmd.end {
2650 reports.retain(|r| r.ts_last <= end);
2651 }
2652
2653 {
2660 let cache = self.core.cache();
2661 reports.retain(|r| {
2662 let Some(cid) = r.client_order_id else {
2663 return true;
2664 };
2665
2666 match cache.order(&cid) {
2667 Some(order) if order.status().is_closed() => {
2668 log::debug!(
2669 "Skipping reconciliation report for terminal order {cid} \
2670 (cache status={:?}, venue status={:?})",
2671 order.status(),
2672 r.order_status,
2673 );
2674 false
2675 }
2676 _ => true,
2677 }
2678 });
2679 }
2680
2681 Ok(reports)
2682 }
2683
2684 async fn generate_fill_reports(
2685 &self,
2686 cmd: GenerateFillReports,
2687 ) -> anyhow::Result<Vec<FillReport>> {
2688 let response = self
2689 .http_client
2690 .inner
2691 .get_fills(
2692 &self.wallet_address,
2693 self.subaccount_number,
2694 None, Some(DYDX_INDEXER_REPORT_LIMIT),
2696 )
2697 .await
2698 .context("failed to fetch fills from dYdX API")?;
2699
2700 let mut reports = Vec::new();
2701 let ts_init = UnixNanos::default();
2702
2703 for fill in response.fills {
2704 let instrument = match self.get_instrument_by_market(&fill.market) {
2705 Some(inst) => inst,
2706 None => {
2707 log::warn!("Unknown market in fill: {}", fill.market);
2708 continue;
2709 }
2710 };
2711
2712 if let Some(filter_id) = cmd.instrument_id
2713 && instrument.id() != filter_id
2714 {
2715 continue;
2716 }
2717
2718 let report = match parse_fill_report(&fill, &instrument, self.core.account_id, ts_init)
2719 {
2720 Ok(r) => r,
2721 Err(e) => {
2722 log::warn!("Failed to parse fill report: {e}");
2723 continue;
2724 }
2725 };
2726
2727 reports.push(report);
2728 }
2729
2730 if let Some(venue_order_id) = cmd.venue_order_id {
2731 reports.retain(|r| r.venue_order_id.as_str() == venue_order_id.as_str());
2732 }
2733
2734 Ok(reports)
2735 }
2736
2737 async fn generate_position_status_reports(
2738 &self,
2739 cmd: &GeneratePositionStatusReports,
2740 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2741 let response = self
2743 .http_client
2744 .inner
2745 .get_subaccount(&self.wallet_address, self.subaccount_number)
2746 .await
2747 .context("failed to fetch subaccount from dYdX API")?;
2748
2749 let mut reports = Vec::new();
2750 let ts_init = UnixNanos::default();
2751
2752 for (market_ticker, perp_position) in &response.subaccount.open_perpetual_positions {
2753 let instrument = match self.get_instrument_by_market(market_ticker) {
2754 Some(inst) => inst,
2755 None => {
2756 log::warn!("Unknown market in position: {market_ticker}");
2757 continue;
2758 }
2759 };
2760
2761 if let Some(filter_id) = cmd.instrument_id
2762 && instrument.id() != filter_id
2763 {
2764 continue;
2765 }
2766
2767 let report = match parse_position_status_report(
2768 perp_position,
2769 &instrument,
2770 self.core.account_id,
2771 ts_init,
2772 ) {
2773 Ok(r) => r,
2774 Err(e) => {
2775 log::warn!("Failed to parse position status report: {e}");
2776 continue;
2777 }
2778 };
2779
2780 reports.push(report);
2781 }
2782
2783 Ok(reports)
2784 }
2785
2786 async fn generate_mass_status(
2787 &self,
2788 lookback_mins: Option<u64>,
2789 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
2790 let ts_init = UnixNanos::default();
2791
2792 let orders_response = self
2794 .http_client
2795 .inner
2796 .get_orders(
2797 &self.wallet_address,
2798 self.subaccount_number,
2799 None,
2800 Some(DYDX_INDEXER_REPORT_LIMIT),
2801 )
2802 .await
2803 .context("failed to fetch orders for mass status")?;
2804
2805 let subaccount_response = self
2807 .http_client
2808 .inner
2809 .get_subaccount(&self.wallet_address, self.subaccount_number)
2810 .await
2811 .context("failed to fetch subaccount for mass status")?;
2812
2813 let fills_response = self
2815 .http_client
2816 .inner
2817 .get_fills(
2818 &self.wallet_address,
2819 self.subaccount_number,
2820 None,
2821 Some(DYDX_INDEXER_REPORT_LIMIT),
2822 )
2823 .await
2824 .context("failed to fetch fills for mass status")?;
2825
2826 let mut order_reports = Vec::new();
2828 let mut orders_filtered = 0usize;
2829
2830 for order in orders_response {
2831 let instrument = match self.get_instrument_by_clob_pair_id(order.clob_pair_id) {
2832 Some(inst) => inst,
2833 None => {
2834 orders_filtered += 1;
2835 continue;
2836 }
2837 };
2838
2839 match parse_order_status_report(&order, &instrument, self.core.account_id, ts_init) {
2840 Ok(mut r) => {
2841 if !order.client_id.is_empty()
2842 && let Ok(client_id_u32) = order.client_id.parse::<u32>()
2843 {
2844 self.encoder.register_known_client_id(client_id_u32);
2845
2846 if let Some(decoded) = self
2847 .encoder
2848 .decode_if_known(client_id_u32, order.client_metadata)
2849 {
2850 log::debug!(
2851 "Decoded reconciliation order: dYdX client_id={} meta={:#x} -> '{}'",
2852 client_id_u32,
2853 order.client_metadata,
2854 decoded,
2855 );
2856 r.client_order_id = Some(decoded);
2857 }
2858 }
2859 order_reports.push(r);
2860 }
2861 Err(e) => {
2862 log::warn!("Failed to parse order status report: {e}");
2863 orders_filtered += 1;
2864 }
2865 }
2866 }
2867
2868 let mut position_reports = Vec::new();
2870
2871 for (market_ticker, perp_position) in
2872 &subaccount_response.subaccount.open_perpetual_positions
2873 {
2874 let instrument = match self.get_instrument_by_market(market_ticker) {
2875 Some(inst) => inst,
2876 None => continue,
2877 };
2878
2879 match parse_position_status_report(
2880 perp_position,
2881 &instrument,
2882 self.core.account_id,
2883 ts_init,
2884 ) {
2885 Ok(r) => position_reports.push(r),
2886 Err(e) => {
2887 log::warn!("Failed to parse position status report: {e}");
2888 }
2889 }
2890 }
2891
2892 let mut fill_reports = Vec::new();
2894 let mut fills_filtered = 0usize;
2895
2896 for fill in fills_response.fills {
2897 let instrument = match self.get_instrument_by_market(&fill.market) {
2898 Some(inst) => inst,
2899 None => {
2900 fills_filtered += 1;
2901 continue;
2902 }
2903 };
2904
2905 match parse_fill_report(&fill, &instrument, self.core.account_id, ts_init) {
2906 Ok(r) => fill_reports.push(r),
2907 Err(e) => {
2908 log::warn!("Failed to parse fill report: {e}");
2909 fills_filtered += 1;
2910 }
2911 }
2912 }
2913
2914 apply_avg_px_from_fills(&mut order_reports, &fill_reports);
2915
2916 {
2921 let cache = self.core.cache();
2922 order_reports.retain(|r| {
2923 let Some(cid) = r.client_order_id else {
2924 return true;
2925 };
2926
2927 match cache.order(&cid) {
2928 Some(order) if order.status().is_closed() => {
2929 log::debug!(
2930 "Skipping reconciliation report for terminal order {cid} \
2931 (cache status={:?}, venue status={:?})",
2932 order.status(),
2933 r.order_status,
2934 );
2935 false
2936 }
2937 _ => true,
2938 }
2939 });
2940 }
2941
2942 if let Some(mins) = lookback_mins {
2944 let now_ns = self.clock.get_time_ns();
2945 let cutoff_ns = now_ns.as_u64().saturating_sub(mins * 60 * 1_000_000_000);
2946 let cutoff = UnixNanos::from(cutoff_ns);
2947
2948 let orders_before = order_reports.len();
2949 order_reports.retain(|r| r.ts_last >= cutoff);
2950 let orders_removed = orders_before - order_reports.len();
2951
2952 let fills_before = fill_reports.len();
2953 fill_reports.retain(|r| r.ts_event >= cutoff);
2954 let fills_removed = fills_before - fill_reports.len();
2955
2956 log::debug!(
2957 "Lookback filter ({}min): orders {}->{} (removed {}), fills {}->{} (removed {}), positions {} (unfiltered)",
2958 mins,
2959 orders_before,
2960 order_reports.len(),
2961 orders_removed,
2962 fills_before,
2963 fill_reports.len(),
2964 fills_removed,
2965 position_reports.len(),
2966 );
2967 } else {
2968 log::debug!(
2969 "Generated mass status: {} orders ({} filtered), {} positions, {} fills ({} filtered)",
2970 order_reports.len(),
2971 orders_filtered,
2972 position_reports.len(),
2973 fill_reports.len(),
2974 fills_filtered,
2975 );
2976 }
2977
2978 let mut mass_status = ExecutionMassStatus::new(
2980 self.core.client_id,
2981 self.core.account_id,
2982 self.core.venue,
2983 ts_init,
2984 None, );
2986
2987 mass_status.add_order_reports(order_reports);
2988 mass_status.add_position_reports(position_reports);
2989 mass_status.add_fill_reports(fill_reports);
2990
2991 Ok(Some(mass_status))
2992 }
2993}
2994
2995#[allow(clippy::too_many_arguments)]
2999fn find_matching_order_report<F>(
3000 orders: &[crate::http::models::Order],
3001 instrument_filter: Option<InstrumentId>,
3002 client_order_id_filter: Option<ClientOrderId>,
3003 venue_order_id_filter: Option<VenueOrderId>,
3004 lookup_instrument: F,
3005 encoder: &ClientOrderIdEncoder,
3006 account_id: AccountId,
3007 ts_init: UnixNanos,
3008) -> anyhow::Result<Option<OrderStatusReport>>
3009where
3010 F: Fn(u32) -> Option<InstrumentAny>,
3011{
3012 for order in orders {
3013 let instrument = match lookup_instrument(order.clob_pair_id) {
3014 Some(inst) => inst,
3015 None => continue,
3016 };
3017
3018 if let Some(filter_id) = instrument_filter
3019 && instrument.id() != filter_id
3020 {
3021 continue;
3022 }
3023
3024 let mut report = parse_order_status_report(order, &instrument, account_id, ts_init)
3025 .context("failed to parse order status report")?;
3026
3027 if !order.client_id.is_empty()
3028 && let Ok(client_id_u32) = order.client_id.parse::<u32>()
3029 {
3030 encoder.register_known_client_id(client_id_u32);
3031
3032 if let Some(decoded) = encoder.decode_if_known(client_id_u32, order.client_metadata) {
3033 log::debug!(
3034 "Decoded order: dYdX client_id={} meta={:#x} -> '{}'",
3035 client_id_u32,
3036 order.client_metadata,
3037 decoded,
3038 );
3039 report.client_order_id = Some(decoded);
3040 }
3041 }
3042
3043 if let Some(client_order_id) = client_order_id_filter
3044 && report.client_order_id != Some(client_order_id)
3045 {
3046 continue;
3047 }
3048
3049 if let Some(venue_order_id) = venue_order_id_filter
3050 && report.venue_order_id.as_str() != venue_order_id.as_str()
3051 {
3052 continue;
3053 }
3054
3055 return Ok(Some(report));
3056 }
3057
3058 Ok(None)
3059}
3060
3061#[cfg(test)]
3062mod tests {
3063 use std::{cell::RefCell, rc::Rc};
3064
3065 use chrono::Utc;
3066 use nautilus_common::{
3067 cache::Cache, clock::TestClock, factories::OrderFactory, messages::ExecutionEvent,
3068 };
3069 use nautilus_model::{
3070 enums::OrderSide as NautilusOrderSide,
3071 identifiers::{Symbol, TraderId},
3072 instruments::{CryptoPerpetual, InstrumentAny},
3073 orders::{Order as _, OrderAny},
3074 types::{Currency, Price, Quantity},
3075 };
3076 use rstest::rstest;
3077 use rust_decimal_macros::dec;
3078
3079 use super::*;
3080 use crate::{
3081 common::{
3082 consts::DYDX_CLIENT_ID,
3083 enums::{DydxOrderStatus, DydxOrderType, DydxTimeInForce},
3084 },
3085 http::models::Order,
3086 };
3087
3088 fn test_instrument(symbol: &str, venue: &str) -> InstrumentAny {
3089 let instrument_id = InstrumentId::new(Symbol::new(symbol), Venue::new(venue));
3090 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
3091 instrument_id,
3092 instrument_id.symbol,
3093 Currency::BTC(),
3094 Currency::USD(),
3095 Currency::USD(),
3096 false,
3097 2,
3098 3,
3099 Price::new(0.01, 2),
3100 Quantity::new(0.001, 3),
3101 None,
3102 None,
3103 None,
3104 None,
3105 None,
3106 None,
3107 None,
3108 None,
3109 None,
3110 None,
3111 None,
3112 None,
3113 None,
3114 None,
3115 UnixNanos::default(),
3116 UnixNanos::default(),
3117 ))
3118 }
3119
3120 fn test_order(id: &str, clob_pair_id: u32, client_id: &str) -> Order {
3121 Order {
3122 id: id.to_string(),
3123 subaccount_id: "sub-1".to_string(),
3124 client_id: client_id.to_string(),
3125 clob_pair_id,
3126 side: NautilusOrderSide::Buy,
3127 size: dec!(1.0),
3128 total_filled: dec!(0),
3129 price: dec!(50000),
3130 status: DydxOrderStatus::Open,
3131 order_type: DydxOrderType::Limit,
3132 time_in_force: DydxTimeInForce::Gtt,
3133 reduce_only: false,
3134 post_only: false,
3135 order_flags: 64,
3136 good_til_block: None,
3137 good_til_block_time: None,
3138 created_at_height: Some(100),
3139 client_metadata: 4,
3140 trigger_price: None,
3141 condition_type: None,
3142 conditional_order_trigger_subticks: None,
3143 execution: None,
3144 updated_at: None,
3145 updated_at_height: None,
3146 ticker: None,
3147 subaccount_number: 0,
3148 order_router_address: None,
3149 }
3150 }
3151
3152 fn test_order_factory() -> OrderFactory {
3153 let clock = Rc::new(RefCell::new(TestClock::new()));
3154 OrderFactory::new(
3155 TraderId::from("TRADER-001"),
3156 StrategyId::from("S-001"),
3157 Some(0),
3158 Some(0),
3159 clock,
3160 false,
3161 false,
3162 )
3163 }
3164
3165 fn create_execution_client() -> (
3166 DydxExecutionClient,
3167 Rc<RefCell<Cache>>,
3168 tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
3169 ) {
3170 const TEST_PRIVATE_KEY: &str =
3171 "0000000000000000000000000000000000000000000000000000000000000001";
3172
3173 let cache = Rc::new(RefCell::new(Cache::default()));
3174 let core = ExecutionClientCore::new(
3175 TraderId::from("TRADER-001"),
3176 *DYDX_CLIENT_ID,
3177 *DYDX_VENUE,
3178 OmsType::Netting,
3179 AccountId::from("DYDX-001"),
3180 AccountType::Margin,
3181 None,
3182 cache.clone(),
3183 );
3184 core.set_connected();
3185
3186 let config = DydxAdapterConfig {
3187 base_url: "http://127.0.0.1:1".to_string(),
3188 ws_url: "ws://127.0.0.1:1".to_string(),
3189 grpc_url: "http://127.0.0.1:1".to_string(),
3190 grpc_urls: vec!["http://127.0.0.1:1".to_string()],
3191 wallet_address: Some("dydx1test".to_string()),
3192 private_key: Some(TEST_PRIVATE_KEY.to_string()),
3193 ..Default::default()
3194 };
3195
3196 let mut client =
3197 DydxExecutionClient::new(core, config, "dydx1test".to_string(), 0).unwrap();
3198 client.block_time_monitor.record_block(100, Utc::now());
3199
3200 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
3201 client.emitter.set_sender(sender);
3202
3203 (client, cache, receiver)
3204 }
3205
3206 fn cache_order(cache: &Rc<RefCell<Cache>>, order: OrderAny) {
3207 cache
3208 .borrow_mut()
3209 .add_order(order, None, Some(*DYDX_CLIENT_ID), false)
3210 .unwrap();
3211 }
3212
3213 fn recv_order_event(
3214 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
3215 ) -> OrderEventAny {
3216 match rx.try_recv().expect("expected execution event") {
3217 ExecutionEvent::Order(event) => event,
3218 event => panic!("Expected order event, was {event:?}"),
3219 }
3220 }
3221
3222 #[rstest]
3223 fn test_submit_order_denies_quote_quantity() {
3224 let (client, cache, mut rx) = create_execution_client();
3225 let mut factory = test_order_factory();
3226 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3227 let order = factory.market(
3228 instrument_id,
3229 OrderSide::Buy,
3230 Quantity::from("100"),
3231 Some(TimeInForce::Ioc),
3232 None,
3233 Some(true),
3234 None,
3235 None,
3236 None,
3237 Some(ClientOrderId::from("O-QUOTE-QTY")),
3238 );
3239 cache_order(&cache, order.clone());
3240
3241 let command = SubmitOrder::from_order(
3242 &order,
3243 order.trader_id(),
3244 Some(*DYDX_CLIENT_ID),
3245 None,
3246 UUID4::new(),
3247 UnixNanos::default(),
3248 );
3249 client.submit_order(command).unwrap();
3250
3251 match recv_order_event(&mut rx) {
3252 OrderEventAny::Denied(event) => {
3253 assert_eq!(event.client_order_id, order.client_order_id());
3254 assert_eq!(event.instrument_id, instrument_id);
3255 assert_eq!(
3256 event.reason,
3257 "Quote quantity orders are not supported by dYdX"
3258 );
3259 }
3260 event => panic!("Expected order denied event, was {event:?}"),
3261 }
3262 }
3263
3264 #[rstest]
3265 fn test_submit_order_denies_market_fok() {
3266 let (client, cache, mut rx) = create_execution_client();
3267 let mut factory = test_order_factory();
3268 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3269 let order = factory.market(
3270 instrument_id,
3271 OrderSide::Buy,
3272 Quantity::from("0.1"),
3273 Some(TimeInForce::Fok),
3274 None,
3275 Some(false),
3276 None,
3277 None,
3278 None,
3279 Some(ClientOrderId::from("O-MARKET-FOK")),
3280 );
3281 cache_order(&cache, order.clone());
3282
3283 let command = SubmitOrder::from_order(
3284 &order,
3285 order.trader_id(),
3286 Some(*DYDX_CLIENT_ID),
3287 None,
3288 UUID4::new(),
3289 UnixNanos::default(),
3290 );
3291 client.submit_order(command).unwrap();
3292
3293 match recv_order_event(&mut rx) {
3294 OrderEventAny::Denied(event) => {
3295 assert_eq!(event.client_order_id, order.client_order_id());
3296 assert_eq!(event.instrument_id, instrument_id);
3297 assert!(
3298 event.reason.contains("Fill-or-kill") && event.reason.contains("deprecated"),
3299 "expected FOK-deprecation reason, was: {}",
3300 event.reason,
3301 );
3302 }
3303 event => panic!("Expected order denied event, was {event:?}"),
3304 }
3305 }
3306
3307 #[rstest]
3308 fn test_submit_order_denies_limit_fok() {
3309 let (client, cache, mut rx) = create_execution_client();
3313 let mut factory = test_order_factory();
3314 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3315 let order = factory.limit(
3316 instrument_id,
3317 OrderSide::Buy,
3318 Quantity::from("0.1"),
3319 Price::from("50000.0"),
3320 Some(TimeInForce::Fok),
3321 None,
3322 Some(false),
3323 None,
3324 Some(false),
3325 None,
3326 None,
3327 None,
3328 None,
3329 None,
3330 None,
3331 Some(ClientOrderId::from("O-LIMIT-FOK")),
3332 );
3333 cache_order(&cache, order.clone());
3334
3335 let command = SubmitOrder::from_order(
3336 &order,
3337 order.trader_id(),
3338 Some(*DYDX_CLIENT_ID),
3339 None,
3340 UUID4::new(),
3341 UnixNanos::default(),
3342 );
3343 client.submit_order(command).unwrap();
3344
3345 match recv_order_event(&mut rx) {
3346 OrderEventAny::Denied(event) => {
3347 assert_eq!(event.client_order_id, order.client_order_id());
3348 assert!(
3349 event.reason.contains("Fill-or-kill") && event.reason.contains("deprecated"),
3350 "expected FOK-deprecation reason, was: {}",
3351 event.reason,
3352 );
3353 }
3354 event => panic!("Expected order denied event, was {event:?}"),
3355 }
3356 }
3357
3358 #[rstest]
3359 fn test_submit_order_denies_day_tif() {
3360 let (client, cache, mut rx) = create_execution_client();
3363 let mut factory = test_order_factory();
3364 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3365 let order = factory.limit(
3366 instrument_id,
3367 OrderSide::Buy,
3368 Quantity::from("0.1"),
3369 Price::from("50000.0"),
3370 Some(TimeInForce::Day),
3371 None,
3372 Some(false),
3373 None,
3374 Some(false),
3375 None,
3376 None,
3377 None,
3378 None,
3379 None,
3380 None,
3381 Some(ClientOrderId::from("O-LIMIT-DAY")),
3382 );
3383 cache_order(&cache, order.clone());
3384
3385 let command = SubmitOrder::from_order(
3386 &order,
3387 order.trader_id(),
3388 Some(*DYDX_CLIENT_ID),
3389 None,
3390 UUID4::new(),
3391 UnixNanos::default(),
3392 );
3393 client.submit_order(command).unwrap();
3394
3395 match recv_order_event(&mut rx) {
3396 OrderEventAny::Denied(event) => {
3397 assert_eq!(event.client_order_id, order.client_order_id());
3398 assert!(
3399 event.reason.contains("DAY"),
3400 "expected DAY-unsupported reason, was: {}",
3401 event.reason,
3402 );
3403 }
3404 event => panic!("Expected order denied event, was {event:?}"),
3405 }
3406 }
3407
3408 #[rstest]
3412 #[case(TimeInForce::Fok, "Fill-or-kill")]
3413 #[case(TimeInForce::Day, "DAY")]
3414 fn test_submit_order_list_denies_unsupported_tif(
3415 #[case] tif: TimeInForce,
3416 #[case] reason_substring: &str,
3417 ) {
3418 use nautilus_common::messages::execution::SubmitOrderList;
3419 use nautilus_model::{identifiers::OrderListId, orders::OrderList};
3420
3421 let (client, cache, mut rx) = create_execution_client();
3422 let mut factory = test_order_factory();
3423 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3424 let order = factory.limit(
3425 instrument_id,
3426 OrderSide::Buy,
3427 Quantity::from("0.1"),
3428 Price::from("50000.0"),
3429 Some(tif),
3430 None,
3431 Some(false),
3432 None,
3433 Some(false),
3434 None,
3435 None,
3436 None,
3437 None,
3438 None,
3439 None,
3440 Some(ClientOrderId::from("O-LIST-1")),
3441 );
3442 cache_order(&cache, order.clone());
3443
3444 let order_list = OrderList::new(
3445 OrderListId::from("OL-1"),
3446 instrument_id,
3447 order.strategy_id(),
3448 vec![order.client_order_id()],
3449 UnixNanos::default(),
3450 );
3451 let init = order.init_event().clone();
3452
3453 let cmd = SubmitOrderList::new(
3454 order.trader_id(),
3455 Some(*DYDX_CLIENT_ID),
3456 order.strategy_id(),
3457 order_list,
3458 vec![init],
3459 None,
3460 None,
3461 None,
3462 UUID4::new(),
3463 UnixNanos::default(),
3464 None, );
3466 client.submit_order_list(cmd).unwrap();
3467
3468 match recv_order_event(&mut rx) {
3469 OrderEventAny::Denied(event) => {
3470 assert_eq!(event.client_order_id, order.client_order_id());
3471 assert!(
3472 event.reason.contains(reason_substring),
3473 "expected reason containing {reason_substring:?}, was: {}",
3474 event.reason,
3475 );
3476 }
3477 event => panic!("Expected order denied event, was {event:?}"),
3478 }
3479 }
3480
3481 #[rstest]
3484 fn test_submit_order_list_denies_supported_siblings_on_abort() {
3485 use nautilus_common::messages::execution::SubmitOrderList;
3486 use nautilus_model::{identifiers::OrderListId, orders::OrderList};
3487
3488 let (client, cache, mut rx) = create_execution_client();
3489 let mut factory = test_order_factory();
3490 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3491 let order_gtc = factory.limit(
3492 instrument_id,
3493 OrderSide::Buy,
3494 Quantity::from("0.1"),
3495 Price::from("50000.0"),
3496 Some(TimeInForce::Gtc),
3497 None,
3498 Some(false),
3499 None,
3500 Some(false),
3501 None,
3502 None,
3503 None,
3504 None,
3505 None,
3506 None,
3507 Some(ClientOrderId::from("O-MIXED-GTC")),
3508 );
3509 let order_fok = factory.limit(
3510 instrument_id,
3511 OrderSide::Sell,
3512 Quantity::from("0.1"),
3513 Price::from("51000.0"),
3514 Some(TimeInForce::Fok),
3515 None,
3516 Some(false),
3517 None,
3518 Some(false),
3519 None,
3520 None,
3521 None,
3522 None,
3523 None,
3524 None,
3525 Some(ClientOrderId::from("O-MIXED-FOK")),
3526 );
3527 cache_order(&cache, order_gtc.clone());
3528 cache_order(&cache, order_fok.clone());
3529
3530 let order_list = OrderList::new(
3531 OrderListId::from("OL-MIXED"),
3532 instrument_id,
3533 order_gtc.strategy_id(),
3534 vec![order_gtc.client_order_id(), order_fok.client_order_id()],
3535 UnixNanos::default(),
3536 );
3537
3538 let cmd = SubmitOrderList::new(
3539 order_gtc.trader_id(),
3540 Some(*DYDX_CLIENT_ID),
3541 order_gtc.strategy_id(),
3542 order_list,
3543 vec![
3544 order_gtc.init_event().clone(),
3545 order_fok.init_event().clone(),
3546 ],
3547 None,
3548 None,
3549 None,
3550 UUID4::new(),
3551 UnixNanos::default(),
3552 None, );
3554 client.submit_order_list(cmd).unwrap();
3555
3556 let mut denied_reasons = std::collections::HashMap::new();
3557
3558 for _ in 0..2 {
3559 match recv_order_event(&mut rx) {
3560 OrderEventAny::Denied(event) => {
3561 denied_reasons.insert(event.client_order_id, event.reason);
3562 }
3563 event => panic!("Expected order denied event, was {event:?}"),
3564 }
3565 }
3566
3567 assert!(
3568 denied_reasons[&order_gtc.client_order_id()].contains("sibling order"),
3569 "GTC sibling should be denied with list-abort reason",
3570 );
3571 assert!(
3572 denied_reasons[&order_fok.client_order_id()].contains("Fill-or-kill"),
3573 "FOK order should be denied with TIF reason",
3574 );
3575 }
3576
3577 #[tokio::test]
3583 async fn test_abort_pending_tasks_classifies_and_drains() {
3584 let (client, _cache, _rx) = create_execution_client();
3585
3586 let pending_cancel = tokio::spawn(async {
3590 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
3591 });
3592
3593 let pending_other = tokio::spawn(async {
3594 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
3595 });
3596
3597 {
3598 let mut guard = client.pending_tasks.lock().expect(MUTEX_POISONED);
3599 guard.push(("cancel_all_orders", pending_cancel));
3600 guard.push(("submit_order", pending_other));
3601 assert_eq!(guard.len(), 2);
3602 }
3603
3604 client.abort_pending_tasks();
3605
3606 let guard = client.pending_tasks.lock().expect(MUTEX_POISONED);
3607 assert!(
3608 guard.is_empty(),
3609 "pending_tasks should be drained after abort",
3610 );
3611 }
3612
3613 #[rstest]
3617 #[case("cancel_all_orders", true)]
3618 #[case("batch_cancel_orders", true)]
3619 #[case("Submit order", false)]
3620 #[case("batch_submit_short_term", false)]
3621 #[case("batch_submit_long_term", false)]
3622 fn test_abort_pending_tasks_label_classification(
3623 #[case] label: &'static str,
3624 #[case] is_cancel: bool,
3625 ) {
3626 assert_eq!(label.contains("cancel"), is_cancel);
3630 }
3631
3632 #[rstest]
3633 fn test_emit_partitioned_cancel_rejections_emits_each_cancel() {
3634 let clock = get_atomic_clock_realtime();
3635 let mut emitter = ExecutionEventEmitter::new(
3636 clock,
3637 TraderId::from("TRADER-001"),
3638 AccountId::from("DYDX-001"),
3639 AccountType::Margin,
3640 None,
3641 );
3642 let (sender, mut rx) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
3643 emitter.set_sender(sender);
3644
3645 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3646 let orders = vec![
3647 DydxCancelOrderRequest {
3648 instrument_id,
3649 client_id: 1,
3650 order_flags: types::ORDER_FLAG_SHORT_TERM,
3651 strategy_id: StrategyId::from("S-001"),
3652 client_order_id: ClientOrderId::from("O-CANCEL-1"),
3653 venue_order_id: Some(VenueOrderId::from("V-1")),
3654 },
3655 DydxCancelOrderRequest {
3656 instrument_id,
3657 client_id: 2,
3658 order_flags: types::ORDER_FLAG_LONG_TERM,
3659 strategy_id: StrategyId::from("S-001"),
3660 client_order_id: ClientOrderId::from("O-CANCEL-2"),
3661 venue_order_id: Some(VenueOrderId::from("V-2")),
3662 },
3663 ];
3664
3665 emit_partitioned_cancel_rejections(&orders, &emitter, clock, "broadcast failed");
3666
3667 for order in &orders {
3668 match recv_order_event(&mut rx) {
3669 OrderEventAny::CancelRejected(event) => {
3670 assert_eq!(event.client_order_id, order.client_order_id);
3671 assert_eq!(event.instrument_id, order.instrument_id);
3672 assert_eq!(event.venue_order_id, order.venue_order_id);
3673 assert_eq!(event.reason, "broadcast failed");
3674 }
3675 event => panic!("Expected order cancel rejected event, was {event:?}"),
3676 }
3677 }
3678 assert!(rx.try_recv().is_err());
3679 }
3680
3681 #[rstest]
3682 fn test_find_matching_order_report_returns_later_match() {
3683 let btc_inst = test_instrument("BTC-USD-PERP", "DYDX");
3687 let eth_inst = test_instrument("ETH-USD-PERP", "DYDX");
3688
3689 let orders = vec![
3691 test_order("order-eth", 1, "22222"),
3692 test_order("order-btc", 0, "11111"),
3693 ];
3694
3695 let encoder = ClientOrderIdEncoder::new();
3696 let report = find_matching_order_report(
3697 &orders,
3698 Some(btc_inst.id()),
3699 None,
3700 None,
3701 |clob_pair_id| match clob_pair_id {
3702 0 => Some(btc_inst.clone()),
3703 1 => Some(eth_inst.clone()),
3704 _ => None,
3705 },
3706 &encoder,
3707 AccountId::new("DYDX-001"),
3708 UnixNanos::default(),
3709 )
3710 .expect("lookup should succeed");
3711
3712 let report = report.expect("matching order should be found");
3713 assert_eq!(report.instrument_id, btc_inst.id());
3714 assert_eq!(report.venue_order_id.as_str(), "order-btc");
3715 }
3716
3717 #[rstest]
3718 fn test_find_matching_order_report_returns_none_when_no_match() {
3719 let btc_inst = test_instrument("BTC-USD-PERP", "DYDX");
3720 let eth_inst = test_instrument("ETH-USD-PERP", "DYDX");
3721
3722 let orders = vec![
3723 test_order("order-eth-1", 1, "22222"),
3724 test_order("order-eth-2", 1, "33333"),
3725 ];
3726
3727 let encoder = ClientOrderIdEncoder::new();
3728 let report = find_matching_order_report(
3729 &orders,
3730 Some(btc_inst.id()),
3731 None,
3732 None,
3733 |clob_pair_id| match clob_pair_id {
3734 0 => Some(btc_inst.clone()),
3735 1 => Some(eth_inst.clone()),
3736 _ => None,
3737 },
3738 &encoder,
3739 AccountId::new("DYDX-001"),
3740 UnixNanos::default(),
3741 )
3742 .expect("lookup should succeed");
3743
3744 assert!(report.is_none());
3745 }
3746
3747 #[rstest]
3748 fn test_find_matching_order_report_filters_by_venue_order_id() {
3749 let btc_inst = test_instrument("BTC-USD-PERP", "DYDX");
3750
3751 let orders = vec![
3752 test_order("order-a", 0, "11111"),
3753 test_order("order-b", 0, "22222"),
3754 test_order("order-c", 0, "33333"),
3755 ];
3756
3757 let encoder = ClientOrderIdEncoder::new();
3758 let target = VenueOrderId::new("order-b");
3759 let report = find_matching_order_report(
3760 &orders,
3761 None,
3762 None,
3763 Some(target),
3764 |_| Some(btc_inst.clone()),
3765 &encoder,
3766 AccountId::new("DYDX-001"),
3767 UnixNanos::default(),
3768 )
3769 .expect("lookup should succeed")
3770 .expect("matching order should be found");
3771
3772 assert_eq!(report.venue_order_id.as_str(), "order-b");
3773 }
3774
3775 #[rstest]
3776 fn test_find_matching_order_report_skips_orders_without_cached_instrument() {
3777 let btc_inst = test_instrument("BTC-USD-PERP", "DYDX");
3778
3779 let orders = vec![
3780 test_order("order-unknown", 99, "11111"),
3782 test_order("order-btc", 0, "22222"),
3783 ];
3784
3785 let encoder = ClientOrderIdEncoder::new();
3786 let report = find_matching_order_report(
3787 &orders,
3788 Some(btc_inst.id()),
3789 None,
3790 None,
3791 |clob_pair_id| (clob_pair_id == 0).then(|| btc_inst.clone()),
3792 &encoder,
3793 AccountId::new("DYDX-001"),
3794 UnixNanos::default(),
3795 )
3796 .expect("lookup should succeed")
3797 .expect("matching order should be found");
3798
3799 assert_eq!(report.venue_order_id.as_str(), "order-btc");
3800 }
3801}