1use std::{
19 collections::HashMap,
20 sync::{
21 Arc,
22 atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
23 },
24};
25
26use arc_swap::ArcSwap;
27use nautilus_core::AtomicMap;
28use nautilus_live::{
29 SocketControl,
30 task::{TaskGroup, TaskShutdownError},
31};
32use nautilus_model::{
33 data::BarType,
34 enums::BarAggregation,
35 identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
36 instruments::{Instrument, InstrumentAny},
37};
38use nautilus_network::{
39 mode::ConnectionMode,
40 websocket::{
41 AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
42 channel_message_handler,
43 },
44};
45use parking_lot::RwLock;
46use tokio_util::sync::CancellationToken;
47use ustr::Ustr;
48
49pub const KRAKEN_SPOT_WS_TOPIC_DELIMITER: char = ':';
53
54use super::{
55 enums::{KrakenWsChannel, KrakenWsMethod},
56 handler::{SpotFeedHandler, SpotHandlerCommand},
57 level_2::L2Depths,
58 messages::{KrakenSpotWsMessage, KrakenWsChannelParams, KrakenWsParams, KrakenWsRequest},
59};
60use crate::{
61 common::{
62 consts::{
63 KRAKEN_RATE_LIMIT_KEY_ORDER, KRAKEN_RATE_LIMIT_KEY_SUBSCRIPTION,
64 KRAKEN_SPOT_WS_ORDER_QUOTA, KRAKEN_SPOT_WS_SUBSCRIPTION_QUOTA,
65 },
66 parse::normalize_spot_symbol,
67 },
68 config::KrakenDataClientConfig,
69 http::{KrakenSpotHttpClient, spot::client::KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND},
70 websocket::error::KrakenWsError,
71};
72
73const WS_PING_MSG: &str = r#"{"method":"ping"}"#;
74
75#[derive(Debug)]
77pub struct KrakenSpotWebSocketClient {
78 url: String,
79 config: KrakenDataClientConfig,
80 signal: Arc<AtomicBool>,
81 connection_mode: Arc<ArcSwap<AtomicU8>>,
82 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<SpotHandlerCommand>>>,
83 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<KrakenSpotWsMessage>>>,
84 handler_tasks: Arc<TaskGroup>,
85 connect_lock: Arc<tokio::sync::Mutex<()>>,
86 subscriptions: SubscriptionState,
87 subscription_payloads: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
88 auth_tracker: AuthTracker,
89 cancellation_token: CancellationToken,
90 req_id_counter: Arc<AtomicU64>,
91 auth_token: Arc<tokio::sync::RwLock<Option<String>>>,
92 account_id: Arc<RwLock<Option<AccountId>>>,
93 truncated_id_map: Arc<AtomicMap<String, ClientOrderId>>,
94 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
95 l2_depths: L2Depths,
96 l3_depths: Arc<parking_lot::Mutex<ahash::AHashMap<String, u32>>>,
97 transport_backend: TransportBackend,
98 proxy_url: Option<String>,
99 socket_control: Option<SocketControl>,
100}
101
102impl Clone for KrakenSpotWebSocketClient {
103 fn clone(&self) -> Self {
104 Self {
105 url: self.url.clone(),
106 config: self.config.clone(),
107 signal: Arc::clone(&self.signal),
108 connection_mode: Arc::clone(&self.connection_mode),
109 cmd_tx: Arc::clone(&self.cmd_tx),
110 out_rx: self.out_rx.clone(),
111 handler_tasks: Arc::clone(&self.handler_tasks),
112 connect_lock: Arc::clone(&self.connect_lock),
113 subscriptions: self.subscriptions.clone(),
114 subscription_payloads: Arc::clone(&self.subscription_payloads),
115 auth_tracker: self.auth_tracker.clone(),
116 cancellation_token: self.cancellation_token.clone(),
117 req_id_counter: self.req_id_counter.clone(),
118 auth_token: self.auth_token.clone(),
119 account_id: Arc::clone(&self.account_id),
120 truncated_id_map: Arc::clone(&self.truncated_id_map),
121 instruments: Arc::clone(&self.instruments),
122 l2_depths: self.l2_depths.clone(),
123 l3_depths: Arc::clone(&self.l3_depths),
124 transport_backend: self.transport_backend,
125 proxy_url: self.proxy_url.clone(),
126 socket_control: self.socket_control.clone(),
127 }
128 }
129}
130
131impl KrakenSpotWebSocketClient {
132 pub fn new(
134 config: KrakenDataClientConfig,
135 cancellation_token: CancellationToken,
136 proxy_url: Option<String>,
137 ) -> Self {
138 let url = if config.ws_private_url.is_some() {
139 config.ws_private_url()
140 } else {
141 config.ws_public_url()
142 };
143 Self::new_with_url(url, config, cancellation_token, proxy_url)
144 }
145
146 pub fn l3(
151 config: KrakenDataClientConfig,
152 cancellation_token: CancellationToken,
153 proxy_url: Option<String>,
154 ) -> Self {
155 let url = config.ws_l3_url();
156 Self::new_with_url(url, config, cancellation_token, proxy_url)
157 }
158
159 fn new_with_url(
160 url: String,
161 mut config: KrakenDataClientConfig,
162 cancellation_token: CancellationToken,
163 proxy_url: Option<String>,
164 ) -> Self {
165 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<SpotHandlerCommand>();
166 let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
167 let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
168
169 let transport_backend = config.transport_backend;
170 config.proxy_url = proxy_url.clone();
171
172 Self {
173 url,
174 config,
175 signal: Arc::new(AtomicBool::new(false)),
176 connection_mode,
177 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
178 out_rx: None,
179 handler_tasks: Arc::new(TaskGroup::new()),
180 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
181 subscriptions: SubscriptionState::new(KRAKEN_SPOT_WS_TOPIC_DELIMITER),
182 subscription_payloads: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
183 auth_tracker: AuthTracker::new(),
184 cancellation_token,
185 req_id_counter: Arc::new(AtomicU64::new(0)),
186 auth_token: Arc::new(tokio::sync::RwLock::new(None)),
187 account_id: Arc::new(RwLock::new(None)),
188 truncated_id_map: Arc::new(AtomicMap::new()),
189 instruments: Arc::new(AtomicMap::new()),
190 l2_depths: L2Depths::default(),
191 l3_depths: Arc::new(parking_lot::Mutex::new(ahash::AHashMap::new())),
192 transport_backend,
193 proxy_url,
194 socket_control: None,
195 }
196 }
197
198 pub(crate) fn begin_shutdown(&self) {
199 self.handler_tasks.begin_shutdown();
200 self.cancellation_token.cancel();
201 self.signal.store(true, Ordering::Relaxed);
202 }
203
204 #[must_use]
206 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
207 self.socket_control = Some(control);
208 self
209 }
210
211 fn get_next_req_id(&self) -> u64 {
212 self.req_id_counter.fetch_add(1, Ordering::Relaxed) + 1
213 }
214
215 pub fn req_id_counter(&self) -> Arc<AtomicU64> {
217 self.req_id_counter.clone()
218 }
219
220 pub async fn handler_command_sender(
222 &self,
223 ) -> tokio::sync::mpsc::UnboundedSender<SpotHandlerCommand> {
224 self.cmd_tx.read().await.clone()
225 }
226
227 pub fn handler_command_handle(
232 &self,
233 ) -> Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<SpotHandlerCommand>>> {
234 self.cmd_tx.clone()
235 }
236
237 pub async fn auth_token(&self) -> Option<String> {
239 self.auth_token.read().await.clone()
240 }
241
242 pub fn auth_token_blocking(&self) -> Option<String> {
248 self.auth_token.try_read().ok().and_then(|g| g.clone())
249 }
250
251 pub fn auth_token_handle(&self) -> Arc<tokio::sync::RwLock<Option<String>>> {
255 self.auth_token.clone()
256 }
257
258 pub async fn connect(&mut self) -> Result<(), KrakenWsError> {
260 let connect_lock = Arc::clone(&self.connect_lock);
261 let _connect_guard = connect_lock.lock().await;
262
263 log::debug!("Connecting to {}", self.url);
264
265 if !self.handler_tasks.is_open() || !self.handler_tasks.is_empty() {
266 self.disconnect_locked().await?;
267 self.handler_tasks.start_generation().map_err(|e| {
268 KrakenWsError::ConnectionError(format!(
269 "Failed to start WebSocket handler task generation: {e}"
270 ))
271 })?;
272 }
273 let handler_spawner = self.handler_tasks.spawner().map_err(|e| {
274 KrakenWsError::ConnectionError(format!(
275 "Failed to acquire WebSocket handler task spawner: {e}"
276 ))
277 })?;
278
279 if self.cancellation_token.is_cancelled() {
280 self.cancellation_token = CancellationToken::new();
281 }
282
283 self.signal.store(false, Ordering::Relaxed);
284
285 let (raw_handler, raw_rx) = channel_message_handler();
286
287 let ws_config = WebSocketConfig {
288 url: self.url.clone(),
289 headers: vec![],
290 heartbeat_interval_secs: Some(self.config.heartbeat_interval_secs),
291 heartbeat_payload: Some(WS_PING_MSG.to_string()),
292 connect_timeout_ms: Some(5_000),
293 reconnect_delay_initial_ms: Some(500),
294 reconnect_delay_max_ms: Some(5_000),
295 reconnect_backoff_factor: Some(1.5),
296 reconnect_jitter_ms: Some(250),
297 reconnect_max_attempts: None,
298 heartbeat_timeout_secs: None,
301 idle_timeout_ms: (self.config.ws_idle_timeout_ms != 0)
302 .then_some(self.config.ws_idle_timeout_ms),
303 backend: self.transport_backend,
304 proxy_url: self.proxy_url.clone(),
305 };
306
307 let keyed_quotas = vec![
308 (
309 KRAKEN_RATE_LIMIT_KEY_SUBSCRIPTION[0].to_string(),
310 *KRAKEN_SPOT_WS_SUBSCRIPTION_QUOTA,
311 ),
312 (
313 KRAKEN_RATE_LIMIT_KEY_ORDER[0].to_string(),
314 *KRAKEN_SPOT_WS_ORDER_QUOTA,
315 ),
316 ];
317
318 let ws_client = WebSocketClient::builder()
319 .config(ws_config)
320 .message_handler(raw_handler)
321 .keyed_quotas(keyed_quotas)
322 .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
323 .connect()
324 .await
325 .map_err(|e| KrakenWsError::ConnectionError(e.to_string()))?;
326
327 self.connection_mode
329 .store(ws_client.connection_mode_atomic());
330 let reconnect_handle = ws_client.reconnect_handle();
331
332 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<KrakenSpotWsMessage>();
333 self.out_rx = Some(Arc::new(out_rx));
334
335 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<SpotHandlerCommand>();
336 *self.cmd_tx.write().await = cmd_tx.clone();
337
338 if let Err(e) = cmd_tx.send(SpotHandlerCommand::SetClient(ws_client)) {
339 return Err(KrakenWsError::ConnectionError(format!(
340 "Failed to send WebSocketClient to handler: {e}"
341 )));
342 }
343
344 if let Some(control) = &self.socket_control {
345 control.register(move || reconnect_handle.request_reconnect());
346 }
347
348 let signal = self.signal.clone();
349 let subscriptions = self.subscriptions.clone();
350 let subscription_payloads = self.subscription_payloads.clone();
351 let config_for_reconnect = self.config.clone();
352 let auth_token_for_reconnect = self.auth_token.clone();
353 let auth_tracker_for_reconnect = self.auth_tracker.clone();
354 let cmd_tx_for_reconnect = cmd_tx.clone();
355
356 let handler_task = async move {
357 let mut handler =
358 SpotFeedHandler::new(signal.clone(), cmd_rx, raw_rx, subscriptions.clone());
359
360 loop {
361 match handler.next().await {
362 Some(KrakenSpotWsMessage::Reconnected) => {
363 if signal.load(Ordering::Relaxed) {
364 continue;
365 }
366 log::info!("WebSocket reconnected, resubscribing");
367
368 subscriptions.reset_after_reconnect();
369
370 let payloads = subscription_payloads.read().await;
371 if payloads.is_empty() {
372 log::debug!("No subscriptions to restore after reconnection");
373 } else {
374 let had_auth = auth_token_for_reconnect.read().await.is_some();
375
376 if had_auth && config_for_reconnect.has_api_credentials() {
377 log::debug!("Re-authenticating after reconnect");
378
379 auth_tracker_for_reconnect.invalidate();
380 let _rx = auth_tracker_for_reconnect.begin();
381
382 match refresh_auth_token(&config_for_reconnect).await {
383 Ok(new_token) => {
384 *auth_token_for_reconnect.write().await = Some(new_token);
385 auth_tracker_for_reconnect.succeed();
386 log::debug!("Re-authentication successful");
387 }
388 Err(e) => {
389 log::error!(
390 "Failed to re-authenticate after reconnect: {e}"
391 );
392 *auth_token_for_reconnect.write().await = None;
393 auth_tracker_for_reconnect.fail(e.to_string());
394 }
395 }
396 }
397
398 log::debug!(
399 "Resubscribing after reconnection: count={}",
400 payloads.len()
401 );
402
403 for (topic, payload) in payloads.iter() {
404 let needs_token =
405 topic == "executions" || topic.starts_with("level3:");
406 let payload = if needs_token {
407 let auth_token = auth_token_for_reconnect.read().await.clone();
408 match auth_token {
409 Some(token) => {
410 match update_auth_token_in_payload(payload, &token) {
411 Ok(p) => p,
412 Err(e) => {
413 log::error!("Failed to update auth token: {e}");
414 continue;
415 }
416 }
417 }
418 None => {
419 log::warn!(
420 "Cannot resubscribe to {topic}: no auth token"
421 );
422 continue;
423 }
424 }
425 } else {
426 payload.clone()
427 };
428
429 if let Err(e) = cmd_tx_for_reconnect
430 .send(SpotHandlerCommand::Subscribe { payload })
431 {
432 log::error!(
433 "Failed to send resubscribe command: error={e}, \
434 topic={topic}"
435 );
436 }
437
438 subscriptions.mark_subscribe(topic);
439 }
440 }
441
442 if out_tx.send(KrakenSpotWsMessage::Reconnected).is_err() {
443 if handler.is_stopped() {
444 log::debug!("Failed to send message (receiver dropped)");
445 } else {
446 log::error!("Failed to send message (receiver dropped)");
447 }
448 break;
449 }
450 }
451 Some(msg) => {
452 if out_tx.send(msg).is_err() {
453 if handler.is_stopped() {
454 log::debug!("Failed to send message (receiver dropped)");
455 } else {
456 log::error!("Failed to send message (receiver dropped)");
457 }
458 break;
459 }
460 }
461 None => {
462 if handler.is_stopped() {
463 log::debug!("Stop signal received, ending message processing");
464 break;
465 }
466 log::warn!("WebSocket stream ended unexpectedly");
467 break;
468 }
469 }
470 }
471
472 log::debug!("Handler task exiting");
473 };
474
475 if let Err(e) = handler_spawner.spawn(handler_task) {
476 if let Some(control) = &self.socket_control {
477 control.deregister();
478 }
479 self.out_rx = None;
480 return Err(KrakenWsError::ConnectionError(format!(
481 "Failed to register WebSocket handler task: {e}"
482 )));
483 }
484
485 log::debug!("WebSocket connected successfully");
486 Ok(())
487 }
488
489 pub async fn disconnect(&mut self) -> Result<(), KrakenWsError> {
491 let connect_lock = Arc::clone(&self.connect_lock);
492 let _connect_guard = connect_lock.lock().await;
493 self.disconnect_locked().await
494 }
495
496 async fn disconnect_locked(&self) -> Result<(), KrakenWsError> {
497 log::debug!("Disconnecting WebSocket");
498
499 self.handler_tasks.begin_shutdown();
500 self.signal.store(true, Ordering::Relaxed);
501
502 if let Err(e) = self
503 .cmd_tx
504 .read()
505 .await
506 .send(SpotHandlerCommand::Disconnect)
507 {
508 log::debug!(
509 "Failed to send disconnect command (handler may already be shut down): {e}"
510 );
511 }
512
513 let task_result = self
514 .handler_tasks
515 .finish_shutdown(
516 tokio::time::Duration::from_secs(2),
517 tokio::time::Duration::from_secs(2),
518 )
519 .await;
520
521 self.subscriptions.clear();
522 self.subscription_payloads.write().await.clear();
523 self.auth_tracker.fail("Disconnected");
524
525 self.l3_depths.lock().clear();
526 self.l2_depths.clear();
527
528 if let Some(control) = &self.socket_control {
529 control.deregister();
530 }
531
532 match task_result {
533 Ok(()) => Ok(()),
534 Err(error @ TaskShutdownError::Timeout { .. }) => Err(KrakenWsError::Timeout(format!(
535 "Spot WebSocket handler shutdown timed out: {error}"
536 ))),
537 Err(e) => Err(KrakenWsError::Disconnected(format!(
538 "Spot WebSocket handler shutdown failed: {e}"
539 ))),
540 }
541 }
542
543 pub async fn close(&mut self) -> Result<(), KrakenWsError> {
545 self.disconnect().await
546 }
547
548 pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), KrakenWsError> {
550 let timeout = tokio::time::Duration::from_secs_f64(timeout_secs);
551
552 tokio::time::timeout(timeout, async {
553 while !self.is_active() {
554 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
555 }
556 })
557 .await
558 .map_err(|_| {
559 KrakenWsError::ConnectionError(format!(
560 "WebSocket connection timeout after {timeout_secs} seconds"
561 ))
562 })?;
563
564 Ok(())
565 }
566
567 #[must_use]
569 pub fn is_authenticated(&self) -> bool {
570 self.auth_tracker.is_authenticated()
571 }
572
573 pub async fn wait_until_authenticated(&self, timeout_secs: f64) -> Result<(), KrakenWsError> {
577 let timeout = tokio::time::Duration::from_secs_f64(timeout_secs);
578
579 if self.auth_tracker.wait_for_authenticated(timeout).await {
580 Ok(())
581 } else {
582 Err(KrakenWsError::AuthenticationError(format!(
583 "Authentication not completed within {timeout_secs} seconds"
584 )))
585 }
586 }
587
588 pub async fn authenticate(&self) -> Result<(), KrakenWsError> {
590 if !self.config.has_api_credentials() {
591 return Err(KrakenWsError::AuthenticationError(
592 "API credentials required for authentication".to_string(),
593 ));
594 }
595
596 let _receiver = self.auth_tracker.begin();
597
598 match refresh_auth_token(&self.config).await {
599 Ok(token) => {
600 *self.auth_token.write().await = Some(token);
601 self.auth_tracker.succeed();
602 Ok(())
603 }
604 Err(e) => {
605 *self.auth_token.write().await = None;
606 self.auth_tracker.fail(e.to_string());
607 Err(e)
608 }
609 }
610 }
611
612 pub fn cancel_all_requests(&self) {
614 self.cancellation_token.cancel();
615 }
616
617 pub fn cancellation_token(&self) -> &CancellationToken {
619 &self.cancellation_token
620 }
621
622 pub async fn subscribe(
624 &self,
625 channel: KrakenWsChannel,
626 symbols: Vec<Ustr>,
627 depth: Option<u32>,
628 ) -> Result<(), KrakenWsError> {
629 if matches!(channel, KrakenWsChannel::Level3) {
630 return Err(KrakenWsError::InvalidMessage(
631 "Use subscribe_book_l3 / unsubscribe_book_l3 for the Level3 channel".to_string(),
632 ));
633 }
634 let mut symbols_to_subscribe = Vec::new();
635 let channel_str = channel.as_ref();
636 for symbol in &symbols {
637 let key = format!("{channel_str}:{symbol}");
638 if self.subscriptions.add_reference(&key) {
639 self.subscriptions.mark_subscribe(&key);
640 symbols_to_subscribe.push(*symbol);
641 }
642 }
643
644 if symbols_to_subscribe.is_empty() {
645 return Ok(());
646 }
647
648 let is_private = matches!(
649 channel,
650 KrakenWsChannel::Executions | KrakenWsChannel::Balances
651 );
652 let token = if is_private {
653 Some(self.auth_token.read().await.clone().ok_or_else(|| {
654 KrakenWsError::AuthenticationError(
655 "Authentication token required for private channels. Call authenticate() first"
656 .to_string(),
657 )
658 })?)
659 } else {
660 None
661 };
662
663 let req_id = self.get_next_req_id();
664 let request = KrakenWsRequest {
665 method: KrakenWsMethod::Subscribe,
666 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
667 channel,
668 symbol: Some(symbols_to_subscribe.clone()),
669 snapshot: None,
670 depth,
671 interval: None,
672 event_trigger: None,
673 token,
674 snap_orders: None,
675 snap_trades: None,
676 })),
677 req_id: Some(req_id),
678 };
679
680 let payload = self.send_command(&request).await?;
681
682 for symbol in &symbols_to_subscribe {
683 let key = format!("{channel_str}:{symbol}");
684 self.subscriptions.confirm_subscribe(&key);
685 self.subscription_payloads
686 .write()
687 .await
688 .insert(key, payload.clone());
689 }
690
691 Ok(())
692 }
693
694 async fn subscribe_with_interval(
696 &self,
697 channel: KrakenWsChannel,
698 symbols: Vec<Ustr>,
699 interval: u32,
700 ) -> Result<(), KrakenWsError> {
701 let mut symbols_to_subscribe = Vec::new();
702 let channel_str = channel.as_ref();
703 for symbol in &symbols {
704 let key = format!("{channel_str}:{symbol}:{interval}");
705 if self.subscriptions.add_reference(&key) {
706 self.subscriptions.mark_subscribe(&key);
707 symbols_to_subscribe.push(*symbol);
708 }
709 }
710
711 if symbols_to_subscribe.is_empty() {
712 return Ok(());
713 }
714
715 let req_id = self.get_next_req_id();
716 let request = KrakenWsRequest {
717 method: KrakenWsMethod::Subscribe,
718 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
719 channel,
720 symbol: Some(symbols_to_subscribe.clone()),
721 snapshot: Some(false),
722 depth: None,
723 interval: Some(interval),
724 event_trigger: None,
725 token: None,
726 snap_orders: None,
727 snap_trades: None,
728 })),
729 req_id: Some(req_id),
730 };
731
732 let payload = self.send_command(&request).await?;
733
734 for symbol in &symbols_to_subscribe {
735 let key = format!("{channel_str}:{symbol}:{interval}");
736 self.subscriptions.confirm_subscribe(&key);
737 self.subscription_payloads
738 .write()
739 .await
740 .insert(key, payload.clone());
741 }
742
743 Ok(())
744 }
745
746 async fn unsubscribe_with_interval(
748 &self,
749 channel: KrakenWsChannel,
750 symbols: Vec<Ustr>,
751 interval: u32,
752 ) -> Result<(), KrakenWsError> {
753 let mut symbols_to_unsubscribe = Vec::new();
754 let channel_str = channel.as_ref();
755 for symbol in &symbols {
756 let key = format!("{channel_str}:{symbol}:{interval}");
757 if self.subscriptions.remove_reference(&key) {
758 self.subscriptions.mark_unsubscribe(&key);
759 symbols_to_unsubscribe.push(*symbol);
760 }
761 }
762
763 if symbols_to_unsubscribe.is_empty() {
764 return Ok(());
765 }
766
767 let req_id = self.get_next_req_id();
768 let request = KrakenWsRequest {
769 method: KrakenWsMethod::Unsubscribe,
770 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
771 channel,
772 symbol: Some(symbols_to_unsubscribe.clone()),
773 snapshot: None,
774 depth: None,
775 interval: Some(interval),
776 event_trigger: None,
777 token: None,
778 snap_orders: None,
779 snap_trades: None,
780 })),
781 req_id: Some(req_id),
782 };
783
784 self.send_command(&request).await?;
785
786 for symbol in &symbols_to_unsubscribe {
787 let key = format!("{channel_str}:{symbol}:{interval}");
788 self.subscriptions.confirm_unsubscribe(&key);
789 self.subscription_payloads.write().await.remove(&key);
790 }
791
792 Ok(())
793 }
794
795 pub async fn unsubscribe(
797 &self,
798 channel: KrakenWsChannel,
799 symbols: Vec<Ustr>,
800 ) -> Result<(), KrakenWsError> {
801 if matches!(channel, KrakenWsChannel::Level3) {
802 return Err(KrakenWsError::InvalidMessage(
803 "Use subscribe_book_l3 / unsubscribe_book_l3 for the Level3 channel".to_string(),
804 ));
805 }
806 let mut symbols_to_unsubscribe = Vec::new();
807 let channel_str = channel.as_ref();
808 for symbol in &symbols {
809 let key = format!("{channel_str}:{symbol}");
810 if self.subscriptions.remove_reference(&key) {
811 self.subscriptions.mark_unsubscribe(&key);
812 symbols_to_unsubscribe.push(*symbol);
813 } else {
814 log::debug!(
815 "Channel {channel_str} symbol {symbol} still has active subscriptions, not unsubscribing"
816 );
817 }
818 }
819
820 if symbols_to_unsubscribe.is_empty() {
821 return Ok(());
822 }
823
824 let is_private = matches!(
825 channel,
826 KrakenWsChannel::Executions | KrakenWsChannel::Balances
827 );
828 let token = if is_private {
829 Some(self.auth_token.read().await.clone().ok_or_else(|| {
830 KrakenWsError::AuthenticationError(
831 "Authentication token required for private channels. Call authenticate() first"
832 .to_string(),
833 )
834 })?)
835 } else {
836 None
837 };
838
839 let req_id = self.get_next_req_id();
840 let request = KrakenWsRequest {
841 method: KrakenWsMethod::Unsubscribe,
842 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
843 channel,
844 symbol: Some(symbols_to_unsubscribe.clone()),
845 snapshot: None,
846 depth: None,
847 interval: None,
848 event_trigger: None,
849 token,
850 snap_orders: None,
851 snap_trades: None,
852 })),
853 req_id: Some(req_id),
854 };
855
856 self.send_command(&request).await?;
857
858 for symbol in &symbols_to_unsubscribe {
859 let key = format!("{channel_str}:{symbol}");
860 self.subscriptions.confirm_unsubscribe(&key);
861 self.subscription_payloads.write().await.remove(&key);
862 }
863
864 Ok(())
865 }
866
867 pub async fn send_ping(&self) -> Result<(), KrakenWsError> {
869 let req_id = self.get_next_req_id();
870
871 let request = KrakenWsRequest {
872 method: KrakenWsMethod::Ping,
873 params: None,
874 req_id: Some(req_id),
875 };
876
877 self.send_command(&request).await?;
878 Ok(())
879 }
880
881 async fn send_command(&self, request: &KrakenWsRequest) -> Result<String, KrakenWsError> {
882 let payload =
883 serde_json::to_string(request).map_err(|e| KrakenWsError::JsonError(e.to_string()))?;
884
885 log::trace!(
886 "Sending WebSocket request: method={:?} ({} bytes)",
887 request.method,
888 payload.len(),
889 );
890
891 let cmd = match request.method {
892 KrakenWsMethod::Subscribe => SpotHandlerCommand::Subscribe {
893 payload: payload.clone(),
894 },
895 KrakenWsMethod::Unsubscribe => SpotHandlerCommand::Unsubscribe {
896 payload: payload.clone(),
897 },
898 KrakenWsMethod::Ping | KrakenWsMethod::Pong => SpotHandlerCommand::Ping {
899 payload: payload.clone(),
900 },
901 KrakenWsMethod::AddOrder
902 | KrakenWsMethod::AmendOrder
903 | KrakenWsMethod::CancelOrder
904 | KrakenWsMethod::BatchAdd => {
905 return Err(KrakenWsError::InvalidMessage(
906 "Order methods must not be sent via send_command; use the dedicated order submission path".to_string()
907 ));
908 }
909 };
910
911 self.cmd_tx
912 .read()
913 .await
914 .send(cmd)
915 .map_err(|e| KrakenWsError::ConnectionError(format!("Failed to send request: {e}")))?;
916
917 Ok(payload)
918 }
919
920 pub fn is_connected(&self) -> bool {
922 let connection_mode_arc = self.connection_mode.load();
923 !ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
924 }
925
926 pub fn is_active(&self) -> bool {
928 let connection_mode_arc = self.connection_mode.load();
929 ConnectionMode::from_atomic(&connection_mode_arc).is_active()
930 && !self.signal.load(Ordering::Relaxed)
931 }
932
933 pub fn is_closed(&self) -> bool {
935 let connection_mode_arc = self.connection_mode.load();
936 ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
937 || self.signal.load(Ordering::Relaxed)
938 }
939
940 pub fn url(&self) -> &str {
942 &self.url
943 }
944
945 pub fn get_subscriptions(&self) -> Vec<String> {
947 self.subscriptions.all_topics()
948 }
949
950 pub fn subscriptions_contains(&self, topic: &str) -> bool {
952 self.subscriptions.all_topics().iter().any(|t| t == topic)
953 }
954
955 pub fn set_account_id(&self, account_id: AccountId) {
957 *self.account_id.write() = Some(account_id);
958 }
959
960 #[must_use]
962 pub fn account_id(&self) -> Option<AccountId> {
963 *self.account_id.read()
964 }
965
966 pub fn cache_instrument(&self, instrument: InstrumentAny) {
968 self.instruments.insert(instrument.id(), instrument);
969 }
970
971 pub fn account_id_shared(&self) -> &Arc<RwLock<Option<AccountId>>> {
973 &self.account_id
974 }
975
976 pub fn truncated_id_map(&self) -> &Arc<AtomicMap<String, ClientOrderId>> {
978 &self.truncated_id_map
979 }
980
981 pub fn cache_client_order(
983 &self,
984 client_order_id: ClientOrderId,
985 _venue_order_id: Option<VenueOrderId>,
986 _instrument_id: InstrumentId,
987 _trader_id: TraderId,
988 _strategy_id: StrategyId,
989 ) {
990 let truncated = crate::common::parse::truncate_cl_ord_id(&client_order_id);
991
992 if truncated != client_order_id.as_str() {
993 self.truncated_id_map.insert(truncated, client_order_id);
994 }
995 }
996
997 pub fn stream(
1005 &mut self,
1006 ) -> Result<impl futures_util::Stream<Item = KrakenSpotWsMessage> + use<>, KrakenWsError> {
1007 let rx = self.out_rx.take().ok_or_else(|| {
1008 KrakenWsError::ChannelError(
1009 "Stream receiver already taken or client not connected".to_string(),
1010 )
1011 })?;
1012 let mut rx = Arc::try_unwrap(rx).map_err(|_| {
1013 KrakenWsError::ChannelError(
1014 "Cannot take ownership of stream - other client clones still hold references"
1015 .to_string(),
1016 )
1017 })?;
1018 Ok(async_stream::stream! {
1019 while let Some(msg) = rx.recv().await {
1020 yield msg;
1021 }
1022 })
1023 }
1024
1025 pub async fn subscribe_book(
1027 &self,
1028 instrument_id: InstrumentId,
1029 depth: Option<u32>,
1030 ) -> Result<(), KrakenWsError> {
1031 let symbol = to_ws_v2_symbol(instrument_id.symbol.inner());
1032 let depth = depth.unwrap_or(10);
1033
1034 if !matches!(depth, 10 | 25 | 100 | 500 | 1000) {
1035 return Err(KrakenWsError::InvalidMessage(format!(
1036 "Invalid L2 depth {depth}, valid values: 10, 25, 100, 500, 1000",
1037 )));
1038 }
1039
1040 let channel_str = KrakenWsChannel::Book.as_ref();
1041 let key = format!("{channel_str}:{symbol}");
1042 let is_first_reference = self.subscriptions.add_reference(&key);
1043
1044 if !is_first_reference {
1045 let existing_depth = self.l2_depths.get(symbol.as_str());
1046
1047 if existing_depth != Some(depth) {
1048 self.subscriptions.remove_reference(&key);
1049 return Err(KrakenWsError::InvalidMessage(format!(
1050 "L2 subscription for {symbol} already exists with depth \
1051 {existing_depth:?}, cannot resubscribe with depth {depth}",
1052 )));
1053 }
1054 return Ok(());
1055 }
1056
1057 self.subscriptions.mark_subscribe(&key);
1058 self.l2_depths.insert(symbol.as_str(), depth);
1059
1060 let req_id = self.get_next_req_id();
1061 let request = KrakenWsRequest {
1062 method: KrakenWsMethod::Subscribe,
1063 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
1064 channel: KrakenWsChannel::Book,
1065 symbol: Some(vec![symbol]),
1066 snapshot: None,
1067 depth: Some(depth),
1068 interval: None,
1069 event_trigger: None,
1070 token: None,
1071 snap_orders: None,
1072 snap_trades: None,
1073 })),
1074 req_id: Some(req_id),
1075 };
1076
1077 let payload = match self.send_command(&request).await {
1078 Ok(payload) => payload,
1079 Err(e) => {
1080 self.l2_depths.remove(symbol.as_str());
1081 self.subscriptions.remove_reference(&key);
1082 self.subscriptions.mark_unsubscribe(&key);
1083 self.subscriptions.confirm_unsubscribe(&key);
1084 return Err(e);
1085 }
1086 };
1087
1088 self.subscriptions.confirm_subscribe(&key);
1089 self.subscription_payloads
1090 .write()
1091 .await
1092 .insert(key, payload);
1093 Ok(())
1094 }
1095
1096 pub async fn subscribe_book_l3(&self, symbol: Ustr, depth: u32) -> Result<(), KrakenWsError> {
1115 if !matches!(depth, 10 | 100 | 1000) {
1116 return Err(KrakenWsError::InvalidMessage(format!(
1117 "Invalid L3 depth {depth}, valid values: 10, 100, 1000",
1118 )));
1119 }
1120
1121 let token = self.auth_token.read().await.clone().ok_or_else(|| {
1122 KrakenWsError::AuthenticationError(
1123 "Authentication token required for level3. Call authenticate() first".to_string(),
1124 )
1125 })?;
1126
1127 let channel_str = KrakenWsChannel::Level3.as_ref();
1128 let key = format!("{channel_str}:{symbol}");
1129
1130 let is_first_reference = self.subscriptions.add_reference(&key);
1131
1132 if !is_first_reference {
1133 let existing_depth = self.l3_depths.lock().get(symbol.as_str()).copied();
1134
1135 if existing_depth != Some(depth) {
1136 self.subscriptions.remove_reference(&key);
1137 return Err(KrakenWsError::InvalidMessage(format!(
1138 "L3 subscription for {symbol} already exists with depth \
1139 {existing_depth:?}, cannot resubscribe with depth {depth}",
1140 )));
1141 }
1142 return Ok(());
1143 }
1144
1145 self.subscriptions.mark_subscribe(&key);
1146
1147 self.l3_depths.lock().insert(symbol.to_string(), depth);
1148
1149 let req_id = self.get_next_req_id();
1150 let request = KrakenWsRequest {
1151 method: KrakenWsMethod::Subscribe,
1152 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
1153 channel: KrakenWsChannel::Level3,
1154 symbol: Some(vec![symbol]),
1155 snapshot: Some(true),
1156 depth: Some(depth),
1157 interval: None,
1158 event_trigger: None,
1159 token: Some(token),
1160 snap_orders: None,
1161 snap_trades: None,
1162 })),
1163 req_id: Some(req_id),
1164 };
1165
1166 let payload = match self.send_command(&request).await {
1167 Ok(p) => p,
1168 Err(e) => {
1169 self.l3_depths.lock().remove(symbol.as_str());
1170 self.subscriptions.remove_reference(&key);
1171 self.subscriptions.mark_unsubscribe(&key);
1172 self.subscriptions.confirm_unsubscribe(&key);
1173 return Err(e);
1174 }
1175 };
1176
1177 self.subscriptions.confirm_subscribe(&key);
1178 self.subscription_payloads
1179 .write()
1180 .await
1181 .insert(key, payload);
1182 Ok(())
1183 }
1184
1185 pub async fn unsubscribe_book_l3(&self, symbol: Ustr) -> Result<(), KrakenWsError> {
1200 let channel_str = KrakenWsChannel::Level3.as_ref();
1201 let key = format!("{channel_str}:{symbol}");
1202 if !self.subscriptions.remove_reference(&key) {
1203 return Ok(());
1204 }
1205 self.subscriptions.mark_unsubscribe(&key);
1206
1207 let token = self.auth_token.read().await.clone();
1208 let req_id = self.get_next_req_id();
1209 let request = KrakenWsRequest {
1210 method: KrakenWsMethod::Unsubscribe,
1211 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
1212 channel: KrakenWsChannel::Level3,
1213 symbol: Some(vec![symbol]),
1214 snapshot: None,
1215 depth: None,
1216 interval: None,
1217 event_trigger: None,
1218 token,
1219 snap_orders: None,
1220 snap_trades: None,
1221 })),
1222 req_id: Some(req_id),
1223 };
1224
1225 self.send_command(&request).await?;
1226 self.subscriptions.confirm_unsubscribe(&key);
1227 self.subscription_payloads.write().await.remove(&key);
1228 self.l3_depths.lock().remove(symbol.as_str());
1229 Ok(())
1230 }
1231
1232 pub async fn resync_book_l3(&self, symbol: Ustr, depth: u32) -> Result<(), KrakenWsError> {
1246 let channel_str = KrakenWsChannel::Level3.as_ref();
1247 let key = format!("{channel_str}:{symbol}");
1248
1249 if !self.subscriptions_contains(&key) {
1255 log::debug!("Skipping L3 resync: subscription cancelled mid-retry, symbol={symbol}",);
1256 return Ok(());
1257 }
1258
1259 let new_token = refresh_auth_token(&self.config).await?;
1260 *self.auth_token.write().await = Some(new_token.clone());
1261
1262 if !self.subscriptions_contains(&key) {
1265 log::debug!(
1266 "Skipping L3 resync: subscription cancelled after token refresh, symbol={symbol}",
1267 );
1268 return Ok(());
1269 }
1270
1271 let unsub_req_id = self.get_next_req_id();
1272 let unsub = KrakenWsRequest {
1273 method: KrakenWsMethod::Unsubscribe,
1274 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
1275 channel: KrakenWsChannel::Level3,
1276 symbol: Some(vec![symbol]),
1277 snapshot: None,
1278 depth: None,
1279 interval: None,
1280 event_trigger: None,
1281 token: Some(new_token.clone()),
1282 snap_orders: None,
1283 snap_trades: None,
1284 })),
1285 req_id: Some(unsub_req_id),
1286 };
1287 self.send_command(&unsub).await?;
1288
1289 if !self.subscriptions_contains(&key) {
1291 log::debug!("Skipping L3 resync resubscribe: cancelled before send, symbol={symbol}",);
1292 return Ok(());
1293 }
1294
1295 let sub_req_id = self.get_next_req_id();
1296 let sub = KrakenWsRequest {
1297 method: KrakenWsMethod::Subscribe,
1298 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
1299 channel: KrakenWsChannel::Level3,
1300 symbol: Some(vec![symbol]),
1301 snapshot: Some(true),
1302 depth: Some(depth),
1303 interval: None,
1304 event_trigger: None,
1305 token: Some(new_token),
1306 snap_orders: None,
1307 snap_trades: None,
1308 })),
1309 req_id: Some(sub_req_id),
1310 };
1311 let payload = self.send_command(&sub).await?;
1312
1313 if self.subscriptions_contains(&key) {
1318 self.subscription_payloads
1319 .write()
1320 .await
1321 .insert(key, payload);
1322 self.l3_depths.lock().insert(symbol.to_string(), depth);
1323 }
1324
1325 Ok(())
1326 }
1327
1328 pub fn validate_l3_checksum(&self) -> bool {
1330 self.config.validate_l3_checksum
1331 }
1332
1333 pub fn has_credentials(&self) -> bool {
1336 self.config.has_api_credentials()
1337 }
1338
1339 pub fn instruments_handle(&self) -> Arc<AtomicMap<InstrumentId, InstrumentAny>> {
1345 Arc::clone(&self.instruments)
1346 }
1347
1348 pub fn l3_depths_handle(&self) -> Arc<parking_lot::Mutex<ahash::AHashMap<String, u32>>> {
1353 Arc::clone(&self.l3_depths)
1354 }
1355
1356 pub(crate) fn l2_depths_handle(&self) -> L2Depths {
1358 self.l2_depths.clone()
1359 }
1360
1361 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), KrakenWsError> {
1366 let symbol = to_ws_v2_symbol(instrument_id.symbol.inner());
1367 let key = format!("quotes:{symbol}");
1368
1369 if !self.subscriptions.add_reference(&key) {
1370 return Ok(());
1371 }
1372
1373 self.subscriptions.mark_subscribe(&key);
1374
1375 let req_id = self.get_next_req_id();
1376 let request = KrakenWsRequest {
1377 method: KrakenWsMethod::Subscribe,
1378 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
1379 channel: KrakenWsChannel::Ticker,
1380 symbol: Some(vec![symbol]),
1381 snapshot: None,
1382 depth: None,
1383 interval: None,
1384 event_trigger: Some("bbo".to_string()),
1385 token: None,
1386 snap_orders: None,
1387 snap_trades: None,
1388 })),
1389 req_id: Some(req_id),
1390 };
1391
1392 let payload = self.send_command(&request).await?;
1393 self.subscriptions.confirm_subscribe(&key);
1394 self.subscription_payloads
1395 .write()
1396 .await
1397 .insert(key, payload);
1398 Ok(())
1399 }
1400
1401 pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> Result<(), KrakenWsError> {
1403 let symbol = to_ws_v2_symbol(instrument_id.symbol.inner());
1404 self.subscribe(KrakenWsChannel::Trade, vec![symbol], None)
1405 .await
1406 }
1407
1408 pub async fn subscribe_bars(&self, bar_type: BarType) -> Result<(), KrakenWsError> {
1414 let symbol = to_ws_v2_symbol(bar_type.instrument_id().symbol.inner());
1415 let interval = bar_type_to_ws_interval(bar_type)?;
1416 self.subscribe_with_interval(KrakenWsChannel::Ohlc, vec![symbol], interval)
1417 .await
1418 }
1419
1420 pub async fn subscribe_executions(
1424 &self,
1425 snap_orders: bool,
1426 snap_trades: bool,
1427 ) -> Result<(), KrakenWsError> {
1428 let req_id = self.get_next_req_id();
1429
1430 let token = self.auth_token.read().await.clone().ok_or_else(|| {
1431 KrakenWsError::AuthenticationError(
1432 "Authentication token required for executions channel. Call authenticate() first"
1433 .to_string(),
1434 )
1435 })?;
1436
1437 let request = KrakenWsRequest {
1438 method: KrakenWsMethod::Subscribe,
1439 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
1440 channel: KrakenWsChannel::Executions,
1441 symbol: None,
1442 snapshot: None,
1443 depth: None,
1444 interval: None,
1445 event_trigger: None,
1446 token: Some(token),
1447 snap_orders: Some(snap_orders),
1448 snap_trades: Some(snap_trades),
1449 })),
1450 req_id: Some(req_id),
1451 };
1452
1453 let payload = self.send_command(&request).await?;
1454
1455 let key = "executions";
1456 if self.subscriptions.add_reference(key) {
1457 self.subscriptions.mark_subscribe(key);
1458 self.subscriptions.confirm_subscribe(key);
1459 self.subscription_payloads
1460 .write()
1461 .await
1462 .insert(key.to_string(), payload);
1463 }
1464
1465 Ok(())
1466 }
1467
1468 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> Result<(), KrakenWsError> {
1470 let symbol = to_ws_v2_symbol(instrument_id.symbol.inner());
1471 let channel_str = KrakenWsChannel::Book.as_ref();
1472 let key = format!("{channel_str}:{symbol}");
1473
1474 if !self.subscriptions.remove_reference(&key) {
1475 return Ok(());
1476 }
1477
1478 self.subscriptions.mark_unsubscribe(&key);
1479
1480 let req_id = self.get_next_req_id();
1481 let request = KrakenWsRequest {
1482 method: KrakenWsMethod::Unsubscribe,
1483 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
1484 channel: KrakenWsChannel::Book,
1485 symbol: Some(vec![symbol]),
1486 snapshot: None,
1487 depth: None,
1488 interval: None,
1489 event_trigger: None,
1490 token: None,
1491 snap_orders: None,
1492 snap_trades: None,
1493 })),
1494 req_id: Some(req_id),
1495 };
1496
1497 self.send_command(&request).await?;
1498 self.subscriptions.confirm_unsubscribe(&key);
1499 self.subscription_payloads.write().await.remove(&key);
1500 self.l2_depths.remove(symbol.as_str());
1501 Ok(())
1502 }
1503
1504 pub async fn unsubscribe_quotes(
1506 &self,
1507 instrument_id: InstrumentId,
1508 ) -> Result<(), KrakenWsError> {
1509 let symbol = to_ws_v2_symbol(instrument_id.symbol.inner());
1510 let key = format!("quotes:{symbol}");
1511
1512 if !self.subscriptions.remove_reference(&key) {
1513 return Ok(());
1514 }
1515
1516 self.subscriptions.mark_unsubscribe(&key);
1517
1518 let req_id = self.get_next_req_id();
1519 let request = KrakenWsRequest {
1520 method: KrakenWsMethod::Unsubscribe,
1521 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
1522 channel: KrakenWsChannel::Ticker,
1523 symbol: Some(vec![symbol]),
1524 snapshot: None,
1525 depth: None,
1526 interval: None,
1527 event_trigger: Some("bbo".to_string()),
1528 token: None,
1529 snap_orders: None,
1530 snap_trades: None,
1531 })),
1532 req_id: Some(req_id),
1533 };
1534
1535 self.send_command(&request).await?;
1536 self.subscriptions.confirm_unsubscribe(&key);
1537 self.subscription_payloads.write().await.remove(&key);
1538 Ok(())
1539 }
1540
1541 pub async fn unsubscribe_trades(
1543 &self,
1544 instrument_id: InstrumentId,
1545 ) -> Result<(), KrakenWsError> {
1546 let symbol = to_ws_v2_symbol(instrument_id.symbol.inner());
1547 self.unsubscribe(KrakenWsChannel::Trade, vec![symbol]).await
1548 }
1549
1550 pub async fn unsubscribe_bars(&self, bar_type: BarType) -> Result<(), KrakenWsError> {
1556 let symbol = to_ws_v2_symbol(bar_type.instrument_id().symbol.inner());
1557 let interval = bar_type_to_ws_interval(bar_type)?;
1558 self.unsubscribe_with_interval(KrakenWsChannel::Ohlc, vec![symbol], interval)
1559 .await
1560 }
1561}
1562
1563async fn refresh_auth_token(config: &KrakenDataClientConfig) -> Result<String, KrakenWsError> {
1565 let api_key = config
1566 .api_key
1567 .clone()
1568 .ok_or_else(|| KrakenWsError::AuthenticationError("Missing API key".to_string()))?;
1569 let api_secret = config
1570 .api_secret
1571 .clone()
1572 .ok_or_else(|| KrakenWsError::AuthenticationError("Missing API secret".to_string()))?;
1573
1574 let http_client = KrakenSpotHttpClient::with_credentials(
1575 api_key,
1576 api_secret,
1577 config.environment,
1578 Some(config.http_base_url()),
1579 config.timeout_secs,
1580 None,
1581 None,
1582 None,
1583 config.proxy_url.clone(),
1584 config
1585 .max_requests_per_second
1586 .unwrap_or(KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND),
1587 )
1588 .map_err(|e| {
1589 KrakenWsError::AuthenticationError(format!("Failed to create HTTP client: {e}"))
1590 })?;
1591
1592 let ws_token = http_client.get_websockets_token().await.map_err(|e| {
1593 KrakenWsError::AuthenticationError(format!("Failed to get WebSocket token: {e}"))
1594 })?;
1595
1596 log::debug!(
1597 "WebSocket authentication token refreshed: token_length={}, expires={}",
1598 ws_token.token.len(),
1599 ws_token.expires
1600 );
1601
1602 Ok(ws_token.token)
1603}
1604
1605fn update_auth_token_in_payload(payload: &str, new_token: &str) -> Result<String, KrakenWsError> {
1606 let mut value: serde_json::Value =
1607 serde_json::from_str(payload).map_err(|e| KrakenWsError::JsonError(e.to_string()))?;
1608
1609 if let Some(params) = value.get_mut("params") {
1610 params["token"] = serde_json::Value::String(new_token.to_string());
1611 }
1612
1613 serde_json::to_string(&value).map_err(|e| KrakenWsError::JsonError(e.to_string()))
1614}
1615
1616#[inline]
1617fn to_ws_v2_symbol(symbol: Ustr) -> Ustr {
1618 Ustr::from(&normalize_spot_symbol(symbol.as_str()))
1619}
1620
1621fn bar_type_to_ws_interval(bar_type: BarType) -> Result<u32, KrakenWsError> {
1622 const VALID_INTERVALS: [u32; 9] = [1, 5, 15, 30, 60, 240, 1440, 10080, 21600];
1623
1624 let spec = bar_type.spec();
1625 let step = spec.step.get() as u32;
1626
1627 let base_minutes = match spec.aggregation {
1628 BarAggregation::Minute => 1,
1629 BarAggregation::Hour => 60,
1630 BarAggregation::Day => 1440,
1631 BarAggregation::Week => 10080,
1632 other => {
1633 return Err(KrakenWsError::SubscriptionError(format!(
1634 "Unsupported bar aggregation for Kraken OHLC streaming: {other:?}"
1635 )));
1636 }
1637 };
1638
1639 let interval = base_minutes * step;
1640
1641 if !VALID_INTERVALS.contains(&interval) {
1642 return Err(KrakenWsError::SubscriptionError(format!(
1643 "Invalid bar interval {interval} minutes for Kraken OHLC streaming. \
1644 Supported intervals: 1, 5, 15, 30, 60, 240, 1440, 10080, 21600"
1645 )));
1646 }
1647
1648 Ok(interval)
1649}
1650
1651#[cfg(test)]
1652mod tests {
1653 use std::sync::{Arc, atomic::Ordering};
1654
1655 use log::{Level, LevelFilter, Log, Metadata, Record};
1656 use parking_lot::Mutex;
1657 use rstest::rstest;
1658 use tokio_util::sync::CancellationToken;
1659
1660 use super::*;
1661 use crate::config::KrakenDataClientConfig;
1662
1663 const SECRET_MARKER: &str = "OUTBOUND_SECRET_MARKER";
1664
1665 struct OutboundLogCapture {
1666 messages: Mutex<Vec<String>>,
1667 }
1668
1669 static OUTBOUND_LOG_CAPTURE: OutboundLogCapture = OutboundLogCapture {
1670 messages: Mutex::new(Vec::new()),
1671 };
1672
1673 impl OutboundLogCapture {
1674 fn clear(&self) {
1675 self.messages.lock().clear();
1676 }
1677
1678 fn messages(&self) -> Vec<String> {
1679 self.messages.lock().clone()
1680 }
1681 }
1682
1683 impl Log for OutboundLogCapture {
1684 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
1685 metadata.level() == Level::Trace
1686 && metadata.target() == "nautilus_kraken::websocket::spot_v2::client"
1687 }
1688
1689 fn log(&self, record: &Record<'_>) {
1690 if self.enabled(record.metadata()) {
1691 let message = record.args().to_string();
1692 if message.starts_with("Sending WebSocket request") {
1693 self.messages.lock().push(message);
1694 }
1695 }
1696 }
1697
1698 fn flush(&self) {}
1699 }
1700
1701 #[rstest]
1702 fn test_req_id_counter_is_shared_arc_and_monotonic() {
1703 let cfg = KrakenDataClientConfig::default();
1704 let client = KrakenSpotWebSocketClient::new(cfg, CancellationToken::new(), None);
1705 let counter = client.req_id_counter();
1706 let a = counter.fetch_add(1, Ordering::Relaxed);
1707 let b = counter.fetch_add(1, Ordering::Relaxed);
1708 assert!(b > a);
1709 #[allow(clippy::redundant_clone)]
1710 let cloned = client.clone();
1711 let cloned_counter = cloned.req_id_counter();
1712 assert!(Arc::ptr_eq(&counter, &cloned_counter));
1713 }
1714
1715 #[rstest]
1716 #[case("XBT/EUR", "BTC/EUR")]
1717 #[case("XBT/USD", "BTC/USD")]
1718 #[case("XBT/USDT", "BTC/USDT")]
1719 #[case("ETH/USD", "ETH/USD")]
1720 #[case("ETH/XBT", "ETH/BTC")]
1721 #[case("SOL/XBT", "SOL/BTC")]
1722 #[case("SOL/USD", "SOL/USD")]
1723 #[case("BTC/USD", "BTC/USD")]
1724 #[case("ETH/BTC", "ETH/BTC")]
1725 #[case("XDG/USD", "DOGE/USD")]
1726 #[case("XDG/EUR", "DOGE/EUR")]
1727 fn test_to_kraken_ws_v2_symbol(#[case] input: &str, #[case] expected: &str) {
1728 let symbol = Ustr::from(input);
1729 let result = to_ws_v2_symbol(symbol);
1730 assert_eq!(result.as_str(), expected);
1731 }
1732
1733 fn test_client_without_credentials() -> KrakenSpotWebSocketClient {
1734 KrakenSpotWebSocketClient::new(
1735 KrakenDataClientConfig::default(),
1736 CancellationToken::new(),
1737 None,
1738 )
1739 }
1740
1741 #[rstest]
1742 #[tokio::test]
1743 async fn test_outbound_logs_omit_payload_bodies() {
1744 log::set_logger(&OUTBOUND_LOG_CAPTURE).expect("test logger already installed");
1745 log::set_max_level(LevelFilter::Trace);
1746
1747 let client = test_client_without_credentials();
1748 let request = KrakenWsRequest {
1749 method: KrakenWsMethod::Subscribe,
1750 params: Some(KrakenWsParams::Channel(KrakenWsChannelParams {
1751 channel: KrakenWsChannel::Executions,
1752 symbol: None,
1753 snapshot: None,
1754 depth: None,
1755 interval: None,
1756 event_trigger: None,
1757 token: Some(SECRET_MARKER.to_string()),
1758 snap_orders: Some(true),
1759 snap_trades: Some(false),
1760 })),
1761 req_id: Some(426),
1762 };
1763 let payload_len = serde_json::to_string(&request).unwrap().len();
1764 OUTBOUND_LOG_CAPTURE.clear();
1765
1766 let error = client.send_command(&request).await.unwrap_err();
1767 let messages = OUTBOUND_LOG_CAPTURE.messages();
1768
1769 assert!(matches!(error, KrakenWsError::ConnectionError(_)));
1770 assert!(
1771 messages
1772 .iter()
1773 .all(|message| !message.contains(SECRET_MARKER)),
1774 "outbound logs exposed the secret marker: {messages:?}"
1775 );
1776 assert!(
1777 messages.iter().any(|message| {
1778 message
1779 == &format!("Sending WebSocket request: method=Subscribe ({payload_len} bytes)")
1780 }),
1781 "subscribe metadata missing or inaccurate: {messages:?}"
1782 );
1783 }
1784
1785 #[rstest]
1786 #[tokio::test]
1787 async fn test_authenticate_without_credentials_errors() {
1788 let client = test_client_without_credentials();
1789
1790 let err = client.authenticate().await.expect_err("should fail");
1791 assert!(
1792 matches!(err, KrakenWsError::AuthenticationError(ref msg) if msg.contains("API credentials required")),
1793 "unexpected error: {err:?}"
1794 );
1795 assert!(!client.is_authenticated());
1796 }
1797
1798 #[rstest]
1799 #[tokio::test]
1800 async fn test_wait_until_authenticated_times_out() {
1801 let client = test_client_without_credentials();
1802
1803 let err = client
1804 .wait_until_authenticated(0.05)
1805 .await
1806 .expect_err("should time out");
1807 assert!(matches!(err, KrakenWsError::AuthenticationError(_)));
1808 }
1809
1810 #[rstest]
1811 #[tokio::test]
1812 async fn test_wait_until_authenticated_resolves_after_succeed() {
1813 let client = test_client_without_credentials();
1814
1815 let tracker = client.auth_tracker.clone();
1816 let _rx = tracker.begin();
1817
1818 tokio::spawn(async move {
1819 tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
1820 tracker.succeed();
1821 });
1822
1823 client
1824 .wait_until_authenticated(1.0)
1825 .await
1826 .expect("should resolve once tracker succeeds");
1827 assert!(client.is_authenticated());
1828 }
1829
1830 #[rstest]
1831 #[tokio::test]
1832 async fn test_is_authenticated_flips_on_fail() {
1833 let client = test_client_without_credentials();
1834
1835 let _rx = client.auth_tracker.begin();
1836 client.auth_tracker.succeed();
1837 assert!(client.is_authenticated());
1838
1839 client.auth_tracker.fail("test failure");
1840 assert!(!client.is_authenticated());
1841 }
1842
1843 #[rstest]
1844 fn test_l3_factory_uses_ws_l3_url() {
1845 let cfg = KrakenDataClientConfig::default();
1846 let client = KrakenSpotWebSocketClient::l3(cfg, CancellationToken::new(), None);
1847 assert_eq!(client.url(), "wss://ws-l3.kraken.com/v2");
1848 }
1849
1850 #[rstest]
1851 fn test_l3_factory_respects_override() {
1852 let cfg = KrakenDataClientConfig {
1853 ws_l3_url: Some("wss://override.example/v2".to_string()),
1854 ..Default::default()
1855 };
1856 let client = KrakenSpotWebSocketClient::l3(cfg, CancellationToken::new(), None);
1857 assert_eq!(client.url(), "wss://override.example/v2");
1858 }
1859
1860 #[rstest]
1861 #[tokio::test]
1862 async fn test_subscribe_book_l3_without_auth_errors_and_leaves_clean_state() {
1863 let cfg = KrakenDataClientConfig::default();
1864 let client = KrakenSpotWebSocketClient::l3(cfg, CancellationToken::new(), None);
1865
1866 let err = client
1867 .subscribe_book_l3(Ustr::from("BTC/USD"), 1000)
1868 .await
1869 .expect_err("should fail without auth token");
1870
1871 assert!(matches!(err, KrakenWsError::AuthenticationError(_)));
1872 assert!(
1873 client.subscriptions.is_empty(),
1874 "no state must leak on auth failure"
1875 );
1876 }
1877
1878 #[rstest]
1879 #[tokio::test]
1880 async fn test_subscribe_book_l3_invalid_depth_errors() {
1881 let cfg = KrakenDataClientConfig::default();
1882 let client = KrakenSpotWebSocketClient::l3(cfg, CancellationToken::new(), None);
1883
1884 let err = client
1885 .subscribe_book_l3(Ustr::from("BTC/USD"), 50)
1886 .await
1887 .expect_err("should fail on invalid depth");
1888
1889 assert!(matches!(err, KrakenWsError::InvalidMessage(_)));
1890 assert!(!client.subscriptions_contains("level3:BTC/USD"));
1891 }
1892
1893 #[rstest]
1894 #[tokio::test]
1895 async fn test_subscribe_book_invalid_depth_errors() {
1896 let client = test_client_without_credentials();
1897 let instrument_id = InstrumentId::from("BTC/USD.KRAKEN");
1898
1899 let err = client
1900 .subscribe_book(instrument_id, Some(50))
1901 .await
1902 .expect_err("should fail on invalid depth");
1903
1904 assert!(matches!(err, KrakenWsError::InvalidMessage(_)));
1905 assert!(!client.subscriptions_contains("book:BTC/USD"));
1906 assert_eq!(client.l2_depths.get("BTC/USD"), None);
1907 }
1908
1909 #[rstest]
1910 #[tokio::test]
1911 async fn test_subscribe_book_defaults_to_depth_10_and_stores_state() {
1912 let client = test_client_without_credentials();
1913 let key = "book:BTC/USD";
1914 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1915 *client.cmd_tx.write().await = cmd_tx;
1916
1917 client
1918 .subscribe_book(InstrumentId::from("BTC/USD.KRAKEN"), None)
1919 .await
1920 .expect("subscribe should succeed");
1921
1922 let cmd = cmd_rx.try_recv().expect("expected subscribe command");
1923 let SpotHandlerCommand::Subscribe { payload } = cmd else {
1924 panic!("expected subscribe command");
1925 };
1926 assert_book_subscribe_payload(&payload, "BTC/USD", 10);
1927
1928 assert_eq!(client.subscriptions.get_reference_count(key), 1);
1929 assert!(client.subscriptions_contains(key));
1930 assert_eq!(client.l2_depths.get("BTC/USD"), Some(10));
1931 assert_eq!(
1932 client.subscription_payloads.read().await.get(key),
1933 Some(&payload)
1934 );
1935 }
1936
1937 #[rstest]
1938 #[tokio::test]
1939 async fn test_subscribe_book_send_failure_rolls_back_l2_state() {
1940 let client = test_client_without_credentials();
1941 let key = "book:BTC/USD";
1942
1943 let err = client
1944 .subscribe_book(InstrumentId::from("BTC/USD.KRAKEN"), Some(10))
1945 .await
1946 .expect_err("should fail when command receiver is closed");
1947
1948 assert!(matches!(err, KrakenWsError::ConnectionError(_)));
1949 assert_eq!(client.subscriptions.get_reference_count(key), 0);
1950 assert!(!client.subscriptions_contains(key));
1951 assert!(client.subscriptions.pending_subscribe_topics().is_empty());
1952 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1953 assert_eq!(client.l2_depths.get("BTC/USD"), None);
1954 assert!(!client.subscription_payloads.read().await.contains_key(key));
1955 }
1956
1957 #[rstest]
1958 fn test_subscribe_book_l3_refcount_idempotent() {
1959 let cfg = KrakenDataClientConfig::default();
1960 let client = KrakenSpotWebSocketClient::l3(cfg, CancellationToken::new(), None);
1961 let key = "level3:BTC/USD";
1962
1963 assert!(client.subscriptions.add_reference(key));
1964 assert!(!client.subscriptions.add_reference(key));
1965 assert!(!client.subscriptions.remove_reference(key));
1966 assert!(client.subscriptions.remove_reference(key));
1967 }
1968
1969 #[rstest]
1970 #[tokio::test]
1971 async fn test_subscribe_book_l3_rejects_depth_mismatch() {
1972 let cfg = KrakenDataClientConfig::default();
1973 let client = KrakenSpotWebSocketClient::l3(cfg, CancellationToken::new(), None);
1974
1975 let key = "level3:BTC/USD";
1976 client.subscriptions.add_reference(key);
1977 client.subscriptions.mark_subscribe(key);
1978 client.subscriptions.confirm_subscribe(key);
1979 client.l3_depths.lock().insert("BTC/USD".to_string(), 1000);
1980
1981 *client.auth_token.write().await = Some("test-token".to_string());
1982
1983 let err = client
1984 .subscribe_book_l3(Ustr::from("BTC/USD"), 10)
1985 .await
1986 .expect_err("should reject depth mismatch");
1987 assert!(matches!(err, KrakenWsError::InvalidMessage(_)));
1988
1989 assert!(client.subscriptions.remove_reference(key));
1990 }
1991
1992 #[rstest]
1993 #[tokio::test]
1994 async fn test_subscribe_book_rejects_depth_mismatch() {
1995 let client = test_client_without_credentials();
1996 let key = "book:BTC/USD";
1997 client.subscriptions.add_reference(key);
1998 client.subscriptions.mark_subscribe(key);
1999 client.subscriptions.confirm_subscribe(key);
2000 client.l2_depths.insert("BTC/USD", 10);
2001
2002 let err = client
2003 .subscribe_book(InstrumentId::from("BTC/USD.KRAKEN"), Some(25))
2004 .await
2005 .expect_err("should reject depth mismatch");
2006 assert!(matches!(err, KrakenWsError::InvalidMessage(_)));
2007
2008 assert!(client.subscriptions.remove_reference(key));
2009 }
2010
2011 #[rstest]
2012 #[tokio::test]
2013 async fn test_unsubscribe_book_removes_l2_depth_on_last_reference() {
2014 let client = test_client_without_credentials();
2015 let key = "book:BTC/USD";
2016 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2017 *client.cmd_tx.write().await = cmd_tx;
2018
2019 client.subscriptions.add_reference(key);
2020 client.subscriptions.mark_subscribe(key);
2021 client.subscriptions.confirm_subscribe(key);
2022 client.l2_depths.insert("BTC/USD", 10);
2023 client
2024 .subscription_payloads
2025 .write()
2026 .await
2027 .insert(key.to_string(), "payload".to_string());
2028
2029 client
2030 .unsubscribe_book(InstrumentId::from("BTC/USD.KRAKEN"))
2031 .await
2032 .expect("unsubscribe should succeed");
2033
2034 assert_eq!(client.subscriptions.get_reference_count(key), 0);
2035 assert!(!client.subscriptions_contains(key));
2036 assert_eq!(client.l2_depths.get("BTC/USD"), None);
2037 assert!(!client.subscription_payloads.read().await.contains_key(key));
2038
2039 let cmd = cmd_rx.try_recv().expect("expected unsubscribe command");
2040 let SpotHandlerCommand::Unsubscribe { payload } = cmd else {
2041 panic!("expected unsubscribe command");
2042 };
2043 assert!(payload.contains(r#""method":"unsubscribe""#));
2044 assert!(payload.contains(r#""channel":"book""#));
2045 assert!(payload.contains(r#""BTC/USD""#));
2046 }
2047
2048 #[rstest]
2049 #[tokio::test]
2050 async fn test_generic_subscribe_rejects_level3() {
2051 let cfg = KrakenDataClientConfig::default();
2052 let client = KrakenSpotWebSocketClient::l3(cfg, CancellationToken::new(), None);
2053
2054 let err = client
2055 .subscribe(
2056 KrakenWsChannel::Level3,
2057 vec![Ustr::from("BTC/USD")],
2058 Some(1000),
2059 )
2060 .await
2061 .expect_err("generic subscribe must reject Level3");
2062 assert!(matches!(err, KrakenWsError::InvalidMessage(_)));
2063
2064 let err = client
2065 .unsubscribe(KrakenWsChannel::Level3, vec![Ustr::from("BTC/USD")])
2066 .await
2067 .expect_err("generic unsubscribe must reject Level3");
2068 assert!(matches!(err, KrakenWsError::InvalidMessage(_)));
2069 }
2070
2071 #[rstest]
2072 fn test_update_auth_token_in_payload_for_level3() {
2073 let original = r#"{"method":"subscribe","params":{"channel":"level3","symbol":["BTC/USD"],"depth":1000,"snapshot":true,"token":"OLD"},"req_id":1}"#;
2074 let rewritten = update_auth_token_in_payload(original, "NEW").unwrap();
2075 assert!(rewritten.contains(r#""token":"NEW""#));
2076 assert!(!rewritten.contains(r#""token":"OLD""#));
2077 }
2078
2079 #[rstest]
2080 fn test_l3_depths_shared_between_subscribe_and_handle() {
2081 let cfg = KrakenDataClientConfig::default();
2082 let client = KrakenSpotWebSocketClient::l3(cfg, CancellationToken::new(), None);
2083
2084 let handle = client.l3_depths_handle();
2085 client.l3_depths.lock().insert("BTC/USD".to_string(), 100);
2086
2087 assert_eq!(handle.lock().get("BTC/USD").copied(), Some(100));
2088 }
2089
2090 #[rstest]
2091 fn test_l2_depths_shared_between_subscribe_and_handle() {
2092 let client = test_client_without_credentials();
2093
2094 let handle = client.l2_depths_handle();
2095 client.l2_depths.insert("BTC/USD", 10);
2096
2097 assert_eq!(handle.get("BTC/USD"), Some(10));
2098 }
2099
2100 #[rstest]
2101 fn test_resync_book_l3_does_not_touch_refcount() {
2102 let cfg = KrakenDataClientConfig::default();
2103 let client = KrakenSpotWebSocketClient::l3(cfg, CancellationToken::new(), None);
2104 let key = "level3:BTC/USD";
2105
2106 assert!(client.subscriptions.add_reference(key));
2107 assert!(!client.subscriptions.add_reference(key));
2108 client.subscriptions.mark_subscribe(key);
2109 client.subscriptions.confirm_subscribe(key);
2110
2111 assert!(client.subscriptions_contains(key));
2112 }
2113
2114 fn assert_book_subscribe_payload(payload: &str, symbol: &str, depth: u32) {
2115 let value: serde_json::Value =
2116 serde_json::from_str(payload).expect("payload should parse as JSON");
2117
2118 assert_eq!(value["method"], serde_json::json!("subscribe"));
2119 assert_eq!(value["params"]["channel"], serde_json::json!("book"));
2120 assert_eq!(value["params"]["symbol"], serde_json::json!([symbol]));
2121 assert_eq!(value["params"]["depth"], serde_json::json!(depth));
2122 }
2123}