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