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