1use std::{
20 collections::HashMap,
21 future::Future,
22 num::NonZeroUsize,
23 sync::{Arc, atomic::AtomicBool},
24 time::Duration,
25};
26
27use ahash::{AHashMap, AHashSet};
28use anyhow::Context;
29use arc_swap::ArcSwap;
30use async_trait::async_trait;
31use jiff::Timestamp;
32use nautilus_common::{
33 clients::DataClient,
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, symbol::VenueSymbol},
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 validate_selections(&config.instruments)?;
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::Instrument(instrument.instrument))
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 let data_type = match cmd.book_type {
627 BookType::L2_MBP => DataType::Book,
628 BookType::L3_MBO => DataType::BookL3,
629 BookType::L1_MBP => anyhow::bail!("CoinAPI streams L2_MBP and L3_MBO book deltas"),
630 };
631
632 self.subscribe_book(cmd.instrument_id, data_type, None)
634 }
635
636 fn subscribe_book_depth(&mut self, cmd: SubscribeBookDepth) -> anyhow::Result<()> {
637 anyhow::ensure!(
638 cmd.book_type == BookType::L2_MBP,
639 "CoinAPI depth snapshots are L2_MBP, was {}",
640 cmd.book_type
641 );
642
643 let depth = cmd.depth.map_or(DEPTH10_LEN, NonZeroUsize::get);
644 let (data_type, _) = BOOK_DEPTH_CHANNELS
645 .into_iter()
646 .find(|(_, levels)| depth <= *levels)
647 .context(format!(
648 "CoinAPI depth snapshots list at most 50 levels, was {depth}"
649 ))?;
650
651 self.subscribe_book(cmd.instrument_id, data_type, Some(depth))
652 }
653
654 fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
655 self.subscribe_channel(cmd.instrument_id, channel(DataType::Quote, None))
656 }
657
658 fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
659 self.subscribe_channel(cmd.instrument_id, channel(DataType::Trade, None))
660 }
661
662 fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
663 let bar_type = cmd.bar_type;
664 anyhow::ensure!(
665 bar_type.aggregation_source() == AggregationSource::External,
666 "CoinAPI streams only external bars, was {bar_type}"
667 );
668
669 let period_id = bar_spec_to_period_id(bar_type.spec())?;
670 self.subscribe_channel(
671 bar_type.instrument_id(),
672 channel(DataType::Ohlcv, Some(period_id)),
673 )
674 }
675
676 fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
677 self.unsubscribe_book(cmd.instrument_id, false)
678 }
679
680 fn unsubscribe_book_depth(&mut self, cmd: &UnsubscribeBookDepth) -> anyhow::Result<()> {
681 self.unsubscribe_book(cmd.instrument_id, true)
682 }
683
684 fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
685 self.unsubscribe_channel(cmd.instrument_id, channel(DataType::Quote, None))
686 }
687
688 fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
689 self.unsubscribe_channel(cmd.instrument_id, channel(DataType::Trade, None))
690 }
691
692 fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
693 let period_id = bar_spec_to_period_id(cmd.bar_type.spec())?;
694 self.unsubscribe_channel(
695 cmd.bar_type.instrument_id(),
696 channel(DataType::Ohlcv, Some(period_id)),
697 )
698 }
699
700 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
701 let selections = select_instruments(&self.config.instruments, |instrument_id| {
702 request
703 .venue
704 .is_none_or(|venue| instrument_id.venue == venue)
705 });
706
707 let http = Arc::clone(&self.http);
708 let price_precisions = self.config.price_precisions.clone();
709 let index = Arc::clone(&self.instruments);
710 let sender = self.data_sender.clone();
711 let client_id = request.client_id.unwrap_or(self.client_id);
712
713 let venue = request.venue.unwrap_or_else(|| Venue::new(COINAPI));
715 let start = datetime_to_unix_nanos(request.start);
716 let end = datetime_to_unix_nanos(request.end);
717 let clock = self.clock;
718
719 self.spawn_command("CoinAPI instruments request", async move {
720 let loaded =
721 load_instruments(&http, &selections, &price_precisions, clock.get_time_ns())
722 .await?;
723
724 let instruments = loaded
725 .iter()
726 .map(|instrument| {
727 index.insert(instrument);
728 instrument.instrument.clone()
729 })
730 .collect();
731
732 let response = DataResponse::Instruments(InstrumentsResponse::new(
733 request.request_id,
734 client_id,
735 venue,
736 instruments,
737 start,
738 end,
739 clock.get_time_ns(),
740 request.params,
741 ));
742 send_response(&sender, response);
743 Ok(())
744 });
745
746 Ok(())
747 }
748
749 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
750 let instrument_id = request.instrument_id;
751 let selections = select_instruments(&self.config.instruments, |candidate| {
752 *candidate == instrument_id
753 });
754
755 anyhow::ensure!(
756 !selections.is_empty(),
757 "{instrument_id} is not a configured CoinAPI instrument"
758 );
759
760 let http = Arc::clone(&self.http);
761 let price_precisions = self.config.price_precisions.clone();
762 let index = Arc::clone(&self.instruments);
763 let sender = self.data_sender.clone();
764 let client_id = request.client_id.unwrap_or(self.client_id);
765 let start = datetime_to_unix_nanos(request.start);
766 let end = datetime_to_unix_nanos(request.end);
767 let clock = self.clock;
768
769 self.spawn_command("CoinAPI instrument request", async move {
770 let loaded =
771 load_instruments(&http, &selections, &price_precisions, clock.get_time_ns())
772 .await?;
773 let instrument = loaded
774 .into_iter()
775 .next()
776 .context("CoinAPI instrument load returned nothing")?;
777 index.insert(&instrument);
778
779 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
780 request.request_id,
781 client_id,
782 instrument_id,
783 instrument.instrument,
784 start,
785 end,
786 clock.get_time_ns(),
787 request.params,
788 )));
789 send_response(&sender, response);
790 Ok(())
791 });
792
793 Ok(())
794 }
795
796 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
797 let instrument_id = request.instrument_id;
798 let (symbol_id, instrument) = self.loaded(instrument_id)?;
799
800 let query = BookQuery {
801 limit_levels: request
802 .depth
803 .map(|depth| u32::try_from(depth.get()).unwrap_or(u32::MAX)),
804 };
805
806 let http = Arc::clone(&self.http);
807 let sender = self.data_sender.clone();
808 let client_id = request.client_id.unwrap_or(self.client_id);
809 let clock = self.clock;
810
811 self.spawn_command("CoinAPI book snapshot request", async move {
812 let book = http.get_book(&symbol_id, &query).await?;
813 let book = http_parse::parse_order_book(&book, &instrument)
814 .with_context(|| format!("Invalid CoinAPI book for {symbol_id}"))?;
815
816 let response = DataResponse::Book(BookResponse::new(
817 request.request_id,
818 client_id,
819 instrument_id,
820 book,
821 None,
822 None,
823 clock.get_time_ns(),
824 request.params,
825 ));
826 send_response(&sender, response);
827 Ok(())
828 });
829
830 Ok(())
831 }
832
833 fn request_quotes(&self, request: RequestQuotes) -> anyhow::Result<()> {
834 let instrument_id = request.instrument_id;
835 let (symbol_id, instrument, range) =
836 self.history(instrument_id, request.start, request.end, request.limit)?;
837
838 let http = Arc::clone(&self.http);
839 let sender = self.data_sender.clone();
840 let client_id = request.client_id.unwrap_or(self.client_id);
841 let start = datetime_to_unix_nanos(request.start);
842 let end = datetime_to_unix_nanos(request.end);
843 let clock = self.clock;
844
845 self.spawn_command("CoinAPI quotes request", async move {
846 let pages = http.get_quotes_range(&symbol_id, &range).await?;
847
848 let quotes = parse_history(&pages, "quote", symbol_id, |quote| {
849 http_parse::parse_quote_tick(quote, &instrument)
850 });
851
852 let response = DataResponse::Quotes(QuotesResponse::new(
853 request.request_id,
854 client_id,
855 instrument_id,
856 quotes,
857 start,
858 end,
859 clock.get_time_ns(),
860 request.params,
861 ));
862 send_response(&sender, response);
863 Ok(())
864 });
865
866 Ok(())
867 }
868
869 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
870 let instrument_id = request.instrument_id;
871 let (symbol_id, instrument, range) =
872 self.history(instrument_id, request.start, request.end, request.limit)?;
873
874 let http = Arc::clone(&self.http);
875 let sender = self.data_sender.clone();
876 let client_id = request.client_id.unwrap_or(self.client_id);
877 let start = datetime_to_unix_nanos(request.start);
878 let end = datetime_to_unix_nanos(request.end);
879 let clock = self.clock;
880
881 self.spawn_command("CoinAPI trades request", async move {
882 let pages = http.get_trades_range(&symbol_id, &range).await?;
883
884 let trades = parse_history(&pages, "trade", symbol_id, |trade| {
885 http_parse::parse_trade_tick(trade, &instrument)
886 });
887
888 let response = DataResponse::Trades(TradesResponse::new(
889 request.request_id,
890 client_id,
891 instrument_id,
892 trades,
893 start,
894 end,
895 clock.get_time_ns(),
896 request.params,
897 ));
898 send_response(&sender, response);
899 Ok(())
900 });
901
902 Ok(())
903 }
904
905 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
906 let bar_type = request.bar_type;
907 anyhow::ensure!(
908 bar_type.aggregation_source() == AggregationSource::External,
909 "CoinAPI provides only external bars, was {bar_type}"
910 );
911
912 let period_id = bar_spec_to_period_id(bar_type.spec())?;
913 let (symbol_id, instrument, range) = self.history(
914 bar_type.instrument_id(),
915 request.start,
916 request.end,
917 request.limit,
918 )?;
919
920 let http = Arc::clone(&self.http);
921 let sender = self.data_sender.clone();
922 let client_id = request.client_id.unwrap_or(self.client_id);
923 let start = datetime_to_unix_nanos(request.start);
924 let end = datetime_to_unix_nanos(request.end);
925 let clock = self.clock;
926
927 self.spawn_command("CoinAPI bars request", async move {
928 let mut pages = http.get_ohlcv_range(&symbol_id, &period_id, &range).await?;
929
930 let closed_by = range.time_end.min(clock.get_time_ns().to_datetime_utc());
932 pages
933 .records
934 .retain(|ohlcv| ohlcv.time_period_end <= closed_by);
935
936 let bars = parse_history(&pages, "OHLCV period", symbol_id, |ohlcv| {
937 http_parse::parse_bar(ohlcv, bar_type, &instrument)
938 });
939
940 let response = DataResponse::Bars(BarsResponse::new(
941 request.request_id,
942 client_id,
943 bar_type,
944 bars,
945 start,
946 end,
947 clock.get_time_ns(),
948 request.params,
949 ));
950 send_response(&sender, response);
951 Ok(())
952 });
953
954 Ok(())
955 }
956}
957
958type SubscriptionSender = tokio::sync::mpsc::UnboundedSender<SubscriptionCommand>;
961
962#[derive(Debug)]
963struct SubscriptionCommand {
964 change: ScopeChange,
965 snapshot: Option<SnapshotWait>,
966 book_active: Option<Arc<AtomicBool>>,
967}
968
969#[derive(Debug)]
971struct SnapshotWait {
972 gate: SnapshotGate,
973 written: tokio::sync::oneshot::Sender<()>,
974}
975
976fn send_subscription(
977 commands: &SubscriptionSender,
978 change: ScopeChange,
979 snapshot: Option<SnapshotWait>,
980 book_active: Option<Arc<AtomicBool>>,
981) -> anyhow::Result<()> {
982 commands
983 .send(SubscriptionCommand {
984 change,
985 snapshot,
986 book_active,
987 })
988 .map_err(|_| anyhow::anyhow!("CoinAPI subscription task has stopped"))
989}
990
991async fn run_subscriptions(
994 ws: Arc<CoinApiWebSocketClient>,
995 mut commands: tokio::sync::mpsc::UnboundedReceiver<SubscriptionCommand>,
996 cancellation: CancellationToken,
997) {
998 loop {
999 let first = tokio::select! {
1000 biased;
1001 () = cancellation.cancelled() => break,
1002 command = commands.recv() => match command {
1003 Some(command) => command,
1004 None => break,
1005 },
1006 };
1007
1008 let mut batch = vec![first];
1009 while let Ok(command) = commands.try_recv() {
1010 batch.push(command);
1011 }
1012
1013 let changes: Vec<ScopeChange> = batch.iter().map(|command| command.change).collect();
1014 let registrations: Vec<_> = batch
1015 .iter()
1016 .map(|command| command.book_active.clone())
1017 .collect();
1018 let mut delay = SUBSCRIPTION_RETRY_DELAY_INITIAL;
1019
1020 let rejected = loop {
1022 let result = tokio::select! {
1023 biased;
1024 () = cancellation.cancelled() => return,
1025 result = ws.apply_registered(&changes, ®istrations) => result,
1026 };
1027
1028 match result {
1029 Ok(rejected) => break rejected,
1030 Err(e) => {
1031 log::warn!("CoinAPI subscription change failed, retrying in {delay:?}: {e}");
1032 tokio::select! {
1033 biased;
1034 () = cancellation.cancelled() => return,
1035 () = time::sleep(delay) => {}
1036 }
1037 delay = (delay * 2).min(SUBSCRIPTION_RETRY_DELAY_MAX);
1038 }
1039 }
1040 };
1041
1042 for (change, e) in &rejected {
1043 log::error!("CoinAPI rejected {change:?}: {e}");
1044 }
1045
1046 for command in batch {
1047 let Some(snapshot) = command.snapshot else {
1048 continue;
1049 };
1050
1051 if rejected
1052 .iter()
1053 .all(|(rejected, _)| *rejected != command.change)
1054 {
1055 snapshot.gate.open();
1056 let _ = snapshot.written.send(());
1057 }
1058 }
1059 }
1060}
1061
1062#[derive(Debug)]
1064struct BookStreams {
1065 subscriptions: Arc<BookSubscriptions>,
1066 ws: Arc<CoinApiWebSocketClient>,
1067 tasks: TaskSpawner,
1068 snapshot_timeout: Duration,
1069}
1070
1071pub(crate) type OhlcvCache = AHashMap<(Ustr, Ustr), Ohlcv>;
1073
1074#[derive(Debug, Default)]
1075struct InstrumentIndex {
1076 state: ArcSwap<InstrumentIndexState>,
1077}
1078
1079#[derive(Debug, Clone, Default)]
1080struct InstrumentIndexState {
1081 by_symbol_id: AHashMap<Ustr, InstrumentAny>,
1082 symbol_ids: AHashMap<InstrumentId, Ustr>,
1083 stream_symbol_ids: AHashMap<InstrumentId, Ustr>,
1084}
1085
1086impl InstrumentIndex {
1087 fn insert(&self, loaded: &CoinApiInstrument) {
1088 self.state.rcu(|state| {
1089 let mut next = (**state).clone();
1090 next.insert(loaded);
1091 Arc::new(next)
1092 });
1093 }
1094
1095 fn begin_stream(&self, loaded: &[CoinApiInstrument]) {
1096 let mut state = InstrumentIndexState::default();
1097 for instrument in loaded {
1098 state
1099 .stream_symbol_ids
1100 .insert(instrument.instrument.id(), instrument.symbol_id);
1101 state.insert(instrument);
1102 }
1103
1104 self.state.store(Arc::new(state));
1105 }
1106
1107 fn stream_symbol_id(&self, instrument_id: InstrumentId) -> Option<Ustr> {
1108 self.state
1109 .load()
1110 .stream_symbol_ids
1111 .get(&instrument_id)
1112 .copied()
1113 }
1114
1115 fn loaded(&self, instrument_id: InstrumentId) -> Option<(Ustr, InstrumentAny)> {
1116 let state = self.state.load();
1117 let symbol_id = *state.symbol_ids.get(&instrument_id)?;
1118 Some((symbol_id, state.by_symbol_id.get(&symbol_id)?.clone()))
1119 }
1120
1121 fn instrument(&self, symbol_id: Ustr) -> Option<InstrumentAny> {
1122 self.state.load().by_symbol_id.get(&symbol_id).cloned()
1123 }
1124}
1125
1126impl InstrumentIndexState {
1127 fn insert(&mut self, loaded: &CoinApiInstrument) {
1128 let instrument_id = loaded.instrument.id();
1129 let stream_symbol_id = self.stream_symbol_ids.get(&instrument_id).copied();
1130
1131 if let Some(previous) = self.symbol_ids.get(&instrument_id)
1132 && *previous != loaded.symbol_id
1133 && Some(*previous) != stream_symbol_id
1134 {
1135 self.by_symbol_id.remove(previous);
1136 }
1137
1138 self.by_symbol_id
1139 .insert(loaded.symbol_id, loaded.instrument.clone());
1140 self.symbol_ids.insert(instrument_id, loaded.symbol_id);
1141
1142 if let Some(symbol_id) = stream_symbol_id
1143 && symbol_id != loaded.symbol_id
1144 {
1145 self.by_symbol_id
1146 .insert(symbol_id, loaded.instrument.clone());
1147 }
1148 }
1149}
1150
1151fn validate_selections(selections: &[CoinApiInstrumentSelection]) -> anyhow::Result<()> {
1152 let mut seen = AHashSet::new();
1153
1154 for selection in selections {
1155 anyhow::ensure!(
1156 !selection.instrument_ids.is_empty(),
1157 "CoinAPI selection for {} lists no instruments",
1158 selection.exchange_id
1159 );
1160
1161 for instrument_id in &selection.instrument_ids {
1162 let exchange_id = VenueSymbol::from_native(*instrument_id)?.exchange_id;
1163 anyhow::ensure!(
1164 exchange_id == selection.exchange_id,
1165 "{instrument_id} maps to CoinAPI exchange {exchange_id}, not {}",
1166 selection.exchange_id
1167 );
1168 anyhow::ensure!(
1169 seen.insert(*instrument_id),
1170 "{instrument_id} is selected more than once"
1171 );
1172 }
1173 }
1174
1175 Ok(())
1176}
1177
1178fn validate_price_precisions(
1179 selections: &[CoinApiInstrumentSelection],
1180 price_precisions: &HashMap<InstrumentId, u8>,
1181) -> anyhow::Result<()> {
1182 for (instrument_id, precision) in price_precisions {
1183 anyhow::ensure!(
1184 selections
1185 .iter()
1186 .any(|selection| selection.instrument_ids.contains(instrument_id)),
1187 "price precision for {instrument_id}, which is not a selected instrument"
1188 );
1189 check_fixed_precision(*precision)
1190 .with_context(|| format!("invalid price precision for {instrument_id}"))?;
1191 }
1192
1193 Ok(())
1194}
1195
1196fn select_instruments(
1198 selections: &[CoinApiInstrumentSelection],
1199 predicate: impl Fn(&InstrumentId) -> bool,
1200) -> Vec<CoinApiInstrumentSelection> {
1201 selections
1202 .iter()
1203 .filter_map(|selection| {
1204 let instrument_ids: Vec<InstrumentId> = selection
1205 .instrument_ids
1206 .iter()
1207 .copied()
1208 .filter(&predicate)
1209 .collect();
1210
1211 (!instrument_ids.is_empty()).then(|| CoinApiInstrumentSelection {
1212 instrument_ids,
1213 ..selection.clone()
1214 })
1215 })
1216 .collect()
1217}
1218
1219async fn load_instruments(
1221 http: &CoinApiHttpClient,
1222 selections: &[CoinApiInstrumentSelection],
1223 price_precisions: &HashMap<InstrumentId, u8>,
1224 ts_init: UnixNanos,
1225) -> anyhow::Result<Vec<CoinApiInstrument>> {
1226 let mut selected = Vec::new();
1227
1228 for selection in selections {
1229 let loaded = http
1230 .request_instruments(
1231 &selection.exchange_id,
1232 &selection.query,
1233 price_precisions,
1234 ts_init,
1235 )
1236 .await?;
1237
1238 for instrument_id in &selection.instrument_ids {
1239 let instrument = loaded
1240 .iter()
1241 .find(|instrument| instrument.instrument.id() == *instrument_id)
1242 .with_context(|| {
1243 format!(
1244 "CoinAPI {} returned no instrument {instrument_id}",
1245 selection.exchange_id
1246 )
1247 })?;
1248
1249 selected.push(instrument.clone());
1250 }
1251 }
1252
1253 Ok(selected)
1254}
1255
1256fn channel(data_type: DataType, period_id: Option<Ustr>) -> Channel {
1257 Channel {
1258 data_type,
1259 period_id,
1260 }
1261}
1262
1263fn send_response(sender: &EventSender<DataEvent>, response: DataResponse) {
1264 if let Err(e) = sender.send(DataEvent::Response(response)) {
1265 log::error!("Failed to send CoinAPI response: {e}");
1266 }
1267}
1268
1269fn parse_history<T, D>(
1271 pages: &HistoryPages<T>,
1272 kind: &str,
1273 symbol_id: Ustr,
1274 parse: impl Fn(&T) -> anyhow::Result<D>,
1275) -> Vec<D> {
1276 let total = pages.records.len();
1277
1278 if pages.is_truncated {
1279 log::warn!(
1280 "CoinAPI credit budget truncated the {kind} history for {symbol_id} after {total} records"
1281 );
1282 }
1283
1284 let mut first_error = None;
1285
1286 let parsed: Vec<D> = pages
1287 .records
1288 .iter()
1289 .filter_map(|record| {
1290 parse(record)
1291 .map_err(|e| {
1292 first_error.get_or_insert(e);
1293 })
1294 .ok()
1295 })
1296 .collect();
1297
1298 if let Some(e) = first_error {
1299 let dropped = total - parsed.len();
1300 log::warn!(
1301 "Dropped {dropped} of {total} CoinAPI {kind} records for {symbol_id}, first failure: {e:#}"
1302 );
1303 }
1304
1305 parsed
1306}
1307
1308async fn run_stream(
1309 mut rx: tokio::sync::mpsc::UnboundedReceiver<CoinApiWsMessage>,
1310 cancellation: CancellationToken,
1311 sender: EventSender<DataEvent>,
1312 instruments: Arc<InstrumentIndex>,
1313 books: BookStreams,
1314 ohlcv: Arc<Mutex<OhlcvCache>>,
1315 clock: &'static AtomicTime,
1316) {
1317 loop {
1318 let message = tokio::select! {
1319 biased;
1320 () = cancellation.cancelled() => break,
1321 message = rx.recv() => match message {
1322 Some(message) => message,
1323 None => break,
1324 },
1325 };
1326
1327 handle_message(message, &sender, &instruments, &books, &ohlcv, clock);
1328 }
1329}
1330
1331fn handle_message(
1332 message: CoinApiWsMessage,
1333 sender: &EventSender<DataEvent>,
1334 instruments: &InstrumentIndex,
1335 books: &BookStreams,
1336 ohlcv: &Arc<Mutex<OhlcvCache>>,
1337 clock: &AtomicTime,
1338) {
1339 let (symbol_id, kind, parsed) = match message {
1340 CoinApiWsMessage::Book(book) => {
1341 let Some(instrument) = instruments.instrument(book.symbol_id) else {
1342 return log_unknown_symbol(book.symbol_id);
1343 };
1344
1345 if book.data_type == DataType::Book {
1346 return handle_book_frame(
1347 BookFrame::Levels(&book),
1348 &instrument,
1349 books,
1350 ohlcv,
1351 sender,
1352 );
1353 }
1354
1355 let Some(depth) = books
1356 .subscriptions
1357 .entries
1358 .load()
1359 .get(&instrument.id())
1360 .and_then(|registration| registration.depth)
1361 else {
1362 return;
1363 };
1364
1365 let parsed = parse_book_depth(&book, depth, &instrument).map(|mut depth| {
1366 depth.sequence = books
1367 .subscriptions
1368 .sync
1369 .depth_sequence(instrument.id(), book.sequence);
1370 Data::BookDepth(Box::new(depth))
1371 });
1372
1373 (book.symbol_id, "book depth", parsed)
1374 }
1375 CoinApiWsMessage::BookL3(book) => {
1376 let Some(instrument) = instruments.instrument(book.symbol_id) else {
1377 return log_unknown_symbol(book.symbol_id);
1378 };
1379
1380 return handle_book_frame(BookFrame::Orders(&book), &instrument, books, ohlcv, sender);
1381 }
1382 CoinApiWsMessage::Quote(quote) => {
1383 let Some(instrument) = instruments.instrument(quote.symbol_id) else {
1384 return log_unknown_symbol(quote.symbol_id);
1385 };
1386
1387 let parsed = parse_quote_tick("e, &instrument).map(Data::Quote);
1388 (quote.symbol_id, "quote", parsed)
1389 }
1390 CoinApiWsMessage::Trade(trade) => {
1391 let Some(instrument) = instruments.instrument(trade.symbol_id) else {
1392 return log_unknown_symbol(trade.symbol_id);
1393 };
1394
1395 let parsed = parse_trade_tick(&trade, &instrument).map(Data::Trade);
1396 (trade.symbol_id, "trade", parsed)
1397 }
1398 CoinApiWsMessage::Ohlcv(update) => {
1399 let Some(closed) = close_period(&mut ohlcv.lock(), update) else {
1400 return;
1401 };
1402
1403 let Some(instrument) = instruments.instrument(closed.symbol_id) else {
1404 return log_unknown_symbol(closed.symbol_id);
1405 };
1406
1407 let parsed = parse_bar(&closed, &instrument, clock.get_time_ns()).map(Data::Bar);
1408 (closed.symbol_id, "OHLCV period", parsed)
1409 }
1410 CoinApiWsMessage::Error(message) => {
1411 log::error!("CoinAPI retired the stream and its subscriptions: {message}");
1412 return;
1413 }
1414 CoinApiWsMessage::Reconnected => {
1415 ohlcv.lock().clear();
1417 reset_books(books, ohlcv);
1418 log::info!("CoinAPI stream reconnected and replayed its subscriptions");
1419 return;
1420 }
1421 };
1422
1423 match parsed {
1424 Ok(data) => {
1425 if let Err(e) = sender.send(DataEvent::Data(data)) {
1426 log::error!("Failed to send CoinAPI data: {e}");
1427 }
1428 }
1429 Err(e) => log::warn!("Dropping CoinAPI {kind} for {symbol_id}: {e:#}"),
1430 }
1431}
1432
1433fn handle_book_frame(
1434 frame: BookFrame<'_>,
1435 instrument: &InstrumentAny,
1436 books: &BookStreams,
1437 ohlcv: &Arc<Mutex<OhlcvCache>>,
1438 sender: &EventSender<DataEvent>,
1439) {
1440 let instrument_id = instrument.id();
1441
1442 match books
1443 .subscriptions
1444 .sync
1445 .apply(instrument_id, frame, instrument, time::Instant::now())
1446 {
1447 (BookSequenceOutcome::Accept, Some(deltas)) => {
1448 if let Err(e) = sender.send(DataEvent::Data(Data::BookDeltas(Box::new(deltas)))) {
1449 log::error!("Failed to send CoinAPI data: {e}");
1450 }
1451 }
1452 (BookSequenceOutcome::Recover, _) => start_recovery(
1453 instrument_id,
1454 &books.subscriptions.sync,
1455 &books.ws,
1456 ohlcv,
1457 books.snapshot_timeout,
1458 &books.tasks,
1459 ),
1460 _ => {}
1461 }
1462}
1463
1464fn reset_books(books: &BookStreams, ohlcv: &Arc<Mutex<OhlcvCache>>) {
1466 let deadline =
1467 (!books.snapshot_timeout.is_zero()).then(|| time::Instant::now() + books.snapshot_timeout);
1468
1469 if books.subscriptions.sync.reset_on_reconnect(deadline) > 0 {
1470 spawn_recovery_monitor(
1471 books.subscriptions.sync.clone(),
1472 Arc::clone(&books.ws),
1473 Arc::clone(ohlcv),
1474 books.snapshot_timeout,
1475 &books.tasks,
1476 );
1477 }
1478}
1479
1480fn log_unknown_symbol(symbol_id: Ustr) {
1481 log::warn!("Dropping CoinAPI message for unloaded symbol {symbol_id}");
1482}
1483
1484fn close_period(cache: &mut OhlcvCache, update: Ohlcv) -> Option<Ohlcv> {
1487 let key = (update.symbol_id, update.period_id);
1488 match cache.get(&key) {
1489 Some(retained) if update.time_period_start < retained.time_period_start => None,
1490 Some(retained) if update.time_period_start == retained.time_period_start => {
1491 cache.insert(key, update);
1492 None
1493 }
1494 _ => cache.insert(key, update),
1495 }
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500 use nautilus_model::types::Price;
1501 use rstest::rstest;
1502
1503 use super::*;
1504
1505 #[cfg(not(all(feature = "simulation", madsim)))]
1506 #[rstest]
1507 #[case::stop(DataClient::stop, false)]
1508 #[case::reset(DataClient::reset, true)]
1509 #[case::dispose(DataClient::dispose, false)]
1510 #[tokio::test]
1511 async fn shutdown_cancels_tasks_and_clears_book_state(
1512 #[case] shutdown: fn(&mut CoinApiDataClient) -> anyhow::Result<()>,
1513 #[case] clears_bars: bool,
1514 ) {
1515 let (sender, _events) = tokio::sync::mpsc::unbounded_channel();
1516 nautilus_common::live::runner::replace_data_event_sender(sender);
1517
1518 let mut client = CoinApiDataClient::new(
1519 ClientId::from("COINAPI"),
1520 CoinApiDataClientConfig {
1521 api_key: Some("test-key".into()),
1522 ..CoinApiDataClientConfig::default()
1523 },
1524 )
1525 .unwrap();
1526
1527 let instrument_id = InstrumentId::from("BTC-USD.COINBASE");
1528
1529 let subscription = Subscription {
1530 channel: channel(DataType::Book, None),
1531 symbol_id: "COINBASE_SPOT_BTC_USD".into(),
1532 };
1533
1534 let symbols: Vec<crate::http::models::Symbol> =
1535 serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1536 let (loaded, _) = http_parse::parse_instruments(&symbols, UnixNanos::default());
1537 let instrument = loaded
1538 .into_iter()
1539 .find(|loaded| loaded.instrument.id() == instrument_id)
1540 .unwrap()
1541 .instrument;
1542 let mut frame: crate::websocket::messages::Book =
1543 serde_json::from_str(include_str!("../test_data/ws_book.json")).unwrap();
1544 frame.sequence = 100;
1545 frame.is_snapshot = Some(true);
1546 let gate = SnapshotGate::default();
1547 gate.open();
1548 let book_cancel = client.books.sync.record_subscription(
1549 instrument_id,
1550 subscription,
1551 time::Instant::now(),
1552 gate.clone(),
1553 );
1554 client.books.entries.insert(
1555 instrument_id,
1556 BookSubscription {
1557 data_type: DataType::Book,
1558 depth: None,
1559 active: Arc::new(AtomicBool::new(false)),
1560 },
1561 );
1562
1563 let (outcome, deltas) = client.books.sync.apply(
1564 instrument_id,
1565 BookFrame::Levels(&frame),
1566 &instrument,
1567 time::Instant::now(),
1568 );
1569 assert_eq!(outcome, BookSequenceOutcome::Accept);
1570 assert_eq!(deltas.unwrap().sequence, 100);
1571 assert_eq!(client.books.sync.depth_sequence(instrument_id, 17), 17);
1572 let retained = update(1, "84523.69");
1573 let key = (retained.symbol_id, retained.period_id);
1574 client.ohlcv.lock().insert(key, retained.clone());
1575 client.is_connected = true;
1576 let (ws, _messages) =
1577 CoinApiWebSocketClient::new(client.api_key.clone(), client.config.websocket_config())
1578 .unwrap();
1579 client.ws = Some(Arc::new(ws));
1580 let (commands, mut command_receiver) = tokio::sync::mpsc::unbounded_channel();
1581 client.subscription_tx = Some(commands);
1582 let mut completions = Vec::new();
1583
1584 for group in [&client.session_tasks, &client.command_tasks] {
1585 let cancel = group.cancellation_token();
1586 let (done, completion) = tokio::sync::oneshot::channel();
1587
1588 group
1589 .spawn(async move {
1590 cancel.cancelled().await;
1591 done.send(()).unwrap();
1592 })
1593 .unwrap();
1594
1595 completions.push(completion);
1596 }
1597
1598 let retired = Arc::clone(&client.books);
1599 shutdown(&mut client).unwrap();
1600
1601 for completion in completions {
1602 tokio::time::timeout(Duration::from_secs(1), completion)
1603 .await
1604 .unwrap()
1605 .unwrap();
1606 }
1607
1608 assert!(client.is_disconnected());
1609 assert!(client.ws.is_none());
1610 assert!(client.subscription_tx.is_none());
1611 assert!(command_receiver.recv().await.is_none());
1612 assert!(client.books.entries.is_empty());
1613 assert!(book_cancel.is_cancelled());
1614 assert_eq!(client.books.sync.reset_on_reconnect(None), 0);
1615 assert!(!client.session_tasks.is_open());
1616 assert!(!client.command_tasks.is_open());
1617
1618 {
1619 let bars = client.ohlcv.lock();
1620
1621 if clears_bars {
1622 assert!(bars.is_empty());
1623 } else {
1624 assert_eq!(bars.len(), 1);
1625 assert_eq!(bars[&key], retained);
1626 }
1627 }
1628
1629 client.connect().await.unwrap();
1630 let active = Arc::new(AtomicBool::new(true));
1631 let replacement_cancel = client.books.sync.record_subscription(
1632 instrument_id,
1633 subscription,
1634 time::Instant::now(),
1635 gate,
1636 );
1637 client.books.entries.insert(
1638 instrument_id,
1639 BookSubscription {
1640 data_type: DataType::Book,
1641 depth: None,
1642 active: Arc::clone(&active),
1643 },
1644 );
1645
1646 retired.retire();
1647
1648 let replacement = client.books.entries.get_cloned(&instrument_id).unwrap();
1649 assert_eq!(replacement.data_type, DataType::Book);
1650 assert_eq!(replacement.depth, None);
1651 assert!(Arc::ptr_eq(&replacement.active, &active));
1652 assert!(
1653 replacement
1654 .active
1655 .load(std::sync::atomic::Ordering::Relaxed)
1656 );
1657 assert!(!replacement_cancel.is_cancelled());
1658 assert!(!Arc::ptr_eq(&retired, &client.books));
1659 frame.sequence = 1;
1660 let (outcome, deltas) = client.books.sync.apply(
1661 instrument_id,
1662 BookFrame::Levels(&frame),
1663 &instrument,
1664 time::Instant::now(),
1665 );
1666 assert_eq!(outcome, BookSequenceOutcome::Accept);
1667 assert_eq!(deltas.unwrap().sequence, 101);
1668 frame.sequence = 2;
1669 frame.is_snapshot = Some(false);
1670 let (outcome, deltas) = client.books.sync.apply(
1671 instrument_id,
1672 BookFrame::Levels(&frame),
1673 &instrument,
1674 time::Instant::now(),
1675 );
1676 assert_eq!(outcome, BookSequenceOutcome::Accept);
1677 assert_eq!(deltas.unwrap().sequence, 102);
1678 assert_eq!(client.books.sync.depth_sequence(instrument_id, 1), 18);
1679 client.disconnect().await.unwrap();
1680 }
1681
1682 fn update(start_second: i64, close: &str) -> Ohlcv {
1683 let mut update: Ohlcv =
1684 serde_json::from_str(include_str!("../test_data/ws_ohlcv.json")).unwrap();
1685 update.time_period_start = jiff::Timestamp::from_second(start_second).unwrap();
1686 update.time_period_end = jiff::Timestamp::from_second(start_second + 1).unwrap();
1687 update.price_close = close.parse().unwrap();
1688 update
1689 }
1690
1691 fn selection(exchange_id: &str, instrument_ids: &[&str]) -> CoinApiInstrumentSelection {
1692 CoinApiInstrumentSelection {
1693 exchange_id: exchange_id.into(),
1694 query: crate::http::query::SymbolsQuery::default(),
1695 instrument_ids: instrument_ids
1696 .iter()
1697 .map(|id| InstrumentId::from(*id))
1698 .collect(),
1699 }
1700 }
1701
1702 #[rstest]
1703 #[case(vec![], None)]
1704 #[case(
1705 vec![selection("COINBASE", &["BTC-USD.COINBASE"]), selection("BINANCEFTS", &["BTCUSDT-PERP.BINANCE"])],
1706 None
1707 )]
1708 #[case(
1709 vec![selection("COINBASE", &[])],
1710 Some("CoinAPI selection for COINBASE lists no instruments")
1711 )]
1712 #[case(
1713 vec![selection("BINANCE", &["BTCUSDT-PERP.BINANCE"])],
1714 Some("BTCUSDT-PERP.BINANCE maps to CoinAPI exchange BINANCEFTS, not BINANCE")
1715 )]
1716 #[case(
1717 vec![selection("COINBASE", &["BTC-USD.COINBASE"]), selection("COINBASE", &["BTC-USD.COINBASE"])],
1718 Some("BTC-USD.COINBASE is selected more than once")
1719 )]
1720 #[case(
1721 vec![selection("DERIBIT", &["BTC-PERPETUAL.DERIBIT"])],
1722 Some("instrument ID BTC-PERPETUAL.DERIBIT has no CoinAPI mapping")
1723 )]
1724 fn selections_validate_mapping_and_uniqueness(
1725 #[case] selections: Vec<CoinApiInstrumentSelection>,
1726 #[case] expected: Option<&str>,
1727 ) {
1728 let result = validate_selections(&selections);
1729
1730 assert_eq!(result.err().map(|e| e.to_string()).as_deref(), expected);
1731 }
1732
1733 #[rstest]
1734 #[case(&[("BTC-USD.COINBASE", 3)], None)]
1735 #[case(
1736 &[("ETH-USD.COINBASE", 3)],
1737 Some("price precision for ETH-USD.COINBASE, which is not a selected instrument")
1738 )]
1739 #[case(&[("BTC-USD.COINBASE", 19)], Some("invalid price precision for BTC-USD.COINBASE"))]
1740 fn price_precisions_require_selected_instruments_and_valid_precision(
1741 #[case] precisions: &[(&str, u8)],
1742 #[case] expected: Option<&str>,
1743 ) {
1744 let selections = vec![selection("COINBASE", &["BTC-USD.COINBASE"])];
1745 let precisions = precisions
1746 .iter()
1747 .map(|(id, precision)| (InstrumentId::from(*id), *precision))
1748 .collect();
1749
1750 let result = validate_price_precisions(&selections, &precisions);
1751
1752 assert_eq!(result.err().map(|e| e.to_string()).as_deref(), expected);
1753 }
1754
1755 #[rstest]
1756 fn select_instruments_narrows_and_drops_empty_selections() {
1757 let selections = vec![
1758 selection("COINBASE", &["BTC-USD.COINBASE", "ETH-USD.COINBASE"]),
1759 selection("BINANCE", &["BTCUSDT.BINANCE"]),
1760 ];
1761
1762 let selected = select_instruments(&selections, |id| {
1763 id.symbol.as_str() != "BTCUSDT" && id.symbol.as_str() != "ETH-USD"
1764 });
1765
1766 assert_eq!(selected.len(), 1);
1767 assert_eq!(selected[0].exchange_id, "COINBASE");
1768 assert_eq!(
1769 selected[0].instrument_ids,
1770 vec![InstrumentId::from("BTC-USD.COINBASE")]
1771 );
1772 }
1773
1774 #[rstest]
1775 fn instrument_index_replaces_changed_symbol_id() {
1776 let symbols: Vec<crate::http::models::Symbol> =
1777 serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1778 let (instruments, _) =
1779 crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1780 let original = instruments
1781 .into_iter()
1782 .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1783 .unwrap();
1784
1785 let suffixed = CoinApiInstrument {
1786 symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1787 ..original.clone()
1788 };
1789
1790 let index = InstrumentIndex::default();
1791
1792 index.insert(&original);
1793 index.insert(&suffixed);
1794
1795 let instrument_id = original.instrument.id();
1796 assert_eq!(
1797 index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1798 Some(suffixed.symbol_id)
1799 );
1800 assert_eq!(
1801 index.instrument(suffixed.symbol_id),
1802 Some(original.instrument)
1803 );
1804 assert_eq!(index.instrument(original.symbol_id), None);
1805 }
1806
1807 #[rstest]
1808 fn stream_symbol_survives_refresh_until_new_session() {
1809 let symbols: Vec<crate::http::models::Symbol> =
1810 serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1811 let (loaded, _) = crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1812 let original = loaded
1813 .into_iter()
1814 .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1815 .unwrap();
1816
1817 let refreshed = CoinApiInstrument {
1818 symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1819 ..original.clone()
1820 };
1821
1822 let latest = CoinApiInstrument {
1823 symbol_id: "COINBASE_SPOT_BTC_USD_22F0063".into(),
1824 ..original.clone()
1825 };
1826
1827 let index = InstrumentIndex::default();
1828 let instrument_id = original.instrument.id();
1829 index.begin_stream(std::slice::from_ref(&original));
1830 index.insert(&refreshed);
1831 index.insert(&latest);
1832
1833 assert_eq!(
1834 index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1835 Some(latest.symbol_id)
1836 );
1837 assert_eq!(
1838 index.stream_symbol_id(instrument_id),
1839 Some(original.symbol_id)
1840 );
1841 assert_eq!(
1842 index.instrument(original.symbol_id),
1843 Some(latest.instrument.clone())
1844 );
1845 assert_eq!(index.instrument(refreshed.symbol_id), None);
1846 assert_eq!(
1847 index.instrument(latest.symbol_id),
1848 Some(latest.instrument.clone())
1849 );
1850
1851 index.begin_stream(std::slice::from_ref(&latest));
1852
1853 assert_eq!(
1854 index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1855 Some(latest.symbol_id)
1856 );
1857 assert_eq!(
1858 index.stream_symbol_id(instrument_id),
1859 Some(latest.symbol_id)
1860 );
1861 assert_eq!(index.instrument(original.symbol_id), None);
1862 assert_eq!(index.instrument(latest.symbol_id), Some(latest.instrument));
1863 }
1864
1865 #[rstest]
1866 fn concurrent_refresh_publishes_coherent_aliases() {
1867 let symbols: Vec<crate::http::models::Symbol> =
1868 serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1869 let (loaded, _) = crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1870 let original = loaded
1871 .into_iter()
1872 .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1873 .unwrap();
1874 let coarse: Vec<crate::http::models::Symbol> = serde_json::from_str(
1875 &include_str!("../test_data/symbols.json").replace("0.01000000000000000000", "0.1"),
1876 )
1877 .unwrap();
1878 let (coarse, _) = crate::http::parse::parse_instruments(&coarse, UnixNanos::default());
1879 let coarse = coarse
1880 .into_iter()
1881 .find(|loaded| loaded.symbol_id == original.symbol_id)
1882 .unwrap();
1883
1884 let updates = [
1885 CoinApiInstrument {
1886 symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1887 ..coarse
1888 },
1889 CoinApiInstrument {
1890 symbol_id: "COINBASE_SPOT_BTC_USD_22F0063".into(),
1891 ..original.clone()
1892 },
1893 ];
1894
1895 assert_eq!(updates[0].instrument.price_increment(), Price::from("0.1"));
1896 assert_eq!(updates[1].instrument.price_increment(), Price::from("0.01"));
1897 let index = InstrumentIndex::default();
1898 index.begin_stream(std::slice::from_ref(&original));
1899 let barrier = std::sync::Barrier::new(updates.len());
1900 std::thread::scope(|scope| {
1901 for update in &updates {
1902 scope.spawn(|| {
1903 barrier.wait();
1904
1905 for _ in 0..1000 {
1906 index.insert(update);
1907 let state = index.state.load();
1908 let symbol_id = state.symbol_ids[&original.instrument.id()];
1909 assert_eq!(state.by_symbol_id.len(), 2);
1910 assert_eq!(
1911 serde_json::to_value(&state.by_symbol_id[&original.symbol_id]).unwrap(),
1912 serde_json::to_value(&state.by_symbol_id[&symbol_id]).unwrap()
1913 );
1914 assert_eq!(
1915 state.stream_symbol_ids[&original.instrument.id()],
1916 original.symbol_id
1917 );
1918 let expected = updates
1919 .iter()
1920 .find(|update| update.symbol_id == symbol_id)
1921 .unwrap();
1922 assert_eq!(
1923 serde_json::to_value(&state.by_symbol_id[&symbol_id]).unwrap(),
1924 serde_json::to_value(&expected.instrument).unwrap()
1925 );
1926 }
1927 });
1928 }
1929 });
1930 }
1931
1932 #[rstest]
1933 fn close_period_emits_latest_update_of_prior_period_once() {
1934 let mut cache = OhlcvCache::new();
1935
1936 let first = close_period(&mut cache, update(10, "1"));
1937 let repeat = close_period(&mut cache, update(10, "2"));
1938 let next = close_period(&mut cache, update(11, "3"));
1939 let late = close_period(&mut cache, update(10, "4"));
1940 let skipped = close_period(&mut cache, update(13, "5"));
1941
1942 assert_eq!(first, None);
1943 assert_eq!(repeat, None);
1944 assert_eq!(next, Some(update(10, "2")));
1945 assert_eq!(late, None);
1946 assert_eq!(skipped, Some(update(11, "3")));
1947 assert_eq!(cache.len(), 1);
1948 assert_eq!(cache.values().next(), Some(&update(13, "5")));
1949 }
1950}