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