Skip to main content

nautilus_deribit/websocket/
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 Deribit API.
17//!
18//! The [`DeribitWebSocketClient`] provides connectivity to Deribit's WebSocket API using
19//! JSON-RPC 2.0. It supports subscribing to market data channels including trades, order books,
20//! and tickers.
21
22use std::{
23    fmt::Debug,
24    sync::{
25        Arc,
26        atomic::{AtomicBool, AtomicU8, Ordering},
27    },
28    time::Duration,
29};
30
31use arc_swap::ArcSwap;
32use futures_util::{FutureExt, Stream, StreamExt, stream::FuturesUnordered};
33use nautilus_common::{enums::LogColor, log_debug};
34use nautilus_core::{
35    AtomicMap, AtomicSet, env::get_or_env_var_opt, string::secret::SecretString,
36    time::get_atomic_clock_realtime,
37};
38use nautilus_live::{SocketControl, task::TaskGroup};
39use nautilus_model::{
40    data::BarType,
41    enums::OrderSide,
42    identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId},
43    instruments::{Instrument, InstrumentAny},
44};
45use nautilus_network::{
46    ConnectionUrl,
47    http::create_standard_nautilus_headers,
48    mode::ConnectionMode,
49    websocket::{
50        AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
51        channel_message_handler,
52    },
53};
54use parking_lot::{Mutex, RwLock, RwLockReadGuard};
55use tokio_util::sync::CancellationToken;
56use ustr::Ustr;
57
58use super::{
59    auth::{AuthState, refresh_token_after_delay, send_auth_request},
60    enums::{DeribitUpdateInterval, DeribitWsChannel},
61    error::{DeribitWsError, DeribitWsResult},
62    handler::{DeribitWsFeedHandler, HandlerCommand},
63    messages::{
64        DeribitCancelAllByInstrumentParams, DeribitCancelParams, DeribitEditParams,
65        DeribitOrderParams, NautilusWsMessage,
66    },
67};
68use crate::common::{
69    consts::{
70        DERIBIT_BOOK_DEFAULT_DEPTH, DERIBIT_TESTNET_WS_URL, DERIBIT_WS_HEARTBEAT_SECS,
71        DERIBIT_WS_ORDER_KEY, DERIBIT_WS_ORDER_QUOTA, DERIBIT_WS_SUBSCRIPTION_KEY,
72        DERIBIT_WS_SUBSCRIPTION_QUOTA, DERIBIT_WS_URL,
73    },
74    credential::{Credential, credential_env_vars},
75    enums::DeribitEnvironment,
76    parse::bar_spec_to_resolution,
77};
78
79/// Authentication timeout in seconds.
80const AUTHENTICATION_TIMEOUT_SECS: u64 = 30;
81
82type CommandSender = tokio::sync::mpsc::UnboundedSender<HandlerCommand>;
83
84/// WebSocket client for connecting to Deribit.
85#[derive(Clone)]
86pub struct DeribitWebSocketClient {
87    url: ConnectionUrl,
88    environment: DeribitEnvironment,
89    heartbeat_interval: Option<u64>,
90    auth_timeout_secs: u64,
91    credential: Option<Credential>,
92    auth_state: Arc<tokio::sync::RwLock<Option<AuthState>>>,
93    signal: Arc<AtomicBool>,
94    connection_mode: Arc<ArcSwap<AtomicU8>>,
95    auth_tracker: AuthTracker,
96    cmd_tx: Arc<RwLock<CommandSender>>,
97    out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>>>,
98    handler_tasks: Arc<TaskGroup>,
99    connect_lock: Arc<tokio::sync::Mutex<()>>,
100    subscriptions_state: SubscriptionState,
101    instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
102    option_greeks_subs: Arc<AtomicSet<InstrumentId>>,
103    mark_price_subs: Arc<AtomicSet<InstrumentId>>,
104    index_price_subs: Arc<AtomicSet<InstrumentId>>,
105    book_depth_subs: Arc<tokio::sync::Mutex<SubscriptionState>>,
106    cancellation_token: CancellationToken,
107    account_id: Option<AccountId>,
108    bars_timestamp_on_close: bool,
109    subscribe_errors: Arc<Mutex<Vec<String>>>,
110    transport_backend: TransportBackend,
111    proxy_url: Option<SecretString>,
112    socket_control: Option<SocketControl>,
113}
114
115impl Debug for DeribitWebSocketClient {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_struct(stringify!(DeribitWebSocketClient))
118            .field("url", &self.url)
119            .field("environment", &self.environment)
120            .field("has_credentials", &self.credential.is_some())
121            .field("is_authenticated", &self.auth_tracker.is_authenticated())
122            .field(
123                "has_auth_state",
124                &self.auth_state.try_read().is_ok_and(|s| s.is_some()),
125            )
126            .field("heartbeat_interval", &self.heartbeat_interval)
127            .finish_non_exhaustive()
128    }
129}
130
131impl DeribitWebSocketClient {
132    /// Creates a new [`DeribitWebSocketClient`] instance.
133    ///
134    /// Falls back to environment variables if credentials are not provided.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if only one of `api_key` or `api_secret` is provided.
139    #[expect(clippy::too_many_arguments)]
140    pub fn new(
141        url: Option<String>,
142        api_key: Option<String>,
143        api_secret: Option<String>,
144        heartbeat_interval: u64,
145        auth_timeout_secs: Option<u64>,
146        environment: DeribitEnvironment,
147        transport_backend: TransportBackend,
148        proxy_url: Option<String>,
149    ) -> anyhow::Result<Self> {
150        Self::new_inner(
151            url,
152            api_key,
153            api_secret,
154            heartbeat_interval,
155            auth_timeout_secs,
156            environment,
157            true,
158            transport_backend,
159            proxy_url,
160        )
161    }
162
163    /// Internal constructor with control over environment variable fallback.
164    #[expect(clippy::too_many_arguments)]
165    fn new_inner(
166        url: Option<String>,
167        api_key: Option<String>,
168        api_secret: Option<String>,
169        heartbeat_interval: u64,
170        auth_timeout_secs: Option<u64>,
171        environment: DeribitEnvironment,
172        env_fallback: bool,
173        transport_backend: TransportBackend,
174        proxy_url: Option<String>,
175    ) -> anyhow::Result<Self> {
176        let url = url.unwrap_or_else(|| match environment {
177            DeribitEnvironment::Testnet => DERIBIT_TESTNET_WS_URL.to_string(),
178            DeribitEnvironment::Mainnet => DERIBIT_WS_URL.to_string(),
179        });
180
181        // Resolve credential from config or environment variables (if env_fallback is true)
182        let credential =
183            Credential::resolve_with_env_fallback(api_key, api_secret, environment, env_fallback)?;
184
185        if credential.is_some() {
186            log::debug!("Credentials loaded ({environment})");
187        } else {
188            log::debug!("No credentials configured - unauthenticated mode");
189        }
190
191        let signal = Arc::new(AtomicBool::new(false));
192        let subscriptions_state = SubscriptionState::new('.');
193
194        Ok(Self {
195            url: url.into(),
196            environment,
197            heartbeat_interval: Some(heartbeat_interval),
198            auth_timeout_secs: auth_timeout_secs.unwrap_or(AUTHENTICATION_TIMEOUT_SECS),
199            credential,
200            auth_state: Arc::new(tokio::sync::RwLock::new(None)),
201            signal,
202            connection_mode: Arc::new(ArcSwap::from_pointee(AtomicU8::new(
203                ConnectionMode::Closed.as_u8(),
204            ))),
205            auth_tracker: AuthTracker::new(),
206            cmd_tx: {
207                let (tx, _) = tokio::sync::mpsc::unbounded_channel();
208                Arc::new(RwLock::new(tx))
209            },
210            out_rx: None,
211            handler_tasks: Arc::new(TaskGroup::new()),
212            connect_lock: Arc::new(tokio::sync::Mutex::new(())),
213            subscriptions_state,
214            instruments_cache: Arc::new(AtomicMap::new()),
215            option_greeks_subs: Arc::new(AtomicSet::new()),
216            mark_price_subs: Arc::new(AtomicSet::new()),
217            index_price_subs: Arc::new(AtomicSet::new()),
218            book_depth_subs: Arc::new(tokio::sync::Mutex::new(SubscriptionState::new('.'))),
219            cancellation_token: CancellationToken::new(),
220            account_id: None,
221            bars_timestamp_on_close: true,
222            subscribe_errors: Arc::new(Mutex::new(Vec::new())),
223            transport_backend,
224            proxy_url: proxy_url.map(SecretString::from),
225            socket_control: None,
226        })
227    }
228
229    pub(crate) fn begin_shutdown(&self) {
230        self.handler_tasks.begin_shutdown();
231        self.signal.store(true, Ordering::Relaxed);
232    }
233
234    /// Configures socket state reporting and reconnect control.
235    #[must_use]
236    pub fn with_socket_control(mut self, control: SocketControl) -> Self {
237        self.socket_control = Some(control);
238        self
239    }
240
241    /// Creates a new public (unauthenticated) client.
242    ///
243    /// Does NOT fall back to environment variables for credentials.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if initialization fails.
248    pub fn new_public(
249        environment: DeribitEnvironment,
250        proxy_url: Option<String>,
251    ) -> anyhow::Result<Self> {
252        Self::new_inner(
253            None,
254            None,
255            None,
256            DERIBIT_WS_HEARTBEAT_SECS,
257            None,
258            environment,
259            false,
260            TransportBackend::default(),
261            proxy_url,
262        )
263    }
264
265    /// Creates an unauthenticated client with a custom URL.
266    ///
267    /// Does NOT fall back to environment variables for credentials.
268    /// Useful for testing against mock servers.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if initialization fails.
273    pub fn new_unauthenticated(
274        url: Option<String>,
275        heartbeat_interval: u64,
276        environment: DeribitEnvironment,
277    ) -> anyhow::Result<Self> {
278        Self::new_inner(
279            url,
280            None,
281            None,
282            heartbeat_interval,
283            None,
284            environment,
285            false,
286            TransportBackend::default(),
287            None,
288        )
289    }
290
291    /// Creates an authenticated client with credentials.
292    ///
293    /// Resolves each credential from the provided argument first, falling back
294    /// to the environment variable for the given `environment`:
295    /// - Testnet: `DERIBIT_TESTNET_API_KEY` and `DERIBIT_TESTNET_API_SECRET`
296    /// - Mainnet: `DERIBIT_API_KEY` and `DERIBIT_API_SECRET`
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if neither the argument nor the environment variable
301    /// provides a credential.
302    pub fn with_credentials(
303        environment: DeribitEnvironment,
304        api_key: Option<String>,
305        api_secret: Option<String>,
306        auth_timeout_secs: Option<u64>,
307        proxy_url: Option<String>,
308    ) -> anyhow::Result<Self> {
309        let (key_env, secret_env) = credential_env_vars(environment);
310
311        let api_key = get_or_env_var_opt(api_key, key_env)
312            .ok_or_else(|| anyhow::anyhow!("Missing environment variable: {key_env}"))?;
313        let api_secret = get_or_env_var_opt(api_secret, secret_env)
314            .ok_or_else(|| anyhow::anyhow!("Missing environment variable: {secret_env}"))?;
315
316        Self::new(
317            None,
318            Some(api_key),
319            Some(api_secret),
320            DERIBIT_WS_HEARTBEAT_SECS,
321            auth_timeout_secs,
322            environment,
323            TransportBackend::default(),
324            proxy_url,
325        )
326    }
327
328    /// Returns the current connection mode.
329    fn connection_mode(&self) -> ConnectionMode {
330        let mode_u8 = self.connection_mode.load().load(Ordering::Relaxed);
331        ConnectionMode::from_u8(mode_u8)
332    }
333
334    /// Returns whether the client is actively connected.
335    #[must_use]
336    pub fn is_active(&self) -> bool {
337        self.connection_mode() == ConnectionMode::Active
338    }
339
340    /// Returns the WebSocket URL.
341    #[must_use]
342    pub fn url(&self) -> &str {
343        self.url.expose()
344    }
345
346    /// Returns the environment for this client.
347    #[must_use]
348    pub fn environment(&self) -> DeribitEnvironment {
349        self.environment
350    }
351
352    /// Returns whether the client is closed.
353    #[must_use]
354    pub fn is_closed(&self) -> bool {
355        let mode = self.connection_mode();
356        mode == ConnectionMode::Disconnect || mode == ConnectionMode::Closed
357    }
358
359    /// Cancel all pending WebSocket requests.
360    pub fn cancel_all_requests(&self) {
361        self.cancellation_token.cancel();
362    }
363
364    /// Returns the cancellation token for this client.
365    #[must_use]
366    pub fn cancellation_token(&self) -> &CancellationToken {
367        &self.cancellation_token
368    }
369
370    /// Waits until the client is active or timeout expires.
371    ///
372    /// # Errors
373    ///
374    /// Returns an error if the timeout expires before the client becomes active.
375    pub async fn wait_until_active(&self, timeout_secs: f64) -> DeribitWsResult<()> {
376        let timeout = tokio::time::Duration::from_secs_f64(timeout_secs);
377
378        tokio::time::timeout(timeout, async {
379            while !self.is_active() {
380                tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
381            }
382        })
383        .await
384        .map_err(|_| {
385            DeribitWsError::Timeout(format!(
386                "WebSocket connection timeout after {timeout_secs} seconds"
387            ))
388        })?;
389
390        Ok(())
391    }
392
393    /// Waits until all pending subscriptions are confirmed by the server.
394    ///
395    /// # Errors
396    ///
397    /// Returns an error if the timeout expires before all subscriptions are confirmed.
398    pub async fn wait_for_subscriptions_confirmed(&self, timeout_secs: f64) -> DeribitWsResult<()> {
399        let timeout = Duration::from_secs_f64(timeout_secs);
400
401        tokio::time::timeout(timeout, async {
402            loop {
403                // Fail fast on permanent subscribe errors
404                let subscribe_error = {
405                    let mut errors = self.subscribe_errors.lock();
406                    if errors.is_empty() {
407                        None
408                    } else {
409                        let msg = errors.join("; ");
410                        errors.clear();
411                        Some(msg)
412                    }
413                };
414
415                if let Some(msg) = subscribe_error {
416                    return Err(DeribitWsError::Subscribe(msg));
417                }
418
419                let pending = self.subscriptions_state.pending_subscribe_topics();
420                if pending.is_empty() {
421                    return Ok(());
422                }
423                tokio::time::sleep(Duration::from_millis(10)).await;
424            }
425        })
426        .await
427        .map_err(|_| {
428            let pending = self.subscriptions_state.pending_subscribe_topics();
429            DeribitWsError::Timeout(format!(
430                "Subscription confirmation timeout after {timeout_secs}s, \
431                still pending: {pending:?}"
432            ))
433        })?
434    }
435
436    /// Caches instruments for use during message parsing.
437    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
438        let tx = self.command_sender();
439
440        self.instruments_cache.rcu(|m| {
441            for inst in instruments {
442                m.insert(inst.raw_symbol().inner(), inst.clone());
443            }
444        });
445        log::debug!("Cached {} instruments", self.instruments_cache.len());
446
447        // Send per-instrument updates to the live handler rather than
448        // a full snapshot, avoiding out-of-order snapshot races.
449        if self.is_active() {
450            for inst in instruments {
451                let _ = tx.send(HandlerCommand::UpdateInstrument(Box::new(inst.clone())));
452            }
453        }
454    }
455
456    /// Caches a single instrument.
457    pub fn cache_instrument(&self, instrument: InstrumentAny) {
458        let tx = self.command_sender();
459        let symbol = instrument.raw_symbol().inner();
460        self.instruments_cache.insert(symbol, instrument);
461
462        // If connected, send update to handler
463        if self.is_active() {
464            let inst = self.instruments_cache.get_cloned(&symbol);
465
466            if let Some(inst) = inst {
467                let _ = tx.send(HandlerCommand::UpdateInstrument(Box::new(inst)));
468            }
469        }
470    }
471
472    /// Sets the shared option greeks subscription set for handler-side gating.
473    pub fn set_option_greeks_subs(&mut self, subs: Arc<AtomicSet<InstrumentId>>) {
474        self.option_greeks_subs = subs;
475    }
476
477    /// Sets the shared mark price subscription set for handler-side gating.
478    pub fn set_mark_price_subs(&mut self, subs: Arc<AtomicSet<InstrumentId>>) {
479        self.mark_price_subs = subs;
480    }
481
482    /// Sets the shared index price subscription set for handler-side gating.
483    pub fn set_index_price_subs(&mut self, subs: Arc<AtomicSet<InstrumentId>>) {
484        self.index_price_subs = subs;
485    }
486
487    /// Registers an instrument for mark price emission from ticker messages.
488    pub fn add_mark_price_sub(&self, instrument_id: InstrumentId) {
489        self.mark_price_subs.insert(instrument_id);
490    }
491
492    /// Unregisters an instrument from mark price emission.
493    pub fn remove_mark_price_sub(&self, instrument_id: &InstrumentId) {
494        self.mark_price_subs.remove(instrument_id);
495    }
496
497    /// Registers an instrument for index price emission from ticker messages.
498    pub fn add_index_price_sub(&self, instrument_id: InstrumentId) {
499        self.index_price_subs.insert(instrument_id);
500    }
501
502    /// Unregisters an instrument from index price emission.
503    pub fn remove_index_price_sub(&self, instrument_id: &InstrumentId) {
504        self.index_price_subs.remove(instrument_id);
505    }
506
507    /// Registers an instrument for option greeks emission from ticker messages.
508    pub fn add_option_greeks_sub(&self, instrument_id: InstrumentId) {
509        self.option_greeks_subs.insert(instrument_id);
510    }
511
512    /// Unregisters an instrument from option greeks emission.
513    pub fn remove_option_greeks_sub(&self, instrument_id: &InstrumentId) {
514        self.option_greeks_subs.remove(instrument_id);
515    }
516
517    /// Connects to the Deribit WebSocket API.
518    ///
519    /// # Errors
520    ///
521    /// Returns an error if the connection fails.
522    pub async fn connect(&mut self) -> anyhow::Result<()> {
523        let connect_lock = Arc::clone(&self.connect_lock);
524        let _connect_guard = connect_lock.lock().await;
525
526        log_debug!(
527            "Connecting to WebSocket: {}",
528            self.url.origin(),
529            color = LogColor::Blue
530        );
531
532        if !self.handler_tasks.is_open() || !self.handler_tasks.is_empty() {
533            self.handler_tasks.begin_shutdown();
534            self.signal.store(true, Ordering::Relaxed);
535            self.finish_handler()
536                .await
537                .map_err(|e| anyhow::anyhow!("failed to stop prior WebSocket handler: {e}"))?;
538            self.handler_tasks.start_generation().map_err(|e| {
539                anyhow::anyhow!("failed to start WebSocket handler task generation: {e}")
540            })?;
541        }
542        let handler_spawner = self.handler_tasks.spawner().map_err(|e| {
543            anyhow::anyhow!("failed to acquire WebSocket handler task spawner: {e}")
544        })?;
545
546        // Reset stop signal and subscription state so callers can
547        // resubscribe cleanly after a manual disconnect/connect cycle.
548        self.signal.store(false, Ordering::Relaxed);
549        self.subscriptions_state.clear();
550        self.book_depth_subs.lock().await.clear();
551
552        // Create message handler and channel
553        let (message_handler, raw_rx) = channel_message_handler();
554
555        // No-op ping handler: handler responds to pings directly
556        // Inbound Ping frames are answered by the transport, so no ping handler is needed;
557        // the reader routes them away from the message channel and the handler never sees them.
558
559        // Configure WebSocket client
560        let headers = create_standard_nautilus_headers();
561
562        let config = WebSocketConfig {
563            url: self.url.expose().to_owned(),
564            headers,
565            heartbeat_interval_secs: self.heartbeat_interval,
566            heartbeat_payload: None, // Deribit uses JSON-RPC heartbeat, not text ping
567            connect_timeout_ms: Some(5_000),
568            reconnect_delay_initial_ms: None,
569            reconnect_delay_max_ms: None,
570            reconnect_backoff_factor: None,
571            reconnect_jitter_ms: None,
572            reconnect_max_attempts: None,
573            heartbeat_timeout_secs: None,
574            idle_timeout_ms: None,
575            writer_capacity: None,
576            backend: self.transport_backend,
577            proxy_url: self
578                .proxy_url
579                .as_ref()
580                .map(|value| value.expose_secret().to_owned()),
581            max_message_size_bytes: None,
582            max_frame_size_bytes: None,
583        };
584
585        // Configure rate limits
586        let keyed_quotas = vec![
587            (
588                DERIBIT_WS_SUBSCRIPTION_KEY.to_string(),
589                *DERIBIT_WS_SUBSCRIPTION_QUOTA,
590            ),
591            (DERIBIT_WS_ORDER_KEY.to_string(), *DERIBIT_WS_ORDER_QUOTA),
592        ];
593
594        // Connect the WebSocket
595        let ws_client = WebSocketClient::builder()
596            .config(config)
597            .message_handler(message_handler)
598            .keyed_quotas(keyed_quotas)
599            .default_quota(*DERIBIT_WS_SUBSCRIPTION_QUOTA)
600            .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
601            .connect()
602            .await?;
603
604        let connection_mode = ws_client.connection_mode_atomic();
605        let reconnect_handle = ws_client.reconnect_handle();
606
607        // Create message channels
608        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
609        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel();
610
611        self.out_rx = Some(Arc::new(out_rx));
612
613        self.subscribe_errors.lock().clear();
614
615        // Create handler
616        let mut handler = DeribitWsFeedHandler::new(
617            self.signal.clone(),
618            cmd_rx,
619            raw_rx,
620            out_tx,
621            self.auth_tracker.clone(),
622            self.subscriptions_state.clone(),
623            self.option_greeks_subs.clone(),
624            self.mark_price_subs.clone(),
625            self.index_price_subs.clone(),
626            self.account_id,
627            self.bars_timestamp_on_close,
628            self.subscribe_errors.clone(),
629        );
630
631        handler.set_book_depth_subs(self.book_depth_subs.clone());
632
633        if let Some(control) = &self.socket_control {
634            control.register(move || reconnect_handle.request_reconnect());
635        }
636
637        // Cache updates hold a read guard while mutating the central cache. Publishing the new
638        // sender under the write guard ensures later subscriptions follow this cache snapshot.
639        {
640            let mut command_sender = self.cmd_tx.write();
641            let _ = cmd_tx.send(HandlerCommand::SetClient(ws_client));
642
643            let instruments: Vec<InstrumentAny> =
644                self.instruments_cache.load().values().cloned().collect();
645
646            if !instruments.is_empty() {
647                log::debug!(
648                    "Sending {} cached instruments to handler",
649                    instruments.len()
650                );
651                let _ = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments));
652            }
653
654            if let Some(interval) = self.heartbeat_interval {
655                let _ = cmd_tx.send(HandlerCommand::SetHeartbeat { interval });
656            }
657
658            *command_sender = cmd_tx.clone();
659            self.connection_mode.store(connection_mode);
660        }
661
662        // Spawn handler task
663        let subscriptions_state = self.subscriptions_state.clone();
664        let credential = self.credential.clone();
665        let auth_tracker = self.auth_tracker.clone();
666        let auth_state = self.auth_state.clone();
667        let heartbeat_interval = self.heartbeat_interval;
668
669        let handler_task = async move {
670            const MAX_REAUTH_ATTEMPTS: u32 = 3;
671
672            let mut pending_reauth = false;
673            let mut reauth_attempts: u32 = 0;
674
675            let mut refresh_cancel = CancellationToken::new();
676            let mut retry_cancel = CancellationToken::new();
677            let mut lifecycle_futures = FuturesUnordered::new();
678
679            loop {
680                let message = {
681                    // `handler.next()` can dequeue work before awaiting transport I/O, so keep the
682                    // same future alive while handler-owned lifecycle futures complete.
683                    let next = handler.next();
684                    tokio::pin!(next);
685
686                    loop {
687                        tokio::select! {
688                            message = &mut next => break message,
689                            _ = lifecycle_futures.next(), if !lifecycle_futures.is_empty() => {}
690                        }
691                    }
692                };
693
694                match message {
695                    Some(msg) => match msg {
696                        NautilusWsMessage::Reconnected => {
697                            log::info!("Reconnected to WebSocket");
698
699                            // Cancel stale refresh and retry tasks from prior connection
700                            refresh_cancel.cancel();
701                            refresh_cancel = CancellationToken::new();
702                            retry_cancel.cancel();
703                            retry_cancel = CancellationToken::new();
704
705                            // Deribit scopes `set_heartbeat` to the connection, so the replacement
706                            // starts with heartbeats off and the venue sends no further
707                            // `test_request` until they are re-armed.
708                            if let Some(interval) = heartbeat_interval {
709                                let _ = cmd_tx.send(HandlerCommand::SetHeartbeat { interval });
710                            }
711
712                            let channels = subscriptions_state.reset_after_reconnect();
713
714                            // Check if we need to re-authenticate
715                            if let Some(cred) = &credential {
716                                log::info!("Re-authenticating after reconnection...");
717
718                                let _rx = auth_tracker.begin();
719                                pending_reauth = true;
720                                reauth_attempts = 1;
721
722                                let previous_scope =
723                                    auth_state.read().await.as_ref().map(|s| s.scope.clone());
724
725                                send_auth_request(cred, previous_scope, &cmd_tx);
726                            } else {
727                                // No credentials - resubscribe immediately
728                                if !channels.is_empty() {
729                                    let _ = cmd_tx.send(HandlerCommand::Subscribe { channels });
730                                }
731                            }
732                        }
733                        NautilusWsMessage::Authenticated(result) => {
734                            let result = *result;
735                            let timestamp = get_atomic_clock_realtime().get_time_ms();
736                            let new_auth_state = AuthState::from_auth_result(&result, timestamp);
737                            *auth_state.write().await = Some(new_auth_state);
738
739                            refresh_cancel.cancel();
740                            refresh_cancel = CancellationToken::new();
741                            retry_cancel.cancel();
742                            retry_cancel = CancellationToken::new();
743
744                            lifecycle_futures.push(
745                                refresh_token_after_delay(
746                                    result.expires_in,
747                                    result.refresh_token.clone(),
748                                    cmd_tx.clone(),
749                                    refresh_cancel.clone(),
750                                )
751                                .boxed(),
752                            );
753
754                            if pending_reauth {
755                                pending_reauth = false;
756                                reauth_attempts = 0;
757                                log::info!(
758                                    "Re-authentication successful (scope: {}), resubscribing to channels",
759                                    result.scope
760                                );
761
762                                let channels = subscriptions_state.all_topics();
763
764                                if !channels.is_empty() {
765                                    let _ = cmd_tx.send(HandlerCommand::Subscribe { channels });
766                                }
767                            } else {
768                                log::debug!(
769                                    "Auth state stored: scope={}, expires_in={}s",
770                                    result.scope,
771                                    result.expires_in
772                                );
773                            }
774                        }
775                        NautilusWsMessage::AuthenticationFailed(reason) => {
776                            if pending_reauth && reauth_attempts < MAX_REAUTH_ATTEMPTS {
777                                let delay_secs = 1u64 << reauth_attempts; // 2s, 4s
778                                log::warn!(
779                                    "Re-authentication attempt {reauth_attempts}/{MAX_REAUTH_ATTEMPTS} \
780                                    failed: {reason} - retrying in {delay_secs}s",
781                                );
782                                reauth_attempts += 1;
783
784                                // Spawn delayed retry so the handler loop keeps
785                                // processing messages during the backoff
786                                if let Some(cred) = &credential {
787                                    let cred = cred.clone();
788                                    let auth_state = auth_state.clone();
789                                    let auth_tracker = auth_tracker.clone();
790                                    let cmd_tx = cmd_tx.clone();
791                                    let cancel = retry_cancel.clone();
792
793                                    lifecycle_futures.push(
794                                        async move {
795                                            tokio::select! {
796                                                () = tokio::time::sleep(Duration::from_secs(delay_secs)) => {}
797                                                () = cancel.cancelled() => return,
798                                            }
799                                            let _rx = auth_tracker.begin();
800                                            let previous_scope = auth_state
801                                                .read()
802                                                .await
803                                                .as_ref()
804                                                .map(|s| s.scope.clone());
805                                            send_auth_request(&cred, previous_scope, &cmd_tx);
806                                        }
807                                        .boxed(),
808                                    );
809                                }
810                            } else if pending_reauth {
811                                pending_reauth = false;
812                                reauth_attempts = 0;
813                                log::error!(
814                                    "Re-authentication failed after {MAX_REAUTH_ATTEMPTS} \
815                                    attempts: {reason} \
816                                    - resubscribing to public channels only"
817                                );
818
819                                let all = subscriptions_state.all_topics();
820                                let mut public_channels = Vec::new();
821
822                                for ch in &all {
823                                    if DeribitWsChannel::requires_auth(ch) {
824                                        // Release private channels so future subscribe
825                                        // calls aren't skipped as already referenced
826                                        subscriptions_state.mark_unsubscribe(ch);
827                                        subscriptions_state.confirm_unsubscribe(ch);
828                                        subscriptions_state.remove_reference(ch);
829                                    } else {
830                                        public_channels.push(ch.clone());
831                                    }
832                                }
833
834                                if !public_channels.is_empty() {
835                                    let _ = cmd_tx.send(HandlerCommand::Subscribe {
836                                        channels: public_channels,
837                                    });
838                                }
839                            } else {
840                                log::error!("Authentication failed: {reason}");
841                            }
842                        }
843                        _ => {}
844                    },
845                    None => {
846                        log::debug!("Handler returned None, stopping task");
847                        break;
848                    }
849                }
850            }
851        };
852
853        if let Err(e) = handler_spawner.spawn(handler_task) {
854            if let Some(control) = &self.socket_control {
855                control.deregister();
856            }
857            self.out_rx = None;
858            anyhow::bail!("failed to register WebSocket handler task: {e}");
859        }
860        log::debug!("Connected to WebSocket");
861
862        Ok(())
863    }
864
865    /// Closes the WebSocket connection.
866    ///
867    /// # Errors
868    ///
869    /// Returns an error if the close operation fails.
870    pub async fn close(&self) -> DeribitWsResult<()> {
871        self.begin_shutdown();
872        let connect_lock = Arc::clone(&self.connect_lock);
873        let _connect_guard = connect_lock.lock().await;
874        self.close_locked().await
875    }
876
877    async fn close_locked(&self) -> DeribitWsResult<()> {
878        log::debug!("Closing WebSocket connection");
879        self.begin_shutdown();
880
881        let _ = self.command_sender().send(HandlerCommand::Disconnect);
882
883        self.finish_handler().await?;
884
885        self.auth_tracker.invalidate();
886
887        if let Some(control) = &self.socket_control {
888            control.deregister();
889        }
890        Ok(())
891    }
892
893    async fn finish_handler(&self) -> DeribitWsResult<()> {
894        self.handler_tasks
895            .finish_shutdown(Duration::from_secs(2), Duration::from_secs(2))
896            .await
897            .map_err(|e| {
898                DeribitWsError::ClientError(format!("WebSocket handler shutdown failed: {e}"))
899            })
900    }
901
902    /// Returns a stream of WebSocket messages.
903    ///
904    /// # Errors
905    ///
906    /// Returns an error if called before `connect()` or if called more than once.
907    pub fn stream(&mut self) -> DeribitWsResult<impl Stream<Item = NautilusWsMessage> + 'static> {
908        let rx = self.out_rx.take().ok_or_else(|| {
909            DeribitWsError::ClientError(
910                "Stream receiver already taken or not connected".to_string(),
911            )
912        })?;
913        let mut rx = Arc::try_unwrap(rx).map_err(|_| {
914            DeribitWsError::ClientError(
915                "Cannot take stream ownership - other references exist".to_string(),
916            )
917        })?;
918
919        Ok(async_stream::stream! {
920            while let Some(msg) = rx.recv().await {
921                yield msg;
922            }
923        })
924    }
925
926    /// Returns whether the client has credentials configured.
927    #[must_use]
928    pub fn has_credentials(&self) -> bool {
929        self.credential.is_some()
930    }
931
932    /// Returns whether the client is authenticated.
933    #[must_use]
934    pub fn is_authenticated(&self) -> bool {
935        self.auth_tracker.is_authenticated()
936    }
937
938    /// Authenticates the WebSocket session with Deribit.
939    ///
940    /// Uses the `client_signature` grant type with HMAC-SHA256 signature.
941    /// This must be called before subscribing to raw data streams.
942    ///
943    /// # Arguments
944    ///
945    /// * `session_name` - Optional session name for session-scoped authentication.
946    ///   When provided, uses `session:<name>` scope which allows skipping `access_token`
947    ///   in subsequent private requests. When `None`, uses default `connection` scope.
948    ///   Recommended to use session scope for order execution compatibility.
949    ///
950    /// # Errors
951    ///
952    /// Returns an error if:
953    /// - No credentials are configured.
954    /// - The authentication request fails.
955    /// - The authentication times out.
956    pub async fn authenticate(&self, session_name: Option<&str>) -> DeribitWsResult<()> {
957        let credential = self.credential.as_ref().ok_or_else(|| {
958            DeribitWsError::Authentication("API credentials not configured".to_string())
959        })?;
960
961        // Determine scope
962        let scope = session_name.map(|name| format!("session:{name}"));
963
964        log::debug!("Authenticating WebSocket...");
965
966        let rx = self.auth_tracker.begin();
967
968        // Send authentication request
969        let cmd_tx = self.command_sender().clone();
970        send_auth_request(credential, scope, &cmd_tx);
971
972        // Wait for authentication result with timeout
973        match self
974            .auth_tracker
975            .wait_for_result::<DeribitWsError>(Duration::from_secs(self.auth_timeout_secs), rx)
976            .await
977        {
978            Ok(()) => {
979                log::debug!("WebSocket authenticated successfully");
980                Ok(())
981            }
982            Err(e) => {
983                log::error!("WebSocket authentication failed: error={e}");
984                Err(e)
985            }
986        }
987    }
988
989    /// Authenticates with session scope using the provided session name.
990    ///
991    /// Use `DERIBIT_DATA_SESSION_NAME` for data clients and
992    /// `DERIBIT_EXECUTION_SESSION_NAME` for execution clients.
993    ///
994    /// # Errors
995    ///
996    /// Returns an error if authentication fails.
997    pub async fn authenticate_session(&self, session_name: &str) -> DeribitWsResult<()> {
998        self.authenticate(Some(session_name)).await
999    }
1000
1001    /// Returns the current authentication state containing tokens.
1002    ///
1003    /// Returns `None` if not authenticated or tokens haven't been stored yet.
1004    pub async fn auth_state(&self) -> Option<AuthState> {
1005        self.auth_state.read().await.clone()
1006    }
1007
1008    /// Returns the current access token if available.
1009    pub async fn access_token(&self) -> Option<SecretString> {
1010        self.auth_state
1011            .read()
1012            .await
1013            .as_ref()
1014            .map(|s| s.access_token.clone())
1015    }
1016
1017    /// Sets the account ID for order/fill reports.
1018    pub fn set_account_id(&mut self, account_id: AccountId) {
1019        self.account_id = Some(account_id);
1020    }
1021
1022    /// Sets whether bar timestamps should use the close time.
1023    ///
1024    /// When `true` (default), bar `ts_event` is set to the bar's close time.
1025    pub fn set_bars_timestamp_on_close(&mut self, value: bool) {
1026        self.bars_timestamp_on_close = value;
1027    }
1028
1029    async fn send_subscribe(&self, channels: Vec<String>) -> DeribitWsResult<()> {
1030        let mut channels_to_subscribe = Vec::new();
1031
1032        for channel in channels {
1033            if self.subscriptions_state.add_reference(&channel) {
1034                self.subscriptions_state.mark_subscribe(&channel);
1035                channels_to_subscribe.push(channel);
1036            } else {
1037                log::debug!("Already subscribed to {channel}, skipping duplicate subscription");
1038            }
1039        }
1040
1041        if channels_to_subscribe.is_empty() {
1042            return Ok(());
1043        }
1044
1045        if let Err(e) = self.command_sender().send(HandlerCommand::Subscribe {
1046            channels: channels_to_subscribe.clone(),
1047        }) {
1048            // Roll back: remove reference and clear pending_subscribe
1049            for channel in &channels_to_subscribe {
1050                self.subscriptions_state.remove_reference(channel);
1051                self.subscriptions_state.mark_unsubscribe(channel);
1052                self.subscriptions_state.confirm_unsubscribe(channel);
1053            }
1054            return Err(DeribitWsError::Send(e.to_string()));
1055        }
1056
1057        log::debug!(
1058            "Sent subscribe for {} channels",
1059            channels_to_subscribe.len()
1060        );
1061        Ok(())
1062    }
1063
1064    async fn send_unsubscribe(&self, channels: Vec<String>) -> DeribitWsResult<()> {
1065        let mut channels_to_unsubscribe = Vec::new();
1066
1067        for channel in channels {
1068            if self.subscriptions_state.remove_reference(&channel) {
1069                self.subscriptions_state.mark_unsubscribe(&channel);
1070                channels_to_unsubscribe.push(channel);
1071            } else {
1072                log::debug!("Still has references to {channel}, skipping unsubscription");
1073            }
1074        }
1075
1076        if channels_to_unsubscribe.is_empty() {
1077            return Ok(());
1078        }
1079
1080        if let Err(e) = self.command_sender().send(HandlerCommand::Unsubscribe {
1081            channels: channels_to_unsubscribe.clone(),
1082        }) {
1083            // Send only fails when the handler task is dead, meaning the
1084            // connection is broken. Restore refcount and mark confirmed so
1085            // the topic is not wedged in pending_unsubscribe. This may
1086            // promote a pending_subscribe topic to confirmed, but that is
1087            // harmless: connect() calls clear() on the next connection
1088            // attempt, resetting all subscription state.
1089            for channel in &channels_to_unsubscribe {
1090                self.subscriptions_state.confirm_unsubscribe(channel);
1091                self.subscriptions_state.add_reference(channel);
1092                self.subscriptions_state.mark_subscribe(channel);
1093                self.subscriptions_state.confirm_subscribe(channel);
1094            }
1095            return Err(DeribitWsError::Send(e.to_string()));
1096        }
1097
1098        log::debug!(
1099            "Sent unsubscribe for {} channels",
1100            channels_to_unsubscribe.len()
1101        );
1102        Ok(())
1103    }
1104
1105    /// Subscribes to trade updates for an instrument.
1106    ///
1107    /// # Arguments
1108    ///
1109    /// * `instrument_id` - The instrument to subscribe to
1110    /// * `interval` - Update interval. Defaults to `Ms100` (100ms). `Raw` requires authentication.
1111    ///
1112    /// # Errors
1113    ///
1114    /// Returns an error if subscription fails or raw is requested without authentication.
1115    pub async fn subscribe_trades(
1116        &self,
1117        instrument_id: InstrumentId,
1118        interval: Option<DeribitUpdateInterval>,
1119    ) -> DeribitWsResult<()> {
1120        let interval = interval.unwrap_or_default();
1121        self.check_auth_requirement(interval)?;
1122        let channel =
1123            DeribitWsChannel::Trades.format_channel(instrument_id.symbol.as_str(), Some(interval));
1124        self.send_subscribe(vec![channel]).await
1125    }
1126
1127    /// Unsubscribes from trade updates for an instrument.
1128    ///
1129    /// # Errors
1130    ///
1131    /// Returns an error if unsubscription fails.
1132    pub async fn unsubscribe_trades(
1133        &self,
1134        instrument_id: InstrumentId,
1135        interval: Option<DeribitUpdateInterval>,
1136    ) -> DeribitWsResult<()> {
1137        let interval = interval.unwrap_or_default();
1138        let channel =
1139            DeribitWsChannel::Trades.format_channel(instrument_id.symbol.as_str(), Some(interval));
1140        self.send_unsubscribe(vec![channel]).await
1141    }
1142
1143    /// Subscribes to order book updates for an instrument.
1144    ///
1145    /// # Arguments
1146    ///
1147    /// * `instrument_id` - The instrument to subscribe to
1148    /// * `interval` - Update interval. Defaults to `Ms100` (100ms). `Raw` requires authentication.
1149    ///
1150    /// # Errors
1151    ///
1152    /// Returns an error if subscription fails or raw is requested without authentication.
1153    pub async fn subscribe_book(
1154        &self,
1155        instrument_id: InstrumentId,
1156        interval: Option<DeribitUpdateInterval>,
1157    ) -> DeribitWsResult<()> {
1158        let interval = interval.unwrap_or_default();
1159        self.check_auth_requirement(interval)?;
1160        let channel =
1161            DeribitWsChannel::Book.format_channel(instrument_id.symbol.as_str(), Some(interval));
1162        self.send_subscribe(vec![channel]).await
1163    }
1164
1165    /// Unsubscribes from order book updates for an instrument.
1166    ///
1167    /// # Errors
1168    ///
1169    /// Returns an error if unsubscription fails.
1170    pub async fn unsubscribe_book(
1171        &self,
1172        instrument_id: InstrumentId,
1173        interval: Option<DeribitUpdateInterval>,
1174    ) -> DeribitWsResult<()> {
1175        let interval = interval.unwrap_or_default();
1176        let channel =
1177            DeribitWsChannel::Book.format_channel(instrument_id.symbol.as_str(), Some(interval));
1178        self.send_unsubscribe(vec![channel]).await
1179    }
1180
1181    /// Subscribes to grouped (depth-limited) order book updates for an instrument.
1182    ///
1183    /// Uses the Deribit grouped book channel format: `book.{instrument}.{group}.{depth}.{interval}`
1184    ///
1185    /// Depth is normalized to Deribit supported values: 1, 10, or 20.
1186    ///
1187    /// # Errors
1188    ///
1189    /// Returns an error if subscription fails or raw is requested without authentication.
1190    pub async fn subscribe_book_grouped(
1191        &self,
1192        instrument_id: InstrumentId,
1193        group: &str,
1194        depth: u32,
1195        interval: Option<DeribitUpdateInterval>,
1196    ) -> DeribitWsResult<()> {
1197        let channel = grouped_book_channel(instrument_id, group, depth, interval);
1198        log::debug!("Subscribing to grouped book channel: {channel}");
1199        self.send_subscribe(vec![channel]).await
1200    }
1201
1202    pub(crate) async fn subscribe_book_depth(
1203        &self,
1204        instrument_id: InstrumentId,
1205        group: &str,
1206        interval: Option<DeribitUpdateInterval>,
1207    ) -> DeribitWsResult<()> {
1208        let channel =
1209            grouped_book_channel(instrument_id, group, DERIBIT_BOOK_DEFAULT_DEPTH, interval);
1210        let depth_subs = self.book_depth_subs.lock().await;
1211        depth_subs.add_reference(&channel);
1212        if let Err(e) = self.send_subscribe(vec![channel.clone()]).await {
1213            depth_subs.remove_reference(&channel);
1214            return Err(e);
1215        }
1216
1217        Ok(())
1218    }
1219
1220    /// Unsubscribes from grouped (depth-limited) order book updates for an instrument.
1221    ///
1222    /// Depth is normalized to Deribit supported values: 1, 10, or 20.
1223    ///
1224    /// # Errors
1225    ///
1226    /// Returns an error if unsubscription fails.
1227    pub async fn unsubscribe_book_grouped(
1228        &self,
1229        instrument_id: InstrumentId,
1230        group: &str,
1231        depth: u32,
1232        interval: Option<DeribitUpdateInterval>,
1233    ) -> DeribitWsResult<()> {
1234        let channel = grouped_book_channel(instrument_id, group, depth, interval);
1235        self.send_unsubscribe(vec![channel]).await
1236    }
1237
1238    pub(crate) async fn unsubscribe_book_depth(
1239        &self,
1240        instrument_id: InstrumentId,
1241        group: &str,
1242        interval: Option<DeribitUpdateInterval>,
1243    ) -> DeribitWsResult<()> {
1244        let channel =
1245            grouped_book_channel(instrument_id, group, DERIBIT_BOOK_DEFAULT_DEPTH, interval);
1246        let depth_subs = self.book_depth_subs.lock().await;
1247        if depth_subs.get_reference_count(&channel) == 0 {
1248            return Ok(());
1249        }
1250
1251        self.send_unsubscribe(vec![channel.clone()]).await?;
1252        depth_subs.remove_reference(&channel);
1253        Ok(())
1254    }
1255
1256    /// Subscribes to ticker updates for an instrument.
1257    ///
1258    /// # Arguments
1259    ///
1260    /// * `instrument_id` - The instrument to subscribe to
1261    /// * `interval` - Update interval. Defaults to `Ms100` (100ms). `Raw` requires authentication.
1262    ///
1263    /// # Errors
1264    ///
1265    /// Returns an error if subscription fails or raw is requested without authentication.
1266    pub async fn subscribe_ticker(
1267        &self,
1268        instrument_id: InstrumentId,
1269        interval: Option<DeribitUpdateInterval>,
1270    ) -> DeribitWsResult<()> {
1271        let interval = interval.unwrap_or_default();
1272        self.check_auth_requirement(interval)?;
1273        let channel =
1274            DeribitWsChannel::Ticker.format_channel(instrument_id.symbol.as_str(), Some(interval));
1275        self.send_subscribe(vec![channel]).await
1276    }
1277
1278    /// Unsubscribes from ticker updates for an instrument.
1279    ///
1280    /// # Errors
1281    ///
1282    /// Returns an error if unsubscription fails.
1283    pub async fn unsubscribe_ticker(
1284        &self,
1285        instrument_id: InstrumentId,
1286        interval: Option<DeribitUpdateInterval>,
1287    ) -> DeribitWsResult<()> {
1288        let interval = interval.unwrap_or_default();
1289        let channel =
1290            DeribitWsChannel::Ticker.format_channel(instrument_id.symbol.as_str(), Some(interval));
1291        self.send_unsubscribe(vec![channel]).await
1292    }
1293
1294    /// Subscribes to quote (best bid/ask) updates for an instrument.
1295    ///
1296    /// Note: Quote channel does not support interval parameter.
1297    ///
1298    /// # Errors
1299    ///
1300    /// Returns an error if subscription fails.
1301    pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> DeribitWsResult<()> {
1302        let channel = DeribitWsChannel::Quote.format_channel(instrument_id.symbol.as_str(), None);
1303        self.send_subscribe(vec![channel]).await
1304    }
1305
1306    /// Unsubscribes from quote updates for an instrument.
1307    ///
1308    /// # Errors
1309    ///
1310    /// Returns an error if unsubscription fails.
1311    pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> DeribitWsResult<()> {
1312        let channel = DeribitWsChannel::Quote.format_channel(instrument_id.symbol.as_str(), None);
1313        self.send_unsubscribe(vec![channel]).await
1314    }
1315
1316    /// Subscribes to instrument status changes for lifecycle notifications.
1317    ///
1318    /// Channel format: `instrument.state.{kind}.{currency}`
1319    ///
1320    /// # Errors
1321    ///
1322    /// Returns an error if subscription fails.
1323    pub async fn subscribe_instrument_status(
1324        &self,
1325        kind: &str,
1326        currency: &str,
1327    ) -> DeribitWsResult<()> {
1328        let channel = DeribitWsChannel::format_instrument_state_channel(kind, currency);
1329        self.send_subscribe(vec![channel]).await
1330    }
1331
1332    /// Unsubscribes from instrument status changes.
1333    ///
1334    /// # Errors
1335    ///
1336    /// Returns an error if unsubscription fails.
1337    pub async fn unsubscribe_instrument_status(
1338        &self,
1339        kind: &str,
1340        currency: &str,
1341    ) -> DeribitWsResult<()> {
1342        let channel = DeribitWsChannel::format_instrument_state_channel(kind, currency);
1343        self.send_unsubscribe(vec![channel]).await
1344    }
1345
1346    /// Subscribes to volatility index updates for the given index name.
1347    ///
1348    /// Channel format: `deribit_volatility_index.{index_name}`
1349    ///
1350    /// # Errors
1351    ///
1352    /// Returns an error if subscription fails.
1353    pub async fn subscribe_volatility_index(&self, index_name: &str) -> DeribitWsResult<()> {
1354        let channel = DeribitWsChannel::VolatilityIndex.format_channel(index_name, None);
1355        self.send_subscribe(vec![channel]).await
1356    }
1357
1358    /// Unsubscribes from volatility index updates for the given index name.
1359    ///
1360    /// # Errors
1361    ///
1362    /// Returns an error if unsubscription fails.
1363    pub async fn unsubscribe_volatility_index(&self, index_name: &str) -> DeribitWsResult<()> {
1364        let channel = DeribitWsChannel::VolatilityIndex.format_channel(index_name, None);
1365        self.send_unsubscribe(vec![channel]).await
1366    }
1367
1368    /// Subscribes to perpetual interest rates updates.
1369    ///
1370    /// Channel format: `perpetual.{instrument_name}.{interval}`
1371    ///
1372    /// # Errors
1373    ///
1374    /// Returns an error if subscription fails.
1375    pub async fn subscribe_perpetual_interests_rates_updates(
1376        &self,
1377        instrument_id: InstrumentId,
1378        interval: Option<DeribitUpdateInterval>,
1379    ) -> DeribitWsResult<()> {
1380        let interval = interval.unwrap_or(DeribitUpdateInterval::Ms100);
1381        let channel = DeribitWsChannel::Perpetual
1382            .format_channel(instrument_id.symbol.as_str(), Some(interval));
1383
1384        self.send_subscribe(vec![channel]).await
1385    }
1386
1387    /// Unsubscribes from perpetual interest rates updates.
1388    ///
1389    /// # Errors
1390    ///
1391    /// Returns an error if subscription fails.
1392    pub async fn unsubscribe_perpetual_interest_rates_updates(
1393        &self,
1394        instrument_id: InstrumentId,
1395        interval: Option<DeribitUpdateInterval>,
1396    ) -> DeribitWsResult<()> {
1397        let interval = interval.unwrap_or(DeribitUpdateInterval::Ms100);
1398        let channel = DeribitWsChannel::Perpetual
1399            .format_channel(instrument_id.symbol.as_str(), Some(interval));
1400
1401        self.send_unsubscribe(vec![channel]).await
1402    }
1403
1404    /// Subscribes to chart/OHLC bar updates for an instrument.
1405    ///
1406    /// # Arguments
1407    ///
1408    /// * `instrument_id` - The instrument to subscribe to
1409    /// * `resolution` - Bar resolution: "1", "3", "5", "10", "15", "30", "60", "120", "180",
1410    ///   "360", "720", "1D" (minutes or 1D for daily)
1411    ///
1412    /// # Errors
1413    ///
1414    /// Returns an error if subscription fails.
1415    pub async fn subscribe_chart(
1416        &self,
1417        instrument_id: InstrumentId,
1418        resolution: &str,
1419    ) -> DeribitWsResult<()> {
1420        // Chart channel format: chart.trades.{instrument}.{resolution}
1421        let channel = format!("chart.trades.{}.{}", instrument_id.symbol, resolution);
1422        self.send_subscribe(vec![channel]).await
1423    }
1424
1425    /// Unsubscribes from chart/OHLC bar updates.
1426    ///
1427    /// # Errors
1428    ///
1429    /// Returns an error if unsubscription fails.
1430    pub async fn unsubscribe_chart(
1431        &self,
1432        instrument_id: InstrumentId,
1433        resolution: &str,
1434    ) -> DeribitWsResult<()> {
1435        let channel = format!("chart.trades.{}.{}", instrument_id.symbol, resolution);
1436        self.send_unsubscribe(vec![channel]).await
1437    }
1438
1439    /// Subscribes to bar updates for an instrument using a BarType specification.
1440    ///
1441    /// Converts the BarType to the nearest supported Deribit resolution and subscribes
1442    /// to the chart channel.
1443    ///
1444    /// # Errors
1445    ///
1446    /// Returns an error if the subscription request fails.
1447    pub async fn subscribe_bars(&self, bar_type: BarType) -> DeribitWsResult<()> {
1448        let resolution = bar_spec_to_resolution(&bar_type);
1449        self.subscribe_chart(bar_type.instrument_id(), &resolution)
1450            .await
1451    }
1452
1453    /// Unsubscribes from bar updates for an instrument using a BarType specification.
1454    ///
1455    /// # Errors
1456    ///
1457    /// Returns an error if the unsubscription request fails.
1458    pub async fn unsubscribe_bars(&self, bar_type: BarType) -> DeribitWsResult<()> {
1459        let resolution = bar_spec_to_resolution(&bar_type);
1460        self.unsubscribe_chart(bar_type.instrument_id(), &resolution)
1461            .await
1462    }
1463
1464    /// Checks if authentication is required for the given interval.
1465    ///
1466    /// # Errors
1467    ///
1468    /// Returns an error if raw interval is requested but client is not authenticated.
1469    fn check_auth_requirement(&self, interval: DeribitUpdateInterval) -> DeribitWsResult<()> {
1470        if interval.requires_auth() && !self.is_authenticated() {
1471            return Err(DeribitWsError::Authentication(
1472                "Raw streams require authentication. Call authenticate() first.".to_string(),
1473            ));
1474        }
1475        Ok(())
1476    }
1477
1478    /// Subscribes to user order updates for all instruments.
1479    ///
1480    /// Requires authentication. Subscribes to `user.orders.any.any.raw` channel.
1481    ///
1482    /// # Errors
1483    ///
1484    /// Returns an error if client is not authenticated or subscription fails.
1485    pub async fn subscribe_user_orders(&self) -> DeribitWsResult<()> {
1486        if !self.is_authenticated() {
1487            return Err(DeribitWsError::Authentication(
1488                "User orders subscription requires authentication".to_string(),
1489            ));
1490        }
1491        self.send_subscribe(vec!["user.orders.any.any.raw".to_string()])
1492            .await
1493    }
1494
1495    /// Unsubscribes from user order updates for all instruments.
1496    ///
1497    /// # Errors
1498    ///
1499    /// Returns an error if unsubscription fails.
1500    pub async fn unsubscribe_user_orders(&self) -> DeribitWsResult<()> {
1501        self.send_unsubscribe(vec!["user.orders.any.any.raw".to_string()])
1502            .await
1503    }
1504
1505    /// Subscribes to user trade/fill updates for all instruments.
1506    ///
1507    /// Requires authentication. Subscribes to `user.trades.any.any.raw` channel.
1508    ///
1509    /// # Errors
1510    ///
1511    /// Returns an error if client is not authenticated or subscription fails.
1512    pub async fn subscribe_user_trades(&self) -> DeribitWsResult<()> {
1513        if !self.is_authenticated() {
1514            return Err(DeribitWsError::Authentication(
1515                "User trades subscription requires authentication".to_string(),
1516            ));
1517        }
1518        self.send_subscribe(vec!["user.trades.any.any.raw".to_string()])
1519            .await
1520    }
1521
1522    /// Unsubscribes from user trade/fill updates for all instruments.
1523    ///
1524    /// # Errors
1525    ///
1526    /// Returns an error if unsubscription fails.
1527    pub async fn unsubscribe_user_trades(&self) -> DeribitWsResult<()> {
1528        self.send_unsubscribe(vec!["user.trades.any.any.raw".to_string()])
1529            .await
1530    }
1531
1532    /// Subscribes to user portfolio updates for all currencies.
1533    ///
1534    /// Requires authentication. Subscribes to `user.portfolio.any` channel which
1535    /// provides real-time account balance and margin updates for all currencies
1536    /// (BTC, ETH, USDC, USDT, etc.).
1537    ///
1538    /// # Errors
1539    ///
1540    /// Returns an error if client is not authenticated or subscription fails.
1541    pub async fn subscribe_user_portfolio(&self) -> DeribitWsResult<()> {
1542        if !self.is_authenticated() {
1543            return Err(DeribitWsError::Authentication(
1544                "User portfolio subscription requires authentication".to_string(),
1545            ));
1546        }
1547        self.send_subscribe(vec!["user.portfolio.any".to_string()])
1548            .await
1549    }
1550
1551    /// Unsubscribes from user portfolio updates for all currencies.
1552    ///
1553    /// # Errors
1554    ///
1555    /// Returns an error if unsubscription fails.
1556    pub async fn unsubscribe_user_portfolio(&self) -> DeribitWsResult<()> {
1557        self.send_unsubscribe(vec!["user.portfolio.any".to_string()])
1558            .await
1559    }
1560
1561    /// Subscribes to multiple channels at once.
1562    ///
1563    /// # Errors
1564    ///
1565    /// Returns an error if subscription fails.
1566    pub async fn subscribe(&self, channels: Vec<String>) -> DeribitWsResult<()> {
1567        self.send_subscribe(channels).await
1568    }
1569
1570    /// Unsubscribes from multiple channels at once.
1571    ///
1572    /// # Errors
1573    ///
1574    /// Returns an error if unsubscription fails.
1575    pub async fn unsubscribe(&self, channels: Vec<String>) -> DeribitWsResult<()> {
1576        self.send_unsubscribe(channels).await
1577    }
1578
1579    /// Submits an order to Deribit via WebSocket.
1580    ///
1581    /// Routes to `private/buy` or `private/sell` JSON-RPC method based on order side.
1582    /// Requires authentication (call `authenticate_session()` first).
1583    ///
1584    /// # Errors
1585    ///
1586    /// Returns an error if:
1587    /// - The client is not authenticated.
1588    /// - The command fails to send.
1589    pub async fn submit_order(
1590        &self,
1591        order_side: OrderSide,
1592        params: DeribitOrderParams,
1593        client_order_id: ClientOrderId,
1594        trader_id: TraderId,
1595        strategy_id: StrategyId,
1596        instrument_id: InstrumentId,
1597    ) -> DeribitWsResult<()> {
1598        if !self.is_authenticated() {
1599            return Err(DeribitWsError::Authentication(
1600                "Submit order requires authentication. Call authenticate_session() first."
1601                    .to_string(),
1602            ));
1603        }
1604
1605        log::debug!(
1606            "Sending {} order: instrument={}, amount={}, price={:?}, client_order_id={}",
1607            order_side,
1608            params.instrument_name,
1609            params.amount,
1610            params.price,
1611            client_order_id
1612        );
1613
1614        let cmd = match order_side {
1615            OrderSide::Buy => HandlerCommand::Buy {
1616                params,
1617                client_order_id,
1618                trader_id,
1619                strategy_id,
1620                instrument_id,
1621            },
1622            OrderSide::Sell => HandlerCommand::Sell {
1623                params,
1624                client_order_id,
1625                trader_id,
1626                strategy_id,
1627                instrument_id,
1628            },
1629        };
1630
1631        self.command_sender()
1632            .send(cmd)
1633            .map_err(|e| DeribitWsError::Send(e.to_string()))?;
1634
1635        Ok(())
1636    }
1637
1638    /// Modifies an existing order on Deribit via WebSocket.
1639    ///
1640    /// The order parameters are sent using the `private/edit` JSON-RPC method.
1641    /// Requires authentication (call `authenticate_session()` first).
1642    ///
1643    /// # Errors
1644    ///
1645    /// Returns an error if:
1646    /// - The client is not authenticated.
1647    /// - The command fails to send.
1648    pub async fn modify_order(
1649        &self,
1650        params: DeribitEditParams,
1651        client_order_id: ClientOrderId,
1652        trader_id: TraderId,
1653        strategy_id: StrategyId,
1654        instrument_id: InstrumentId,
1655    ) -> DeribitWsResult<()> {
1656        if !self.is_authenticated() {
1657            return Err(DeribitWsError::Authentication(
1658                "Modify order requires authentication. Call authenticate_session() first."
1659                    .to_string(),
1660            ));
1661        }
1662
1663        log::debug!(
1664            "Sending modify order: order_id={}, amount={}, price={:?}, client_order_id={client_order_id}",
1665            params.order_id,
1666            params.amount,
1667            params.price,
1668        );
1669
1670        self.command_sender()
1671            .send(HandlerCommand::Edit {
1672                params,
1673                client_order_id,
1674                trader_id,
1675                strategy_id,
1676                instrument_id,
1677            })
1678            .map_err(|e| DeribitWsError::Send(e.to_string()))?;
1679
1680        Ok(())
1681    }
1682
1683    /// Cancels an existing order on Deribit via WebSocket.
1684    ///
1685    /// The order is cancelled using the `private/cancel` JSON-RPC method.
1686    /// Requires authentication (call `authenticate_session()` first).
1687    ///
1688    /// # Errors
1689    ///
1690    /// Returns an error if:
1691    /// - The client is not authenticated.
1692    /// - The command fails to send.
1693    pub async fn cancel_order(
1694        &self,
1695        order_id: &str,
1696        client_order_id: ClientOrderId,
1697        trader_id: TraderId,
1698        strategy_id: StrategyId,
1699        instrument_id: InstrumentId,
1700    ) -> DeribitWsResult<()> {
1701        if !self.is_authenticated() {
1702            return Err(DeribitWsError::Authentication(
1703                "Cancel order requires authentication. Call authenticate_session() first."
1704                    .to_string(),
1705            ));
1706        }
1707
1708        let params = DeribitCancelParams {
1709            order_id: order_id.to_string(),
1710        };
1711
1712        log::debug!("Sending cancel order: order_id={order_id}, client_order_id={client_order_id}");
1713
1714        self.command_sender()
1715            .send(HandlerCommand::Cancel {
1716                params,
1717                client_order_id,
1718                trader_id,
1719                strategy_id,
1720                instrument_id,
1721            })
1722            .map_err(|e| DeribitWsError::Send(e.to_string()))?;
1723
1724        Ok(())
1725    }
1726
1727    /// Cancels all orders for a specific instrument on Deribit via WebSocket.
1728    ///
1729    /// Uses the `private/cancel_all_by_instrument` JSON-RPC method.
1730    /// Requires authentication (call `authenticate_session()` first).
1731    ///
1732    /// # Errors
1733    ///
1734    /// Returns an error if:
1735    /// - The client is not authenticated.
1736    /// - The command fails to send.
1737    pub async fn cancel_all_orders(
1738        &self,
1739        instrument_id: InstrumentId,
1740        order_type: Option<String>,
1741    ) -> DeribitWsResult<()> {
1742        if !self.is_authenticated() {
1743            return Err(DeribitWsError::Authentication(
1744                "Cancel all orders requires authentication. Call authenticate_session() first."
1745                    .to_string(),
1746            ));
1747        }
1748
1749        let instrument_name = instrument_id.symbol.to_string();
1750        let params = DeribitCancelAllByInstrumentParams {
1751            instrument_name: instrument_name.clone(),
1752            order_type,
1753        };
1754
1755        log::debug!("Sending cancel_all_orders: instrument={instrument_name}");
1756
1757        self.command_sender()
1758            .send(HandlerCommand::CancelAllByInstrument {
1759                params,
1760                instrument_id,
1761            })
1762            .map_err(|e| DeribitWsError::Send(e.to_string()))?;
1763
1764        Ok(())
1765    }
1766
1767    /// Queries the state of an order on Deribit via WebSocket.
1768    ///
1769    /// Uses the `private/get_order_state` JSON-RPC method.
1770    /// Requires authentication (call `authenticate_session()` first).
1771    ///
1772    /// # Errors
1773    ///
1774    /// Returns an error if:
1775    /// - The client is not authenticated.
1776    /// - The command fails to send.
1777    pub async fn query_order(
1778        &self,
1779        order_id: &str,
1780        client_order_id: ClientOrderId,
1781        trader_id: TraderId,
1782        strategy_id: StrategyId,
1783        instrument_id: InstrumentId,
1784    ) -> DeribitWsResult<()> {
1785        if !self.is_authenticated() {
1786            return Err(DeribitWsError::Authentication(
1787                "Query order state requires authentication. Call authenticate_session() first."
1788                    .to_string(),
1789            ));
1790        }
1791
1792        log::debug!("Sending query_order: order_id={order_id}, client_order_id={client_order_id}");
1793
1794        self.command_sender()
1795            .send(HandlerCommand::GetOrderState {
1796                order_id: order_id.to_string(),
1797                client_order_id,
1798                trader_id,
1799                strategy_id,
1800                instrument_id,
1801            })
1802            .map_err(|e| DeribitWsError::Send(e.to_string()))?;
1803
1804        Ok(())
1805    }
1806
1807    fn command_sender(&self) -> RwLockReadGuard<'_, CommandSender> {
1808        self.cmd_tx.read()
1809    }
1810}
1811
1812fn grouped_book_channel(
1813    instrument_id: InstrumentId,
1814    group: &str,
1815    depth: u32,
1816    interval: Option<DeribitUpdateInterval>,
1817) -> String {
1818    let interval = match interval {
1819        Some(DeribitUpdateInterval::Raw) | None => DeribitUpdateInterval::Ms100,
1820        Some(i) => i,
1821    };
1822
1823    let depth = if depth < 5 {
1824        1
1825    } else if depth < 15 {
1826        10
1827    } else {
1828        20
1829    };
1830
1831    format!(
1832        "book.{}.{group}.{depth}.{}",
1833        instrument_id.symbol,
1834        interval.as_str()
1835    )
1836}
1837
1838#[cfg(test)]
1839mod tests {
1840    use futures_util::StreamExt;
1841    use rstest::rstest;
1842
1843    use super::*;
1844
1845    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1846    async fn active_state_waits_for_sender_publication() {
1847        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1848        let addr = listener.local_addr().unwrap();
1849        let server = tokio::spawn(async move {
1850            let (socket, _) = listener.accept().await.unwrap();
1851            let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
1852
1853            while matches!(socket.next().await, Some(Ok(_))) {}
1854        });
1855
1856        let mut ws_client = DeribitWebSocketClient::new_unauthenticated(
1857            Some(format!("ws://{addr}")),
1858            30,
1859            DeribitEnvironment::Testnet,
1860        )
1861        .unwrap();
1862        let observer = ws_client.clone();
1863        let sender = Arc::clone(&ws_client.cmd_tx);
1864        let (held_tx, held_rx) = tokio::sync::oneshot::channel();
1865
1866        let publication = tokio::task::spawn_blocking(move || {
1867            let guard = sender.read();
1868            held_tx.send(()).unwrap();
1869            let deadline = std::time::Instant::now() + Duration::from_secs(5);
1870
1871            while sender.try_read().is_some() {
1872                assert!(
1873                    std::time::Instant::now() < deadline,
1874                    "connect must queue its writer"
1875                );
1876                std::thread::yield_now();
1877            }
1878
1879            let active = observer.is_active();
1880            drop(guard);
1881            active
1882        });
1883
1884        held_rx.await.unwrap();
1885        ws_client.connect().await.unwrap();
1886        let active_before_publication = publication.await.unwrap();
1887        let active_after_publication = ws_client.is_active();
1888        ws_client.close().await.unwrap();
1889        server.abort();
1890
1891        assert!(!active_before_publication);
1892        assert!(active_after_publication);
1893    }
1894
1895    struct DropSignal(Arc<AtomicBool>);
1896
1897    impl Drop for DropSignal {
1898        fn drop(&mut self) {
1899            self.0.store(true, Ordering::Release);
1900        }
1901    }
1902
1903    #[tokio::test]
1904    async fn test_last_client_owner_drop_aborts_handler_task() {
1905        let client = DeribitWebSocketClient::new_unauthenticated(
1906            Some("ws://127.0.0.1:0/ws/api/v2".to_string()),
1907            30,
1908            DeribitEnvironment::Testnet,
1909        )
1910        .unwrap();
1911        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1912        let dropped = Arc::new(AtomicBool::new(false));
1913        let drop_signal = DropSignal(Arc::clone(&dropped));
1914        client
1915            .handler_tasks
1916            .spawn(async move {
1917                let _drop_signal = drop_signal;
1918                started_tx.send(()).expect("started receiver");
1919                std::future::pending::<()>().await;
1920            })
1921            .expect("handler task should register");
1922        started_rx.await.expect("handler task started");
1923        let clone = client.clone();
1924
1925        drop(client);
1926        assert!(!dropped.load(Ordering::Acquire));
1927        drop(clone);
1928
1929        tokio::time::timeout(Duration::from_secs(1), async {
1930            while !dropped.load(Ordering::Acquire) {
1931                tokio::task::yield_now().await;
1932            }
1933        })
1934        .await
1935        .expect("handler task aborted");
1936    }
1937
1938    #[rstest]
1939    #[case::depth_last(true)]
1940    #[case::deltas_last(false)]
1941    #[tokio::test]
1942    async fn test_book_depth_shared_subscription_lifetime(#[case] depth_last: bool) {
1943        let client = DeribitWebSocketClient::new_public(DeribitEnvironment::Testnet, None).unwrap();
1944        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1945        *client.cmd_tx.write() = tx;
1946        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1947        let channel = "book.BTC-PERPETUAL.none.10.100ms";
1948        client
1949            .subscribe_book_depth(instrument_id, "none", None)
1950            .await
1951            .unwrap();
1952        client
1953            .subscribe_book_depth(instrument_id, "none", None)
1954            .await
1955            .unwrap();
1956        client
1957            .subscribe_book_grouped(instrument_id, "none", 10, None)
1958            .await
1959            .unwrap();
1960
1961        assert_eq!(
1962            client
1963                .book_depth_subs
1964                .lock()
1965                .await
1966                .get_reference_count(channel),
1967            2
1968        );
1969        assert_eq!(client.subscriptions_state.get_reference_count(channel), 3);
1970
1971        let HandlerCommand::Subscribe { channels } = rx.try_recv().unwrap() else {
1972            panic!("expected subscribe");
1973        };
1974
1975        assert_eq!(channels, [channel]);
1976        assert!(rx.try_recv().is_err());
1977
1978        client
1979            .unsubscribe_book_depth(instrument_id, "none", None)
1980            .await
1981            .unwrap();
1982        assert_eq!(
1983            client
1984                .book_depth_subs
1985                .lock()
1986                .await
1987                .get_reference_count(channel),
1988            1
1989        );
1990        assert_eq!(client.subscriptions_state.get_reference_count(channel), 2);
1991        assert!(rx.try_recv().is_err());
1992
1993        if depth_last {
1994            client
1995                .unsubscribe_book_grouped(instrument_id, "none", 10, None)
1996                .await
1997                .unwrap();
1998        } else {
1999            client
2000                .unsubscribe_book_depth(instrument_id, "none", None)
2001                .await
2002                .unwrap();
2003        }
2004
2005        assert_eq!(
2006            client
2007                .book_depth_subs
2008                .lock()
2009                .await
2010                .get_reference_count(channel),
2011            usize::from(depth_last)
2012        );
2013        assert_eq!(client.subscriptions_state.get_reference_count(channel), 1);
2014        assert!(rx.try_recv().is_err());
2015
2016        if depth_last {
2017            client
2018                .unsubscribe_book_depth(instrument_id, "none", None)
2019                .await
2020                .unwrap();
2021        } else {
2022            client
2023                .unsubscribe_book_grouped(instrument_id, "none", 10, None)
2024                .await
2025                .unwrap();
2026        }
2027
2028        let HandlerCommand::Unsubscribe { channels } = rx.try_recv().unwrap() else {
2029            panic!("expected unsubscribe");
2030        };
2031
2032        assert_eq!(channels, [channel]);
2033        assert_eq!(
2034            client
2035                .book_depth_subs
2036                .lock()
2037                .await
2038                .get_reference_count(channel),
2039            0
2040        );
2041        assert_eq!(client.subscriptions_state.get_reference_count(channel), 0);
2042        assert!(rx.try_recv().is_err());
2043    }
2044
2045    #[tokio::test]
2046    async fn test_book_depth_unsubscribe_without_reference_preserves_grouped_subscription() {
2047        let client = DeribitWebSocketClient::new_public(DeribitEnvironment::Testnet, None).unwrap();
2048        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2049        *client.cmd_tx.write() = tx;
2050        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
2051        let channel = "book.BTC-PERPETUAL.none.10.100ms";
2052        client
2053            .subscribe_book_grouped(instrument_id, "none", 10, None)
2054            .await
2055            .unwrap();
2056
2057        let HandlerCommand::Subscribe { channels } = rx.try_recv().unwrap() else {
2058            panic!("expected subscribe");
2059        };
2060
2061        assert_eq!(channels, [channel]);
2062
2063        client
2064            .unsubscribe_book_depth(instrument_id, "none", None)
2065            .await
2066            .unwrap();
2067
2068        assert_eq!(
2069            client
2070                .book_depth_subs
2071                .lock()
2072                .await
2073                .get_reference_count(channel),
2074            0
2075        );
2076        assert_eq!(client.subscriptions_state.get_reference_count(channel), 1);
2077        assert_eq!(client.subscriptions_state.all_topics(), [channel]);
2078        assert!(rx.try_recv().is_err());
2079    }
2080
2081    #[tokio::test]
2082    async fn test_book_depth_subscribe_send_failure_restores_intent() {
2083        let client = DeribitWebSocketClient::new_public(DeribitEnvironment::Testnet, None).unwrap();
2084        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
2085        let error = client
2086            .subscribe_book_depth(instrument_id, "none", None)
2087            .await
2088            .unwrap_err();
2089
2090        assert!(matches!(error, DeribitWsError::Send(_)));
2091        assert_eq!(
2092            client
2093                .book_depth_subs
2094                .lock()
2095                .await
2096                .get_reference_count("book.BTC-PERPETUAL.none.10.100ms"),
2097            0
2098        );
2099        assert_eq!(
2100            client
2101                .subscriptions_state
2102                .get_reference_count("book.BTC-PERPETUAL.none.10.100ms"),
2103            0
2104        );
2105    }
2106
2107    #[tokio::test]
2108    async fn test_book_depth_unsubscribe_send_failure_preserves_intent() {
2109        let client = DeribitWebSocketClient::new_public(DeribitEnvironment::Testnet, None).unwrap();
2110        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
2111        let channel = "book.BTC-PERPETUAL.none.10.100ms";
2112        client.book_depth_subs.lock().await.add_reference(channel);
2113        client.subscriptions_state.add_reference(channel);
2114        client.subscriptions_state.mark_subscribe(channel);
2115        client.subscriptions_state.confirm_subscribe(channel);
2116        let error = client
2117            .unsubscribe_book_depth(instrument_id, "none", None)
2118            .await
2119            .unwrap_err();
2120
2121        assert!(matches!(error, DeribitWsError::Send(_)));
2122        assert_eq!(
2123            client
2124                .book_depth_subs
2125                .lock()
2126                .await
2127                .get_reference_count(channel),
2128            1
2129        );
2130        assert_eq!(client.subscriptions_state.get_reference_count(channel), 1);
2131        assert_eq!(client.subscriptions_state.all_topics(), [channel]);
2132    }
2133
2134    #[rstest]
2135    #[tokio::test]
2136    async fn test_unsubscribe_send_failure_restores_subscription() {
2137        let client = DeribitWebSocketClient::new_unauthenticated(
2138            Some("ws://127.0.0.1:0/ws/api/v2".to_string()),
2139            30,
2140            DeribitEnvironment::Testnet,
2141        )
2142        .unwrap();
2143        let channel = "trades.BTC-PERPETUAL.raw";
2144        client.subscriptions_state.add_reference(channel);
2145        client.subscriptions_state.mark_subscribe(channel);
2146        client.subscriptions_state.confirm_subscribe(channel);
2147
2148        let error = client
2149            .send_unsubscribe(vec![channel.to_string()])
2150            .await
2151            .unwrap_err();
2152
2153        assert!(matches!(error, DeribitWsError::Send(_)));
2154        assert_eq!(client.subscriptions_state.get_reference_count(channel), 1);
2155        assert_eq!(client.subscriptions_state.len(), 1);
2156        assert_eq!(client.subscriptions_state.all_topics(), [channel]);
2157        assert!(
2158            client
2159                .subscriptions_state
2160                .pending_subscribe_topics()
2161                .is_empty()
2162        );
2163        assert!(
2164            client
2165                .subscriptions_state
2166                .pending_unsubscribe_topics()
2167                .is_empty()
2168        );
2169    }
2170}