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