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