1use std::{
19 str::FromStr,
20 sync::{
21 Arc,
22 atomic::{AtomicBool, Ordering},
23 },
24 time::Duration,
25};
26
27use anyhow::Context;
28use dashmap::DashMap;
29use futures_util::{Stream, StreamExt, pin_mut};
30use nautilus_common::{
31 clients::DataClient,
32 live::runner::get_data_event_sender,
33 messages::{
34 DataEvent, DataResponse,
35 data::{
36 BarsResponse, BookResponse, FundingRatesResponse, InstrumentResponse,
37 InstrumentsResponse, RequestBars, RequestBookSnapshot, RequestFundingRates,
38 RequestInstrument, RequestInstruments, RequestTrades, SubscribeBars,
39 SubscribeBookDeltas, SubscribeFundingRates, SubscribeIndexPrices, SubscribeInstrument,
40 SubscribeInstrumentStatus, SubscribeInstruments, SubscribeMarkPrices, SubscribeQuotes,
41 SubscribeTrades, TradesResponse, UnsubscribeBars, UnsubscribeBookDeltas,
42 UnsubscribeFundingRates, UnsubscribeIndexPrices, UnsubscribeInstrument,
43 UnsubscribeInstrumentStatus, UnsubscribeInstruments, UnsubscribeMarkPrices,
44 UnsubscribeQuotes, UnsubscribeTrades,
45 },
46 },
47};
48use nautilus_core::{
49 AtomicMap, AtomicSet,
50 datetime::datetime_to_unix_nanos,
51 time::{AtomicTime, get_atomic_clock_realtime},
52};
53use nautilus_live::{
54 SocketControlFactory,
55 task::{TaskGroup, TaskGroupGuard, TaskSpawner},
56};
57use nautilus_model::{
58 data::{
59 Bar, BarSpecification, BarType, BookOrder, Data as NautilusData, FundingRateUpdate,
60 IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate, OrderBookDelta, OrderBookDeltas,
61 QuoteTick,
62 },
63 enums::{BookAction, BookType, MarketStatusAction, OrderSide, RecordFlag},
64 identifiers::{ClientId, InstrumentId, Symbol, Venue},
65 instruments::{Instrument, InstrumentAny},
66 orderbook::OrderBook,
67 types::Quantity,
68};
69use rust_decimal::Decimal;
70use ustr::Ustr;
71
72use crate::{
73 common::{
74 consts::DYDX_VENUE,
75 enums::DydxCandleResolution,
76 instrument_cache::InstrumentCache,
77 parse::{extract_raw_symbol, parse_price},
78 },
79 config::DydxDataClientConfig,
80 http::client::DydxHttpClient,
81 websocket::{
82 client::{DydxWebSocketClient, candle_ids_from_topics},
83 enums::DydxWsOutputMessage,
84 parse as ws_parse,
85 },
86};
87
88struct WsMessageContext {
89 clock: &'static AtomicTime,
90 data_sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
91 instrument_cache: Arc<InstrumentCache>,
92 order_books: Arc<DashMap<InstrumentId, OrderBook>>,
93 last_quotes: Arc<DashMap<InstrumentId, QuoteTick>>,
94 ws_client: DydxWebSocketClient,
95 http_client: DydxHttpClient,
96 active_quote_subs: Arc<AtomicSet<InstrumentId>>,
97 active_delta_subs: Arc<AtomicSet<InstrumentId>>,
98 active_trade_subs: Arc<AtomicSet<InstrumentId>>,
99 active_bar_subs: Arc<AtomicMap<(InstrumentId, String), BarType>>,
100 incomplete_bars: Arc<DashMap<BarType, Bar>>,
101 bar_type_mappings: Arc<AtomicMap<String, BarType>>,
102 active_mark_price_subs: Arc<AtomicSet<InstrumentId>>,
103 active_index_price_subs: Arc<AtomicSet<InstrumentId>>,
104 active_funding_rate_subs: Arc<AtomicSet<InstrumentId>>,
105 active_instrument_status_subs: Arc<AtomicSet<InstrumentId>>,
106 last_instrument_statuses: Arc<DashMap<InstrumentId, InstrumentStatus>>,
107 bars_timestamp_on_close: bool,
108 pending_bars: Arc<DashMap<String, Bar>>,
109 seen_tickers: Arc<AtomicSet<Ustr>>,
110 command_spawner: TaskSpawner,
111}
112
113#[derive(Debug)]
121pub struct DydxDataClient {
122 clock: &'static AtomicTime,
123 client_id: ClientId,
124 config: DydxDataClientConfig,
125 http_client: DydxHttpClient,
126 ws_client: DydxWebSocketClient,
127 is_connected: AtomicBool,
128 session_tasks: TaskGroup,
129 command_tasks: TaskGroup,
130 shutdown_errors: Vec<String>,
131 data_sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
132 instrument_cache: Arc<InstrumentCache>,
133 order_books: Arc<DashMap<InstrumentId, OrderBook>>,
134 last_quotes: Arc<DashMap<InstrumentId, QuoteTick>>,
135 incomplete_bars: Arc<DashMap<BarType, Bar>>,
136 bar_type_mappings: Arc<AtomicMap<String, BarType>>,
137 active_quote_subs: Arc<AtomicSet<InstrumentId>>,
138 active_delta_subs: Arc<AtomicSet<InstrumentId>>,
139 active_trade_subs: Arc<AtomicSet<InstrumentId>>,
140 active_bar_subs: Arc<AtomicMap<(InstrumentId, String), BarType>>,
141 active_mark_price_subs: Arc<AtomicSet<InstrumentId>>,
142 active_index_price_subs: Arc<AtomicSet<InstrumentId>>,
143 active_funding_rate_subs: Arc<AtomicSet<InstrumentId>>,
144 active_instrument_status_subs: Arc<AtomicSet<InstrumentId>>,
145 last_instrument_statuses: Arc<DashMap<InstrumentId, InstrumentStatus>>,
146}
147
148impl DydxDataClient {
149 fn map_bar_spec_to_resolution(spec: &BarSpecification) -> anyhow::Result<&'static str> {
150 let resolution: &'static str = DydxCandleResolution::from_bar_spec(spec)?.into();
151 Ok(resolution)
152 }
153
154 pub fn new(
160 client_id: ClientId,
161 config: DydxDataClientConfig,
162 http_client: DydxHttpClient,
163 ws_client: DydxWebSocketClient,
164 ) -> anyhow::Result<Self> {
165 let clock = get_atomic_clock_realtime();
166 let data_sender = get_data_event_sender();
167 let ws_client =
168 ws_client.with_socket_factory(SocketControlFactory::new(client_id, Some(*DYDX_VENUE)));
169
170 let instrument_cache = Arc::clone(http_client.instrument_cache());
171 let session_tasks = TaskGroup::new();
172 let command_tasks = TaskGroup::new();
173
174 Ok(Self {
175 clock,
176 client_id,
177 config,
178 http_client,
179 ws_client,
180 is_connected: AtomicBool::new(false),
181 session_tasks,
182 command_tasks,
183 shutdown_errors: Vec::new(),
184 data_sender,
185 instrument_cache,
186 order_books: Arc::new(DashMap::new()),
187 last_quotes: Arc::new(DashMap::new()),
188 incomplete_bars: Arc::new(DashMap::new()),
189 bar_type_mappings: Arc::new(AtomicMap::new()),
190 active_quote_subs: Arc::new(AtomicSet::new()),
191 active_delta_subs: Arc::new(AtomicSet::new()),
192 active_trade_subs: Arc::new(AtomicSet::new()),
193 active_bar_subs: Arc::new(AtomicMap::new()),
194 active_mark_price_subs: Arc::new(AtomicSet::new()),
195 active_index_price_subs: Arc::new(AtomicSet::new()),
196 active_funding_rate_subs: Arc::new(AtomicSet::new()),
197 active_instrument_status_subs: Arc::new(AtomicSet::new()),
198 last_instrument_statuses: Arc::new(DashMap::new()),
199 })
200 }
201
202 #[must_use]
204 pub fn venue(&self) -> Venue {
205 *DYDX_VENUE
206 }
207
208 #[must_use]
210 pub fn config(&self) -> &DydxDataClientConfig {
211 &self.config
212 }
213
214 #[must_use]
216 pub fn is_connected(&self) -> bool {
217 self.is_connected.load(Ordering::Relaxed)
218 }
219
220 fn spawn_ws<F>(&self, fut: F, context: &'static str)
221 where
222 F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
223 {
224 let future = async move {
225 if let Err(e) = fut.await {
226 log::error!("{context}: {e:?}");
227 }
228 };
229
230 if let Err(e) = self.command_tasks.spawn(future) {
231 log::warn!("Skipping dYdX {context} after shutdown began: {e}");
232 }
233 }
234
235 fn spawn_command<F>(&self, future: F)
236 where
237 F: std::future::Future<Output = ()> + Send + 'static,
238 {
239 if let Err(e) = self.command_tasks.spawn(future) {
240 log::warn!("Skipping dYdX data command after shutdown began: {e}");
241 }
242 }
243
244 fn spawn_ws_stream_handler(
245 &self,
246 stream: impl Stream<Item = DydxWsOutputMessage> + Send + 'static,
247 ctx: WsMessageContext,
248 ) -> anyhow::Result<()> {
249 let cancellation = self.session_tasks.cancellation_token();
250
251 let future = async move {
252 log::debug!("Message processing task started");
253 pin_mut!(stream);
254
255 loop {
256 tokio::select! {
257 maybe_msg = stream.next() => {
258 match maybe_msg {
259 Some(msg) => Self::handle_ws_message(msg, &ctx),
260 None => {
261 log::debug!("WebSocket message channel closed");
262 break;
263 }
264 }
265 }
266 () = cancellation.cancelled() => {
267 log::debug!("WebSocket message task cancelled");
268 break;
269 }
270 }
271 }
272 log::debug!("WebSocket stream handler ended");
273 };
274
275 self.session_tasks
276 .spawn(future)
277 .context("failed to register dYdX WebSocket stream task")?;
278 Ok(())
279 }
280
281 async fn finish_tasks(&self) -> anyhow::Result<()> {
282 let (session_result, command_result) = tokio::join!(
283 self.session_tasks
284 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
285 self.command_tasks
286 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
287 );
288 session_result.context("failed to finish dYdX data session tasks")?;
289 command_result.context("failed to finish dYdX data command tasks")?;
290 Ok(())
291 }
292
293 async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
294 if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
295 self.session_tasks.begin_shutdown();
296 self.command_tasks.begin_shutdown();
297 self.ws_client.begin_shutdown();
298 self.finish_shutdown().await?;
299 self.session_tasks
300 .start_generation()
301 .context("failed to start dYdX data session task generation")?;
302 self.command_tasks
303 .start_generation()
304 .context("failed to start dYdX data command task generation")?;
305 }
306 Ok(())
307 }
308
309 async fn finish_shutdown(&mut self) -> anyhow::Result<()> {
310 if let Err(e) = self
311 .ws_client
312 .disconnect()
313 .await
314 .context("failed to disconnect dYdX websocket")
315 {
316 self.shutdown_errors.push(e.to_string());
317 }
318
319 if let Err(e) = self.finish_tasks().await {
320 self.shutdown_errors.push(e.to_string());
321 }
322
323 if !self.shutdown_errors.is_empty() {
324 anyhow::bail!(std::mem::take(&mut self.shutdown_errors).join("; "));
325 }
326 Ok(())
327 }
328
329 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
330 self.session_tasks.begin_shutdown();
331 self.command_tasks.begin_shutdown();
332 self.ws_client.begin_shutdown();
333 let shutdown_result = self.finish_shutdown().await;
334 self.is_connected.store(false, Ordering::Release);
335 shutdown_result
336 }
337
338 async fn bootstrap_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>> {
339 self.http_client
340 .fetch_and_cache_instruments()
341 .await
342 .context("failed to load instruments from dYdX")?;
343
344 let instruments: Vec<InstrumentAny> = self.http_client.all_instruments();
345
346 if instruments.is_empty() {
347 log::warn!("No instruments were loaded");
348 return Ok(instruments);
349 }
350
351 log::debug!("Loaded {} instruments into shared cache", instruments.len());
352
353 self.ws_client.cache_instruments(instruments.clone());
354
355 for instrument in &instruments {
356 if let Err(e) = self
357 .data_sender
358 .send(DataEvent::Instrument(instrument.clone()))
359 {
360 log::warn!("Failed to publish instrument {}: {e}", instrument.id());
361 }
362 }
363 log::debug!("Published {} instruments to data engine", instruments.len());
364
365 Ok(instruments)
366 }
367}
368
369#[async_trait::async_trait(?Send)]
370impl DataClient for DydxDataClient {
371 fn client_id(&self) -> ClientId {
372 self.client_id
373 }
374
375 fn venue(&self) -> Option<Venue> {
376 Some(*DYDX_VENUE)
377 }
378
379 fn start(&mut self) -> anyhow::Result<()> {
380 log::info!(
381 "Starting: client_id={}, is_testnet={}",
382 self.client_id,
383 self.http_client.is_testnet()
384 );
385 Ok(())
386 }
387
388 fn stop(&mut self) -> anyhow::Result<()> {
389 log::info!("Stopping {}", self.client_id);
390 self.session_tasks.begin_shutdown();
391 self.command_tasks.begin_shutdown();
392 self.ws_client.begin_shutdown();
393 self.is_connected.store(false, Ordering::Relaxed);
394 Ok(())
395 }
396
397 fn reset(&mut self) -> anyhow::Result<()> {
398 log::debug!("Resetting {}", self.client_id);
399 self.session_tasks.begin_shutdown();
400 self.command_tasks.begin_shutdown();
401 self.ws_client.begin_shutdown();
402 self.is_connected.store(false, Ordering::Relaxed);
403 Ok(())
404 }
405
406 fn dispose(&mut self) -> anyhow::Result<()> {
407 log::debug!("Disposing {}", self.client_id);
408 self.stop()
409 }
410
411 async fn connect(&mut self) -> anyhow::Result<()> {
412 if self.is_connected() && self.session_tasks.is_open() && self.command_tasks.is_open() {
413 return Ok(());
414 }
415
416 log::info!("Connecting");
417
418 self.prepare_task_groups().await?;
419 let ws_client = self.ws_client.clone();
420 let setup_guard =
421 TaskGroupGuard::new(&[&self.session_tasks, &self.command_tasks], move || {
422 ws_client.begin_shutdown();
423 });
424
425 self.bootstrap_instruments().await?;
426
427 let session_result = async {
428 self.ws_client
429 .connect()
430 .await
431 .context("failed to connect dYdX websocket")?;
432
433 self.ws_client
434 .subscribe_markets()
435 .await
436 .context("failed to subscribe to markets channel")?;
437
438 let seen_tickers: Arc<AtomicSet<Ustr>> = Arc::new(AtomicSet::new());
439
440 for instrument in self.instrument_cache.all_instruments() {
441 let id = instrument.id();
442 let ticker = extract_raw_symbol(id.symbol.as_str());
443 seen_tickers.insert(Ustr::from(ticker));
444 }
445
446 let command_spawner = self
447 .command_tasks
448 .spawner()
449 .context("dYdX data command task admission is closed")?;
450 let ctx = WsMessageContext {
451 clock: self.clock,
452 data_sender: self.data_sender.clone(),
453 instrument_cache: self.instrument_cache.clone(),
454 order_books: self.order_books.clone(),
455 last_quotes: self.last_quotes.clone(),
456 ws_client: self.ws_client.clone(),
457 http_client: self.http_client.clone(),
458 active_quote_subs: self.active_quote_subs.clone(),
459 active_delta_subs: self.active_delta_subs.clone(),
460 active_trade_subs: self.active_trade_subs.clone(),
461 active_bar_subs: self.active_bar_subs.clone(),
462 incomplete_bars: self.incomplete_bars.clone(),
463 bar_type_mappings: self.bar_type_mappings.clone(),
464 active_mark_price_subs: self.active_mark_price_subs.clone(),
465 active_index_price_subs: self.active_index_price_subs.clone(),
466 active_funding_rate_subs: self.active_funding_rate_subs.clone(),
467 active_instrument_status_subs: self.active_instrument_status_subs.clone(),
468 last_instrument_statuses: self.last_instrument_statuses.clone(),
469 bars_timestamp_on_close: self.ws_client.bars_timestamp_on_close(),
470 pending_bars: Arc::new(DashMap::new()),
471 seen_tickers,
472 command_spawner,
473 };
474
475 let stream = self.ws_client.stream();
476 self.spawn_ws_stream_handler(stream, ctx)?;
477
478 Ok::<(), anyhow::Error>(())
479 }
480 .await;
481
482 if let Err(e) = session_result {
483 if let Err(teardown_error) = self.teardown_partial_connect().await {
484 return Err(e.context(format!(
485 "dYdX data startup teardown failed: {teardown_error}"
486 )));
487 }
488 return Err(e);
489 }
490
491 self.is_connected.store(true, Ordering::Relaxed);
492 setup_guard.disarm();
493 log::info!("Connected");
494
495 Ok(())
496 }
497
498 async fn disconnect(&mut self) -> anyhow::Result<()> {
499 log::info!("Disconnecting");
500
501 self.teardown_partial_connect().await?;
502
503 self.last_instrument_statuses.clear();
504 self.is_connected.store(false, Ordering::Relaxed);
505 log::info!("Disconnected dYdX data client");
506
507 Ok(())
508 }
509
510 fn is_connected(&self) -> bool {
511 self.is_connected.load(Ordering::Relaxed)
512 }
513
514 fn is_disconnected(&self) -> bool {
515 !self.is_connected()
516 }
517
518 fn subscribe_instruments(&mut self, _cmd: SubscribeInstruments) -> anyhow::Result<()> {
519 log::debug!(
520 "subscribe_instruments: dYdX instruments discovered via global v4_markets channel"
521 );
522 Ok(())
523 }
524
525 fn subscribe_instrument(&mut self, cmd: SubscribeInstrument) -> anyhow::Result<()> {
526 if let Some(instrument) = self.instrument_cache.get(&cmd.instrument_id) {
527 log::debug!("Sending cached instrument for {}", cmd.instrument_id);
528 if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
529 log::warn!("Failed to send instrument {}: {e}", cmd.instrument_id);
530 }
531 } else {
532 log::warn!(
533 "Instrument {} not found in cache (available: {})",
534 cmd.instrument_id,
535 self.instrument_cache.len()
536 );
537 }
538 Ok(())
539 }
540
541 fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
542 if cmd.book_type != BookType::L2_MBP {
543 anyhow::bail!(
544 "dYdX only supports L2_MBP order book deltas, received {:?}",
545 cmd.book_type
546 );
547 }
548
549 self.ensure_order_book(cmd.instrument_id, BookType::L2_MBP);
550 self.active_delta_subs.insert(cmd.instrument_id);
551
552 let ws = self.ws_client.clone();
553 let instrument_id = cmd.instrument_id;
554
555 self.spawn_ws(
556 async move {
557 ws.subscribe_orderbook(instrument_id)
558 .await
559 .context("orderbook subscription")
560 },
561 "dYdX orderbook subscription",
562 );
563
564 Ok(())
565 }
566
567 fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
568 log::debug!(
569 "Subscribe_quotes for {}: subscribing to orderbook WS channel for quote synthesis",
570 cmd.instrument_id
571 );
572
573 self.ensure_order_book(cmd.instrument_id, BookType::L2_MBP);
574 self.active_quote_subs.insert(cmd.instrument_id);
575 let ws = self.ws_client.clone();
576 let instrument_id = cmd.instrument_id;
577
578 self.spawn_ws(
579 async move {
580 ws.subscribe_orderbook(instrument_id)
581 .await
582 .context("orderbook subscription (for quotes)")
583 },
584 "dYdX orderbook subscription (quotes)",
585 );
586
587 Ok(())
588 }
589
590 fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
591 let ws = self.ws_client.clone();
592 let instrument_id = cmd.instrument_id;
593
594 self.active_trade_subs.insert(instrument_id);
595
596 self.spawn_ws(
597 async move {
598 ws.subscribe_trades(instrument_id)
599 .await
600 .context("trade subscription")
601 },
602 "dYdX trade subscription",
603 );
604
605 Ok(())
606 }
607
608 fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
609 let instrument_id = cmd.instrument_id;
610 self.active_mark_price_subs.insert(instrument_id);
611 log::debug!("Subscribed to mark prices for {instrument_id} (via v4_markets channel)");
612 Ok(())
613 }
614
615 fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
616 let instrument_id = cmd.instrument_id;
617 self.active_index_price_subs.insert(instrument_id);
618 log::debug!("Subscribed to index prices for {instrument_id} (via v4_markets channel)");
619 Ok(())
620 }
621
622 fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
623 let ws = self.ws_client.clone();
624 let instrument_id = cmd.bar_type.instrument_id();
625 let spec = cmd.bar_type.spec();
626
627 let resolution = Self::map_bar_spec_to_resolution(&spec)?;
628 let bar_type = cmd.bar_type;
629 self.active_bar_subs
630 .insert((instrument_id, resolution.to_string()), bar_type);
631
632 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
633 let topic = format!("{ticker}/{resolution}");
634 self.bar_type_mappings.insert(topic, bar_type);
635
636 self.spawn_ws(
637 async move {
638 ws.subscribe_candles(instrument_id, resolution)
639 .await
640 .context("candles subscription")
641 },
642 "dYdX candles subscription",
643 );
644
645 Ok(())
646 }
647
648 fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
649 let instrument_id = cmd.instrument_id;
650 self.active_funding_rate_subs.insert(instrument_id);
651 log::debug!("Subscribed to funding rates for {instrument_id} (via v4_markets channel)");
652 Ok(())
653 }
654
655 fn subscribe_instrument_status(
656 &mut self,
657 cmd: SubscribeInstrumentStatus,
658 ) -> anyhow::Result<()> {
659 let instrument_id = cmd.instrument_id;
660 self.active_instrument_status_subs.insert(instrument_id);
661 log::debug!("Subscribed to instrument status for {instrument_id} (via v4_markets channel)");
662
663 if let Some(status) = self.last_instrument_statuses.get(&instrument_id)
665 && let Err(e) = self.data_sender.send(DataEvent::InstrumentStatus(*status))
666 {
667 log::error!("Failed to replay instrument status for {instrument_id}: {e}");
668 }
669
670 Ok(())
671 }
672
673 fn unsubscribe_instruments(&mut self, _cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
674 log::debug!("unsubscribe_instruments: dYdX markets channel is global; no-op");
675 Ok(())
676 }
677
678 fn unsubscribe_instrument(&mut self, _cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
679 log::debug!("unsubscribe_instrument: dYdX markets channel is global; no-op");
680 Ok(())
681 }
682
683 fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
684 self.active_delta_subs.remove(&cmd.instrument_id);
685
686 let ws = self.ws_client.clone();
687 let instrument_id = cmd.instrument_id;
688
689 self.spawn_ws(
690 async move {
691 ws.unsubscribe_orderbook(instrument_id)
692 .await
693 .context("orderbook unsubscription")
694 },
695 "dYdX orderbook unsubscription",
696 );
697
698 Ok(())
699 }
700
701 fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
702 log::debug!(
703 "unsubscribe_quotes for {}: removing quote subscription",
704 cmd.instrument_id
705 );
706
707 self.active_quote_subs.remove(&cmd.instrument_id);
708
709 let ws = self.ws_client.clone();
710 let instrument_id = cmd.instrument_id;
711
712 self.spawn_ws(
713 async move {
714 ws.unsubscribe_orderbook(instrument_id)
715 .await
716 .context("orderbook unsubscription (for quotes)")
717 },
718 "dYdX orderbook unsubscription (quotes)",
719 );
720
721 Ok(())
722 }
723
724 fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
725 self.active_trade_subs.remove(&cmd.instrument_id);
726
727 let ws = self.ws_client.clone();
728 let instrument_id = cmd.instrument_id;
729
730 self.spawn_ws(
731 async move {
732 ws.unsubscribe_trades(instrument_id)
733 .await
734 .context("trade unsubscription")
735 },
736 "dYdX trade unsubscription",
737 );
738
739 Ok(())
740 }
741
742 fn unsubscribe_mark_prices(&mut self, cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
743 self.active_mark_price_subs.remove(&cmd.instrument_id);
744 log::debug!("Unsubscribed from mark prices for {}", cmd.instrument_id);
745 Ok(())
746 }
747
748 fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
749 self.active_index_price_subs.remove(&cmd.instrument_id);
750 log::debug!("Unsubscribed from index prices for {}", cmd.instrument_id);
751 Ok(())
752 }
753
754 fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
755 let ws = self.ws_client.clone();
756 let instrument_id = cmd.bar_type.instrument_id();
757 let spec = cmd.bar_type.spec();
758
759 let resolution = Self::map_bar_spec_to_resolution(&spec)?;
760
761 self.active_bar_subs
762 .remove(&(instrument_id, resolution.to_string()));
763
764 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
765 let topic = format!("{ticker}/{resolution}");
766 self.bar_type_mappings.remove(&topic);
767
768 self.spawn_ws(
769 async move {
770 ws.unsubscribe_candles(instrument_id, resolution)
771 .await
772 .context("candles unsubscription")
773 },
774 "dYdX candles unsubscription",
775 );
776
777 Ok(())
778 }
779
780 fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
781 self.active_funding_rate_subs.remove(&cmd.instrument_id);
782 log::debug!("Unsubscribed from funding rates for {}", cmd.instrument_id);
783 Ok(())
784 }
785
786 fn unsubscribe_instrument_status(
787 &mut self,
788 cmd: &UnsubscribeInstrumentStatus,
789 ) -> anyhow::Result<()> {
790 self.active_instrument_status_subs
791 .remove(&cmd.instrument_id);
792 log::debug!(
793 "Unsubscribed from instrument status for {}",
794 cmd.instrument_id
795 );
796 Ok(())
797 }
798
799 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
800 if request.start.is_some() {
801 log::warn!(
802 "Requesting instrument {} with specified `start` which has no effect",
803 request.instrument_id
804 );
805 }
806
807 if request.end.is_some() {
808 log::warn!(
809 "Requesting instrument {} with specified `end` which has no effect",
810 request.instrument_id
811 );
812 }
813
814 let instrument_cache = self.instrument_cache.clone();
815 let sender = self.data_sender.clone();
816 let http = self.http_client.clone();
817 let instrument_id = request.instrument_id;
818 let request_id = request.request_id;
819 let client_id = request.client_id.unwrap_or(self.client_id);
820 let start = request.start;
821 let end = request.end;
822 let params = request.params;
823 let clock = self.clock;
824 let start_nanos = datetime_to_unix_nanos(start);
825 let end_nanos = datetime_to_unix_nanos(end);
826
827 self.spawn_command(async move {
828 let instrument = match http.request_instruments(None, None, None).await {
829 Ok(instruments) => {
830 for inst in &instruments {
831 instrument_cache.insert_instrument_only(inst.clone());
832 }
833 instruments.into_iter().find(|i| i.id() == instrument_id)
834 }
835 Err(e) => {
836 log::error!("Failed to fetch instruments from dYdX: {e:?}");
837 None
838 }
839 };
840
841 if let Some(inst) = instrument {
842 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
843 request_id,
844 client_id,
845 instrument_id,
846 inst,
847 start_nanos,
848 end_nanos,
849 clock.get_time_ns(),
850 params,
851 )));
852
853 if let Err(e) = sender.send(DataEvent::Response(response)) {
854 log::error!("Failed to send instrument response: {e}");
855 }
856 } else {
857 log::error!("Instrument {instrument_id} not found");
858 }
859 });
860
861 Ok(())
862 }
863
864 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
865 let http = self.http_client.clone();
866 let sender = self.data_sender.clone();
867 let instrument_cache = self.instrument_cache.clone();
868 let request_id = request.request_id;
869 let client_id = request.client_id.unwrap_or(self.client_id);
870 let venue = self.venue();
871 let start = request.start;
872 let end = request.end;
873 let params = request.params;
874 let clock = self.clock;
875 let start_nanos = datetime_to_unix_nanos(start);
876 let end_nanos = datetime_to_unix_nanos(end);
877
878 self.spawn_command(async move {
879 match http.request_instruments(None, None, None).await {
880 Ok(instruments) => {
881 log::debug!("Fetched {} instruments from dYdX", instruments.len());
882
883 for instrument in &instruments {
884 instrument_cache.insert_instrument_only(instrument.clone());
885 }
886
887 let response = DataResponse::Instruments(InstrumentsResponse::new(
888 request_id,
889 client_id,
890 venue,
891 instruments,
892 start_nanos,
893 end_nanos,
894 clock.get_time_ns(),
895 params,
896 ));
897
898 if let Err(e) = sender.send(DataEvent::Response(response)) {
899 log::error!("Failed to send instruments response: {e}");
900 }
901 }
902 Err(e) => {
903 log::error!("Failed to fetch instruments from dYdX: {e:?}");
904
905 let response = DataResponse::Instruments(InstrumentsResponse::new(
906 request_id,
907 client_id,
908 venue,
909 Vec::new(),
910 start_nanos,
911 end_nanos,
912 clock.get_time_ns(),
913 params,
914 ));
915
916 if let Err(e) = sender.send(DataEvent::Response(response)) {
917 log::error!("Failed to send empty instruments response: {e}");
918 }
919 }
920 }
921 });
922
923 Ok(())
924 }
925
926 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
927 if request.depth.is_some() {
928 log::warn!(
929 "Requesting book snapshot for {} with specified `depth` which has no effect",
930 request.instrument_id
931 );
932 }
933
934 let http_client = self.http_client.clone();
935 let sender = self.data_sender.clone();
936 let instrument_id = request.instrument_id;
937 let request_id = request.request_id;
938 let client_id = request.client_id.unwrap_or(self.client_id);
939 let params = request.params;
940 let clock = self.clock;
941
942 self.spawn_command(async move {
943 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
944
945 match http_client.request_orderbook_snapshot(instrument_id).await {
946 Ok(deltas) => {
947 if let Err(e) = book.apply_deltas(&deltas) {
948 log::error!("Failed to apply book snapshot for {instrument_id}: {e}");
949 book.reset();
950 }
951 }
952 Err(e) => {
953 log::error!("Book snapshot request failed for {instrument_id}: {e:?}");
954 }
955 }
956
957 let response = DataResponse::Book(BookResponse::new(
958 request_id,
959 client_id,
960 instrument_id,
961 book,
962 None,
963 None,
964 clock.get_time_ns(),
965 params,
966 ));
967
968 if let Err(e) = sender.send(DataEvent::Response(response)) {
969 log::error!("Failed to send book snapshot response: {e}");
970 }
971 });
972
973 Ok(())
974 }
975
976 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
977 let http_client = self.http_client.clone();
978 let sender = self.data_sender.clone();
979 let instrument_id = request.instrument_id;
980 let start = request.start;
981 let end = request.end;
982 let limit = request.limit.map(|n| n.get() as u32);
983 let request_id = request.request_id;
984 let client_id = request.client_id.unwrap_or(self.client_id);
985 let params = request.params;
986 let clock = self.clock;
987 let start_nanos = datetime_to_unix_nanos(start);
988 let end_nanos = datetime_to_unix_nanos(end);
989
990 self.spawn_command(async move {
991 match http_client
992 .request_trade_ticks(instrument_id, start, end, limit)
993 .await
994 .context("failed to request trades from dYdX")
995 {
996 Ok(trades) => {
997 let response = DataResponse::Trades(TradesResponse::new(
998 request_id,
999 client_id,
1000 instrument_id,
1001 trades,
1002 start_nanos,
1003 end_nanos,
1004 clock.get_time_ns(),
1005 params,
1006 ));
1007
1008 if let Err(e) = sender.send(DataEvent::Response(response)) {
1009 log::error!("Failed to send trades response: {e}");
1010 }
1011 }
1012 Err(e) => {
1013 log::error!("Trade request failed for {instrument_id}: {e:?}");
1014
1015 let response = DataResponse::Trades(TradesResponse::new(
1016 request_id,
1017 client_id,
1018 instrument_id,
1019 Vec::new(),
1020 start_nanos,
1021 end_nanos,
1022 clock.get_time_ns(),
1023 params,
1024 ));
1025
1026 if let Err(e) = sender.send(DataEvent::Response(response)) {
1027 log::error!("Failed to send empty trades response: {e}");
1028 }
1029 }
1030 }
1031 });
1032
1033 Ok(())
1034 }
1035
1036 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1037 let http_client = self.http_client.clone();
1038 let sender = self.data_sender.clone();
1039 let bar_type = request.bar_type;
1040 let start = request.start;
1041 let end = request.end;
1042 let limit = request.limit.map(|n| n.get() as u32);
1043 let request_id = request.request_id;
1044 let client_id = request.client_id.unwrap_or(self.client_id);
1045 let params = request.params;
1046 let clock = self.clock;
1047 let start_nanos = datetime_to_unix_nanos(start);
1048 let end_nanos = datetime_to_unix_nanos(end);
1049
1050 self.spawn_command(async move {
1051 match http_client
1052 .request_bars(bar_type, start, end, limit, true)
1053 .await
1054 .context("failed to request bars from dYdX")
1055 {
1056 Ok(bars) => {
1057 let response = DataResponse::Bars(BarsResponse::new(
1058 request_id,
1059 client_id,
1060 bar_type,
1061 bars,
1062 start_nanos,
1063 end_nanos,
1064 clock.get_time_ns(),
1065 params,
1066 ));
1067
1068 if let Err(e) = sender.send(DataEvent::Response(response)) {
1069 log::error!("Failed to send bars response: {e}");
1070 }
1071 }
1072 Err(e) => {
1073 log::error!("Bar request failed for {bar_type}: {e:?}");
1074
1075 let response = DataResponse::Bars(BarsResponse::new(
1076 request_id,
1077 client_id,
1078 bar_type,
1079 Vec::new(),
1080 start_nanos,
1081 end_nanos,
1082 clock.get_time_ns(),
1083 params,
1084 ));
1085
1086 if let Err(e) = sender.send(DataEvent::Response(response)) {
1087 log::error!("Failed to send empty bars response: {e}");
1088 }
1089 }
1090 }
1091 });
1092
1093 Ok(())
1094 }
1095
1096 fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
1097 let http_client = self.http_client.clone();
1098 let sender = self.data_sender.clone();
1099 let instrument_id = request.instrument_id;
1100 let start = request.start;
1101 let end = request.end;
1102 let limit = request.limit.map(|n| n.get() as u32);
1103 let request_id = request.request_id;
1104 let client_id = request.client_id.unwrap_or(self.client_id);
1105 let params = request.params;
1106 let clock = self.clock;
1107 let start_nanos = datetime_to_unix_nanos(start);
1108 let end_nanos = datetime_to_unix_nanos(end);
1109
1110 self.spawn_command(async move {
1111 match http_client
1112 .request_funding_rates(instrument_id, start, end, limit)
1113 .await
1114 .context("failed to request funding rates from dYdX")
1115 {
1116 Ok(funding_rates) => {
1117 let response = DataResponse::FundingRates(FundingRatesResponse::new(
1118 request_id,
1119 client_id,
1120 instrument_id,
1121 funding_rates,
1122 start_nanos,
1123 end_nanos,
1124 clock.get_time_ns(),
1125 params,
1126 ));
1127
1128 if let Err(e) = sender.send(DataEvent::Response(response)) {
1129 log::error!("Failed to send funding rates response: {e}");
1130 }
1131 }
1132 Err(e) => {
1133 log::error!("Funding rates request failed for {instrument_id}: {e:?}");
1134
1135 let response = DataResponse::FundingRates(FundingRatesResponse::new(
1136 request_id,
1137 client_id,
1138 instrument_id,
1139 Vec::new(),
1140 start_nanos,
1141 end_nanos,
1142 clock.get_time_ns(),
1143 params,
1144 ));
1145
1146 if let Err(e) = sender.send(DataEvent::Response(response)) {
1147 log::error!("Failed to send empty funding rates response: {e}");
1148 }
1149 }
1150 }
1151 });
1152
1153 Ok(())
1154 }
1155}
1156
1157impl DydxDataClient {
1158 #[must_use]
1160 pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
1161 self.instrument_cache.get(instrument_id)
1162 }
1163
1164 #[must_use]
1166 pub fn get_instruments(&self) -> Vec<InstrumentAny> {
1167 self.instrument_cache.all_instruments()
1168 }
1169
1170 pub fn cache_instrument(&self, instrument: InstrumentAny) {
1172 self.instrument_cache.insert_instrument_only(instrument);
1173 }
1174
1175 pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
1179 self.instrument_cache.clear();
1180 self.instrument_cache.insert_instruments_only(instruments);
1181 }
1182
1183 fn ensure_order_book(&self, instrument_id: InstrumentId, book_type: BookType) {
1184 self.order_books
1185 .entry(instrument_id)
1186 .or_insert_with(|| OrderBook::new(instrument_id, book_type));
1187 }
1188
1189 #[must_use]
1191 pub fn get_bar_type_for_topic(&self, topic: &str) -> Option<BarType> {
1192 self.bar_type_mappings.load().get(topic).copied()
1193 }
1194
1195 #[must_use]
1197 pub fn get_bar_topics(&self) -> Vec<String> {
1198 self.bar_type_mappings.load().keys().cloned().collect()
1199 }
1200
1201 fn handle_ws_message(message: DydxWsOutputMessage, ctx: &WsMessageContext) {
1202 let ts_init = ctx.clock.get_time_ns();
1203
1204 match message {
1205 DydxWsOutputMessage::Trades { id, contents } => {
1206 let Some(instrument) = ctx.instrument_cache.get_by_market(&id) else {
1207 log::warn!("No instrument cached for market {id}");
1208 return;
1209 };
1210 let instrument_id = instrument.id();
1211
1212 match ws_parse::parse_trade_ticks(instrument_id, &instrument, &contents, ts_init) {
1213 Ok(data) => {
1214 Self::handle_data_message(
1215 data,
1216 &ctx.data_sender,
1217 &ctx.incomplete_bars,
1218 ctx.clock,
1219 );
1220 }
1221 Err(e) => log::error!("Failed to parse trade ticks for {id}: {e}"),
1222 }
1223 }
1224 DydxWsOutputMessage::OrderbookSnapshot { id, contents } => {
1225 let Some(instrument) = ctx.instrument_cache.get_by_market(&id) else {
1226 log::warn!("No instrument cached for market {id}");
1227 return;
1228 };
1229 let instrument_id = instrument.id();
1230
1231 match ws_parse::parse_orderbook_snapshot(
1232 &instrument_id,
1233 &contents,
1234 instrument.price_precision(),
1235 instrument.size_precision(),
1236 ts_init,
1237 ) {
1238 Ok(deltas) => {
1239 Self::handle_deltas_message(
1240 deltas,
1241 &ctx.data_sender,
1242 &ctx.order_books,
1243 &ctx.last_quotes,
1244 &ctx.instrument_cache,
1245 &ctx.active_quote_subs,
1246 &ctx.active_delta_subs,
1247 );
1248 }
1249 Err(e) => log::error!("Failed to parse orderbook snapshot for {id}: {e}"),
1250 }
1251 }
1252 DydxWsOutputMessage::OrderbookUpdate { id, contents } => {
1253 let Some(instrument) = ctx.instrument_cache.get_by_market(&id) else {
1254 log::warn!("No instrument cached for market {id}");
1255 return;
1256 };
1257 let instrument_id = instrument.id();
1258
1259 match ws_parse::parse_orderbook_deltas(
1260 &instrument_id,
1261 &contents,
1262 instrument.price_precision(),
1263 instrument.size_precision(),
1264 ts_init,
1265 ) {
1266 Ok(deltas) => {
1267 Self::handle_deltas_message(
1268 deltas,
1269 &ctx.data_sender,
1270 &ctx.order_books,
1271 &ctx.last_quotes,
1272 &ctx.instrument_cache,
1273 &ctx.active_quote_subs,
1274 &ctx.active_delta_subs,
1275 );
1276 }
1277 Err(e) => log::error!("Failed to parse orderbook deltas for {id}: {e}"),
1278 }
1279 }
1280 DydxWsOutputMessage::OrderbookBatch { id, updates } => {
1281 let Some(instrument) = ctx.instrument_cache.get_by_market(&id) else {
1282 log::warn!("No instrument cached for market {id}");
1283 return;
1284 };
1285 let instrument_id = instrument.id();
1286 let price_precision = instrument.price_precision();
1287 let size_precision = instrument.size_precision();
1288
1289 let mut all_deltas = Vec::new();
1290 let last_idx = updates.len().saturating_sub(1);
1291
1292 for (i, update) in updates.iter().enumerate() {
1293 let is_last = i == last_idx;
1294 let result = if is_last {
1295 ws_parse::parse_orderbook_deltas(
1296 &instrument_id,
1297 update,
1298 price_precision,
1299 size_precision,
1300 ts_init,
1301 )
1302 .map(|d| d.deltas)
1303 } else {
1304 ws_parse::parse_orderbook_deltas_with_flag(
1305 &instrument_id,
1306 update,
1307 price_precision,
1308 size_precision,
1309 ts_init,
1310 false,
1311 )
1312 };
1313
1314 match result {
1315 Ok(deltas) => all_deltas.extend(deltas),
1316 Err(e) => {
1317 log::error!("Failed to parse orderbook batch delta {i} for {id}: {e}");
1318 return;
1319 }
1320 }
1321 }
1322
1323 if all_deltas.is_empty() {
1324 return;
1325 }
1326 let deltas = OrderBookDeltas::new(instrument_id, all_deltas);
1327 Self::handle_deltas_message(
1328 deltas,
1329 &ctx.data_sender,
1330 &ctx.order_books,
1331 &ctx.last_quotes,
1332 &ctx.instrument_cache,
1333 &ctx.active_quote_subs,
1334 &ctx.active_delta_subs,
1335 );
1336 }
1337 DydxWsOutputMessage::Candles { id, contents } => {
1338 let parts: Vec<&str> = id.splitn(2, '/').collect();
1339 if parts.len() != 2 {
1340 log::warn!("Unexpected candle topic format: {id}");
1341 return;
1342 }
1343 let ticker = parts[0];
1344
1345 let Some(bar_type) = ctx.bar_type_mappings.load().get(&id).copied() else {
1346 log::debug!("No bar type mapping for candle topic {id}");
1347 return;
1348 };
1349
1350 let Some(instrument) = ctx.instrument_cache.get_by_market(ticker) else {
1351 log::warn!("No instrument cached for market {ticker}");
1352 return;
1353 };
1354
1355 match ws_parse::parse_candle_bar(
1356 bar_type,
1357 &instrument,
1358 &contents,
1359 ctx.bars_timestamp_on_close,
1360 ts_init,
1361 ) {
1362 Ok(bar) => {
1363 let prev = ctx.pending_bars.get(&id).map(|r| *r);
1364 if let Some(prev_bar) = prev
1365 && bar.ts_event != prev_bar.ts_event
1366 {
1367 Self::emit_bar_guarded(prev_bar, ctx);
1368 }
1369 ctx.pending_bars.insert(id, bar);
1370 }
1371 Err(e) => log::error!("Failed to parse candle bar for {id}: {e}"),
1372 }
1373 }
1374 DydxWsOutputMessage::Markets(contents) => {
1375 Self::handle_markets_message(&contents, ctx, ts_init);
1376 }
1377 DydxWsOutputMessage::SubaccountSubscribed(_) => {
1378 log::debug!("Ignoring subaccount subscribed on data client");
1379 }
1380 DydxWsOutputMessage::SubaccountsChannelData(_) => {
1381 log::debug!("Ignoring subaccounts channel data on data client");
1382 }
1383 DydxWsOutputMessage::BlockHeight { .. } => {
1384 log::debug!("Ignoring block height on data client");
1385 }
1386 DydxWsOutputMessage::Error(err) => {
1387 log::warn!("dYdX WS error: {err}");
1388 }
1389 DydxWsOutputMessage::Reconnected { topics } => {
1390 let reconnected_candles = candle_ids_from_topics(&topics);
1391 ctx.pending_bars
1392 .retain(|id, _| !reconnected_candles.contains(id));
1393
1394 let total_subs = ctx.active_quote_subs.len()
1395 + ctx.active_delta_subs.len()
1396 + ctx.active_trade_subs.len()
1397 + ctx.active_bar_subs.len();
1398
1399 log::info!(
1400 "dYdX WS reconnected; handler replayed channel subscriptions (active data subscriptions: total={}, quotes={}, deltas={}, trades={}, bars={})",
1401 total_subs,
1402 ctx.active_quote_subs.len(),
1403 ctx.active_delta_subs.len(),
1404 ctx.active_trade_subs.len(),
1405 ctx.active_bar_subs.len()
1406 );
1407 }
1408 }
1409 }
1410
1411 fn instrument_id_from_ticker(ticker: &str) -> InstrumentId {
1412 let symbol = format!("{ticker}-PERP");
1413 InstrumentId::new(Symbol::new(&symbol), *DYDX_VENUE)
1414 }
1415
1416 fn handle_markets_message(
1417 contents: &crate::websocket::messages::DydxMarketsContents,
1418 ctx: &WsMessageContext,
1419 ts_init: nautilus_core::UnixNanos,
1420 ) {
1421 if let Some(ref oracle_prices) = contents.oracle_prices {
1422 for (ticker, oracle_data) in oracle_prices {
1423 let instrument_id = Self::instrument_id_from_ticker(ticker);
1424
1425 let Ok(price) = parse_price(&oracle_data.oracle_price, "oracle_price") else {
1426 log::warn!("Failed to parse oracle price for {ticker}");
1427 continue;
1428 };
1429
1430 if ctx.active_mark_price_subs.contains(&instrument_id) {
1431 let mark_price = MarkPriceUpdate::new(instrument_id, price, ts_init, ts_init);
1432 let data = NautilusData::MarkPrice(mark_price);
1433 if let Err(e) = ctx.data_sender.send(DataEvent::Data(data)) {
1434 log::error!("Failed to emit mark price for {instrument_id}: {e}");
1435 }
1436 }
1437
1438 if ctx.active_index_price_subs.contains(&instrument_id) {
1439 let index_price = IndexPriceUpdate::new(instrument_id, price, ts_init, ts_init);
1440 let data = NautilusData::IndexPrice(index_price);
1441 if let Err(e) = ctx.data_sender.send(DataEvent::Data(data)) {
1442 log::error!("Failed to emit index price for {instrument_id}: {e}");
1443 }
1444 }
1445 }
1446 }
1447
1448 Self::handle_markets_trading_data(contents.trading.as_ref(), ctx, ts_init, false);
1449 Self::handle_markets_trading_data(contents.markets.as_ref(), ctx, ts_init, true);
1450 }
1451
1452 fn handle_markets_trading_data(
1453 trading: Option<
1454 &std::collections::HashMap<String, crate::websocket::messages::DydxMarketTradingUpdate>,
1455 >,
1456 ctx: &WsMessageContext,
1457 ts_init: nautilus_core::UnixNanos,
1458 is_snapshot: bool,
1459 ) {
1460 let Some(trading_map) = trading else {
1461 return;
1462 };
1463
1464 for (ticker, update) in trading_map {
1465 let instrument_id = Self::instrument_id_from_ticker(ticker);
1466
1467 if let Some(status) = &update.status {
1468 if *status == crate::common::enums::DydxMarketStatus::Unknown {
1469 log::warn!("Skipping unmodeled dYdX market status for {instrument_id}");
1470 } else {
1471 let action = MarketStatusAction::from(*status);
1472 let is_trading =
1473 matches!(status, crate::common::enums::DydxMarketStatus::Active);
1474
1475 let instrument_status = InstrumentStatus::new(
1476 instrument_id,
1477 action,
1478 ts_init,
1479 ts_init,
1480 None,
1481 None,
1482 Some(is_trading),
1483 None,
1484 None,
1485 );
1486
1487 ctx.last_instrument_statuses
1488 .insert(instrument_id, instrument_status);
1489
1490 if ctx.active_instrument_status_subs.contains(&instrument_id)
1491 && let Err(e) = ctx
1492 .data_sender
1493 .send(DataEvent::InstrumentStatus(instrument_status))
1494 {
1495 log::error!("Failed to emit instrument status for {instrument_id}: {e}");
1496 }
1497 }
1498 }
1499
1500 let ticker_ustr = Ustr::from(ticker.as_str());
1501 if !ctx.seen_tickers.contains(&ticker_ustr) {
1502 let is_active = update
1503 .status
1504 .as_ref()
1505 .is_none_or(|s| matches!(s, crate::common::enums::DydxMarketStatus::Active));
1506
1507 if ctx.instrument_cache.get_by_market(ticker).is_some() {
1508 ctx.seen_tickers.insert(ticker_ustr);
1509 } else if is_active {
1510 ctx.seen_tickers.insert(ticker_ustr);
1511 Self::handle_new_instrument_discovered(ticker, ctx);
1512 }
1513 }
1514
1515 if let Some(ref rate_str) = update.next_funding_rate {
1516 if let Ok(rate) = Decimal::from_str(rate_str) {
1517 if ctx.active_funding_rate_subs.contains(&instrument_id) {
1518 let funding_rate = FundingRateUpdate {
1519 instrument_id,
1520 rate,
1521 interval: Some(60),
1522 next_funding_ns: None,
1523 ts_event: ts_init,
1524 ts_init,
1525 };
1526
1527 if let Err(e) = ctx.data_sender.send(DataEvent::FundingRate(funding_rate)) {
1528 log::error!("Failed to emit funding rate for {instrument_id}: {e}");
1529 }
1530 }
1531 } else {
1532 log::warn!("Failed to parse next_funding_rate for {ticker}: {rate_str}");
1533 }
1534 }
1535
1536 if is_snapshot
1537 && let Some(ref oracle_price_str) = update.oracle_price
1538 && let Ok(price) = parse_price(oracle_price_str, "oracle_price")
1539 {
1540 if ctx.active_mark_price_subs.contains(&instrument_id) {
1541 let mark_price = MarkPriceUpdate::new(instrument_id, price, ts_init, ts_init);
1542 let data = NautilusData::MarkPrice(mark_price);
1543
1544 if let Err(e) = ctx.data_sender.send(DataEvent::Data(data)) {
1545 log::error!("Failed to emit mark price for {instrument_id}: {e}");
1546 }
1547 }
1548
1549 if ctx.active_index_price_subs.contains(&instrument_id) {
1550 let index_price = IndexPriceUpdate::new(instrument_id, price, ts_init, ts_init);
1551 let data = NautilusData::IndexPrice(index_price);
1552
1553 if let Err(e) = ctx.data_sender.send(DataEvent::Data(data)) {
1554 log::error!("Failed to emit index price for {instrument_id}: {e}");
1555 }
1556 }
1557 }
1558 }
1559 }
1560
1561 fn emit_bar_guarded(bar: Bar, ctx: &WsMessageContext) {
1562 let current_time_ns = ctx.clock.get_time_ns();
1563 if bar.ts_event <= current_time_ns {
1564 ctx.incomplete_bars.remove(&bar.bar_type);
1565 if let Err(e) = ctx
1566 .data_sender
1567 .send(DataEvent::Data(NautilusData::Bar(bar)))
1568 {
1569 log::error!("Failed to emit completed bar: {e}");
1570 }
1571 } else {
1572 ctx.incomplete_bars.insert(bar.bar_type, bar);
1573 }
1574 }
1575
1576 fn handle_new_instrument_discovered(ticker: &str, ctx: &WsMessageContext) {
1577 log::debug!("New instrument discovered via WebSocket: {ticker}");
1578
1579 let http_client = ctx.http_client.clone();
1580 let ws_client = ctx.ws_client.clone();
1581 let data_sender = ctx.data_sender.clone();
1582 let ticker = ticker.to_string();
1583
1584 if let Err(e) = ctx.command_spawner.spawn(async move {
1585 match http_client.fetch_and_cache_single_instrument(&ticker).await {
1586 Ok(Some(instrument)) => {
1587 ws_client.cache_instrument(instrument.clone());
1588 if let Err(e) = data_sender.send(DataEvent::Instrument(instrument)) {
1589 log::error!("Failed to emit new instrument: {e}");
1590 }
1591 log::debug!("Fetched and cached new instrument: {ticker}");
1592 }
1593 Ok(None) => {
1594 log::warn!("New instrument {ticker} not found or inactive");
1595 }
1596 Err(e) => {
1597 log::error!("Failed to fetch new instrument {ticker}: {e}");
1598 }
1599 }
1600 }) {
1601 log::warn!("Skipping new dYdX instrument fetch after shutdown began: {e}");
1602 }
1603 }
1604
1605 fn handle_data_message(
1606 payloads: Vec<NautilusData>,
1607 data_sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
1608 incomplete_bars: &Arc<DashMap<BarType, Bar>>,
1609 clock: &'static AtomicTime,
1610 ) {
1611 for data in payloads {
1612 if let NautilusData::Bar(bar) = data {
1614 Self::handle_bar_message(bar, data_sender, incomplete_bars, clock);
1615 } else if let Err(e) = data_sender.send(DataEvent::Data(data)) {
1616 log::error!("Failed to emit data event: {e}");
1617 }
1618 }
1619 }
1620
1621 fn handle_bar_message(
1622 bar: Bar,
1623 data_sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
1624 incomplete_bars: &Arc<DashMap<BarType, Bar>>,
1625 clock: &'static AtomicTime,
1626 ) {
1627 let current_time_ns = clock.get_time_ns();
1628 let bar_type = bar.bar_type;
1629
1630 if bar.ts_event <= current_time_ns {
1631 incomplete_bars.remove(&bar_type);
1633
1634 if let Err(e) = data_sender.send(DataEvent::Data(NautilusData::Bar(bar))) {
1635 log::error!("Failed to emit completed bar: {e}");
1636 }
1637 } else {
1638 log::trace!(
1640 "Caching incomplete bar for {} (ts_event={}, current={})",
1641 bar_type,
1642 bar.ts_event,
1643 current_time_ns
1644 );
1645 incomplete_bars.insert(bar_type, bar);
1646 }
1647 }
1648
1649 fn resolve_crossed_order_book(
1650 book: &mut OrderBook,
1651 venue_deltas: &OrderBookDeltas,
1652 instrument: &InstrumentAny,
1653 ) -> anyhow::Result<OrderBookDeltas> {
1654 let instrument_id = venue_deltas.instrument_id;
1655 let ts_init = venue_deltas.ts_init;
1656 let mut all_deltas = venue_deltas.deltas.clone();
1657
1658 let snapshot_flag = RecordFlag::F_SNAPSHOT as u8;
1662 let is_snapshot_batch = venue_deltas
1663 .deltas
1664 .iter()
1665 .any(|d| d.flags & snapshot_flag != 0);
1666 let synthetic_flags = if is_snapshot_batch { snapshot_flag } else { 0 };
1667
1668 book.apply_deltas(venue_deltas)?;
1670
1671 let mut is_crossed = if let (Some(bid_price), Some(ask_price)) =
1673 (book.best_bid_price(), book.best_ask_price())
1674 {
1675 bid_price >= ask_price
1676 } else {
1677 false
1678 };
1679
1680 while is_crossed {
1682 log::debug!(
1683 "Resolving crossed order book for {}: bid={:?} >= ask={:?}",
1684 instrument_id,
1685 book.best_bid_price(),
1686 book.best_ask_price()
1687 );
1688
1689 let bid_price = match book.best_bid_price() {
1690 Some(p) => p,
1691 None => break,
1692 };
1693 let ask_price = match book.best_ask_price() {
1694 Some(p) => p,
1695 None => break,
1696 };
1697 let bid_size = match book.best_bid_size() {
1698 Some(s) => s,
1699 None => break,
1700 };
1701 let ask_size = match book.best_ask_size() {
1702 Some(s) => s,
1703 None => break,
1704 };
1705
1706 let mut temp_deltas = Vec::new();
1707
1708 if bid_size > ask_size {
1709 let new_bid_size = Quantity::from_decimal_dp(
1711 bid_size.as_decimal() - ask_size.as_decimal(),
1712 instrument.size_precision(),
1713 )?;
1714 temp_deltas.push(OrderBookDelta::new(
1715 instrument_id,
1716 BookAction::Update,
1717 BookOrder::new(OrderSide::Buy, bid_price, new_bid_size, 0),
1718 synthetic_flags,
1719 0,
1720 ts_init,
1721 ts_init,
1722 ));
1723 temp_deltas.push(OrderBookDelta::new(
1724 instrument_id,
1725 BookAction::Delete,
1726 BookOrder::new(
1727 OrderSide::Sell,
1728 ask_price,
1729 Quantity::zero(instrument.size_precision()),
1730 0,
1731 ),
1732 synthetic_flags,
1733 0,
1734 ts_init,
1735 ts_init,
1736 ));
1737 } else if bid_size < ask_size {
1738 let new_ask_size = Quantity::from_decimal_dp(
1740 ask_size.as_decimal() - bid_size.as_decimal(),
1741 instrument.size_precision(),
1742 )?;
1743 temp_deltas.push(OrderBookDelta::new(
1744 instrument_id,
1745 BookAction::Update,
1746 BookOrder::new(OrderSide::Sell, ask_price, new_ask_size, 0),
1747 synthetic_flags,
1748 0,
1749 ts_init,
1750 ts_init,
1751 ));
1752 temp_deltas.push(OrderBookDelta::new(
1753 instrument_id,
1754 BookAction::Delete,
1755 BookOrder::new(
1756 OrderSide::Buy,
1757 bid_price,
1758 Quantity::zero(instrument.size_precision()),
1759 0,
1760 ),
1761 synthetic_flags,
1762 0,
1763 ts_init,
1764 ts_init,
1765 ));
1766 } else {
1767 temp_deltas.push(OrderBookDelta::new(
1769 instrument_id,
1770 BookAction::Delete,
1771 BookOrder::new(
1772 OrderSide::Buy,
1773 bid_price,
1774 Quantity::zero(instrument.size_precision()),
1775 0,
1776 ),
1777 synthetic_flags,
1778 0,
1779 ts_init,
1780 ts_init,
1781 ));
1782 temp_deltas.push(OrderBookDelta::new(
1783 instrument_id,
1784 BookAction::Delete,
1785 BookOrder::new(
1786 OrderSide::Sell,
1787 ask_price,
1788 Quantity::zero(instrument.size_precision()),
1789 0,
1790 ),
1791 synthetic_flags,
1792 0,
1793 ts_init,
1794 ts_init,
1795 ));
1796 }
1797
1798 let temp_deltas_obj = OrderBookDeltas::new(instrument_id, temp_deltas.clone());
1800 book.apply_deltas(&temp_deltas_obj)?;
1801 all_deltas.extend(temp_deltas);
1802
1803 is_crossed = if let (Some(bid_price), Some(ask_price)) =
1805 (book.best_bid_price(), book.best_ask_price())
1806 {
1807 bid_price >= ask_price
1808 } else {
1809 false
1810 };
1811 }
1812
1813 if let Some(last_delta) = all_deltas.last_mut() {
1816 last_delta.flags = synthetic_flags | RecordFlag::F_LAST as u8;
1817 }
1818
1819 Ok(OrderBookDeltas::new(instrument_id, all_deltas))
1820 }
1821
1822 fn handle_deltas_message(
1823 deltas: OrderBookDeltas,
1824 data_sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
1825 order_books: &Arc<DashMap<InstrumentId, OrderBook>>,
1826 last_quotes: &Arc<DashMap<InstrumentId, QuoteTick>>,
1827 instrument_cache: &Arc<InstrumentCache>,
1828 active_quote_subs: &Arc<AtomicSet<InstrumentId>>,
1829 active_delta_subs: &Arc<AtomicSet<InstrumentId>>,
1830 ) {
1831 let instrument_id = deltas.instrument_id;
1832
1833 let instrument = match instrument_cache.get(&instrument_id) {
1835 Some(inst) => inst,
1836 None => {
1837 log::error!("Cannot resolve crossed order book: no instrument for {instrument_id}");
1838 if active_delta_subs.contains(&instrument_id)
1840 && let Err(e) = data_sender.send(DataEvent::Data(NautilusData::from(deltas)))
1841 {
1842 log::error!("Failed to emit order book deltas: {e}");
1843 }
1844 return;
1845 }
1846 };
1847
1848 let mut book = order_books
1850 .entry(instrument_id)
1851 .or_insert_with(|| OrderBook::new(instrument_id, BookType::L2_MBP));
1852
1853 let resolved_deltas =
1855 match Self::resolve_crossed_order_book(&mut book, &deltas, &instrument) {
1856 Ok(d) => d,
1857 Err(e) => {
1858 log::error!("Failed to resolve crossed order book for {instrument_id}: {e}");
1859 return;
1860 }
1861 };
1862
1863 if active_quote_subs.contains(&instrument_id) {
1865 let quote_opt = if let (Some(bid_price), Some(ask_price)) =
1868 (book.best_bid_price(), book.best_ask_price())
1869 && let (Some(bid_size), Some(ask_size)) =
1870 (book.best_bid_size(), book.best_ask_size())
1871 {
1872 Some(QuoteTick::new(
1873 instrument_id,
1874 bid_price,
1875 ask_price,
1876 bid_size,
1877 ask_size,
1878 resolved_deltas.ts_event,
1879 resolved_deltas.ts_init,
1880 ))
1881 } else {
1882 if book.best_bid_price().is_none() && book.best_ask_price().is_none() {
1884 log::debug!(
1885 "Empty orderbook for {instrument_id} after applying deltas, using last quote"
1886 );
1887 last_quotes.get(&instrument_id).map(|q| *q)
1888 } else {
1889 None
1890 }
1891 };
1892
1893 if let Some(quote) = quote_opt {
1894 let emit_quote = !matches!(
1896 last_quotes.get(&instrument_id),
1897 Some(existing) if *existing == quote
1898 );
1899
1900 if emit_quote {
1901 last_quotes.insert(instrument_id, quote);
1902 if let Err(e) = data_sender.send(DataEvent::Data(NautilusData::Quote(quote))) {
1903 log::error!("Failed to emit quote tick: {e}");
1904 }
1905 }
1906 } else if book.best_bid_price().is_some() || book.best_ask_price().is_some() {
1907 log::debug!(
1909 "Incomplete top-of-book for {instrument_id} (bid={:?}, ask={:?})",
1910 book.best_bid_price(),
1911 book.best_ask_price()
1912 );
1913 }
1914 }
1915
1916 if active_delta_subs.contains(&instrument_id) {
1918 let data: NautilusData = resolved_deltas.into();
1919 if let Err(e) = data_sender.send(DataEvent::Data(data)) {
1920 log::error!("Failed to emit order book deltas event: {e}");
1921 }
1922 }
1923 }
1924}
1925
1926#[cfg(test)]
1927mod tests {
1928 use nautilus_core::UnixNanos;
1929 use nautilus_model::{
1930 data::{BookOrder, OrderBookDelta, OrderBookDeltas},
1931 enums::{BookAction, BookType, OrderSide, RecordFlag},
1932 identifiers::{InstrumentId, Symbol},
1933 instruments::{CryptoPerpetual, InstrumentAny},
1934 orderbook::OrderBook,
1935 types::{Currency, Price, Quantity},
1936 };
1937 use rstest::rstest;
1938 use rust_decimal_macros::dec;
1939
1940 use super::*;
1941 use crate::common::consts::DYDX_VENUE;
1942
1943 fn test_instrument() -> InstrumentAny {
1944 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
1945 InstrumentAny::CryptoPerpetual(
1946 CryptoPerpetual::builder()
1947 .instrument_id(instrument_id)
1948 .raw_symbol(instrument_id.symbol)
1949 .base_currency(Currency::BTC())
1950 .quote_currency(Currency::USD())
1951 .settlement_currency(Currency::USD())
1952 .is_inverse(false)
1953 .price_precision(2)
1954 .size_precision(8)
1956 .price_increment(Price::new(0.01, 2))
1957 .size_increment(Quantity::new(0.00000001, 8))
1958 .ts_event(UnixNanos::default())
1959 .ts_init(UnixNanos::default())
1960 .build()
1961 .unwrap(),
1962 )
1963 }
1964
1965 fn seed_book_with_levels(
1966 instrument_id: InstrumentId,
1967 bids: &[(f64, f64)],
1968 asks: &[(f64, f64)],
1969 ) -> OrderBook {
1970 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1971 let ts = UnixNanos::default();
1972
1973 let mut deltas: Vec<OrderBookDelta> = Vec::new();
1974 deltas.push(OrderBookDelta::clear(instrument_id, 0, ts, ts));
1975 for (price, size) in bids {
1976 deltas.push(OrderBookDelta::new(
1977 instrument_id,
1978 BookAction::Add,
1979 BookOrder::new(
1980 OrderSide::Buy,
1981 Price::new(*price, 2),
1982 Quantity::new(*size, 8),
1983 0,
1984 ),
1985 0,
1986 0,
1987 ts,
1988 ts,
1989 ));
1990 }
1991
1992 for (price, size) in asks {
1993 deltas.push(OrderBookDelta::new(
1994 instrument_id,
1995 BookAction::Add,
1996 BookOrder::new(
1997 OrderSide::Sell,
1998 Price::new(*price, 2),
1999 Quantity::new(*size, 8),
2000 0,
2001 ),
2002 0,
2003 0,
2004 ts,
2005 ts,
2006 ));
2007 }
2008
2009 if let Some(last) = deltas.last_mut() {
2010 last.flags = RecordFlag::F_LAST as u8;
2011 }
2012
2013 book.apply_deltas(&OrderBookDeltas::new(instrument_id, deltas))
2014 .expect("failed to apply seed deltas");
2015 book
2016 }
2017
2018 fn crossing_bid_deltas(
2019 instrument_id: InstrumentId,
2020 bid_price: f64,
2021 bid_size: f64,
2022 ) -> OrderBookDeltas {
2023 let ts = UnixNanos::default();
2024 let delta = OrderBookDelta::new(
2025 instrument_id,
2026 BookAction::Add,
2027 BookOrder::new(
2028 OrderSide::Buy,
2029 Price::new(bid_price, 2),
2030 Quantity::new(bid_size, 8),
2031 0,
2032 ),
2033 RecordFlag::F_LAST as u8,
2034 0,
2035 ts,
2036 ts,
2037 );
2038 OrderBookDeltas::new(instrument_id, vec![delta])
2039 }
2040
2041 #[rstest]
2042 fn test_resolve_crossed_order_book_preserves_decimal_precision() {
2043 let instrument = test_instrument();
2048 let instrument_id = instrument.id();
2049 let mut book = seed_book_with_levels(
2050 instrument_id,
2051 &[(99.00, 1.00000000)],
2052 &[(100.05, 0.50000000)],
2053 );
2054
2055 let venue_deltas = crossing_bid_deltas(instrument_id, 100.10, 1.00000001);
2056
2057 let resolved =
2058 DydxDataClient::resolve_crossed_order_book(&mut book, &venue_deltas, &instrument)
2059 .expect("resolution should succeed");
2060
2061 let update = resolved
2064 .deltas
2065 .iter()
2066 .find(|d| {
2067 d.action == BookAction::Update
2068 && d.order.side == Some(OrderSide::Buy)
2069 && d.order.price.as_decimal() == dec!(100.10)
2070 })
2071 .expect("expected a Buy Update delta from crossed-book resolution");
2072 assert_eq!(update.order.size.as_decimal(), dec!(0.50000001));
2073
2074 assert_eq!(
2076 resolved.deltas.last().unwrap().flags,
2077 RecordFlag::F_LAST as u8,
2078 );
2079
2080 if let (Some(bid), Some(ask)) = (book.best_bid_price(), book.best_ask_price()) {
2082 assert!(bid < ask, "book still crossed: bid={bid:?} ask={ask:?}");
2083 }
2084 }
2085
2086 fn crossing_snapshot_batch(
2087 instrument_id: InstrumentId,
2088 bid_price: f64,
2089 bid_size: f64,
2090 ) -> OrderBookDeltas {
2091 let ts = UnixNanos::default();
2092 let snapshot = RecordFlag::F_SNAPSHOT as u8;
2093 let last = RecordFlag::F_LAST as u8;
2094 let deltas = vec![OrderBookDelta::new(
2097 instrument_id,
2098 BookAction::Add,
2099 BookOrder::new(
2100 OrderSide::Buy,
2101 Price::new(bid_price, 2),
2102 Quantity::new(bid_size, 8),
2103 0,
2104 ),
2105 snapshot | last,
2106 0,
2107 ts,
2108 ts,
2109 )];
2110 OrderBookDeltas::new(instrument_id, deltas)
2111 }
2112
2113 #[rstest]
2118 fn test_resolve_crossed_order_book_preserves_snapshot_flags() {
2119 let instrument = test_instrument();
2120 let instrument_id = instrument.id();
2121 let mut book = seed_book_with_levels(
2122 instrument_id,
2123 &[(99.00, 1.00000000)],
2124 &[(100.05, 0.50000000)],
2125 );
2126
2127 let venue_deltas = crossing_snapshot_batch(instrument_id, 100.10, 1.00000001);
2128
2129 let resolved =
2130 DydxDataClient::resolve_crossed_order_book(&mut book, &venue_deltas, &instrument)
2131 .expect("resolution should succeed");
2132
2133 let snapshot = RecordFlag::F_SNAPSHOT as u8;
2134 let last = RecordFlag::F_LAST as u8;
2135
2136 for (idx, delta) in resolved.deltas.iter().enumerate() {
2138 assert!(
2139 delta.flags & snapshot != 0,
2140 "delta at index {idx} lost F_SNAPSHOT: flags={:#010b}",
2141 delta.flags,
2142 );
2143 }
2144 assert_eq!(
2145 resolved.deltas.last().unwrap().flags,
2146 snapshot | last,
2147 "snapshot terminator must be F_SNAPSHOT | F_LAST",
2148 );
2149 }
2150
2151 #[rstest]
2152 fn test_resolve_crossed_order_book_equal_sizes_removes_both_levels() {
2153 let instrument = test_instrument();
2156 let instrument_id = instrument.id();
2157 let mut book = seed_book_with_levels(
2158 instrument_id,
2159 &[(99.00, 1.00000000)],
2160 &[(100.05, 1.00000000)],
2161 );
2162
2163 let venue_deltas = crossing_bid_deltas(instrument_id, 100.10, 1.00000000);
2164
2165 let resolved =
2166 DydxDataClient::resolve_crossed_order_book(&mut book, &venue_deltas, &instrument)
2167 .expect("resolution should succeed");
2168
2169 let deletes_count = resolved
2171 .deltas
2172 .iter()
2173 .filter(|d| {
2174 d.action == BookAction::Delete
2175 && (d.order.price.as_decimal() == dec!(100.10)
2176 || d.order.price.as_decimal() == dec!(100.05))
2177 })
2178 .count();
2179 assert_eq!(deletes_count, 2);
2180 }
2181}