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