nautilus_binance/spot/websocket/trading/
client.rs1use 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;
38use nautilus_live::{SocketControl, task::TaskGroup};
39use nautilus_network::{
40 mode::ConnectionMode,
41 ratelimiter::quota::Quota,
42 websocket::{
43 AuthTracker, PingHandler, TransportBackend, WebSocketClient, WebSocketConfig,
44 channel_message_handler,
45 },
46};
47use parking_lot::Mutex;
48use tokio_util::sync::CancellationToken;
49use ustr::Ustr;
50
51use super::{
52 error::{BinanceWsApiError, BinanceWsApiResult},
53 handler::BinanceSpotWsTradingHandler,
54 messages::{BinanceSpotWsTradingCommand, BinanceSpotWsTradingMessage},
55};
56use crate::{
57 common::{
58 consts::{BINANCE_API_KEY_HEADER, BINANCE_SPOT_SBE_WS_API_URL},
59 credential::SigningCredential,
60 },
61 spot::http::query::{CancelOrderParams, CancelReplaceOrderParams, NewOrderParams},
62};
63
64pub const BINANCE_API_KEY: &str = "BINANCE_API_KEY";
66
67pub const BINANCE_API_SECRET: &str = "BINANCE_API_SECRET";
69
70pub static BINANCE_WS_RATE_LIMIT_KEY_ORDER: LazyLock<[Ustr; 1]> =
74 LazyLock::new(|| [Ustr::from("order")]);
75
76#[expect(clippy::missing_panics_doc)]
81#[must_use]
82pub fn binance_ws_order_quota() -> Quota {
83 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant")
84}
85
86#[derive(Clone)]
91pub struct BinanceSpotWsTradingClient {
92 url: String,
93 credential: Arc<SigningCredential>,
94 heartbeat: Option<u64>,
95 signal: Arc<AtomicBool>,
96 connection_mode: Arc<ArcSwap<AtomicU8>>,
97 user_data_tracker: AuthTracker,
98 cmd_tx:
99 Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<BinanceSpotWsTradingCommand>>>,
100 out_rx: Arc<Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<BinanceSpotWsTradingMessage>>>>,
101 handler_tasks: Arc<TaskGroup>,
102 connect_lock: Arc<tokio::sync::Mutex<()>>,
103 request_id_counter: Arc<AtomicU64>,
104 cancellation_token: Arc<Mutex<CancellationToken>>,
105 transport_backend: TransportBackend,
106 proxy_url: Option<String>,
107 recv_window_ms: Option<u64>,
108 socket_control: Option<SocketControl>,
109}
110
111impl Debug for BinanceSpotWsTradingClient {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.debug_struct(stringify!(BinanceSpotWsTradingClient))
114 .field("url", &self.url)
115 .field("credential", &REDACTED)
116 .field("heartbeat", &self.heartbeat)
117 .finish_non_exhaustive()
118 }
119}
120
121impl BinanceSpotWsTradingClient {
122 #[must_use]
124 pub fn new(
125 url: Option<String>,
126 api_key: String,
127 api_secret: String,
128 heartbeat: Option<u64>,
129 transport_backend: TransportBackend,
130 ) -> Self {
131 let url = url.unwrap_or_else(|| BINANCE_SPOT_SBE_WS_API_URL.to_string());
132 let credential = Arc::new(SigningCredential::new(api_key, api_secret));
133
134 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
135
136 Self {
137 url,
138 credential,
139 heartbeat,
140 signal: Arc::new(AtomicBool::new(false)),
141 connection_mode: Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
142 ConnectionMode::Closed as u8,
143 )))),
144 user_data_tracker: AuthTracker::new(),
145 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
146 out_rx: Arc::new(Mutex::new(None)),
147 handler_tasks: Arc::new(TaskGroup::new()),
148 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
149 request_id_counter: Arc::new(AtomicU64::new(1)),
150 cancellation_token: Arc::new(Mutex::new(CancellationToken::new())),
151 transport_backend,
152 proxy_url: None,
153 recv_window_ms: None,
154 socket_control: None,
155 }
156 }
157
158 #[must_use]
160 pub fn with_proxy(mut self, proxy_url: Option<String>) -> Self {
161 self.proxy_url = proxy_url;
162 self
163 }
164
165 #[must_use]
167 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
168 self.socket_control = Some(control);
169 self
170 }
171
172 #[must_use]
174 pub const fn with_recv_window(mut self, recv_window_ms: Option<u64>) -> Self {
175 self.recv_window_ms = recv_window_ms;
176 self
177 }
178
179 pub fn with_env(
189 url: Option<String>,
190 api_key: Option<String>,
191 api_secret: Option<String>,
192 heartbeat: Option<u64>,
193 transport_backend: TransportBackend,
194 ) -> anyhow::Result<Self> {
195 let api_key = nautilus_core::env::get_or_env_var(api_key, BINANCE_API_KEY)?;
196 let api_secret = nautilus_core::env::get_or_env_var(api_secret, BINANCE_API_SECRET)?;
197 Ok(Self::new(
198 url,
199 api_key,
200 api_secret,
201 heartbeat,
202 transport_backend,
203 ))
204 }
205
206 pub fn from_env(url: Option<String>, heartbeat: Option<u64>) -> anyhow::Result<Self> {
216 Self::with_env(url, None, None, heartbeat, TransportBackend::default())
217 }
218
219 #[must_use]
221 pub fn is_active(&self) -> bool {
222 let mode_u8 = self.connection_mode.load().load(Ordering::Relaxed);
223 mode_u8 == ConnectionMode::Active as u8
224 }
225
226 #[must_use]
228 pub fn is_user_data_active(&self) -> bool {
229 self.is_active() && self.user_data_tracker.is_authenticated()
230 }
231
232 pub fn mark_user_data_active(&self) {
234 self.user_data_tracker.succeed();
235 }
236
237 pub fn mark_user_data_inactive(&self) {
239 self.user_data_tracker.invalidate();
240 }
241
242 #[must_use]
244 pub fn is_closed(&self) -> bool {
245 let mode_u8 = self.connection_mode.load().load(Ordering::Relaxed);
246 mode_u8 == ConnectionMode::Closed as u8
247 }
248
249 pub fn next_request_id(&self) -> String {
251 let id = self.request_id_counter.fetch_add(1, Ordering::Relaxed);
252 format!("req-{id}")
253 }
254
255 pub async fn connect(&mut self) -> BinanceWsApiResult<()> {
261 let connect_lock = Arc::clone(&self.connect_lock);
262 let _connect_guard = connect_lock.lock().await;
263
264 if !self.handler_tasks.is_open() || !self.handler_tasks.is_empty() {
265 self.disconnect_handler().await?;
266 self.handler_tasks.start_generation().map_err(|e| {
267 BinanceWsApiError::ClientError(format!(
268 "failed to start WebSocket handler task generation: {e}"
269 ))
270 })?;
271 }
272 let handler_spawner = self.handler_tasks.spawner().map_err(|e| {
273 BinanceWsApiError::ClientError(format!(
274 "failed to acquire WebSocket handler task spawner: {e}"
275 ))
276 })?;
277 self.signal.store(false, Ordering::Relaxed);
278 self.user_data_tracker.invalidate();
279 *self.cancellation_token.lock() = CancellationToken::new();
280
281 let (raw_handler, raw_rx) = channel_message_handler();
282 let ping_handler: PingHandler = Arc::new(move |_| {});
283
284 let headers = vec![(
285 BINANCE_API_KEY_HEADER.to_string(),
286 self.credential.api_key().to_string(),
287 )];
288
289 let config = WebSocketConfig {
290 url: self.url.clone(),
291 headers,
292 heartbeat_interval_secs: self.heartbeat,
293 heartbeat_payload: None,
294 connect_timeout_ms: Some(5_000),
295 reconnect_delay_initial_ms: Some(500),
296 reconnect_delay_max_ms: Some(5_000),
297 reconnect_backoff_factor: Some(2.0),
298 reconnect_jitter_ms: Some(250),
299 reconnect_max_attempts: None,
300 heartbeat_timeout_secs: None,
301 idle_timeout_ms: None,
302 backend: self.transport_backend,
303 proxy_url: self.proxy_url.clone(),
304 };
305
306 let keyed_quotas = vec![(
308 BINANCE_WS_RATE_LIMIT_KEY_ORDER[0].as_str().to_string(),
309 binance_ws_order_quota(),
310 )];
311
312 let client = WebSocketClient::builder()
313 .config(config)
314 .message_handler(raw_handler)
315 .ping_handler(ping_handler)
316 .keyed_quotas(keyed_quotas)
317 .default_quota(binance_ws_order_quota())
318 .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
319 .connect()
320 .await
321 .map_err(|e| BinanceWsApiError::ConnectionError(e.to_string()))?;
322
323 client.set_auth_tracker(self.user_data_tracker.clone(), true);
324 self.connection_mode.store(client.connection_mode_atomic());
325 let reconnect_handle = client.reconnect_handle();
326
327 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
328 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel();
329
330 {
331 let mut rx_guard = self.out_rx.lock();
332 *rx_guard = Some(out_rx);
333 }
334
335 {
336 let mut tx_guard = self.cmd_tx.write().await;
337 *tx_guard = cmd_tx;
338 }
339
340 let signal = self.signal.clone();
341 let credential = self.credential.clone();
342 let mut handler =
343 BinanceSpotWsTradingHandler::new(signal, cmd_rx, raw_rx, out_tx, credential)
344 .with_recv_window(self.recv_window_ms);
345
346 self.cmd_tx
347 .read()
348 .await
349 .send(BinanceSpotWsTradingCommand::SetClient(client))
350 .map_err(|e| BinanceWsApiError::HandlerUnavailable(e.to_string()))?;
351 if let Some(control) = &self.socket_control {
352 control.register(move || reconnect_handle.request_reconnect());
353 }
354
355 let cancellation_token = self.cancellation_token.lock().clone();
356
357 let handler_task = async move {
358 tokio::select! {
359 () = cancellation_token.cancelled() => {
360 log::debug!("Handler task cancelled");
361 }
362 _ = handler.run() => {
363 log::debug!("Handler run completed");
364 }
365 }
366 };
367
368 if let Err(e) = handler_spawner.spawn(handler_task) {
369 if let Some(control) = &self.socket_control {
370 control.deregister();
371 }
372 self.out_rx.lock().take();
373 return Err(BinanceWsApiError::HandlerUnavailable(format!(
374 "failed to register handler task: {e}"
375 )));
376 }
377
378 Ok(())
379 }
380
381 pub async fn disconnect(&mut self) -> BinanceWsApiResult<()> {
387 let connect_lock = Arc::clone(&self.connect_lock);
388 let _connect_guard = connect_lock.lock().await;
389
390 self.disconnect_handler().await
391 }
392
393 pub(crate) fn begin_shutdown(&self) {
394 self.handler_tasks.begin_shutdown();
395 self.signal.store(true, Ordering::Relaxed);
396 self.cancellation_token.lock().cancel();
397 }
398
399 async fn disconnect_handler(&self) -> BinanceWsApiResult<()> {
400 self.handler_tasks.begin_shutdown();
401 self.signal.store(true, Ordering::Relaxed);
402
403 if let Err(e) = self
404 .cmd_tx
405 .read()
406 .await
407 .send(BinanceSpotWsTradingCommand::Disconnect)
408 {
409 log::debug!("Failed to send disconnect command: {e}");
410 }
411
412 self.cancellation_token.lock().cancel();
413
414 let result = self
415 .handler_tasks
416 .finish_shutdown(Duration::from_secs(2), Duration::from_secs(2))
417 .await
418 .map_err(|e| {
419 BinanceWsApiError::ClientError(format!("handler task shutdown failed: {e}"))
420 });
421
422 if let Some(control) = &self.socket_control {
423 control.deregister();
424 }
425 result
426 }
427
428 pub async fn place_order(&self, params: NewOrderParams) -> BinanceWsApiResult<String> {
434 let id = self.next_request_id();
435 self.place_order_with_id(id.clone(), params).await?;
436 Ok(id)
437 }
438
439 pub async fn place_order_with_id(
445 &self,
446 id: String,
447 params: NewOrderParams,
448 ) -> BinanceWsApiResult<()> {
449 let cmd = BinanceSpotWsTradingCommand::PlaceOrder { id, params };
450 self.send_cmd(cmd).await
451 }
452
453 pub async fn cancel_order(&self, params: CancelOrderParams) -> BinanceWsApiResult<String> {
459 let id = self.next_request_id();
460 self.cancel_order_with_id(id.clone(), params).await?;
461 Ok(id)
462 }
463
464 pub async fn cancel_order_with_id(
470 &self,
471 id: String,
472 params: CancelOrderParams,
473 ) -> BinanceWsApiResult<()> {
474 let cmd = BinanceSpotWsTradingCommand::CancelOrder { id, params };
475 self.send_cmd(cmd).await
476 }
477
478 pub async fn cancel_replace_order(
484 &self,
485 params: CancelReplaceOrderParams,
486 ) -> BinanceWsApiResult<String> {
487 let id = self.next_request_id();
488 self.cancel_replace_order_with_id(id.clone(), params)
489 .await?;
490 Ok(id)
491 }
492
493 pub async fn cancel_replace_order_with_id(
499 &self,
500 id: String,
501 params: CancelReplaceOrderParams,
502 ) -> BinanceWsApiResult<()> {
503 let cmd = BinanceSpotWsTradingCommand::CancelReplaceOrder { id, params };
504 self.send_cmd(cmd).await
505 }
506
507 pub async fn cancel_all_orders(&self, symbol: impl Into<String>) -> BinanceWsApiResult<String> {
513 let id = self.next_request_id();
514 let cmd = BinanceSpotWsTradingCommand::CancelAllOrders {
515 id: id.clone(),
516 symbol: symbol.into(),
517 };
518 self.send_cmd(cmd).await?;
519 Ok(id)
520 }
521
522 pub async fn recv(&self) -> Option<BinanceSpotWsTradingMessage> {
526 let rx_opt = {
528 let mut rx_guard = self.out_rx.lock();
529 rx_guard.take()
530 };
531
532 if let Some(mut rx) = rx_opt {
533 let result = rx.recv().await;
534
535 let mut rx_guard = self.out_rx.lock();
536 *rx_guard = Some(rx);
537 result
538 } else {
539 None
540 }
541 }
542
543 pub async fn session_logon(&self) -> BinanceWsApiResult<()> {
549 self.send_cmd(BinanceSpotWsTradingCommand::SessionLogon)
550 .await
551 }
552
553 pub async fn subscribe_user_data(&self) -> BinanceWsApiResult<()> {
559 self.send_cmd(BinanceSpotWsTradingCommand::SubscribeUserData)
560 .await
561 }
562
563 async fn send_cmd(&self, cmd: BinanceSpotWsTradingCommand) -> BinanceWsApiResult<()> {
564 self.cmd_tx
565 .read()
566 .await
567 .send(cmd)
568 .map_err(|e| BinanceWsApiError::HandlerUnavailable(e.to_string()))
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use rstest::rstest;
575
576 use super::*;
577
578 #[rstest]
579 fn test_operational_options_are_preserved() {
580 let client = BinanceSpotWsTradingClient::new(
581 None,
582 "api-key".to_string(),
583 "hmac-secret".to_string(),
584 None,
585 TransportBackend::default(),
586 )
587 .with_proxy(Some("http://proxy.example:8080".to_string()))
588 .with_recv_window(Some(45_000));
589
590 assert_eq!(
591 client.proxy_url.as_deref(),
592 Some("http://proxy.example:8080")
593 );
594 assert_eq!(client.recv_window_ms, Some(45_000));
595 }
596}