1use std::{
28 fmt::Debug,
29 sync::{
30 Arc, Weak,
31 atomic::{AtomicBool, AtomicU64, Ordering},
32 },
33};
34
35use nautilus_core::string::secret::SecretString;
36use nautilus_live::book::snapshot::SnapshotGate;
37use nautilus_network::{
38 Message, RECONNECTED, dst,
39 websocket::{
40 AuthTracker, MessageHandler, SubscriptionState, TransportBackend, WebSocketClient,
41 WebSocketConfig, proxy::ProxyTarget, split_topic,
42 },
43};
44use parking_lot::Mutex;
45use url::Url;
46use ustr::Ustr;
47
48use super::{
49 error::Error,
50 messages::{Channel, CoinApiWsMessage, DataType, Frame, Hello, ScopeChange, Subscription},
51};
52use crate::{
53 book::sync::{BookReceipts, BookSubscriptions, BookWrite, BookWrites},
54 common::{consts::COINAPI_NAUTILUS_REFERRER, credential::is_valid_api_key},
55};
56
57pub const WS_URL: &str = "wss://ws.coinapi.io/v1/";
59
60const IDLE_TIMEOUT_MS: u64 = 5_000;
62const RECONNECT_DELAY_INITIAL_MS: u64 = 1_000;
63const RECONNECT_DELAY_MAX_MS: u64 = 30_000;
64const RECONNECT_BACKOFF_FACTOR: f64 = 2.0;
65const RECONNECT_JITTER_MS: u64 = 500;
66const TOPIC_DELIMITER: char = ':';
67const PERIOD_DELIMITER: char = '.';
68
69const HELLO_INTERVAL_MIN: dst::time::Duration = dst::time::Duration::from_secs(2);
72const RESTART_INTERVAL_MIN: dst::time::Duration = dst::time::Duration::from_secs(5);
73
74#[derive(Debug, Clone)]
76pub struct CoinApiWebSocketConfig {
77 pub url: String,
79 pub backend: TransportBackend,
81 pub proxy_url: Option<SecretString>,
83}
84
85impl Default for CoinApiWebSocketConfig {
86 fn default() -> Self {
87 Self {
88 url: WS_URL.into(),
89 backend: TransportBackend::default(),
90 proxy_url: None,
91 }
92 }
93}
94
95#[derive(Debug)]
105pub struct CoinApiWebSocketClient {
106 api_key: SecretString,
107 config: CoinApiWebSocketConfig,
108 connection: Arc<tokio::sync::Mutex<Option<Connection>>>,
109 hello_at: Arc<Mutex<Option<dst::time::Instant>>>,
110 next_connection_id: AtomicU64,
111 out_tx: StreamSender,
112 books: Option<Arc<BookSubscriptions>>,
113}
114
115impl CoinApiWebSocketClient {
116 pub fn new(
122 api_key: SecretString,
123 config: CoinApiWebSocketConfig,
124 ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver<CoinApiWsMessage>), Error> {
125 if !is_valid_api_key(api_key.expose_secret()) {
126 return Err(Error::Validation(
127 "API key must contain visible ASCII characters".into(),
128 ));
129 }
130
131 let url = Url::parse(&config.url).map_err(|_| Error::Validation("invalid URL".into()))?;
132
133 if !matches!(url.scheme(), "ws" | "wss")
134 || url.host_str().is_none()
135 || !url.username().is_empty()
136 || url.password().is_some()
137 || url.query().is_some()
138 || url.fragment().is_some()
139 {
140 return Err(Error::Validation(
141 "URL must be WS(S) without credentials, query, or fragment".into(),
142 ));
143 }
144
145 if let Some(proxy) = &config.proxy_url {
146 ProxyTarget::parse(proxy.expose_secret())
147 .map_err(|_| Error::Validation("proxy URL must be a valid HTTP(S) URL".into()))?;
148 }
149
150 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel();
151
152 let client = Self {
153 api_key,
154 config,
155 connection: Arc::new(tokio::sync::Mutex::new(None)),
156 hello_at: Arc::new(Mutex::new(None)),
157 next_connection_id: AtomicU64::new(0),
158 out_tx: StreamSender::Public(out_tx),
159 books: None,
160 };
161
162 Ok((client, out_rx))
163 }
164
165 pub async fn subscribe(&self, subscription: Subscription) -> Result<(), Error> {
174 self.apply_one(ScopeChange::Subscribe(subscription)).await
175 }
176
177 pub async fn unsubscribe(&self, subscription: Subscription) -> Result<(), Error> {
186 self.apply_one(ScopeChange::Unsubscribe(subscription)).await
187 }
188
189 pub async fn apply(&self, changes: &[ScopeChange]) -> Result<Vec<(ScopeChange, Error)>, Error> {
201 self.apply_registered(changes, &[], &[]).await
202 }
203
204 pub(crate) fn bind_books(
205 &mut self,
206 books: Arc<BookSubscriptions>,
207 ) -> tokio::sync::mpsc::UnboundedReceiver<StreamMessage> {
208 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
209 self.books = Some(books);
210 self.out_tx = StreamSender::Bound(sender);
211 receiver
212 }
213
214 pub(crate) async fn apply_registered(
215 &self,
216 changes: &[ScopeChange],
217 registrations: &[Option<Arc<AtomicBool>>],
218 gates: &[Option<SnapshotGate>],
219 ) -> Result<Vec<(ScopeChange, Error)>, Error> {
220 let mut rejected = Vec::new();
221 let mut valid = Vec::with_capacity(changes.len());
222
223 for (index, change) in changes.iter().enumerate() {
224 match change.subscription().validate() {
225 Ok(()) => valid.push((
226 *change,
227 registrations.get(index).cloned().flatten(),
228 gates.get(index).cloned().flatten(),
229 )),
230 Err(e) => rejected.push((*change, e)),
231 }
232 }
233
234 let mut guard = self.connection.lock().await;
235 let fresh = guard.is_none();
236
237 if fresh
238 && !valid
239 .iter()
240 .any(|(change, _, _)| matches!(change, ScopeChange::Subscribe(_)))
241 {
242 return Ok(rejected);
243 }
244
245 if fresh {
246 let connection = self.connect().await?;
247 connection.auth.begin();
248 *guard = Some(connection);
249 }
250
251 let Some(connection) = guard.as_mut() else {
252 return Ok(rejected);
253 };
254
255 let result = connection.apply_scope(&valid, &mut rejected).await;
256 if (matches!(result, Ok(false)) || (fresh && result.is_err()))
257 && let Some(connection) = guard.take()
258 {
259 connection.close().await;
260 }
261
262 result.map(|_| rejected)
263 }
264
265 async fn apply_one(&self, change: ScopeChange) -> Result<(), Error> {
266 match self.apply(&[change]).await?.pop() {
267 Some((_, e)) => Err(e),
268 None => Ok(()),
269 }
270 }
271
272 pub async fn resubscribe(
292 &self,
293 subscription: Subscription,
294 paused: impl FnOnce(),
295 ) -> Result<(), Error> {
296 self.resubscribe_inner(subscription, paused, None)
297 .await
298 .map(|_| ())
299 }
300
301 pub(crate) async fn resubscribe_book(
302 &self,
303 subscription: Subscription,
304 paused: impl FnOnce(),
305 gate: SnapshotGate,
306 ) -> Result<Option<BookWrites>, Error> {
307 self.resubscribe_inner(subscription, paused, Some(gate))
308 .await
309 }
310
311 async fn resubscribe_inner(
312 &self,
313 subscription: Subscription,
314 paused: impl FnOnce(),
315 gate: Option<SnapshotGate>,
316 ) -> Result<Option<BookWrites>, Error> {
317 subscription.validate()?;
318
319 if !subscription.channel.data_type.is_book() {
320 return Err(Error::Validation(format!(
321 "{} has no snapshot to restart",
322 topic(&subscription)
323 )));
324 }
325
326 let mut guard = self.connection.lock().await;
327
328 let Some(connection) = guard
329 .as_mut()
330 .filter(|connection| is_subscribed(&connection.subscriptions, &subscription))
331 else {
332 return Err(Error::Validation(format!(
333 "{} is not subscribed",
334 topic(&subscription)
335 )));
336 };
337
338 if let Some(restarted_at) = connection.restarted_at {
339 dst::time::sleep_until(restarted_at + RESTART_INTERVAL_MIN).await;
340 }
341
342 let scope = scope(&connection.subscriptions);
343 let removal = removal_scope(&scope, &subscription);
344 let pauses = removal
345 .iter()
346 .all(|other| other.symbol_id != subscription.symbol_id);
347
348 let removed = send_paced(connection, &removal).await;
349 connection.restarted_at = *connection.hello_at.lock();
350 removed?;
351
352 if pauses {
353 paused();
354 }
355
356 let snapshots = gate
357 .map(|gate| (subscription, None, gate))
358 .into_iter()
359 .collect::<Vec<_>>();
360 send_paced_books(connection, &scope, &snapshots).await
361 }
362
363 pub async fn close(&self) {
367 let mut guard = self.connection.lock().await;
368 if let Some(connection) = guard.take() {
369 connection.close().await;
370 }
371 }
372
373 async fn connect(&self) -> Result<Connection, Error> {
374 let config = WebSocketConfig::builder()
375 .url(self.config.url.clone())
376 .headers(vec![
377 (
378 "X-CoinAPI-Key".into(),
379 self.api_key.expose_secret().to_owned(),
380 ),
381 ("X-Referrer".into(), COINAPI_NAUTILUS_REFERRER.into()),
382 ])
383 .idle_timeout_ms(IDLE_TIMEOUT_MS)
384 .reconnect_delay_initial_ms(RECONNECT_DELAY_INITIAL_MS)
385 .reconnect_delay_max_ms(RECONNECT_DELAY_MAX_MS)
386 .reconnect_backoff_factor(RECONNECT_BACKOFF_FACTOR)
387 .reconnect_jitter_ms(RECONNECT_JITTER_MS)
388 .backend(self.config.backend)
389 .maybe_proxy_url(
390 self.config
391 .proxy_url
392 .as_ref()
393 .map(|url| url.expose_secret().to_owned()),
394 )
395 .build()
396 .map_err(|e| Error::Validation(e.to_string()))?;
397
398 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
399 let books = self.books.clone();
400 let handler: MessageHandler = Arc::new(move |message| {
401 let receipts = books.as_ref().map(|books| books.sync.receipts());
402 let _ = raw_tx.send((message, receipts));
403 });
404
405 let client = WebSocketClient::builder()
406 .config(config)
407 .message_handler(handler)
408 .connect()
409 .await?;
410
411 let auth = AuthTracker::new();
412 client.set_auth_tracker(auth.clone(), false);
413 let client = Arc::new(client);
414 let subscriptions = SubscriptionState::new(TOPIC_DELIMITER);
415 let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed);
416
417 let handler = ConnectionHandler {
418 id,
419 api_key: self.api_key.clone(),
420 client: Arc::clone(&client),
421 connection: Arc::downgrade(&self.connection),
422 auth: auth.clone(),
423 subscriptions: subscriptions.clone(),
424 out_tx: self.out_tx.clone(),
425 books: self.books.clone(),
426 };
427
428 let task = dst::task::spawn(handler.run(raw_rx));
429
430 Ok(Connection {
431 id,
432 client,
433 auth,
434 subscriptions,
435 hello_at: Arc::clone(&self.hello_at),
436 restarted_at: None,
437 task,
438 books: self.books.clone(),
439 })
440 }
441}
442
443type RegisteredChange = (ScopeChange, Option<Arc<AtomicBool>>, Option<SnapshotGate>);
444
445struct Connection {
446 id: u64,
447 client: Arc<WebSocketClient>,
448 auth: AuthTracker,
449 subscriptions: SubscriptionState,
450 hello_at: Arc<Mutex<Option<dst::time::Instant>>>,
451 restarted_at: Option<dst::time::Instant>,
452 task: dst::task::JoinHandle<()>,
453 books: Option<Arc<BookSubscriptions>>,
454}
455
456impl Debug for Connection {
457 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
458 f.debug_struct(stringify!(Connection))
459 .field("id", &self.id)
460 .field("auth", &self.auth.auth_state())
461 .field("subscriptions", &self.subscriptions)
462 .finish_non_exhaustive()
463 }
464}
465
466impl Connection {
467 async fn apply_scope(
468 &self,
469 changes: &[RegisteredChange],
470 rejected: &mut Vec<(ScopeChange, Error)>,
471 ) -> Result<bool, Error> {
472 let applied = self.change_scope(changes, rejected);
473 let scope = scope(&self.subscriptions);
474
475 if scope.is_empty() {
476 return Ok(false);
477 }
478
479 if applied.is_empty() {
480 return Ok(true);
481 }
482
483 let snapshots = changes
484 .iter()
485 .filter_map(|(change, registration, gate)| {
486 if let ScopeChange::Subscribe(subscription) = change
487 && applied.contains(change)
488 && let Some(gate) = gate
489 {
490 Some((*subscription, registration.clone(), gate.clone()))
491 } else {
492 None
493 }
494 })
495 .collect::<Vec<_>>();
496
497 let writes = match send_paced_books(self, &scope, &snapshots).await {
498 Ok(writes) => writes,
499 Err(e) => {
500 revert(&self.subscriptions, &applied);
501 return Err(e);
502 }
503 };
504
505 self.commit_scope(changes, &applied);
506
507 if let Some(writes) = writes {
508 writes.commit();
509 }
510
511 Ok(true)
512 }
513
514 fn commit_scope(&self, changes: &[RegisteredChange], applied: &[ScopeChange]) {
515 for (change, registration, _) in changes {
517 if matches!(change, ScopeChange::Subscribe(_))
518 && applied.contains(change)
519 && let Some(registration) = registration
520 {
521 registration.store(true, Ordering::Relaxed);
522 }
523 }
524
525 for change in applied {
526 if let ScopeChange::Unsubscribe(subscription) = change {
527 self.subscriptions.confirm_unsubscribe(&topic(subscription));
528 }
529 }
530 }
531
532 fn change_scope(
533 &self,
534 changes: &[RegisteredChange],
535 rejected: &mut Vec<(ScopeChange, Error)>,
536 ) -> Vec<ScopeChange> {
537 let mut applied = Vec::with_capacity(changes.len());
538
539 for (change, _, _) in changes {
540 let change = *change;
541 let topic = topic(change.subscription());
542
543 match change {
544 ScopeChange::Subscribe(subscription) => {
545 let scope = scope(&self.subscriptions);
546
547 if let Err(e) = check_book_type(&scope, &subscription) {
548 rejected.push((change, e));
549 } else if self.subscriptions.try_mark_subscribe(&topic) {
550 applied.push(change);
551 }
552 }
553 ScopeChange::Unsubscribe(subscription) => {
554 if is_subscribed(&self.subscriptions, &subscription) {
555 self.subscriptions.mark_unsubscribe(&topic);
556 applied.push(change);
557 }
558 }
559 }
560 }
561
562 applied
563 }
564
565 async fn close(self) {
566 self.task.abort();
567 self.client.disconnect().await;
568 }
569}
570
571impl Drop for Connection {
572 fn drop(&mut self) {
573 self.task.abort();
574 }
575}
576
577#[derive(Debug)]
578pub(crate) struct StreamMessage {
579 pub(crate) message: CoinApiWsMessage,
580 pub(crate) write: Option<Arc<BookWrite>>,
581}
582
583#[derive(Debug, Clone)]
584enum StreamSender {
585 Public(tokio::sync::mpsc::UnboundedSender<CoinApiWsMessage>),
586 Bound(tokio::sync::mpsc::UnboundedSender<StreamMessage>),
587}
588
589impl StreamSender {
590 fn send(&self, message: CoinApiWsMessage, receipts: Option<&BookReceipts>) {
591 match self {
592 Self::Public(sender) => {
593 let _ = sender.send(message);
594 }
595 Self::Bound(sender) => {
596 let write = message.subscription().and_then(|subscription| {
597 receipts.and_then(|receipts| receipts.get(&subscription).cloned())
598 });
599
600 let _ = sender.send(StreamMessage { message, write });
601 }
602 }
603 }
604}
605
606struct ConnectionHandler {
608 id: u64,
609 api_key: SecretString,
610 client: Arc<WebSocketClient>,
611 connection: Weak<tokio::sync::Mutex<Option<Connection>>>,
612 auth: AuthTracker,
613 subscriptions: SubscriptionState,
614 out_tx: StreamSender,
615 books: Option<Arc<BookSubscriptions>>,
616}
617
618impl ConnectionHandler {
619 async fn run(
620 self,
621 mut raw_rx: tokio::sync::mpsc::UnboundedReceiver<(Message, Option<BookReceipts>)>,
622 ) {
623 while let Some((message, receipts)) = raw_rx.recv().await {
624 let Message::Text(bytes) = message else {
625 continue;
626 };
627
628 if bytes.as_ref() == RECONNECTED.as_bytes() {
629 self.replay().await;
630 continue;
631 }
632
633 match Frame::decode(&bytes) {
634 Ok(Frame::Data(message)) => self.route(message, receipts.as_ref()),
635 Ok(Frame::Heartbeat) => self.confirm_session(),
636 Ok(Frame::Reconnect(notice)) => {
637 log::info!(
638 "CoinAPI will close the connection before {}",
639 notice.before_time
640 );
641 }
642 Ok(Frame::Error(error)) => {
643 let message = error
644 .message
645 .replace(self.api_key.expose_secret(), "<redacted>");
646 self.retire(message).await;
647 return;
648 }
649 Ok(Frame::Unknown(_)) => log::debug!("Ignoring unknown CoinAPI frame type"),
650 Err(e) => {
651 log::warn!(
652 "Failed to decode CoinAPI frame: category={:?}, line={}, column={}, bytes={}",
653 e.classify(),
654 e.line(),
655 e.column(),
656 bytes.len()
657 );
658 }
659 }
660 }
661 }
662
663 fn route(&self, message: CoinApiWsMessage, receipts: Option<&BookReceipts>) {
664 let Some(subscription) = message.subscription() else {
665 return;
666 };
667
668 if !is_subscribed(&self.subscriptions, &subscription) {
670 log::trace!("Dropping unsubscribed CoinAPI frame");
671 return;
672 }
673
674 self.subscriptions.confirm_subscribe(&topic(&subscription));
675 self.confirm_session();
676 self.out_tx.send(message, receipts);
677 }
678
679 fn confirm_session(&self) {
680 if !self.auth.is_authenticated() {
681 self.auth.succeed();
682 }
683 }
684
685 async fn replay(&self) {
686 let Some(connection) = self.connection.upgrade() else {
687 return;
688 };
689
690 let mut guard = connection.lock().await;
691
692 let Some(connection) = guard.as_mut().filter(|connection| connection.id == self.id) else {
693 return;
694 };
695
696 self.subscriptions.reset_after_reconnect();
697 self.auth.begin();
698
699 match send_paced(connection, &scope(&self.subscriptions)).await {
701 Ok(()) => {
702 self.out_tx.send(CoinApiWsMessage::Reconnected, None);
703 }
704 Err(e) => log::error!("Failed to replay CoinAPI scope: {e}"),
705 }
706 }
707
708 async fn retire(&self, message: String) {
709 log::error!("CoinAPI rejected the session: {message}");
710 self.auth.fail(message.clone());
711
712 let Some(connection) = self.connection.upgrade() else {
713 return;
714 };
715
716 let mut guard = connection.lock().await;
717 self.client.disconnect().await;
718
719 if guard
720 .as_ref()
721 .is_some_and(|connection| connection.id == self.id)
722 && let Some(books) = &self.books
723 {
724 books.retire();
725 }
726
727 self.out_tx.send(CoinApiWsMessage::Error(message), None);
728
729 if guard
731 .as_ref()
732 .is_some_and(|connection| connection.id == self.id)
733 {
734 guard.take();
735 }
736 }
737}
738
739async fn send_paced(connection: &Connection, scope: &[Subscription]) -> Result<(), Error> {
741 send_paced_books(connection, scope, &[]).await.map(|_| ())
742}
743
744async fn send_paced_books(
745 connection: &Connection,
746 scope: &[Subscription],
747 snapshots: &[(Subscription, Option<Arc<AtomicBool>>, SnapshotGate)],
748) -> Result<Option<BookWrites>, Error> {
749 let hello_at = *connection.hello_at.lock();
750 if let Some(hello_at) = hello_at {
751 dst::time::sleep_until(hello_at + HELLO_INTERVAL_MIN).await;
752 }
753
754 let writes = connection
757 .books
758 .as_ref()
759 .map(|books| books.begin_writes(snapshots));
760 *connection.hello_at.lock() = Some(dst::time::Instant::now());
761 let result = send_scope(&connection.client, scope).await;
762 *connection.hello_at.lock() = Some(dst::time::Instant::now());
763
764 result?;
765 Ok(writes)
766}
767
768fn revert(subscriptions: &SubscriptionState, applied: &[ScopeChange]) {
770 for change in applied.iter().rev() {
771 let topic = topic(change.subscription());
772
773 match change {
774 ScopeChange::Subscribe(_) => {
775 subscriptions.mark_unsubscribe(&topic);
776 subscriptions.confirm_unsubscribe(&topic);
777 }
778 ScopeChange::Unsubscribe(_) => subscriptions.mark_subscribe(&topic),
779 }
780 }
781}
782
783async fn send_scope(client: &WebSocketClient, scope: &[Subscription]) -> Result<(), Error> {
786 if scope.is_empty() {
787 return Ok(());
788 }
789
790 let hello = serde_json::to_string(&Hello::new(scope))
791 .expect("CoinAPI hello contains only serializable fields");
792 client
793 .send_text_on_connection(hello, None, client.connection_epoch())
794 .await?;
795 Ok(())
796}
797
798fn scope(subscriptions: &SubscriptionState) -> Vec<Subscription> {
799 subscriptions
800 .all_topics()
801 .iter()
802 .filter_map(|topic| parse_topic(topic))
803 .collect()
804}
805
806fn check_book_type(scope: &[Subscription], subscription: &Subscription) -> Result<(), Error> {
808 let requested = subscription.channel.data_type;
809
810 if !requested.is_book() {
811 return Ok(());
812 }
813
814 match scope
815 .iter()
816 .map(|subscription| subscription.channel.data_type)
817 .find(|active| active.is_book() && *active != requested)
818 {
819 Some(active) => Err(Error::Validation(format!(
820 "scope already streams {}, so cannot add {}",
821 active.as_ref(),
822 requested.as_ref()
823 ))),
824 None => Ok(()),
825 }
826}
827
828fn removal_scope(scope: &[Subscription], pair: &Subscription) -> Vec<Subscription> {
831 let others: Vec<Subscription> = scope
832 .iter()
833 .filter(|subscription| subscription.symbol_id != pair.symbol_id)
834 .copied()
835 .collect();
836
837 if !others.is_empty() {
838 return others;
839 }
840
841 let channels: Vec<Subscription> = scope
842 .iter()
843 .filter(|subscription| *subscription != pair)
844 .copied()
845 .collect();
846
847 if !channels.is_empty() {
848 return channels;
849 }
850
851 vec![Subscription {
852 channel: Channel {
853 data_type: DataType::Trade,
854 period_id: None,
855 },
856 symbol_id: pair.symbol_id,
857 }]
858}
859
860fn is_subscribed(subscriptions: &SubscriptionState, subscription: &Subscription) -> bool {
861 subscriptions.is_subscribed(
862 &Ustr::from(&channel_key(&subscription.channel)),
863 &subscription.symbol_id,
864 )
865}
866
867fn topic(subscription: &Subscription) -> String {
868 format!(
869 "{}{TOPIC_DELIMITER}{}",
870 channel_key(&subscription.channel),
871 subscription.symbol_id
872 )
873}
874
875fn channel_key(channel: &Channel) -> String {
876 match channel.period_id {
877 Some(period) => format!("{}{PERIOD_DELIMITER}{period}", channel.data_type.as_ref()),
878 None => channel.data_type.as_ref().to_owned(),
879 }
880}
881
882fn parse_topic(topic: &str) -> Option<Subscription> {
883 let (channel, symbol_id) = split_topic(topic, TOPIC_DELIMITER);
884
885 let (data_type, period_id) = match channel.split_once(PERIOD_DELIMITER) {
886 Some((data_type, period)) => (data_type, Some(period.into())),
887 None => (channel, None),
888 };
889
890 Some(Subscription {
891 channel: Channel {
892 data_type: data_type.parse().ok()?,
893 period_id,
894 },
895 symbol_id: Ustr::from(symbol_id?),
896 })
897}
898
899#[cfg(test)]
900mod tests {
901 #[cfg(not(all(feature = "simulation", madsim)))]
902 use futures_util::{SinkExt, StreamExt};
903 #[cfg(not(all(feature = "simulation", madsim)))]
904 use nautilus_network::error::SendError;
905 use rstest::rstest;
906
907 use super::*;
908
909 fn sub(data_type: DataType, symbol_id: &str) -> Subscription {
910 Subscription {
911 channel: Channel {
912 data_type,
913 period_id: None,
914 },
915 symbol_id: symbol_id.into(),
916 }
917 }
918
919 #[rstest]
920 #[case::other_symbols_remain(
921 vec![sub(DataType::Book, "A"), sub(DataType::Trade, "A"), sub(DataType::Book, "B")],
922 vec![sub(DataType::Book, "B")]
923 )]
924 #[case::only_symbol_keeps_other_channels(
925 vec![sub(DataType::Book, "A"), sub(DataType::Trade, "A"), sub(DataType::Quote, "A")],
926 vec![sub(DataType::Trade, "A"), sub(DataType::Quote, "A")]
927 )]
928 #[case::pair_is_whole_scope(vec![sub(DataType::Book, "A")], vec![sub(DataType::Trade, "A")])]
929 fn removal_scope_leaves_pair_out_of_effective_scope(
930 #[case] scope: Vec<Subscription>,
931 #[case] expected: Vec<Subscription>,
932 ) {
933 assert_eq!(removal_scope(&scope, &sub(DataType::Book, "A")), expected);
934 }
935
936 #[rstest]
937 #[case(DataType::Book, None)]
938 #[case(DataType::Trade, None)]
939 #[case(
940 DataType::Book5,
941 Some("Invalid CoinAPI WebSocket request: scope already streams book, so cannot add book5")
942 )]
943 #[case(
944 DataType::BookL3,
945 Some(
946 "Invalid CoinAPI WebSocket request: scope already streams book, so cannot add book_l3"
947 )
948 )]
949 fn scope_accepts_one_book_type(#[case] requested: DataType, #[case] expected: Option<&str>) {
950 let scope = [sub(DataType::Trade, "A"), sub(DataType::Book, "A")];
951
952 let result = check_book_type(&scope, &sub(requested, "B"));
953
954 assert_eq!(result.err().map(|e| e.to_string()).as_deref(), expected);
955 }
956
957 #[rstest]
958 fn rollback_undoes_repeated_scope_changes_in_reverse_order() {
959 let subscriptions = SubscriptionState::new(TOPIC_DELIMITER);
960 let a = sub(DataType::Trade, "A");
961 let b = sub(DataType::Trade, "B");
962 let c = sub(DataType::Trade, "C");
963 let d = sub(DataType::Trade, "D");
964
965 for subscription in [a, b] {
966 subscriptions.mark_subscribe(&topic(&subscription));
967 subscriptions.confirm_subscribe(&topic(&subscription));
968 }
969
970 let changes = [
971 ScopeChange::Unsubscribe(a),
972 ScopeChange::Subscribe(c),
973 ScopeChange::Unsubscribe(c),
974 ScopeChange::Subscribe(a),
975 ScopeChange::Unsubscribe(b),
976 ScopeChange::Subscribe(d),
977 ];
978
979 for change in &changes {
980 match change {
981 ScopeChange::Subscribe(subscription) => {
982 subscriptions.mark_subscribe(&topic(subscription));
983 }
984 ScopeChange::Unsubscribe(subscription) => {
985 subscriptions.mark_unsubscribe(&topic(subscription));
986 }
987 }
988 }
989
990 revert(&subscriptions, &changes);
991
992 assert_eq!(scope(&subscriptions), [a, b]);
993 }
994
995 #[cfg(not(all(feature = "simulation", madsim)))]
996 #[tokio::test]
997 async fn failed_send_on_existing_connection_rolls_back_scope() {
998 tokio::time::timeout(std::time::Duration::from_secs(10), async {
999 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1000
1001 let (client, _messages) = CoinApiWebSocketClient::new(
1002 "test-key".into(),
1003 CoinApiWebSocketConfig {
1004 url: format!("ws://{}", listener.local_addr().unwrap()),
1005 backend: TransportBackend::Tungstenite,
1006 ..CoinApiWebSocketConfig::default()
1007 },
1008 )
1009 .unwrap();
1010
1011 let peer = tokio::spawn(async move {
1012 let (socket, _) = listener.accept().await.unwrap();
1013 let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
1014 let hello = socket.next().await.unwrap().unwrap();
1015 while let Some(message) = socket.next().await {
1016 if message.unwrap().is_close() {
1017 socket.flush().await.unwrap();
1018 break;
1019 }
1020 }
1021
1022 hello
1023 });
1024
1025 let original = sub(DataType::Trade, "COINBASE_SPOT_BTC_USD");
1026 let replacement = sub(DataType::Quote, "COINBASE_SPOT_BTC_USD");
1027 assert!(
1028 client
1029 .apply(&[ScopeChange::Subscribe(original)])
1030 .await
1031 .unwrap()
1032 .is_empty()
1033 );
1034 let transport = client
1035 .connection
1036 .lock()
1037 .await
1038 .as_ref()
1039 .unwrap()
1040 .client
1041 .clone();
1042 transport.disconnect().await;
1043 let hello = peer.await.unwrap();
1044
1045 let error = client
1046 .apply(&[
1047 ScopeChange::Unsubscribe(original),
1048 ScopeChange::Subscribe(replacement),
1049 ])
1050 .await
1051 .unwrap_err();
1052 let remaining = scope(
1053 &client
1054 .connection
1055 .lock()
1056 .await
1057 .as_ref()
1058 .unwrap()
1059 .subscriptions,
1060 );
1061 client.close().await;
1062
1063 assert_eq!(hello.into_text().unwrap(), r#"{"type":"hello","heartbeat":true,"subscribe_data_type":["trade"],"subscribe_filter_symbol_id":["COINBASE_SPOT_BTC_USD$"]}"#);
1064 assert!(matches!(error, Error::Send(SendError::Closed)));
1065 assert_eq!(remaining, [original]);
1066 })
1067 .await
1068 .unwrap();
1069 }
1070}