Skip to main content

nautilus_coinbase/data/
mod.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//! Coinbase Advanced Trade data client for NautilusTrader.
17//!
18//! Implements the [`DataClient`] trait, providing market data subscriptions and
19//! historical data requests through the Coinbase Advanced Trade API.
20
21use std::{
22    sync::{
23        Arc,
24        atomic::{AtomicBool, Ordering},
25    },
26    time::Duration,
27};
28
29use ahash::AHashSet;
30use anyhow::Context;
31use nautilus_common::{
32    cache::InstrumentLookupError,
33    clients::{DataClient, execute_bar_subscriptions, execute_instrument_subscriptions},
34    live::{runner::get_data_event_sender, sender::EventSender},
35    messages::{
36        DataEvent,
37        data::{
38            BarsResponse, BookResponse, DataResponse, InstrumentResponse, InstrumentsResponse,
39            RequestBars, RequestBookSnapshot, RequestInstrument, RequestInstruments, RequestTrades,
40            SubscribeBars, SubscribeBookDeltas, SubscribeFundingRates, SubscribeIndexPrices,
41            SubscribeInstrument, SubscribeInstrumentStatus, SubscribeMarkPrices, SubscribeQuotes,
42            SubscribeTrades, TradesResponse, UnsubscribeBars, UnsubscribeBookDeltas,
43            UnsubscribeFundingRates, UnsubscribeIndexPrices, UnsubscribeInstrument,
44            UnsubscribeInstrumentStatus, UnsubscribeMarkPrices, UnsubscribeQuotes,
45            UnsubscribeTrades,
46        },
47    },
48};
49use nautilus_core::{
50    AtomicMap,
51    datetime::datetime_to_unix_nanos,
52    time::{AtomicTime, get_atomic_clock_realtime},
53};
54use nautilus_live::{
55    SocketControl,
56    task::{TaskGroup, TaskGroupGuard},
57};
58use nautilus_model::{
59    data::Data,
60    enums::{BarAggregation, BookType},
61    identifiers::{ClientId, InstrumentId, Venue},
62    instruments::{Instrument, InstrumentAny},
63    orderbook::OrderBook,
64};
65use parking_lot::Mutex;
66use tokio_util::sync::CancellationToken;
67use ustr::Ustr;
68
69pub(crate) mod poll;
70
71use crate::{
72    common::{
73        consts::COINBASE_VENUE, credential::CoinbaseCredential, enums::CoinbaseWsChannel,
74        parse::bar_type_to_granularity,
75    },
76    config::CoinbaseDataClientConfig,
77    data::poll::DerivPollManager,
78    http::{
79        client::{CoinbaseHttpClient, data_client_retry_config},
80        models::{CandlesResponse, PriceBook, TickerResponse},
81        parse::{parse_bar, parse_product_book_snapshot, parse_trade_tick},
82    },
83    provider::CoinbaseInstrumentProvider,
84    websocket::{client::CoinbaseWebSocketClient, handler::NautilusWsMessage},
85};
86
87/// Data client for Coinbase Advanced Trade.
88///
89/// Owns an HTTP client, WebSocket client, and instrument provider. Bootstraps
90/// instruments on connect, subscribes to WS channels for live data, and handles
91/// historical data requests through the REST API.
92#[derive(Debug)]
93pub struct CoinbaseDataClient {
94    client_id: ClientId,
95    #[allow(dead_code)]
96    config: CoinbaseDataClientConfig,
97    http_client: CoinbaseHttpClient,
98    ws_client: CoinbaseWebSocketClient,
99    provider: CoinbaseInstrumentProvider,
100    is_connected: AtomicBool,
101    cancellation_token: CancellationToken,
102    session_tasks: TaskGroup,
103    command_tasks: TaskGroup,
104    shutdown_errors: Vec<String>,
105    data_sender: EventSender<DataEvent>,
106    instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
107    deriv_polls: DerivPollManager,
108    clock: &'static AtomicTime,
109    instrument_status_subs: Arc<Mutex<AHashSet<InstrumentId>>>,
110}
111
112impl CoinbaseDataClient {
113    /// Creates a new [`CoinbaseDataClient`] instance.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the HTTP client fails to initialize.
118    pub fn new(client_id: ClientId, config: CoinbaseDataClientConfig) -> anyhow::Result<Self> {
119        let clock = get_atomic_clock_realtime();
120        let data_sender = get_data_event_sender();
121
122        let retry_config = data_client_retry_config();
123        let proxy_url = config
124            .proxy_url
125            .as_ref()
126            .map(|value| value.expose_secret().to_owned());
127
128        let http_client = match CoinbaseCredential::resolve(
129            config.api_key.as_ref().map(|value| value.expose_secret()),
130            config
131                .api_secret
132                .as_ref()
133                .map(|value| value.expose_secret()),
134        ) {
135            Some(credential) => CoinbaseHttpClient::with_credentials(
136                credential,
137                config.environment,
138                config.http_timeout_secs,
139                proxy_url.clone(),
140                Some(retry_config),
141            )
142            .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
143            None => CoinbaseHttpClient::new(
144                config.environment,
145                config.http_timeout_secs,
146                proxy_url.clone(),
147                Some(retry_config),
148            )
149            .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
150        };
151
152        if let Some(url) = &config.base_url_rest {
153            http_client.set_base_url(url.clone());
154        }
155
156        let ws_url = config.ws_url();
157        let ws_client = CoinbaseWebSocketClient::new(&ws_url, config.transport_backend, proxy_url)
158            .with_socket_control(SocketControl::new(
159                client_id,
160                Some(*COINBASE_VENUE),
161                "coinbase-data-streams",
162            ));
163        let provider = CoinbaseInstrumentProvider::new(http_client.clone());
164
165        let deriv_polls = DerivPollManager::new(
166            http_client.clone(),
167            data_sender.clone(),
168            clock,
169            config.derivatives_poll_interval_secs,
170        );
171
172        let session_tasks = TaskGroup::new();
173        let command_tasks = TaskGroup::new();
174
175        Ok(Self {
176            client_id,
177            config,
178            http_client,
179            ws_client,
180            provider,
181            is_connected: AtomicBool::new(false),
182            cancellation_token: session_tasks.cancellation_token(),
183            session_tasks,
184            command_tasks,
185            shutdown_errors: Vec::new(),
186            data_sender,
187            instruments: Arc::new(AtomicMap::new()),
188            deriv_polls,
189            clock,
190            instrument_status_subs: Arc::new(Mutex::new(AHashSet::new())),
191        })
192    }
193
194    fn venue(&self) -> Venue {
195        *COINBASE_VENUE
196    }
197
198    fn spawn_command<F>(&self, future: F)
199    where
200        F: std::future::Future<Output = ()> + Send + 'static,
201    {
202        if let Err(e) = self.command_tasks.spawn(future) {
203            log::warn!("Skipping Coinbase data command after shutdown began: {e}");
204        }
205    }
206
207    async fn finish_tasks(&self) -> anyhow::Result<()> {
208        let (session_result, command_result) = tokio::join!(
209            self.session_tasks
210                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
211            self.command_tasks
212                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
213        );
214        session_result.context("failed to finish Coinbase data session tasks")?;
215        command_result.context("failed to finish Coinbase data command tasks")?;
216        Ok(())
217    }
218
219    async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
220        if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
221            self.teardown_partial_connect().await?;
222            self.session_tasks
223                .start_generation()
224                .context("failed to start Coinbase data session task generation")?;
225            self.command_tasks
226                .start_generation()
227                .context("failed to start Coinbase data command task generation")?;
228            self.cancellation_token = self.session_tasks.cancellation_token();
229        }
230        self.deriv_polls.prepare().await
231    }
232
233    async fn bootstrap_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>> {
234        let instruments = self
235            .provider
236            .load_all()
237            .await
238            .context("failed to fetch instruments during bootstrap")?;
239
240        self.instruments.rcu(|m| {
241            for instrument in &instruments {
242                m.insert(instrument.id(), instrument.clone());
243            }
244        });
245
246        self.ws_client.update_instruments(&instruments).await;
247
248        log::debug!("Bootstrapped {} instruments", instruments.len());
249        Ok(instruments)
250    }
251
252    async fn spawn_ws(&mut self) -> anyhow::Result<()> {
253        self.ws_client
254            .connect()
255            .await
256            .context("failed to connect to Coinbase WebSocket")?;
257
258        let Some(mut out_rx) = self.ws_client.take_out_rx() else {
259            self.ws_client
260                .disconnect()
261                .await
262                .context("failed to roll back Coinbase WebSocket without output receiver")?;
263            anyhow::bail!("WebSocket output receiver not available");
264        };
265
266        let data_sender = self.data_sender.clone();
267        let cancellation_token = self.cancellation_token.clone();
268        let status_subs = Arc::clone(&self.instrument_status_subs);
269
270        let future = async move {
271            log::debug!("Coinbase WebSocket consumption loop started");
272
273            loop {
274                tokio::select! {
275                    () = cancellation_token.cancelled() => {
276                        log::debug!("WebSocket consumption loop cancelled");
277                        break;
278                    }
279                    msg_opt = out_rx.recv() => {
280                        match msg_opt {
281                            Some(msg) => dispatch_ws_message(msg, &data_sender, &status_subs),
282                            None => {
283                                log::debug!("WebSocket output channel closed");
284                                break;
285                            }
286                        }
287                    }
288                }
289            }
290
291            log::debug!("Coinbase WebSocket consumption loop finished");
292        };
293
294        if let Err(e) = self.session_tasks.spawn(future) {
295            if let Err(shutdown_error) = self.ws_client.disconnect().await {
296                return Err(anyhow::Error::new(e).context(format!(
297                    "failed to register Coinbase WebSocket consumption task; startup rollback \
298                     failed: {shutdown_error}"
299                )));
300            }
301            return Err(anyhow::Error::new(e)
302                .context("failed to register Coinbase WebSocket consumption task"));
303        }
304        log::debug!("WebSocket consumption task registered");
305        Ok(())
306    }
307
308    async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
309        self.cancellation_token.cancel();
310        self.session_tasks.begin_shutdown();
311        self.command_tasks.begin_shutdown();
312        self.deriv_polls.shutdown();
313        self.ws_client.begin_shutdown();
314
315        let (tasks_result, polls_result) =
316            tokio::join!(self.finish_tasks(), self.deriv_polls.finish_shutdown());
317
318        if let Err(e) = tasks_result {
319            self.shutdown_errors.push(e.to_string());
320        }
321
322        if let Err(e) = polls_result {
323            self.shutdown_errors.push(e.to_string());
324        }
325
326        if let Err(e) = self.ws_client.disconnect().await {
327            self.shutdown_errors.push(e.to_string());
328        }
329        self.is_connected.store(false, Ordering::Relaxed);
330
331        if !self.shutdown_errors.is_empty() {
332            anyhow::bail!(std::mem::take(&mut self.shutdown_errors).join("; "));
333        }
334        Ok(())
335    }
336
337    fn product_id(instrument_id: InstrumentId) -> Ustr {
338        instrument_id.symbol.inner()
339    }
340
341    // Resolves a caller-supplied product id to Coinbase's canonical alias (if
342    // any). Coinbase consolidates aliased pairs into a single book server-side
343    // and rewrites WS subscription confirmations and inbound messages to use
344    // the canonical id (e.g. BTC-USDC -> BTC-USD), so we must subscribe with
345    // the canonical id and remember the mapping so inbound messages can be
346    // re-keyed to what the strategy actually subscribed to.
347    fn resolve_wire_product_id(&self, subscribed: Ustr) -> Ustr {
348        self.http_client
349            .product_aliases()
350            .get_cloned(&subscribed)
351            .filter(|alias| !alias.is_empty())
352            .unwrap_or(subscribed)
353    }
354}
355
356fn dispatch_ws_message(
357    msg: NautilusWsMessage,
358    data_sender: &EventSender<DataEvent>,
359    status_subs: &Arc<Mutex<AHashSet<InstrumentId>>>,
360) {
361    match msg {
362        NautilusWsMessage::Trade(trade) => {
363            if let Err(e) = data_sender.send(DataEvent::Data(Data::Trade(trade))) {
364                log::error!("Failed to send trade tick: {e}");
365            }
366        }
367        NautilusWsMessage::Quote(quote) => {
368            if let Err(e) = data_sender.send(DataEvent::Data(Data::Quote(quote))) {
369                log::error!("Failed to send quote tick: {e}");
370            }
371        }
372        NautilusWsMessage::Deltas(deltas) => {
373            if let Err(e) = data_sender.send(DataEvent::Data(Data::BookDeltas(Box::new(deltas)))) {
374                log::error!("Failed to send order book deltas: {e}");
375            }
376        }
377        NautilusWsMessage::Bar(bar) => {
378            if let Err(e) = data_sender.send(DataEvent::Data(Data::Bar(bar))) {
379                log::error!("Failed to send bar: {e}");
380            }
381        }
382        NautilusWsMessage::InstrumentStatus(status) => {
383            // Coinbase publishes status for every product on a single feed,
384            // so filter to currently-subscribed instruments before emitting.
385            let subscribed = status_subs.lock().contains(&status.instrument_id);
386            if subscribed
387                && let Err(e) = data_sender.send(DataEvent::Data(Data::InstrumentStatus(*status)))
388            {
389                log::error!("Failed to send instrument status: {e}");
390            }
391        }
392        NautilusWsMessage::Reconnected => {
393            log::info!("WebSocket reconnected");
394        }
395        NautilusWsMessage::Error(e) => {
396            log::warn!("WebSocket error: {e}");
397        }
398        NautilusWsMessage::UserOrder(_) => {
399            // User-channel execution reports are consumed by the execution client
400            log::debug!("Dropping user-channel update received on the data client");
401        }
402        NautilusWsMessage::FuturesBalanceSummary(_) => {
403            // Futures balance summary events are consumed by the execution client
404            log::debug!("Dropping futures_balance_summary event received on the data client");
405        }
406    }
407}
408
409#[async_trait::async_trait(?Send)]
410impl DataClient for CoinbaseDataClient {
411    fn client_id(&self) -> ClientId {
412        self.client_id
413    }
414
415    fn venue(&self) -> Option<Venue> {
416        Some(Self::venue(self))
417    }
418
419    fn start(&mut self) -> anyhow::Result<()> {
420        log::info!(
421            "Starting Coinbase data client: client_id={}, environment={:?}",
422            self.client_id,
423            self.config.environment,
424        );
425        Ok(())
426    }
427
428    fn stop(&mut self) -> anyhow::Result<()> {
429        log::info!("Stopping Coinbase data client {}", self.client_id);
430        self.session_tasks.begin_shutdown();
431        self.command_tasks.begin_shutdown();
432        self.deriv_polls.shutdown();
433        self.ws_client.begin_shutdown();
434        self.is_connected.store(false, Ordering::Relaxed);
435        Ok(())
436    }
437
438    fn reset(&mut self) -> anyhow::Result<()> {
439        log::debug!("Resetting Coinbase data client {}", self.client_id);
440        self.session_tasks.begin_shutdown();
441        self.command_tasks.begin_shutdown();
442        self.deriv_polls.shutdown();
443        self.ws_client.begin_shutdown();
444        self.is_connected.store(false, Ordering::Relaxed);
445        self.instrument_status_subs.lock().clear();
446        Ok(())
447    }
448
449    fn dispose(&mut self) -> anyhow::Result<()> {
450        log::debug!("Disposing Coinbase data client {}", self.client_id);
451        self.stop()
452    }
453
454    fn is_connected(&self) -> bool {
455        self.is_connected.load(Ordering::Acquire)
456    }
457
458    fn is_disconnected(&self) -> bool {
459        !self.is_connected()
460    }
461
462    async fn connect(&mut self) -> anyhow::Result<()> {
463        if self.is_connected() && self.session_tasks.is_open() && self.command_tasks.is_open() {
464            return Ok(());
465        }
466
467        self.prepare_task_groups().await?;
468        let cancellation_token = self.cancellation_token.clone();
469        let ws_client = self.ws_client.clone();
470        let setup_guard =
471            TaskGroupGuard::new(&[&self.session_tasks, &self.command_tasks], move || {
472                cancellation_token.cancel();
473                ws_client.begin_shutdown();
474            });
475
476        let connect_result = async {
477            let instruments = self
478                .bootstrap_instruments()
479                .await
480                .context("failed to bootstrap instruments")?;
481
482            for instrument in instruments {
483                if let Err(e) = self.data_sender.send(DataEvent::Data(instrument.into())) {
484                    log::warn!("Failed to send instrument: {e}");
485                }
486            }
487
488            self.spawn_ws()
489                .await
490                .context("failed to spawn WebSocket client")?;
491
492            // Re-spawn polling tasks for any derivatives subscriptions that
493            // survived a previous disconnect. The data engine's client adapter
494            // remembers the subscription set and suppresses duplicate subscribe
495            // commands, so without this resume step index-price and
496            // funding-rate streams would stay dark after a reconnect.
497            self.deriv_polls.resume();
498            Ok::<(), anyhow::Error>(())
499        }
500        .await;
501
502        if let Err(e) = connect_result {
503            if let Err(teardown_error) = self.teardown_partial_connect().await {
504                return Err(e.context(format!(
505                    "Coinbase data startup teardown failed: {teardown_error}"
506                )));
507            }
508            return Err(e);
509        }
510
511        self.is_connected.store(true, Ordering::Relaxed);
512        setup_guard.disarm();
513        log::info!("Connected: client_id={}", self.client_id);
514
515        Ok(())
516    }
517
518    async fn disconnect(&mut self) -> anyhow::Result<()> {
519        let shutdown_result = self.teardown_partial_connect().await;
520        self.instruments.store(ahash::AHashMap::new());
521        log::info!("Disconnected: client_id={}", self.client_id);
522
523        shutdown_result
524    }
525
526    fn subscribe_instrument(&mut self, cmd: SubscribeInstrument) -> anyhow::Result<()> {
527        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
528            let instruments = self.instruments.load();
529
530            if let Some(instrument) = instruments.get(&instrument_id) {
531                if let Err(e) = self
532                    .data_sender
533                    .send(DataEvent::Data(instrument.clone().into()))
534                {
535                    log::error!("Failed to send instrument {instrument_id}: {e}");
536                }
537            } else {
538                log::warn!("Instrument {instrument_id} not found in cache");
539            }
540
541            Ok(())
542        })
543    }
544
545    fn subscribe_book_deltas(&mut self, subscription: SubscribeBookDeltas) -> anyhow::Result<()> {
546        execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
547            let subscription = subscription.with_instrument_ids(instrument_id);
548            if subscription.book_type != BookType::L2_MBP {
549                anyhow::bail!("Coinbase only supports L2_MBP order book deltas");
550            }
551
552            let ws = self.ws_client.clone();
553            let subscribed_id = Self::product_id(instrument_id);
554            let wire_id = self.resolve_wire_product_id(subscribed_id);
555            if wire_id != subscribed_id {
556                ws.register_subscription_alias(wire_id, subscribed_id);
557            }
558
559            self.spawn_command(async move {
560                if let Err(e) = ws.subscribe(CoinbaseWsChannel::Level2, &[wire_id]).await {
561                    log::error!("Failed to subscribe to book deltas: {e:?}");
562                }
563            });
564
565            Ok(())
566        })
567    }
568
569    fn subscribe_quotes(&mut self, subscription: SubscribeQuotes) -> anyhow::Result<()> {
570        execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
571            let ws = self.ws_client.clone();
572            let subscribed_id = Self::product_id(instrument_id);
573            let wire_id = self.resolve_wire_product_id(subscribed_id);
574            if wire_id != subscribed_id {
575                ws.register_subscription_alias(wire_id, subscribed_id);
576            }
577
578            self.spawn_command(async move {
579                if let Err(e) = ws.subscribe(CoinbaseWsChannel::Ticker, &[wire_id]).await {
580                    log::error!("Failed to subscribe to quotes: {e:?}");
581                }
582            });
583
584            Ok(())
585        })
586    }
587
588    fn subscribe_trades(&mut self, subscription: SubscribeTrades) -> anyhow::Result<()> {
589        execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
590            let ws = self.ws_client.clone();
591            let subscribed_id = Self::product_id(instrument_id);
592            let wire_id = self.resolve_wire_product_id(subscribed_id);
593            if wire_id != subscribed_id {
594                ws.register_subscription_alias(wire_id, subscribed_id);
595            }
596
597            self.spawn_command(async move {
598                if let Err(e) = ws
599                    .subscribe(CoinbaseWsChannel::MarketTrades, &[wire_id])
600                    .await
601                {
602                    log::error!("Failed to subscribe to trades: {e:?}");
603                }
604            });
605
606            Ok(())
607        })
608    }
609
610    fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
611        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
612            // Coinbase Advanced Trade does not publish a live mark price for its
613            // perpetuals on either WS or REST. `settlement_price` is the prior
614            // daily settlement and drifts from the live index, so synthesizing a
615            // mark from it would be misleading. Reject explicitly so callers
616            // failing this subscription know why.
617            anyhow::bail!(
618                "Coinbase Advanced Trade does not publish mark prices; \
619                 cannot subscribe for {instrument_id}"
620            )
621        })
622    }
623
624    fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
625        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
626            self.deriv_polls.subscribe_index(instrument_id);
627            Ok(())
628        })
629    }
630
631    fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
632        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
633            self.deriv_polls.subscribe_funding(instrument_id);
634            Ok(())
635        })
636    }
637
638    fn subscribe_instrument_status(
639        &mut self,
640        cmd: SubscribeInstrumentStatus,
641    ) -> anyhow::Result<()> {
642        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
643            // Register the canonical-to-subscribed alias so the handler re-keys
644            // inbound status events for aliased products (e.g. caller subscribed
645            // to `BTC-USDC` but the venue reports the canonical `BTC-USD`).
646            // Without this the filter below would drop alias-only subscriptions.
647            let subscribed_id = Self::product_id(instrument_id);
648            let wire_id = self.resolve_wire_product_id(subscribed_id);
649            if wire_id != subscribed_id {
650                self.ws_client
651                    .register_subscription_alias(wire_id, subscribed_id);
652            }
653
654            // Coinbase publishes a single product-wide status feed. Only subscribe
655            // to the WS channel once; subsequent calls just record the instrument.
656            let was_empty = {
657                let mut subs = self.instrument_status_subs.lock();
658                let was_empty = subs.is_empty();
659                subs.insert(instrument_id);
660                was_empty
661            };
662
663            if was_empty {
664                let ws = self.ws_client.clone();
665                self.spawn_command(async move {
666                    if let Err(e) = ws.subscribe(CoinbaseWsChannel::Status, &[]).await {
667                        log::error!("Failed to subscribe to status channel: {e:?}");
668                    }
669                });
670            }
671
672            Ok(())
673        })
674    }
675
676    fn subscribe_bars(&mut self, subscription: SubscribeBars) -> anyhow::Result<()> {
677        execute_bar_subscriptions(&subscription.bar_types, |bar_type| {
678            let instrument_id = bar_type.instrument_id();
679
680            if !self.instruments.contains_key(&instrument_id) {
681                anyhow::bail!(InstrumentLookupError::not_found(instrument_id));
682            }
683
684            let subscribed_id = Self::product_id(instrument_id);
685            let wire_id = self.resolve_wire_product_id(subscribed_id);
686            if wire_id != subscribed_id {
687                self.ws_client
688                    .register_subscription_alias(wire_id, subscribed_id);
689            }
690
691            let key = wire_id.to_string();
692
693            // Register on the original client so the bar type persists across clones
694            self.ws_client.register_bar_type(key.clone(), bar_type);
695
696            let mut ws = self.ws_client.clone();
697            self.spawn_command(async move {
698                ws.add_bar_type(key, bar_type).await;
699
700                if let Err(e) = ws.subscribe(CoinbaseWsChannel::Candles, &[wire_id]).await {
701                    log::error!("Failed to subscribe to bars: {e:?}");
702                }
703            });
704
705            Ok(())
706        })
707    }
708
709    // Unsubscribe paths intentionally do NOT call
710    // `unregister_subscription_alias`. The same canonical wire id is shared
711    // across multiple data channels (ticker, market_trades, level2,
712    // candles), so dropping the entry on the first unsubscribe would cause
713    // every still-active channel for the same alias to mistag inbound
714    // messages. The mapping is stable per product for the process lifetime
715    // and the venue does not deliver messages for products that aren't
716    // subscribed to, so leaving it in place is safe.
717
718    fn unsubscribe_instrument(
719        &mut self,
720        _unsubscription: &UnsubscribeInstrument,
721    ) -> anyhow::Result<()> {
722        // `subscribe_instrument` only replays cached state; no venue subscription to tear down.
723        Ok(())
724    }
725
726    fn unsubscribe_book_deltas(
727        &mut self,
728        unsubscription: &UnsubscribeBookDeltas,
729    ) -> anyhow::Result<()> {
730        execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
731            log::debug!("Unsubscribing from book deltas: {instrument_id}");
732
733            let ws = self.ws_client.clone();
734            let subscribed_id = Self::product_id(instrument_id);
735            let wire_id = self.resolve_wire_product_id(subscribed_id);
736            self.spawn_command(async move {
737                if let Err(e) = ws.unsubscribe(CoinbaseWsChannel::Level2, &[wire_id]).await {
738                    log::error!("Failed to unsubscribe from book deltas: {e:?}");
739                }
740            });
741
742            Ok(())
743        })
744    }
745
746    fn unsubscribe_quotes(&mut self, unsubscription: &UnsubscribeQuotes) -> anyhow::Result<()> {
747        execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
748            log::debug!("Unsubscribing from quotes: {instrument_id}");
749
750            let ws = self.ws_client.clone();
751            let subscribed_id = Self::product_id(instrument_id);
752            let wire_id = self.resolve_wire_product_id(subscribed_id);
753            self.spawn_command(async move {
754                if let Err(e) = ws.unsubscribe(CoinbaseWsChannel::Ticker, &[wire_id]).await {
755                    log::error!("Failed to unsubscribe from quotes: {e:?}");
756                }
757            });
758
759            Ok(())
760        })
761    }
762
763    fn unsubscribe_trades(&mut self, unsubscription: &UnsubscribeTrades) -> anyhow::Result<()> {
764        execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
765            log::debug!("Unsubscribing from trades: {instrument_id}");
766
767            let ws = self.ws_client.clone();
768            let subscribed_id = Self::product_id(instrument_id);
769            let wire_id = self.resolve_wire_product_id(subscribed_id);
770
771            self.spawn_command(async move {
772                if let Err(e) = ws
773                    .unsubscribe(CoinbaseWsChannel::MarketTrades, &[wire_id])
774                    .await
775                {
776                    log::error!("Failed to unsubscribe from trades: {e:?}");
777                }
778            });
779
780            Ok(())
781        })
782    }
783
784    fn unsubscribe_mark_prices(&mut self, _cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
785        Ok(())
786    }
787
788    fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
789        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
790            self.deriv_polls.unsubscribe_index(instrument_id);
791            Ok(())
792        })
793    }
794
795    fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
796        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
797            self.deriv_polls.unsubscribe_funding(instrument_id);
798            Ok(())
799        })
800    }
801
802    fn unsubscribe_instrument_status(
803        &mut self,
804        cmd: &UnsubscribeInstrumentStatus,
805    ) -> anyhow::Result<()> {
806        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
807            log::debug!("Unsubscribing from instrument status: {instrument_id}");
808
809            let now_empty = {
810                let mut subs = self.instrument_status_subs.lock();
811                subs.remove(&instrument_id);
812                subs.is_empty()
813            };
814
815            if now_empty {
816                let ws = self.ws_client.clone();
817                self.spawn_command(async move {
818                    if let Err(e) = ws.unsubscribe(CoinbaseWsChannel::Status, &[]).await {
819                        log::error!("Failed to unsubscribe from status channel: {e:?}");
820                    }
821                });
822            }
823
824            Ok(())
825        })
826    }
827
828    fn unsubscribe_bars(&mut self, unsubscription: &UnsubscribeBars) -> anyhow::Result<()> {
829        execute_bar_subscriptions(&unsubscription.bar_types, |bar_type| {
830            let instrument_id = bar_type.instrument_id();
831            let subscribed_id = Self::product_id(instrument_id);
832            let wire_id = self.resolve_wire_product_id(subscribed_id);
833            let ws = self.ws_client.clone();
834            self.spawn_command(async move {
835                if let Err(e) = ws.unsubscribe(CoinbaseWsChannel::Candles, &[wire_id]).await {
836                    log::error!("Failed to unsubscribe from bars: {e:?}");
837                }
838            });
839
840            Ok(())
841        })
842    }
843
844    fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
845        log::debug!("Requesting all instruments");
846
847        let provider = self.provider.clone();
848        let sender = self.data_sender.clone();
849        let instruments_cache = self.instruments.clone();
850        let ws = self.ws_client.clone();
851        let request_id = request.request_id;
852        let client_id = request.client_id.unwrap_or(self.client_id);
853        let venue = Self::venue(self);
854        let start_nanos = datetime_to_unix_nanos(request.start);
855        let end_nanos = datetime_to_unix_nanos(request.end);
856        let params = request.params;
857        let clock = self.clock;
858
859        self.spawn_command(async move {
860            match provider.load_all().await {
861                Ok(instruments) => {
862                    instruments_cache.rcu(|m| {
863                        for instrument in &instruments {
864                            m.insert(instrument.id(), instrument.clone());
865                        }
866                    });
867
868                    ws.update_instruments(&instruments).await;
869
870                    let response = DataResponse::Instruments(InstrumentsResponse::new(
871                        request_id,
872                        client_id,
873                        venue,
874                        instruments,
875                        start_nanos,
876                        end_nanos,
877                        clock.get_time_ns(),
878                        params,
879                    ));
880
881                    if let Err(e) = sender.send(DataEvent::Response(response)) {
882                        log::error!("Failed to send instruments response: {e}");
883                    }
884                }
885                Err(e) => {
886                    log::error!("Failed to fetch instruments: {e:?}");
887                }
888            }
889        });
890
891        Ok(())
892    }
893
894    fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
895        let instrument_id = request.single_instrument_id()?;
896
897        log::debug!("Requesting instrument: {instrument_id}");
898
899        let provider = self.provider.clone();
900        let sender = self.data_sender.clone();
901        let instruments_cache = self.instruments.clone();
902        let ws = self.ws_client.clone();
903        let product_id = instrument_id.symbol.to_string();
904        let request_id = request.request_id;
905        let client_id = request.client_id.unwrap_or(self.client_id);
906        let start_nanos = datetime_to_unix_nanos(request.start);
907        let end_nanos = datetime_to_unix_nanos(request.end);
908        let params = request.params;
909        let clock = self.clock;
910
911        self.spawn_command(async move {
912            match provider.load(&product_id).await {
913                Ok(instrument) => {
914                    instruments_cache.rcu(|m| {
915                        m.insert(instrument.id(), instrument.clone());
916                    });
917                    ws.update_instrument(instrument.clone()).await;
918
919                    let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
920                        request_id,
921                        client_id,
922                        instrument.id(),
923                        instrument,
924                        start_nanos,
925                        end_nanos,
926                        clock.get_time_ns(),
927                        params,
928                    )));
929
930                    if let Err(e) = sender.send(DataEvent::Response(response)) {
931                        log::error!("Failed to send instrument response: {e}");
932                    }
933                }
934                Err(e) => {
935                    log::error!("Failed to fetch instrument {instrument_id}: {e:?}");
936                }
937            }
938        });
939
940        Ok(())
941    }
942
943    fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
944        let instrument_id = request.single_instrument_id()?;
945
946        let product_id = instrument_id.symbol.to_string();
947
948        let instruments = self.instruments.load();
949        let instrument = instruments
950            .get(&instrument_id)
951            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
952        let price_precision = instrument.price_precision();
953        let size_precision = instrument.size_precision();
954        let depth = request.depth.map(|d| d.get() as u32);
955
956        let http = self.http_client.clone();
957        let sender = self.data_sender.clone();
958        let client_id = request.client_id.unwrap_or(self.client_id);
959        let request_id = request.request_id;
960        let params = request.params;
961        let clock = self.clock;
962
963        self.spawn_command(async move {
964            match http.get_product_book(&product_id, depth).await {
965                Ok(json) => {
966                    let pricebook_value = json.get("pricebook").cloned().unwrap_or(json);
967
968                    let pricebook: PriceBook = match serde_json::from_value(pricebook_value) {
969                        Ok(b) => b,
970                        Err(e) => {
971                            log::error!("Failed to parse product book: {e}");
972                            return;
973                        }
974                    };
975
976                    let ts_init = clock.get_time_ns();
977
978                    match parse_product_book_snapshot(
979                        &pricebook,
980                        instrument_id,
981                        price_precision,
982                        size_precision,
983                        ts_init,
984                    ) {
985                        Ok(deltas) => {
986                            let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
987
988                            for delta in &deltas.deltas {
989                                if delta.order.side.is_some() {
990                                    book.add(
991                                        delta.order,
992                                        delta.flags,
993                                        delta.sequence,
994                                        delta.ts_event,
995                                    );
996                                }
997                            }
998
999                            let response = DataResponse::Book(BookResponse::new(
1000                                request_id,
1001                                client_id,
1002                                instrument_id,
1003                                book,
1004                                None,
1005                                None,
1006                                clock.get_time_ns(),
1007                                params,
1008                            ));
1009
1010                            if let Err(e) = sender.send(DataEvent::Response(response)) {
1011                                log::error!("Failed to send book snapshot response: {e}");
1012                            }
1013                        }
1014                        Err(e) => {
1015                            log::error!("Failed to parse book snapshot for {instrument_id}: {e}");
1016                        }
1017                    }
1018                }
1019                Err(e) => {
1020                    log::error!("Book snapshot request failed for {instrument_id}: {e:?}");
1021                }
1022            }
1023        });
1024
1025        Ok(())
1026    }
1027
1028    fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1029        let instrument_id = request.single_instrument_id()?;
1030
1031        log::debug!("Requesting trades for {instrument_id}");
1032        let product_id = instrument_id.symbol.to_string();
1033
1034        let instruments = self.instruments.load();
1035        let instrument = instruments
1036            .get(&instrument_id)
1037            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1038        let price_precision = instrument.price_precision();
1039        let size_precision = instrument.size_precision();
1040
1041        let http = self.http_client.clone();
1042        let sender = self.data_sender.clone();
1043        let request_id = request.request_id;
1044        let client_id = request.client_id.unwrap_or(self.client_id);
1045        let limit = request.limit.map_or(100, |n| n.get() as u32);
1046        let start_nanos = datetime_to_unix_nanos(request.start);
1047        let end_nanos = datetime_to_unix_nanos(request.end);
1048        let params = request.params;
1049        let clock = self.clock;
1050
1051        self.spawn_command(async move {
1052            match http.get_market_trades(&product_id, limit).await {
1053                Ok(json) => {
1054                    let ticker: TickerResponse = match serde_json::from_value(json) {
1055                        Ok(r) => r,
1056                        Err(e) => {
1057                            log::error!("Failed to parse trades response: {e}");
1058                            return;
1059                        }
1060                    };
1061
1062                    let ts_init = clock.get_time_ns();
1063                    let mut trades: Vec<_> = ticker
1064                        .trades
1065                        .iter()
1066                        .filter_map(|trade| {
1067                            parse_trade_tick(
1068                                trade,
1069                                instrument_id,
1070                                price_precision,
1071                                size_precision,
1072                                ts_init,
1073                            )
1074                            .map_err(|e| log::warn!("Failed to parse trade: {e}"))
1075                            .ok()
1076                        })
1077                        .collect();
1078
1079                    // Coinbase returns newest-first; sort ascending
1080                    trades.sort_by_key(|t| t.ts_event);
1081
1082                    let response = DataResponse::Trades(TradesResponse::new(
1083                        request_id,
1084                        client_id,
1085                        instrument_id,
1086                        trades,
1087                        start_nanos,
1088                        end_nanos,
1089                        clock.get_time_ns(),
1090                        params,
1091                    ));
1092
1093                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1094                        log::error!("Failed to send trades response: {e}");
1095                    }
1096                }
1097                Err(e) => log::error!("Trades request failed for {instrument_id}: {e:?}"),
1098            }
1099        });
1100
1101        Ok(())
1102    }
1103
1104    fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1105        let bar_type = request.single_bar_type()?;
1106
1107        log::debug!("Requesting bars for {bar_type}");
1108        let granularity = bar_type_to_granularity(&bar_type)?;
1109        let instrument_id = bar_type.instrument_id();
1110        let product_id = instrument_id.symbol.to_string();
1111
1112        let instruments = self.instruments.load();
1113        let instrument = instruments
1114            .get(&instrument_id)
1115            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1116        let price_precision = instrument.price_precision();
1117        let size_precision = instrument.size_precision();
1118
1119        let http = self.http_client.clone();
1120        let sender = self.data_sender.clone();
1121        let request_id = request.request_id;
1122        let client_id = request.client_id.unwrap_or(self.client_id);
1123        let start = request.start;
1124        let end = request.end;
1125        let limit = request.limit.map(|n| n.get());
1126        let start_nanos = datetime_to_unix_nanos(start);
1127        let end_nanos = datetime_to_unix_nanos(end);
1128        let params = request.params;
1129        let clock = self.clock;
1130
1131        self.spawn_command(async move {
1132            let now = jiff::Timestamp::now();
1133            let end_secs = end.unwrap_or(now).as_second().to_string();
1134            let start_secs = if let Some(s) = start {
1135                s.as_second().to_string()
1136            } else {
1137                let spec = bar_type.spec();
1138                let step_secs = match spec.aggregation {
1139                    BarAggregation::Minute => spec.step.get() as i64 * 60,
1140                    BarAggregation::Hour => spec.step.get() as i64 * 3600,
1141                    BarAggregation::Day => spec.step.get() as i64 * 86400,
1142                    _ => 60,
1143                };
1144                let count = limit.unwrap_or(300) as i64;
1145                let end_ts = end.unwrap_or(now).as_second();
1146                (end_ts - count * step_secs).to_string()
1147            };
1148
1149            let granularity_str = granularity.to_string();
1150
1151            match http
1152                .get_candles(&product_id, &start_secs, &end_secs, &granularity_str)
1153                .await
1154            {
1155                Ok(json) => {
1156                    let candles_response: CandlesResponse = match serde_json::from_value(json) {
1157                        Ok(r) => r,
1158                        Err(e) => {
1159                            log::error!("Failed to parse candles response: {e}");
1160                            return;
1161                        }
1162                    };
1163
1164                    let ts_init = clock.get_time_ns();
1165                    let mut bars: Vec<_> = candles_response
1166                        .candles
1167                        .iter()
1168                        .filter_map(|candle| {
1169                            parse_bar(candle, bar_type, price_precision, size_precision, ts_init)
1170                                .map_err(|e| log::warn!("Failed to parse bar: {e}"))
1171                                .ok()
1172                        })
1173                        .collect();
1174
1175                    bars.sort_by_key(|b| b.ts_event);
1176
1177                    if let Some(limit) = limit
1178                        && bars.len() > limit
1179                    {
1180                        bars.drain(..bars.len() - limit);
1181                    }
1182
1183                    let response = DataResponse::Bars(BarsResponse::new(
1184                        request_id,
1185                        client_id,
1186                        bar_type,
1187                        bars,
1188                        start_nanos,
1189                        end_nanos,
1190                        clock.get_time_ns(),
1191                        params,
1192                    ));
1193
1194                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1195                        log::error!("Failed to send bars response: {e}");
1196                    }
1197                }
1198                Err(e) => log::error!("Bar request failed: {e:?}"),
1199            }
1200        });
1201
1202        Ok(())
1203    }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use nautilus_common::{
1209        live::runner::set_data_event_sender, messages::data::SubscribeMarkPrices,
1210    };
1211    use nautilus_core::{UUID4, UnixNanos};
1212    use nautilus_model::identifiers::InstrumentId;
1213    use rstest::rstest;
1214
1215    use super::*;
1216    use crate::common::consts::COINBASE_CLIENT_ID;
1217
1218    // Coinbase Advanced Trade does not publish live mark prices for its
1219    // perpetuals, so `subscribe_mark_prices` must return an explicit error
1220    // naming the instrument and mentioning mark prices. A regression that
1221    // silently `Ok(())`s the call would mask the unsupported feature.
1222    #[rstest]
1223    #[tokio::test]
1224    async fn test_subscribe_mark_prices_rejects_with_explicit_error() {
1225        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1226        set_data_event_sender(tx);
1227
1228        let config = CoinbaseDataClientConfig::default();
1229        let mut client =
1230            CoinbaseDataClient::new(*COINBASE_CLIENT_ID, config).expect("client construction");
1231
1232        let instrument_id = InstrumentId::from("BIP-20DEC30-CDE.COINBASE");
1233        let cmd = SubscribeMarkPrices::new(
1234            instrument_id,
1235            Some(*COINBASE_CLIENT_ID),
1236            None,
1237            UUID4::new(),
1238            UnixNanos::default(),
1239            None,
1240            None,
1241        );
1242
1243        let err = client
1244            .subscribe_mark_prices(cmd)
1245            .expect_err("must reject mark-price subscriptions");
1246        let msg = err.to_string();
1247        assert!(
1248            msg.contains("mark prices"),
1249            "error must mention mark prices, was: {msg}"
1250        );
1251        assert!(
1252            msg.contains("BIP-20DEC30-CDE.COINBASE"),
1253            "error must name the instrument, was: {msg}"
1254        );
1255    }
1256
1257    fn make_status_event(instrument_id: InstrumentId) -> NautilusWsMessage {
1258        use nautilus_model::{data::InstrumentStatus, enums::MarketStatusAction};
1259
1260        let status = InstrumentStatus::new(
1261            instrument_id,
1262            MarketStatusAction::Trading,
1263            UnixNanos::from(1),
1264            UnixNanos::from(2),
1265            None,
1266            None,
1267            Some(true),
1268            None,
1269            None,
1270        );
1271        NautilusWsMessage::InstrumentStatus(Box::new(status))
1272    }
1273
1274    // The dispatch filter is what keeps the venue-wide `status` feed from
1275    // leaking every product's events to a subscriber that only cares about
1276    // one. A regression that drops the `subs.contains(...)` guard would let
1277    // every product's status reach `data_sender`.
1278    #[rstest]
1279    fn test_dispatch_ws_message_status_filter_forwards_subscribed() {
1280        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1281        let instrument_id = InstrumentId::from("BTC-USD.COINBASE");
1282        let mut set = AHashSet::new();
1283        set.insert(instrument_id);
1284        let subs = Arc::new(Mutex::new(set));
1285
1286        dispatch_ws_message(make_status_event(instrument_id), &tx.into(), &subs);
1287
1288        match rx.try_recv() {
1289            Ok(DataEvent::Data(Data::InstrumentStatus(status))) => {
1290                assert_eq!(status.instrument_id, instrument_id);
1291            }
1292            other => panic!("expected DataEvent::Data(Data::InstrumentStatus(...)), was {other:?}"),
1293        }
1294    }
1295
1296    #[rstest]
1297    fn test_dispatch_ws_message_status_filter_drops_unsubscribed() {
1298        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1299        let subscribed = InstrumentId::from("BTC-USD.COINBASE");
1300        let unsubscribed = InstrumentId::from("ETH-USD.COINBASE");
1301        let mut set = AHashSet::new();
1302        set.insert(subscribed);
1303        let subs = Arc::new(Mutex::new(set));
1304
1305        dispatch_ws_message(make_status_event(unsubscribed), &tx.into(), &subs);
1306
1307        assert!(
1308            rx.try_recv().is_err(),
1309            "unsubscribed status must be dropped"
1310        );
1311    }
1312
1313    // First subscribe on an empty set must populate `instrument_status_subs`;
1314    // a second subscribe for the same instrument must not add a duplicate or
1315    // re-spawn the channel-level WS subscribe. Reset must clear the field.
1316    #[rstest]
1317    #[tokio::test]
1318    async fn test_subscribe_instrument_status_records_and_idempotent() {
1319        use nautilus_common::messages::data::SubscribeInstrumentStatus;
1320
1321        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1322        set_data_event_sender(tx);
1323
1324        let config = CoinbaseDataClientConfig::default();
1325        let mut client =
1326            CoinbaseDataClient::new(*COINBASE_CLIENT_ID, config).expect("client construction");
1327
1328        let instrument_id = InstrumentId::from("BTC-USD.COINBASE");
1329        let cmd = SubscribeInstrumentStatus::new(
1330            instrument_id,
1331            Some(*COINBASE_CLIENT_ID),
1332            None,
1333            UUID4::new(),
1334            UnixNanos::default(),
1335            None,
1336            None,
1337        );
1338
1339        client.subscribe_instrument_status(cmd.clone()).unwrap();
1340        assert!(
1341            client
1342                .instrument_status_subs
1343                .lock()
1344                .contains(&instrument_id)
1345        );
1346
1347        // Duplicate subscribe keeps the set at size 1.
1348        client.subscribe_instrument_status(cmd).unwrap();
1349        assert_eq!(client.instrument_status_subs.lock().len(), 1);
1350
1351        // Reset clears the set so a subsequent connect starts clean.
1352        client.reset().unwrap();
1353        assert!(client.instrument_status_subs.lock().is_empty());
1354    }
1355
1356    // Unsubscribing the last instrument empties the set; intermediate
1357    // unsubscribes leave other entries in place.
1358    #[rstest]
1359    #[tokio::test]
1360    async fn test_unsubscribe_instrument_status_emptying_set() {
1361        use nautilus_common::messages::data::{
1362            SubscribeInstrumentStatus, UnsubscribeInstrumentStatus,
1363        };
1364
1365        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1366        set_data_event_sender(tx);
1367
1368        let mut client =
1369            CoinbaseDataClient::new(*COINBASE_CLIENT_ID, CoinbaseDataClientConfig::default())
1370                .expect("client construction");
1371
1372        let a = InstrumentId::from("BTC-USD.COINBASE");
1373        let b = InstrumentId::from("ETH-USD.COINBASE");
1374
1375        for id in [a, b] {
1376            client
1377                .subscribe_instrument_status(SubscribeInstrumentStatus::new(
1378                    id,
1379                    Some(*COINBASE_CLIENT_ID),
1380                    None,
1381                    UUID4::new(),
1382                    UnixNanos::default(),
1383                    None,
1384                    None,
1385                ))
1386                .unwrap();
1387        }
1388        assert_eq!(client.instrument_status_subs.lock().len(), 2);
1389
1390        let unsub = |id| {
1391            UnsubscribeInstrumentStatus::new(
1392                id,
1393                Some(*COINBASE_CLIENT_ID),
1394                None,
1395                UUID4::new(),
1396                UnixNanos::default(),
1397                None,
1398                None,
1399            )
1400        };
1401
1402        // Intermediate unsubscribe: `a` leaves, `b` retained.
1403        client.unsubscribe_instrument_status(&unsub(a)).unwrap();
1404        {
1405            let subs = client.instrument_status_subs.lock();
1406            assert!(!subs.contains(&a), "a removed");
1407            assert!(subs.contains(&b), "b retained");
1408            assert_eq!(subs.len(), 1);
1409        }
1410
1411        // Last unsubscribe: set must end up empty so a future first-subscribe
1412        // re-arms the channel-level WS subscription.
1413        client.unsubscribe_instrument_status(&unsub(b)).unwrap();
1414        assert!(
1415            client.instrument_status_subs.lock().is_empty(),
1416            "last unsubscribe must empty the set",
1417        );
1418    }
1419}