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