1use std::{
20 collections::HashMap,
21 future::Future,
22 num::NonZeroUsize,
23 sync::{Arc, atomic::AtomicBool},
24 time::Duration,
25};
26
27use ahash::AHashMap;
28use anyhow::Context;
29use arc_swap::ArcSwap;
30use async_trait::async_trait;
31use jiff::Timestamp;
32use nautilus_common::{
33 clients::{DataClient, execute_bar_subscriptions, execute_instrument_subscriptions},
34 live::{dst::time, runner::get_data_event_sender, sender::EventSender},
35 messages::{
36 DataEvent, DataResponse,
37 data::{
38 BarsResponse, BookResponse, InstrumentResponse, InstrumentsResponse, QuotesResponse,
39 RequestBars, RequestBookSnapshot, RequestInstrument, RequestInstruments, RequestQuotes,
40 RequestTrades, SubscribeBars, SubscribeBookDeltas, SubscribeBookDepth, SubscribeQuotes,
41 SubscribeTrades, TradesResponse, UnsubscribeBars, UnsubscribeBookDeltas,
42 UnsubscribeBookDepth, UnsubscribeQuotes, UnsubscribeTrades,
43 },
44 },
45};
46use nautilus_core::{
47 UnixNanos,
48 datetime::datetime_to_unix_nanos,
49 string::secret::SecretString,
50 time::{AtomicTime, get_atomic_clock_realtime},
51};
52use nautilus_live::{
53 book::{
54 BookSequenceOutcome,
55 snapshot::{SnapshotGate, snapshot_expired},
56 },
57 task::{TaskGroup, TaskSpawner},
58};
59use nautilus_model::{
60 data::{DEPTH10_LEN, Data},
61 enums::{AggregationSource, BookType},
62 identifiers::{ClientId, InstrumentId, Venue},
63 instruments::{Instrument, InstrumentAny},
64 types::fixed::check_fixed_precision,
65};
66use parking_lot::Mutex;
67use tokio_util::sync::CancellationToken;
68use ustr::Ustr;
69
70use crate::{
71 book::{
72 recovery::{spawn_recovery_monitor, spawn_recovery_task, start_recovery},
73 sync::{BookFrame, BookSubscription, BookSubscriptions},
74 },
75 common::{consts::COINAPI, credential::resolve_api_key},
76 config::{CoinApiDataClientConfig, CoinApiInstrumentSelection},
77 http::{
78 client::{CoinApiHttpClient, HistoryPages},
79 parse::{self as http_parse, CoinApiInstrument},
80 query::{BookQuery, HistoryRange},
81 },
82 websocket::{
83 client::CoinApiWebSocketClient,
84 messages::{Channel, CoinApiWsMessage, DataType, Ohlcv, ScopeChange, Subscription},
85 parse::{
86 bar_spec_to_period_id, parse_bar, parse_book_depth, parse_quote_tick, parse_trade_tick,
87 },
88 },
89};
90
91const TASK_GRACE: Duration = Duration::from_secs(1);
92const TASK_FORCE: Duration = Duration::from_secs(2);
93const SUBSCRIPTION_RETRY_DELAY_INITIAL: Duration = Duration::from_secs(1);
94const SUBSCRIPTION_RETRY_DELAY_MAX: Duration = Duration::from_secs(30);
95
96const BOOK_DEPTH_CHANNELS: [(DataType, usize); 3] = [
98 (DataType::Book5, 5),
99 (DataType::Book20, 20),
100 (DataType::Book50, 50),
101];
102
103#[derive(Debug)]
117pub struct CoinApiDataClient {
118 client_id: ClientId,
119 config: CoinApiDataClientConfig,
120 api_key: SecretString,
121 http: Arc<CoinApiHttpClient>,
122 ws: Option<Arc<CoinApiWebSocketClient>>,
123 subscription_tx: Option<SubscriptionSender>,
124 is_connected: bool,
125 session_tasks: TaskGroup,
126 command_tasks: TaskGroup,
127 data_sender: EventSender<DataEvent>,
128 instruments: Arc<InstrumentIndex>,
129 books: Arc<BookSubscriptions>,
130 ohlcv: Arc<Mutex<OhlcvCache>>,
131 clock: &'static AtomicTime,
132}
133
134impl CoinApiDataClient {
135 pub fn new(client_id: ClientId, config: CoinApiDataClientConfig) -> anyhow::Result<Self> {
148 config.validate()?;
149 validate_price_precisions(&config.instruments, &config.price_precisions)?;
150 let api_key = resolve_api_key(config.api_key.clone())?;
151 let http = CoinApiHttpClient::new(api_key.clone(), config.http_config())?;
152
153 CoinApiWebSocketClient::new(api_key.clone(), config.websocket_config())?;
155
156 Ok(Self {
157 client_id,
158 config,
159 api_key,
160 http: Arc::new(http),
161 ws: None,
162 subscription_tx: None,
163 is_connected: false,
164 session_tasks: TaskGroup::new(),
165 command_tasks: TaskGroup::new(),
166 data_sender: get_data_event_sender(),
167 instruments: Arc::new(InstrumentIndex::default()),
168 books: Arc::new(BookSubscriptions::default()),
169 ohlcv: Arc::new(Mutex::new(OhlcvCache::new())),
170 clock: get_atomic_clock_realtime(),
171 })
172 }
173
174 fn subscribe_book(
176 &self,
177 instrument_id: InstrumentId,
178 data_type: DataType,
179 depth: Option<usize>,
180 ) -> anyhow::Result<()> {
181 let _write = self.books.writes.lock();
182
183 if let Some(active) = self.books.entries.get_cloned(&instrument_id) {
184 let active_depth = active.depth;
185 anyhow::ensure!(
186 active.data_type == data_type && active_depth == depth,
187 "Conflicting CoinAPI book subscription for {instrument_id}: active {} with depth \
188 {active_depth:?}, requested {} with depth {depth:?}",
189 active.data_type.as_ref(),
190 data_type.as_ref()
191 );
192 return Ok(());
193 }
194
195 if let Some(active) = self
196 .books
197 .entries
198 .load()
199 .values()
200 .find(|active| active.data_type != data_type)
201 {
202 anyhow::bail!(
203 "CoinAPI streams one book channel per connection: {} is active, requested {}",
204 active.data_type.as_ref(),
205 data_type.as_ref()
206 );
207 }
208
209 let (commands, subscription) =
210 self.subscription(instrument_id, channel(data_type, None))?;
211
212 let snapshot = if depth.is_some() {
213 None
214 } else {
215 Some(self.expect_book_snapshot(instrument_id, subscription)?)
216 };
217
218 let registration = BookSubscription {
219 data_type,
220 depth,
221 active: Arc::new(AtomicBool::new(false)),
222 };
223
224 if let Err(e) = send_subscription(
225 &commands,
226 ScopeChange::Subscribe(subscription),
227 snapshot,
228 Some(Arc::clone(®istration.active)),
229 ) {
230 self.books.sync.remove(instrument_id);
231 return Err(e);
232 }
233
234 self.books.entries.insert(instrument_id, registration);
235 Ok(())
236 }
237
238 fn expect_book_snapshot(
241 &self,
242 instrument_id: InstrumentId,
243 subscription: Subscription,
244 ) -> anyhow::Result<SnapshotWait> {
245 let ws = self
246 .ws
247 .clone()
248 .with_context(|| format!("{} is not connected", self.client_id))?;
249 let spawner = self.session_tasks.spawner()?;
250 let shutdown = spawner.cancellation_token();
251 let tracker = self.books.sync.clone();
252 let ohlcv = Arc::clone(&self.ohlcv);
253 let timeout = self.book_snapshot_timeout();
254
255 let gate = SnapshotGate::default();
256 gate.lock().close();
257 let cancel = tracker.record_subscription(
258 instrument_id,
259 subscription,
260 time::Instant::now(),
261 gate.clone(),
262 );
263 let guard = cancel.clone().drop_guard();
264 let (written_tx, written_rx) = tokio::sync::oneshot::channel();
265
266 let task = {
267 let spawner = spawner.clone();
268
269 async move {
270 let _guard = guard;
271
272 tokio::select! {
273 biased;
274 () = shutdown.cancelled() => return,
275 () = cancel.cancelled() => return,
276 written = written_rx => {
277 if written.is_err() {
278 return;
279 }
280 }
281 }
282
283 tokio::select! {
284 biased;
285 () = shutdown.cancelled() => return,
286 expired = snapshot_expired(&cancel, timeout) => {
287 if !expired {
288 return;
289 }
290 }
291 }
292
293 log::warn!(
294 "Initial book snapshot missing for {instrument_id}; requesting a fresh snapshot"
295 );
296
297 if let Some((recovery, subscription)) =
298 tracker.claim_subscription_recovery(instrument_id, &cancel)
299 {
300 spawn_recovery_task(
301 instrument_id,
302 subscription,
303 recovery,
304 ws,
305 ohlcv,
306 timeout,
307 &spawner,
308 );
309 }
310 }
311 };
312
313 if let Err(e) = spawner.spawn(task) {
314 self.books.sync.remove(instrument_id);
315 return Err(e.into());
316 }
317
318 Ok(SnapshotWait {
319 gate,
320 written: written_tx,
321 })
322 }
323
324 fn unsubscribe_book(&self, instrument_id: InstrumentId, depth: bool) -> anyhow::Result<()> {
326 let _write = self.books.writes.lock();
327
328 let Some(registration) = self
329 .books
330 .entries
331 .get_cloned(&instrument_id)
332 .filter(|registration| registration.depth.is_some() == depth)
333 else {
334 return Ok(());
335 };
336
337 let (commands, subscription) =
338 self.subscription(instrument_id, channel(registration.data_type, None))?;
339
340 self.books.sync.remove(instrument_id);
342 self.books.entries.remove(&instrument_id);
343
344 send_subscription(
345 &commands,
346 ScopeChange::Unsubscribe(subscription),
347 None,
348 None,
349 )
350 }
351
352 fn book_snapshot_timeout(&self) -> Duration {
353 Duration::from_secs(self.config.book_snapshot_timeout_secs)
354 }
355
356 fn subscribe_channel(
357 &self,
358 instrument_id: InstrumentId,
359 channel: Channel,
360 ) -> anyhow::Result<()> {
361 let (commands, subscription) = self.subscription(instrument_id, channel)?;
362
363 if let Some(period_id) = channel.period_id {
365 self.ohlcv
366 .lock()
367 .remove(&(subscription.symbol_id, period_id));
368 }
369
370 send_subscription(&commands, ScopeChange::Subscribe(subscription), None, None)
371 }
372
373 fn unsubscribe_channel(
374 &self,
375 instrument_id: InstrumentId,
376 channel: Channel,
377 ) -> anyhow::Result<()> {
378 let (commands, subscription) = self.subscription(instrument_id, channel)?;
379 send_subscription(
380 &commands,
381 ScopeChange::Unsubscribe(subscription),
382 None,
383 None,
384 )
385 }
386
387 fn subscription(
388 &self,
389 instrument_id: InstrumentId,
390 channel: Channel,
391 ) -> anyhow::Result<(SubscriptionSender, Subscription)> {
392 let commands = self
393 .subscription_tx
394 .clone()
395 .with_context(|| format!("{} is not connected", self.client_id))?;
396 let symbol_id = self
397 .instruments
398 .stream_symbol_id(instrument_id)
399 .with_context(|| format!("{instrument_id} is not a loaded CoinAPI instrument"))?;
400
401 Ok((commands, Subscription { channel, symbol_id }))
402 }
403
404 fn history(
406 &self,
407 instrument_id: InstrumentId,
408 start: Option<Timestamp>,
409 end: Option<Timestamp>,
410 limit: Option<NonZeroUsize>,
411 ) -> anyhow::Result<(Ustr, InstrumentAny, HistoryRange)> {
412 let (symbol_id, instrument) = self.loaded(instrument_id)?;
413 let time_start = start.context("CoinAPI historical requests require a start time")?;
414 let time_end = end.unwrap_or_else(|| self.clock.get_time_ns().to_datetime_utc());
415
416 let range = HistoryRange {
417 time_start,
418 time_end,
419 limit,
420 };
421
422 Ok((symbol_id, instrument, range))
423 }
424
425 fn loaded(&self, instrument_id: InstrumentId) -> anyhow::Result<(Ustr, InstrumentAny)> {
426 self.instruments
427 .loaded(instrument_id)
428 .with_context(|| format!("{instrument_id} is not a loaded CoinAPI instrument"))
429 }
430
431 fn spawn_command<F>(&self, description: &'static str, future: F)
432 where
433 F: Future<Output = anyhow::Result<()>> + Send + 'static,
434 {
435 let cancellation = self.command_tasks.cancellation_token();
436
437 let future = async move {
438 tokio::select! {
439 biased;
440 () = cancellation.cancelled() => {}
441 result = future => {
442 if let Err(e) = result {
443 log::error!("{description} failed: {e:#}");
444 }
445 }
446 }
447 };
448
449 if let Err(e) = self.command_tasks.spawn(future) {
450 log::warn!("Skipping {description} after shutdown began: {e}");
451 }
452 }
453
454 fn begin_shutdown(&mut self) {
455 self.session_tasks.begin_shutdown();
456 self.command_tasks.begin_shutdown();
457 self.is_connected = false;
458 }
459
460 async fn finish_shutdown(&self) -> anyhow::Result<()> {
461 let (session, command) = tokio::join!(
462 self.session_tasks.finish_shutdown(TASK_GRACE, TASK_FORCE),
463 self.command_tasks.finish_shutdown(TASK_GRACE, TASK_FORCE),
464 );
465 session.map_err(|e| anyhow::anyhow!("Failed to stop CoinAPI stream task: {e}"))?;
466 command.map_err(|e| anyhow::anyhow!("Failed to stop CoinAPI command tasks: {e}"))?;
467 Ok(())
468 }
469}
470
471#[async_trait(?Send)]
472impl DataClient for CoinApiDataClient {
473 fn client_id(&self) -> ClientId {
474 self.client_id
475 }
476
477 fn venue(&self) -> Option<Venue> {
478 None
479 }
480
481 fn start(&mut self) -> anyhow::Result<()> {
482 log::debug!("Starting {}", self.client_id);
483 Ok(())
484 }
485
486 fn stop(&mut self) -> anyhow::Result<()> {
487 log::debug!("Stopping {}", self.client_id);
488 self.begin_shutdown();
489
490 self.ws = None;
492 self.subscription_tx = None;
493 self.books.clear();
494 Ok(())
495 }
496
497 fn reset(&mut self) -> anyhow::Result<()> {
498 log::debug!("Resetting {}", self.client_id);
499 self.begin_shutdown();
500 self.ws = None;
501 self.subscription_tx = None;
502 self.books.clear();
503 self.ohlcv.lock().clear();
504 Ok(())
505 }
506
507 fn dispose(&mut self) -> anyhow::Result<()> {
508 log::debug!("Disposing {}", self.client_id);
509 self.begin_shutdown();
510 self.ws = None;
511 self.subscription_tx = None;
512 self.books.clear();
513 Ok(())
514 }
515
516 fn is_connected(&self) -> bool {
517 self.is_connected
518 }
519
520 fn is_disconnected(&self) -> bool {
521 !self.is_connected
522 }
523
524 async fn connect(&mut self) -> anyhow::Result<()> {
525 if self.is_connected {
526 return Ok(());
527 }
528
529 log::info!("Connecting {}", self.client_id);
530
531 if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
532 self.begin_shutdown();
533 self.finish_shutdown().await?;
534 self.session_tasks
535 .start_generation()
536 .map_err(|e| anyhow::anyhow!("Failed to reopen CoinAPI stream tasks: {e}"))?;
537 self.command_tasks
538 .start_generation()
539 .map_err(|e| anyhow::anyhow!("Failed to reopen CoinAPI command tasks: {e}"))?;
540 }
541
542 let loaded = load_instruments(
543 &self.http,
544 &self.config.instruments,
545 &self.config.price_precisions,
546 self.clock.get_time_ns(),
547 )
548 .await
549 .context("Failed to load CoinAPI instruments")?;
550
551 self.instruments.begin_stream(&loaded);
552
553 for instrument in loaded {
554 if let Err(e) = self
555 .data_sender
556 .send(DataEvent::Data(instrument.instrument.into()))
557 {
558 log::warn!("Failed to send instrument: {e}");
559 }
560 }
561
562 let (mut ws, rx) =
563 CoinApiWebSocketClient::new(self.api_key.clone(), self.config.websocket_config())?;
564
565 self.books = Arc::new(BookSubscriptions {
566 sync: self.books.sync.clone(),
567 ..BookSubscriptions::default()
568 });
569
570 ws.bind_books(Arc::clone(&self.books));
571 let ws = Arc::new(ws);
572
573 let books = BookStreams {
574 subscriptions: Arc::clone(&self.books),
575 ws: Arc::clone(&ws),
576 tasks: self.session_tasks.spawner()?,
577 snapshot_timeout: self.book_snapshot_timeout(),
578 };
579
580 self.session_tasks.spawn(run_stream(
581 rx,
582 self.session_tasks.cancellation_token(),
583 self.data_sender.clone(),
584 Arc::clone(&self.instruments),
585 books,
586 Arc::clone(&self.ohlcv),
587 self.clock,
588 ))?;
589
590 let (subscription_tx, subscription_rx) = tokio::sync::mpsc::unbounded_channel();
591
592 self.session_tasks.spawn(run_subscriptions(
593 Arc::clone(&ws),
594 subscription_rx,
595 self.session_tasks.cancellation_token(),
596 ))?;
597
598 self.ws = Some(ws);
599 self.subscription_tx = Some(subscription_tx);
600 self.is_connected = true;
601 log::info!("Connected {}", self.client_id);
602 Ok(())
603 }
604
605 async fn disconnect(&mut self) -> anyhow::Result<()> {
606 log::info!("Disconnecting {}", self.client_id);
607 self.begin_shutdown();
608
609 let result = self.finish_shutdown().await;
611
612 self.subscription_tx = None;
613
614 if let Some(ws) = self.ws.take() {
615 ws.close().await;
616 }
617
618 self.books.clear();
619 self.ohlcv.lock().clear();
620
621 log::info!("Disconnected {}", self.client_id);
622 result
623 }
624
625 fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
626 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
627 let cmd = cmd.with_instrument_ids(instrument_id);
628
629 let data_type = match cmd.book_type {
630 BookType::L2_MBP => DataType::Book,
631 BookType::L3_MBO => DataType::BookL3,
632 BookType::L1_MBP => anyhow::bail!("CoinAPI streams L2_MBP and L3_MBO book deltas"),
633 };
634
635 self.subscribe_book(instrument_id, data_type, None)
637 })
638 }
639
640 fn subscribe_book_depth(&mut self, cmd: SubscribeBookDepth) -> anyhow::Result<()> {
641 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
642 let cmd = cmd.with_instrument_ids(instrument_id);
643 anyhow::ensure!(
644 cmd.book_type == BookType::L2_MBP,
645 "CoinAPI depth snapshots are L2_MBP, was {}",
646 cmd.book_type
647 );
648
649 let depth = cmd.depth.map_or(DEPTH10_LEN, NonZeroUsize::get);
650 let (data_type, _) = BOOK_DEPTH_CHANNELS
651 .into_iter()
652 .find(|(_, levels)| depth <= *levels)
653 .context(format!(
654 "CoinAPI depth snapshots list at most 50 levels, was {depth}"
655 ))?;
656
657 self.subscribe_book(instrument_id, data_type, Some(depth))
658 })
659 }
660
661 fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
662 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
663 self.subscribe_channel(instrument_id, channel(DataType::Quote, None))
664 })
665 }
666
667 fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
668 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
669 self.subscribe_channel(instrument_id, channel(DataType::Trade, None))
670 })
671 }
672
673 fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
674 execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
675 anyhow::ensure!(
676 bar_type.aggregation_source() == AggregationSource::External,
677 "CoinAPI streams only external bars, was {bar_type}"
678 );
679
680 let period_id = bar_spec_to_period_id(bar_type.spec())?;
681 self.subscribe_channel(
682 bar_type.instrument_id(),
683 channel(DataType::Ohlcv, Some(period_id)),
684 )
685 })
686 }
687
688 fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
689 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
690 self.unsubscribe_book(instrument_id, false)
691 })
692 }
693
694 fn unsubscribe_book_depth(&mut self, cmd: &UnsubscribeBookDepth) -> anyhow::Result<()> {
695 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
696 self.unsubscribe_book(instrument_id, true)
697 })
698 }
699
700 fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
701 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
702 self.unsubscribe_channel(instrument_id, channel(DataType::Quote, None))
703 })
704 }
705
706 fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
707 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
708 self.unsubscribe_channel(instrument_id, channel(DataType::Trade, None))
709 })
710 }
711
712 fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
713 execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
714 let period_id = bar_spec_to_period_id(bar_type.spec())?;
715 self.unsubscribe_channel(
716 bar_type.instrument_id(),
717 channel(DataType::Ohlcv, Some(period_id)),
718 )
719 })
720 }
721
722 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
723 let selections = select_instruments(&self.config.instruments, |instrument_id| {
724 request
725 .venue
726 .is_none_or(|venue| instrument_id.venue == venue)
727 });
728
729 let http = Arc::clone(&self.http);
730 let price_precisions = self.config.price_precisions.clone();
731 let index = Arc::clone(&self.instruments);
732 let sender = self.data_sender.clone();
733 let client_id = request.client_id.unwrap_or(self.client_id);
734
735 let venue = request.venue.unwrap_or_else(|| Venue::new(COINAPI));
737 let start = datetime_to_unix_nanos(request.start);
738 let end = datetime_to_unix_nanos(request.end);
739 let clock = self.clock;
740
741 self.spawn_command("CoinAPI instruments request", async move {
742 let loaded =
743 load_instruments(&http, &selections, &price_precisions, clock.get_time_ns())
744 .await?;
745
746 index.extend(&loaded);
747 let instruments = loaded
748 .iter()
749 .map(|instrument| instrument.instrument.clone())
750 .collect();
751
752 let response = DataResponse::Instruments(InstrumentsResponse::new(
753 request.request_id,
754 client_id,
755 venue,
756 instruments,
757 start,
758 end,
759 clock.get_time_ns(),
760 request.params,
761 ));
762 send_response(&sender, response);
763 Ok(())
764 });
765
766 Ok(())
767 }
768
769 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
770 let instrument_id = request.single_instrument_id()?;
771 let selections = select_instruments(&self.config.instruments, |candidate| {
772 *candidate == instrument_id
773 });
774
775 anyhow::ensure!(
776 !selections.is_empty(),
777 "{instrument_id} is not a configured CoinAPI instrument"
778 );
779
780 let http = Arc::clone(&self.http);
781 let price_precisions = self.config.price_precisions.clone();
782 let index = Arc::clone(&self.instruments);
783 let sender = self.data_sender.clone();
784 let client_id = request.client_id.unwrap_or(self.client_id);
785 let start = datetime_to_unix_nanos(request.start);
786 let end = datetime_to_unix_nanos(request.end);
787 let clock = self.clock;
788
789 self.spawn_command("CoinAPI instrument request", async move {
790 let loaded =
791 load_instruments(&http, &selections, &price_precisions, clock.get_time_ns())
792 .await?;
793 let instrument = loaded
794 .into_iter()
795 .next()
796 .context("CoinAPI instrument load returned nothing")?;
797 index.insert(&instrument);
798
799 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
800 request.request_id,
801 client_id,
802 instrument_id,
803 instrument.instrument,
804 start,
805 end,
806 clock.get_time_ns(),
807 request.params,
808 )));
809 send_response(&sender, response);
810 Ok(())
811 });
812
813 Ok(())
814 }
815
816 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
817 let instrument_id = request.single_instrument_id()?;
818
819 let (symbol_id, instrument) = self.loaded(instrument_id)?;
820
821 let query = BookQuery {
822 limit_levels: request
823 .depth
824 .map(|depth| u32::try_from(depth.get()).unwrap_or(u32::MAX)),
825 };
826
827 let http = Arc::clone(&self.http);
828 let sender = self.data_sender.clone();
829 let client_id = request.client_id.unwrap_or(self.client_id);
830 let clock = self.clock;
831
832 self.spawn_command("CoinAPI book snapshot request", async move {
833 let book = http.get_book(&symbol_id, &query).await?;
834 let book = http_parse::parse_order_book(&book, &instrument)
835 .with_context(|| format!("Invalid CoinAPI book for {symbol_id}"))?;
836
837 let response = DataResponse::Book(BookResponse::new(
838 request.request_id,
839 client_id,
840 instrument_id,
841 book,
842 None,
843 None,
844 clock.get_time_ns(),
845 request.params,
846 ));
847 send_response(&sender, response);
848 Ok(())
849 });
850
851 Ok(())
852 }
853
854 fn request_quotes(&self, request: RequestQuotes) -> anyhow::Result<()> {
855 let instrument_id = request.single_instrument_id()?;
856
857 let (symbol_id, instrument, range) =
858 self.history(instrument_id, request.start, request.end, request.limit)?;
859
860 let http = Arc::clone(&self.http);
861 let sender = self.data_sender.clone();
862 let client_id = request.client_id.unwrap_or(self.client_id);
863 let start = datetime_to_unix_nanos(request.start);
864 let end = datetime_to_unix_nanos(request.end);
865 let clock = self.clock;
866
867 self.spawn_command("CoinAPI quotes request", async move {
868 let pages = http.get_quotes_range(&symbol_id, &range).await?;
869
870 let quotes = parse_history(&pages, "quote", symbol_id, |quote| {
871 http_parse::parse_quote_tick(quote, &instrument)
872 });
873
874 let response = DataResponse::Quotes(QuotesResponse::new(
875 request.request_id,
876 client_id,
877 instrument_id,
878 quotes,
879 start,
880 end,
881 clock.get_time_ns(),
882 request.params,
883 ));
884 send_response(&sender, response);
885 Ok(())
886 });
887
888 Ok(())
889 }
890
891 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
892 let instrument_id = request.single_instrument_id()?;
893
894 let (symbol_id, instrument, range) =
895 self.history(instrument_id, request.start, request.end, request.limit)?;
896
897 let http = Arc::clone(&self.http);
898 let sender = self.data_sender.clone();
899 let client_id = request.client_id.unwrap_or(self.client_id);
900 let start = datetime_to_unix_nanos(request.start);
901 let end = datetime_to_unix_nanos(request.end);
902 let clock = self.clock;
903
904 self.spawn_command("CoinAPI trades request", async move {
905 let pages = http.get_trades_range(&symbol_id, &range).await?;
906
907 let trades = parse_history(&pages, "trade", symbol_id, |trade| {
908 http_parse::parse_trade_tick(trade, &instrument)
909 });
910
911 let response = DataResponse::Trades(TradesResponse::new(
912 request.request_id,
913 client_id,
914 instrument_id,
915 trades,
916 start,
917 end,
918 clock.get_time_ns(),
919 request.params,
920 ));
921 send_response(&sender, response);
922 Ok(())
923 });
924
925 Ok(())
926 }
927
928 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
929 let bar_type = request.single_bar_type()?;
930
931 anyhow::ensure!(
932 bar_type.aggregation_source() == AggregationSource::External,
933 "CoinAPI provides only external bars, was {bar_type}"
934 );
935
936 let period_id = bar_spec_to_period_id(bar_type.spec())?;
937 let (symbol_id, instrument, range) = self.history(
938 bar_type.instrument_id(),
939 request.start,
940 request.end,
941 request.limit,
942 )?;
943
944 let http = Arc::clone(&self.http);
945 let sender = self.data_sender.clone();
946 let client_id = request.client_id.unwrap_or(self.client_id);
947 let start = datetime_to_unix_nanos(request.start);
948 let end = datetime_to_unix_nanos(request.end);
949 let clock = self.clock;
950
951 self.spawn_command("CoinAPI bars request", async move {
952 let mut pages = http.get_ohlcv_range(&symbol_id, &period_id, &range).await?;
953
954 let closed_by = range.time_end.min(clock.get_time_ns().to_datetime_utc());
956 pages
957 .records
958 .retain(|ohlcv| ohlcv.time_period_end <= closed_by);
959
960 let bars = parse_history(&pages, "OHLCV period", symbol_id, |ohlcv| {
961 http_parse::parse_bar(ohlcv, bar_type, &instrument)
962 });
963
964 let response = DataResponse::Bars(BarsResponse::new(
965 request.request_id,
966 client_id,
967 bar_type,
968 bars,
969 start,
970 end,
971 clock.get_time_ns(),
972 request.params,
973 ));
974 send_response(&sender, response);
975 Ok(())
976 });
977
978 Ok(())
979 }
980}
981
982type SubscriptionSender = tokio::sync::mpsc::UnboundedSender<SubscriptionCommand>;
985
986#[derive(Debug)]
987struct SubscriptionCommand {
988 change: ScopeChange,
989 snapshot: Option<SnapshotWait>,
990 book_active: Option<Arc<AtomicBool>>,
991}
992
993#[derive(Debug)]
995struct SnapshotWait {
996 gate: SnapshotGate,
997 written: tokio::sync::oneshot::Sender<()>,
998}
999
1000fn send_subscription(
1001 commands: &SubscriptionSender,
1002 change: ScopeChange,
1003 snapshot: Option<SnapshotWait>,
1004 book_active: Option<Arc<AtomicBool>>,
1005) -> anyhow::Result<()> {
1006 commands
1007 .send(SubscriptionCommand {
1008 change,
1009 snapshot,
1010 book_active,
1011 })
1012 .map_err(|_| anyhow::anyhow!("CoinAPI subscription task has stopped"))
1013}
1014
1015async fn run_subscriptions(
1018 ws: Arc<CoinApiWebSocketClient>,
1019 mut commands: tokio::sync::mpsc::UnboundedReceiver<SubscriptionCommand>,
1020 cancellation: CancellationToken,
1021) {
1022 loop {
1023 let first = tokio::select! {
1024 biased;
1025 () = cancellation.cancelled() => break,
1026 command = commands.recv() => match command {
1027 Some(command) => command,
1028 None => break,
1029 },
1030 };
1031
1032 let mut batch = vec![first];
1033 while let Ok(command) = commands.try_recv() {
1034 batch.push(command);
1035 }
1036
1037 let changes: Vec<ScopeChange> = batch.iter().map(|command| command.change).collect();
1038 let registrations: Vec<_> = batch
1039 .iter()
1040 .map(|command| command.book_active.clone())
1041 .collect();
1042 let mut delay = SUBSCRIPTION_RETRY_DELAY_INITIAL;
1043
1044 let rejected = loop {
1046 let result = tokio::select! {
1047 biased;
1048 () = cancellation.cancelled() => return,
1049 result = ws.apply_registered(&changes, ®istrations) => result,
1050 };
1051
1052 match result {
1053 Ok(rejected) => break rejected,
1054 Err(e) => {
1055 log::warn!("CoinAPI subscription change failed, retrying in {delay:?}: {e}");
1056 tokio::select! {
1057 biased;
1058 () = cancellation.cancelled() => return,
1059 () = time::sleep(delay) => {}
1060 }
1061 delay = (delay * 2).min(SUBSCRIPTION_RETRY_DELAY_MAX);
1062 }
1063 }
1064 };
1065
1066 for (change, e) in &rejected {
1067 log::error!("CoinAPI rejected {change:?}: {e}");
1068 }
1069
1070 for command in batch {
1071 let Some(snapshot) = command.snapshot else {
1072 continue;
1073 };
1074
1075 if rejected
1076 .iter()
1077 .all(|(rejected, _)| *rejected != command.change)
1078 {
1079 snapshot.gate.open();
1080 let _ = snapshot.written.send(());
1081 }
1082 }
1083 }
1084}
1085
1086#[derive(Debug)]
1088struct BookStreams {
1089 subscriptions: Arc<BookSubscriptions>,
1090 ws: Arc<CoinApiWebSocketClient>,
1091 tasks: TaskSpawner,
1092 snapshot_timeout: Duration,
1093}
1094
1095pub(crate) type OhlcvCache = AHashMap<(Ustr, Ustr), Ohlcv>;
1097
1098#[derive(Debug, Default)]
1099struct InstrumentIndex {
1100 state: ArcSwap<InstrumentIndexState>,
1101}
1102
1103#[derive(Debug, Clone, Default)]
1104struct InstrumentIndexState {
1105 by_symbol_id: AHashMap<Ustr, InstrumentAny>,
1106 symbol_ids: AHashMap<InstrumentId, Ustr>,
1107 stream_symbol_ids: AHashMap<InstrumentId, Ustr>,
1108}
1109
1110impl InstrumentIndex {
1111 fn insert(&self, loaded: &CoinApiInstrument) {
1112 self.extend(std::slice::from_ref(loaded));
1113 }
1114
1115 fn extend(&self, loaded: &[CoinApiInstrument]) {
1116 if loaded.is_empty() {
1117 return;
1118 }
1119
1120 self.state.rcu(|state| {
1121 let mut next = (**state).clone();
1122 for instrument in loaded {
1123 next.insert(instrument);
1124 }
1125
1126 Arc::new(next)
1127 });
1128 }
1129
1130 fn begin_stream(&self, loaded: &[CoinApiInstrument]) {
1131 let mut state = InstrumentIndexState::default();
1132 for instrument in loaded {
1133 state
1134 .stream_symbol_ids
1135 .insert(instrument.instrument.id(), instrument.symbol_id);
1136 state.insert(instrument);
1137 }
1138
1139 self.state.store(Arc::new(state));
1140 }
1141
1142 fn stream_symbol_id(&self, instrument_id: InstrumentId) -> Option<Ustr> {
1143 self.state
1144 .load()
1145 .stream_symbol_ids
1146 .get(&instrument_id)
1147 .copied()
1148 }
1149
1150 fn loaded(&self, instrument_id: InstrumentId) -> Option<(Ustr, InstrumentAny)> {
1151 let state = self.state.load();
1152 let symbol_id = *state.symbol_ids.get(&instrument_id)?;
1153 Some((symbol_id, state.by_symbol_id.get(&symbol_id)?.clone()))
1154 }
1155
1156 fn instrument(&self, symbol_id: Ustr) -> Option<InstrumentAny> {
1157 self.state.load().by_symbol_id.get(&symbol_id).cloned()
1158 }
1159}
1160
1161impl InstrumentIndexState {
1162 fn insert(&mut self, loaded: &CoinApiInstrument) {
1163 let instrument_id = loaded.instrument.id();
1164 let stream_symbol_id = self.stream_symbol_ids.get(&instrument_id).copied();
1165
1166 if let Some(previous) = self.symbol_ids.get(&instrument_id)
1167 && *previous != loaded.symbol_id
1168 && Some(*previous) != stream_symbol_id
1169 {
1170 self.by_symbol_id.remove(previous);
1171 }
1172
1173 self.by_symbol_id
1174 .insert(loaded.symbol_id, loaded.instrument.clone());
1175 self.symbol_ids.insert(instrument_id, loaded.symbol_id);
1176
1177 if let Some(symbol_id) = stream_symbol_id
1178 && symbol_id != loaded.symbol_id
1179 {
1180 self.by_symbol_id
1181 .insert(symbol_id, loaded.instrument.clone());
1182 }
1183 }
1184}
1185
1186fn validate_price_precisions(
1187 selections: &[CoinApiInstrumentSelection],
1188 price_precisions: &HashMap<InstrumentId, u8>,
1189) -> anyhow::Result<()> {
1190 for (instrument_id, precision) in price_precisions {
1191 anyhow::ensure!(
1192 selections
1193 .iter()
1194 .any(|selection| selection.instrument_ids.contains(instrument_id)),
1195 "price precision for {instrument_id}, which is not a selected instrument"
1196 );
1197 check_fixed_precision(*precision)
1198 .with_context(|| format!("invalid price precision for {instrument_id}"))?;
1199 }
1200
1201 Ok(())
1202}
1203
1204fn select_instruments(
1206 selections: &[CoinApiInstrumentSelection],
1207 predicate: impl Fn(&InstrumentId) -> bool,
1208) -> Vec<CoinApiInstrumentSelection> {
1209 selections
1210 .iter()
1211 .filter_map(|selection| {
1212 let instrument_ids: Vec<InstrumentId> = selection
1213 .instrument_ids
1214 .iter()
1215 .copied()
1216 .filter(&predicate)
1217 .collect();
1218
1219 (!instrument_ids.is_empty()).then(|| CoinApiInstrumentSelection {
1220 instrument_ids,
1221 ..selection.clone()
1222 })
1223 })
1224 .collect()
1225}
1226
1227async fn load_instruments(
1229 http: &CoinApiHttpClient,
1230 selections: &[CoinApiInstrumentSelection],
1231 price_precisions: &HashMap<InstrumentId, u8>,
1232 ts_init: UnixNanos,
1233) -> anyhow::Result<Vec<CoinApiInstrument>> {
1234 let mut selected = Vec::new();
1235
1236 for selection in selections {
1237 let loaded = http
1238 .request_instruments(
1239 &selection.exchange_id,
1240 &selection.query,
1241 price_precisions,
1242 ts_init,
1243 )
1244 .await?;
1245
1246 for instrument_id in &selection.instrument_ids {
1247 let instrument = loaded
1248 .iter()
1249 .find(|instrument| instrument.instrument.id() == *instrument_id)
1250 .with_context(|| {
1251 format!(
1252 "CoinAPI {} returned no instrument {instrument_id}",
1253 selection.exchange_id
1254 )
1255 })?;
1256
1257 selected.push(instrument.clone());
1258 }
1259 }
1260
1261 Ok(selected)
1262}
1263
1264fn channel(data_type: DataType, period_id: Option<Ustr>) -> Channel {
1265 Channel {
1266 data_type,
1267 period_id,
1268 }
1269}
1270
1271fn send_response(sender: &EventSender<DataEvent>, response: DataResponse) {
1272 if let Err(e) = sender.send(DataEvent::Response(response)) {
1273 log::error!("Failed to send CoinAPI response: {e}");
1274 }
1275}
1276
1277fn parse_history<T, D>(
1279 pages: &HistoryPages<T>,
1280 kind: &str,
1281 symbol_id: Ustr,
1282 parse: impl Fn(&T) -> anyhow::Result<D>,
1283) -> Vec<D> {
1284 let total = pages.records.len();
1285
1286 if pages.is_truncated {
1287 log::warn!(
1288 "CoinAPI credit budget truncated the {kind} history for {symbol_id} after {total} records"
1289 );
1290 }
1291
1292 let mut first_error = None;
1293
1294 let parsed: Vec<D> = pages
1295 .records
1296 .iter()
1297 .filter_map(|record| {
1298 parse(record)
1299 .map_err(|e| {
1300 first_error.get_or_insert(e);
1301 })
1302 .ok()
1303 })
1304 .collect();
1305
1306 if let Some(e) = first_error {
1307 let dropped = total - parsed.len();
1308 log::warn!(
1309 "Dropped {dropped} of {total} CoinAPI {kind} records for {symbol_id}, first failure: {e:#}"
1310 );
1311 }
1312
1313 parsed
1314}
1315
1316async fn run_stream(
1317 mut rx: tokio::sync::mpsc::UnboundedReceiver<CoinApiWsMessage>,
1318 cancellation: CancellationToken,
1319 sender: EventSender<DataEvent>,
1320 instruments: Arc<InstrumentIndex>,
1321 books: BookStreams,
1322 ohlcv: Arc<Mutex<OhlcvCache>>,
1323 clock: &'static AtomicTime,
1324) {
1325 loop {
1326 let message = tokio::select! {
1327 biased;
1328 () = cancellation.cancelled() => break,
1329 message = rx.recv() => match message {
1330 Some(message) => message,
1331 None => break,
1332 },
1333 };
1334
1335 handle_message(message, &sender, &instruments, &books, &ohlcv, clock);
1336 }
1337}
1338
1339fn handle_message(
1340 message: CoinApiWsMessage,
1341 sender: &EventSender<DataEvent>,
1342 instruments: &InstrumentIndex,
1343 books: &BookStreams,
1344 ohlcv: &Arc<Mutex<OhlcvCache>>,
1345 clock: &AtomicTime,
1346) {
1347 let (symbol_id, kind, parsed) = match message {
1348 CoinApiWsMessage::Book(book) => {
1349 let Some(instrument) = instruments.instrument(book.symbol_id) else {
1350 return log_unknown_symbol(book.symbol_id);
1351 };
1352
1353 if book.data_type == DataType::Book {
1354 return handle_book_frame(
1355 BookFrame::Levels(&book),
1356 &instrument,
1357 books,
1358 ohlcv,
1359 sender,
1360 );
1361 }
1362
1363 let Some(depth) = books
1364 .subscriptions
1365 .entries
1366 .load()
1367 .get(&instrument.id())
1368 .and_then(|registration| registration.depth)
1369 else {
1370 return;
1371 };
1372
1373 let parsed = parse_book_depth(&book, depth, &instrument).map(|mut depth| {
1374 depth.sequence = books
1375 .subscriptions
1376 .sync
1377 .depth_sequence(instrument.id(), book.sequence);
1378 Data::BookDepth(Box::new(depth))
1379 });
1380
1381 (book.symbol_id, "book depth", parsed)
1382 }
1383 CoinApiWsMessage::BookL3(book) => {
1384 let Some(instrument) = instruments.instrument(book.symbol_id) else {
1385 return log_unknown_symbol(book.symbol_id);
1386 };
1387
1388 return handle_book_frame(BookFrame::Orders(&book), &instrument, books, ohlcv, sender);
1389 }
1390 CoinApiWsMessage::Quote(quote) => {
1391 let Some(instrument) = instruments.instrument(quote.symbol_id) else {
1392 return log_unknown_symbol(quote.symbol_id);
1393 };
1394
1395 let parsed = parse_quote_tick("e, &instrument).map(Data::Quote);
1396 (quote.symbol_id, "quote", parsed)
1397 }
1398 CoinApiWsMessage::Trade(trade) => {
1399 let Some(instrument) = instruments.instrument(trade.symbol_id) else {
1400 return log_unknown_symbol(trade.symbol_id);
1401 };
1402
1403 let parsed = parse_trade_tick(&trade, &instrument).map(Data::Trade);
1404 (trade.symbol_id, "trade", parsed)
1405 }
1406 CoinApiWsMessage::Ohlcv(update) => {
1407 let Some(closed) = close_period(&mut ohlcv.lock(), update) else {
1408 return;
1409 };
1410
1411 let Some(instrument) = instruments.instrument(closed.symbol_id) else {
1412 return log_unknown_symbol(closed.symbol_id);
1413 };
1414
1415 let parsed = parse_bar(&closed, &instrument, clock.get_time_ns()).map(Data::Bar);
1416 (closed.symbol_id, "OHLCV period", parsed)
1417 }
1418 CoinApiWsMessage::Error(message) => {
1419 log::error!("CoinAPI retired the stream and its subscriptions: {message}");
1420 return;
1421 }
1422 CoinApiWsMessage::Reconnected => {
1423 ohlcv.lock().clear();
1425 reset_books(books, ohlcv);
1426 log::info!("CoinAPI stream reconnected and replayed its subscriptions");
1427 return;
1428 }
1429 };
1430
1431 match parsed {
1432 Ok(data) => {
1433 if let Err(e) = sender.send(DataEvent::Data(data)) {
1434 log::error!("Failed to send CoinAPI data: {e}");
1435 }
1436 }
1437 Err(e) => log::warn!("Dropping CoinAPI {kind} for {symbol_id}: {e:#}"),
1438 }
1439}
1440
1441fn handle_book_frame(
1442 frame: BookFrame<'_>,
1443 instrument: &InstrumentAny,
1444 books: &BookStreams,
1445 ohlcv: &Arc<Mutex<OhlcvCache>>,
1446 sender: &EventSender<DataEvent>,
1447) {
1448 let instrument_id = instrument.id();
1449
1450 match books
1451 .subscriptions
1452 .sync
1453 .apply(instrument_id, frame, instrument, time::Instant::now())
1454 {
1455 (BookSequenceOutcome::Accept, Some(deltas)) => {
1456 if let Err(e) = sender.send(DataEvent::Data(Data::BookDeltas(Box::new(deltas)))) {
1457 log::error!("Failed to send CoinAPI data: {e}");
1458 }
1459 }
1460 (BookSequenceOutcome::Recover, _) => start_recovery(
1461 instrument_id,
1462 &books.subscriptions.sync,
1463 &books.ws,
1464 ohlcv,
1465 books.snapshot_timeout,
1466 &books.tasks,
1467 ),
1468 _ => {}
1469 }
1470}
1471
1472fn reset_books(books: &BookStreams, ohlcv: &Arc<Mutex<OhlcvCache>>) {
1474 let deadline =
1475 (!books.snapshot_timeout.is_zero()).then(|| time::Instant::now() + books.snapshot_timeout);
1476
1477 if books.subscriptions.sync.reset_on_reconnect(deadline) > 0 {
1478 spawn_recovery_monitor(
1479 books.subscriptions.sync.clone(),
1480 Arc::clone(&books.ws),
1481 Arc::clone(ohlcv),
1482 books.snapshot_timeout,
1483 &books.tasks,
1484 );
1485 }
1486}
1487
1488fn log_unknown_symbol(symbol_id: Ustr) {
1489 log::warn!("Dropping CoinAPI message for unloaded symbol {symbol_id}");
1490}
1491
1492fn close_period(cache: &mut OhlcvCache, update: Ohlcv) -> Option<Ohlcv> {
1495 let key = (update.symbol_id, update.period_id);
1496 match cache.get(&key) {
1497 Some(retained) if update.time_period_start < retained.time_period_start => None,
1498 Some(retained) if update.time_period_start == retained.time_period_start => {
1499 cache.insert(key, update);
1500 None
1501 }
1502 _ => cache.insert(key, update),
1503 }
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508 use nautilus_model::types::Price;
1509 use rstest::rstest;
1510
1511 use super::*;
1512
1513 #[cfg(not(all(feature = "simulation", madsim)))]
1514 #[rstest]
1515 #[case::stop(DataClient::stop, false)]
1516 #[case::reset(DataClient::reset, true)]
1517 #[case::dispose(DataClient::dispose, false)]
1518 #[tokio::test]
1519 async fn shutdown_cancels_tasks_and_clears_book_state(
1520 #[case] shutdown: fn(&mut CoinApiDataClient) -> anyhow::Result<()>,
1521 #[case] clears_bars: bool,
1522 ) {
1523 let (sender, _events) = tokio::sync::mpsc::unbounded_channel();
1524 nautilus_common::live::runner::replace_data_event_sender(sender);
1525
1526 let mut client = CoinApiDataClient::new(
1527 ClientId::from("COINAPI"),
1528 CoinApiDataClientConfig {
1529 api_key: Some("test-key".into()),
1530 ..CoinApiDataClientConfig::default()
1531 },
1532 )
1533 .unwrap();
1534
1535 let instrument_id = InstrumentId::from("BTC-USD.COINBASE");
1536
1537 let subscription = Subscription {
1538 channel: channel(DataType::Book, None),
1539 symbol_id: "COINBASE_SPOT_BTC_USD".into(),
1540 };
1541
1542 let symbols: Vec<crate::http::models::Symbol> =
1543 serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1544 let (loaded, _) = http_parse::parse_instruments(&symbols, UnixNanos::default());
1545 let instrument = loaded
1546 .into_iter()
1547 .find(|loaded| loaded.instrument.id() == instrument_id)
1548 .unwrap()
1549 .instrument;
1550 let mut frame: crate::websocket::messages::Book =
1551 serde_json::from_str(include_str!("../test_data/ws_book.json")).unwrap();
1552 frame.sequence = 100;
1553 frame.is_snapshot = Some(true);
1554 let gate = SnapshotGate::default();
1555 gate.open();
1556 let book_cancel = client.books.sync.record_subscription(
1557 instrument_id,
1558 subscription,
1559 time::Instant::now(),
1560 gate.clone(),
1561 );
1562 client.books.entries.insert(
1563 instrument_id,
1564 BookSubscription {
1565 data_type: DataType::Book,
1566 depth: None,
1567 active: Arc::new(AtomicBool::new(false)),
1568 },
1569 );
1570
1571 let (outcome, deltas) = client.books.sync.apply(
1572 instrument_id,
1573 BookFrame::Levels(&frame),
1574 &instrument,
1575 time::Instant::now(),
1576 );
1577 assert_eq!(outcome, BookSequenceOutcome::Accept);
1578 assert_eq!(deltas.unwrap().sequence, 100);
1579 assert_eq!(client.books.sync.depth_sequence(instrument_id, 17), 17);
1580 let retained = update(1, "84523.69");
1581 let key = (retained.symbol_id, retained.period_id);
1582 client.ohlcv.lock().insert(key, retained.clone());
1583 client.is_connected = true;
1584 let (ws, _messages) =
1585 CoinApiWebSocketClient::new(client.api_key.clone(), client.config.websocket_config())
1586 .unwrap();
1587 client.ws = Some(Arc::new(ws));
1588 let (commands, mut command_receiver) = tokio::sync::mpsc::unbounded_channel();
1589 client.subscription_tx = Some(commands);
1590 let mut completions = Vec::new();
1591
1592 for group in [&client.session_tasks, &client.command_tasks] {
1593 let cancel = group.cancellation_token();
1594 let (done, completion) = tokio::sync::oneshot::channel();
1595
1596 group
1597 .spawn(async move {
1598 cancel.cancelled().await;
1599 done.send(()).unwrap();
1600 })
1601 .unwrap();
1602
1603 completions.push(completion);
1604 }
1605
1606 let retired = Arc::clone(&client.books);
1607 shutdown(&mut client).unwrap();
1608
1609 for completion in completions {
1610 tokio::time::timeout(Duration::from_secs(1), completion)
1611 .await
1612 .unwrap()
1613 .unwrap();
1614 }
1615
1616 assert!(client.is_disconnected());
1617 assert!(client.ws.is_none());
1618 assert!(client.subscription_tx.is_none());
1619 assert!(command_receiver.recv().await.is_none());
1620 assert!(client.books.entries.is_empty());
1621 assert!(book_cancel.is_cancelled());
1622 assert_eq!(client.books.sync.reset_on_reconnect(None), 0);
1623 assert!(!client.session_tasks.is_open());
1624 assert!(!client.command_tasks.is_open());
1625
1626 {
1627 let bars = client.ohlcv.lock();
1628
1629 if clears_bars {
1630 assert!(bars.is_empty());
1631 } else {
1632 assert_eq!(bars.len(), 1);
1633 assert_eq!(bars[&key], retained);
1634 }
1635 }
1636
1637 client.connect().await.unwrap();
1638 let active = Arc::new(AtomicBool::new(true));
1639 let replacement_cancel = client.books.sync.record_subscription(
1640 instrument_id,
1641 subscription,
1642 time::Instant::now(),
1643 gate,
1644 );
1645 client.books.entries.insert(
1646 instrument_id,
1647 BookSubscription {
1648 data_type: DataType::Book,
1649 depth: None,
1650 active: Arc::clone(&active),
1651 },
1652 );
1653
1654 retired.retire();
1655
1656 let replacement = client.books.entries.get_cloned(&instrument_id).unwrap();
1657 assert_eq!(replacement.data_type, DataType::Book);
1658 assert_eq!(replacement.depth, None);
1659 assert!(Arc::ptr_eq(&replacement.active, &active));
1660 assert!(
1661 replacement
1662 .active
1663 .load(std::sync::atomic::Ordering::Relaxed)
1664 );
1665 assert!(!replacement_cancel.is_cancelled());
1666 assert!(!Arc::ptr_eq(&retired, &client.books));
1667 frame.sequence = 1;
1668 let (outcome, deltas) = client.books.sync.apply(
1669 instrument_id,
1670 BookFrame::Levels(&frame),
1671 &instrument,
1672 time::Instant::now(),
1673 );
1674 assert_eq!(outcome, BookSequenceOutcome::Accept);
1675 assert_eq!(deltas.unwrap().sequence, 101);
1676 frame.sequence = 2;
1677 frame.is_snapshot = Some(false);
1678 let (outcome, deltas) = client.books.sync.apply(
1679 instrument_id,
1680 BookFrame::Levels(&frame),
1681 &instrument,
1682 time::Instant::now(),
1683 );
1684 assert_eq!(outcome, BookSequenceOutcome::Accept);
1685 assert_eq!(deltas.unwrap().sequence, 102);
1686 assert_eq!(client.books.sync.depth_sequence(instrument_id, 1), 18);
1687 client.disconnect().await.unwrap();
1688 }
1689
1690 fn update(start_second: i64, close: &str) -> Ohlcv {
1691 let mut update: Ohlcv =
1692 serde_json::from_str(include_str!("../test_data/ws_ohlcv.json")).unwrap();
1693 update.time_period_start = jiff::Timestamp::from_second(start_second).unwrap();
1694 update.time_period_end = jiff::Timestamp::from_second(start_second + 1).unwrap();
1695 update.price_close = close.parse().unwrap();
1696 update
1697 }
1698
1699 fn selection(exchange_id: &str, instrument_ids: &[&str]) -> CoinApiInstrumentSelection {
1700 CoinApiInstrumentSelection {
1701 exchange_id: exchange_id.into(),
1702 query: crate::http::query::SymbolsQuery::default(),
1703 instrument_ids: instrument_ids
1704 .iter()
1705 .map(|id| InstrumentId::from(*id))
1706 .collect(),
1707 }
1708 }
1709
1710 #[rstest]
1711 #[case(vec![], None)]
1712 #[case(
1713 vec![selection("COINBASE", &["BTC-USD.COINBASE"]), selection("BINANCEFTS", &["BTCUSDT-PERP.BINANCE"])],
1714 None
1715 )]
1716 #[case(
1717 vec![selection("COINBASE", &[])],
1718 Some("CoinAPI selection for COINBASE lists no instruments")
1719 )]
1720 #[case(
1721 vec![selection("BINANCE", &["BTCUSDT-PERP.BINANCE"])],
1722 Some("BTCUSDT-PERP.BINANCE maps to CoinAPI exchange BINANCEFTS, not BINANCE")
1723 )]
1724 #[case(
1725 vec![selection("COINBASE", &["BTC-USD.COINBASE"]), selection("COINBASE", &["BTC-USD.COINBASE"])],
1726 Some("duplicate instruments")
1727 )]
1728 #[case(
1729 vec![selection("DERIBIT", &["BTC-PERPETUAL.DERIBIT"])],
1730 Some("instrument ID BTC-PERPETUAL.DERIBIT has no CoinAPI mapping")
1731 )]
1732 fn selections_validate_mapping_and_uniqueness(
1733 #[case] selections: Vec<CoinApiInstrumentSelection>,
1734 #[case] expected: Option<&str>,
1735 ) {
1736 let config = CoinApiDataClientConfig {
1737 instruments: selections,
1738 ..Default::default()
1739 };
1740
1741 let result = config.validate();
1742
1743 assert_eq!(result.err().map(|e| e.to_string()).as_deref(), expected);
1744 }
1745
1746 #[rstest]
1747 #[case(&[("BTC-USD.COINBASE", 3)], None)]
1748 #[case(
1749 &[("ETH-USD.COINBASE", 3)],
1750 Some("price precision for ETH-USD.COINBASE, which is not a selected instrument")
1751 )]
1752 #[case(&[("BTC-USD.COINBASE", 19)], Some("invalid price precision for BTC-USD.COINBASE"))]
1753 fn price_precisions_require_selected_instruments_and_valid_precision(
1754 #[case] precisions: &[(&str, u8)],
1755 #[case] expected: Option<&str>,
1756 ) {
1757 let selections = vec![selection("COINBASE", &["BTC-USD.COINBASE"])];
1758 let precisions = precisions
1759 .iter()
1760 .map(|(id, precision)| (InstrumentId::from(*id), *precision))
1761 .collect();
1762
1763 let result = validate_price_precisions(&selections, &precisions);
1764
1765 assert_eq!(result.err().map(|e| e.to_string()).as_deref(), expected);
1766 }
1767
1768 #[rstest]
1769 fn select_instruments_narrows_and_drops_empty_selections() {
1770 let selections = vec![
1771 selection("COINBASE", &["BTC-USD.COINBASE", "ETH-USD.COINBASE"]),
1772 selection("BINANCE", &["BTCUSDT.BINANCE"]),
1773 ];
1774
1775 let selected = select_instruments(&selections, |id| {
1776 id.symbol.as_str() != "BTCUSDT" && id.symbol.as_str() != "ETH-USD"
1777 });
1778
1779 assert_eq!(selected.len(), 1);
1780 assert_eq!(selected[0].exchange_id, "COINBASE");
1781 assert_eq!(
1782 selected[0].instrument_ids,
1783 vec![InstrumentId::from("BTC-USD.COINBASE")]
1784 );
1785 }
1786
1787 #[rstest]
1788 fn instrument_index_replaces_changed_symbol_id() {
1789 let symbols: Vec<crate::http::models::Symbol> =
1790 serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1791 let (instruments, _) =
1792 crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1793 let original = instruments
1794 .into_iter()
1795 .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1796 .unwrap();
1797
1798 let suffixed = CoinApiInstrument {
1799 symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1800 ..original.clone()
1801 };
1802
1803 let index = InstrumentIndex::default();
1804
1805 index.insert(&original);
1806 index.insert(&suffixed);
1807
1808 let instrument_id = original.instrument.id();
1809 assert_eq!(
1810 index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1811 Some(suffixed.symbol_id)
1812 );
1813 assert_eq!(
1814 index.instrument(suffixed.symbol_id),
1815 Some(original.instrument)
1816 );
1817 assert_eq!(index.instrument(original.symbol_id), None);
1818 }
1819
1820 #[rstest]
1821 fn stream_symbol_survives_refresh_until_new_session() {
1822 let symbols: Vec<crate::http::models::Symbol> =
1823 serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1824 let (loaded, _) = crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1825 let original = loaded
1826 .into_iter()
1827 .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1828 .unwrap();
1829
1830 let refreshed = CoinApiInstrument {
1831 symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1832 ..original.clone()
1833 };
1834
1835 let latest = CoinApiInstrument {
1836 symbol_id: "COINBASE_SPOT_BTC_USD_22F0063".into(),
1837 ..original.clone()
1838 };
1839
1840 let index = InstrumentIndex::default();
1841 let instrument_id = original.instrument.id();
1842 index.begin_stream(std::slice::from_ref(&original));
1843 index.insert(&refreshed);
1844 index.insert(&latest);
1845
1846 assert_eq!(
1847 index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1848 Some(latest.symbol_id)
1849 );
1850 assert_eq!(
1851 index.stream_symbol_id(instrument_id),
1852 Some(original.symbol_id)
1853 );
1854 assert_eq!(
1855 index.instrument(original.symbol_id),
1856 Some(latest.instrument.clone())
1857 );
1858 assert_eq!(index.instrument(refreshed.symbol_id), None);
1859 assert_eq!(
1860 index.instrument(latest.symbol_id),
1861 Some(latest.instrument.clone())
1862 );
1863
1864 index.begin_stream(std::slice::from_ref(&latest));
1865
1866 assert_eq!(
1867 index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1868 Some(latest.symbol_id)
1869 );
1870 assert_eq!(
1871 index.stream_symbol_id(instrument_id),
1872 Some(latest.symbol_id)
1873 );
1874 assert_eq!(index.instrument(original.symbol_id), None);
1875 assert_eq!(index.instrument(latest.symbol_id), Some(latest.instrument));
1876 }
1877
1878 #[rstest]
1879 fn batch_refresh_retains_stream_aliases_and_disjoint_updates() {
1880 let symbols: Vec<crate::http::models::Symbol> =
1881 serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1882 let (loaded, _) = crate::http::parse::parse_instruments(&symbols, 17.into());
1883 assert!(loaded.len() >= 2);
1884 let index = InstrumentIndex::default();
1885 index.begin_stream(&loaded);
1886
1887 let (refreshed, _) = crate::http::parse::parse_instruments(&symbols, 29.into());
1888
1889 let refreshed: Vec<_> = refreshed
1890 .into_iter()
1891 .enumerate()
1892 .map(|(i, loaded)| CoinApiInstrument {
1893 symbol_id: Ustr::from(&format!("REFRESHED_{i}")),
1894 ..loaded
1895 })
1896 .collect();
1897
1898 let barrier = std::sync::Barrier::new(2);
1899 let split = refreshed.len() / 2;
1900 std::thread::scope(|scope| {
1901 for batch in [&refreshed[..split], &refreshed[split..]] {
1902 scope.spawn(|| {
1903 barrier.wait();
1904 index.extend(batch);
1905 });
1906 }
1907 });
1908
1909 for (original, refreshed) in loaded.iter().zip(&refreshed) {
1910 let id = original.instrument.id();
1911 assert_eq!(index.stream_symbol_id(id), Some(original.symbol_id));
1912 assert_eq!(index.loaded(id).unwrap().0, refreshed.symbol_id);
1913
1914 for alias in [original.symbol_id, refreshed.symbol_id] {
1915 assert_eq!(
1916 serde_json::to_value(index.instrument(alias).unwrap()).unwrap(),
1917 serde_json::to_value(&refreshed.instrument).unwrap()
1918 );
1919 }
1920 }
1921
1922 let state = index.state.load();
1923 assert_eq!(state.symbol_ids.len(), loaded.len());
1924 assert_eq!(state.stream_symbol_ids.len(), loaded.len());
1925 assert_eq!(state.by_symbol_id.len(), loaded.len() * 2);
1926 }
1927
1928 #[rstest]
1929 fn concurrent_refresh_publishes_coherent_aliases() {
1930 let symbols: Vec<crate::http::models::Symbol> =
1931 serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1932 let (loaded, _) = crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1933 let original = loaded
1934 .into_iter()
1935 .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1936 .unwrap();
1937 let coarse: Vec<crate::http::models::Symbol> = serde_json::from_str(
1938 &include_str!("../test_data/symbols.json").replace("0.01000000000000000000", "0.1"),
1939 )
1940 .unwrap();
1941 let (coarse, _) = crate::http::parse::parse_instruments(&coarse, UnixNanos::default());
1942 let coarse = coarse
1943 .into_iter()
1944 .find(|loaded| loaded.symbol_id == original.symbol_id)
1945 .unwrap();
1946
1947 let updates = [
1948 CoinApiInstrument {
1949 symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1950 ..coarse
1951 },
1952 CoinApiInstrument {
1953 symbol_id: "COINBASE_SPOT_BTC_USD_22F0063".into(),
1954 ..original.clone()
1955 },
1956 ];
1957
1958 assert_eq!(updates[0].instrument.price_increment(), Price::from("0.1"));
1959 assert_eq!(updates[1].instrument.price_increment(), Price::from("0.01"));
1960 let index = InstrumentIndex::default();
1961 index.begin_stream(std::slice::from_ref(&original));
1962 let barrier = std::sync::Barrier::new(updates.len());
1963 std::thread::scope(|scope| {
1964 for update in &updates {
1965 scope.spawn(|| {
1966 barrier.wait();
1967
1968 for _ in 0..1000 {
1969 index.insert(update);
1970 let state = index.state.load();
1971 let symbol_id = state.symbol_ids[&original.instrument.id()];
1972 assert_eq!(state.by_symbol_id.len(), 2);
1973 assert_eq!(
1974 serde_json::to_value(&state.by_symbol_id[&original.symbol_id]).unwrap(),
1975 serde_json::to_value(&state.by_symbol_id[&symbol_id]).unwrap()
1976 );
1977 assert_eq!(
1978 state.stream_symbol_ids[&original.instrument.id()],
1979 original.symbol_id
1980 );
1981 let expected = updates
1982 .iter()
1983 .find(|update| update.symbol_id == symbol_id)
1984 .unwrap();
1985 assert_eq!(
1986 serde_json::to_value(&state.by_symbol_id[&symbol_id]).unwrap(),
1987 serde_json::to_value(&expected.instrument).unwrap()
1988 );
1989 }
1990 });
1991 }
1992 });
1993 }
1994
1995 #[rstest]
1996 fn close_period_emits_latest_update_of_prior_period_once() {
1997 let mut cache = OhlcvCache::new();
1998
1999 let first = close_period(&mut cache, update(10, "1"));
2000 let repeat = close_period(&mut cache, update(10, "2"));
2001 let next = close_period(&mut cache, update(11, "3"));
2002 let late = close_period(&mut cache, update(10, "4"));
2003 let skipped = close_period(&mut cache, update(13, "5"));
2004
2005 assert_eq!(first, None);
2006 assert_eq!(repeat, None);
2007 assert_eq!(next, Some(update(10, "2")));
2008 assert_eq!(late, None);
2009 assert_eq!(skipped, Some(update(11, "3")));
2010 assert_eq!(cache.len(), 1);
2011 assert_eq!(cache.values().next(), Some(&update(13, "5")));
2012 }
2013}