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