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