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, 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 for instrument in &instruments {
247 self.ws_client.update_instrument(instrument.clone()).await;
248 }
249
250 log::debug!("Bootstrapped {} instruments", instruments.len());
251 Ok(instruments)
252 }
253
254 async fn spawn_ws(&mut self) -> anyhow::Result<()> {
255 self.ws_client
256 .connect()
257 .await
258 .context("failed to connect to Coinbase WebSocket")?;
259
260 let Some(mut out_rx) = self.ws_client.take_out_rx() else {
261 self.ws_client
262 .disconnect()
263 .await
264 .context("failed to roll back Coinbase WebSocket without output receiver")?;
265 anyhow::bail!("WebSocket output receiver not available");
266 };
267
268 let data_sender = self.data_sender.clone();
269 let cancellation_token = self.cancellation_token.clone();
270 let status_subs = Arc::clone(&self.instrument_status_subs);
271
272 let future = async move {
273 log::debug!("Coinbase WebSocket consumption loop started");
274
275 loop {
276 tokio::select! {
277 () = cancellation_token.cancelled() => {
278 log::debug!("WebSocket consumption loop cancelled");
279 break;
280 }
281 msg_opt = out_rx.recv() => {
282 match msg_opt {
283 Some(msg) => dispatch_ws_message(msg, &data_sender, &status_subs),
284 None => {
285 log::debug!("WebSocket output channel closed");
286 break;
287 }
288 }
289 }
290 }
291 }
292
293 log::debug!("Coinbase WebSocket consumption loop finished");
294 };
295
296 if let Err(e) = self.session_tasks.spawn(future) {
297 if let Err(shutdown_error) = self.ws_client.disconnect().await {
298 return Err(anyhow::Error::new(e).context(format!(
299 "failed to register Coinbase WebSocket consumption task; startup rollback \
300 failed: {shutdown_error}"
301 )));
302 }
303 return Err(anyhow::Error::new(e)
304 .context("failed to register Coinbase WebSocket consumption task"));
305 }
306 log::debug!("WebSocket consumption task registered");
307 Ok(())
308 }
309
310 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
311 self.cancellation_token.cancel();
312 self.session_tasks.begin_shutdown();
313 self.command_tasks.begin_shutdown();
314 self.deriv_polls.shutdown();
315 self.ws_client.begin_shutdown();
316
317 let (tasks_result, polls_result) =
318 tokio::join!(self.finish_tasks(), self.deriv_polls.finish_shutdown());
319
320 if let Err(e) = tasks_result {
321 self.shutdown_errors.push(e.to_string());
322 }
323
324 if let Err(e) = polls_result {
325 self.shutdown_errors.push(e.to_string());
326 }
327
328 if let Err(e) = self.ws_client.disconnect().await {
329 self.shutdown_errors.push(e.to_string());
330 }
331 self.is_connected.store(false, Ordering::Relaxed);
332
333 if !self.shutdown_errors.is_empty() {
334 anyhow::bail!(std::mem::take(&mut self.shutdown_errors).join("; "));
335 }
336 Ok(())
337 }
338
339 fn product_id(instrument_id: InstrumentId) -> Ustr {
340 instrument_id.symbol.inner()
341 }
342
343 fn resolve_wire_product_id(&self, subscribed: Ustr) -> Ustr {
350 self.http_client
351 .product_aliases()
352 .get_cloned(&subscribed)
353 .filter(|alias| !alias.is_empty())
354 .unwrap_or(subscribed)
355 }
356}
357
358fn dispatch_ws_message(
359 msg: NautilusWsMessage,
360 data_sender: &EventSender<DataEvent>,
361 status_subs: &Arc<Mutex<AHashSet<InstrumentId>>>,
362) {
363 match msg {
364 NautilusWsMessage::Trade(trade) => {
365 if let Err(e) = data_sender.send(DataEvent::Data(Data::Trade(trade))) {
366 log::error!("Failed to send trade tick: {e}");
367 }
368 }
369 NautilusWsMessage::Quote(quote) => {
370 if let Err(e) = data_sender.send(DataEvent::Data(Data::Quote(quote))) {
371 log::error!("Failed to send quote tick: {e}");
372 }
373 }
374 NautilusWsMessage::Deltas(deltas) => {
375 if let Err(e) = data_sender.send(DataEvent::Data(Data::BookDeltas(Box::new(deltas)))) {
376 log::error!("Failed to send order book deltas: {e}");
377 }
378 }
379 NautilusWsMessage::Bar(bar) => {
380 if let Err(e) = data_sender.send(DataEvent::Data(Data::Bar(bar))) {
381 log::error!("Failed to send bar: {e}");
382 }
383 }
384 NautilusWsMessage::InstrumentStatus(status) => {
385 let subscribed = status_subs.lock().contains(&status.instrument_id);
388 if subscribed && let Err(e) = data_sender.send(DataEvent::InstrumentStatus(*status)) {
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::Instrument(instrument)) {
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 let instruments = self.instruments.load();
528
529 if let Some(instrument) = instruments.get(&cmd.instrument_id) {
530 if let Err(e) = self
531 .data_sender
532 .send(DataEvent::Instrument(instrument.clone()))
533 {
534 log::error!("Failed to send instrument {}: {e}", cmd.instrument_id);
535 }
536 } else {
537 log::warn!("Instrument {} not found in cache", cmd.instrument_id);
538 }
539
540 Ok(())
541 }
542
543 fn subscribe_book_deltas(&mut self, subscription: SubscribeBookDeltas) -> anyhow::Result<()> {
544 if subscription.book_type != BookType::L2_MBP {
545 anyhow::bail!("Coinbase only supports L2_MBP order book deltas");
546 }
547
548 let ws = self.ws_client.clone();
549 let subscribed_id = Self::product_id(subscription.instrument_id);
550 let wire_id = self.resolve_wire_product_id(subscribed_id);
551 if wire_id != subscribed_id {
552 ws.register_subscription_alias(wire_id, subscribed_id);
553 }
554
555 self.spawn_command(async move {
556 if let Err(e) = ws.subscribe(CoinbaseWsChannel::Level2, &[wire_id]).await {
557 log::error!("Failed to subscribe to book deltas: {e:?}");
558 }
559 });
560
561 Ok(())
562 }
563
564 fn subscribe_quotes(&mut self, subscription: SubscribeQuotes) -> anyhow::Result<()> {
565 let ws = self.ws_client.clone();
566 let subscribed_id = Self::product_id(subscription.instrument_id);
567 let wire_id = self.resolve_wire_product_id(subscribed_id);
568 if wire_id != subscribed_id {
569 ws.register_subscription_alias(wire_id, subscribed_id);
570 }
571
572 self.spawn_command(async move {
573 if let Err(e) = ws.subscribe(CoinbaseWsChannel::Ticker, &[wire_id]).await {
574 log::error!("Failed to subscribe to quotes: {e:?}");
575 }
576 });
577
578 Ok(())
579 }
580
581 fn subscribe_trades(&mut self, subscription: SubscribeTrades) -> anyhow::Result<()> {
582 let ws = self.ws_client.clone();
583 let subscribed_id = Self::product_id(subscription.instrument_id);
584 let wire_id = self.resolve_wire_product_id(subscribed_id);
585 if wire_id != subscribed_id {
586 ws.register_subscription_alias(wire_id, subscribed_id);
587 }
588
589 self.spawn_command(async move {
590 if let Err(e) = ws
591 .subscribe(CoinbaseWsChannel::MarketTrades, &[wire_id])
592 .await
593 {
594 log::error!("Failed to subscribe to trades: {e:?}");
595 }
596 });
597
598 Ok(())
599 }
600
601 fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
602 anyhow::bail!(
608 "Coinbase Advanced Trade does not publish mark prices; \
609 cannot subscribe for {}",
610 cmd.instrument_id
611 )
612 }
613
614 fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
615 self.deriv_polls.subscribe_index(cmd.instrument_id);
616 Ok(())
617 }
618
619 fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
620 self.deriv_polls.subscribe_funding(cmd.instrument_id);
621 Ok(())
622 }
623
624 fn subscribe_instrument_status(
625 &mut self,
626 cmd: SubscribeInstrumentStatus,
627 ) -> anyhow::Result<()> {
628 let subscribed_id = Self::product_id(cmd.instrument_id);
633 let wire_id = self.resolve_wire_product_id(subscribed_id);
634 if wire_id != subscribed_id {
635 self.ws_client
636 .register_subscription_alias(wire_id, subscribed_id);
637 }
638
639 let was_empty = {
642 let mut subs = self.instrument_status_subs.lock();
643 let was_empty = subs.is_empty();
644 subs.insert(cmd.instrument_id);
645 was_empty
646 };
647
648 if was_empty {
649 let ws = self.ws_client.clone();
650 self.spawn_command(async move {
651 if let Err(e) = ws.subscribe(CoinbaseWsChannel::Status, &[]).await {
652 log::error!("Failed to subscribe to status channel: {e:?}");
653 }
654 });
655 }
656 Ok(())
657 }
658
659 fn subscribe_bars(&mut self, subscription: SubscribeBars) -> anyhow::Result<()> {
660 let instrument_id = subscription.bar_type.instrument_id();
661
662 if !self.instruments.contains_key(&instrument_id) {
663 anyhow::bail!(InstrumentLookupError::not_found(instrument_id));
664 }
665
666 let bar_type = subscription.bar_type;
667 let subscribed_id = Self::product_id(instrument_id);
668 let wire_id = self.resolve_wire_product_id(subscribed_id);
669 if wire_id != subscribed_id {
670 self.ws_client
671 .register_subscription_alias(wire_id, subscribed_id);
672 }
673 let key = wire_id.to_string();
674
675 self.ws_client.register_bar_type(key.clone(), bar_type);
677
678 let mut ws = self.ws_client.clone();
679
680 self.spawn_command(async move {
681 ws.add_bar_type(key, bar_type).await;
682
683 if let Err(e) = ws.subscribe(CoinbaseWsChannel::Candles, &[wire_id]).await {
684 log::error!("Failed to subscribe to bars: {e:?}");
685 }
686 });
687
688 Ok(())
689 }
690
691 fn unsubscribe_instrument(
701 &mut self,
702 _unsubscription: &UnsubscribeInstrument,
703 ) -> anyhow::Result<()> {
704 Ok(())
706 }
707
708 fn unsubscribe_book_deltas(
709 &mut self,
710 unsubscription: &UnsubscribeBookDeltas,
711 ) -> anyhow::Result<()> {
712 log::debug!(
713 "Unsubscribing from book deltas: {}",
714 unsubscription.instrument_id
715 );
716
717 let ws = self.ws_client.clone();
718 let subscribed_id = Self::product_id(unsubscription.instrument_id);
719 let wire_id = self.resolve_wire_product_id(subscribed_id);
720
721 self.spawn_command(async move {
722 if let Err(e) = ws.unsubscribe(CoinbaseWsChannel::Level2, &[wire_id]).await {
723 log::error!("Failed to unsubscribe from book deltas: {e:?}");
724 }
725 });
726
727 Ok(())
728 }
729
730 fn unsubscribe_quotes(&mut self, unsubscription: &UnsubscribeQuotes) -> anyhow::Result<()> {
731 log::debug!(
732 "Unsubscribing from quotes: {}",
733 unsubscription.instrument_id
734 );
735
736 let ws = self.ws_client.clone();
737 let subscribed_id = Self::product_id(unsubscription.instrument_id);
738 let wire_id = self.resolve_wire_product_id(subscribed_id);
739
740 self.spawn_command(async move {
741 if let Err(e) = ws.unsubscribe(CoinbaseWsChannel::Ticker, &[wire_id]).await {
742 log::error!("Failed to unsubscribe from quotes: {e:?}");
743 }
744 });
745
746 Ok(())
747 }
748
749 fn unsubscribe_trades(&mut self, unsubscription: &UnsubscribeTrades) -> anyhow::Result<()> {
750 log::debug!(
751 "Unsubscribing from trades: {}",
752 unsubscription.instrument_id
753 );
754
755 let ws = self.ws_client.clone();
756 let subscribed_id = Self::product_id(unsubscription.instrument_id);
757 let wire_id = self.resolve_wire_product_id(subscribed_id);
758
759 self.spawn_command(async move {
760 if let Err(e) = ws
761 .unsubscribe(CoinbaseWsChannel::MarketTrades, &[wire_id])
762 .await
763 {
764 log::error!("Failed to unsubscribe from trades: {e:?}");
765 }
766 });
767
768 Ok(())
769 }
770
771 fn unsubscribe_mark_prices(&mut self, _cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
772 Ok(())
773 }
774
775 fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
776 self.deriv_polls.unsubscribe_index(cmd.instrument_id);
777 Ok(())
778 }
779
780 fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
781 self.deriv_polls.unsubscribe_funding(cmd.instrument_id);
782 Ok(())
783 }
784
785 fn unsubscribe_instrument_status(
786 &mut self,
787 cmd: &UnsubscribeInstrumentStatus,
788 ) -> anyhow::Result<()> {
789 log::debug!(
790 "Unsubscribing from instrument status: {}",
791 cmd.instrument_id
792 );
793
794 let now_empty = {
795 let mut subs = self.instrument_status_subs.lock();
796 subs.remove(&cmd.instrument_id);
797 subs.is_empty()
798 };
799
800 if now_empty {
801 let ws = self.ws_client.clone();
802 self.spawn_command(async move {
803 if let Err(e) = ws.unsubscribe(CoinbaseWsChannel::Status, &[]).await {
804 log::error!("Failed to unsubscribe from status channel: {e:?}");
805 }
806 });
807 }
808 Ok(())
809 }
810
811 fn unsubscribe_bars(&mut self, unsubscription: &UnsubscribeBars) -> anyhow::Result<()> {
812 let instrument_id = unsubscription.bar_type.instrument_id();
813 let subscribed_id = Self::product_id(instrument_id);
814 let wire_id = self.resolve_wire_product_id(subscribed_id);
815 let ws = self.ws_client.clone();
816
817 self.spawn_command(async move {
818 if let Err(e) = ws.unsubscribe(CoinbaseWsChannel::Candles, &[wire_id]).await {
819 log::error!("Failed to unsubscribe from bars: {e:?}");
820 }
821 });
822
823 Ok(())
824 }
825
826 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
827 log::debug!("Requesting all instruments");
828
829 let provider = self.provider.clone();
830 let sender = self.data_sender.clone();
831 let instruments_cache = self.instruments.clone();
832 let ws = self.ws_client.clone();
833 let request_id = request.request_id;
834 let client_id = request.client_id.unwrap_or(self.client_id);
835 let venue = Self::venue(self);
836 let start_nanos = datetime_to_unix_nanos(request.start);
837 let end_nanos = datetime_to_unix_nanos(request.end);
838 let params = request.params;
839 let clock = self.clock;
840
841 self.spawn_command(async move {
842 match provider.load_all().await {
843 Ok(instruments) => {
844 instruments_cache.rcu(|m| {
845 for instrument in &instruments {
846 m.insert(instrument.id(), instrument.clone());
847 }
848 });
849
850 for instrument in &instruments {
851 ws.update_instrument(instrument.clone()).await;
852 }
853
854 let response = DataResponse::Instruments(InstrumentsResponse::new(
855 request_id,
856 client_id,
857 venue,
858 instruments,
859 start_nanos,
860 end_nanos,
861 clock.get_time_ns(),
862 params,
863 ));
864
865 if let Err(e) = sender.send(DataEvent::Response(response)) {
866 log::error!("Failed to send instruments response: {e}");
867 }
868 }
869 Err(e) => {
870 log::error!("Failed to fetch instruments: {e:?}");
871 }
872 }
873 });
874
875 Ok(())
876 }
877
878 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
879 log::debug!("Requesting instrument: {}", request.instrument_id);
880
881 let provider = self.provider.clone();
882 let sender = self.data_sender.clone();
883 let instruments_cache = self.instruments.clone();
884 let ws = self.ws_client.clone();
885 let instrument_id = request.instrument_id;
886 let product_id = instrument_id.symbol.to_string();
887 let request_id = request.request_id;
888 let client_id = request.client_id.unwrap_or(self.client_id);
889 let start_nanos = datetime_to_unix_nanos(request.start);
890 let end_nanos = datetime_to_unix_nanos(request.end);
891 let params = request.params;
892 let clock = self.clock;
893
894 self.spawn_command(async move {
895 match provider.load(&product_id).await {
896 Ok(instrument) => {
897 instruments_cache.rcu(|m| {
898 m.insert(instrument.id(), instrument.clone());
899 });
900 ws.update_instrument(instrument.clone()).await;
901
902 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
903 request_id,
904 client_id,
905 instrument.id(),
906 instrument,
907 start_nanos,
908 end_nanos,
909 clock.get_time_ns(),
910 params,
911 )));
912
913 if let Err(e) = sender.send(DataEvent::Response(response)) {
914 log::error!("Failed to send instrument response: {e}");
915 }
916 }
917 Err(e) => {
918 log::error!("Failed to fetch instrument {instrument_id}: {e:?}");
919 }
920 }
921 });
922
923 Ok(())
924 }
925
926 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
927 let instrument_id = request.instrument_id;
928 let product_id = instrument_id.symbol.to_string();
929
930 let instruments = self.instruments.load();
931 let instrument = instruments
932 .get(&instrument_id)
933 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
934 let price_precision = instrument.price_precision();
935 let size_precision = instrument.size_precision();
936 let depth = request.depth.map(|d| d.get() as u32);
937
938 let http = self.http_client.clone();
939 let sender = self.data_sender.clone();
940 let client_id = request.client_id.unwrap_or(self.client_id);
941 let request_id = request.request_id;
942 let params = request.params;
943 let clock = self.clock;
944
945 self.spawn_command(async move {
946 match http.get_product_book(&product_id, depth).await {
947 Ok(json) => {
948 let pricebook_value = json.get("pricebook").cloned().unwrap_or(json);
949
950 let pricebook: PriceBook = match serde_json::from_value(pricebook_value) {
951 Ok(b) => b,
952 Err(e) => {
953 log::error!("Failed to parse product book: {e}");
954 return;
955 }
956 };
957
958 let ts_init = clock.get_time_ns();
959
960 match parse_product_book_snapshot(
961 &pricebook,
962 instrument_id,
963 price_precision,
964 size_precision,
965 ts_init,
966 ) {
967 Ok(deltas) => {
968 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
969
970 for delta in &deltas.deltas {
971 if delta.order.side.is_some() {
972 book.add(
973 delta.order,
974 delta.flags,
975 delta.sequence,
976 delta.ts_event,
977 );
978 }
979 }
980
981 let response = DataResponse::Book(BookResponse::new(
982 request_id,
983 client_id,
984 instrument_id,
985 book,
986 None,
987 None,
988 clock.get_time_ns(),
989 params,
990 ));
991
992 if let Err(e) = sender.send(DataEvent::Response(response)) {
993 log::error!("Failed to send book snapshot response: {e}");
994 }
995 }
996 Err(e) => {
997 log::error!("Failed to parse book snapshot for {instrument_id}: {e}");
998 }
999 }
1000 }
1001 Err(e) => {
1002 log::error!("Book snapshot request failed for {instrument_id}: {e:?}");
1003 }
1004 }
1005 });
1006
1007 Ok(())
1008 }
1009
1010 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1011 log::debug!("Requesting trades for {}", request.instrument_id);
1012
1013 let instrument_id = request.instrument_id;
1014 let product_id = instrument_id.symbol.to_string();
1015
1016 let instruments = self.instruments.load();
1017 let instrument = instruments
1018 .get(&instrument_id)
1019 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1020 let price_precision = instrument.price_precision();
1021 let size_precision = instrument.size_precision();
1022
1023 let http = self.http_client.clone();
1024 let sender = self.data_sender.clone();
1025 let request_id = request.request_id;
1026 let client_id = request.client_id.unwrap_or(self.client_id);
1027 let limit = request.limit.map_or(100, |n| n.get() as u32);
1028 let start_nanos = datetime_to_unix_nanos(request.start);
1029 let end_nanos = datetime_to_unix_nanos(request.end);
1030 let params = request.params;
1031 let clock = self.clock;
1032
1033 self.spawn_command(async move {
1034 match http.get_market_trades(&product_id, limit).await {
1035 Ok(json) => {
1036 let ticker: TickerResponse = match serde_json::from_value(json) {
1037 Ok(r) => r,
1038 Err(e) => {
1039 log::error!("Failed to parse trades response: {e}");
1040 return;
1041 }
1042 };
1043
1044 let ts_init = clock.get_time_ns();
1045 let mut trades: Vec<_> = ticker
1046 .trades
1047 .iter()
1048 .filter_map(|trade| {
1049 parse_trade_tick(
1050 trade,
1051 instrument_id,
1052 price_precision,
1053 size_precision,
1054 ts_init,
1055 )
1056 .map_err(|e| log::warn!("Failed to parse trade: {e}"))
1057 .ok()
1058 })
1059 .collect();
1060
1061 trades.sort_by_key(|t| t.ts_event);
1063
1064 let response = DataResponse::Trades(TradesResponse::new(
1065 request_id,
1066 client_id,
1067 instrument_id,
1068 trades,
1069 start_nanos,
1070 end_nanos,
1071 clock.get_time_ns(),
1072 params,
1073 ));
1074
1075 if let Err(e) = sender.send(DataEvent::Response(response)) {
1076 log::error!("Failed to send trades response: {e}");
1077 }
1078 }
1079 Err(e) => log::error!("Trades request failed for {instrument_id}: {e:?}"),
1080 }
1081 });
1082
1083 Ok(())
1084 }
1085
1086 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1087 log::debug!("Requesting bars for {}", request.bar_type);
1088
1089 let bar_type = request.bar_type;
1090 let granularity = bar_type_to_granularity(&bar_type)?;
1091 let instrument_id = bar_type.instrument_id();
1092 let product_id = instrument_id.symbol.to_string();
1093
1094 let instruments = self.instruments.load();
1095 let instrument = instruments
1096 .get(&instrument_id)
1097 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1098 let price_precision = instrument.price_precision();
1099 let size_precision = instrument.size_precision();
1100
1101 let http = self.http_client.clone();
1102 let sender = self.data_sender.clone();
1103 let request_id = request.request_id;
1104 let client_id = request.client_id.unwrap_or(self.client_id);
1105 let start = request.start;
1106 let end = request.end;
1107 let limit = request.limit.map(|n| n.get());
1108 let start_nanos = datetime_to_unix_nanos(start);
1109 let end_nanos = datetime_to_unix_nanos(end);
1110 let params = request.params;
1111 let clock = self.clock;
1112
1113 self.spawn_command(async move {
1114 let now = jiff::Timestamp::now();
1115 let end_secs = end.unwrap_or(now).as_second().to_string();
1116 let start_secs = if let Some(s) = start {
1117 s.as_second().to_string()
1118 } else {
1119 let spec = bar_type.spec();
1120 let step_secs = match spec.aggregation {
1121 BarAggregation::Minute => spec.step.get() as i64 * 60,
1122 BarAggregation::Hour => spec.step.get() as i64 * 3600,
1123 BarAggregation::Day => spec.step.get() as i64 * 86400,
1124 _ => 60,
1125 };
1126 let count = limit.unwrap_or(300) as i64;
1127 let end_ts = end.unwrap_or(now).as_second();
1128 (end_ts - count * step_secs).to_string()
1129 };
1130
1131 let granularity_str = granularity.to_string();
1132
1133 match http
1134 .get_candles(&product_id, &start_secs, &end_secs, &granularity_str)
1135 .await
1136 {
1137 Ok(json) => {
1138 let candles_response: CandlesResponse = match serde_json::from_value(json) {
1139 Ok(r) => r,
1140 Err(e) => {
1141 log::error!("Failed to parse candles response: {e}");
1142 return;
1143 }
1144 };
1145
1146 let ts_init = clock.get_time_ns();
1147 let mut bars: Vec<_> = candles_response
1148 .candles
1149 .iter()
1150 .filter_map(|candle| {
1151 parse_bar(candle, bar_type, price_precision, size_precision, ts_init)
1152 .map_err(|e| log::warn!("Failed to parse bar: {e}"))
1153 .ok()
1154 })
1155 .collect();
1156
1157 bars.sort_by_key(|b| b.ts_event);
1158
1159 if let Some(limit) = limit
1160 && bars.len() > limit
1161 {
1162 bars.drain(..bars.len() - limit);
1163 }
1164
1165 let response = DataResponse::Bars(BarsResponse::new(
1166 request_id,
1167 client_id,
1168 bar_type,
1169 bars,
1170 start_nanos,
1171 end_nanos,
1172 clock.get_time_ns(),
1173 params,
1174 ));
1175
1176 if let Err(e) = sender.send(DataEvent::Response(response)) {
1177 log::error!("Failed to send bars response: {e}");
1178 }
1179 }
1180 Err(e) => log::error!("Bar request failed: {e:?}"),
1181 }
1182 });
1183
1184 Ok(())
1185 }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use nautilus_common::{
1191 live::runner::set_data_event_sender, messages::data::SubscribeMarkPrices,
1192 };
1193 use nautilus_core::{UUID4, UnixNanos};
1194 use nautilus_model::identifiers::InstrumentId;
1195 use rstest::rstest;
1196
1197 use super::*;
1198 use crate::common::consts::COINBASE_CLIENT_ID;
1199
1200 #[rstest]
1205 #[tokio::test]
1206 async fn test_subscribe_mark_prices_rejects_with_explicit_error() {
1207 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1208 set_data_event_sender(tx);
1209
1210 let config = CoinbaseDataClientConfig::default();
1211 let mut client =
1212 CoinbaseDataClient::new(*COINBASE_CLIENT_ID, config).expect("client construction");
1213
1214 let instrument_id = InstrumentId::from("BIP-20DEC30-CDE.COINBASE");
1215 let cmd = SubscribeMarkPrices::new(
1216 instrument_id,
1217 Some(*COINBASE_CLIENT_ID),
1218 None,
1219 UUID4::new(),
1220 UnixNanos::default(),
1221 None,
1222 None,
1223 );
1224
1225 let err = client
1226 .subscribe_mark_prices(cmd)
1227 .expect_err("must reject mark-price subscriptions");
1228 let msg = err.to_string();
1229 assert!(
1230 msg.contains("mark prices"),
1231 "error must mention mark prices, was: {msg}"
1232 );
1233 assert!(
1234 msg.contains("BIP-20DEC30-CDE.COINBASE"),
1235 "error must name the instrument, was: {msg}"
1236 );
1237 }
1238
1239 fn make_status_event(instrument_id: InstrumentId) -> NautilusWsMessage {
1240 use nautilus_model::{data::InstrumentStatus, enums::MarketStatusAction};
1241
1242 let status = InstrumentStatus::new(
1243 instrument_id,
1244 MarketStatusAction::Trading,
1245 UnixNanos::from(1),
1246 UnixNanos::from(2),
1247 None,
1248 None,
1249 Some(true),
1250 None,
1251 None,
1252 );
1253 NautilusWsMessage::InstrumentStatus(Box::new(status))
1254 }
1255
1256 #[rstest]
1261 fn test_dispatch_ws_message_status_filter_forwards_subscribed() {
1262 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1263 let instrument_id = InstrumentId::from("BTC-USD.COINBASE");
1264 let mut set = AHashSet::new();
1265 set.insert(instrument_id);
1266 let subs = Arc::new(Mutex::new(set));
1267
1268 dispatch_ws_message(make_status_event(instrument_id), &tx.into(), &subs);
1269
1270 match rx.try_recv() {
1271 Ok(DataEvent::InstrumentStatus(status)) => {
1272 assert_eq!(status.instrument_id, instrument_id);
1273 }
1274 other => panic!("expected DataEvent::InstrumentStatus, was {other:?}"),
1275 }
1276 }
1277
1278 #[rstest]
1279 fn test_dispatch_ws_message_status_filter_drops_unsubscribed() {
1280 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1281 let subscribed = InstrumentId::from("BTC-USD.COINBASE");
1282 let unsubscribed = InstrumentId::from("ETH-USD.COINBASE");
1283 let mut set = AHashSet::new();
1284 set.insert(subscribed);
1285 let subs = Arc::new(Mutex::new(set));
1286
1287 dispatch_ws_message(make_status_event(unsubscribed), &tx.into(), &subs);
1288
1289 assert!(
1290 rx.try_recv().is_err(),
1291 "unsubscribed status must be dropped"
1292 );
1293 }
1294
1295 #[rstest]
1299 #[tokio::test]
1300 async fn test_subscribe_instrument_status_records_and_idempotent() {
1301 use nautilus_common::messages::data::SubscribeInstrumentStatus;
1302
1303 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1304 set_data_event_sender(tx);
1305
1306 let config = CoinbaseDataClientConfig::default();
1307 let mut client =
1308 CoinbaseDataClient::new(*COINBASE_CLIENT_ID, config).expect("client construction");
1309
1310 let instrument_id = InstrumentId::from("BTC-USD.COINBASE");
1311 let cmd = SubscribeInstrumentStatus::new(
1312 instrument_id,
1313 Some(*COINBASE_CLIENT_ID),
1314 None,
1315 UUID4::new(),
1316 UnixNanos::default(),
1317 None,
1318 None,
1319 );
1320
1321 client.subscribe_instrument_status(cmd.clone()).unwrap();
1322 assert!(
1323 client
1324 .instrument_status_subs
1325 .lock()
1326 .contains(&instrument_id)
1327 );
1328
1329 client.subscribe_instrument_status(cmd).unwrap();
1331 assert_eq!(client.instrument_status_subs.lock().len(), 1);
1332
1333 client.reset().unwrap();
1335 assert!(client.instrument_status_subs.lock().is_empty());
1336 }
1337
1338 #[rstest]
1341 #[tokio::test]
1342 async fn test_unsubscribe_instrument_status_emptying_set() {
1343 use nautilus_common::messages::data::{
1344 SubscribeInstrumentStatus, UnsubscribeInstrumentStatus,
1345 };
1346
1347 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1348 set_data_event_sender(tx);
1349
1350 let mut client =
1351 CoinbaseDataClient::new(*COINBASE_CLIENT_ID, CoinbaseDataClientConfig::default())
1352 .expect("client construction");
1353
1354 let a = InstrumentId::from("BTC-USD.COINBASE");
1355 let b = InstrumentId::from("ETH-USD.COINBASE");
1356
1357 for id in [a, b] {
1358 client
1359 .subscribe_instrument_status(SubscribeInstrumentStatus::new(
1360 id,
1361 Some(*COINBASE_CLIENT_ID),
1362 None,
1363 UUID4::new(),
1364 UnixNanos::default(),
1365 None,
1366 None,
1367 ))
1368 .unwrap();
1369 }
1370 assert_eq!(client.instrument_status_subs.lock().len(), 2);
1371
1372 let unsub = |id| {
1373 UnsubscribeInstrumentStatus::new(
1374 id,
1375 Some(*COINBASE_CLIENT_ID),
1376 None,
1377 UUID4::new(),
1378 UnixNanos::default(),
1379 None,
1380 None,
1381 )
1382 };
1383
1384 client.unsubscribe_instrument_status(&unsub(a)).unwrap();
1386 {
1387 let subs = client.instrument_status_subs.lock();
1388 assert!(!subs.contains(&a), "a removed");
1389 assert!(subs.contains(&b), "b retained");
1390 assert_eq!(subs.len(), 1);
1391 }
1392
1393 client.unsubscribe_instrument_status(&unsub(b)).unwrap();
1396 assert!(
1397 client.instrument_status_subs.lock().is_empty(),
1398 "last unsubscribe must empty the set",
1399 );
1400 }
1401}