Skip to main content

nautilus_kraken/websocket/spot_v2/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! WebSocket client for the Kraken v2 streaming API.
17
18use 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
49/// Topic delimiter for Kraken Spot v2 WebSocket subscriptions.
50///
51/// Topics use colon format: `channel:symbol` (e.g., `Trade:ETH/USD`).
52pub 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/// WebSocket client for the Kraken Spot v2 streaming API.
76#[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    /// Creates a new client for the configured public/private endpoint.
133    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    /// Creates a new client configured for the Kraken Spot `level3` WebSocket endpoint.
147    ///
148    /// Selects `config.ws_l3_url()` and otherwise mirrors [`Self::new`]. `Level3`
149    /// subscriptions are treated as authenticated and must follow `authenticate()`.
150    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    /// Configures socket state reporting and reconnect control.
205    #[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    /// Returns the shared request-id counter.
216    pub fn req_id_counter(&self) -> Arc<AtomicU64> {
217        self.req_id_counter.clone()
218    }
219
220    /// Returns a clone of the handler command channel sender.
221    pub async fn handler_command_sender(
222        &self,
223    ) -> tokio::sync::mpsc::UnboundedSender<SpotHandlerCommand> {
224        self.cmd_tx.read().await.clone()
225    }
226
227    /// Returns the shared `cmd_tx` handle. Unlike
228    /// [`handler_command_sender`](Self::handler_command_sender) (a snapshot
229    /// clone), this exposes the `RwLock` so callers see the live sender
230    /// after `connect()` swaps it in.
231    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    /// Returns the current cached authentication token, if any.
238    pub async fn auth_token(&self) -> Option<String> {
239        self.auth_token.read().await.clone()
240    }
241
242    /// Returns the current cached authentication token without awaiting.
243    ///
244    /// Returns `None` when the lock is contended or no token is cached. Used by
245    /// the synchronous order-routing path where the auth token is normally
246    /// uncontended; callers fall back to REST when the lock is unavailable.
247    pub fn auth_token_blocking(&self) -> Option<String> {
248        self.auth_token.try_read().ok().and_then(|g| g.clone())
249    }
250
251    /// Returns a clone of the auth token handle for components that need
252    /// non-async, lock-free read access (e.g. compensating cancels triggered
253    /// from the timeout task in `OrderRequestState`).
254    pub fn auth_token_handle(&self) -> Arc<tokio::sync::RwLock<Option<String>>> {
255        self.auth_token.clone()
256    }
257
258    /// Connects to the WebSocket server.
259    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            // Treat a silent connection as dead so the reconnect + resubscribe
299            // path runs. `0` disables; see `ws_idle_timeout_ms` docs (issue #4255).
300            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        // Share connection state across clones via ArcSwap
328        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    /// Disconnects from the WebSocket server.
490    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    /// Closes the WebSocket connection.
544    pub async fn close(&mut self) -> Result<(), KrakenWsError> {
545        self.disconnect().await
546    }
547
548    /// Waits until the connection is active or timeout.
549    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    /// Returns true if the WebSocket is authenticated for private subscriptions.
568    #[must_use]
569    pub fn is_authenticated(&self) -> bool {
570        self.auth_tracker.is_authenticated()
571    }
572
573    /// Waits until the WebSocket is authenticated or the timeout elapses.
574    ///
575    /// Returns an error on timeout or explicit auth failure.
576    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    /// Authenticates with the Kraken API to enable private subscriptions.
589    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    /// Cancels all pending requests.
613    pub fn cancel_all_requests(&self) {
614        self.cancellation_token.cancel();
615    }
616
617    /// Returns the cancellation token for this client.
618    pub fn cancellation_token(&self) -> &CancellationToken {
619        &self.cancellation_token
620    }
621
622    /// Subscribes to a channel for the given symbols.
623    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    /// Subscribes to a channel with a specific interval (for OHLC).
695    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    /// Unsubscribes from a channel with a specific interval (for OHLC).
747    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    /// Unsubscribes from a channel for the given symbols.
796    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    /// Sends a ping message to keep the connection alive.
868    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    /// Returns true if connected (not closed).
921    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    /// Returns true if the connection is active.
927    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    /// Returns true if the connection is closed.
934    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    /// Returns the WebSocket URL.
941    pub fn url(&self) -> &str {
942        &self.url
943    }
944
945    /// Returns all active subscriptions.
946    pub fn get_subscriptions(&self) -> Vec<String> {
947        self.subscriptions.all_topics()
948    }
949
950    /// Returns `true` if a topic is currently subscribed (confirmed).
951    pub fn subscriptions_contains(&self, topic: &str) -> bool {
952        self.subscriptions.all_topics().iter().any(|t| t == topic)
953    }
954
955    /// Sets the account ID for execution report parsing.
956    pub fn set_account_id(&self, account_id: AccountId) {
957        *self.account_id.write() = Some(account_id);
958    }
959
960    /// Returns the account ID if set.
961    #[must_use]
962    pub fn account_id(&self) -> Option<AccountId> {
963        *self.account_id.read()
964    }
965
966    /// Caches an instrument for execution report parsing.
967    pub fn cache_instrument(&self, instrument: InstrumentAny) {
968        self.instruments.insert(instrument.id(), instrument);
969    }
970
971    /// Returns a shared reference to the account ID.
972    pub fn account_id_shared(&self) -> &Arc<RwLock<Option<AccountId>>> {
973        &self.account_id
974    }
975
976    /// Returns a shared reference to the truncated ID map.
977    pub fn truncated_id_map(&self) -> &Arc<AtomicMap<String, ClientOrderId>> {
978        &self.truncated_id_map
979    }
980
981    /// Caches a client order for truncated ID resolution.
982    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    /// Returns a stream of WebSocket messages.
998    ///
999    /// # Errors
1000    ///
1001    /// Returns an error if:
1002    /// - The stream receiver has already been taken
1003    /// - Other clones of this client still hold references to the receiver
1004    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    /// Subscribes to order book updates for the given instrument.
1026    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    /// Subscribes to the `level3` channel for the given Kraken symbol.
1097    ///
1098    /// `depth` must be one of `10`, `100`, or `1000`. The depth is recorded in
1099    /// the per-client `l3_depths` map so the message handler and `resync_book_l3`
1100    /// can recover it without a separate side-table.
1101    ///
1102    /// If the symbol is already subscribed, the existing depth must match - Kraken
1103    /// streams one depth per `(symbol, channel)` pair, so a second subscribe with
1104    /// a different depth would corrupt the local runtime state. Mismatch returns
1105    /// an error without mutating state.
1106    ///
1107    /// # Errors
1108    ///
1109    /// Returns an error if `depth` is invalid, the auth token is not cached
1110    /// (call `authenticate` first), the requested depth differs from an existing
1111    /// subscription, or the message cannot be sent. On any of those failures
1112    /// the reference count and pending state are rolled back fully so callers
1113    /// retry from a clean state.
1114    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    /// Unsubscribes from the `level3` channel for the given Kraken symbol.
1186    ///
1187    /// Mirrors the existing `KrakenSpotWebSocketClient::unsubscribe` pattern:
1188    /// `SubscriptionState` is mutated optimistically before `send_command`. If
1189    /// the send fails, the local state reflects an unsubscribe that the venue
1190    /// never received; the next reconnect's payload replay will not include the
1191    /// topic (it was removed from `subscription_payloads`). Tightening this to
1192    /// roll-back-on-send-fail is a codebase-wide pattern change (every channel
1193    /// has it) and is out of scope for this PR - the L3 path matches the
1194    /// existing surface area rather than introducing an inconsistent improvement.
1195    ///
1196    /// # Errors
1197    ///
1198    /// Returns an error if the message cannot be sent.
1199    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    /// Resynchronizes the `level3` book for `symbol` after a checksum mismatch.
1233    ///
1234    /// Refreshes the auth token unconditionally and issues a venue-level
1235    /// unsubscribe followed by a subscribe with `snapshot=true`, **bypassing
1236    /// `SubscriptionState` reference counts** so the user's logical
1237    /// subscription survives. The reference count is not changed; if multiple
1238    /// callers hold references to the same symbol, all of them continue to see
1239    /// the symbol as subscribed throughout the resync.
1240    ///
1241    /// # Errors
1242    ///
1243    /// Returns an error if the auth token cannot be refreshed or the
1244    /// unsubscribe/subscribe messages cannot be sent.
1245    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        // A resync can be retrying / awaiting a token refresh while the user
1250        // unsubscribes. Bail before mutating any state if the user no longer
1251        // holds a logical subscription, otherwise the venue-level subscribe
1252        // below would resurrect an orphaned stream that `SubscriptionState`
1253        // can never tear down.
1254        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        // Re-check after the await - the user may have unsubscribed while we
1263        // were minting a fresh token.
1264        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        // Final check before issuing the resubscribe - same race window.
1290        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        // Only persist replay payload + depth if the subscription is still
1314        // referenced. A late-arriving unsubscribe between `send_command` and
1315        // here would otherwise leave an orphan entry the reconnect path would
1316        // replay.
1317        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    /// Returns whether L3 checksum validation is enabled for this client.
1329    pub fn validate_l3_checksum(&self) -> bool {
1330        self.config.validate_l3_checksum
1331    }
1332
1333    /// Returns `true` if the client has API credentials configured
1334    /// (post-environment-variable resolution).
1335    pub fn has_credentials(&self) -> bool {
1336        self.config.has_api_credentials()
1337    }
1338
1339    /// Returns a shared handle to the per-client instrument map.
1340    ///
1341    /// L3 stream-loop consumers read instruments through this handle so that
1342    /// `cache_instrument()` updates made after `connect()` are observed by the
1343    /// runtime book reconstruction without needing a re-connect.
1344    pub fn instruments_handle(&self) -> Arc<AtomicMap<InstrumentId, InstrumentAny>> {
1345        Arc::clone(&self.instruments)
1346    }
1347
1348    /// Returns a shared handle to the per-symbol L3 depth map.
1349    ///
1350    /// Stream-loop consumers read this map to drive `process_l3_message`'s
1351    /// resync depth lookup; `subscribe_book_l3` writes the depth.
1352    pub fn l3_depths_handle(&self) -> Arc<parking_lot::Mutex<ahash::AHashMap<String, u32>>> {
1353        Arc::clone(&self.l3_depths)
1354    }
1355
1356    /// Returns a shared handle to the per-symbol L2 depth map.
1357    pub(crate) fn l2_depths_handle(&self) -> L2Depths {
1358        self.l2_depths.clone()
1359    }
1360
1361    /// Subscribes to quote updates for the given instrument.
1362    ///
1363    /// Uses the Ticker channel with `event_trigger: "bbo"` for updates only on
1364    /// best bid/offer changes.
1365    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    /// Subscribes to trade updates for the given instrument.
1402    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    /// Subscribes to bar/OHLC updates for the given bar type.
1409    ///
1410    /// # Errors
1411    ///
1412    /// Returns an error if the bar aggregation is not supported by Kraken.
1413    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    /// Subscribes to execution updates (order and fill events).
1421    ///
1422    /// Requires authentication - call `authenticate()` first.
1423    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    /// Unsubscribes from order book updates for the given instrument.
1469    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    /// Unsubscribes from quote updates for the given instrument.
1505    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    /// Unsubscribes from trade updates for the given instrument.
1542    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    /// Unsubscribes from bar/OHLC updates for the given bar type.
1551    ///
1552    /// # Errors
1553    ///
1554    /// Returns an error if the bar aggregation is not supported by Kraken.
1555    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
1563/// Helper function to refresh authentication token via HTTP API.
1564async 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}