Skip to main content

nautilus_binance/spot/websocket/trading/
handler.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 message handler.
17//!
18//! The handler runs in a dedicated Tokio task as the I/O boundary between the client
19//! orchestrator and the network layer. It exclusively owns the `WebSocketClient` and
20//! processes commands from the client via an unbounded channel.
21//!
22//! ## Responsibilities
23//!
24//! - Command processing: Receives `BinanceSpotWsTradingCommand` from client, serializes to JSON requests.
25//! - Response decoding: Parses SBE binary responses using schema 3 decoders.
26//! - Request correlation: Matches responses to pending requests by ID.
27//! - Message transformation: Emits `BinanceSpotWsTradingMessage` events to client via channel.
28
29use std::{
30    fmt::Debug,
31    sync::{
32        Arc,
33        atomic::{AtomicBool, AtomicU64, Ordering},
34    },
35};
36
37use ahash::AHashMap;
38use nautilus_network::{RECONNECTED, websocket::WebSocketClient};
39use tokio_tungstenite::tungstenite::Message;
40
41use super::{
42    client::BINANCE_WS_RATE_LIMIT_KEY_ORDER,
43    error::{BinanceWsApiError, BinanceWsApiResult},
44    messages::{
45        BinanceSpotWsTradingCommand, BinanceSpotWsTradingMessage, BinanceSpotWsTradingRequest,
46        BinanceSpotWsTradingRequestMeta, method,
47    },
48};
49use crate::{
50    common::credential::{SigningCredential, canonical_ws_query_string},
51    spot::{
52        enums::BinanceSpotUserDataEventType,
53        http::parse,
54        sbe::spot::{
55            ReadBuf,
56            error_response_codec::ErrorResponseDecoder,
57            message_header_codec,
58            web_socket_response_codec::{SBE_TEMPLATE_ID, WebSocketResponseDecoder},
59        },
60    },
61};
62
63/// Binance Spot WebSocket API handler.
64///
65/// Runs in a dedicated Tokio task, processing commands from the client
66/// and transforming raw WebSocket messages into Nautilus domain events.
67/// Messages are sent to the client via the output channel.
68pub struct BinanceSpotWsTradingHandler {
69    signal: Arc<AtomicBool>,
70    inner: Option<WebSocketClient>,
71    cmd_rx: tokio::sync::mpsc::UnboundedReceiver<BinanceSpotWsTradingCommand>,
72    raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
73    out_tx: tokio::sync::mpsc::UnboundedSender<BinanceSpotWsTradingMessage>,
74    credential: Arc<SigningCredential>,
75    pending_requests: AHashMap<String, BinanceSpotWsTradingRequestMeta>,
76    request_id_counter: AtomicU64,
77    recv_window_ms: Option<u64>,
78}
79
80impl Debug for BinanceSpotWsTradingHandler {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct(stringify!(BinanceSpotWsTradingHandler))
83            .field("inner", &self.inner.as_ref().map(|_| "<client>"))
84            .field(
85                "pending_requests",
86                &format!("{} pending", self.pending_requests.len()),
87            )
88            .finish_non_exhaustive()
89    }
90}
91
92impl BinanceSpotWsTradingHandler {
93    /// Creates a new handler instance.
94    #[must_use]
95    pub fn new(
96        signal: Arc<AtomicBool>,
97        cmd_rx: tokio::sync::mpsc::UnboundedReceiver<BinanceSpotWsTradingCommand>,
98        raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
99        out_tx: tokio::sync::mpsc::UnboundedSender<BinanceSpotWsTradingMessage>,
100        credential: Arc<SigningCredential>,
101    ) -> Self {
102        Self {
103            signal,
104            inner: None,
105            cmd_rx,
106            raw_rx,
107            out_tx,
108            credential,
109            pending_requests: AHashMap::new(),
110            request_id_counter: AtomicU64::new(1000),
111            recv_window_ms: None,
112        }
113    }
114
115    /// Configures the receive window added before request signing.
116    #[must_use]
117    pub const fn with_recv_window(mut self, recv_window_ms: Option<u64>) -> Self {
118        self.recv_window_ms = recv_window_ms;
119        self
120    }
121
122    /// Runs the main event loop for commands and raw messages.
123    ///
124    /// Sends output messages via `out_tx` channel. Returns `false` when disconnected
125    /// or the signal is set, indicating the handler should exit.
126    pub async fn run(&mut self) -> bool {
127        loop {
128            if self.signal.load(Ordering::Relaxed) {
129                return false;
130            }
131
132            tokio::select! {
133                Some(cmd) = self.cmd_rx.recv() => {
134                    match cmd {
135                        BinanceSpotWsTradingCommand::SetClient(client) => {
136                            log::debug!("Handler received WebSocket client");
137                            self.inner = Some(client);
138                            self.emit(BinanceSpotWsTradingMessage::Connected);
139                        }
140                        BinanceSpotWsTradingCommand::Disconnect => {
141                            log::debug!("Handler disconnecting WebSocket client");
142                            self.inner = None;
143                            return false;
144                        }
145                        BinanceSpotWsTradingCommand::PlaceOrder { id, params } => {
146                            if let Err(e) = self.handle_place_order(id.clone(), params).await {
147                                log::debug!("Failed to handle place order command: {e}");
148                                self.pending_requests.remove(&id);
149                                self.emit(BinanceSpotWsTradingMessage::RequestFailed {
150                                    request_id: id,
151                                    msg: e.to_string(),
152                                });
153                            }
154                        }
155                        BinanceSpotWsTradingCommand::CancelOrder { id, params } => {
156                            if let Err(e) = self.handle_cancel_order(id.clone(), params).await {
157                                log::debug!("Failed to handle cancel order command: {e}");
158                                self.pending_requests.remove(&id);
159                                self.emit(BinanceSpotWsTradingMessage::RequestFailed {
160                                    request_id: id,
161                                    msg: e.to_string(),
162                                });
163                            }
164                        }
165                        BinanceSpotWsTradingCommand::CancelReplaceOrder { id, params } => {
166                            if let Err(e) = self.handle_cancel_replace_order(id.clone(), params).await {
167                                log::debug!("Failed to handle cancel replace command: {e}");
168                                self.pending_requests.remove(&id);
169                                self.emit(BinanceSpotWsTradingMessage::RequestFailed {
170                                    request_id: id,
171                                    msg: e.to_string(),
172                                });
173                            }
174                        }
175                        BinanceSpotWsTradingCommand::CancelAllOrders { id, symbol } => {
176                            if let Err(e) = self.handle_cancel_all_orders(id.clone(), symbol).await {
177                                log::debug!("Failed to handle cancel all command: {e}");
178                                self.pending_requests.remove(&id);
179                                self.emit(BinanceSpotWsTradingMessage::RequestFailed {
180                                    request_id: id,
181                                    msg: e.to_string(),
182                                });
183                            }
184                        }
185                        BinanceSpotWsTradingCommand::SessionLogon => {
186                            if let Err(e) = self.handle_session_logon().await {
187                                log::error!("Session logon failed: {e}");
188                                self.emit(BinanceSpotWsTradingMessage::AuthenticationRejected(
189                                    format!("Session logon failed: {e}"),
190                                ));
191                            }
192                        }
193                        BinanceSpotWsTradingCommand::SubscribeUserData => {
194                            if let Err(e) = self.handle_subscribe_user_data().await {
195                                log::error!("User data subscribe failed: {e}");
196                                self.emit(
197                                    BinanceSpotWsTradingMessage::UserDataSubscriptionRejected(
198                                        format!("User data subscribe failed: {e}"),
199                                    ),
200                                );
201                            }
202                        }
203                    }
204                }
205                Some(msg) = self.raw_rx.recv() => {
206                    if let Message::Text(ref text) = msg
207                        && text.as_str() == RECONNECTED
208                    {
209                        log::debug!("Handler received reconnection signal");
210
211                        // Fail any pending requests - they won't get responses on new connection
212                        self.fail_pending_requests();
213
214                        self.emit(BinanceSpotWsTradingMessage::Reconnected);
215                        continue;
216                    }
217
218                    self.handle_message(msg);
219                }
220                else => {
221                    // Both channels closed
222                    return false;
223                }
224            }
225        }
226    }
227
228    /// Sends a message to the output channel.
229    fn emit(&self, msg: BinanceSpotWsTradingMessage) {
230        if let Err(e) = self.out_tx.send(msg) {
231            log::error!("Failed to send message to output channel: {e}");
232        }
233    }
234
235    /// Fails all pending requests after a reconnection.
236    fn fail_pending_requests(&mut self) {
237        if self.pending_requests.is_empty() {
238            return;
239        }
240
241        let count = self.pending_requests.len();
242        log::warn!("Failing {count} pending requests after reconnection");
243
244        let pending = std::mem::take(&mut self.pending_requests);
245        for (request_id, _meta) in pending {
246            self.emit(BinanceSpotWsTradingMessage::RequestFailed {
247                request_id,
248                msg: "Connection lost before response received".to_string(),
249            });
250        }
251    }
252
253    async fn handle_place_order(
254        &mut self,
255        id: String,
256        params: crate::spot::http::query::NewOrderParams,
257    ) -> BinanceWsApiResult<()> {
258        let params_json = serde_json::to_value(&params)
259            .map_err(|e| BinanceWsApiError::ClientError(e.to_string()))?;
260        let signed_params = self.sign_params(params_json)?;
261
262        let request = BinanceSpotWsTradingRequest::new(&id, method::ORDER_PLACE, signed_params);
263        self.pending_requests
264            .insert(id.clone(), BinanceSpotWsTradingRequestMeta::PlaceOrder);
265        self.send_request(request).await
266    }
267
268    async fn handle_cancel_order(
269        &mut self,
270        id: String,
271        params: crate::spot::http::query::CancelOrderParams,
272    ) -> BinanceWsApiResult<()> {
273        let params_json = serde_json::to_value(&params)
274            .map_err(|e| BinanceWsApiError::ClientError(e.to_string()))?;
275        let signed_params = self.sign_params(params_json)?;
276
277        let request = BinanceSpotWsTradingRequest::new(&id, method::ORDER_CANCEL, signed_params);
278        self.pending_requests
279            .insert(id.clone(), BinanceSpotWsTradingRequestMeta::CancelOrder);
280        self.send_request(request).await
281    }
282
283    async fn handle_cancel_replace_order(
284        &mut self,
285        id: String,
286        params: crate::spot::http::query::CancelReplaceOrderParams,
287    ) -> BinanceWsApiResult<()> {
288        let params_json = serde_json::to_value(&params)
289            .map_err(|e| BinanceWsApiError::ClientError(e.to_string()))?;
290        let signed_params = self.sign_params(params_json)?;
291
292        let request =
293            BinanceSpotWsTradingRequest::new(&id, method::ORDER_CANCEL_REPLACE, signed_params);
294        self.pending_requests.insert(
295            id.clone(),
296            BinanceSpotWsTradingRequestMeta::CancelReplaceOrder,
297        );
298        self.send_request(request).await
299    }
300
301    async fn handle_cancel_all_orders(
302        &mut self,
303        id: String,
304        symbol: String,
305    ) -> BinanceWsApiResult<()> {
306        let params_json = serde_json::json!({ "symbol": symbol });
307        let signed_params = self.sign_params(params_json)?;
308
309        let request =
310            BinanceSpotWsTradingRequest::new(&id, method::OPEN_ORDERS_CANCEL_ALL, signed_params);
311        self.pending_requests
312            .insert(id.clone(), BinanceSpotWsTradingRequestMeta::CancelAllOrders);
313        self.send_request(request).await
314    }
315
316    async fn handle_session_logon(&mut self) -> BinanceWsApiResult<()> {
317        let id = self.next_request_id();
318        let params_json = serde_json::json!({});
319        let signed_params = self.sign_params(params_json)?;
320
321        let request = BinanceSpotWsTradingRequest::new(&id, method::SESSION_LOGON, signed_params);
322        self.pending_requests
323            .insert(id, BinanceSpotWsTradingRequestMeta::SessionLogon);
324        self.send_request(request).await
325    }
326
327    async fn handle_subscribe_user_data(&mut self) -> BinanceWsApiResult<()> {
328        let id = self.next_request_id();
329        let request = BinanceSpotWsTradingRequest::new(
330            &id,
331            "userDataStream.subscribe",
332            serde_json::json!({}),
333        );
334        self.pending_requests
335            .insert(id, BinanceSpotWsTradingRequestMeta::SubscribeUserData);
336        self.send_request(request).await
337    }
338
339    fn next_request_id(&self) -> String {
340        let id = self.request_id_counter.fetch_add(1, Ordering::Relaxed);
341        format!("ws-{id}")
342    }
343
344    fn sign_params(&self, mut params: serde_json::Value) -> BinanceWsApiResult<serde_json::Value> {
345        let timestamp = std::time::SystemTime::now()
346            .duration_since(std::time::UNIX_EPOCH)
347            .map_err(|e| BinanceWsApiError::ClientError(e.to_string()))?
348            .as_millis() as i64;
349
350        if let Some(obj) = params.as_object_mut() {
351            obj.insert("timestamp".to_string(), serde_json::json!(timestamp));
352            obj.insert(
353                "apiKey".to_string(),
354                serde_json::json!(self.credential.api_key()),
355            );
356
357            if let Some(recv_window_ms) = self.recv_window_ms {
358                obj.insert("recvWindow".to_string(), serde_json::json!(recv_window_ms));
359            }
360        }
361
362        // Sign over a key-sorted query string: Binance's WS API verifies the
363        // signature against the parameters sorted by key, which must not depend
364        // on serde_json's Map iteration order (issue #4410).
365        let query_string = canonical_ws_query_string(
366            params
367                .as_object()
368                .into_iter()
369                .flatten()
370                .map(|(k, v)| (k.as_str(), v)),
371        )
372        .map_err(|e| BinanceWsApiError::ClientError(e.to_string()))?;
373        let signature = self.credential.sign(&query_string);
374
375        if let Some(obj) = params.as_object_mut() {
376            obj.insert("signature".to_string(), serde_json::json!(signature));
377        }
378
379        Ok(params)
380    }
381
382    async fn send_request(
383        &mut self,
384        request: BinanceSpotWsTradingRequest,
385    ) -> BinanceWsApiResult<()> {
386        let client = self.inner.as_mut().ok_or_else(|| {
387            BinanceWsApiError::ConnectionError("WebSocket not connected".to_string())
388        })?;
389
390        let json = serde_json::to_string(&request)
391            .map_err(|e| BinanceWsApiError::ClientError(e.to_string()))?;
392
393        log::debug!(
394            "Sending WebSocket API request id={} method={}",
395            request.id,
396            request.method
397        );
398
399        let keys = Some(BINANCE_WS_RATE_LIMIT_KEY_ORDER.as_slice());
400
401        let is_session_setup = matches!(
402            self.pending_requests.get(&request.id),
403            Some(
404                BinanceSpotWsTradingRequestMeta::SessionLogon
405                    | BinanceSpotWsTradingRequestMeta::SubscribeUserData
406            )
407        );
408
409        let result = if is_session_setup {
410            client
411                .send_text_on_connection(json, keys, client.connection_epoch())
412                .await
413        } else {
414            client.send_text(json, keys).await
415        };
416
417        result.map_err(|e| {
418            BinanceWsApiError::ConnectionError(format!("Failed to send request: {e}"))
419        })?;
420
421        Ok(())
422    }
423
424    fn handle_message(&mut self, msg: Message) {
425        match msg {
426            Message::Binary(data) => self.handle_binary_response(&data),
427            Message::Text(text) => self.handle_text_response(&text),
428            Message::Ping(_) | Message::Pong(_) => {}
429            Message::Close(frame) => {
430                log::debug!("WebSocket closed: {frame:?}");
431            }
432            Message::Frame(_) => {}
433        }
434    }
435
436    fn handle_binary_response(&mut self, data: &[u8]) {
437        match self.decode_ws_api_response(data) {
438            Ok(response) => self.emit(response),
439            Err(e) => {
440                log::error!("Failed to decode WebSocket API response: {e}");
441                self.emit(BinanceSpotWsTradingMessage::Error(e.to_string()));
442            }
443        }
444    }
445
446    fn handle_text_response(&mut self, text: &str) {
447        let json: serde_json::Value = match serde_json::from_str(text) {
448            Ok(j) => j,
449            Err(e) => {
450                log::warn!("Failed to parse text response as JSON: {e}");
451                return;
452            }
453        };
454
455        // User data events arrive wrapped: {"subscriptionId": N, "event": {...}}
456        if let Some(event) = json.get("event") {
457            self.handle_user_data_event(event);
458            return;
459        }
460
461        // Legacy listen-key streams, including Binance US, send the event directly.
462        if json.get("e").is_some() {
463            self.handle_user_data_event(&json);
464            return;
465        }
466
467        // WS API responses have an "id" field for request correlation
468        if let Some(id) = json.get("id") {
469            let id_str = match id {
470                serde_json::Value::String(s) => s.clone(),
471                serde_json::Value::Number(n) => n.to_string(),
472                _ => return,
473            };
474
475            if let Some(meta) = self.pending_requests.remove(&id_str) {
476                // Check for error: nested {"error": {"code": N, "msg": "..."}}
477                // or top-level {"code": N, "msg": "..."}
478                let error_info = json
479                    .get("error")
480                    .map(|e| {
481                        (
482                            e.get("code")
483                                .and_then(|v| v.as_i64())
484                                .and_then(|code| i32::try_from(code).ok()),
485                            e.get("msg")
486                                .and_then(|v| v.as_str())
487                                .unwrap_or("Unknown error")
488                                .to_string(),
489                        )
490                    })
491                    .or_else(|| {
492                        json.get("code").and_then(|c| c.as_i64()).map(|code| {
493                            let msg = json
494                                .get("msg")
495                                .and_then(|v| v.as_str())
496                                .unwrap_or("Unknown error")
497                                .to_string();
498                            (i32::try_from(code).ok(), msg)
499                        })
500                    });
501
502                if let Some((code, msg)) = error_info {
503                    let status = json
504                        .get("status")
505                        .and_then(|v| v.as_u64())
506                        .map(|s| s as u16);
507                    let rejection = match code {
508                        Some(code) => {
509                            self.create_rejection(id_str, status.unwrap_or(0), code, msg, meta)
510                        }
511                        None => BinanceSpotWsTradingMessage::RequestFailed {
512                            request_id: id_str,
513                            msg: format!("Missing or invalid venue error code: {msg}"),
514                        },
515                    };
516                    self.emit(rejection);
517                    return;
518                }
519
520                // Success response
521                match meta {
522                    BinanceSpotWsTradingRequestMeta::SessionLogon => {
523                        log::debug!("Session authenticated");
524                        self.emit(BinanceSpotWsTradingMessage::Authenticated);
525                    }
526                    BinanceSpotWsTradingRequestMeta::SubscribeUserData => {
527                        let subscription_id = json
528                            .get("result")
529                            .and_then(|r| r.get("subscriptionId"))
530                            .map(|v| v.to_string())
531                            .unwrap_or_default();
532                        log::debug!("User data stream subscribed: id={subscription_id}");
533                        self.emit(BinanceSpotWsTradingMessage::UserDataSubscribed {
534                            subscription_id,
535                        });
536                    }
537                    _ => {
538                        // Order operation responses come as SBE binary, not JSON text.
539                        // If we get a JSON success for an order operation, log it.
540                        log::debug!("Unexpected JSON success for request {id_str}");
541                    }
542                }
543                return;
544            }
545
546            // Error response without matching pending request
547            if let Some(code) = json.get("code").and_then(|v| v.as_i64()) {
548                let msg = json
549                    .get("msg")
550                    .and_then(|v| v.as_str())
551                    .unwrap_or("Unknown error");
552                log::warn!(
553                    "Received error response without matching request ID: code={code} msg={msg}"
554                );
555            }
556            return;
557        }
558
559        // Stream termination event
560        if json.get("eventStreamTerminated").is_some() {
561            log::warn!("User data stream terminated, resubscribe needed");
562            return;
563        }
564
565        log::debug!("Unhandled text message: {} bytes", text.len());
566    }
567
568    fn handle_user_data_event(&self, event: &serde_json::Value) {
569        if let Some(msg) = classify_user_data_event(event) {
570            self.emit(msg);
571        }
572    }
573
574    fn decode_ws_api_response(
575        &mut self,
576        data: &[u8],
577    ) -> Result<BinanceSpotWsTradingMessage, BinanceWsApiError> {
578        // Check template ID before parsing
579        if data.len() >= message_header_codec::ENCODED_LENGTH {
580            let buf = ReadBuf::new(data);
581            let template_id = buf.get_u16_at(2);
582
583            // User data stream events arrive as SBE with their own template IDs
584            // (not wrapped in WebSocketResponse template 50).
585            match template_id {
586                601 => {
587                    log::debug!("Received SBE BalanceUpdateEvent ({} bytes)", data.len());
588                    match super::decode_sbe::decode_balance_update(data) {
589                        Ok(msg) => {
590                            log::debug!(
591                                "SBE balance update: asset={}, delta={}",
592                                msg.asset,
593                                msg.delta
594                            );
595                            return Ok(BinanceSpotWsTradingMessage::BalanceUpdate(msg));
596                        }
597                        Err(e) => {
598                            log::error!("Failed to decode SBE BalanceUpdateEvent: {e}");
599                            return Ok(BinanceSpotWsTradingMessage::Error(format!(
600                                "SBE BalanceUpdateEvent decode failed: {e}"
601                            )));
602                        }
603                    }
604                }
605                603 => {
606                    log::debug!("Received SBE ExecutionReportEvent ({} bytes)", data.len());
607                    match super::decode_sbe::decode_execution_report(data) {
608                        Ok(report) => {
609                            log::debug!(
610                                "SBE execution report: symbol={}, order_id={}, exec={:?}, status={:?}",
611                                report.symbol,
612                                report.order_id,
613                                report.execution_type,
614                                report.order_status
615                            );
616                            return Ok(BinanceSpotWsTradingMessage::ExecutionReport(Box::new(
617                                report,
618                            )));
619                        }
620                        Err(e) => {
621                            log::error!("Failed to decode SBE ExecutionReportEvent: {e}");
622                            return Ok(BinanceSpotWsTradingMessage::Error(format!(
623                                "SBE ExecutionReportEvent decode failed: {e}"
624                            )));
625                        }
626                    }
627                }
628                606 => {
629                    return Ok(match super::decode_sbe::decode_list_status(data) {
630                        Ok(status) => BinanceSpotWsTradingMessage::ListStatus(status),
631                        Err(e) => BinanceSpotWsTradingMessage::Error(format!(
632                            "SBE ListStatusEvent decode failed: {e}"
633                        )),
634                    });
635                }
636                607 => {
637                    log::debug!(
638                        "Received SBE OutboundAccountPositionEvent ({} bytes)",
639                        data.len()
640                    );
641
642                    match super::decode_sbe::decode_account_position(data) {
643                        Ok(msg) => {
644                            log::debug!("SBE account position: {} balance(s)", msg.balances.len());
645                            return Ok(BinanceSpotWsTradingMessage::AccountPosition(msg));
646                        }
647                        Err(e) => {
648                            log::error!("Failed to decode SBE OutboundAccountPositionEvent: {e}");
649                            return Ok(BinanceSpotWsTradingMessage::Error(format!(
650                                "SBE OutboundAccountPositionEvent decode failed: {e}"
651                            )));
652                        }
653                    }
654                }
655                610 => {
656                    let event_time = parse_server_shutdown_event_time_ms(data);
657                    return Ok(BinanceSpotWsTradingMessage::ServerShutdown { event_time });
658                }
659                _ => {} // Fall through to WebSocketResponse parsing
660            }
661        }
662
663        // Standard WebSocketResponse envelope (template 50)
664        let (request_id, status, result_data) = self.parse_envelope(data)?;
665
666        // Look up the pending request by ID
667        let meta = self.pending_requests.remove(&request_id).ok_or_else(|| {
668            BinanceWsApiError::UnknownRequestId(format!("No pending request for ID: {request_id}"))
669        })?;
670
671        // Check for error status (non-200)
672        if status != 200 {
673            return Ok(match Self::try_decode_sbe_error(&result_data) {
674                Some((code, msg)) => self.create_rejection(request_id, status, code, msg, meta),
675                // An undecodable error payload carries no definitive command evidence
676                None => BinanceSpotWsTradingMessage::RequestFailed {
677                    request_id,
678                    msg: format!("Request failed with status {status}; error payload undecodable"),
679                },
680            });
681        }
682
683        // Decode the inner payload based on request type
684        match meta {
685            BinanceSpotWsTradingRequestMeta::PlaceOrder => {
686                let response = parse::decode_new_order_full(&result_data)?;
687                Ok(BinanceSpotWsTradingMessage::OrderAccepted {
688                    request_id,
689                    response,
690                })
691            }
692            BinanceSpotWsTradingRequestMeta::CancelOrder => {
693                let response = parse::decode_cancel_order(&result_data)?;
694                Ok(BinanceSpotWsTradingMessage::OrderCanceled {
695                    request_id,
696                    response,
697                })
698            }
699            BinanceSpotWsTradingRequestMeta::CancelReplaceOrder => {
700                let (cancel_response, new_order_response) =
701                    parse::decode_cancel_replace_orders(&result_data)?;
702                Ok(BinanceSpotWsTradingMessage::CancelReplaceAccepted {
703                    request_id,
704                    cancel_response,
705                    new_order_response,
706                })
707            }
708            BinanceSpotWsTradingRequestMeta::CancelAllOrders => {
709                let responses = parse::decode_cancel_open_orders(&result_data)?;
710                Ok(BinanceSpotWsTradingMessage::AllOrdersCanceled {
711                    request_id,
712                    responses,
713                })
714            }
715            BinanceSpotWsTradingRequestMeta::SessionLogon => {
716                log::debug!("Session authenticated (SBE response)");
717                Ok(BinanceSpotWsTradingMessage::Authenticated)
718            }
719            BinanceSpotWsTradingRequestMeta::SubscribeUserData => {
720                log::debug!("User data stream subscribed (SBE response)");
721                Ok(BinanceSpotWsTradingMessage::UserDataSubscribed {
722                    subscription_id: request_id,
723                })
724            }
725        }
726    }
727
728    /// Parses the WebSocketResponse SBE envelope.
729    ///
730    /// Returns (request_id, status, result_payload).
731    fn parse_envelope(&self, data: &[u8]) -> Result<(String, u16, Vec<u8>), BinanceWsApiError> {
732        if data.len() < message_header_codec::ENCODED_LENGTH {
733            return Err(BinanceWsApiError::DecodeError(
734                crate::spot::sbe::error::SbeDecodeError::BufferTooShort {
735                    expected: message_header_codec::ENCODED_LENGTH,
736                    actual: data.len(),
737                },
738            ));
739        }
740
741        let buf = ReadBuf::new(data);
742
743        // Parse message header
744        let block_length = buf.get_u16_at(0);
745        let template_id = buf.get_u16_at(2);
746
747        if template_id != SBE_TEMPLATE_ID {
748            return Err(BinanceWsApiError::DecodeError(
749                crate::spot::sbe::error::SbeDecodeError::UnknownTemplateId(template_id),
750            ));
751        }
752
753        let version = buf.get_u16_at(6);
754
755        // Create decoder at offset after message header
756        let decoder = WebSocketResponseDecoder::default().wrap(
757            buf,
758            message_header_codec::ENCODED_LENGTH,
759            block_length,
760            version,
761        );
762
763        // Read status from fixed block (offset 1 within block)
764        let status = decoder.status();
765
766        // Skip rate_limits group
767        let mut rate_limits = decoder.rate_limits_decoder();
768        while rate_limits.advance().unwrap_or(None).is_some() {}
769        let mut decoder = rate_limits.parent().map_err(|e| {
770            BinanceWsApiError::ClientError(format!("Failed to get parent from rate_limits: {e}"))
771        })?;
772
773        // Extract request ID
774        let id_coords = decoder.id_decoder();
775        let id_bytes = decoder.id_slice(id_coords);
776        let request_id = String::from_utf8_lossy(id_bytes).to_string();
777
778        // Extract result payload - copy to owned Vec to avoid lifetime issues
779        let result_coords = decoder.result_decoder();
780        let result_data = decoder.result_slice(result_coords).to_vec();
781
782        Ok((request_id, status, result_data))
783    }
784
785    fn create_rejection(
786        &self,
787        request_id: String,
788        status: u16,
789        code: i32,
790        msg: String,
791        meta: BinanceSpotWsTradingRequestMeta,
792    ) -> BinanceSpotWsTradingMessage {
793        match meta {
794            BinanceSpotWsTradingRequestMeta::PlaceOrder => {
795                BinanceSpotWsTradingMessage::OrderRejected {
796                    request_id,
797                    status,
798                    code,
799                    msg,
800                }
801            }
802            BinanceSpotWsTradingRequestMeta::CancelOrder => {
803                BinanceSpotWsTradingMessage::CancelRejected {
804                    request_id,
805                    status,
806                    code,
807                    msg,
808                }
809            }
810            BinanceSpotWsTradingRequestMeta::CancelReplaceOrder => {
811                BinanceSpotWsTradingMessage::CancelReplaceRejected {
812                    request_id,
813                    status,
814                    code,
815                    msg,
816                }
817            }
818            BinanceSpotWsTradingRequestMeta::CancelAllOrders => {
819                BinanceSpotWsTradingMessage::CancelRejected {
820                    request_id,
821                    status,
822                    code,
823                    msg,
824                }
825            }
826            BinanceSpotWsTradingRequestMeta::SessionLogon => {
827                BinanceSpotWsTradingMessage::AuthenticationRejected(format!("code={code}: {msg}"))
828            }
829            BinanceSpotWsTradingRequestMeta::SubscribeUserData => {
830                BinanceSpotWsTradingMessage::UserDataSubscriptionRejected(format!(
831                    "code={code}: {msg}"
832                ))
833            }
834        }
835    }
836
837    // Decodes the SBE error response to extract the Binance error code and message
838    fn try_decode_sbe_error(data: &[u8]) -> Option<(i32, String)> {
839        const HEADER_LEN: usize = 8;
840
841        if data.len()
842            < HEADER_LEN + crate::spot::sbe::spot::error_response_codec::SBE_BLOCK_LENGTH as usize
843        {
844            return None;
845        }
846
847        let buf = ReadBuf::new(data);
848        let header = message_header_codec::MessageHeaderDecoder::default().wrap(buf, 0);
849        if header.template_id() != crate::spot::sbe::spot::error_response_codec::SBE_TEMPLATE_ID {
850            return None;
851        }
852
853        let mut decoder = ErrorResponseDecoder::default().header(header, 0);
854        let code = i32::from(decoder.code());
855        let msg_coords = decoder.msg_decoder();
856        let msg_bytes = decoder.msg_slice(msg_coords);
857        let msg = String::from_utf8_lossy(msg_bytes).into_owned();
858
859        Some((code, msg))
860    }
861}
862
863/// Classifies a JSON user-data event into a trading message, if any.
864///
865/// Returns `None` when the event type is unknown or the payload fails to
866/// deserialize; in that case the caller logs and drops the event.
867pub(crate) fn classify_user_data_event(
868    event: &serde_json::Value,
869) -> Option<BinanceSpotWsTradingMessage> {
870    let event_type = event
871        .get("e")
872        .and_then(|v| serde_json::from_value::<BinanceSpotUserDataEventType>(v.clone()).ok())
873        .unwrap_or(BinanceSpotUserDataEventType::Unknown);
874
875    match event_type {
876        BinanceSpotUserDataEventType::ExecutionReport => {
877            match serde_json::from_value::<super::user_data::BinanceSpotExecutionReport>(
878                event.clone(),
879            ) {
880                Ok(report) => {
881                    log::debug!(
882                        "Execution report: symbol={}, order_id={}, exec={:?}, status={:?}",
883                        report.symbol,
884                        report.order_id,
885                        report.execution_type,
886                        report.order_status
887                    );
888                    Some(BinanceSpotWsTradingMessage::ExecutionReport(Box::new(
889                        report,
890                    )))
891                }
892                Err(e) => {
893                    log::warn!("Failed to parse execution report: {e}");
894                    None
895                }
896            }
897        }
898        BinanceSpotUserDataEventType::OutboundAccountPosition => {
899            match serde_json::from_value::<super::user_data::BinanceSpotAccountPositionMsg>(
900                event.clone(),
901            ) {
902                Ok(msg) => {
903                    log::debug!("Account position update: {} balance(s)", msg.balances.len());
904                    Some(BinanceSpotWsTradingMessage::AccountPosition(msg))
905                }
906                Err(e) => {
907                    log::warn!("Failed to parse account position: {e}");
908                    None
909                }
910            }
911        }
912        BinanceSpotUserDataEventType::BalanceUpdate => {
913            match serde_json::from_value::<super::user_data::BinanceSpotBalanceUpdateMsg>(
914                event.clone(),
915            ) {
916                Ok(msg) => {
917                    log::debug!("Balance update: asset={}, delta={}", msg.asset, msg.delta);
918                    Some(BinanceSpotWsTradingMessage::BalanceUpdate(msg))
919                }
920                Err(e) => {
921                    log::warn!("Failed to parse balance update: {e}");
922                    None
923                }
924            }
925        }
926        BinanceSpotUserDataEventType::ServerShutdown => {
927            let event_time = event.get("E").and_then(|v| v.as_i64()).unwrap_or_default();
928            Some(BinanceSpotWsTradingMessage::ServerShutdown { event_time })
929        }
930        BinanceSpotUserDataEventType::ListenKeyExpired
931        | BinanceSpotUserDataEventType::ExternalLockUpdate
932        | BinanceSpotUserDataEventType::EventStreamTerminated
933        | BinanceSpotUserDataEventType::Unknown => {
934            log::debug!("Unhandled user data event type: {event_type:?}");
935            None
936        }
937    }
938}
939
940/// Parses the `event_time` from an SBE `ServerShutdownEvent` (template 610) frame.
941///
942/// The SBE field is microseconds; the trading message variant documents
943/// milliseconds (matching the JSON dispatch), so this divides by 1_000.
944/// Returns `0` when the buffer is too short to contain the field.
945pub(crate) fn parse_server_shutdown_event_time_ms(data: &[u8]) -> i64 {
946    if data.len() < message_header_codec::ENCODED_LENGTH + 8 {
947        return 0;
948    }
949    let buf = ReadBuf::new(data);
950    buf.get_i64_at(message_header_codec::ENCODED_LENGTH) / 1_000
951}
952
953#[cfg(test)]
954mod tests {
955    use std::time::Duration;
956
957    use futures_util::StreamExt;
958    use nautilus_common::testing::wait_until_async;
959    use nautilus_network::{
960        error::SendError,
961        websocket::{AuthTracker, WebSocketConfig},
962    };
963    use rstest::rstest;
964
965    use super::*;
966    use crate::spot::sbe::spot::{
967        cancel_order_response_codec::CancelOrderResponseDecoder,
968        new_order_full_response_codec::NewOrderFullResponseDecoder,
969        self_trade_prevention_mode::SelfTradePreventionMode,
970    };
971
972    #[tokio::test]
973    async fn test_authentication_with_full_replay_buffer() {
974        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
975        let address = listener.local_addr().unwrap();
976        let (replayed_tx, replayed_rx) = tokio::sync::oneshot::channel();
977
978        let server = tokio::spawn(async move {
979            let (first, _) = listener.accept().await.unwrap();
980            let _first = tokio_tungstenite::accept_async(first).await.unwrap();
981            let (replacement, _) = listener.accept().await.unwrap();
982            let mut replacement = tokio_tungstenite::accept_async(replacement).await.unwrap();
983            let auth = replacement.next().await.unwrap().unwrap();
984            let auth: serde_json::Value = serde_json::from_str(auth.to_text().unwrap()).unwrap();
985            assert_eq!(auth["method"], method::SESSION_LOGON);
986            assert_eq!(auth["id"], "ws-1000");
987            assert_eq!(auth["params"]["apiKey"], "api-key");
988            let subscribe = replacement.next().await.unwrap().unwrap();
989            let subscribe: serde_json::Value =
990                serde_json::from_str(subscribe.to_text().unwrap()).unwrap();
991            assert_eq!(subscribe["method"], "userDataStream.subscribe");
992            assert_eq!(subscribe["id"], "ws-1001");
993            assert_eq!(subscribe["params"], serde_json::json!({}));
994            assert_eq!(
995                replacement.next().await.unwrap().unwrap(),
996                Message::text("held")
997            );
998            replayed_tx.send(()).unwrap();
999            std::future::pending::<()>().await;
1000        });
1001
1002        let tracker = AuthTracker::new();
1003        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1004        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1005        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1006
1007        let mut handler = BinanceSpotWsTradingHandler::new(
1008            Arc::new(AtomicBool::new(false)),
1009            cmd_rx,
1010            raw_rx,
1011            out_tx,
1012            Arc::new(SigningCredential::new(
1013                "api-key".to_string(),
1014                "secret".to_string(),
1015            )),
1016        );
1017        let config = WebSocketConfig::builder()
1018            .url(format!("ws://{address}"))
1019            .writer_capacity(1)
1020            .reconnect_delay_initial_ms(1)
1021            .reconnect_delay_max_ms(1)
1022            .reconnect_jitter_ms(0)
1023            .build()
1024            .unwrap();
1025        let client = WebSocketClient::builder()
1026            .config(config)
1027            .message_handler(Arc::new(|_| {}))
1028            .connect()
1029            .await
1030            .unwrap();
1031        client.set_auth_tracker(tracker.clone(), true);
1032
1033        // Enqueue and request reconnect without yielding so the writer retains this message
1034        client.send_text("held".to_string(), None).await.unwrap();
1035        assert!(client.request_reconnect());
1036        wait_until_async(
1037            || async { client.is_active() && client.connection_epoch() == 1 },
1038            Duration::from_secs(5),
1039        )
1040        .await;
1041        assert!(matches!(
1042            client.send_text("overflow".to_string(), None).await,
1043            Err(SendError::BufferFull)
1044        ));
1045        handler.inner = Some(client);
1046        tokio::time::timeout(Duration::from_secs(5), handler.handle_session_logon())
1047            .await
1048            .unwrap()
1049            .unwrap();
1050        tokio::time::timeout(Duration::from_secs(5), handler.handle_subscribe_user_data())
1051            .await
1052            .unwrap()
1053            .unwrap();
1054        tracker.succeed();
1055        tokio::time::timeout(Duration::from_secs(5), replayed_rx)
1056            .await
1057            .unwrap()
1058            .unwrap();
1059        handler.inner.as_ref().unwrap().disconnect().await;
1060        server.abort();
1061    }
1062
1063    #[rstest]
1064    #[case::valid(false)]
1065    #[case::malformed(true)]
1066    fn test_list_status_routes_typed_message_or_controlled_error(#[case] malformed: bool) {
1067        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1068        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1069        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1070
1071        let mut handler = BinanceSpotWsTradingHandler::new(
1072            Arc::new(AtomicBool::new(false)),
1073            cmd_rx,
1074            raw_rx,
1075            out_tx,
1076            Arc::new(SigningCredential::new(
1077                "api-key".to_owned(),
1078                "secret".to_owned(),
1079            )),
1080        );
1081        let mut data = crate::common::testing::load_fixture_bytes(
1082            "spot/user_data_sbe/list_status_executing.sbe",
1083        );
1084
1085        if malformed {
1086            data.pop();
1087        }
1088
1089        let message = handler.decode_ws_api_response(&data).unwrap();
1090        if malformed {
1091            let BinanceSpotWsTradingMessage::Error(reason) = message else {
1092                panic!("Expected controlled error");
1093            };
1094
1095            assert!(reason.starts_with("SBE ListStatusEvent decode failed:"));
1096        } else {
1097            let BinanceSpotWsTradingMessage::ListStatus(status) = message else {
1098                panic!("Expected typed list status");
1099            };
1100
1101            assert_eq!(
1102                status,
1103                super::super::decode_sbe::decode_list_status(&data).unwrap()
1104            );
1105        }
1106    }
1107
1108    #[rstest]
1109    fn test_cancel_replace_response_decodes_both_orders() {
1110        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1111        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1112        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1113        let mut handler = BinanceSpotWsTradingHandler::new(
1114            Arc::new(AtomicBool::new(false)),
1115            cmd_rx,
1116            raw_rx,
1117            out_tx,
1118            Arc::new(SigningCredential::new(
1119                "api-key".to_string(),
1120                "secret".to_string(),
1121            )),
1122        );
1123        let placed = include_bytes!(
1124            "../../../../test_data/spot/user_data_sbe/mainnet/web_socket_response_1.sbe"
1125        );
1126        let canceled = include_bytes!(
1127            "../../../../test_data/spot/user_data_sbe/mainnet/web_socket_response_2.sbe"
1128        );
1129        let (request_id, _, replacement) = handler.parse_envelope(placed).unwrap();
1130        let (_, _, cancellation) = handler.parse_envelope(canceled).unwrap();
1131        let expected_new = parse::decode_new_order_full(&replacement).unwrap();
1132        let expected_cancel = parse::decode_cancel_order(&cancellation).unwrap();
1133        let mut payload = Vec::new();
1134
1135        for field in [
1136            2_u16,
1137            crate::spot::sbe::spot::cancel_replace_order_response_codec::SBE_TEMPLATE_ID,
1138            crate::spot::sbe::spot::SBE_SCHEMA_ID,
1139            crate::spot::sbe::spot::SBE_SCHEMA_VERSION,
1140        ] {
1141            payload.extend_from_slice(&field.to_le_bytes());
1142        }
1143        let success =
1144            crate::spot::sbe::spot::cancel_replace_status::CancelReplaceStatus::Success as u8;
1145        payload.extend_from_slice(&[success, success]);
1146        payload.extend_from_slice(&(cancellation.len() as u16).to_le_bytes());
1147        payload.extend_from_slice(&cancellation);
1148        payload.extend_from_slice(&(replacement.len() as u32).to_le_bytes());
1149        payload.extend_from_slice(&replacement);
1150        let mut response = placed[..placed.len() - replacement.len() - 4].to_vec();
1151        response.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1152        response.extend_from_slice(&payload);
1153        handler.pending_requests.insert(
1154            request_id.clone(),
1155            BinanceSpotWsTradingRequestMeta::CancelReplaceOrder,
1156        );
1157
1158        let decoded = handler.decode_ws_api_response(&response).unwrap();
1159
1160        let BinanceSpotWsTradingMessage::CancelReplaceAccepted {
1161            request_id: actual_id,
1162            cancel_response,
1163            new_order_response,
1164        } = decoded
1165        else {
1166            panic!("Expected cancel-replace acceptance");
1167        };
1168        assert_eq!(actual_id, request_id);
1169        assert_eq!(cancel_response, expected_cancel);
1170        assert_eq!(new_order_response, expected_new);
1171        assert!(handler.pending_requests.is_empty());
1172    }
1173
1174    #[rstest]
1175    fn test_mainnet_order_responses_match_generated_decoders() {
1176        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1177        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1178        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1179        let handler = BinanceSpotWsTradingHandler::new(
1180            Arc::new(AtomicBool::new(false)),
1181            cmd_rx,
1182            raw_rx,
1183            out_tx,
1184            Arc::new(SigningCredential::new(
1185                "api-key".to_string(),
1186                "secret".to_string(),
1187            )),
1188        );
1189        let placed = include_bytes!(
1190            "../../../../test_data/spot/user_data_sbe/mainnet/web_socket_response_1.sbe"
1191        );
1192        let canceled = include_bytes!(
1193            "../../../../test_data/spot/user_data_sbe/mainnet/web_socket_response_2.sbe"
1194        );
1195        let (_, _, replacement) = handler.parse_envelope(placed).unwrap();
1196        let (_, _, cancellation) = handler.parse_envelope(canceled).unwrap();
1197        let placed_header = message_header_codec::MessageHeaderDecoder::default()
1198            .wrap(ReadBuf::new(&replacement), 0);
1199        let placed_decoder = NewOrderFullResponseDecoder::default().header(placed_header, 0);
1200        let canceled_header = message_header_codec::MessageHeaderDecoder::default()
1201            .wrap(ReadBuf::new(&cancellation), 0);
1202        let canceled_decoder = CancelOrderResponseDecoder::default().header(canceled_header, 0);
1203
1204        let new_order = parse::decode_new_order_full(&replacement).unwrap();
1205        let cancel = parse::decode_cancel_order(&cancellation).unwrap();
1206
1207        assert_eq!(
1208            new_order.self_trade_prevention_mode,
1209            placed_decoder.self_trade_prevention_mode()
1210        );
1211        assert_eq!(new_order.working_time, placed_decoder.working_time());
1212        assert_eq!(new_order.stop_price_mantissa, placed_decoder.stop_price());
1213        assert_eq!(
1214            cancel.self_trade_prevention_mode,
1215            canceled_decoder.self_trade_prevention_mode()
1216        );
1217        assert_eq!(
1218            cancel.self_trade_prevention_mode,
1219            SelfTradePreventionMode::ExpireMaker
1220        );
1221    }
1222
1223    #[rstest]
1224    #[case::missing(None)]
1225    #[case::overflow(Some(i64::MAX))]
1226    fn test_json_error_without_valid_code_is_ambiguous(#[case] code: Option<i64>) {
1227        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1228        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1229        let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel();
1230        let mut handler = BinanceSpotWsTradingHandler::new(
1231            Arc::new(AtomicBool::new(false)),
1232            cmd_rx,
1233            raw_rx,
1234            out_tx,
1235            Arc::new(SigningCredential::new(
1236                "test-key".to_string(),
1237                "test-secret".to_string(),
1238            )),
1239        );
1240        handler.pending_requests.insert(
1241            "order-1".to_string(),
1242            BinanceSpotWsTradingRequestMeta::PlaceOrder,
1243        );
1244        let response = serde_json::json!({"id": "order-1", "status": 400, "error": {"code": code, "msg": "invalid request"}});
1245
1246        handler.handle_text_response(&response.to_string());
1247
1248        let BinanceSpotWsTradingMessage::RequestFailed { request_id, msg } =
1249            out_rx.try_recv().unwrap()
1250        else {
1251            panic!("expected ambiguous request failure");
1252        };
1253        assert_eq!(request_id, "order-1");
1254        assert_eq!(msg, "Missing or invalid venue error code: invalid request");
1255        assert!(handler.pending_requests.is_empty());
1256        assert!(out_rx.try_recv().is_err());
1257    }
1258
1259    #[rstest]
1260    #[case::microseconds_converted_to_ms(1_700_000_000_000_000_i64, 1_700_000_000_000_i64)]
1261    #[case::zero(0_i64, 0_i64)]
1262    #[case::negative(-1_000_i64, -1_i64)]
1263    fn test_parse_server_shutdown_event_time_ms(
1264        #[case] event_time_us: i64,
1265        #[case] expected_ms: i64,
1266    ) {
1267        let mut buf = vec![0u8; message_header_codec::ENCODED_LENGTH];
1268        buf.extend_from_slice(&event_time_us.to_le_bytes());
1269        assert_eq!(parse_server_shutdown_event_time_ms(&buf), expected_ms);
1270    }
1271
1272    #[rstest]
1273    fn test_parse_server_shutdown_event_time_ms_short_buffer_returns_zero() {
1274        let buf = vec![0u8; message_header_codec::ENCODED_LENGTH + 4];
1275        assert_eq!(parse_server_shutdown_event_time_ms(&buf), 0);
1276    }
1277
1278    #[rstest]
1279    fn test_classify_user_data_event_server_shutdown_emits_variant() {
1280        let event = serde_json::json!({"e": "serverShutdown", "E": 1_700_000_000_000_i64});
1281        let msg = classify_user_data_event(&event).expect("expected ServerShutdown");
1282        match msg {
1283            BinanceSpotWsTradingMessage::ServerShutdown { event_time } => {
1284                assert_eq!(event_time, 1_700_000_000_000);
1285            }
1286            other => panic!("expected ServerShutdown variant, was {other:?}"),
1287        }
1288    }
1289
1290    #[rstest]
1291    fn test_classify_user_data_event_server_shutdown_missing_event_time_defaults_to_zero() {
1292        let event = serde_json::json!({"e": "serverShutdown"});
1293        let msg = classify_user_data_event(&event).expect("expected ServerShutdown");
1294        match msg {
1295            BinanceSpotWsTradingMessage::ServerShutdown { event_time } => {
1296                assert_eq!(event_time, 0);
1297            }
1298            other => panic!("expected ServerShutdown variant, was {other:?}"),
1299        }
1300    }
1301
1302    #[rstest]
1303    fn test_classify_user_data_event_unknown_returns_none() {
1304        let event = serde_json::json!({"e": "somethingElse"});
1305        assert!(classify_user_data_event(&event).is_none());
1306    }
1307
1308    #[rstest]
1309    fn test_sign_params_includes_recv_window_in_signature() {
1310        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1311        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1312        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1313        let credential = Arc::new(SigningCredential::new(
1314            "api-key".to_string(),
1315            "hmac-secret".to_string(),
1316        ));
1317        let handler = BinanceSpotWsTradingHandler::new(
1318            Arc::new(AtomicBool::new(false)),
1319            cmd_rx,
1320            raw_rx,
1321            out_tx,
1322            credential.clone(),
1323        )
1324        .with_recv_window(Some(45_000));
1325
1326        let signed = handler
1327            .sign_params(serde_json::json!({"symbol": "BTCUSDT"}))
1328            .unwrap();
1329        let mut unsigned = signed.clone();
1330        let signature = unsigned
1331            .as_object_mut()
1332            .unwrap()
1333            .remove("signature")
1334            .unwrap();
1335        let query = canonical_ws_query_string(
1336            unsigned
1337                .as_object()
1338                .unwrap()
1339                .iter()
1340                .map(|(key, value)| (key.as_str(), value)),
1341        )
1342        .unwrap();
1343
1344        assert_eq!(signed["recvWindow"], 45_000);
1345        assert_eq!(signature, credential.sign(&query));
1346    }
1347}