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, execute_bar_subscriptions, execute_instrument_subscriptions},
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::{Price, Quantity, fixed::FIXED_PRECISION},
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_decimal},
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::Data(instrument.clone().into()))
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 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
505 if let Some(instrument) = self.instrument_cache.get(&instrument_id) {
506 log::debug!("Sending cached instrument for {instrument_id}");
507
508 if let Err(e) = self.data_sender.send(DataEvent::Data(instrument.into())) {
509 log::warn!("Failed to send instrument {instrument_id}: {e}");
510 }
511 } else {
512 log::warn!(
513 "Instrument {} not found in cache (available: {})",
514 instrument_id,
515 self.instrument_cache.len()
516 );
517 }
518
519 Ok(())
520 })
521 }
522
523 fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
524 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
525 let cmd = cmd.with_instrument_ids(instrument_id);
526 if cmd.book_type != BookType::L2_MBP {
527 anyhow::bail!(
528 "dYdX only supports L2_MBP order book deltas, received {:?}",
529 cmd.book_type
530 );
531 }
532
533 self.ensure_order_book(instrument_id, BookType::L2_MBP);
534 self.active_delta_subs.insert(instrument_id);
535
536 let ws = self.ws_client.clone();
537 self.spawn_ws(
538 async move {
539 ws.subscribe_orderbook(instrument_id)
540 .await
541 .context("orderbook subscription")
542 },
543 "dYdX orderbook subscription",
544 );
545
546 Ok(())
547 })
548 }
549
550 fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
551 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
552 log::debug!(
553 "Subscribe_quotes for {instrument_id}: subscribing to orderbook WS channel for quote synthesis"
554 );
555
556 self.last_quotes.remove(&instrument_id);
557 self.ensure_order_book(instrument_id, BookType::L2_MBP);
558 self.active_quote_subs.insert(instrument_id);
559 let ws = self.ws_client.clone();
560 self.spawn_ws(
561 async move {
562 ws.subscribe_orderbook(instrument_id)
563 .await
564 .context("orderbook subscription (for quotes)")
565 },
566 "dYdX orderbook subscription (quotes)",
567 );
568
569 Ok(())
570 })
571 }
572
573 fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
574 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
575 let ws = self.ws_client.clone();
576
577 self.active_trade_subs.insert(instrument_id);
578 self.spawn_ws(
579 async move {
580 ws.subscribe_trades(instrument_id)
581 .await
582 .context("trade subscription")
583 },
584 "dYdX trade subscription",
585 );
586
587 Ok(())
588 })
589 }
590
591 fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
592 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
593 self.active_mark_price_subs.insert(instrument_id);
594 log::debug!("Subscribed to mark prices for {instrument_id} (via v4_markets channel)");
595 Ok(())
596 })
597 }
598
599 fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
600 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
601 self.active_index_price_subs.insert(instrument_id);
602 log::debug!("Subscribed to index prices for {instrument_id} (via v4_markets channel)");
603 Ok(())
604 })
605 }
606
607 fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
608 execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
609 let ws = self.ws_client.clone();
610 let instrument_id = bar_type.instrument_id();
611 let spec = bar_type.spec();
612
613 let resolution = Self::map_bar_spec_to_resolution(&spec)?;
614 self.active_bar_subs
615 .insert((instrument_id, resolution.to_string()), bar_type);
616
617 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
618 let topic = format!("{ticker}/{resolution}");
619 self.bar_type_mappings.insert(topic, bar_type);
620
621 self.spawn_ws(
622 async move {
623 ws.subscribe_candles(instrument_id, resolution)
624 .await
625 .context("candles subscription")
626 },
627 "dYdX candles subscription",
628 );
629
630 Ok(())
631 })
632 }
633
634 fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
635 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
636 self.active_funding_rate_subs.insert(instrument_id);
637 log::debug!("Subscribed to funding rates for {instrument_id} (via v4_markets channel)");
638 Ok(())
639 })
640 }
641
642 fn subscribe_instrument_status(
643 &mut self,
644 cmd: SubscribeInstrumentStatus,
645 ) -> anyhow::Result<()> {
646 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
647 self.active_instrument_status_subs.insert(instrument_id);
648 log::debug!(
649 "Subscribed to instrument status for {instrument_id} (via v4_markets channel)"
650 );
651
652 if let Some(status) = self.last_instrument_statuses.get(&instrument_id)
654 && let Err(e) = self
655 .data_sender
656 .send(DataEvent::Data(NautilusData::InstrumentStatus(*status)))
657 {
658 log::error!("Failed to replay instrument status for {instrument_id}: {e}");
659 }
660
661 Ok(())
662 })
663 }
664
665 fn unsubscribe_instruments(&mut self, _cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
666 log::debug!("unsubscribe_instruments: dYdX markets channel is global; no-op");
667 Ok(())
668 }
669
670 fn unsubscribe_instrument(&mut self, _cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
671 log::debug!("unsubscribe_instrument: dYdX markets channel is global; no-op");
672 Ok(())
673 }
674
675 fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
676 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
677 self.active_delta_subs.remove(&instrument_id);
678
679 let ws = self.ws_client.clone();
680 self.spawn_ws(
681 async move {
682 ws.unsubscribe_orderbook(instrument_id)
683 .await
684 .context("orderbook unsubscription")
685 },
686 "dYdX orderbook unsubscription",
687 );
688
689 Ok(())
690 })
691 }
692
693 fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
694 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
695 log::debug!("unsubscribe_quotes for {instrument_id}: removing quote subscription");
696
697 self.active_quote_subs.remove(&instrument_id);
698 self.last_quotes.remove(&instrument_id);
699
700 let ws = self.ws_client.clone();
701 self.spawn_ws(
702 async move {
703 ws.unsubscribe_orderbook(instrument_id)
704 .await
705 .context("orderbook unsubscription (for quotes)")
706 },
707 "dYdX orderbook unsubscription (quotes)",
708 );
709
710 Ok(())
711 })
712 }
713
714 fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
715 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
716 self.active_trade_subs.remove(&instrument_id);
717
718 let ws = self.ws_client.clone();
719 self.spawn_ws(
720 async move {
721 ws.unsubscribe_trades(instrument_id)
722 .await
723 .context("trade unsubscription")
724 },
725 "dYdX trade unsubscription",
726 );
727
728 Ok(())
729 })
730 }
731
732 fn unsubscribe_mark_prices(&mut self, cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
733 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
734 self.active_mark_price_subs.remove(&instrument_id);
735 log::debug!("Unsubscribed from mark prices for {instrument_id}");
736 Ok(())
737 })
738 }
739
740 fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
741 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
742 self.active_index_price_subs.remove(&instrument_id);
743 log::debug!("Unsubscribed from index prices for {instrument_id}");
744 Ok(())
745 })
746 }
747
748 fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
749 execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
750 let ws = self.ws_client.clone();
751 let instrument_id = bar_type.instrument_id();
752 let spec = bar_type.spec();
753
754 let resolution = Self::map_bar_spec_to_resolution(&spec)?;
755
756 self.active_bar_subs
757 .remove(&(instrument_id, resolution.to_string()));
758
759 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
760 let topic = format!("{ticker}/{resolution}");
761 self.bar_type_mappings.remove(&topic);
762
763 self.spawn_ws(
764 async move {
765 ws.unsubscribe_candles(instrument_id, resolution)
766 .await
767 .context("candles unsubscription")
768 },
769 "dYdX candles unsubscription",
770 );
771
772 Ok(())
773 })
774 }
775
776 fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
777 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
778 self.active_funding_rate_subs.remove(&instrument_id);
779 log::debug!("Unsubscribed from funding rates for {instrument_id}");
780 Ok(())
781 })
782 }
783
784 fn unsubscribe_instrument_status(
785 &mut self,
786 cmd: &UnsubscribeInstrumentStatus,
787 ) -> anyhow::Result<()> {
788 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
789 self.active_instrument_status_subs.remove(&instrument_id);
790 log::debug!("Unsubscribed from instrument status for {instrument_id}");
791 Ok(())
792 })
793 }
794
795 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
796 let instrument_id = request.single_instrument_id()?;
797
798 if request.start.is_some() {
799 log::warn!(
800 "Requesting instrument {instrument_id} with specified `start` which has no effect"
801 );
802 }
803
804 if request.end.is_some() {
805 log::warn!(
806 "Requesting instrument {instrument_id} with specified `end` which has no effect"
807 );
808 }
809
810 let instrument_cache = self.instrument_cache.clone();
811 let sender = self.data_sender.clone();
812 let http = self.http_client.clone();
813 let request_id = request.request_id;
814 let client_id = request.client_id.unwrap_or(self.client_id);
815 let start = request.start;
816 let end = request.end;
817 let params = request.params;
818 let clock = self.clock;
819 let start_nanos = datetime_to_unix_nanos(start);
820 let end_nanos = datetime_to_unix_nanos(end);
821
822 self.spawn_command(async move {
823 let instrument = match http.request_instruments(None).await {
824 Ok(instruments) => {
825 for inst in &instruments {
826 instrument_cache.insert_instrument_only(inst.clone());
827 }
828 instruments.into_iter().find(|i| i.id() == instrument_id)
829 }
830 Err(e) => {
831 log::error!("Failed to fetch instruments from dYdX: {e:?}");
832 None
833 }
834 };
835
836 if let Some(inst) = instrument {
837 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
838 request_id,
839 client_id,
840 instrument_id,
841 inst,
842 start_nanos,
843 end_nanos,
844 clock.get_time_ns(),
845 params,
846 )));
847
848 if let Err(e) = sender.send(DataEvent::Response(response)) {
849 log::error!("Failed to send instrument response: {e}");
850 }
851 } else {
852 log::error!("Instrument {instrument_id} not found");
853 }
854 });
855
856 Ok(())
857 }
858
859 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
860 let http = self.http_client.clone();
861 let sender = self.data_sender.clone();
862 let instrument_cache = self.instrument_cache.clone();
863 let request_id = request.request_id;
864 let client_id = request.client_id.unwrap_or(self.client_id);
865 let venue = self.venue();
866 let start = request.start;
867 let end = request.end;
868 let params = request.params;
869 let clock = self.clock;
870 let start_nanos = datetime_to_unix_nanos(start);
871 let end_nanos = datetime_to_unix_nanos(end);
872
873 self.spawn_command(async move {
874 match http.request_instruments(None).await {
875 Ok(instruments) => {
876 log::debug!("Fetched {} instruments from dYdX", instruments.len());
877
878 for instrument in &instruments {
879 instrument_cache.insert_instrument_only(instrument.clone());
880 }
881
882 let response = DataResponse::Instruments(InstrumentsResponse::new(
883 request_id,
884 client_id,
885 venue,
886 instruments,
887 start_nanos,
888 end_nanos,
889 clock.get_time_ns(),
890 params,
891 ));
892
893 if let Err(e) = sender.send(DataEvent::Response(response)) {
894 log::error!("Failed to send instruments response: {e}");
895 }
896 }
897 Err(e) => {
898 log::error!("Failed to fetch instruments from dYdX: {e:?}");
899
900 let response = DataResponse::Instruments(InstrumentsResponse::new(
901 request_id,
902 client_id,
903 venue,
904 Vec::new(),
905 start_nanos,
906 end_nanos,
907 clock.get_time_ns(),
908 params,
909 ));
910
911 if let Err(e) = sender.send(DataEvent::Response(response)) {
912 log::error!("Failed to send empty instruments response: {e}");
913 }
914 }
915 }
916 });
917
918 Ok(())
919 }
920
921 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
922 let instrument_id = request.single_instrument_id()?;
923
924 if request.depth.is_some() {
925 log::warn!(
926 "Requesting book snapshot for {instrument_id} with specified `depth` which has no effect"
927 );
928 }
929
930 let http_client = self.http_client.clone();
931 let sender = self.data_sender.clone();
932 let request_id = request.request_id;
933 let client_id = request.client_id.unwrap_or(self.client_id);
934 let params = request.params;
935 let clock = self.clock;
936
937 self.spawn_command(async move {
938 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
939
940 match http_client.request_orderbook_snapshot(instrument_id).await {
941 Ok(deltas) => {
942 if let Err(e) = book.apply_deltas(&deltas) {
943 log::error!("Failed to apply book snapshot for {instrument_id}: {e}");
944 book.reset();
945 }
946 }
947 Err(e) => {
948 log::error!("Book snapshot request failed for {instrument_id}: {e:?}");
949 }
950 }
951
952 let response = DataResponse::Book(BookResponse::new(
953 request_id,
954 client_id,
955 instrument_id,
956 book,
957 None,
958 None,
959 clock.get_time_ns(),
960 params,
961 ));
962
963 if let Err(e) = sender.send(DataEvent::Response(response)) {
964 log::error!("Failed to send book snapshot response: {e}");
965 }
966 });
967
968 Ok(())
969 }
970
971 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
972 let instrument_id = request.single_instrument_id()?;
973
974 let http_client = self.http_client.clone();
975 let sender = self.data_sender.clone();
976 let start = request.start;
977 let end = request.end;
978 let limit = request.limit.map(|n| n.get() as u32);
979 let request_id = request.request_id;
980 let client_id = request.client_id.unwrap_or(self.client_id);
981 let params = request.params;
982 let clock = self.clock;
983 let start_nanos = datetime_to_unix_nanos(start);
984 let end_nanos = datetime_to_unix_nanos(end);
985
986 self.spawn_command(async move {
987 match http_client
988 .request_trade_ticks(instrument_id, start, end, limit)
989 .await
990 .context("failed to request trades from dYdX")
991 {
992 Ok(trades) => {
993 let response = DataResponse::Trades(TradesResponse::new(
994 request_id,
995 client_id,
996 instrument_id,
997 trades,
998 start_nanos,
999 end_nanos,
1000 clock.get_time_ns(),
1001 params,
1002 ));
1003
1004 if let Err(e) = sender.send(DataEvent::Response(response)) {
1005 log::error!("Failed to send trades response: {e}");
1006 }
1007 }
1008 Err(e) => {
1009 log::error!("Trade request failed for {instrument_id}: {e:?}");
1010
1011 let response = DataResponse::Trades(TradesResponse::new(
1012 request_id,
1013 client_id,
1014 instrument_id,
1015 Vec::new(),
1016 start_nanos,
1017 end_nanos,
1018 clock.get_time_ns(),
1019 params,
1020 ));
1021
1022 if let Err(e) = sender.send(DataEvent::Response(response)) {
1023 log::error!("Failed to send empty trades response: {e}");
1024 }
1025 }
1026 }
1027 });
1028
1029 Ok(())
1030 }
1031
1032 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1033 let bar_type = request.single_bar_type()?;
1034
1035 let http_client = self.http_client.clone();
1036 let sender = self.data_sender.clone();
1037 let start = request.start;
1038 let end = request.end;
1039 let limit = request.limit.map(|n| n.get() as u32);
1040 let request_id = request.request_id;
1041 let client_id = request.client_id.unwrap_or(self.client_id);
1042 let params = request.params;
1043 let clock = self.clock;
1044 let start_nanos = datetime_to_unix_nanos(start);
1045 let end_nanos = datetime_to_unix_nanos(end);
1046
1047 self.spawn_command(async move {
1048 match http_client
1049 .request_bars(bar_type, start, end, limit, true)
1050 .await
1051 .context("failed to request bars from dYdX")
1052 {
1053 Ok(bars) => {
1054 let response = DataResponse::Bars(BarsResponse::new(
1055 request_id,
1056 client_id,
1057 bar_type,
1058 bars,
1059 start_nanos,
1060 end_nanos,
1061 clock.get_time_ns(),
1062 params,
1063 ));
1064
1065 if let Err(e) = sender.send(DataEvent::Response(response)) {
1066 log::error!("Failed to send bars response: {e}");
1067 }
1068 }
1069 Err(e) => {
1070 log::error!("Bar request failed for {bar_type}: {e:?}");
1071
1072 let response = DataResponse::Bars(BarsResponse::new(
1073 request_id,
1074 client_id,
1075 bar_type,
1076 Vec::new(),
1077 start_nanos,
1078 end_nanos,
1079 clock.get_time_ns(),
1080 params,
1081 ));
1082
1083 if let Err(e) = sender.send(DataEvent::Response(response)) {
1084 log::error!("Failed to send empty bars response: {e}");
1085 }
1086 }
1087 }
1088 });
1089
1090 Ok(())
1091 }
1092
1093 fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
1094 let instrument_id = request.single_instrument_id()?;
1095
1096 let http_client = self.http_client.clone();
1097 let sender = self.data_sender.clone();
1098 let start = request.start;
1099 let end = request.end;
1100 let limit = request.limit.map(|n| n.get() as u32);
1101 let request_id = request.request_id;
1102 let client_id = request.client_id.unwrap_or(self.client_id);
1103 let params = request.params;
1104 let clock = self.clock;
1105 let start_nanos = datetime_to_unix_nanos(start);
1106 let end_nanos = datetime_to_unix_nanos(end);
1107
1108 self.spawn_command(async move {
1109 match http_client
1110 .request_funding_rates(instrument_id, start, end, limit)
1111 .await
1112 .context("failed to request funding rates from dYdX")
1113 {
1114 Ok(funding_rates) => {
1115 let response = DataResponse::FundingRates(FundingRatesResponse::new(
1116 request_id,
1117 client_id,
1118 instrument_id,
1119 funding_rates,
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 funding rates response: {e}");
1128 }
1129 }
1130 Err(e) => {
1131 log::error!("Funding rates request failed for {instrument_id}: {e:?}");
1132
1133 let response = DataResponse::FundingRates(FundingRatesResponse::new(
1134 request_id,
1135 client_id,
1136 instrument_id,
1137 Vec::new(),
1138 start_nanos,
1139 end_nanos,
1140 clock.get_time_ns(),
1141 params,
1142 ));
1143
1144 if let Err(e) = sender.send(DataEvent::Response(response)) {
1145 log::error!("Failed to send empty funding rates response: {e}");
1146 }
1147 }
1148 }
1149 });
1150
1151 Ok(())
1152 }
1153}
1154
1155impl DydxDataClient {
1156 #[must_use]
1158 pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
1159 self.instrument_cache.get(instrument_id)
1160 }
1161
1162 #[must_use]
1164 pub fn get_instruments(&self) -> Vec<InstrumentAny> {
1165 self.instrument_cache.all_instruments()
1166 }
1167
1168 pub fn cache_instrument(&self, instrument: InstrumentAny) {
1170 self.instrument_cache.insert_instrument_only(instrument);
1171 }
1172
1173 pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
1177 self.instrument_cache.clear();
1178 self.instrument_cache.insert_instruments_only(instruments);
1179 }
1180
1181 fn ensure_order_book(&self, instrument_id: InstrumentId, book_type: BookType) {
1182 self.order_books
1183 .entry(instrument_id)
1184 .or_insert_with(|| OrderBook::new(instrument_id, book_type));
1185 }
1186
1187 #[must_use]
1189 pub fn get_bar_type_for_topic(&self, topic: &str) -> Option<BarType> {
1190 self.bar_type_mappings.load().get(topic).copied()
1191 }
1192
1193 #[must_use]
1195 pub fn get_bar_topics(&self) -> Vec<String> {
1196 self.bar_type_mappings.load().keys().cloned().collect()
1197 }
1198
1199 fn handle_ws_message(message: DydxWsOutputMessage, ctx: &WsMessageContext) {
1200 let ts_init = ctx.clock.get_time_ns();
1201
1202 match message {
1203 DydxWsOutputMessage::Trades { id, contents } => {
1204 let Some(instrument) = ctx.instrument_cache.get_by_market(&id) else {
1205 log::warn!("No instrument cached for market {id}");
1206 return;
1207 };
1208 let instrument_id = instrument.id();
1209
1210 match ws_parse::parse_trade_ticks(instrument_id, &instrument, &contents, ts_init) {
1211 Ok(data) => {
1212 Self::handle_data_message(
1213 data,
1214 &ctx.data_sender,
1215 &ctx.incomplete_bars,
1216 ctx.clock,
1217 );
1218 }
1219 Err(e) => log::error!("Failed to parse trade ticks for {id}: {e}"),
1220 }
1221 }
1222 DydxWsOutputMessage::OrderbookSnapshot { id, contents } => {
1223 let Some(instrument) = ctx.instrument_cache.get_by_market(&id) else {
1224 log::warn!("No instrument cached for market {id}");
1225 return;
1226 };
1227 let instrument_id = instrument.id();
1228
1229 match ws_parse::parse_orderbook_snapshot(
1230 &instrument_id,
1231 &contents,
1232 instrument.price_precision(),
1233 instrument.size_precision(),
1234 ts_init,
1235 ) {
1236 Ok(deltas) => {
1237 Self::handle_deltas_message(
1238 deltas,
1239 &ctx.data_sender,
1240 &ctx.order_books,
1241 &ctx.last_quotes,
1242 &ctx.instrument_cache,
1243 &ctx.active_quote_subs,
1244 &ctx.active_delta_subs,
1245 );
1246 }
1247 Err(e) => log::error!("Failed to parse orderbook snapshot for {id}: {e}"),
1248 }
1249 }
1250 DydxWsOutputMessage::OrderbookUpdate { id, contents } => {
1251 let Some(instrument) = ctx.instrument_cache.get_by_market(&id) else {
1252 log::warn!("No instrument cached for market {id}");
1253 return;
1254 };
1255 let instrument_id = instrument.id();
1256
1257 match ws_parse::parse_orderbook_deltas(
1258 &instrument_id,
1259 &contents,
1260 instrument.price_precision(),
1261 instrument.size_precision(),
1262 ts_init,
1263 ) {
1264 Ok(deltas) => {
1265 Self::handle_deltas_message(
1266 deltas,
1267 &ctx.data_sender,
1268 &ctx.order_books,
1269 &ctx.last_quotes,
1270 &ctx.instrument_cache,
1271 &ctx.active_quote_subs,
1272 &ctx.active_delta_subs,
1273 );
1274 }
1275 Err(e) => log::error!("Failed to parse orderbook deltas for {id}: {e}"),
1276 }
1277 }
1278 DydxWsOutputMessage::OrderbookBatch { id, updates } => {
1279 let Some(instrument) = ctx.instrument_cache.get_by_market(&id) else {
1280 log::warn!("No instrument cached for market {id}");
1281 return;
1282 };
1283 let instrument_id = instrument.id();
1284 let price_precision = instrument.price_precision();
1285 let size_precision = instrument.size_precision();
1286
1287 let mut all_deltas = Vec::new();
1288 let last_idx = updates.len().saturating_sub(1);
1289
1290 for (i, update) in updates.iter().enumerate() {
1291 let is_last = i == last_idx;
1292 let result = if is_last {
1293 ws_parse::parse_orderbook_deltas(
1294 &instrument_id,
1295 update,
1296 price_precision,
1297 size_precision,
1298 ts_init,
1299 )
1300 .map(|d| d.deltas)
1301 } else {
1302 ws_parse::parse_orderbook_deltas_with_flag(
1303 &instrument_id,
1304 update,
1305 price_precision,
1306 size_precision,
1307 ts_init,
1308 false,
1309 )
1310 };
1311
1312 match result {
1313 Ok(deltas) => all_deltas.extend(deltas),
1314 Err(e) => {
1315 log::error!("Failed to parse orderbook batch delta {i} for {id}: {e}");
1316 return;
1317 }
1318 }
1319 }
1320
1321 if all_deltas.is_empty() {
1322 return;
1323 }
1324 let deltas = OrderBookDeltas::new(instrument_id, all_deltas);
1325 Self::handle_deltas_message(
1326 deltas,
1327 &ctx.data_sender,
1328 &ctx.order_books,
1329 &ctx.last_quotes,
1330 &ctx.instrument_cache,
1331 &ctx.active_quote_subs,
1332 &ctx.active_delta_subs,
1333 );
1334 }
1335 DydxWsOutputMessage::Candles { id, contents } => {
1336 let parts: Vec<&str> = id.splitn(2, '/').collect();
1337 if parts.len() != 2 {
1338 log::warn!("Unexpected candle topic format: {id}");
1339 return;
1340 }
1341 let ticker = parts[0];
1342
1343 let Some(bar_type) = ctx.bar_type_mappings.load().get(&id).copied() else {
1344 log::debug!("No bar type mapping for candle topic {id}");
1345 return;
1346 };
1347
1348 let Some(instrument) = ctx.instrument_cache.get_by_market(ticker) else {
1349 log::warn!("No instrument cached for market {ticker}");
1350 return;
1351 };
1352
1353 match ws_parse::parse_candle_bar(
1354 bar_type,
1355 &instrument,
1356 &contents,
1357 ctx.bars_timestamp_on_close,
1358 ts_init,
1359 ) {
1360 Ok(bar) => {
1361 let prev = ctx.pending_bars.get(&id).map(|r| *r);
1362 if let Some(prev_bar) = prev
1363 && bar.ts_event != prev_bar.ts_event
1364 {
1365 Self::emit_bar_guarded(prev_bar, ctx);
1366 }
1367 ctx.pending_bars.insert(id, bar);
1368 }
1369 Err(e) => log::error!("Failed to parse candle bar for {id}: {e}"),
1370 }
1371 }
1372 DydxWsOutputMessage::Markets(contents) => {
1373 Self::handle_markets_message(&contents, ctx, ts_init);
1374 }
1375 DydxWsOutputMessage::SubaccountSubscribed(_) => {
1376 log::debug!("Ignoring subaccount subscribed on data client");
1377 }
1378 DydxWsOutputMessage::SubaccountsChannelData(_) => {
1379 log::debug!("Ignoring subaccounts channel data on data client");
1380 }
1381 DydxWsOutputMessage::BlockHeight { .. } => {
1382 log::debug!("Ignoring block height on data client");
1383 }
1384 DydxWsOutputMessage::Error(err) => {
1385 log::warn!("dYdX WS error: {err}");
1386 }
1387 DydxWsOutputMessage::Reconnected { topics } => {
1388 Self::clear_reconnected_quotes(&topics, ctx);
1389 let reconnected_candles = candle_ids_from_topics(&topics);
1390 ctx.pending_bars
1391 .retain(|id, _| !reconnected_candles.contains(id));
1392
1393 let total_subs = ctx.active_quote_subs.len()
1394 + ctx.active_delta_subs.len()
1395 + ctx.active_trade_subs.len()
1396 + ctx.active_bar_subs.len();
1397
1398 log::info!(
1399 "dYdX WS reconnected; handler replayed channel subscriptions (active data subscriptions: total={}, quotes={}, deltas={}, trades={}, bars={})",
1400 total_subs,
1401 ctx.active_quote_subs.len(),
1402 ctx.active_delta_subs.len(),
1403 ctx.active_trade_subs.len(),
1404 ctx.active_bar_subs.len()
1405 );
1406 }
1407 }
1408 }
1409
1410 fn clear_reconnected_quotes(topics: &[String], ctx: &WsMessageContext) {
1411 let prefix = format!(
1412 "{}{}",
1413 DydxWsChannel::Orderbook.as_ref(),
1414 DYDX_WS_TOPIC_DELIMITER,
1415 );
1416
1417 for topic in topics {
1418 if let Some(ticker) = topic.strip_prefix(&prefix) {
1419 ctx.last_quotes
1420 .remove(&Self::instrument_id_from_ticker(ticker));
1421 }
1422 }
1423 }
1424
1425 fn instrument_id_from_ticker(ticker: &str) -> InstrumentId {
1426 let symbol = format!("{ticker}-PERP");
1427 InstrumentId::new(Symbol::new(&symbol), *DYDX_VENUE)
1428 }
1429
1430 fn handle_markets_message(
1431 contents: &crate::websocket::messages::DydxMarketsContents,
1432 ctx: &WsMessageContext,
1433 ts_init: nautilus_core::UnixNanos,
1434 ) {
1435 if let Some(ref oracle_prices) = contents.oracle_prices {
1436 for (ticker, oracle_data) in oracle_prices {
1437 let instrument_id = Self::instrument_id_from_ticker(ticker);
1438
1439 let Ok(price) = parse_oracle_price(&oracle_data.oracle_price) else {
1440 log::warn!("Failed to parse oracle price for {ticker}");
1441 continue;
1442 };
1443
1444 if ctx.active_mark_price_subs.contains(&instrument_id) {
1445 let mark_price = MarkPriceUpdate::new(instrument_id, price, ts_init, ts_init);
1446 let data = NautilusData::MarkPrice(mark_price);
1447 if let Err(e) = ctx.data_sender.send(DataEvent::Data(data)) {
1448 log::error!("Failed to emit mark price for {instrument_id}: {e}");
1449 }
1450 }
1451
1452 if ctx.active_index_price_subs.contains(&instrument_id) {
1453 let index_price = IndexPriceUpdate::new(instrument_id, price, ts_init, ts_init);
1454 let data = NautilusData::IndexPrice(index_price);
1455 if let Err(e) = ctx.data_sender.send(DataEvent::Data(data)) {
1456 log::error!("Failed to emit index price for {instrument_id}: {e}");
1457 }
1458 }
1459 }
1460 }
1461
1462 Self::handle_markets_trading_data(contents.trading.as_ref(), ctx, ts_init, false);
1463 Self::handle_markets_trading_data(contents.markets.as_ref(), ctx, ts_init, true);
1464 }
1465
1466 fn handle_markets_trading_data(
1467 trading: Option<
1468 &std::collections::HashMap<String, crate::websocket::messages::DydxMarketTradingUpdate>,
1469 >,
1470 ctx: &WsMessageContext,
1471 ts_init: nautilus_core::UnixNanos,
1472 is_snapshot: bool,
1473 ) {
1474 let Some(trading_map) = trading else {
1475 return;
1476 };
1477
1478 for (ticker, update) in trading_map {
1479 let instrument_id = Self::instrument_id_from_ticker(ticker);
1480
1481 if let Some(status) = &update.status {
1482 if *status == crate::common::enums::DydxMarketStatus::Unknown {
1483 log::warn!("Skipping unmodeled dYdX market status for {instrument_id}");
1484 } else {
1485 let action = MarketStatusAction::from(*status);
1486 let is_trading =
1487 matches!(status, crate::common::enums::DydxMarketStatus::Active);
1488
1489 let instrument_status = InstrumentStatus::new(
1490 instrument_id,
1491 action,
1492 ts_init,
1493 ts_init,
1494 None,
1495 None,
1496 Some(is_trading),
1497 None,
1498 None,
1499 );
1500
1501 ctx.last_instrument_statuses
1502 .insert(instrument_id, instrument_status);
1503
1504 if ctx.active_instrument_status_subs.contains(&instrument_id)
1505 && let Err(e) =
1506 ctx.data_sender
1507 .send(DataEvent::Data(NautilusData::InstrumentStatus(
1508 instrument_status,
1509 )))
1510 {
1511 log::error!("Failed to emit instrument status for {instrument_id}: {e}");
1512 }
1513 }
1514 }
1515
1516 let ticker_ustr = Ustr::from(ticker.as_str());
1517 if !ctx.seen_tickers.contains(&ticker_ustr) {
1518 let is_active = update
1519 .status
1520 .as_ref()
1521 .is_none_or(|s| matches!(s, crate::common::enums::DydxMarketStatus::Active));
1522
1523 if ctx.instrument_cache.get_by_market(ticker).is_some() {
1524 ctx.seen_tickers.insert(ticker_ustr);
1525 } else if is_active {
1526 ctx.seen_tickers.insert(ticker_ustr);
1527 Self::handle_new_instrument_discovered(ticker, ctx);
1528 }
1529 }
1530
1531 if let Some(ref rate_str) = update.next_funding_rate {
1532 if let Ok(rate) = Decimal::from_str(rate_str) {
1533 if ctx.active_funding_rate_subs.contains(&instrument_id) {
1534 let funding_rate = FundingRateUpdate {
1535 instrument_id,
1536 rate,
1537 interval: Some(60),
1538 next_funding_ns: None,
1539 ts_event: ts_init,
1540 ts_init,
1541 };
1542
1543 if let Err(e) = ctx
1544 .data_sender
1545 .send(DataEvent::Data(NautilusData::FundingRate(funding_rate)))
1546 {
1547 log::error!("Failed to emit funding rate for {instrument_id}: {e}");
1548 }
1549 }
1550 } else {
1551 log::warn!("Failed to parse next_funding_rate for {ticker}: {rate_str}");
1552 }
1553 }
1554
1555 if is_snapshot
1556 && let Some(ref oracle_price_str) = update.oracle_price
1557 && let Ok(price) = parse_oracle_price(oracle_price_str)
1558 {
1559 if ctx.active_mark_price_subs.contains(&instrument_id) {
1560 let mark_price = MarkPriceUpdate::new(instrument_id, price, ts_init, ts_init);
1561 let data = NautilusData::MarkPrice(mark_price);
1562
1563 if let Err(e) = ctx.data_sender.send(DataEvent::Data(data)) {
1564 log::error!("Failed to emit mark price for {instrument_id}: {e}");
1565 }
1566 }
1567
1568 if ctx.active_index_price_subs.contains(&instrument_id) {
1569 let index_price = IndexPriceUpdate::new(instrument_id, price, ts_init, ts_init);
1570 let data = NautilusData::IndexPrice(index_price);
1571
1572 if let Err(e) = ctx.data_sender.send(DataEvent::Data(data)) {
1573 log::error!("Failed to emit index price for {instrument_id}: {e}");
1574 }
1575 }
1576 }
1577 }
1578 }
1579
1580 fn emit_bar_guarded(bar: Bar, ctx: &WsMessageContext) {
1581 let current_time_ns = ctx.clock.get_time_ns();
1582 if bar.ts_event <= current_time_ns {
1583 ctx.incomplete_bars.remove(&bar.bar_type);
1584 if let Err(e) = ctx
1585 .data_sender
1586 .send(DataEvent::Data(NautilusData::Bar(bar)))
1587 {
1588 log::error!("Failed to emit completed bar: {e}");
1589 }
1590 } else {
1591 ctx.incomplete_bars.insert(bar.bar_type, bar);
1592 }
1593 }
1594
1595 fn handle_new_instrument_discovered(ticker: &str, ctx: &WsMessageContext) {
1596 log::debug!("New instrument discovered via WebSocket: {ticker}");
1597
1598 let http_client = ctx.http_client.clone();
1599 let ws_client = ctx.ws_client.clone();
1600 let data_sender = ctx.data_sender.clone();
1601 let ticker = ticker.to_string();
1602
1603 if let Err(e) = ctx.command_spawner.spawn(async move {
1604 match http_client.fetch_and_cache_single_instrument(&ticker).await {
1605 Ok(Some(instrument)) => {
1606 ws_client.cache_instrument(instrument.clone());
1607 if let Err(e) = data_sender.send(DataEvent::Data(instrument.into())) {
1608 log::error!("Failed to emit new instrument: {e}");
1609 }
1610 log::debug!("Fetched and cached new instrument: {ticker}");
1611 }
1612 Ok(None) => {
1613 log::warn!("New instrument {ticker} not found or inactive");
1614 }
1615 Err(e) => {
1616 log::error!("Failed to fetch new instrument {ticker}: {e}");
1617 }
1618 }
1619 }) {
1620 log::warn!("Skipping new dYdX instrument fetch after shutdown began: {e}");
1621 }
1622 }
1623
1624 fn handle_data_message(
1625 payloads: Vec<NautilusData>,
1626 data_sender: &EventSender<DataEvent>,
1627 incomplete_bars: &Arc<DashMap<BarType, Bar>>,
1628 clock: &'static AtomicTime,
1629 ) {
1630 for data in payloads {
1631 if let NautilusData::Bar(bar) = data {
1632 Self::handle_bar_message(bar, data_sender, incomplete_bars, clock);
1633 } else if let Err(e) = data_sender.send(DataEvent::Data(data)) {
1634 log::error!("Failed to emit data event: {e}");
1635 }
1636 }
1637 }
1638
1639 fn handle_bar_message(
1640 bar: Bar,
1641 data_sender: &EventSender<DataEvent>,
1642 incomplete_bars: &Arc<DashMap<BarType, Bar>>,
1643 clock: &'static AtomicTime,
1644 ) {
1645 let current_time_ns = clock.get_time_ns();
1646 let bar_type = bar.bar_type;
1647
1648 if bar.ts_event <= current_time_ns {
1649 incomplete_bars.remove(&bar_type);
1650
1651 if let Err(e) = data_sender.send(DataEvent::Data(NautilusData::Bar(bar))) {
1652 log::error!("Failed to emit completed bar: {e}");
1653 }
1654 } else {
1655 log::trace!(
1656 "Caching incomplete bar for {} (ts_event={}, current={})",
1657 bar_type,
1658 bar.ts_event,
1659 current_time_ns
1660 );
1661 incomplete_bars.insert(bar_type, bar);
1662 }
1663 }
1664
1665 fn resolve_crossed_order_book(
1666 book: &mut OrderBook,
1667 venue_deltas: OrderBookDeltas,
1668 instrument: &InstrumentAny,
1669 ) -> anyhow::Result<OrderBookDeltas> {
1670 let instrument_id = venue_deltas.instrument_id;
1671 let ts_init = venue_deltas.ts_init;
1672
1673 let snapshot_flag = RecordFlag::F_SNAPSHOT as u8;
1677 let is_snapshot_batch = venue_deltas
1678 .deltas
1679 .iter()
1680 .any(|d| d.flags & snapshot_flag != 0);
1681 let synthetic_flags = if is_snapshot_batch { snapshot_flag } else { 0 };
1682
1683 book.apply_deltas(&venue_deltas)?;
1684 let mut all_deltas = venue_deltas.deltas;
1685
1686 let mut is_crossed = if let (Some(bid_price), Some(ask_price)) =
1687 (book.best_bid_price(), book.best_ask_price())
1688 {
1689 bid_price >= ask_price
1690 } else {
1691 false
1692 };
1693
1694 while is_crossed {
1695 log::debug!(
1696 "Resolving crossed order book for {}: bid={:?} >= ask={:?}",
1697 instrument_id,
1698 book.best_bid_price(),
1699 book.best_ask_price()
1700 );
1701
1702 let bid_price = match book.best_bid_price() {
1703 Some(p) => p,
1704 None => break,
1705 };
1706 let ask_price = match book.best_ask_price() {
1707 Some(p) => p,
1708 None => break,
1709 };
1710 let bid_size = match book.best_bid_size() {
1711 Some(s) => s,
1712 None => break,
1713 };
1714 let ask_size = match book.best_ask_size() {
1715 Some(s) => s,
1716 None => break,
1717 };
1718
1719 let mut temp_deltas = Vec::new();
1720
1721 if bid_size > ask_size {
1722 let new_bid_size = Quantity::from_decimal_dp(
1723 bid_size.as_decimal() - ask_size.as_decimal(),
1724 instrument.size_precision(),
1725 )?;
1726 temp_deltas.push(OrderBookDelta::new(
1727 instrument_id,
1728 BookAction::Update,
1729 BookOrder::new(OrderSide::Buy, bid_price, new_bid_size, 0),
1730 synthetic_flags,
1731 0,
1732 ts_init,
1733 ts_init,
1734 ));
1735 temp_deltas.push(OrderBookDelta::new(
1736 instrument_id,
1737 BookAction::Delete,
1738 BookOrder::new(
1739 OrderSide::Sell,
1740 ask_price,
1741 Quantity::zero(instrument.size_precision()),
1742 0,
1743 ),
1744 synthetic_flags,
1745 0,
1746 ts_init,
1747 ts_init,
1748 ));
1749 } else if bid_size < ask_size {
1750 let new_ask_size = Quantity::from_decimal_dp(
1751 ask_size.as_decimal() - bid_size.as_decimal(),
1752 instrument.size_precision(),
1753 )?;
1754 temp_deltas.push(OrderBookDelta::new(
1755 instrument_id,
1756 BookAction::Update,
1757 BookOrder::new(OrderSide::Sell, ask_price, new_ask_size, 0),
1758 synthetic_flags,
1759 0,
1760 ts_init,
1761 ts_init,
1762 ));
1763 temp_deltas.push(OrderBookDelta::new(
1764 instrument_id,
1765 BookAction::Delete,
1766 BookOrder::new(
1767 OrderSide::Buy,
1768 bid_price,
1769 Quantity::zero(instrument.size_precision()),
1770 0,
1771 ),
1772 synthetic_flags,
1773 0,
1774 ts_init,
1775 ts_init,
1776 ));
1777 } else {
1778 temp_deltas.push(OrderBookDelta::new(
1779 instrument_id,
1780 BookAction::Delete,
1781 BookOrder::new(
1782 OrderSide::Buy,
1783 bid_price,
1784 Quantity::zero(instrument.size_precision()),
1785 0,
1786 ),
1787 synthetic_flags,
1788 0,
1789 ts_init,
1790 ts_init,
1791 ));
1792 temp_deltas.push(OrderBookDelta::new(
1793 instrument_id,
1794 BookAction::Delete,
1795 BookOrder::new(
1796 OrderSide::Sell,
1797 ask_price,
1798 Quantity::zero(instrument.size_precision()),
1799 0,
1800 ),
1801 synthetic_flags,
1802 0,
1803 ts_init,
1804 ts_init,
1805 ));
1806 }
1807
1808 let temp_deltas_obj = OrderBookDeltas::new(instrument_id, temp_deltas);
1809 book.apply_deltas(&temp_deltas_obj)?;
1810 all_deltas.extend(temp_deltas_obj.deltas);
1811
1812 is_crossed = if let (Some(bid_price), Some(ask_price)) =
1813 (book.best_bid_price(), book.best_ask_price())
1814 {
1815 bid_price >= ask_price
1816 } else {
1817 false
1818 };
1819 }
1820
1821 if let Some(last_delta) = all_deltas.last_mut() {
1824 last_delta.flags = synthetic_flags | RecordFlag::F_LAST as u8;
1825 }
1826
1827 Ok(OrderBookDeltas::new(instrument_id, all_deltas))
1828 }
1829
1830 fn handle_deltas_message(
1831 deltas: OrderBookDeltas,
1832 data_sender: &EventSender<DataEvent>,
1833 order_books: &Arc<DashMap<InstrumentId, OrderBook>>,
1834 last_quotes: &Arc<DashMap<InstrumentId, QuoteTick>>,
1835 instrument_cache: &Arc<InstrumentCache>,
1836 active_quote_subs: &Arc<AtomicSet<InstrumentId>>,
1837 active_delta_subs: &Arc<AtomicSet<InstrumentId>>,
1838 ) {
1839 let instrument_id = deltas.instrument_id;
1840
1841 let instrument = match instrument_cache.get(&instrument_id) {
1842 Some(inst) => inst,
1843 None => {
1844 log::error!("Cannot resolve crossed order book: no instrument for {instrument_id}");
1845 if active_delta_subs.contains(&instrument_id)
1846 && let Err(e) = data_sender.send(DataEvent::Data(NautilusData::from(deltas)))
1847 {
1848 log::error!("Failed to emit order book deltas: {e}");
1849 }
1850 return;
1851 }
1852 };
1853
1854 let mut book = order_books
1856 .entry(instrument_id)
1857 .or_insert_with(|| OrderBook::new(instrument_id, BookType::L2_MBP));
1858
1859 let resolved_deltas = match Self::resolve_crossed_order_book(&mut book, deltas, &instrument)
1860 {
1861 Ok(d) => d,
1862 Err(e) => {
1863 log::error!("Failed to resolve crossed order book for {instrument_id}: {e}");
1864 return;
1865 }
1866 };
1867
1868 if active_quote_subs.contains(&instrument_id) {
1869 let quote_opt = if let (Some(bid_price), Some(ask_price)) =
1871 (book.best_bid_price(), book.best_ask_price())
1872 && let (Some(bid_size), Some(ask_size)) =
1873 (book.best_bid_size(), book.best_ask_size())
1874 {
1875 Some(QuoteTick::new(
1876 instrument_id,
1877 bid_price,
1878 ask_price,
1879 bid_size,
1880 ask_size,
1881 resolved_deltas.ts_event,
1882 resolved_deltas.ts_init,
1883 ))
1884 } else if book.best_bid_price().is_none() && book.best_ask_price().is_none() {
1885 log::debug!(
1886 "Empty orderbook for {instrument_id} after applying deltas, using last quote"
1887 );
1888 last_quotes.get(&instrument_id).map(|q| *q)
1889 } else {
1890 None
1891 };
1892
1893 if let Some(quote) = quote_opt {
1894 let emit_quote = !matches!(
1895 last_quotes.get(&instrument_id),
1896 Some(existing) if existing.has_same_bid_ask("e)
1897 );
1898
1899 if emit_quote {
1900 last_quotes.insert(instrument_id, quote);
1901 if let Err(e) = data_sender.send(DataEvent::Data(NautilusData::Quote(quote))) {
1902 log::error!("Failed to emit quote tick: {e}");
1903 }
1904 }
1905 } else if book.best_bid_price().is_some() || book.best_ask_price().is_some() {
1906 log::debug!(
1907 "Incomplete top-of-book for {instrument_id} (bid={:?}, ask={:?})",
1908 book.best_bid_price(),
1909 book.best_ask_price()
1910 );
1911 }
1912 }
1913
1914 if active_delta_subs.contains(&instrument_id) {
1915 let data: NautilusData = resolved_deltas.into();
1916 if let Err(e) = data_sender.send(DataEvent::Data(data)) {
1917 log::error!("Failed to emit order book deltas event: {e}");
1918 }
1919 }
1920 }
1921}
1922
1923fn parse_oracle_price(value: &str) -> anyhow::Result<Price> {
1924 let decimal = parse_decimal(value, "oracle_price")?;
1925
1926 Price::from_decimal_dp(decimal, FIXED_PRECISION).map_err(Into::into)
1928}
1929
1930struct WsMessageContext {
1931 clock: &'static AtomicTime,
1932 data_sender: EventSender<DataEvent>,
1933 instrument_cache: Arc<InstrumentCache>,
1934 order_books: Arc<DashMap<InstrumentId, OrderBook>>,
1935 last_quotes: Arc<DashMap<InstrumentId, QuoteTick>>,
1936 ws_client: DydxWebSocketClient,
1937 http_client: DydxHttpClient,
1938 active_quote_subs: Arc<AtomicSet<InstrumentId>>,
1939 active_delta_subs: Arc<AtomicSet<InstrumentId>>,
1940 active_trade_subs: Arc<AtomicSet<InstrumentId>>,
1941 active_bar_subs: Arc<AtomicMap<(InstrumentId, String), BarType>>,
1942 incomplete_bars: Arc<DashMap<BarType, Bar>>,
1943 bar_type_mappings: Arc<AtomicMap<String, BarType>>,
1944 active_mark_price_subs: Arc<AtomicSet<InstrumentId>>,
1945 active_index_price_subs: Arc<AtomicSet<InstrumentId>>,
1946 active_funding_rate_subs: Arc<AtomicSet<InstrumentId>>,
1947 active_instrument_status_subs: Arc<AtomicSet<InstrumentId>>,
1948 last_instrument_statuses: Arc<DashMap<InstrumentId, InstrumentStatus>>,
1949 bars_timestamp_on_close: bool,
1950 pending_bars: Arc<DashMap<String, Bar>>,
1951 seen_tickers: Arc<AtomicSet<Ustr>>,
1952 command_spawner: TaskSpawner,
1953}
1954
1955#[cfg(test)]
1956#[path = "../benches"]
1957pub(crate) mod tests {
1958 #[allow(dead_code)]
1960 #[path = "quotes/mod.rs"]
1961 pub(crate) mod benchmarks;
1962
1963 use nautilus_common::live::runner::set_data_event_sender;
1964 use nautilus_core::{UUID4, UnixNanos};
1965 use nautilus_model::{
1966 data::{BookOrder, OrderBookDelta, OrderBookDeltas},
1967 enums::{BookAction, BookType, OrderSide, RecordFlag},
1968 identifiers::{InstrumentId, Symbol},
1969 instruments::{CryptoPerpetual, InstrumentAny},
1970 orderbook::OrderBook,
1971 types::{Currency, Price, Quantity},
1972 };
1973 use rstest::rstest;
1974 use rust_decimal_macros::dec;
1975
1976 use super::*;
1977 use crate::{
1978 common::{consts::DYDX_VENUE, enums::DydxNetwork, testing::load_json_fixture},
1979 websocket::messages::DydxMarketsContents,
1980 };
1981
1982 fn test_instrument() -> InstrumentAny {
1983 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
1984 InstrumentAny::CryptoPerpetual(
1985 CryptoPerpetual::builder()
1986 .instrument_id(instrument_id)
1987 .raw_symbol(instrument_id.symbol)
1988 .base_currency(Currency::BTC())
1989 .quote_currency(Currency::USD())
1990 .settlement_currency(Currency::USD())
1991 .is_inverse(false)
1992 .price_precision(2)
1993 .size_precision(8)
1994 .price_increment(Price::new(0.01, 2))
1995 .size_increment(Quantity::new(0.00000001, 8))
1996 .ts_event(UnixNanos::default())
1997 .ts_init(UnixNanos::default())
1998 .build()
1999 .unwrap(),
2000 )
2001 }
2002
2003 fn seed_book_with_levels(
2004 instrument_id: InstrumentId,
2005 bids: &[(f64, f64)],
2006 asks: &[(f64, f64)],
2007 ) -> OrderBook {
2008 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
2009 let ts = UnixNanos::default();
2010
2011 let mut deltas: Vec<OrderBookDelta> = Vec::new();
2012 deltas.push(OrderBookDelta::clear(instrument_id, 0, ts, ts));
2013 for (price, size) in bids {
2014 deltas.push(OrderBookDelta::new(
2015 instrument_id,
2016 BookAction::Add,
2017 BookOrder::new(
2018 OrderSide::Buy,
2019 Price::new(*price, 2),
2020 Quantity::new(*size, 8),
2021 0,
2022 ),
2023 0,
2024 0,
2025 ts,
2026 ts,
2027 ));
2028 }
2029
2030 for (price, size) in asks {
2031 deltas.push(OrderBookDelta::new(
2032 instrument_id,
2033 BookAction::Add,
2034 BookOrder::new(
2035 OrderSide::Sell,
2036 Price::new(*price, 2),
2037 Quantity::new(*size, 8),
2038 0,
2039 ),
2040 0,
2041 0,
2042 ts,
2043 ts,
2044 ));
2045 }
2046
2047 if let Some(last) = deltas.last_mut() {
2048 last.flags = RecordFlag::F_LAST as u8;
2049 }
2050
2051 book.apply_deltas(&OrderBookDeltas::new(instrument_id, deltas))
2052 .expect("failed to apply seed deltas");
2053 book
2054 }
2055
2056 fn crossing_bid_deltas(
2057 instrument_id: InstrumentId,
2058 bid_price: f64,
2059 bid_size: f64,
2060 ) -> OrderBookDeltas {
2061 let ts = UnixNanos::default();
2062 let delta = OrderBookDelta::new(
2063 instrument_id,
2064 BookAction::Add,
2065 BookOrder::new(
2066 OrderSide::Buy,
2067 Price::new(bid_price, 2),
2068 Quantity::new(bid_size, 8),
2069 0,
2070 ),
2071 RecordFlag::F_LAST as u8,
2072 0,
2073 ts,
2074 ts,
2075 );
2076 OrderBookDeltas::new(instrument_id, vec![delta])
2077 }
2078
2079 #[rstest]
2080 fn test_resolve_crossed_order_book_preserves_decimal_precision() {
2081 let instrument = test_instrument();
2086 let instrument_id = instrument.id();
2087 let mut book = seed_book_with_levels(
2088 instrument_id,
2089 &[(99.00, 1.00000000)],
2090 &[(100.05, 0.50000000)],
2091 );
2092
2093 let venue_deltas = crossing_bid_deltas(instrument_id, 100.10, 1.00000001);
2094
2095 let resolved =
2096 DydxDataClient::resolve_crossed_order_book(&mut book, venue_deltas, &instrument)
2097 .expect("resolution should succeed");
2098
2099 let update = resolved
2102 .deltas
2103 .iter()
2104 .find(|d| {
2105 d.action == BookAction::Update
2106 && d.order.side == Some(OrderSide::Buy)
2107 && d.order.price.as_decimal() == dec!(100.10)
2108 })
2109 .expect("expected a Buy Update delta from crossed-book resolution");
2110 assert_eq!(update.order.size.as_decimal(), dec!(0.50000001));
2111
2112 assert_eq!(
2113 resolved.deltas.last().unwrap().flags,
2114 RecordFlag::F_LAST as u8,
2115 );
2116
2117 if let (Some(bid), Some(ask)) = (book.best_bid_price(), book.best_ask_price()) {
2118 assert!(bid < ask, "book still crossed: bid={bid:?} ask={ask:?}");
2119 }
2120 }
2121
2122 fn crossing_snapshot_batch(
2123 instrument_id: InstrumentId,
2124 bid_price: f64,
2125 bid_size: f64,
2126 ) -> OrderBookDeltas {
2127 let ts = UnixNanos::default();
2128 let snapshot = RecordFlag::F_SNAPSHOT as u8;
2129 let last = RecordFlag::F_LAST as u8;
2130 let deltas = vec![OrderBookDelta::new(
2131 instrument_id,
2132 BookAction::Add,
2133 BookOrder::new(
2134 OrderSide::Buy,
2135 Price::new(bid_price, 2),
2136 Quantity::new(bid_size, 8),
2137 0,
2138 ),
2139 snapshot | last,
2140 0,
2141 ts,
2142 ts,
2143 )];
2144 OrderBookDeltas::new(instrument_id, deltas)
2145 }
2146
2147 #[rstest]
2148 fn test_resolve_crossed_order_book_preserves_snapshot_flags() {
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, 0.50000000)],
2155 );
2156
2157 let venue_deltas = crossing_snapshot_batch(instrument_id, 100.10, 1.00000001);
2158
2159 let resolved =
2160 DydxDataClient::resolve_crossed_order_book(&mut book, venue_deltas, &instrument)
2161 .expect("resolution should succeed");
2162
2163 let snapshot = RecordFlag::F_SNAPSHOT as u8;
2164 let last = RecordFlag::F_LAST as u8;
2165
2166 for (idx, delta) in resolved.deltas.iter().enumerate() {
2167 assert!(
2168 delta.flags & snapshot != 0,
2169 "delta at index {idx} lost F_SNAPSHOT: flags={:#010b}",
2170 delta.flags,
2171 );
2172 }
2173 assert_eq!(
2174 resolved.deltas.last().unwrap().flags,
2175 snapshot | last,
2176 "snapshot terminator must be F_SNAPSHOT | F_LAST",
2177 );
2178 }
2179
2180 #[rstest]
2181 fn test_resolve_crossed_order_book_equal_sizes_removes_both_levels() {
2182 let instrument = test_instrument();
2183 let instrument_id = instrument.id();
2184 let mut book = seed_book_with_levels(
2185 instrument_id,
2186 &[(99.00, 1.00000000)],
2187 &[(100.05, 1.00000000)],
2188 );
2189
2190 let venue_deltas = crossing_bid_deltas(instrument_id, 100.10, 1.00000000);
2191
2192 let resolved =
2193 DydxDataClient::resolve_crossed_order_book(&mut book, venue_deltas, &instrument)
2194 .expect("resolution should succeed");
2195
2196 let deletes_count = resolved
2197 .deltas
2198 .iter()
2199 .filter(|d| {
2200 d.action == BookAction::Delete
2201 && (d.order.price.as_decimal() == dec!(100.10)
2202 || d.order.price.as_decimal() == dec!(100.05))
2203 })
2204 .count();
2205 assert_eq!(deletes_count, 2);
2206 }
2207
2208 #[rstest]
2209 #[case::bid_price(dec!(99.25), dec!(100.05), dec!(1.25), dec!(0.50))]
2210 #[case::ask_price(dec!(99.00), dec!(99.75), dec!(1.25), dec!(0.50))]
2211 #[case::bid_size(dec!(99.00), dec!(100.05), dec!(1.75), dec!(0.50))]
2212 #[case::ask_size(dec!(99.00), dec!(100.05), dec!(1.25), dec!(0.75))]
2213 fn test_handle_deltas_message_emits_quote_only_on_top_of_book_change(
2214 #[case] bid_price: Decimal,
2215 #[case] ask_price: Decimal,
2216 #[case] bid_size: Decimal,
2217 #[case] ask_size: Decimal,
2218 ) {
2219 let instrument = test_instrument();
2220 let instrument_id = instrument.id();
2221 let instrument_cache = Arc::new(InstrumentCache::new());
2222 instrument_cache.insert_instrument_only(instrument);
2223 let order_books = Arc::new(DashMap::new());
2224 let last_quotes = Arc::new(DashMap::new());
2225 let active_quote_subs = Arc::new(AtomicSet::new());
2226 active_quote_subs.insert(instrument_id);
2227 let active_delta_subs = Arc::new(AtomicSet::new());
2228 active_delta_subs.insert(instrument_id);
2229 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2230 let data_sender: EventSender<DataEvent> = tx.into();
2231 let ts1 = UnixNanos::new(1_700_000_000_000_000_000);
2232 let ts2 = UnixNanos::new(1_700_000_000_100_000_000);
2233 let ts3 = UnixNanos::new(1_700_000_000_200_000_000);
2234 let ts4 = UnixNanos::new(1_700_000_000_300_000_000);
2235
2236 let messages = [
2238 book_update_deltas(
2239 instrument_id,
2240 &[(dec!(99.00), dec!(1.25)), (dec!(98.50), dec!(2.00))],
2241 &[(dec!(100.05), dec!(0.50)), (dec!(100.50), dec!(3.00))],
2242 ts1,
2243 ),
2244 book_update_deltas(instrument_id, &[(dec!(98.50), dec!(2.75))], &[], ts2),
2245 book_update_deltas(
2246 instrument_id,
2247 &[(bid_price, bid_size)],
2248 &[(ask_price, ask_size)],
2249 ts3,
2250 ),
2251 book_update_deltas(
2252 instrument_id,
2253 &[(bid_price, bid_size)],
2254 &[(ask_price, ask_size)],
2255 ts4,
2256 ),
2257 ];
2258 let expected_deltas = messages.to_vec();
2259
2260 for deltas in messages {
2261 DydxDataClient::handle_deltas_message(
2262 deltas,
2263 &data_sender,
2264 &order_books,
2265 &last_quotes,
2266 &instrument_cache,
2267 &active_quote_subs,
2268 &active_delta_subs,
2269 );
2270 }
2271
2272 let mut quotes = Vec::new();
2273 let mut book_deltas = Vec::new();
2274
2275 while let Ok(event) = rx.try_recv() {
2276 match event {
2277 DataEvent::Data(NautilusData::Quote(quote)) => quotes.push(quote),
2278 DataEvent::Data(NautilusData::BookDeltas(deltas)) => book_deltas.push(*deltas),
2279 other => panic!("Unexpected event: {other:?}"),
2280 }
2281 }
2282
2283 let initial = QuoteTick::new(
2284 instrument_id,
2285 Price::from_decimal_dp(dec!(99.00), 2).unwrap(),
2286 Price::from_decimal_dp(dec!(100.05), 2).unwrap(),
2287 Quantity::from_decimal_dp(dec!(1.25), 8).unwrap(),
2288 Quantity::from_decimal_dp(dec!(0.50), 8).unwrap(),
2289 ts1,
2290 ts1,
2291 );
2292
2293 let changed = QuoteTick::new(
2294 instrument_id,
2295 Price::from_decimal_dp(bid_price, 2).unwrap(),
2296 Price::from_decimal_dp(ask_price, 2).unwrap(),
2297 Quantity::from_decimal_dp(bid_size, 8).unwrap(),
2298 Quantity::from_decimal_dp(ask_size, 8).unwrap(),
2299 ts3,
2300 ts3,
2301 );
2302
2303 assert_eq!(quotes, vec![initial, changed]);
2304
2305 assert_eq!(
2307 serde_json::to_value(&book_deltas).unwrap(),
2308 serde_json::to_value(&expected_deltas).unwrap(),
2309 );
2310 }
2311
2312 #[rstest]
2313 #[case::resubscribe(false, false)]
2314 #[case::resubscribe_after_late_quote(false, true)]
2315 #[case::reconnect(true, false)]
2316 #[tokio::test]
2317 async fn test_quote_subscription_emits_unchanged_initial_quote(
2318 #[case] reconnect: bool,
2319 #[case] late_quote: bool,
2320 ) {
2321 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2322 set_data_event_sender(tx);
2323
2324 let http = DydxHttpClient::new(None, 10, None, DydxNetwork::Testnet, None).unwrap();
2325 let ws = DydxWebSocketClient::new_public("ws://127.0.0.1:1".to_string(), None, None);
2326
2327 ws.begin_shutdown();
2328
2329 let client_id = ClientId::from("DYDX");
2330 let mut client =
2331 DydxDataClient::new(client_id, DydxDataClientConfig::default(), http, ws).unwrap();
2332 let instrument = test_instrument();
2333 let instrument_id = instrument.id();
2334 client.instrument_cache.insert_instrument_only(instrument);
2335 let ts1 = UnixNanos::new(1_700_000_000_000_000_000);
2336 let ts2 = UnixNanos::new(1_700_000_000_100_000_000);
2337 let ts3 = UnixNanos::new(1_700_000_000_200_000_000);
2338
2339 let initial = QuoteTick::new(
2340 instrument_id,
2341 Price::from("99.00"),
2342 Price::from("100.05"),
2343 Quantity::from("1.25000000"),
2344 Quantity::from("0.50000000"),
2345 ts1,
2346 ts1,
2347 );
2348
2349 let subscribe = |instrument_id| {
2350 SubscribeQuotes::new(
2351 instrument_id,
2352 Some(client_id),
2353 None,
2354 UUID4::new(),
2355 UnixNanos::default(),
2356 None,
2357 None,
2358 )
2359 };
2360
2361 let handle = |client: &DydxDataClient, instrument_id, ts| {
2362 let deltas = book_update_deltas(
2363 instrument_id,
2364 &[(dec!(99.00), dec!(1.25))],
2365 &[(dec!(100.05), dec!(0.50))],
2366 ts,
2367 );
2368
2369 DydxDataClient::handle_deltas_message(
2370 deltas,
2371 &client.data_sender,
2372 &client.order_books,
2373 &client.last_quotes,
2374 &client.instrument_cache,
2375 &client.active_quote_subs,
2376 &client.active_delta_subs,
2377 );
2378 };
2379
2380 client.subscribe_quotes(subscribe(instrument_id)).unwrap();
2381 handle(&client, instrument_id, ts1);
2382 let healthy_id = InstrumentId::from("ETH-USD-PERP.DYDX");
2383
2384 if reconnect {
2385 let InstrumentAny::CryptoPerpetual(mut healthy) = test_instrument() else {
2386 panic!("Expected perpetual instrument");
2387 };
2388
2389 healthy.id = healthy_id;
2390 healthy.raw_symbol = healthy_id.symbol;
2391 client
2392 .instrument_cache
2393 .insert_instrument_only(InstrumentAny::CryptoPerpetual(healthy));
2394 client.subscribe_quotes(subscribe(healthy_id)).unwrap();
2395 handle(&client, healthy_id, ts1);
2396
2397 let ctx = WsMessageContext {
2398 clock: client.clock,
2399 data_sender: client.data_sender.clone(),
2400 instrument_cache: client.instrument_cache.clone(),
2401 order_books: client.order_books.clone(),
2402 last_quotes: client.last_quotes.clone(),
2403 ws_client: client.ws_client.clone(),
2404 http_client: client.http_client.clone(),
2405 active_quote_subs: client.active_quote_subs.clone(),
2406 active_delta_subs: client.active_delta_subs.clone(),
2407 active_trade_subs: client.active_trade_subs.clone(),
2408 active_bar_subs: client.active_bar_subs.clone(),
2409 incomplete_bars: client.incomplete_bars.clone(),
2410 bar_type_mappings: client.bar_type_mappings.clone(),
2411 active_mark_price_subs: client.active_mark_price_subs.clone(),
2412 active_index_price_subs: client.active_index_price_subs.clone(),
2413 active_funding_rate_subs: client.active_funding_rate_subs.clone(),
2414 active_instrument_status_subs: client.active_instrument_status_subs.clone(),
2415 last_instrument_statuses: client.last_instrument_statuses.clone(),
2416 bars_timestamp_on_close: client.ws_client.bars_timestamp_on_close(),
2417 pending_bars: Arc::new(DashMap::new()),
2418 seen_tickers: Arc::new(AtomicSet::new()),
2419 command_spawner: client.command_tasks.spawner().unwrap(),
2420 };
2421
2422 DydxDataClient::handle_ws_message(
2423 DydxWsOutputMessage::Reconnected {
2424 topics: vec![
2425 "v4_orderbook:BTC-USD".to_string(),
2426 "v4_trades:ETH-USD".to_string(),
2427 "v4_candles:ETH-USD/1MIN".to_string(),
2428 ],
2429 },
2430 &ctx,
2431 );
2432
2433 handle(&client, healthy_id, ts3);
2434 } else {
2435 client
2436 .unsubscribe_quotes(&UnsubscribeQuotes::new(
2437 instrument_id,
2438 Some(client_id),
2439 None,
2440 UUID4::new(),
2441 UnixNanos::default(),
2442 None,
2443 None,
2444 ))
2445 .unwrap();
2446 handle(&client, instrument_id, ts2);
2447 if late_quote {
2448 client.last_quotes.insert(instrument_id, initial);
2449 }
2450
2451 client.subscribe_quotes(subscribe(instrument_id)).unwrap();
2452 }
2453
2454 handle(&client, instrument_id, ts3);
2455 let mut quotes = Vec::new();
2456
2457 while let Ok(event) = rx.try_recv() {
2458 match event {
2459 DataEvent::Data(NautilusData::Quote(quote)) => quotes.push(quote),
2460 other => panic!("Unexpected event: {other:?}"),
2461 }
2462 }
2463
2464 let mut expected = vec![initial];
2465 if reconnect {
2466 expected.push(QuoteTick {
2467 instrument_id: healthy_id,
2468 ..initial
2469 });
2470 }
2471
2472 expected.push(QuoteTick {
2473 ts_event: ts3,
2474 ts_init: ts3,
2475 ..initial
2476 });
2477
2478 let subscription_error = client
2479 .ws_client
2480 .subscribe_orderbook(instrument_id)
2481 .await
2482 .unwrap_err();
2483
2484 assert_eq!(quotes, expected);
2485 assert_eq!(
2486 subscription_error.to_string(),
2487 "WebSocket transport error: WebSocket connection pool is closed"
2488 );
2489 }
2490
2491 #[rstest]
2492 #[case::both(true, true)]
2493 #[case::mark_only(true, false)]
2494 #[case::index_only(false, true)]
2495 #[case::unsubscribed(false, false)]
2496 #[tokio::test]
2497 async fn test_oracle_prices_keep_fixed_precision(
2498 #[case] mark_subscribed: bool,
2499 #[case] index_subscribed: bool,
2500 #[values(true, false)] snapshot: bool,
2501 ) {
2502 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2503 let command_tasks = TaskGroup::new();
2504
2505 let ctx = WsMessageContext {
2506 clock: get_atomic_clock_realtime(),
2507 data_sender: tx.into(),
2508 instrument_cache: Arc::new(InstrumentCache::new()),
2509 order_books: Arc::new(DashMap::new()),
2510 last_quotes: Arc::new(DashMap::new()),
2511 ws_client: DydxWebSocketClient::new_public("ws://127.0.0.1:1".to_string(), None, None),
2512 http_client: DydxHttpClient::new(None, 10, None, DydxNetwork::Testnet, None).unwrap(),
2513 active_quote_subs: Arc::new(AtomicSet::new()),
2514 active_delta_subs: Arc::new(AtomicSet::new()),
2515 active_trade_subs: Arc::new(AtomicSet::new()),
2516 active_bar_subs: Arc::new(AtomicMap::new()),
2517 incomplete_bars: Arc::new(DashMap::new()),
2518 bar_type_mappings: Arc::new(AtomicMap::new()),
2519 active_mark_price_subs: Arc::new(AtomicSet::new()),
2520 active_index_price_subs: Arc::new(AtomicSet::new()),
2521 active_funding_rate_subs: Arc::new(AtomicSet::new()),
2522 active_instrument_status_subs: Arc::new(AtomicSet::new()),
2523 last_instrument_statuses: Arc::new(DashMap::new()),
2524 bars_timestamp_on_close: true,
2525 pending_bars: Arc::new(DashMap::new()),
2526 seen_tickers: Arc::new(AtomicSet::new()),
2527 command_spawner: command_tasks.spawner().unwrap(),
2528 };
2529
2530 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
2531 ctx.seen_tickers.insert(Ustr::from("BTC-USD"));
2532 if mark_subscribed {
2533 ctx.active_mark_price_subs.insert(instrument_id);
2534 }
2535
2536 if index_subscribed {
2537 ctx.active_index_price_subs.insert(instrument_id);
2538 }
2539
2540 let (fixture, field) = if snapshot {
2541 ("ws_markets_subscribed.json", "markets")
2542 } else {
2543 ("ws_markets_update.json", "oraclePrices")
2544 };
2545
2546 let mut fixture = load_json_fixture(fixture);
2547 fixture["contents"][field]
2548 .as_object_mut()
2549 .unwrap()
2550 .retain(|ticker, _| ticker == "BTC-USD");
2551
2552 let mut expected = Vec::new();
2553 let inputs = [
2554 "81162.6",
2555 "81468.36718",
2556 "81470.1",
2557 "81471.20431",
2558 "81468",
2559 "81468.00000",
2560 "0.000000001",
2561 ];
2562
2563 for (i, value) in inputs.iter().enumerate() {
2564 fixture["contents"][field]["BTC-USD"]["oraclePrice"] =
2565 serde_json::Value::String((*value).to_string());
2566 let contents: DydxMarketsContents =
2567 serde_json::from_value(fixture["contents"].clone()).unwrap();
2568 let ts = UnixNanos::new(1_700_000_000_000_000_000 + i as u64 * 1_000_000_000);
2569 DydxDataClient::handle_markets_message(&contents, &ctx, ts);
2570 let decimal = Decimal::from_str(value).unwrap();
2571 if mark_subscribed {
2572 expected.push((instrument_id, true, decimal, ts, ts));
2573 }
2574
2575 if index_subscribed {
2576 expected.push((instrument_id, false, decimal, ts, ts));
2577 }
2578 }
2579
2580 let mut actual = Vec::new();
2581
2582 while let Ok(event) = rx.try_recv() {
2583 let (id, mark, price, ts_event, ts_init) = match event {
2584 DataEvent::Data(NautilusData::MarkPrice(update)) => (
2585 update.instrument_id,
2586 true,
2587 update.value,
2588 update.ts_event,
2589 update.ts_init,
2590 ),
2591 DataEvent::Data(NautilusData::IndexPrice(update)) => (
2592 update.instrument_id,
2593 false,
2594 update.value,
2595 update.ts_event,
2596 update.ts_init,
2597 ),
2598 other => panic!("Unexpected event: {other:?}"),
2599 };
2600
2601 assert_eq!(price.precision, FIXED_PRECISION);
2602 actual.push((id, mark, price.as_decimal(), ts_event, ts_init));
2603 }
2604
2605 assert_eq!(actual, expected);
2606 }
2607
2608 fn book_update_deltas(
2609 instrument_id: InstrumentId,
2610 bids: &[(Decimal, Decimal)],
2611 asks: &[(Decimal, Decimal)],
2612 ts: UnixNanos,
2613 ) -> OrderBookDeltas {
2614 let bids = bids.iter().map(|level| (OrderSide::Buy, level));
2615 let asks = asks.iter().map(|level| (OrderSide::Sell, level));
2616
2617 let mut deltas: Vec<OrderBookDelta> = bids
2618 .chain(asks)
2619 .map(|(side, (price, size))| {
2620 OrderBookDelta::new(
2621 instrument_id,
2622 BookAction::Update,
2623 BookOrder::new(
2624 side,
2625 Price::from_decimal_dp(*price, 2).unwrap(),
2626 Quantity::from_decimal_dp(*size, 8).unwrap(),
2627 0,
2628 ),
2629 0,
2630 0,
2631 ts,
2632 ts,
2633 )
2634 })
2635 .collect();
2636
2637 if let Some(last) = deltas.last_mut() {
2638 last.flags = RecordFlag::F_LAST as u8;
2639 }
2640
2641 OrderBookDeltas::new(instrument_id, deltas)
2642 }
2643}