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