Skip to main content

nautilus_dydx/
data.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Live market data client implementation for the dYdX adapter.
17
18use 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/// dYdX data client for live market data streaming and historical data requests.
89///
90/// This client integrates with the Nautilus DataEngine to provide:
91/// - Real-time market data via WebSocket subscriptions
92/// - Historical data via REST API requests
93/// - Automatic instrument discovery and caching
94/// - Connection lifecycle management
95#[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    /// Creates a new [`DydxDataClient`] instance.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the client fails to initialize.
134    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    /// Returns the venue for this data client.
178    #[must_use]
179    pub fn venue(&self) -> Venue {
180        *DYDX_VENUE
181    }
182
183    /// Returns a reference to the client configuration.
184    #[must_use]
185    pub fn config(&self) -> &DydxDataClientConfig {
186        &self.config
187    }
188
189    /// Returns `true` when the client is connected.
190    #[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            // Replay last known status (initial snapshot arrives before subscription)
653            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    /// Returns a cached instrument by InstrumentId.
1157    #[must_use]
1158    pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
1159        self.instrument_cache.get(instrument_id)
1160    }
1161
1162    /// Returns all cached instruments.
1163    #[must_use]
1164    pub fn get_instruments(&self) -> Vec<InstrumentAny> {
1165        self.instrument_cache.all_instruments()
1166    }
1167
1168    /// Caches a single instrument.
1169    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1170        self.instrument_cache.insert_instrument_only(instrument);
1171    }
1172
1173    /// Caches multiple instruments.
1174    ///
1175    /// Clears the existing cache first, then adds all provided instruments.
1176    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    /// Returns the BarType for a given WebSocket candle topic.
1188    #[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    /// Returns all registered bar topics.
1194    #[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        // If the input batch is a snapshot, every synthetic and terminator delta must
1674        // carry F_SNAPSHOT as well so consumers apply the whole batch as one
1675        // replacement image rather than a snapshot followed by standalone updates.
1676        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        // Set F_LAST on the final delta, preserving F_SNAPSHOT when the batch is a
1822        // snapshot so consumers close the replacement image correctly.
1823        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        // Always maintain local orderbook -- both subscription types need book state
1855        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            // Edge case: If orderbook is empty after deltas, fall back to last quote
1870            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(&quote)
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    // Oracle prices can be finer than trading ticks; keep their scale stable for persistence
1927    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    // These routines are exercised by Criterion, not the unit-test harness
1959    #[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        // Book seeded uncrossed: bid at 99.00 / ask at 100.05 size=0.50000000.
2082        // Venue delta adds a crossing bid at 100.10 size=1.00000001.
2083        // The reducing side (Buy) must end up at size = 0.50000001 exactly --
2084        // f64 subtraction of 1.00000001 - 0.5 would round this to 0.50000000 at 8 dp.
2085        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        // An Update on the Buy side at the crossing price must carry the exact
2100        // Decimal-subtracted remainder (0.50000001), not the f64-rounded 0.50000000.
2101        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        // The second message changes depth only; the third changes one top-of-book field
2237        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        // PartialEq omits batch content and nested order values
2306        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}