Skip to main content

nautilus_binance/spot/websocket/trading/
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//! Binance Spot WebSocket API client for SBE trading.
17//!
18//! ## Connection Details
19//!
20//! - Endpoint: `ws-api.binance.com:443/ws-api/v3`
21//! - Authentication: Ed25519 signature per request
22//! - SBE responses: Enabled via `responseFormat=sbe` query parameter
23//! - Connection validity: 24 hours
24//! - Ping/pong: Every 20 seconds
25
26use std::{
27    fmt::Debug,
28    num::NonZeroU32,
29    sync::{
30        Arc, LazyLock,
31        atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
32    },
33    time::Duration,
34};
35
36use arc_swap::ArcSwap;
37use nautilus_core::string::secret::{REDACTED, SecretString};
38use nautilus_live::{SocketControl, task::TaskGroup};
39use nautilus_network::{
40    http::create_standard_nautilus_headers,
41    mode::ConnectionMode,
42    ratelimiter::quota::Quota,
43    websocket::{
44        AuthTracker, PingHandler, TransportBackend, WebSocketClient, WebSocketConfig,
45        channel_message_handler,
46    },
47};
48use parking_lot::Mutex;
49use tokio_util::sync::CancellationToken;
50use ustr::Ustr;
51
52use super::{
53    error::{BinanceWsApiError, BinanceWsApiResult},
54    handler::BinanceSpotWsTradingHandler,
55    messages::{BinanceSpotWsTradingCommand, BinanceSpotWsTradingMessage},
56};
57use crate::{
58    common::{
59        consts::{BINANCE_API_KEY_HEADER, BINANCE_SPOT_SBE_WS_API_URL},
60        credential::SigningCredential,
61    },
62    spot::http::query::{CancelOrderParams, CancelReplaceOrderParams, NewOrderParams},
63};
64
65/// Environment variable key for Binance API key.
66pub const BINANCE_API_KEY: &str = "BINANCE_API_KEY";
67
68/// Environment variable key for Binance API secret.
69pub const BINANCE_API_SECRET: &str = "BINANCE_API_SECRET";
70
71/// Pre-interned rate limit key for order operations (place/cancel/replace).
72///
73/// Binance WebSocket API: 1200 requests per minute per IP (20/sec).
74pub static BINANCE_WS_RATE_LIMIT_KEY_ORDER: LazyLock<[Ustr; 1]> =
75    LazyLock::new(|| [Ustr::from("order")]);
76
77/// Binance WebSocket API order rate limit: 1200 per minute (20/sec).
78///
79/// Based on Binance documentation for WebSocket API rate limits.
80// Constant values are provably valid
81#[expect(clippy::missing_panics_doc)]
82#[must_use]
83pub fn binance_ws_order_quota() -> Quota {
84    Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant")
85}
86
87/// Binance Spot WebSocket API client for SBE trading.
88///
89/// This client provides order management via WebSocket with SBE-encoded responses,
90/// complementing the HTTP client with lower-latency order submission.
91#[derive(Clone)]
92pub struct BinanceSpotWsTradingClient {
93    url: SecretString,
94    credential: Arc<SigningCredential>,
95    heartbeat: Option<u64>,
96    signal: Arc<AtomicBool>,
97    connection_mode: Arc<ArcSwap<AtomicU8>>,
98    user_data_tracker: AuthTracker,
99    cmd_tx:
100        Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<BinanceSpotWsTradingCommand>>>,
101    out_rx: Arc<Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<BinanceSpotWsTradingMessage>>>>,
102    handler_tasks: Arc<TaskGroup>,
103    connect_lock: Arc<tokio::sync::Mutex<()>>,
104    request_id_counter: Arc<AtomicU64>,
105    cancellation_token: Arc<Mutex<CancellationToken>>,
106    transport_backend: TransportBackend,
107    proxy_url: Option<SecretString>,
108    recv_window_ms: Option<u64>,
109    socket_control: Option<SocketControl>,
110}
111
112impl Debug for BinanceSpotWsTradingClient {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct(stringify!(BinanceSpotWsTradingClient))
115            .field("url", &REDACTED)
116            .field("credential", &REDACTED)
117            .field("heartbeat", &self.heartbeat)
118            .finish_non_exhaustive()
119    }
120}
121
122impl BinanceSpotWsTradingClient {
123    /// Creates a new [`BinanceSpotWsTradingClient`] instance.
124    #[must_use]
125    pub fn new(
126        url: Option<String>,
127        api_key: String,
128        api_secret: String,
129        heartbeat: Option<u64>,
130        transport_backend: TransportBackend,
131    ) -> Self {
132        let url =
133            SecretString::from(url.unwrap_or_else(|| BINANCE_SPOT_SBE_WS_API_URL.to_string()));
134        let credential = Arc::new(SigningCredential::new(api_key, api_secret));
135
136        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
137
138        Self {
139            url,
140            credential,
141            heartbeat,
142            signal: Arc::new(AtomicBool::new(false)),
143            connection_mode: Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
144                ConnectionMode::Closed as u8,
145            )))),
146            user_data_tracker: AuthTracker::new(),
147            cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
148            out_rx: Arc::new(Mutex::new(None)),
149            handler_tasks: Arc::new(TaskGroup::new()),
150            connect_lock: Arc::new(tokio::sync::Mutex::new(())),
151            request_id_counter: Arc::new(AtomicU64::new(1)),
152            cancellation_token: Arc::new(Mutex::new(CancellationToken::new())),
153            transport_backend,
154            proxy_url: None,
155            recv_window_ms: None,
156            socket_control: None,
157        }
158    }
159
160    /// Configures the proxy used by the WebSocket connection.
161    #[must_use]
162    pub fn with_proxy(mut self, proxy_url: Option<String>) -> Self {
163        self.proxy_url = proxy_url.map(SecretString::from);
164        self
165    }
166
167    /// Configures socket state reporting and reconnect control.
168    #[must_use]
169    pub fn with_socket_control(mut self, control: SocketControl) -> Self {
170        self.socket_control = Some(control);
171        self
172    }
173
174    /// Configures the receive window added to signed WebSocket API requests.
175    #[must_use]
176    pub const fn with_recv_window(mut self, recv_window_ms: Option<u64>) -> Self {
177        self.recv_window_ms = recv_window_ms;
178        self
179    }
180
181    /// Creates a new client with credentials sourced from environment variables.
182    ///
183    /// Falls back to env vars if `api_key` or `api_secret` are `None`:
184    /// - `BINANCE_API_KEY` for the API key
185    /// - `BINANCE_API_SECRET` for the API secret
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if credentials are missing from environment.
190    pub fn with_env(
191        url: Option<String>,
192        api_key: Option<String>,
193        api_secret: Option<String>,
194        heartbeat: Option<u64>,
195        transport_backend: TransportBackend,
196    ) -> anyhow::Result<Self> {
197        let api_key = nautilus_core::env::get_or_env_var(api_key, BINANCE_API_KEY)?;
198        let api_secret = nautilus_core::env::get_or_env_var(api_secret, BINANCE_API_SECRET)?;
199        Ok(Self::new(
200            url,
201            api_key,
202            api_secret,
203            heartbeat,
204            transport_backend,
205        ))
206    }
207
208    /// Creates a new client with credentials loaded entirely from environment variables.
209    ///
210    /// Reads:
211    /// - `BINANCE_API_KEY` for the API key
212    /// - `BINANCE_API_SECRET` for the API secret
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if environment variables are missing.
217    pub fn from_env(url: Option<String>, heartbeat: Option<u64>) -> anyhow::Result<Self> {
218        Self::with_env(url, None, None, heartbeat, TransportBackend::default())
219    }
220
221    /// Returns whether the client is actively connected.
222    #[must_use]
223    pub fn is_active(&self) -> bool {
224        let mode_u8 = self.connection_mode.load().load(Ordering::Relaxed);
225        mode_u8 == ConnectionMode::Active as u8
226    }
227
228    /// Returns whether the private user data stream is active on the current connection.
229    #[must_use]
230    pub fn is_user_data_active(&self) -> bool {
231        self.is_active() && self.user_data_tracker.is_authenticated()
232    }
233
234    /// Marks the private user data stream active on the current connection.
235    pub fn mark_user_data_active(&self) {
236        self.user_data_tracker.succeed();
237    }
238
239    /// Marks the private user data stream inactive.
240    pub fn mark_user_data_inactive(&self) {
241        self.user_data_tracker.invalidate();
242    }
243
244    /// Returns whether the client is closed.
245    #[must_use]
246    pub fn is_closed(&self) -> bool {
247        let mode_u8 = self.connection_mode.load().load(Ordering::Relaxed);
248        mode_u8 == ConnectionMode::Closed as u8
249    }
250
251    /// Generates the next request ID.
252    pub fn next_request_id(&self) -> String {
253        let id = self.request_id_counter.fetch_add(1, Ordering::Relaxed);
254        format!("req-{id}")
255    }
256
257    /// Connects to the WebSocket API server.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if connection fails.
262    pub async fn connect(&mut self) -> BinanceWsApiResult<()> {
263        let connect_lock = Arc::clone(&self.connect_lock);
264        let _connect_guard = connect_lock.lock().await;
265
266        if !self.handler_tasks.is_open() || !self.handler_tasks.is_empty() {
267            self.disconnect_handler().await?;
268            self.handler_tasks.start_generation().map_err(|e| {
269                BinanceWsApiError::ClientError(format!(
270                    "failed to start WebSocket handler task generation: {e}"
271                ))
272            })?;
273        }
274        let handler_spawner = self.handler_tasks.spawner().map_err(|e| {
275            BinanceWsApiError::ClientError(format!(
276                "failed to acquire WebSocket handler task spawner: {e}"
277            ))
278        })?;
279        self.signal.store(false, Ordering::Relaxed);
280        self.user_data_tracker.invalidate();
281        *self.cancellation_token.lock() = CancellationToken::new();
282
283        let (raw_handler, raw_rx) = channel_message_handler();
284        let ping_handler: PingHandler = Arc::new(move |_| {});
285
286        let mut headers = create_standard_nautilus_headers();
287        headers.push((
288            BINANCE_API_KEY_HEADER.to_string(),
289            self.credential.api_key().to_string(),
290        ));
291
292        let config = WebSocketConfig {
293            url: self.url.expose_secret().to_owned(),
294            headers,
295            heartbeat_interval_secs: self.heartbeat,
296            heartbeat_payload: None,
297            connect_timeout_ms: Some(5_000),
298            reconnect_delay_initial_ms: Some(500),
299            reconnect_delay_max_ms: Some(5_000),
300            reconnect_backoff_factor: Some(2.0),
301            reconnect_jitter_ms: Some(250),
302            reconnect_max_attempts: None,
303            heartbeat_timeout_secs: None,
304            idle_timeout_ms: None,
305            writer_capacity: None,
306            backend: self.transport_backend,
307            proxy_url: self
308                .proxy_url
309                .as_ref()
310                .map(|value| value.expose_secret().to_owned()),
311            max_message_size_bytes: None,
312            max_frame_size_bytes: None,
313        };
314
315        // Configure rate limits for order operations
316        let keyed_quotas = vec![(
317            BINANCE_WS_RATE_LIMIT_KEY_ORDER[0].to_string(),
318            binance_ws_order_quota(),
319        )];
320
321        let client = WebSocketClient::builder()
322            .config(config)
323            .message_handler(raw_handler)
324            .ping_handler(ping_handler)
325            .keyed_quotas(keyed_quotas)
326            .default_quota(binance_ws_order_quota())
327            .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
328            .connect()
329            .await
330            .map_err(|e| BinanceWsApiError::ConnectionError(e.to_string()))?;
331
332        client.set_auth_tracker(self.user_data_tracker.clone(), true);
333        let connection_mode = client.connection_mode_atomic();
334        let reconnect_handle = client.reconnect_handle();
335
336        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
337        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel();
338
339        cmd_tx
340            .send(BinanceSpotWsTradingCommand::SetClient(client))
341            .map_err(|e| BinanceWsApiError::HandlerUnavailable(e.to_string()))?;
342
343        {
344            let mut rx_guard = self.out_rx.lock();
345            *rx_guard = Some(out_rx);
346        }
347
348        {
349            let mut tx_guard = self.cmd_tx.write().await;
350            *tx_guard = cmd_tx;
351            self.connection_mode.store(connection_mode);
352        }
353
354        let signal = self.signal.clone();
355        let credential = self.credential.clone();
356        let mut handler =
357            BinanceSpotWsTradingHandler::new(signal, cmd_rx, raw_rx, out_tx, credential)
358                .with_recv_window(self.recv_window_ms);
359
360        if let Some(control) = &self.socket_control {
361            control.register(move || reconnect_handle.request_reconnect());
362        }
363
364        let cancellation_token = self.cancellation_token.lock().clone();
365
366        let handler_task = async move {
367            tokio::select! {
368                () = cancellation_token.cancelled() => {
369                    log::debug!("Handler task cancelled");
370                }
371                _ = handler.run() => {
372                    log::debug!("Handler run completed");
373                }
374            }
375        };
376
377        if let Err(e) = handler_spawner.spawn(handler_task) {
378            if let Some(control) = &self.socket_control {
379                control.deregister();
380            }
381            self.out_rx.lock().take();
382            return Err(BinanceWsApiError::HandlerUnavailable(format!(
383                "failed to register handler task: {e}"
384            )));
385        }
386
387        Ok(())
388    }
389
390    /// Disconnects from the WebSocket API server.
391    ///
392    /// # Errors
393    ///
394    /// Returns an error if the handler task fails or does not stop after abort.
395    pub async fn disconnect(&mut self) -> BinanceWsApiResult<()> {
396        let connect_lock = Arc::clone(&self.connect_lock);
397        let _connect_guard = connect_lock.lock().await;
398
399        self.disconnect_handler().await
400    }
401
402    pub(crate) fn begin_shutdown(&self) {
403        self.handler_tasks.begin_shutdown();
404        self.signal.store(true, Ordering::Relaxed);
405        self.cancellation_token.lock().cancel();
406    }
407
408    async fn disconnect_handler(&self) -> BinanceWsApiResult<()> {
409        self.handler_tasks.begin_shutdown();
410        self.signal.store(true, Ordering::Relaxed);
411
412        if let Err(e) = self
413            .cmd_tx
414            .read()
415            .await
416            .send(BinanceSpotWsTradingCommand::Disconnect)
417        {
418            log::debug!("Failed to send disconnect command: {e}");
419        }
420
421        self.cancellation_token.lock().cancel();
422
423        let result = self
424            .handler_tasks
425            .finish_shutdown(Duration::from_secs(2), Duration::from_secs(2))
426            .await
427            .map_err(|e| {
428                BinanceWsApiError::ClientError(format!("handler task shutdown failed: {e}"))
429            });
430
431        if let Some(control) = &self.socket_control {
432            control.deregister();
433        }
434        result
435    }
436
437    /// Places a new order via WebSocket API.
438    ///
439    /// # Errors
440    ///
441    /// Returns an error if the handler is unavailable.
442    pub async fn place_order(&self, params: NewOrderParams) -> BinanceWsApiResult<String> {
443        let id = self.next_request_id();
444        self.place_order_with_id(id.clone(), params).await?;
445        Ok(id)
446    }
447
448    /// Places a new order via WebSocket API using a pre-generated request ID.
449    ///
450    /// # Errors
451    ///
452    /// Returns an error if the handler is unavailable.
453    pub async fn place_order_with_id(
454        &self,
455        id: String,
456        params: NewOrderParams,
457    ) -> BinanceWsApiResult<()> {
458        let cmd = BinanceSpotWsTradingCommand::PlaceOrder { id, params };
459        self.send_cmd(cmd).await
460    }
461
462    /// Cancels an order via WebSocket API.
463    ///
464    /// # Errors
465    ///
466    /// Returns an error if the handler is unavailable.
467    pub async fn cancel_order(&self, params: CancelOrderParams) -> BinanceWsApiResult<String> {
468        let id = self.next_request_id();
469        self.cancel_order_with_id(id.clone(), params).await?;
470        Ok(id)
471    }
472
473    /// Cancels an order via WebSocket API using a pre-generated request ID.
474    ///
475    /// # Errors
476    ///
477    /// Returns an error if the handler is unavailable.
478    pub async fn cancel_order_with_id(
479        &self,
480        id: String,
481        params: CancelOrderParams,
482    ) -> BinanceWsApiResult<()> {
483        let cmd = BinanceSpotWsTradingCommand::CancelOrder { id, params };
484        self.send_cmd(cmd).await
485    }
486
487    /// Cancels and replaces an order atomically via WebSocket API.
488    ///
489    /// # Errors
490    ///
491    /// Returns an error if the handler is unavailable.
492    pub async fn cancel_replace_order(
493        &self,
494        params: CancelReplaceOrderParams,
495    ) -> BinanceWsApiResult<String> {
496        let id = self.next_request_id();
497        self.cancel_replace_order_with_id(id.clone(), params)
498            .await?;
499        Ok(id)
500    }
501
502    /// Cancels and replaces an order atomically via WebSocket API using a pre-generated request ID.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if the handler is unavailable.
507    pub async fn cancel_replace_order_with_id(
508        &self,
509        id: String,
510        params: CancelReplaceOrderParams,
511    ) -> BinanceWsApiResult<()> {
512        let cmd = BinanceSpotWsTradingCommand::CancelReplaceOrder { id, params };
513        self.send_cmd(cmd).await
514    }
515
516    /// Cancels all open orders for a symbol via WebSocket API.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if the handler is unavailable.
521    pub async fn cancel_all_orders(&self, symbol: impl Into<String>) -> BinanceWsApiResult<String> {
522        let id = self.next_request_id();
523        let cmd = BinanceSpotWsTradingCommand::CancelAllOrders {
524            id: id.clone(),
525            symbol: symbol.into(),
526        };
527        self.send_cmd(cmd).await?;
528        Ok(id)
529    }
530
531    /// Receives the next message from the handler.
532    ///
533    /// Returns `None` if the receiver is closed or not initialized.
534    pub async fn recv(&self) -> Option<BinanceSpotWsTradingMessage> {
535        // Take the receiver out of the mutex to avoid holding it across await
536        let rx_opt = {
537            let mut rx_guard = self.out_rx.lock();
538            rx_guard.take()
539        };
540
541        if let Some(mut rx) = rx_opt {
542            let result = rx.recv().await;
543
544            let mut rx_guard = self.out_rx.lock();
545            *rx_guard = Some(rx);
546            result
547        } else {
548            None
549        }
550    }
551
552    /// Authenticates the WebSocket session via `session.logon`.
553    ///
554    /// # Errors
555    ///
556    /// Returns an error if the handler is unavailable.
557    pub async fn session_logon(&self) -> BinanceWsApiResult<()> {
558        self.send_cmd(BinanceSpotWsTradingCommand::SessionLogon)
559            .await
560    }
561
562    /// Subscribes to the user data stream via `userDataStream.subscribe`.
563    ///
564    /// # Errors
565    ///
566    /// Returns an error if the handler is unavailable.
567    pub async fn subscribe_user_data(&self) -> BinanceWsApiResult<()> {
568        self.send_cmd(BinanceSpotWsTradingCommand::SubscribeUserData)
569            .await
570    }
571
572    async fn send_cmd(&self, cmd: BinanceSpotWsTradingCommand) -> BinanceWsApiResult<()> {
573        self.cmd_tx
574            .read()
575            .await
576            .send(cmd)
577            .map_err(|e| BinanceWsApiError::HandlerUnavailable(e.to_string()))
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use futures_util::StreamExt;
584    use nautilus_testkit::connection::race_command_with_publication;
585    use rstest::rstest;
586
587    use super::*;
588
589    #[tokio::test]
590    async fn command_during_sender_publication_reaches_initialized_handler() {
591        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
592        let addr = listener.local_addr().unwrap();
593        let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
594
595        let server = tokio::spawn(async move {
596            let (socket, _) = listener.accept().await.unwrap();
597            let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
598            while let Some(Ok(message)) = socket.next().await {
599                if let tokio_tungstenite::tungstenite::Message::Text(text) = message {
600                    let payload: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
601                    let _ = received_tx.send(payload);
602                }
603            }
604        });
605
606        let url = format!("ws://{addr}");
607
608        let mut ws_client = BinanceSpotWsTradingClient::new(
609            Some(url),
610            "startup-key".to_string(),
611            "startup-secret".to_string(),
612            None,
613            TransportBackend::default(),
614        );
615        let observer = ws_client.clone();
616
617        let command = BinanceSpotWsTradingCommand::CancelOrder {
618            id: "startup-73".to_string(),
619            params: CancelOrderParams::by_order_id("ETHUSDT", 724),
620        };
621
622        race_command_with_publication(
623            Arc::clone(&ws_client.cmd_tx),
624            ws_client.connect(),
625            move || command,
626            || observer.is_active(),
627        )
628        .await
629        .expect("connect failed");
630        let received = tokio::time::timeout(Duration::from_secs(2), received_rx.recv()).await;
631        ws_client.disconnect().await.expect("disconnect failed");
632        server.abort();
633
634        let mut payload = received
635            .expect("startup command must reach server")
636            .unwrap();
637        let timestamp = payload["params"]
638            .as_object_mut()
639            .unwrap()
640            .remove("timestamp")
641            .unwrap();
642        let signature = payload["params"]
643            .as_object_mut()
644            .unwrap()
645            .remove("signature")
646            .unwrap();
647        let timestamp = timestamp.as_u64().unwrap();
648        let query = format!("apiKey=startup-key&orderId=724&symbol=ETHUSDT&timestamp={timestamp}");
649        assert_eq!(
650            signature,
651            serde_json::json!(observer.credential.sign(&query))
652        );
653        assert_eq!(
654            payload,
655            serde_json::json!({
656                "id": "startup-73",
657                "method": "order.cancel",
658                "params": {
659                    "apiKey": "startup-key",
660                    "symbol": "ETHUSDT",
661                    "orderId": 724
662                }
663            })
664        );
665    }
666
667    #[rstest]
668    fn test_operational_options_are_preserved() {
669        let client = BinanceSpotWsTradingClient::new(
670            None,
671            "api-key".to_string(),
672            "hmac-secret".to_string(),
673            None,
674            TransportBackend::default(),
675        )
676        .with_proxy(Some("http://proxy.example:8080".to_string()))
677        .with_recv_window(Some(45_000));
678
679        assert_eq!(
680            client.proxy_url.as_ref().map(SecretString::expose_secret),
681            Some("http://proxy.example:8080")
682        );
683        assert_eq!(client.recv_window_ms, Some(45_000));
684    }
685
686    #[rstest]
687    fn test_url_is_redacted() {
688        let url = "wss://stream.example/ws/private-listen-key";
689        let client = BinanceSpotWsTradingClient::new(
690            Some(url.to_string()),
691            "api-key".to_string(),
692            "hmac-secret".to_string(),
693            None,
694            TransportBackend::default(),
695        );
696
697        let debug = format!("{client:?}");
698
699        assert_eq!(client.url.expose_secret(), url);
700        assert!(debug.contains(REDACTED));
701        assert!(!debug.contains("private-listen-key"));
702    }
703}