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