Skip to main content

nautilus_binance/spot/
data.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//! Live market data client implementation for the Binance Spot adapter.
17
18use std::{
19    str::FromStr,
20    sync::{
21        Arc,
22        atomic::{AtomicBool, Ordering},
23    },
24    time::Duration,
25};
26
27use ahash::AHashMap;
28use anyhow::Context;
29use futures_util::{StreamExt, pin_mut};
30use nautilus_common::{
31    clients::DataClient,
32    live::runner::get_data_event_sender,
33    messages::{
34        DataEvent,
35        data::{
36            BarsResponse, BookResponse, CustomDataResponse, DataResponse, InstrumentResponse,
37            InstrumentsResponse, RequestBars, RequestBookSnapshot, RequestCustomData,
38            RequestInstrument, RequestInstruments, RequestTrades, SubscribeBars,
39            SubscribeBookDeltas, SubscribeCustomData, SubscribeInstrument, SubscribeInstruments,
40            SubscribeQuotes, SubscribeTrades, TradesResponse, UnsubscribeBars,
41            UnsubscribeBookDeltas, UnsubscribeCustomData, UnsubscribeQuotes, UnsubscribeTrades,
42            subscribe::SubscribeInstrumentStatus, unsubscribe::UnsubscribeInstrumentStatus,
43        },
44    },
45};
46use nautilus_core::{
47    AtomicMap, Params,
48    datetime::datetime_to_unix_nanos,
49    nanos::UnixNanos,
50    time::{AtomicTime, get_atomic_clock_realtime},
51};
52use nautilus_live::{
53    SocketControlFactory,
54    task::{TaskGroup, TaskGroupGuard, TaskSpawner},
55};
56use nautilus_model::{
57    data::{BookOrder, CustomData, Data, DataType, OrderBookDelta, OrderBookDeltas, QuoteTick},
58    enums::{
59        AggregationSource, BookAction, BookType, MarketStatusAction, OrderSide, PriceType,
60        RecordFlag,
61    },
62    identifiers::{ClientId, InstrumentId, Venue},
63    instruments::{Instrument, InstrumentAny},
64    types::{Price, Quantity},
65};
66use parking_lot::RwLock;
67use tokio_util::sync::CancellationToken;
68use ustr::Ustr;
69
70use crate::{
71    common::{
72        bar::{binance_bar_data_type, parse_binance_bar_type},
73        consts::{BINANCE_VENUE, BINANCE_WS_HEARTBEAT_SECS},
74        credential::resolve_credentials,
75        enums::{BinanceEnvironment, BinanceProductType},
76        parse::{bar_spec_to_binance_interval, quote_to_l1_deltas},
77        status::diff_and_emit_statuses,
78        urls::{get_http_base_url_with_us, get_ws_base_url_with_us},
79    },
80    config::{BinanceDataClientConfig, BinanceSpotMarketDataMode},
81    data_types::register_binance_custom_data,
82    spot::{
83        http::{BinanceDepth, DepthParams, client::BinanceSpotHttpClient},
84        websocket::{
85            public_json::{
86                BinanceSpotPublicJsonWebSocketClient,
87                messages::BinanceSpotPublicWsMessage,
88                parse::{
89                    parse_book_ticker as parse_json_book_ticker,
90                    parse_depth_diff as parse_json_depth_diff,
91                    parse_depth_snapshot as parse_json_depth_snapshot,
92                    parse_kline as parse_json_kline, parse_ticker as parse_json_ticker,
93                    parse_trade as parse_json_trade,
94                },
95            },
96            streams::{
97                client::BinanceSpotWebSocketClient,
98                messages::BinanceSpotWsMessage,
99                parse::{
100                    parse_bbo_event, parse_depth_diff, parse_depth_snapshot, parse_trades_event,
101                },
102            },
103        },
104    },
105};
106
107const MAX_SNAPSHOT_RETRIES: u32 = 5;
108const MAX_BUFFERED_DEPTH_UPDATES: usize = 10_000;
109const SNAPSHOT_RETRY_BACKOFF_BASE_MS: u64 = 250;
110const SNAPSHOT_RETRY_BACKOFF_CAP_MS: u64 = 3_000;
111
112#[derive(Debug, Clone)]
113struct BufferedDepthUpdate {
114    deltas: OrderBookDeltas,
115    first_update_id: u64,
116    final_update_id: u64,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120enum BookSyncStatus {
121    Buffering,
122    Failed,
123}
124
125#[derive(Debug, Clone)]
126struct BookBuffer {
127    updates: Vec<BufferedDepthUpdate>,
128    epoch: u64,
129    status: BookSyncStatus,
130}
131
132impl BookBuffer {
133    fn new(epoch: u64) -> Self {
134        Self {
135            updates: Vec::new(),
136            epoch,
137            status: BookSyncStatus::Buffering,
138        }
139    }
140}
141
142#[derive(Debug, Clone)]
143enum SpotWsClient {
144    Sbe(BinanceSpotWebSocketClient),
145    JsonPublic(BinanceSpotPublicJsonWebSocketClient),
146}
147
148impl SpotWsClient {
149    fn has_credentials(&self) -> bool {
150        match self {
151            Self::Sbe(client) => client.has_credentials(),
152            Self::JsonPublic(_) => true, // Public JSON streams require no credentials
153        }
154    }
155
156    fn replace_instruments(&self, instruments: &[InstrumentAny]) {
157        match self {
158            Self::Sbe(client) => client.replace_instruments(instruments),
159            Self::JsonPublic(client) => client.replace_instruments(instruments),
160        }
161    }
162
163    async fn subscribe(&self, streams: Vec<String>) -> anyhow::Result<()> {
164        match self {
165            Self::Sbe(client) => client.subscribe(streams).await.map_err(Into::into),
166            Self::JsonPublic(client) => client.subscribe(streams).await,
167        }
168    }
169
170    async fn unsubscribe(&self, streams: Vec<String>) -> anyhow::Result<()> {
171        match self {
172            Self::Sbe(client) => client.unsubscribe(streams).await.map_err(Into::into),
173            Self::JsonPublic(client) => client.unsubscribe(streams).await,
174        }
175    }
176
177    async fn close(&mut self) -> anyhow::Result<()> {
178        match self {
179            Self::Sbe(client) => client.close().await.map_err(Into::into),
180            Self::JsonPublic(client) => client.close().await,
181        }
182    }
183
184    fn begin_shutdown(&self) {
185        match self {
186            Self::Sbe(client) => client.begin_shutdown(),
187            Self::JsonPublic(client) => client.begin_shutdown(),
188        }
189    }
190}
191
192fn looks_like_spot_sbe_ws_url(base_url: &str) -> bool {
193    let without_scheme = base_url
194        .split_once("://")
195        .map_or(base_url, |(_, rest)| rest);
196    let host = without_scheme
197        .split(['/', ':'])
198        .next()
199        .unwrap_or(without_scheme);
200    host.starts_with("stream-sbe") || host.starts_with("demo-stream-sbe")
201}
202
203fn resolve_spot_json_ws_url(
204    base_url_ws: Option<String>,
205    environment: BinanceEnvironment,
206    us: bool,
207) -> String {
208    let default_url =
209        get_ws_base_url_with_us(BinanceProductType::Spot, environment, us).to_string();
210
211    match base_url_ws {
212        Some(url) if looks_like_spot_sbe_ws_url(&url) => {
213            log::warn!(
214                "Spot JSON market-data mode received an SBE WebSocket URL override (`{url}`); \
215                 using Spot JSON WebSocket default for {environment:?}: {default_url}",
216            );
217            default_url
218        }
219        Some(url) => url,
220        None => default_url,
221    }
222}
223
224/// Binance Spot data client for SBE market data.
225#[derive(Debug)]
226pub struct BinanceSpotDataClient {
227    clock: &'static AtomicTime,
228    client_id: ClientId,
229    config: BinanceDataClientConfig,
230    http_client: BinanceSpotHttpClient,
231    ws_client: SpotWsClient,
232    spot_market_data_mode: BinanceSpotMarketDataMode,
233    is_connected: AtomicBool,
234    cancellation_token: CancellationToken,
235    session_tasks: TaskGroup,
236    command_tasks: TaskGroup,
237    shutdown_errors: Vec<String>,
238    data_sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
239    instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
240    status_cache: Arc<AtomicMap<InstrumentId, MarketStatusAction>>,
241    book_buffers: Arc<AtomicMap<InstrumentId, BookBuffer>>,
242    book_subscriptions: Arc<AtomicMap<InstrumentId, u32>>,
243    l1_book_subscriptions: Arc<AtomicMap<InstrumentId, u32>>,
244    quote_refs: Arc<AtomicMap<InstrumentId, u32>>,
245    ticker_refs: Arc<AtomicMap<InstrumentId, u32>>,
246    book_epoch: Arc<RwLock<u64>>,
247}
248
249impl BinanceSpotDataClient {
250    /// Creates a new [`BinanceSpotDataClient`] instance.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if the client fails to initialize.
255    pub fn new(client_id: ClientId, config: BinanceDataClientConfig) -> anyhow::Result<Self> {
256        config.validate()?;
257        let clock = get_atomic_clock_realtime();
258        let spot_market_data_mode = config.spot_market_data_mode;
259        let base_url_http = config.base_url_http.clone().or_else(|| {
260            config.us.then(|| {
261                get_http_base_url_with_us(config.product_type, config.environment, true).to_string()
262            })
263        });
264
265        let http_client = BinanceSpotHttpClient::new_with_json_responses(
266            config.environment,
267            clock,
268            config.api_key.clone(),
269            config.api_secret.clone(),
270            base_url_http,
271            Some(config.recv_window_ms),
272            None, // timeout_secs
273            config.proxy_url.clone(),
274            config.us,
275        )?;
276
277        let creds = if spot_market_data_mode == BinanceSpotMarketDataMode::Sbe {
278            resolve_credentials(
279                config.api_key.clone(),
280                config.api_secret.clone(),
281                config.environment,
282                config.product_type,
283            )
284            .inspect_err(|e| {
285                log::warn!(
286                    "Failed to resolve Binance API credentials ({e}). \
287                     Spot SBE WebSocket streams require an Ed25519 API key. \
288                     Set the appropriate env vars for your environment, \
289                     or provide api_key/api_secret in the data client config"
290                );
291            })
292            .ok()
293        } else {
294            None
295        };
296
297        let socket_factory = SocketControlFactory::new(client_id, Some(*BINANCE_VENUE));
298        let ws_client = match spot_market_data_mode {
299            // SBE streams require Ed25519 authentication
300            BinanceSpotMarketDataMode::Sbe => SpotWsClient::Sbe(
301                BinanceSpotWebSocketClient::new(
302                    config.base_url_ws.clone(),
303                    creds.as_ref().map(|(k, _)| k.clone()),
304                    creds.as_ref().map(|(_, s)| s.clone()),
305                    Some(BINANCE_WS_HEARTBEAT_SECS),
306                    config.transport_backend,
307                )?
308                .with_proxy(config.proxy_url.clone())
309                .with_socket_control(socket_factory, "binance-spot-sbe-data-streams"),
310            ),
311            BinanceSpotMarketDataMode::Json => SpotWsClient::JsonPublic(
312                BinanceSpotPublicJsonWebSocketClient::new(
313                    Some(resolve_spot_json_ws_url(
314                        config.base_url_ws.clone(),
315                        config.environment,
316                        config.us,
317                    )),
318                    Some(BINANCE_WS_HEARTBEAT_SECS),
319                    config.transport_backend,
320                )
321                .with_proxy(config.proxy_url.clone())
322                .with_socket_control(socket_factory, "binance-spot-json-data-streams"),
323            ),
324        };
325        let data_sender = get_data_event_sender();
326
327        log::debug!("Configured Spot market data mode: {spot_market_data_mode:?}");
328
329        let session_tasks = TaskGroup::new();
330        let command_tasks = TaskGroup::new();
331
332        Ok(Self {
333            clock,
334            client_id,
335            config,
336            http_client,
337            ws_client,
338            spot_market_data_mode,
339            is_connected: AtomicBool::new(false),
340            cancellation_token: session_tasks.cancellation_token(),
341            session_tasks,
342            command_tasks,
343            shutdown_errors: Vec::new(),
344            data_sender,
345            instruments: Arc::new(AtomicMap::new()),
346            status_cache: Arc::new(AtomicMap::new()),
347            book_buffers: Arc::new(AtomicMap::new()),
348            book_subscriptions: Arc::new(AtomicMap::new()),
349            l1_book_subscriptions: Arc::new(AtomicMap::new()),
350            quote_refs: Arc::new(AtomicMap::new()),
351            ticker_refs: Arc::new(AtomicMap::new()),
352            book_epoch: Arc::new(RwLock::new(0)),
353        })
354    }
355
356    fn venue(&self) -> Venue {
357        *BINANCE_VENUE
358    }
359
360    fn send_data(sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>, data: Data) {
361        if let Err(e) = sender.send(DataEvent::Data(data)) {
362            log::error!("Failed to emit data event: {e}");
363        }
364    }
365
366    fn spawn_ws<F>(&self, fut: F, context: &'static str)
367    where
368        F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
369    {
370        let future = async move {
371            if let Err(e) = fut.await {
372                log::error!("{context}: {e:?}");
373            }
374        };
375
376        if let Err(e) = self.command_tasks.spawn(future) {
377            log::warn!("Skipping Binance Spot {context} after shutdown began: {e}");
378        }
379    }
380
381    fn spawn_command<F>(&self, future: F)
382    where
383        F: std::future::Future<Output = ()> + Send + 'static,
384    {
385        if let Err(e) = self.command_tasks.spawn(future) {
386            log::warn!("Skipping Binance Spot data command after shutdown began: {e}");
387        }
388    }
389
390    async fn finish_tasks(&self) -> anyhow::Result<()> {
391        let (session_result, command_result) = tokio::join!(
392            self.session_tasks
393                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
394            self.command_tasks
395                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
396        );
397        let mut errors = Vec::new();
398        if let Err(e) = session_result {
399            errors.push(format!(
400                "failed to finish Binance Spot data session tasks: {e}"
401            ));
402        }
403
404        if let Err(e) = command_result {
405            errors.push(format!(
406                "failed to finish Binance Spot data command tasks: {e}"
407            ));
408        }
409
410        if !errors.is_empty() {
411            anyhow::bail!(errors.join("; "));
412        }
413        Ok(())
414    }
415
416    async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
417        if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
418            self.teardown_partial_connect().await?;
419            self.session_tasks
420                .start_generation()
421                .context("failed to start Binance Spot data session task generation")?;
422            self.command_tasks
423                .start_generation()
424                .context("failed to start Binance Spot data command task generation")?;
425            self.cancellation_token = self.session_tasks.cancellation_token();
426        }
427        Ok(())
428    }
429
430    async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
431        self.session_tasks.begin_shutdown();
432        self.command_tasks.begin_shutdown();
433        self.ws_client.begin_shutdown();
434        if let Err(e) = self.ws_client.close().await {
435            self.shutdown_errors
436                .push(format!("WebSocket close failed: {e}"));
437        }
438
439        if let Err(e) = self.finish_tasks().await {
440            self.shutdown_errors.push(e.to_string());
441        }
442        self.is_connected.store(false, Ordering::Release);
443
444        if !self.shutdown_errors.is_empty() {
445            let errors = std::mem::take(&mut self.shutdown_errors);
446            anyhow::bail!("Binance Spot data teardown failed: {}", errors.join("; "));
447        }
448        Ok(())
449    }
450
451    #[expect(clippy::too_many_arguments)]
452    async fn refresh_instrument_catalogue(
453        http: &BinanceSpotHttpClient,
454        provider: &crate::config::BinanceInstrumentProviderConfig,
455        us: bool,
456        instruments_cache: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
457        status_cache: &Arc<AtomicMap<InstrumentId, MarketStatusAction>>,
458        ws: &SpotWsClient,
459        sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
460        clock: &'static AtomicTime,
461        emit_status_changes: bool,
462    ) -> anyhow::Result<Vec<InstrumentAny>> {
463        let instruments = http
464            .request_instruments_with_config(provider, us)
465            .await
466            .context("failed to request Binance Spot instruments")?;
467        let venue_statuses = http
468            .request_symbol_statuses(us)
469            .await
470            .context("failed to request Binance Spot instrument statuses")?;
471
472        let instrument_map = instruments
473            .iter()
474            .map(|instrument| (instrument.id(), instrument.clone()))
475            .collect::<AHashMap<_, _>>();
476        let status_map = venue_statuses
477            .into_iter()
478            .filter(|(instrument_id, _)| instrument_map.contains_key(instrument_id))
479            .collect::<AHashMap<_, _>>();
480
481        instruments_cache.store(instrument_map);
482        ws.replace_instruments(&instruments);
483
484        if emit_status_changes {
485            let mut cached_statuses = (**status_cache.load()).clone();
486            let ts = clock.get_time_ns();
487            diff_and_emit_statuses(&status_map, &mut cached_statuses, sender, ts, ts);
488            status_cache.store(cached_statuses);
489        } else {
490            status_cache.store(status_map);
491        }
492
493        for instrument in &instruments {
494            if let Err(e) = sender.send(DataEvent::Instrument(instrument.clone())) {
495                log::warn!("Failed to send refreshed Binance Spot instrument: {e}");
496            }
497        }
498
499        Ok(instruments)
500    }
501
502    #[expect(clippy::too_many_arguments)]
503    fn handle_ws_message(
504        msg: BinanceSpotWsMessage,
505        data_sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
506        instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
507        ws_instruments: &Arc<AtomicMap<Ustr, InstrumentAny>>,
508        book_buffers: &Arc<AtomicMap<InstrumentId, BookBuffer>>,
509        book_subscriptions: &Arc<AtomicMap<InstrumentId, u32>>,
510        l1_book_subscriptions: &Arc<AtomicMap<InstrumentId, u32>>,
511        book_epoch: &Arc<RwLock<u64>>,
512        http_client: &BinanceSpotHttpClient,
513        clock: &'static AtomicTime,
514        command_spawner: &TaskSpawner,
515    ) {
516        let ts_init = clock.get_time_ns();
517
518        match msg {
519            BinanceSpotWsMessage::Trades(ref event) => {
520                let symbol = event.symbol;
521                let cache = ws_instruments.load();
522                if let Some(instrument) = cache.get(&symbol) {
523                    let trades = parse_trades_event(event, instrument, ts_init);
524                    for data in trades {
525                        Self::send_data(data_sender, data);
526                    }
527                }
528            }
529            BinanceSpotWsMessage::BestBidAsk(ref event) => {
530                let symbol = event.symbol;
531                let cache = ws_instruments.load();
532                if let Some(instrument) = cache.get(&symbol) {
533                    let quote = parse_bbo_event(event, instrument, ts_init);
534                    Self::send_top_of_book(
535                        data_sender,
536                        l1_book_subscriptions,
537                        quote,
538                        event.book_update_id as u64,
539                    );
540                }
541            }
542            BinanceSpotWsMessage::DepthSnapshot(ref event) => {
543                let symbol = event.symbol;
544                let cache = ws_instruments.load();
545                if let Some(instrument) = cache.get(&symbol)
546                    && let Some(deltas) = parse_depth_snapshot(event, instrument, ts_init)
547                {
548                    Self::send_data(data_sender, Data::Deltas(Box::new(deltas)));
549                }
550            }
551            BinanceSpotWsMessage::DepthDiff(ref event) => {
552                let symbol = event.symbol;
553                let cache = ws_instruments.load();
554                if let Some(instrument) = cache.get(&symbol)
555                    && let Some(deltas) = parse_depth_diff(event, instrument, ts_init)
556                {
557                    let first_update_id = event.first_book_update_id as u64;
558                    let final_update_id = event.last_book_update_id as u64;
559
560                    Self::route_depth_diff(
561                        data_sender,
562                        book_buffers,
563                        deltas,
564                        first_update_id,
565                        final_update_id,
566                    );
567                }
568            }
569            BinanceSpotWsMessage::ServerShutdown(ref msg) => {
570                log::warn!(
571                    "Binance server shutdown notice (event_time={}); disconnect expected within ~10 minutes",
572                    msg.event_time,
573                );
574            }
575            BinanceSpotWsMessage::RawBinary(data) => {
576                log::debug!("Unhandled binary message: {} bytes", data.len());
577            }
578            BinanceSpotWsMessage::RawJson(value) => {
579                log::debug!("Unhandled JSON message: {value:?}");
580            }
581            BinanceSpotWsMessage::Error(e) => {
582                log::warn!("Binance WebSocket error: code={}, msg={}", e.code, e.msg);
583            }
584            BinanceSpotWsMessage::Reconnected => {
585                log::info!("WebSocket reconnected, rebuilding order book snapshots");
586
587                Self::rebuild_full_depth_books(
588                    data_sender,
589                    instruments,
590                    book_buffers,
591                    book_subscriptions,
592                    book_epoch,
593                    http_client,
594                    clock,
595                    command_spawner,
596                );
597            }
598        }
599    }
600
601    #[expect(clippy::too_many_arguments)]
602    fn handle_public_json_ws_message(
603        msg: BinanceSpotPublicWsMessage,
604        data_sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
605        instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
606        ws_instruments: &Arc<AtomicMap<Ustr, InstrumentAny>>,
607        book_buffers: &Arc<AtomicMap<InstrumentId, BookBuffer>>,
608        book_subscriptions: &Arc<AtomicMap<InstrumentId, u32>>,
609        l1_book_subscriptions: &Arc<AtomicMap<InstrumentId, u32>>,
610        book_epoch: &Arc<RwLock<u64>>,
611        http_client: &BinanceSpotHttpClient,
612        clock: &'static AtomicTime,
613        command_spawner: &TaskSpawner,
614    ) {
615        let ts_init = clock.get_time_ns();
616
617        match msg {
618            BinanceSpotPublicWsMessage::Trade(ref event) => {
619                let symbol = event.symbol;
620                let cache = ws_instruments.load();
621                if let Some(instrument) = cache.get(&symbol) {
622                    match parse_json_trade(event, instrument, ts_init) {
623                        Ok(trade) => Self::send_data(data_sender, Data::Trade(trade)),
624                        Err(e) => log::warn!("Failed to parse Spot JSON trade: {e}"),
625                    }
626                }
627            }
628            BinanceSpotPublicWsMessage::BookTicker(ref event) => {
629                let symbol = event.symbol;
630                let cache = ws_instruments.load();
631                if let Some(instrument) = cache.get(&symbol) {
632                    match parse_json_book_ticker(event, instrument, ts_init) {
633                        Ok(quote) => Self::send_top_of_book(
634                            data_sender,
635                            l1_book_subscriptions,
636                            quote,
637                            event.book_update_id,
638                        ),
639                        Err(e) => log::warn!("Failed to parse Spot JSON book ticker: {e}"),
640                    }
641                }
642            }
643            BinanceSpotPublicWsMessage::DepthSnapshot(ref event) => {
644                let symbol = event.symbol;
645                let cache = ws_instruments.load();
646                if let Some(instrument) = cache.get(&symbol)
647                    && let Some(deltas) = parse_json_depth_snapshot(event, instrument, ts_init)
648                {
649                    Self::send_data(data_sender, Data::Deltas(Box::new(deltas)));
650                }
651            }
652            BinanceSpotPublicWsMessage::DepthDiff(ref event) => {
653                let symbol = event.symbol;
654                let cache = ws_instruments.load();
655                if let Some(instrument) = cache.get(&symbol) {
656                    match parse_json_depth_diff(event, instrument, ts_init) {
657                        Ok(Some(deltas)) => Self::route_depth_diff(
658                            data_sender,
659                            book_buffers,
660                            deltas,
661                            event.first_update_id,
662                            event.final_update_id,
663                        ),
664                        Ok(None) => {}
665                        Err(e) => log::warn!("Failed to parse Spot JSON depth update: {e}"),
666                    }
667                }
668            }
669            BinanceSpotPublicWsMessage::Kline(ref event) => {
670                let symbol = event.symbol;
671                let cache = ws_instruments.load();
672                if let Some(instrument) = cache.get(&symbol) {
673                    match parse_json_kline(event, instrument, ts_init) {
674                        Ok(Some(bar)) => {
675                            Self::send_data(data_sender, Data::Bar(bar.bar()));
676                            let data_type = binance_bar_data_type(bar.bar_type);
677                            Self::send_data(
678                                data_sender,
679                                Data::Custom(CustomData::new(Arc::new(bar), data_type)),
680                            );
681                        }
682                        Ok(None) => {} // Kline not closed yet
683                        Err(e) => log::warn!("Failed to parse Spot JSON kline: {e}"),
684                    }
685                }
686            }
687            BinanceSpotPublicWsMessage::Ticker(ref event) => {
688                let symbol = event.symbol;
689                let cache = ws_instruments.load();
690                if let Some(instrument) = cache.get(&symbol) {
691                    match parse_json_ticker(event, instrument, ts_init) {
692                        Ok(ticker) => {
693                            let data_type = spot_ticker_data_type(instrument.id());
694                            Self::send_data(
695                                data_sender,
696                                Data::Custom(CustomData::new(Arc::new(ticker), data_type)),
697                            );
698                        }
699                        Err(e) => log::warn!("Failed to parse Spot JSON ticker: {e}"),
700                    }
701                }
702            }
703            BinanceSpotPublicWsMessage::ServerShutdown(ref msg) => {
704                log::warn!(
705                    "Binance Spot JSON server shutdown notice (event_time={}); disconnect expected within ~10 minutes",
706                    msg.event_time,
707                );
708            }
709            BinanceSpotPublicWsMessage::RawJson(value) => {
710                log::debug!("Unhandled Spot JSON message: {value:?}");
711            }
712            BinanceSpotPublicWsMessage::Error(e) => {
713                log::warn!("Spot JSON WebSocket error: code={}, msg={}", e.code, e.msg);
714            }
715            BinanceSpotPublicWsMessage::Reconnected => {
716                log::info!("Spot JSON WebSocket reconnected, rebuilding order book snapshots");
717
718                Self::rebuild_full_depth_books(
719                    data_sender,
720                    instruments,
721                    book_buffers,
722                    book_subscriptions,
723                    book_epoch,
724                    http_client,
725                    clock,
726                    command_spawner,
727                );
728            }
729        }
730    }
731
732    fn send_top_of_book(
733        data_sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
734        l1_book_subscriptions: &Arc<AtomicMap<InstrumentId, u32>>,
735        quote: QuoteTick,
736        sequence: u64,
737    ) {
738        Self::send_data(data_sender, Data::Quote(quote));
739        if l1_book_subscriptions.contains_key(&quote.instrument_id) {
740            let deltas = quote_to_l1_deltas(quote, sequence);
741            Self::send_data(data_sender, Data::Deltas(Box::new(deltas)));
742        }
743    }
744
745    fn route_depth_diff(
746        data_sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
747        book_buffers: &Arc<AtomicMap<InstrumentId, BookBuffer>>,
748        deltas: OrderBookDeltas,
749        first_update_id: u64,
750        final_update_id: u64,
751    ) {
752        let instrument_id = deltas.instrument_id;
753
754        if book_buffers.contains_key(&instrument_id) {
755            let mut handled_by_sync = false;
756            book_buffers.rcu(|m| {
757                handled_by_sync = false;
758
759                if let Some(buffer) = m.get_mut(&instrument_id) {
760                    handled_by_sync = true;
761
762                    if buffer.status == BookSyncStatus::Buffering {
763                        buffer.updates.push(BufferedDepthUpdate {
764                            deltas: deltas.clone(),
765                            first_update_id,
766                            final_update_id,
767                        });
768                        trim_buffered_depth_updates(&mut buffer.updates);
769                    }
770                }
771            });
772
773            if handled_by_sync {
774                return;
775            }
776        }
777
778        Self::send_data(data_sender, Data::Deltas(Box::new(deltas)));
779    }
780
781    #[expect(
782        clippy::too_many_arguments,
783        reason = "book recovery requires the full subscription and command ownership context"
784    )]
785    fn rebuild_full_depth_books(
786        data_sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
787        instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
788        book_buffers: &Arc<AtomicMap<InstrumentId, BookBuffer>>,
789        book_subscriptions: &Arc<AtomicMap<InstrumentId, u32>>,
790        book_epoch: &Arc<RwLock<u64>>,
791        http_client: &BinanceSpotHttpClient,
792        clock: &'static AtomicTime,
793        command_spawner: &TaskSpawner,
794    ) {
795        let epoch = {
796            let mut guard = book_epoch.write();
797            *guard = guard.wrapping_add(1);
798            *guard
799        };
800
801        let subs: Vec<(InstrumentId, u32)> = {
802            let guard = book_subscriptions.load();
803            guard.iter().map(|(k, v)| (*k, *v)).collect()
804        };
805
806        for (instrument_id, depth) in subs {
807            if depth != 0 {
808                continue;
809            }
810
811            book_buffers.insert(instrument_id, BookBuffer::new(epoch));
812
813            log::debug!(
814                "OrderBook snapshot rebuild for {instrument_id} starting \
815                (reconnect, epoch={epoch})"
816            );
817
818            let http = http_client.clone();
819            let sender = data_sender.clone();
820            let buffers = book_buffers.clone();
821            let insts = instruments.clone();
822
823            if let Err(e) = command_spawner.spawn(async move {
824                Self::fetch_and_emit_snapshot(
825                    http,
826                    sender,
827                    buffers,
828                    insts,
829                    instrument_id,
830                    epoch,
831                    clock,
832                )
833                .await;
834            }) {
835                log::warn!("Skipping Binance Spot snapshot rebuild after shutdown began: {e}");
836            }
837        }
838    }
839
840    fn quote_stream_suffix(&self) -> &'static str {
841        match self.spot_market_data_mode {
842            BinanceSpotMarketDataMode::Sbe => "bestBidAsk",
843            BinanceSpotMarketDataMode::Json => "bookTicker",
844        }
845    }
846
847    fn required_instrument_id_metadata(data_type: &DataType) -> anyhow::Result<InstrumentId> {
848        let raw = data_type
849            .metadata()
850            .as_ref()
851            .and_then(|metadata| metadata.get("instrument_id"))
852            .and_then(|value| value.as_str())
853            .map(str::trim)
854            .filter(|value| !value.is_empty())
855            .context("custom data subscription requires `instrument_id` metadata")?;
856        InstrumentId::from_str(raw)
857            .with_context(|| format!("invalid instrument_id metadata `{raw}`"))
858    }
859
860    async fn fetch_and_emit_snapshot(
861        http: BinanceSpotHttpClient,
862        sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
863        buffers: Arc<AtomicMap<InstrumentId, BookBuffer>>,
864        instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
865        instrument_id: InstrumentId,
866        epoch: u64,
867        clock: &'static AtomicTime,
868    ) {
869        Self::fetch_and_emit_snapshot_inner(
870            http,
871            sender,
872            buffers,
873            instruments,
874            instrument_id,
875            epoch,
876            clock,
877            0,
878        )
879        .await;
880    }
881
882    #[expect(clippy::too_many_arguments)]
883    async fn fetch_and_emit_snapshot_inner(
884        http: BinanceSpotHttpClient,
885        sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
886        buffers: Arc<AtomicMap<InstrumentId, BookBuffer>>,
887        instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
888        instrument_id: InstrumentId,
889        epoch: u64,
890        clock: &'static AtomicTime,
891        retry_count: u32,
892    ) {
893        const SNAPSHOT_DEPTH: u32 = 5000;
894
895        if Self::wait_for_buffered_update(&buffers, instrument_id, epoch)
896            .await
897            .is_none()
898        {
899            return;
900        }
901
902        let params = DepthParams {
903            symbol: instrument_id.symbol.as_str().to_uppercase(),
904            limit: Some(SNAPSHOT_DEPTH),
905        };
906
907        match http.inner().depth(&params).await {
908            Ok(depth_snapshot) => {
909                let ts_init = clock.get_time_ns();
910                let last_update_id = depth_snapshot.last_update_id as u64;
911
912                {
913                    let guard = buffers.load();
914                    match guard.get(&instrument_id) {
915                        None => {
916                            log::debug!(
917                                "OrderBook subscription for {instrument_id} was cancelled, \
918                                discarding snapshot"
919                            );
920                            return;
921                        }
922                        Some(buffer) if buffer.epoch != epoch => {
923                            log::debug!(
924                                "OrderBook snapshot for {instrument_id} is stale \
925                                (epoch {epoch} != {}), discarding",
926                                buffer.epoch
927                            );
928                            return;
929                        }
930                        Some(buffer) if buffer.status == BookSyncStatus::Failed => {
931                            log::debug!(
932                                "OrderBook snapshot for {instrument_id} belongs to a failed \
933                                sync, discarding"
934                            );
935                            return;
936                        }
937                        _ => {}
938                    }
939                }
940
941                let (price_precision, size_precision) = {
942                    let guard = instruments.load();
943                    match guard.get(&instrument_id) {
944                        Some(inst) => (inst.price_precision(), inst.size_precision()),
945                        None => {
946                            log::error!("No instrument in cache for snapshot: {instrument_id}");
947                            Self::mark_book_sync_failed(&buffers, instrument_id, epoch);
948                            return;
949                        }
950                    }
951                };
952
953                let Some(first) = Self::wait_for_first_applicable_update(
954                    &buffers,
955                    instrument_id,
956                    epoch,
957                    last_update_id,
958                )
959                .await
960                else {
961                    return;
962                };
963
964                let target = last_update_id + 1;
965                if !spot_overlap_valid(first.first_update_id, first.final_update_id, last_update_id)
966                {
967                    if retry_count < MAX_SNAPSHOT_RETRIES {
968                        log::warn!(
969                            "OrderBook overlap validation failed for {instrument_id}: \
970                            lastUpdateId={last_update_id}, first_update_id={}, \
971                            final_update_id={} (need U <= {} <= u), \
972                            retrying snapshot (attempt {}/{})",
973                            first.first_update_id,
974                            first.final_update_id,
975                            target,
976                            retry_count + 1,
977                            MAX_SNAPSHOT_RETRIES
978                        );
979
980                        tokio::time::sleep(spot_snapshot_retry_backoff(retry_count)).await;
981
982                        Box::pin(Self::fetch_and_emit_snapshot_inner(
983                            http,
984                            sender,
985                            buffers,
986                            instruments,
987                            instrument_id,
988                            epoch,
989                            clock,
990                            retry_count + 1,
991                        ))
992                        .await;
993                        return;
994                    }
995
996                    log::error!(
997                        "OrderBook overlap validation failed for {instrument_id} after \
998                        {MAX_SNAPSHOT_RETRIES} retries; no deltas will be emitted until \
999                        resubscribe or reconnect"
1000                    );
1001                    Self::mark_book_sync_failed(&buffers, instrument_id, epoch);
1002                    return;
1003                }
1004
1005                let Some(buffered) =
1006                    Self::take_buffered_depth_updates(&buffers, instrument_id, epoch)
1007                else {
1008                    return;
1009                };
1010
1011                let mut replayed = 0;
1012                let mut last_final_update_id = last_update_id;
1013                let mut is_first = true;
1014                let mut replay_ready = Vec::with_capacity(buffered.len());
1015
1016                for update in buffered {
1017                    if update.final_update_id <= last_update_id {
1018                        continue;
1019                    }
1020
1021                    if !spot_continuity_ok(is_first, update.first_update_id, last_final_update_id) {
1022                        if retry_count < MAX_SNAPSHOT_RETRIES {
1023                            log::warn!(
1024                                "OrderBook continuity break for {instrument_id}: \
1025                                expected U={}, was U={}, triggering resync (attempt {}/{})",
1026                                last_final_update_id + 1,
1027                                update.first_update_id,
1028                                retry_count + 1,
1029                                MAX_SNAPSHOT_RETRIES
1030                            );
1031
1032                            Self::reset_book_sync_buffer(&buffers, instrument_id, epoch);
1033                            tokio::time::sleep(spot_snapshot_retry_backoff(retry_count)).await;
1034
1035                            Box::pin(Self::fetch_and_emit_snapshot_inner(
1036                                http,
1037                                sender,
1038                                buffers,
1039                                instruments,
1040                                instrument_id,
1041                                epoch,
1042                                clock,
1043                                retry_count + 1,
1044                            ))
1045                            .await;
1046                            return;
1047                        }
1048
1049                        log::error!(
1050                            "OrderBook continuity break for {instrument_id} after \
1051                            {MAX_SNAPSHOT_RETRIES} retries; no deltas will be emitted until \
1052                            resubscribe or reconnect"
1053                        );
1054                        Self::mark_book_sync_failed(&buffers, instrument_id, epoch);
1055                        return;
1056                    }
1057
1058                    last_final_update_id = update.final_update_id;
1059                    is_first = false;
1060                    replayed += 1;
1061                    replay_ready.push(update);
1062                }
1063
1064                let snapshot_ts_event = replay_ready
1065                    .first()
1066                    .map_or(ts_init, |update| update.deltas.ts_event);
1067
1068                let snapshot_deltas = match parse_spot_depth_snapshot(
1069                    &depth_snapshot,
1070                    instrument_id,
1071                    price_precision,
1072                    size_precision,
1073                    snapshot_ts_event,
1074                    ts_init,
1075                ) {
1076                    Ok(Some(deltas)) => deltas,
1077                    Ok(None) => {
1078                        if retry_count < MAX_SNAPSHOT_RETRIES {
1079                            log::warn!(
1080                                "OrderBook snapshot for {instrument_id} contained no levels; \
1081                                retrying snapshot (attempt {}/{})",
1082                                retry_count + 1,
1083                                MAX_SNAPSHOT_RETRIES
1084                            );
1085
1086                            tokio::time::sleep(spot_snapshot_retry_backoff(retry_count)).await;
1087
1088                            Box::pin(Self::fetch_and_emit_snapshot_inner(
1089                                http,
1090                                sender,
1091                                buffers,
1092                                instruments,
1093                                instrument_id,
1094                                epoch,
1095                                clock,
1096                                retry_count + 1,
1097                            ))
1098                            .await;
1099                            return;
1100                        }
1101
1102                        log::error!(
1103                            "OrderBook snapshot for {instrument_id} contained no levels after \
1104                            {MAX_SNAPSHOT_RETRIES} retries; no deltas will be emitted until \
1105                            resubscribe or reconnect"
1106                        );
1107                        Self::mark_book_sync_failed(&buffers, instrument_id, epoch);
1108                        return;
1109                    }
1110                    Err(e) => {
1111                        if retry_count < MAX_SNAPSHOT_RETRIES {
1112                            log::warn!(
1113                                "Failed to parse order book snapshot for {instrument_id}: {e}; \
1114                                retrying snapshot (attempt {}/{})",
1115                                retry_count + 1,
1116                                MAX_SNAPSHOT_RETRIES
1117                            );
1118
1119                            tokio::time::sleep(spot_snapshot_retry_backoff(retry_count)).await;
1120
1121                            Box::pin(Self::fetch_and_emit_snapshot_inner(
1122                                http,
1123                                sender,
1124                                buffers,
1125                                instruments,
1126                                instrument_id,
1127                                epoch,
1128                                clock,
1129                                retry_count + 1,
1130                            ))
1131                            .await;
1132                            return;
1133                        }
1134
1135                        log::error!(
1136                            "Failed to parse order book snapshot for {instrument_id} after \
1137                            {MAX_SNAPSHOT_RETRIES} retries: {e}; no deltas will be emitted \
1138                            until resubscribe or reconnect"
1139                        );
1140                        Self::mark_book_sync_failed(&buffers, instrument_id, epoch);
1141                        return;
1142                    }
1143                };
1144
1145                if let Err(e) =
1146                    sender.send(DataEvent::Data(Data::Deltas(Box::new(snapshot_deltas))))
1147                {
1148                    log::error!("Failed to send snapshot: {e}");
1149                }
1150
1151                for update in replay_ready {
1152                    if let Err(e) =
1153                        sender.send(DataEvent::Data(Data::Deltas(Box::new(update.deltas))))
1154                    {
1155                        log::error!("Failed to send replayed deltas: {e}");
1156                    }
1157                }
1158
1159                while let Some(more) =
1160                    Self::drain_buffered_depth_updates(&buffers, instrument_id, epoch)
1161                {
1162                    for update in more {
1163                        if update.final_update_id <= last_update_id {
1164                            continue;
1165                        }
1166
1167                        if !spot_continuity_ok(
1168                            is_first,
1169                            update.first_update_id,
1170                            last_final_update_id,
1171                        ) {
1172                            if retry_count < MAX_SNAPSHOT_RETRIES {
1173                                log::warn!(
1174                                    "OrderBook continuity break for {instrument_id}: \
1175                                    expected U={}, was U={}, triggering resync (attempt {}/{})",
1176                                    last_final_update_id + 1,
1177                                    update.first_update_id,
1178                                    retry_count + 1,
1179                                    MAX_SNAPSHOT_RETRIES
1180                                );
1181
1182                                Self::reset_book_sync_buffer(&buffers, instrument_id, epoch);
1183                                tokio::time::sleep(spot_snapshot_retry_backoff(retry_count)).await;
1184
1185                                Box::pin(Self::fetch_and_emit_snapshot_inner(
1186                                    http,
1187                                    sender,
1188                                    buffers,
1189                                    instruments,
1190                                    instrument_id,
1191                                    epoch,
1192                                    clock,
1193                                    retry_count + 1,
1194                                ))
1195                                .await;
1196                                return;
1197                            }
1198                            log::error!(
1199                                "OrderBook continuity break for {instrument_id} after \
1200                                {MAX_SNAPSHOT_RETRIES} retries; no deltas will be emitted \
1201                                until resubscribe or reconnect"
1202                            );
1203                            Self::mark_book_sync_failed(&buffers, instrument_id, epoch);
1204                            return;
1205                        }
1206
1207                        last_final_update_id = update.final_update_id;
1208                        is_first = false;
1209                        replayed += 1;
1210
1211                        if let Err(e) =
1212                            sender.send(DataEvent::Data(Data::Deltas(Box::new(update.deltas))))
1213                        {
1214                            log::error!("Failed to send replayed deltas: {e}");
1215                        }
1216                    }
1217                }
1218
1219                log::debug!(
1220                    "OrderBook snapshot rebuild for {instrument_id} completed \
1221                    (lastUpdateId={last_update_id}, replayed={replayed})"
1222                );
1223            }
1224            Err(e) => {
1225                if retry_count < MAX_SNAPSHOT_RETRIES {
1226                    log::warn!(
1227                        "Failed to request order book snapshot for {instrument_id}: {e}; \
1228                        retrying snapshot (attempt {}/{})",
1229                        retry_count + 1,
1230                        MAX_SNAPSHOT_RETRIES
1231                    );
1232
1233                    tokio::time::sleep(spot_snapshot_retry_backoff(retry_count)).await;
1234
1235                    Box::pin(Self::fetch_and_emit_snapshot_inner(
1236                        http,
1237                        sender,
1238                        buffers,
1239                        instruments,
1240                        instrument_id,
1241                        epoch,
1242                        clock,
1243                        retry_count + 1,
1244                    ))
1245                    .await;
1246                    return;
1247                }
1248
1249                log::error!(
1250                    "Failed to request order book snapshot for {instrument_id} after \
1251                    {MAX_SNAPSHOT_RETRIES} retries: {e}; no deltas will be emitted until \
1252                    resubscribe or reconnect"
1253                );
1254                Self::mark_book_sync_failed(&buffers, instrument_id, epoch);
1255            }
1256        }
1257    }
1258
1259    async fn wait_for_buffered_update(
1260        buffers: &Arc<AtomicMap<InstrumentId, BookBuffer>>,
1261        instrument_id: InstrumentId,
1262        epoch: u64,
1263    ) -> Option<()> {
1264        loop {
1265            let guard = buffers.load();
1266            match guard.get(&instrument_id) {
1267                Some(buffer)
1268                    if buffer.epoch == epoch
1269                        && buffer.status == BookSyncStatus::Buffering
1270                        && !buffer.updates.is_empty() =>
1271                {
1272                    return Some(());
1273                }
1274                Some(buffer)
1275                    if buffer.epoch == epoch && buffer.status == BookSyncStatus::Buffering => {}
1276                _ => return None,
1277            }
1278
1279            drop(guard);
1280            tokio::time::sleep(Duration::from_millis(100)).await;
1281        }
1282    }
1283
1284    async fn wait_for_first_applicable_update(
1285        buffers: &Arc<AtomicMap<InstrumentId, BookBuffer>>,
1286        instrument_id: InstrumentId,
1287        epoch: u64,
1288        last_update_id: u64,
1289    ) -> Option<BufferedDepthUpdate> {
1290        loop {
1291            let mut first = None;
1292            let mut waiting = false;
1293            buffers.rcu(|m| {
1294                first = None;
1295                waiting = false;
1296
1297                if let Some(buffer) = m.get_mut(&instrument_id)
1298                    && buffer.epoch == epoch
1299                    && buffer.status == BookSyncStatus::Buffering
1300                {
1301                    buffer
1302                        .updates
1303                        .retain(|update| update.final_update_id > last_update_id);
1304                    first = first_applicable_spot_update(&buffer.updates, last_update_id).cloned();
1305                    waiting = first.is_none();
1306                }
1307            });
1308
1309            if first.is_some() {
1310                return first;
1311            }
1312
1313            if !waiting {
1314                return None;
1315            }
1316
1317            tokio::time::sleep(Duration::from_millis(100)).await;
1318        }
1319    }
1320
1321    fn take_buffered_depth_updates(
1322        buffers: &Arc<AtomicMap<InstrumentId, BookBuffer>>,
1323        instrument_id: InstrumentId,
1324        epoch: u64,
1325    ) -> Option<Vec<BufferedDepthUpdate>> {
1326        let mut taken = None;
1327        buffers.rcu(|m| {
1328            taken = None;
1329
1330            if let Some(buffer) = m.get_mut(&instrument_id)
1331                && buffer.epoch == epoch
1332                && buffer.status == BookSyncStatus::Buffering
1333            {
1334                taken = Some(std::mem::take(&mut buffer.updates));
1335            }
1336        });
1337        taken
1338    }
1339
1340    fn drain_buffered_depth_updates(
1341        buffers: &Arc<AtomicMap<InstrumentId, BookBuffer>>,
1342        instrument_id: InstrumentId,
1343        epoch: u64,
1344    ) -> Option<Vec<BufferedDepthUpdate>> {
1345        let mut taken = None;
1346        buffers.rcu(|m| {
1347            taken = None;
1348
1349            if let Some(buffer) = m.get_mut(&instrument_id)
1350                && buffer.epoch == epoch
1351                && buffer.status == BookSyncStatus::Buffering
1352            {
1353                if buffer.updates.is_empty() {
1354                    m.remove(&instrument_id);
1355                } else {
1356                    taken = Some(std::mem::take(&mut buffer.updates));
1357                }
1358            }
1359        });
1360        taken
1361    }
1362
1363    fn reset_book_sync_buffer(
1364        buffers: &Arc<AtomicMap<InstrumentId, BookBuffer>>,
1365        instrument_id: InstrumentId,
1366        epoch: u64,
1367    ) {
1368        buffers.rcu(|m| {
1369            if let Some(buffer) = m.get_mut(&instrument_id)
1370                && buffer.epoch == epoch
1371            {
1372                buffer.updates.clear();
1373                buffer.status = BookSyncStatus::Buffering;
1374            }
1375        });
1376    }
1377
1378    fn mark_book_sync_failed(
1379        buffers: &Arc<AtomicMap<InstrumentId, BookBuffer>>,
1380        instrument_id: InstrumentId,
1381        epoch: u64,
1382    ) {
1383        buffers.rcu(|m| {
1384            if let Some(buffer) = m.get_mut(&instrument_id)
1385                && buffer.epoch == epoch
1386            {
1387                buffer.updates.clear();
1388                buffer.status = BookSyncStatus::Failed;
1389            }
1390        });
1391    }
1392}
1393
1394fn spot_ticker_data_type(instrument_id: InstrumentId) -> DataType {
1395    let mut metadata = Params::new();
1396    metadata.insert(
1397        "instrument_id".to_string(),
1398        serde_json::Value::String(instrument_id.to_string()),
1399    );
1400    DataType::new(
1401        "BinanceSpotTicker",
1402        Some(metadata),
1403        Some(instrument_id.to_string()),
1404    )
1405}
1406
1407fn upsert_instrument(
1408    cache: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
1409    instrument: InstrumentAny,
1410) {
1411    cache.insert(instrument.id(), instrument);
1412}
1413
1414// Spot requires first diff to overlap the REST snapshot: `U <= lastUpdateId + 1 <= u`.
1415fn spot_overlap_valid(first_update_id: u64, final_update_id: u64, last_update_id: u64) -> bool {
1416    let target = last_update_id + 1;
1417    first_update_id <= target && final_update_id >= target
1418}
1419
1420// After the first applied diff, each spot update must satisfy `U == previous u + 1`.
1421fn spot_continuity_ok(is_first: bool, first_update_id: u64, prev_final_update_id: u64) -> bool {
1422    is_first || first_update_id == prev_final_update_id + 1
1423}
1424
1425fn spot_snapshot_retry_backoff(retry_count: u32) -> Duration {
1426    let multiplier = 1_u64 << retry_count.min(4);
1427    let millis = SNAPSHOT_RETRY_BACKOFF_BASE_MS
1428        .saturating_mul(multiplier)
1429        .min(SNAPSHOT_RETRY_BACKOFF_CAP_MS);
1430    Duration::from_millis(millis)
1431}
1432
1433fn first_applicable_spot_update(
1434    updates: &[BufferedDepthUpdate],
1435    last_update_id: u64,
1436) -> Option<&BufferedDepthUpdate> {
1437    updates
1438        .iter()
1439        .find(|update| update.final_update_id > last_update_id)
1440}
1441
1442fn trim_buffered_depth_updates(updates: &mut Vec<BufferedDepthUpdate>) {
1443    let excess = updates.len().saturating_sub(MAX_BUFFERED_DEPTH_UPDATES);
1444    if excess > 0 {
1445        updates.drain(..excess);
1446    }
1447}
1448
1449fn parse_spot_depth_snapshot(
1450    depth: &BinanceDepth,
1451    instrument_id: InstrumentId,
1452    price_precision: u8,
1453    size_precision: u8,
1454    ts_event: UnixNanos,
1455    ts_init: UnixNanos,
1456) -> anyhow::Result<Option<OrderBookDeltas>> {
1457    let sequence = depth.last_update_id as u64;
1458
1459    let total_levels = depth.bids.len() + depth.asks.len();
1460    let mut deltas = Vec::with_capacity(total_levels + 1);
1461
1462    // REST snapshots carry no event time; use the caller's best venue-time estimate.
1463    deltas.push(OrderBookDelta::clear(
1464        instrument_id,
1465        sequence,
1466        ts_event,
1467        ts_init,
1468    ));
1469
1470    for (i, level) in depth.bids.iter().enumerate() {
1471        let price = Price::from_mantissa_exponent_checked(
1472            level.price_mantissa,
1473            depth.price_exponent,
1474            price_precision,
1475        )?;
1476        let size = Quantity::from_mantissa_exponent_checked(
1477            level.qty_mantissa as u64,
1478            depth.qty_exponent,
1479            size_precision,
1480        )?;
1481        let flags = if i == depth.bids.len() - 1 && depth.asks.is_empty() {
1482            RecordFlag::F_LAST as u8
1483        } else {
1484            0
1485        };
1486
1487        let order = BookOrder::new(OrderSide::Buy, price, size, 0);
1488
1489        deltas.push(OrderBookDelta::new(
1490            instrument_id,
1491            BookAction::Add,
1492            order,
1493            flags,
1494            sequence,
1495            ts_event,
1496            ts_init,
1497        ));
1498    }
1499
1500    for (i, level) in depth.asks.iter().enumerate() {
1501        let price = Price::from_mantissa_exponent_checked(
1502            level.price_mantissa,
1503            depth.price_exponent,
1504            price_precision,
1505        )?;
1506        let size = Quantity::from_mantissa_exponent_checked(
1507            level.qty_mantissa as u64,
1508            depth.qty_exponent,
1509            size_precision,
1510        )?;
1511        let flags = if i == depth.asks.len() - 1 {
1512            RecordFlag::F_LAST as u8
1513        } else {
1514            0
1515        };
1516
1517        let order = BookOrder::new(OrderSide::Sell, price, size, 0);
1518
1519        deltas.push(OrderBookDelta::new(
1520            instrument_id,
1521            BookAction::Add,
1522            order,
1523            flags,
1524            sequence,
1525            ts_event,
1526            ts_init,
1527        ));
1528    }
1529
1530    if deltas.len() <= 1 {
1531        return Ok(None);
1532    }
1533
1534    Ok(Some(OrderBookDeltas::new(instrument_id, deltas)))
1535}
1536
1537#[async_trait::async_trait(?Send)]
1538impl DataClient for BinanceSpotDataClient {
1539    fn client_id(&self) -> ClientId {
1540        self.client_id
1541    }
1542
1543    fn venue(&self) -> Option<Venue> {
1544        Some(self.venue())
1545    }
1546
1547    fn start(&mut self) -> anyhow::Result<()> {
1548        log::info!(
1549            "Started: client_id={}, product_type={:?}, environment={:?}",
1550            self.client_id,
1551            self.config.product_type,
1552            self.config.environment,
1553        );
1554        Ok(())
1555    }
1556
1557    fn stop(&mut self) -> anyhow::Result<()> {
1558        log::info!("Stopping {id}", id = self.client_id);
1559        self.session_tasks.begin_shutdown();
1560        self.command_tasks.begin_shutdown();
1561        self.ws_client.begin_shutdown();
1562        self.is_connected.store(false, Ordering::Relaxed);
1563        Ok(())
1564    }
1565
1566    fn reset(&mut self) -> anyhow::Result<()> {
1567        log::debug!("Resetting {id}", id = self.client_id);
1568
1569        self.session_tasks.begin_shutdown();
1570        self.command_tasks.begin_shutdown();
1571        self.ws_client.begin_shutdown();
1572        self.is_connected.store(false, Ordering::Relaxed);
1573
1574        self.book_subscriptions.store(AHashMap::new());
1575        self.l1_book_subscriptions.store(AHashMap::new());
1576        self.quote_refs.store(AHashMap::new());
1577        self.ticker_refs.store(AHashMap::new());
1578        self.book_buffers.store(AHashMap::new());
1579
1580        Ok(())
1581    }
1582
1583    fn dispose(&mut self) -> anyhow::Result<()> {
1584        log::debug!("Disposing {id}", id = self.client_id);
1585        self.stop()
1586    }
1587
1588    async fn connect(&mut self) -> anyhow::Result<()> {
1589        if self.is_connected() && self.session_tasks.is_open() && self.command_tasks.is_open() {
1590            return Ok(());
1591        }
1592
1593        register_binance_custom_data();
1594
1595        if self.spot_market_data_mode == BinanceSpotMarketDataMode::Sbe
1596            && !self.ws_client.has_credentials()
1597        {
1598            anyhow::bail!(
1599                "Binance Spot market data mode SBE requires Ed25519 API credentials. \
1600                 Set the appropriate env vars for your environment, \
1601                 or provide api_key/api_secret in the data client config"
1602            );
1603        }
1604
1605        self.prepare_task_groups().await?;
1606        let ws_client = self.ws_client.clone();
1607        let setup_guard =
1608            TaskGroupGuard::new(&[&self.session_tasks, &self.command_tasks], move || {
1609                ws_client.begin_shutdown();
1610            });
1611
1612        Self::refresh_instrument_catalogue(
1613            &self.http_client,
1614            &self.config.instrument_provider,
1615            self.config.us,
1616            &self.instruments,
1617            &self.status_cache,
1618            &self.ws_client,
1619            &self.data_sender,
1620            self.clock,
1621            false,
1622        )
1623        .await?;
1624
1625        let session_result = async {
1626            match &mut self.ws_client {
1627                SpotWsClient::Sbe(ws_client) => {
1628                    log::info!("Connecting to Binance Spot SBE WebSocket...");
1629                    ws_client.connect().await.map_err(|e| {
1630                        log::error!("Binance Spot SBE WebSocket connection failed: {e:?}");
1631                        anyhow::anyhow!("failed to connect Binance Spot SBE WebSocket: {e}")
1632                    })?;
1633                    log::info!("Binance Spot SBE WebSocket connected");
1634
1635                    let stream = ws_client.stream();
1636                    let sender = self.data_sender.clone();
1637                    let insts = self.instruments.clone();
1638                    let ws_insts = ws_client.instruments_cache();
1639                    let buffers = self.book_buffers.clone();
1640                    let book_subs = self.book_subscriptions.clone();
1641                    let l1_book_subs = self.l1_book_subscriptions.clone();
1642                    let book_epoch = self.book_epoch.clone();
1643                    let http = self.http_client.clone();
1644                    let clock = self.clock;
1645                    let cancel = self.cancellation_token.clone();
1646                    let command_spawner = self
1647                        .command_tasks
1648                        .spawner()
1649                        .context("Binance Spot command task admission is closed")?;
1650
1651                    let future = async move {
1652                        pin_mut!(stream);
1653
1654                        loop {
1655                            tokio::select! {
1656                                Some(message) = stream.next() => {
1657                                    Self::handle_ws_message(
1658                                        message,
1659                                        &sender,
1660                                        &insts,
1661                                        &ws_insts,
1662                                        &buffers,
1663                                        &book_subs,
1664                                        &l1_book_subs,
1665                                        &book_epoch,
1666                                        &http,
1667                                        clock,
1668                                        &command_spawner,
1669                                    );
1670                                }
1671                                () = cancel.cancelled() => {
1672                                    log::debug!("Spot SBE WebSocket stream task cancelled");
1673                                    break;
1674                                }
1675                            }
1676                        }
1677                    };
1678                    self.session_tasks
1679                        .spawn(future)
1680                        .context("failed to register Binance Spot SBE stream task")?;
1681                }
1682                SpotWsClient::JsonPublic(ws_client) => {
1683                    log::info!("Connecting to Binance Spot public JSON WebSocket...");
1684                    ws_client.connect().await.map_err(|e| {
1685                        log::error!("Binance Spot public JSON WebSocket connection failed: {e:?}");
1686                        anyhow::anyhow!("failed to connect Binance Spot public JSON WebSocket: {e}")
1687                    })?;
1688                    log::info!("Binance Spot public JSON WebSocket connected");
1689
1690                    let stream = ws_client.stream();
1691                    let sender = self.data_sender.clone();
1692                    let insts = self.instruments.clone();
1693                    let ws_insts = ws_client.instruments_cache();
1694                    let buffers = self.book_buffers.clone();
1695                    let book_subs = self.book_subscriptions.clone();
1696                    let l1_book_subs = self.l1_book_subscriptions.clone();
1697                    let book_epoch = self.book_epoch.clone();
1698                    let http = self.http_client.clone();
1699                    let clock = self.clock;
1700                    let cancel = self.cancellation_token.clone();
1701                    let command_spawner = self
1702                        .command_tasks
1703                        .spawner()
1704                        .context("Binance Spot command task admission is closed")?;
1705
1706                    let future = async move {
1707                        pin_mut!(stream);
1708
1709                        loop {
1710                            tokio::select! {
1711                                Some(message) = stream.next() => {
1712                                    Self::handle_public_json_ws_message(
1713                                        message,
1714                                        &sender,
1715                                        &insts,
1716                                        &ws_insts,
1717                                        &buffers,
1718                                        &book_subs,
1719                                        &l1_book_subs,
1720                                        &book_epoch,
1721                                        &http,
1722                                        clock,
1723                                        &command_spawner,
1724                                    );
1725                                }
1726                                () = cancel.cancelled() => {
1727                                    log::debug!("Spot JSON WebSocket stream task cancelled");
1728                                    break;
1729                                }
1730                            }
1731                        }
1732                    };
1733                    self.session_tasks
1734                        .spawn(future)
1735                        .context("failed to register Binance Spot JSON stream task")?;
1736                }
1737            }
1738
1739            let poll_secs = self.config.instrument_status_poll_secs;
1740            if poll_secs > 0 {
1741                let http = self.http_client.clone();
1742                let poll_sender = self.data_sender.clone();
1743                let poll_instruments = self.instruments.clone();
1744                let poll_status_cache = self.status_cache.clone();
1745                let poll_cancel = self.cancellation_token.clone();
1746                let clock = self.clock;
1747                let us = self.config.us;
1748
1749                let future = async move {
1750                    let mut interval =
1751                        tokio::time::interval(tokio::time::Duration::from_secs(poll_secs));
1752                    interval.tick().await; // Skip first immediate tick
1753
1754                    loop {
1755                        tokio::select! {
1756                            _ = interval.tick() => {
1757                                match http.request_symbol_statuses(us).await {
1758                                    Ok(statuses) => {
1759                                        let ts = clock.get_time_ns();
1760                                        let inst_guard = poll_instruments.load();
1761                                        let new_statuses = statuses
1762                                            .into_iter()
1763                                            .filter(|(instrument_id, _)| {
1764                                                inst_guard.contains_key(instrument_id)
1765                                            })
1766                                            .collect();
1767                                        drop(inst_guard);
1768
1769                                        let mut cache =
1770                                            (**poll_status_cache.load()).clone();
1771                                        diff_and_emit_statuses(
1772                                            &new_statuses, &mut cache, &poll_sender, ts, ts,
1773                                        );
1774                                        poll_status_cache.store(cache);
1775                                    }
1776                                    Err(e) => {
1777                                        log::warn!("Instrument status poll failed: {e}");
1778                                    }
1779                                }
1780                            }
1781                            () = poll_cancel.cancelled() => {
1782                                log::debug!("Instrument status polling task cancelled");
1783                                break;
1784                            }
1785                        }
1786                    }
1787                };
1788                self.session_tasks
1789                    .spawn(future)
1790                    .context("failed to register Binance Spot status polling task")?;
1791                log::debug!("Instrument status polling started: interval={poll_secs}s");
1792            }
1793
1794            let refresh_secs = self.config.instrument_refresh_interval_secs;
1795            if refresh_secs > 0 {
1796                let http = self.http_client.clone();
1797                let provider = self.config.instrument_provider.clone();
1798                let us = self.config.us;
1799                let instruments = self.instruments.clone();
1800                let statuses = self.status_cache.clone();
1801                let ws = self.ws_client.clone();
1802                let sender = self.data_sender.clone();
1803                let clock = self.clock;
1804                let cancel = self.cancellation_token.clone();
1805
1806                let future = async move {
1807                    let mut interval = tokio::time::interval(Duration::from_secs(refresh_secs));
1808                    interval.tick().await;
1809
1810                    loop {
1811                        tokio::select! {
1812                            _ = interval.tick() => {
1813                                if let Err(e) = Self::refresh_instrument_catalogue(
1814                                    &http,
1815                                    &provider,
1816                                    us,
1817                                    &instruments,
1818                                    &statuses,
1819                                    &ws,
1820                                    &sender,
1821                                    clock,
1822                                    true,
1823                                ).await {
1824                                    log::warn!("Binance Spot instrument refresh failed: {e}");
1825                                }
1826                            }
1827                            () = cancel.cancelled() => {
1828                                log::debug!("Binance Spot instrument refresh task cancelled");
1829                                break;
1830                            }
1831                        }
1832                    }
1833                };
1834                self.session_tasks
1835                    .spawn(future)
1836                    .context("failed to register Binance Spot instrument refresh task")?;
1837                log::debug!("Instrument refresh started: interval={refresh_secs}s");
1838            }
1839
1840            Ok::<(), anyhow::Error>(())
1841        }
1842        .await;
1843
1844        if let Err(e) = session_result {
1845            if let Err(teardown_error) = self.teardown_partial_connect().await {
1846                return Err(e.context(format!(
1847                    "Binance Spot data startup teardown failed: {teardown_error}"
1848                )));
1849            }
1850            return Err(e);
1851        }
1852
1853        setup_guard.disarm();
1854        self.is_connected.store(true, Ordering::Release);
1855        log::info!("Connected: client_id={}", self.client_id);
1856        Ok(())
1857    }
1858
1859    async fn disconnect(&mut self) -> anyhow::Result<()> {
1860        self.teardown_partial_connect().await?;
1861
1862        self.book_subscriptions.store(AHashMap::new());
1863        self.l1_book_subscriptions.store(AHashMap::new());
1864        self.quote_refs.store(AHashMap::new());
1865        self.ticker_refs.store(AHashMap::new());
1866        self.book_buffers.store(AHashMap::new());
1867
1868        self.is_connected.store(false, Ordering::Release);
1869        log::info!("Disconnected: client_id={}", self.client_id);
1870        Ok(())
1871    }
1872
1873    fn is_connected(&self) -> bool {
1874        self.is_connected.load(Ordering::Relaxed)
1875    }
1876
1877    fn is_disconnected(&self) -> bool {
1878        !self.is_connected()
1879    }
1880
1881    fn subscribe(&mut self, cmd: SubscribeCustomData) -> anyhow::Result<()> {
1882        if cmd.data_type.type_name() != "BinanceSpotTicker" {
1883            log::warn!(
1884                "Unsupported custom data subscription: {}",
1885                cmd.data_type.type_name()
1886            );
1887            return Ok(());
1888        }
1889        anyhow::ensure!(
1890            self.spot_market_data_mode == BinanceSpotMarketDataMode::Json,
1891            "Binance Spot 24-hour ticker custom data requires JSON market-data mode"
1892        );
1893        let instrument_id = Self::required_instrument_id_metadata(&cmd.data_type)?;
1894        anyhow::ensure!(
1895            instrument_id.venue == self.venue(),
1896            "Spot ticker requires a BINANCE instrument"
1897        );
1898        let should_subscribe = {
1899            let previous = self
1900                .ticker_refs
1901                .load()
1902                .get(&instrument_id)
1903                .copied()
1904                .unwrap_or(0);
1905            self.ticker_refs
1906                .rcu(|refs| *refs.entry(instrument_id).or_insert(0) += 1);
1907            previous == 0
1908        };
1909
1910        if should_subscribe {
1911            let ws = self.ws_client.clone();
1912            let stream = format!("{}@ticker", instrument_id.symbol.as_str().to_lowercase());
1913            self.spawn_ws(
1914                async move {
1915                    ws.subscribe(vec![stream])
1916                        .await
1917                        .context("ticker subscription")
1918                },
1919                "ticker subscription",
1920            );
1921        }
1922        Ok(())
1923    }
1924
1925    fn subscribe_instruments(&mut self, _cmd: SubscribeInstruments) -> anyhow::Result<()> {
1926        log::debug!("subscribe_instruments: Binance instruments are fetched via HTTP on connect");
1927        Ok(())
1928    }
1929
1930    fn subscribe_instrument(&mut self, _cmd: SubscribeInstrument) -> anyhow::Result<()> {
1931        log::debug!("subscribe_instrument: Binance instruments are fetched via HTTP on connect");
1932        Ok(())
1933    }
1934
1935    fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
1936        if cmd.book_type == BookType::L1_MBP {
1937            anyhow::ensure!(
1938                cmd.depth.is_none_or(|depth| depth.get() == 1),
1939                "Binance Spot L1_MBP supports depth 1 only"
1940            );
1941            anyhow::ensure!(
1942                !self.book_subscriptions.contains_key(&cmd.instrument_id),
1943                "cannot subscribe L1_MBP and L2_MBP for the same Binance Spot instrument"
1944            );
1945            self.l1_book_subscriptions.rcu(|subscriptions| {
1946                *subscriptions.entry(cmd.instrument_id).or_insert(0) += 1;
1947            });
1948            self.subscribe_top_of_book(cmd.instrument_id);
1949            return Ok(());
1950        }
1951
1952        if cmd.book_type != BookType::L2_MBP {
1953            anyhow::bail!("Binance Spot supports L1_MBP and L2_MBP order book subscriptions");
1954        }
1955        anyhow::ensure!(
1956            !self.l1_book_subscriptions.contains_key(&cmd.instrument_id),
1957            "cannot subscribe L1_MBP and L2_MBP for the same Binance Spot instrument"
1958        );
1959
1960        let instrument_id = cmd.instrument_id;
1961        let ws = self.ws_client.clone();
1962        let symbol_lower = instrument_id.symbol.as_str().to_lowercase();
1963
1964        if self.spot_market_data_mode == BinanceSpotMarketDataMode::Json && cmd.depth.is_some() {
1965            // Explicit depth requests use partial-book streams. Full-depth JSON
1966            // subscriptions fall through to the REST snapshot + @depth diff path.
1967            let depth_level = match cmd.depth.map(|d| d.get()) {
1968                Some(1..=5) => 5,
1969                Some(6..=10) => 10,
1970                _ => 20,
1971            };
1972            self.book_subscriptions.insert(instrument_id, depth_level);
1973
1974            let stream = format!("{symbol_lower}@depth{depth_level}");
1975            self.spawn_ws(
1976                async move {
1977                    ws.subscribe(vec![stream])
1978                        .await
1979                        .context("book deltas subscription")
1980                },
1981                "order book subscription",
1982            );
1983            return Ok(());
1984        }
1985
1986        match cmd.depth.map(|d| d.get()) {
1987            // Partial book streams are self-contained snapshots.
1988            Some(depth) => {
1989                let depth_level = match depth {
1990                    1..=5 => 5,
1991                    6..=10 => 10,
1992                    _ => 20,
1993                };
1994                self.book_subscriptions.insert(instrument_id, depth_level);
1995
1996                let stream = format!("{symbol_lower}@depth{depth_level}");
1997                self.spawn_ws(
1998                    async move {
1999                        ws.subscribe(vec![stream])
2000                            .await
2001                            .context("book deltas subscription")
2002                    },
2003                    "order book subscription",
2004                );
2005            }
2006            // Full book diffs are seeded by a REST snapshot and replayed.
2007            None => {
2008                self.book_subscriptions.insert(instrument_id, 0);
2009
2010                // Bump epoch to invalidate any in-flight snapshot from a prior subscription
2011                let epoch = {
2012                    let mut guard = self.book_epoch.write();
2013                    *guard = guard.wrapping_add(1);
2014                    *guard
2015                };
2016
2017                // Start buffering diffs before the snapshot lands
2018                self.book_buffers
2019                    .insert(instrument_id, BookBuffer::new(epoch));
2020
2021                log::debug!("OrderBook full snapshot rebuild for {instrument_id} starting");
2022
2023                let stream = format!("{symbol_lower}@depth");
2024                self.spawn_ws(
2025                    async move {
2026                        ws.subscribe(vec![stream])
2027                            .await
2028                            .context("book deltas subscription")
2029                    },
2030                    "order book subscription",
2031                );
2032
2033                let http = self.http_client.clone();
2034                let sender = self.data_sender.clone();
2035                let buffers = self.book_buffers.clone();
2036                let instruments = self.instruments.clone();
2037                let clock = self.clock;
2038
2039                self.spawn_command(async move {
2040                    Self::fetch_and_emit_snapshot(
2041                        http,
2042                        sender,
2043                        buffers,
2044                        instruments,
2045                        instrument_id,
2046                        epoch,
2047                        clock,
2048                    )
2049                    .await;
2050                });
2051            }
2052        }
2053        Ok(())
2054    }
2055
2056    fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
2057        self.subscribe_top_of_book(cmd.instrument_id);
2058        Ok(())
2059    }
2060
2061    fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
2062        let instrument_id = cmd.instrument_id;
2063        let ws = self.ws_client.clone();
2064
2065        let stream = format!("{}@trade", instrument_id.symbol.as_str().to_lowercase());
2066
2067        self.spawn_ws(
2068            async move {
2069                ws.subscribe(vec![stream])
2070                    .await
2071                    .context("trades subscription")
2072            },
2073            "trade subscription",
2074        );
2075        Ok(())
2076    }
2077
2078    fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
2079        anyhow::ensure!(
2080            self.spot_market_data_mode == BinanceSpotMarketDataMode::Json,
2081            "Binance Spot kline subscriptions require JSON market-data mode"
2082        );
2083        let bar_type = cmd.bar_type;
2084        let ws = self.ws_client.clone();
2085        let interval = bar_spec_to_binance_interval(bar_type.spec())?;
2086
2087        let stream = format!(
2088            "{}@kline_{}",
2089            bar_type.instrument_id().symbol.as_str().to_lowercase(),
2090            interval.as_str()
2091        );
2092
2093        self.spawn_ws(
2094            async move {
2095                ws.subscribe(vec![stream])
2096                    .await
2097                    .context("bars subscription")
2098            },
2099            "bar subscription",
2100        );
2101        Ok(())
2102    }
2103
2104    fn subscribe_instrument_status(
2105        &mut self,
2106        cmd: SubscribeInstrumentStatus,
2107    ) -> anyhow::Result<()> {
2108        log::debug!(
2109            "subscribe_instrument_status: {id} (status changes detected via periodic exchange info polling)",
2110            id = cmd.instrument_id,
2111        );
2112        Ok(())
2113    }
2114
2115    fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
2116        let instrument_id = cmd.instrument_id;
2117
2118        if let Some(count) = self
2119            .l1_book_subscriptions
2120            .load()
2121            .get(&instrument_id)
2122            .copied()
2123        {
2124            if count == 1 {
2125                self.l1_book_subscriptions.remove(&instrument_id);
2126            } else {
2127                self.l1_book_subscriptions.rcu(|subscriptions| {
2128                    if let Some(existing) = subscriptions.get_mut(&instrument_id) {
2129                        *existing -= 1;
2130                    }
2131                });
2132            }
2133            self.unsubscribe_top_of_book(instrument_id);
2134            return Ok(());
2135        }
2136        let ws = self.ws_client.clone();
2137
2138        // Stop buffering/tracking so any in-flight snapshot task is discarded
2139        self.book_subscriptions.remove(&instrument_id);
2140        self.book_buffers.remove(&instrument_id);
2141
2142        let symbol_lower = instrument_id.symbol.as_str().to_lowercase();
2143        let streams = vec![
2144            format!("{symbol_lower}@depth"),
2145            format!("{symbol_lower}@depth5"),
2146            format!("{symbol_lower}@depth10"),
2147            format!("{symbol_lower}@depth20"),
2148        ];
2149
2150        self.spawn_ws(
2151            async move {
2152                ws.unsubscribe(streams)
2153                    .await
2154                    .context("book deltas unsubscribe")
2155            },
2156            "order book unsubscribe",
2157        );
2158        Ok(())
2159    }
2160
2161    fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
2162        self.unsubscribe_top_of_book(cmd.instrument_id);
2163        Ok(())
2164    }
2165
2166    fn unsubscribe(&mut self, cmd: &UnsubscribeCustomData) -> anyhow::Result<()> {
2167        if cmd.data_type.type_name() != "BinanceSpotTicker" {
2168            log::warn!(
2169                "Unsupported custom data unsubscription: {}",
2170                cmd.data_type.type_name()
2171            );
2172            return Ok(());
2173        }
2174        let instrument_id = Self::required_instrument_id_metadata(&cmd.data_type)?;
2175        let should_unsubscribe = match self.ticker_refs.load().get(&instrument_id).copied() {
2176            Some(1) => {
2177                self.ticker_refs.remove(&instrument_id);
2178                true
2179            }
2180            Some(count) if count > 1 => {
2181                self.ticker_refs.rcu(|refs| {
2182                    if let Some(existing) = refs.get_mut(&instrument_id) {
2183                        *existing -= 1;
2184                    }
2185                });
2186                false
2187            }
2188            _ => false,
2189        };
2190
2191        if should_unsubscribe {
2192            let ws = self.ws_client.clone();
2193            let stream = format!("{}@ticker", instrument_id.symbol.as_str().to_lowercase());
2194            self.spawn_ws(
2195                async move {
2196                    ws.unsubscribe(vec![stream])
2197                        .await
2198                        .context("ticker unsubscribe")
2199                },
2200                "ticker unsubscribe",
2201            );
2202        }
2203        Ok(())
2204    }
2205
2206    fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
2207        let instrument_id = cmd.instrument_id;
2208        let ws = self.ws_client.clone();
2209
2210        let stream = format!("{}@trade", instrument_id.symbol.as_str().to_lowercase());
2211
2212        self.spawn_ws(
2213            async move {
2214                ws.unsubscribe(vec![stream])
2215                    .await
2216                    .context("trades unsubscribe")
2217            },
2218            "trade unsubscribe",
2219        );
2220        Ok(())
2221    }
2222
2223    fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
2224        let bar_type = cmd.bar_type;
2225        let ws = self.ws_client.clone();
2226        let interval = bar_spec_to_binance_interval(bar_type.spec())?;
2227
2228        let stream = format!(
2229            "{}@kline_{}",
2230            bar_type.instrument_id().symbol.as_str().to_lowercase(),
2231            interval.as_str()
2232        );
2233
2234        self.spawn_ws(
2235            async move {
2236                ws.unsubscribe(vec![stream])
2237                    .await
2238                    .context("bars unsubscribe")
2239            },
2240            "bar unsubscribe",
2241        );
2242        Ok(())
2243    }
2244
2245    fn unsubscribe_instrument_status(
2246        &mut self,
2247        cmd: &UnsubscribeInstrumentStatus,
2248    ) -> anyhow::Result<()> {
2249        log::debug!(
2250            "unsubscribe_instrument_status: {id}",
2251            id = cmd.instrument_id,
2252        );
2253        Ok(())
2254    }
2255
2256    fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
2257        let http = self.http_client.clone();
2258        let sender = self.data_sender.clone();
2259        let instruments_cache = self.instruments.clone();
2260        let request_id = request.request_id;
2261        let client_id = request.client_id.unwrap_or(self.client_id);
2262        let venue = self.venue();
2263        let start = request.start;
2264        let end = request.end;
2265        let params = request.params;
2266        let clock = self.clock;
2267        let provider = self.config.instrument_provider.clone();
2268        let us = self.config.us;
2269        let start_nanos = datetime_to_unix_nanos(start);
2270        let end_nanos = datetime_to_unix_nanos(end);
2271
2272        self.spawn_command(async move {
2273            match http.request_instruments_with_config(&provider, us).await {
2274                Ok(instruments) => {
2275                    for instrument in &instruments {
2276                        upsert_instrument(&instruments_cache, instrument.clone());
2277                    }
2278
2279                    let response = DataResponse::Instruments(InstrumentsResponse::new(
2280                        request_id,
2281                        client_id,
2282                        venue,
2283                        instruments,
2284                        start_nanos,
2285                        end_nanos,
2286                        clock.get_time_ns(),
2287                        params,
2288                    ));
2289
2290                    if let Err(e) = sender.send(DataEvent::Response(response)) {
2291                        log::error!("Failed to send instruments response: {e}");
2292                    }
2293                }
2294                Err(e) => log::error!("Instruments request failed: {e:?}"),
2295            }
2296        });
2297
2298        Ok(())
2299    }
2300
2301    fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
2302        let http = self.http_client.clone();
2303        let sender = self.data_sender.clone();
2304        let instruments = self.instruments.clone();
2305        let instrument_id = request.instrument_id;
2306        let request_id = request.request_id;
2307        let client_id = request.client_id.unwrap_or(self.client_id);
2308        let start = request.start;
2309        let end = request.end;
2310        let params = request.params;
2311        let clock = self.clock;
2312        let provider = self.config.instrument_provider.clone();
2313        let us = self.config.us;
2314        let start_nanos = datetime_to_unix_nanos(start);
2315        let end_nanos = datetime_to_unix_nanos(end);
2316
2317        self.spawn_command(async move {
2318            match http.request_instruments_with_config(&provider, us).await {
2319                Ok(all_instruments) => {
2320                    for instrument in &all_instruments {
2321                        upsert_instrument(&instruments, instrument.clone());
2322                    }
2323
2324                    let instrument = all_instruments
2325                        .into_iter()
2326                        .find(|i| i.id() == instrument_id);
2327
2328                    if let Some(instrument) = instrument {
2329                        let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
2330                            request_id,
2331                            client_id,
2332                            instrument.id(),
2333                            instrument,
2334                            start_nanos,
2335                            end_nanos,
2336                            clock.get_time_ns(),
2337                            params,
2338                        )));
2339
2340                        if let Err(e) = sender.send(DataEvent::Response(response)) {
2341                            log::error!("Failed to send instrument response: {e}");
2342                        }
2343                    } else {
2344                        log::error!("Instrument not found: {instrument_id}");
2345                    }
2346                }
2347                Err(e) => log::error!("Instrument request failed: {e:?}"),
2348            }
2349        });
2350
2351        Ok(())
2352    }
2353
2354    fn request_data(&self, request: RequestCustomData) -> anyhow::Result<()> {
2355        if request.data_type.type_name() != "BinanceBar" {
2356            log::warn!(
2357                "Unsupported custom data request: {}",
2358                request.data_type.type_name()
2359            );
2360            return Ok(());
2361        }
2362        let bar_type = parse_binance_bar_type(&request.data_type)?;
2363        anyhow::ensure!(
2364            bar_type.aggregation_source() == AggregationSource::External,
2365            "historical BinanceBar requests require EXTERNAL aggregation"
2366        );
2367        anyhow::ensure!(
2368            bar_type.spec().price_type == PriceType::Last,
2369            "historical BinanceBar requests require LAST price type"
2370        );
2371        anyhow::ensure!(
2372            bar_type.spec().is_time_aggregated(),
2373            "historical BinanceBar requests require time aggregation"
2374        );
2375        let http = self.http_client.clone();
2376        let sender = self.data_sender.clone();
2377        let request_id = request.request_id;
2378        let client_id = request.client_id;
2379        let data_type = request.data_type;
2380        let start = request.start;
2381        let end = request.end;
2382        let limit = request.limit.map(|value| value.get() as u32);
2383        let params = request.params;
2384        let clock = self.clock;
2385        let venue = self.venue();
2386        let start_nanos = datetime_to_unix_nanos(start);
2387        let end_nanos = datetime_to_unix_nanos(end);
2388
2389        self.spawn_command(async move {
2390            match http.request_binance_bars(bar_type, start, end, limit).await {
2391                Ok(bars) => {
2392                    let response = DataResponse::Data(CustomDataResponse::new(
2393                        request_id,
2394                        client_id,
2395                        Some(venue),
2396                        data_type,
2397                        bars,
2398                        start_nanos,
2399                        end_nanos,
2400                        clock.get_time_ns(),
2401                        params,
2402                    ));
2403
2404                    if let Err(e) = sender.send(DataEvent::Response(response)) {
2405                        log::error!("Failed to send BinanceBar response: {e}");
2406                    }
2407                }
2408                Err(e) => log::error!("BinanceBar request failed for {bar_type}: {e:?}"),
2409            }
2410        });
2411        Ok(())
2412    }
2413
2414    fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
2415        let http = self.http_client.clone();
2416        let sender = self.data_sender.clone();
2417        let instrument_id = request.instrument_id;
2418        let limit = request.limit.map(|n| n.get() as u32);
2419        let request_id = request.request_id;
2420        let client_id = request.client_id.unwrap_or(self.client_id);
2421        let params = request.params;
2422        let clock = self.clock;
2423        let start_nanos = datetime_to_unix_nanos(request.start);
2424        let end_nanos = datetime_to_unix_nanos(request.end);
2425        let start = request.start;
2426        let end = request.end;
2427        anyhow::ensure!(
2428            limit.is_none_or(|value| value <= 1000),
2429            "Binance Spot trade limit must not exceed 1000"
2430        );
2431
2432        self.spawn_command(async move {
2433            let result = if start.is_some() || end.is_some() {
2434                http.request_agg_trades(instrument_id, start, end, limit)
2435                    .await
2436            } else {
2437                http.request_trades(instrument_id, limit).await
2438            };
2439
2440            match result.context("failed to request trades from Binance") {
2441                Ok(trades) => {
2442                    let response = DataResponse::Trades(TradesResponse::new(
2443                        request_id,
2444                        client_id,
2445                        instrument_id,
2446                        trades,
2447                        start_nanos,
2448                        end_nanos,
2449                        clock.get_time_ns(),
2450                        params,
2451                    ));
2452
2453                    if let Err(e) = sender.send(DataEvent::Response(response)) {
2454                        log::error!("Failed to send trades response: {e}");
2455                    }
2456                }
2457                Err(e) => log::error!("Trade request failed: {e:?}"),
2458            }
2459        });
2460
2461        Ok(())
2462    }
2463
2464    fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
2465        let http = self.http_client.clone();
2466        let sender = self.data_sender.clone();
2467        let bar_type = request.bar_type;
2468        let start = request.start;
2469        let end = request.end;
2470        let limit = request.limit.map(|n| n.get() as u32);
2471        let request_id = request.request_id;
2472        let client_id = request.client_id.unwrap_or(self.client_id);
2473        let params = request.params;
2474        let clock = self.clock;
2475        let start_nanos = datetime_to_unix_nanos(start);
2476        let end_nanos = datetime_to_unix_nanos(end);
2477        anyhow::ensure!(
2478            bar_type.aggregation_source() == AggregationSource::External,
2479            "Binance historical bars require EXTERNAL aggregation"
2480        );
2481        anyhow::ensure!(
2482            bar_type.spec().price_type == PriceType::Last,
2483            "Binance historical bars require LAST price type"
2484        );
2485        anyhow::ensure!(
2486            bar_type.spec().is_time_aggregated(),
2487            "Binance historical bars require time aggregation"
2488        );
2489
2490        self.spawn_command(async move {
2491            let result = http.request_bars(bar_type, start, end, limit).await;
2492
2493            match result.context("failed to request bars from Binance") {
2494                Ok(bars) => {
2495                    let response = DataResponse::Bars(BarsResponse::new(
2496                        request_id,
2497                        client_id,
2498                        bar_type,
2499                        bars,
2500                        start_nanos,
2501                        end_nanos,
2502                        clock.get_time_ns(),
2503                        params,
2504                    ));
2505
2506                    if let Err(e) = sender.send(DataEvent::Response(response)) {
2507                        log::error!("Failed to send bars response: {e}");
2508                    }
2509                }
2510                Err(e) => log::error!("Bar request failed: {e:?}"),
2511            }
2512        });
2513
2514        Ok(())
2515    }
2516
2517    fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
2518        let depth = request.depth.map(|value| value.get() as u32);
2519        anyhow::ensure!(
2520            depth.is_none_or(|value| (1..=5000).contains(&value)),
2521            "Binance Spot order-book depth must be between 1 and 5000"
2522        );
2523        let http = self.http_client.clone();
2524        let sender = self.data_sender.clone();
2525        let instrument_id = request.instrument_id;
2526        let request_id = request.request_id;
2527        let client_id = request.client_id.unwrap_or(self.client_id);
2528        let params = request.params;
2529        let clock = self.clock;
2530
2531        self.spawn_command(async move {
2532            match http.request_book_snapshot(instrument_id, depth).await {
2533                Ok(book) => {
2534                    let response = DataResponse::Book(BookResponse::new(
2535                        request_id,
2536                        client_id,
2537                        instrument_id,
2538                        book,
2539                        None,
2540                        None,
2541                        clock.get_time_ns(),
2542                        params,
2543                    ));
2544
2545                    if let Err(e) = sender.send(DataEvent::Response(response)) {
2546                        log::error!("Failed to send book snapshot response: {e}");
2547                    }
2548                }
2549                Err(e) => log::error!("Book snapshot request failed for {instrument_id}: {e:?}"),
2550            }
2551        });
2552        Ok(())
2553    }
2554}
2555
2556impl BinanceSpotDataClient {
2557    fn subscribe_top_of_book(&self, instrument_id: InstrumentId) {
2558        let should_subscribe = {
2559            let previous = self
2560                .quote_refs
2561                .load()
2562                .get(&instrument_id)
2563                .copied()
2564                .unwrap_or(0);
2565            self.quote_refs.rcu(|refs| {
2566                *refs.entry(instrument_id).or_insert(0) += 1;
2567            });
2568            previous == 0
2569        };
2570
2571        if should_subscribe {
2572            let ws = self.ws_client.clone();
2573            let suffix = self.quote_stream_suffix();
2574            let stream = format!("{}@{suffix}", instrument_id.symbol.as_str().to_lowercase());
2575            self.spawn_ws(
2576                async move {
2577                    ws.subscribe(vec![stream])
2578                        .await
2579                        .context("top-of-book subscription")
2580                },
2581                "top-of-book subscription",
2582            );
2583        }
2584    }
2585
2586    fn unsubscribe_top_of_book(&self, instrument_id: InstrumentId) {
2587        let should_unsubscribe = match self.quote_refs.load().get(&instrument_id).copied() {
2588            Some(1) => {
2589                self.quote_refs.remove(&instrument_id);
2590                true
2591            }
2592            Some(count) if count > 1 => {
2593                self.quote_refs.rcu(|refs| {
2594                    if let Some(existing) = refs.get_mut(&instrument_id) {
2595                        *existing -= 1;
2596                    }
2597                });
2598                false
2599            }
2600            _ => false,
2601        };
2602
2603        if should_unsubscribe {
2604            let ws = self.ws_client.clone();
2605            let suffix = self.quote_stream_suffix();
2606            let stream = format!("{}@{suffix}", instrument_id.symbol.as_str().to_lowercase());
2607            self.spawn_ws(
2608                async move {
2609                    ws.unsubscribe(vec![stream])
2610                        .await
2611                        .context("top-of-book unsubscribe")
2612                },
2613                "top-of-book unsubscribe",
2614            );
2615        }
2616    }
2617}
2618
2619#[cfg(test)]
2620mod tests {
2621    use std::{sync::Arc, time::Duration};
2622
2623    use nautilus_common::messages::DataEvent;
2624    use nautilus_core::{AtomicMap, nanos::UnixNanos, time::AtomicTime};
2625    use nautilus_live::task::TaskGroup;
2626    use nautilus_model::{
2627        data::{BookOrder, Data, OrderBookDelta, OrderBookDeltas},
2628        enums::{BookAction, OrderSide, RecordFlag},
2629        identifiers::InstrumentId,
2630        instruments::{Instrument, InstrumentAny, stubs::currency_pair_btcusdt},
2631        types::{Price, Quantity},
2632    };
2633    use parking_lot::RwLock;
2634    use rstest::rstest;
2635    use rust_decimal_macros::dec;
2636    use ustr::Ustr;
2637
2638    use super::{
2639        BinanceDepth, BinanceEnvironment, BinanceSpotDataClient, BinanceSpotMarketDataMode,
2640        BookBuffer, BufferedDepthUpdate, first_applicable_spot_update, parse_spot_depth_snapshot,
2641        resolve_spot_json_ws_url, spot_continuity_ok, spot_overlap_valid,
2642        spot_snapshot_retry_backoff,
2643    };
2644    use crate::{
2645        common::consts::BINANCE_SPOT_WS_URL,
2646        spot::{
2647            http::{BinancePriceLevel, BinanceSpotHttpClient},
2648            sbe::stream::BestBidAskStreamEvent,
2649            websocket::streams::messages::BinanceSpotWsMessage,
2650        },
2651    };
2652
2653    #[rstest]
2654    fn handle_ws_message_uses_clock_timestamp_for_sbe_bbo_ts_init() {
2655        let ts_init = UnixNanos::from(1_800_000_000_000_000_000u64);
2656        let clock = Box::leak(Box::new(AtomicTime::new(false, ts_init)));
2657        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
2658        let instruments = Arc::new(AtomicMap::new());
2659        instruments.insert(instrument.id(), instrument.clone());
2660        let ws_instruments = Arc::new(AtomicMap::new());
2661        ws_instruments.insert(Ustr::from("BTCUSDT"), instrument);
2662        let book_buffers = Arc::new(AtomicMap::<InstrumentId, BookBuffer>::new());
2663        let book_subscriptions = Arc::new(AtomicMap::<InstrumentId, u32>::new());
2664        let l1_book_subscriptions = Arc::new(AtomicMap::<InstrumentId, u32>::new());
2665        let book_epoch = Arc::new(RwLock::new(0));
2666        let http_client = BinanceSpotHttpClient::new(
2667            BinanceEnvironment::Testnet,
2668            clock,
2669            None,
2670            None,
2671            None,
2672            None,
2673            None,
2674            None,
2675        )
2676        .unwrap();
2677        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
2678        let event_time_us = 1_700_000_000_000_000;
2679        let message = BinanceSpotWsMessage::BestBidAsk(BestBidAskStreamEvent {
2680            event_time_us,
2681            book_update_id: 123,
2682            price_exponent: -2,
2683            qty_exponent: -4,
2684            bid_price_mantissa: 12_345,
2685            bid_qty_mantissa: 25_000,
2686            ask_price_mantissa: 12_350,
2687            ask_qty_mantissa: 30_000,
2688            symbol: Ustr::from("BTCUSDT"),
2689        });
2690        let command_tasks = TaskGroup::new();
2691        let command_spawner = command_tasks.spawner().unwrap();
2692
2693        BinanceSpotDataClient::handle_ws_message(
2694            message,
2695            &sender,
2696            &instruments,
2697            &ws_instruments,
2698            &book_buffers,
2699            &book_subscriptions,
2700            &l1_book_subscriptions,
2701            &book_epoch,
2702            &http_client,
2703            clock,
2704            &command_spawner,
2705        );
2706
2707        let DataEvent::Data(Data::Quote(quote)) = receiver.try_recv().unwrap() else {
2708            panic!("expected quote data event");
2709        };
2710        assert_eq!(quote.ts_event, UnixNanos::from_micros(event_time_us as u64));
2711        assert_eq!(quote.ts_init, ts_init);
2712    }
2713
2714    #[rstest]
2715    fn overlap_accepts_first_diff_straddling_snapshot() {
2716        assert!(spot_overlap_valid(90, 110, 100));
2717        assert!(spot_overlap_valid(101, 101, 100));
2718        assert!(spot_overlap_valid(101, 200, 100));
2719    }
2720
2721    #[rstest]
2722    fn overlap_rejects_gap_and_stale() {
2723        assert!(!spot_overlap_valid(103, 110, 100));
2724        assert!(!spot_overlap_valid(90, 100, 100));
2725    }
2726
2727    #[rstest]
2728    fn continuity_skips_first_then_requires_contiguous_u() {
2729        assert!(spot_continuity_ok(true, 999, 100));
2730        assert!(spot_continuity_ok(false, 101, 100));
2731        assert!(!spot_continuity_ok(false, 102, 100));
2732        assert!(!spot_continuity_ok(false, 100, 100));
2733    }
2734
2735    #[rstest]
2736    #[case(0, 250)]
2737    #[case(1, 500)]
2738    #[case(2, 1_000)]
2739    #[case(3, 2_000)]
2740    #[case(4, 3_000)]
2741    #[case(5, 3_000)]
2742    fn snapshot_retry_backoff_exponentially_increases_then_caps(
2743        #[case] retry_count: u32,
2744        #[case] expected_ms: u64,
2745    ) {
2746        assert_eq!(
2747            spot_snapshot_retry_backoff(retry_count),
2748            Duration::from_millis(expected_ms)
2749        );
2750    }
2751
2752    #[rstest]
2753    fn first_applicable_update_skips_stale_diffs() {
2754        let updates = vec![
2755            buffered_update(90, 100),
2756            buffered_update(101, 101),
2757            buffered_update(102, 103),
2758        ];
2759
2760        let update = first_applicable_spot_update(&updates, 100).unwrap();
2761
2762        assert_eq!(update.first_update_id, 101);
2763        assert_eq!(update.final_update_id, 101);
2764        assert!(first_applicable_spot_update(&updates, 103).is_none());
2765    }
2766
2767    #[rstest]
2768    fn parse_spot_depth_snapshot_sets_sequence_and_last_flag() {
2769        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2770        let depth = depth_snapshot(
2771            vec![price_level(10_000, 1_000)],
2772            vec![price_level(10_100, 2_000)],
2773        );
2774
2775        let deltas = parse_spot_depth_snapshot(
2776            &depth,
2777            instrument_id,
2778            2,
2779            3,
2780            UnixNanos::from(1),
2781            UnixNanos::from(2),
2782        )
2783        .unwrap()
2784        .unwrap();
2785
2786        assert_eq!(deltas.deltas.len(), 3);
2787        assert_eq!(deltas.deltas[0].sequence, 123);
2788        assert_eq!(deltas.deltas[1].sequence, 123);
2789        assert_eq!(deltas.deltas[2].sequence, 123);
2790        assert_eq!(deltas.ts_event, UnixNanos::from(1));
2791        assert_eq!(deltas.ts_init, UnixNanos::from(2));
2792        assert_eq!(deltas.deltas[1].order.price.as_decimal(), dec!(100.00));
2793        assert_eq!(deltas.deltas[1].order.size.as_decimal(), dec!(1.000));
2794        assert_eq!(deltas.deltas[1].flags, 0);
2795        assert_eq!(deltas.deltas[2].flags, RecordFlag::F_LAST as u8);
2796    }
2797
2798    #[rstest]
2799    fn parse_spot_depth_snapshot_sets_last_flag_for_bid_only_snapshot() {
2800        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2801        let depth = depth_snapshot(vec![price_level(10_000, 1_000)], vec![]);
2802
2803        let deltas = parse_spot_depth_snapshot(
2804            &depth,
2805            instrument_id,
2806            2,
2807            3,
2808            UnixNanos::from(1),
2809            UnixNanos::from(2),
2810        )
2811        .unwrap()
2812        .unwrap();
2813
2814        assert_eq!(deltas.deltas.len(), 2);
2815        assert_eq!(deltas.deltas[1].flags, RecordFlag::F_LAST as u8);
2816    }
2817
2818    #[rstest]
2819    fn parse_spot_depth_snapshot_returns_none_for_empty_book() {
2820        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2821        let depth = depth_snapshot(vec![], vec![]);
2822
2823        let deltas = parse_spot_depth_snapshot(
2824            &depth,
2825            instrument_id,
2826            2,
2827            3,
2828            UnixNanos::from(1),
2829            UnixNanos::from(2),
2830        )
2831        .unwrap();
2832
2833        assert!(deltas.is_none());
2834    }
2835
2836    #[rstest]
2837    fn parse_spot_depth_snapshot_rejects_out_of_range_price() {
2838        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2839        let depth = BinanceDepth {
2840            last_update_id: 123,
2841            price_exponent: 100,
2842            qty_exponent: -3,
2843            bids: vec![price_level(i64::MAX, 1_000)],
2844            asks: vec![],
2845        };
2846
2847        let result = parse_spot_depth_snapshot(
2848            &depth,
2849            instrument_id,
2850            2,
2851            3,
2852            UnixNanos::from(1),
2853            UnixNanos::from(2),
2854        );
2855
2856        assert!(result.is_err());
2857    }
2858
2859    #[rstest]
2860    fn parse_spot_depth_snapshot_rejects_out_of_range_quantity() {
2861        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2862        let depth = BinanceDepth {
2863            last_update_id: 123,
2864            price_exponent: -2,
2865            qty_exponent: 100,
2866            bids: vec![price_level(10_000, i64::MAX)],
2867            asks: vec![],
2868        };
2869
2870        let result = parse_spot_depth_snapshot(
2871            &depth,
2872            instrument_id,
2873            2,
2874            3,
2875            UnixNanos::from(1),
2876            UnixNanos::from(2),
2877        );
2878
2879        assert!(result.is_err());
2880    }
2881
2882    fn buffered_update(first_update_id: u64, final_update_id: u64) -> BufferedDepthUpdate {
2883        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2884        let ts = UnixNanos::default();
2885        let order = BookOrder::new(
2886            OrderSide::Buy,
2887            Price::from_raw(1, 0),
2888            Quantity::from_raw(1, 0),
2889            0,
2890        );
2891        let delta = OrderBookDelta::new(
2892            instrument_id,
2893            BookAction::Update,
2894            order,
2895            0,
2896            final_update_id,
2897            ts,
2898            ts,
2899        );
2900        let deltas = OrderBookDeltas::new(instrument_id, vec![delta]);
2901
2902        BufferedDepthUpdate {
2903            deltas,
2904            first_update_id,
2905            final_update_id,
2906        }
2907    }
2908
2909    fn depth_snapshot(bids: Vec<BinancePriceLevel>, asks: Vec<BinancePriceLevel>) -> BinanceDepth {
2910        BinanceDepth {
2911            last_update_id: 123,
2912            price_exponent: -2,
2913            qty_exponent: -3,
2914            bids,
2915            asks,
2916        }
2917    }
2918
2919    fn price_level(price_mantissa: i64, qty_mantissa: i64) -> BinancePriceLevel {
2920        BinancePriceLevel {
2921            price_mantissa,
2922            qty_mantissa,
2923        }
2924    }
2925
2926    #[rstest]
2927    fn test_spot_market_data_mode_default_is_sbe() {
2928        assert_eq!(
2929            BinanceSpotMarketDataMode::default(),
2930            BinanceSpotMarketDataMode::Sbe
2931        );
2932    }
2933
2934    #[rstest]
2935    fn test_resolve_spot_json_ws_url_uses_environment_default_without_override() {
2936        assert_eq!(
2937            resolve_spot_json_ws_url(None, BinanceEnvironment::Live, false),
2938            BINANCE_SPOT_WS_URL.to_string()
2939        );
2940    }
2941
2942    #[rstest]
2943    fn test_resolve_spot_json_ws_url_rewrites_sbe_override_to_spot_default() {
2944        assert_eq!(
2945            resolve_spot_json_ws_url(
2946                Some("wss://stream-sbe.binance.com/ws".to_string()),
2947                BinanceEnvironment::Live,
2948                false,
2949            ),
2950            BINANCE_SPOT_WS_URL.to_string()
2951        );
2952    }
2953
2954    #[rstest]
2955    fn test_resolve_spot_json_ws_url_preserves_non_sbe_override() {
2956        let custom = "wss://example.com/ws".to_string();
2957        assert_eq!(
2958            resolve_spot_json_ws_url(Some(custom.clone()), BinanceEnvironment::Live, false),
2959            custom
2960        );
2961    }
2962
2963    #[rstest]
2964    fn test_resolve_spot_json_ws_url_uses_binance_us_default() {
2965        assert_eq!(
2966            resolve_spot_json_ws_url(None, BinanceEnvironment::Live, true),
2967            "wss://stream.binance.us:9443/ws"
2968        );
2969    }
2970}