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