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    num::NonZeroU32,
20    str::FromStr,
21    sync::{
22        Arc,
23        atomic::{AtomicBool, Ordering},
24    },
25    time::Duration,
26};
27
28use ahash::AHashMap;
29use anyhow::Context;
30use futures_util::{StreamExt, pin_mut};
31use nautilus_common::{
32    clients::DataClient,
33    live::{runner::get_data_event_sender, sender::EventSender},
34    messages::{
35        DataEvent,
36        data::{
37            BarsResponse, BookResponse, CustomDataResponse, DataResponse, InstrumentResponse,
38            InstrumentsResponse, RequestBars, RequestBookSnapshot, RequestCustomData,
39            RequestInstrument, RequestInstruments, RequestTrades, SubscribeBars,
40            SubscribeBookDeltas, SubscribeCustomData, SubscribeInstrument, SubscribeInstruments,
41            SubscribeQuotes, SubscribeTrades, TradesResponse, UnsubscribeBars,
42            UnsubscribeBookDeltas, UnsubscribeCustomData, UnsubscribeQuotes, UnsubscribeTrades,
43            subscribe::SubscribeInstrumentStatus, unsubscribe::UnsubscribeInstrumentStatus,
44        },
45    },
46};
47use nautilus_core::{
48    AtomicMap, Params,
49    datetime::datetime_to_unix_nanos,
50    nanos::UnixNanos,
51    time::{AtomicTime, get_atomic_clock_realtime},
52};
53use nautilus_live::{
54    SocketControlFactory,
55    task::{TaskGroup, TaskGroupGuard, TaskSpawner},
56};
57use nautilus_model::{
58    data::{BookOrder, CustomData, Data, DataType, OrderBookDelta, OrderBookDeltas, QuoteTick},
59    enums::{
60        AggregationSource, BookAction, BookType, MarketStatusAction, OrderSide, PriceType,
61        RecordFlag,
62    },
63    identifiers::{ClientId, InstrumentId, Venue},
64    instruments::{Instrument, InstrumentAny},
65    types::{Price, Quantity},
66};
67use parking_lot::Mutex;
68use tokio_util::sync::CancellationToken;
69use ustr::Ustr;
70
71use crate::{
72    book::{
73        BinanceBookError,
74        pacing::SnapshotPacer,
75        recovery::spawn_recovery,
76        sync::{BookSyncTracker, DepthSequencing, DepthSnapshot, DepthUpdate},
77    },
78    common::{
79        bar::{binance_bar_data_type, binance_bars_to_custom_data, parse_binance_bar_type},
80        consts::{BINANCE_VENUE, BINANCE_WS_HEARTBEAT_SECS},
81        credential::resolve_credentials,
82        enums::{BinanceEnvironment, BinanceProductType},
83        parse::{bar_spec_to_binance_interval, quote_to_l1_deltas},
84        status::diff_and_emit_statuses,
85        urls::{get_http_base_url_with_us, get_ws_base_url_with_us},
86        websocket::chain_command,
87    },
88    config::{BinanceDataClientConfig, BinanceSpotMarketDataMode},
89    data_types::register_binance_custom_data,
90    spot::{
91        http::{BinanceDepth, BinanceSpotHttpError, DepthParams, client::BinanceSpotHttpClient},
92        websocket::{
93            public_json::{
94                BinanceSpotPublicJsonWebSocketClient,
95                messages::BinanceSpotPublicWsMessage,
96                parse::{
97                    parse_book_ticker as parse_json_book_ticker,
98                    parse_depth_diff as parse_json_depth_diff,
99                    parse_depth_snapshot as parse_json_depth_snapshot,
100                    parse_kline as parse_json_kline, parse_ticker as parse_json_ticker,
101                    parse_trade as parse_json_trade,
102                },
103            },
104            streams::{
105                client::BinanceSpotWebSocketClient,
106                messages::BinanceSpotWsMessage,
107                parse::{
108                    parse_bbo_event, parse_depth_diff, parse_depth_snapshot, parse_trades_event,
109                },
110            },
111        },
112    },
113};
114
115const BOOK_DEPTHS_JSON: [usize; 3] = [5, 10, 20];
116
117// Venue maximum, so seeded books are as deep as REST allows, at the top request weight of 250
118const SNAPSHOT_DEPTH: u32 = 5000;
119
120// Half the venue's 6,000 per-minute request weight, leaving room for other requests
121const SNAPSHOT_WEIGHT_PER_MINUTE: NonZeroU32 = NonZeroU32::new(3_000).expect("non-zero");
122
123/// Binance Spot data client for SBE market data.
124#[derive(Debug)]
125pub struct BinanceSpotDataClient {
126    clock: &'static AtomicTime,
127    client_id: ClientId,
128    config: BinanceDataClientConfig,
129    http_client: BinanceSpotHttpClient,
130    ws_client: SpotWsClient,
131    spot_market_data_mode: BinanceSpotMarketDataMode,
132    is_connected: AtomicBool,
133    cancellation_token: CancellationToken,
134    session_tasks: TaskGroup,
135    command_tasks: TaskGroup,
136    shutdown_errors: Vec<String>,
137    data_sender: EventSender<DataEvent>,
138    instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
139    status_cache: Arc<AtomicMap<InstrumentId, MarketStatusAction>>,
140    book_sync: BookSyncTracker,
141    book_subscriptions: Arc<AtomicMap<InstrumentId, u32>>,
142    l1_book_subscriptions: Arc<AtomicMap<InstrumentId, u32>>,
143    quote_refs: Arc<AtomicMap<InstrumentId, u32>>,
144    ticker_refs: Arc<AtomicMap<InstrumentId, u32>>,
145    book_command_tail: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
146}
147
148impl BinanceSpotDataClient {
149    /// Creates a new [`BinanceSpotDataClient`] instance.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the client fails to initialize.
154    pub fn new(client_id: ClientId, config: BinanceDataClientConfig) -> anyhow::Result<Self> {
155        config.validate()?;
156        let clock = get_atomic_clock_realtime();
157        let spot_market_data_mode = config.spot_market_data_mode;
158        let base_url_http = config.base_url_http.clone().or_else(|| {
159            config.us.then(|| {
160                get_http_base_url_with_us(config.product_type, config.environment, true).to_string()
161            })
162        });
163        let api_key = config
164            .api_key
165            .as_ref()
166            .map(|value| value.expose_secret().to_owned());
167        let api_secret = config
168            .api_secret
169            .as_ref()
170            .map(|value| value.expose_secret().to_owned());
171        let proxy_url = config
172            .proxy_url
173            .as_ref()
174            .map(|value| value.expose_secret().to_owned());
175
176        let http_client = BinanceSpotHttpClient::new_with_json_responses(
177            config.environment,
178            clock,
179            api_key.clone(),
180            api_secret.clone(),
181            base_url_http,
182            Some(config.recv_window_ms),
183            None, // timeout_secs
184            proxy_url.clone(),
185            config.us,
186        )?
187        .with_retry_config(config.retry_config());
188
189        let creds = if spot_market_data_mode == BinanceSpotMarketDataMode::Sbe {
190            resolve_credentials(api_key, api_secret, config.environment, config.product_type)
191                .inspect_err(|e| {
192                    log::warn!(
193                        "Failed to resolve Binance API credentials ({e}). \
194                     Spot SBE WebSocket streams require an Ed25519 API key. \
195                     Set the appropriate env vars for your environment, \
196                     or provide api_key/api_secret in the data client config"
197                    );
198                })
199                .ok()
200        } else {
201            None
202        };
203
204        let socket_factory = SocketControlFactory::new(client_id, Some(*BINANCE_VENUE));
205        let ws_client = match spot_market_data_mode {
206            // SBE streams require Ed25519 authentication
207            BinanceSpotMarketDataMode::Sbe => SpotWsClient::Sbe(
208                BinanceSpotWebSocketClient::new(
209                    config.base_url_ws.clone(),
210                    creds.as_ref().map(|(k, _)| k.clone()),
211                    creds.as_ref().map(|(_, s)| s.clone()),
212                    Some(BINANCE_WS_HEARTBEAT_SECS),
213                    config.transport_backend,
214                )?
215                .with_proxy(proxy_url)
216                .with_socket_control(socket_factory, "binance-spot-sbe-data-streams"),
217            ),
218            BinanceSpotMarketDataMode::Json => SpotWsClient::JsonPublic(
219                BinanceSpotPublicJsonWebSocketClient::new(
220                    Some(resolve_spot_json_ws_url(
221                        config.base_url_ws.clone(),
222                        config.environment,
223                        config.us,
224                    )),
225                    Some(BINANCE_WS_HEARTBEAT_SECS),
226                    config.transport_backend,
227                )
228                .with_proxy(proxy_url)
229                .with_socket_control(socket_factory, "binance-spot-json-data-streams"),
230            ),
231        };
232        let data_sender = get_data_event_sender();
233        let snapshot_pacer = Arc::new(SnapshotPacer::new(SNAPSHOT_WEIGHT_PER_MINUTE));
234        let book_sync =
235            BookSyncTracker::new(DepthSequencing::Spot, data_sender.clone(), snapshot_pacer);
236
237        log::debug!("Configured Spot market data mode: {spot_market_data_mode:?}");
238
239        let session_tasks = TaskGroup::new();
240        let command_tasks = TaskGroup::new();
241
242        Ok(Self {
243            clock,
244            client_id,
245            config,
246            http_client,
247            ws_client,
248            spot_market_data_mode,
249            is_connected: AtomicBool::new(false),
250            cancellation_token: session_tasks.cancellation_token(),
251            session_tasks,
252            command_tasks,
253            shutdown_errors: Vec::new(),
254            data_sender,
255            instruments: Arc::new(AtomicMap::new()),
256            status_cache: Arc::new(AtomicMap::new()),
257            book_sync,
258            book_subscriptions: Arc::new(AtomicMap::new()),
259            l1_book_subscriptions: Arc::new(AtomicMap::new()),
260            quote_refs: Arc::new(AtomicMap::new()),
261            ticker_refs: Arc::new(AtomicMap::new()),
262            book_command_tail: Mutex::new(None),
263        })
264    }
265
266    fn venue(&self) -> Venue {
267        *BINANCE_VENUE
268    }
269
270    fn send_data(sender: &EventSender<DataEvent>, data: Data) {
271        if let Err(e) = sender.send(DataEvent::Data(data)) {
272            log::error!("Failed to emit data event: {e}");
273        }
274    }
275
276    fn spawn_ws<F>(&self, fut: F, context: &'static str)
277    where
278        F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
279    {
280        let future = async move {
281            if let Err(e) = fut.await {
282                log::error!("{context}: {e:?}");
283            }
284        };
285
286        if let Err(e) = self.command_tasks.spawn(future) {
287            log::warn!("Skipping Binance Spot {context} after shutdown began: {e}");
288        }
289    }
290
291    fn spawn_command<F>(&self, future: F)
292    where
293        F: std::future::Future<Output = ()> + Send + 'static,
294    {
295        if let Err(e) = self.command_tasks.spawn(future) {
296            log::warn!("Skipping Binance Spot data command after shutdown began: {e}");
297        }
298    }
299
300    async fn finish_tasks(&self) -> anyhow::Result<()> {
301        let (session_result, command_result) = tokio::join!(
302            self.session_tasks
303                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
304            self.command_tasks
305                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
306        );
307        let mut errors = Vec::new();
308        if let Err(e) = session_result {
309            errors.push(format!(
310                "failed to finish Binance Spot data session tasks: {e}"
311            ));
312        }
313
314        if let Err(e) = command_result {
315            errors.push(format!(
316                "failed to finish Binance Spot data command tasks: {e}"
317            ));
318        }
319
320        if !errors.is_empty() {
321            anyhow::bail!(errors.join("; "));
322        }
323        Ok(())
324    }
325
326    async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
327        if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
328            self.teardown_partial_connect().await?;
329            self.session_tasks
330                .start_generation()
331                .context("failed to start Binance Spot data session task generation")?;
332            self.command_tasks
333                .start_generation()
334                .context("failed to start Binance Spot data command task generation")?;
335            self.cancellation_token = self.session_tasks.cancellation_token();
336        }
337        Ok(())
338    }
339
340    async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
341        self.session_tasks.begin_shutdown();
342        self.command_tasks.begin_shutdown();
343        self.ws_client.begin_shutdown();
344        if let Err(e) = self.ws_client.close().await {
345            self.shutdown_errors
346                .push(format!("WebSocket close failed: {e}"));
347        }
348
349        if let Err(e) = self.finish_tasks().await {
350            self.shutdown_errors.push(e.to_string());
351        }
352        self.is_connected.store(false, Ordering::Release);
353
354        if !self.shutdown_errors.is_empty() {
355            let errors = std::mem::take(&mut self.shutdown_errors);
356            anyhow::bail!("Binance Spot data teardown failed: {}", errors.join("; "));
357        }
358        Ok(())
359    }
360
361    #[expect(clippy::too_many_arguments)]
362    async fn refresh_instrument_catalog(
363        http: &BinanceSpotHttpClient,
364        provider: &crate::config::BinanceInstrumentProviderConfig,
365        us: bool,
366        instruments_cache: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
367        status_cache: &Arc<AtomicMap<InstrumentId, MarketStatusAction>>,
368        ws: &SpotWsClient,
369        sender: &EventSender<DataEvent>,
370        clock: &'static AtomicTime,
371        emit_status_changes: bool,
372    ) -> anyhow::Result<Vec<InstrumentAny>> {
373        let instruments = http
374            .request_instruments_with_config(provider, us)
375            .await
376            .context("failed to request Binance Spot instruments")?;
377        let venue_statuses = http
378            .request_symbol_statuses(us)
379            .await
380            .context("failed to request Binance Spot instrument statuses")?;
381
382        let instrument_map = instruments
383            .iter()
384            .map(|instrument| (instrument.id(), instrument.clone()))
385            .collect::<AHashMap<_, _>>();
386        let status_map = venue_statuses
387            .into_iter()
388            .filter(|(instrument_id, _)| instrument_map.contains_key(instrument_id))
389            .collect::<AHashMap<_, _>>();
390
391        instruments_cache.store(instrument_map);
392        ws.replace_instruments(&instruments);
393
394        if emit_status_changes {
395            let mut cached_statuses = (**status_cache.load()).clone();
396            let ts = clock.get_time_ns();
397            diff_and_emit_statuses(&status_map, &mut cached_statuses, sender, ts, ts);
398            status_cache.store(cached_statuses);
399        } else {
400            status_cache.store(status_map);
401        }
402
403        for instrument in &instruments {
404            if let Err(e) = sender.send(DataEvent::Instrument(instrument.clone())) {
405                log::warn!("Failed to send refreshed Binance Spot instrument: {e}");
406            }
407        }
408
409        Ok(instruments)
410    }
411
412    #[expect(clippy::too_many_arguments)]
413    fn handle_ws_message(
414        msg: BinanceSpotWsMessage,
415        data_sender: &EventSender<DataEvent>,
416        instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
417        ws_instruments: &Arc<AtomicMap<Ustr, InstrumentAny>>,
418        book_sync: &BookSyncTracker,
419        l1_book_subscriptions: &Arc<AtomicMap<InstrumentId, u32>>,
420        http_client: &BinanceSpotHttpClient,
421        snapshot_timeout: Duration,
422        clock: &'static AtomicTime,
423        command_spawner: &TaskSpawner,
424    ) {
425        let ts_init = clock.get_time_ns();
426
427        match msg {
428            BinanceSpotWsMessage::Trades(ref event) => {
429                let symbol = event.symbol;
430                let cache = ws_instruments.load();
431                if let Some(instrument) = cache.get(&symbol) {
432                    let trades = parse_trades_event(event, instrument, ts_init);
433                    for data in trades {
434                        Self::send_data(data_sender, data);
435                    }
436                }
437            }
438            BinanceSpotWsMessage::BestBidAsk(ref event) => {
439                let symbol = event.symbol;
440                let cache = ws_instruments.load();
441                if let Some(instrument) = cache.get(&symbol) {
442                    let quote = parse_bbo_event(event, instrument, ts_init);
443                    Self::send_top_of_book(
444                        data_sender,
445                        l1_book_subscriptions,
446                        quote,
447                        event.book_update_id as u64,
448                    );
449                }
450            }
451            BinanceSpotWsMessage::DepthSnapshot(ref event) => {
452                let symbol = event.symbol;
453                let cache = ws_instruments.load();
454                if let Some(instrument) = cache.get(&symbol) {
455                    let deltas = parse_depth_snapshot(event, instrument, ts_init);
456                    Self::send_data(data_sender, Data::BookDeltas(Box::new(deltas)));
457                }
458            }
459            BinanceSpotWsMessage::DepthDiff(ref event) => {
460                let symbol = event.symbol;
461                let cache = ws_instruments.load();
462                if let Some(instrument) = cache.get(&symbol) {
463                    let update = DepthUpdate {
464                        first_update_id: event.first_book_update_id as u64,
465                        final_update_id: event.last_book_update_id as u64,
466                        prev_final_update_id: None,
467                        deltas: parse_depth_diff(event, instrument, ts_init),
468                    };
469
470                    Self::route_depth_diff(
471                        instrument.id(),
472                        update,
473                        book_sync,
474                        instruments,
475                        http_client,
476                        snapshot_timeout,
477                        clock,
478                        command_spawner,
479                    );
480                }
481            }
482            BinanceSpotWsMessage::ServerShutdown(ref msg) => {
483                log::warn!(
484                    "Binance server shutdown notice (event_time={}); disconnect expected within ~10 minutes",
485                    msg.event_time,
486                );
487            }
488            BinanceSpotWsMessage::RawBinary(data) => {
489                log::debug!("Unhandled binary message: {} bytes", data.len());
490            }
491            BinanceSpotWsMessage::RawJson(value) => {
492                log::debug!("Unhandled JSON message: {value:?}");
493            }
494            BinanceSpotWsMessage::Error(e) => {
495                log::warn!("Binance WebSocket error: code={}, msg={}", e.code, e.msg);
496            }
497            BinanceSpotWsMessage::Reconnected => {
498                log::info!("WebSocket reconnected, rebuilding order book snapshots");
499                book_sync.reset_on_reconnect();
500            }
501        }
502    }
503
504    #[expect(clippy::too_many_arguments)]
505    fn handle_public_json_ws_message(
506        msg: BinanceSpotPublicWsMessage,
507        data_sender: &EventSender<DataEvent>,
508        instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
509        ws_instruments: &Arc<AtomicMap<Ustr, InstrumentAny>>,
510        book_sync: &BookSyncTracker,
511        l1_book_subscriptions: &Arc<AtomicMap<InstrumentId, u32>>,
512        http_client: &BinanceSpotHttpClient,
513        snapshot_timeout: Duration,
514        clock: &'static AtomicTime,
515        command_spawner: &TaskSpawner,
516    ) {
517        let ts_init = clock.get_time_ns();
518
519        match msg {
520            BinanceSpotPublicWsMessage::Trade(ref event) => {
521                let symbol = event.symbol;
522                let cache = ws_instruments.load();
523                if let Some(instrument) = cache.get(&symbol) {
524                    match parse_json_trade(event, instrument, ts_init) {
525                        Ok(trade) => Self::send_data(data_sender, Data::Trade(trade)),
526                        Err(e) => log::warn!("Failed to parse Spot JSON trade: {e}"),
527                    }
528                }
529            }
530            BinanceSpotPublicWsMessage::BookTicker(ref event) => {
531                let symbol = event.symbol;
532                let cache = ws_instruments.load();
533                if let Some(instrument) = cache.get(&symbol) {
534                    match parse_json_book_ticker(event, instrument, ts_init) {
535                        Ok(quote) => Self::send_top_of_book(
536                            data_sender,
537                            l1_book_subscriptions,
538                            quote,
539                            event.book_update_id,
540                        ),
541                        Err(e) => log::warn!("Failed to parse Spot JSON book ticker: {e}"),
542                    }
543                }
544            }
545            BinanceSpotPublicWsMessage::DepthSnapshot(ref event) => {
546                let symbol = event.symbol;
547                let cache = ws_instruments.load();
548                if let Some(instrument) = cache.get(&symbol) {
549                    let deltas = parse_json_depth_snapshot(event, instrument, ts_init);
550                    Self::send_data(data_sender, Data::BookDeltas(Box::new(deltas)));
551                }
552            }
553            BinanceSpotPublicWsMessage::DepthDiff(ref event) => {
554                let symbol = event.symbol;
555                let cache = ws_instruments.load();
556                if let Some(instrument) = cache.get(&symbol) {
557                    // A dropped diff surfaces as a sequence gap on the next one
558                    match parse_json_depth_diff(event, instrument, ts_init) {
559                        Ok(deltas) => {
560                            let update = DepthUpdate {
561                                first_update_id: event.first_update_id,
562                                final_update_id: event.final_update_id,
563                                prev_final_update_id: None,
564                                deltas,
565                            };
566
567                            Self::route_depth_diff(
568                                instrument.id(),
569                                update,
570                                book_sync,
571                                instruments,
572                                http_client,
573                                snapshot_timeout,
574                                clock,
575                                command_spawner,
576                            );
577                        }
578                        Err(e) => log::warn!("Failed to parse Spot JSON depth update: {e}"),
579                    }
580                }
581            }
582            BinanceSpotPublicWsMessage::Kline(ref event) => {
583                let symbol = event.symbol;
584                let cache = ws_instruments.load();
585                if let Some(instrument) = cache.get(&symbol) {
586                    match parse_json_kline(event, instrument, ts_init) {
587                        Ok(Some(bar)) => {
588                            Self::send_data(data_sender, Data::Bar(bar.bar()));
589                            let data_type = binance_bar_data_type(bar.bar_type);
590                            Self::send_data(
591                                data_sender,
592                                Data::Custom(CustomData::new(Arc::new(bar), data_type)),
593                            );
594                        }
595                        Ok(None) => {} // Kline not closed yet
596                        Err(e) => log::warn!("Failed to parse Spot JSON kline: {e}"),
597                    }
598                }
599            }
600            BinanceSpotPublicWsMessage::Ticker(ref event) => {
601                let symbol = event.symbol;
602                let cache = ws_instruments.load();
603                if let Some(instrument) = cache.get(&symbol) {
604                    match parse_json_ticker(event, instrument, ts_init) {
605                        Ok(ticker) => {
606                            let data_type = spot_ticker_data_type(instrument.id());
607                            Self::send_data(
608                                data_sender,
609                                Data::Custom(CustomData::new(Arc::new(ticker), data_type)),
610                            );
611                        }
612                        Err(e) => log::warn!("Failed to parse Spot JSON ticker: {e}"),
613                    }
614                }
615            }
616            BinanceSpotPublicWsMessage::ServerShutdown(ref msg) => {
617                log::warn!(
618                    "Binance Spot JSON server shutdown notice (event_time={}); disconnect expected within ~10 minutes",
619                    msg.event_time,
620                );
621            }
622            BinanceSpotPublicWsMessage::RawJson(value) => {
623                log::debug!("Unhandled Spot JSON message: {value:?}");
624            }
625            BinanceSpotPublicWsMessage::Error(e) => {
626                log::warn!("Spot JSON WebSocket error: code={}, msg={}", e.code, e.msg);
627            }
628            BinanceSpotPublicWsMessage::Reconnected => {
629                log::info!("Spot JSON WebSocket reconnected, rebuilding order book snapshots");
630                book_sync.reset_on_reconnect();
631            }
632        }
633    }
634
635    fn send_top_of_book(
636        data_sender: &EventSender<DataEvent>,
637        l1_book_subscriptions: &Arc<AtomicMap<InstrumentId, u32>>,
638        quote: QuoteTick,
639        sequence: u64,
640    ) {
641        Self::send_data(data_sender, Data::Quote(quote));
642        if l1_book_subscriptions.contains_key(&quote.instrument_id) {
643            let deltas = quote_to_l1_deltas(quote, sequence);
644            Self::send_data(data_sender, Data::BookDeltas(Box::new(deltas)));
645        }
646    }
647
648    #[expect(
649        clippy::too_many_arguments,
650        reason = "book recovery requires the full subscription and command ownership context"
651    )]
652    fn route_depth_diff(
653        instrument_id: InstrumentId,
654        update: DepthUpdate,
655        book_sync: &BookSyncTracker,
656        instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
657        http_client: &BinanceSpotHttpClient,
658        snapshot_timeout: Duration,
659        clock: &'static AtomicTime,
660        command_spawner: &TaskSpawner,
661    ) {
662        let Some(recovery) = book_sync.handle_update(instrument_id, update) else {
663            return;
664        };
665
666        let http = http_client.clone();
667        let instruments = instruments.clone();
668
669        spawn_recovery(
670            instrument_id,
671            recovery,
672            book_sync.clone(),
673            move || {
674                Self::fetch_depth_snapshot(http.clone(), instruments.clone(), instrument_id, clock)
675            },
676            depth_request_weight(SNAPSHOT_DEPTH),
677            snapshot_timeout,
678            command_spawner,
679        );
680    }
681
682    fn quote_stream_suffix(&self) -> &'static str {
683        match self.spot_market_data_mode {
684            BinanceSpotMarketDataMode::Sbe => "bestBidAsk",
685            BinanceSpotMarketDataMode::Json => "bookTicker",
686        }
687    }
688
689    fn required_instrument_id_metadata(data_type: &DataType) -> anyhow::Result<InstrumentId> {
690        let raw = data_type
691            .metadata()
692            .as_ref()
693            .and_then(|metadata| metadata.get("instrument_id"))
694            .and_then(|value| value.as_str())
695            .map(str::trim)
696            .filter(|value| !value.is_empty())
697            .context("custom data subscription requires `instrument_id` metadata")?;
698        InstrumentId::from_str(raw)
699            .with_context(|| format!("invalid instrument_id metadata `{raw}`"))
700    }
701
702    async fn fetch_depth_snapshot(
703        http: BinanceSpotHttpClient,
704        instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
705        instrument_id: InstrumentId,
706        clock: &'static AtomicTime,
707    ) -> Result<DepthSnapshot, BinanceBookError> {
708        let params = DepthParams {
709            symbol: instrument_id.symbol.as_str().to_uppercase(),
710            limit: Some(SNAPSHOT_DEPTH),
711        };
712
713        let depth = http
714            .inner()
715            .depth(&params)
716            .await
717            .map_err(|e| depth_snapshot_error(instrument_id, &e))?;
718
719        let ts_init = clock.get_time_ns();
720
721        let (price_precision, size_precision) = instruments
722            .load()
723            .get(&instrument_id)
724            .map(|instrument| (instrument.price_precision(), instrument.size_precision()))
725            .ok_or_else(|| {
726                BinanceBookError::Permanent(format!("no instrument cached for {instrument_id}"))
727            })?;
728
729        let deltas = parse_spot_depth_snapshot(
730            &depth,
731            instrument_id,
732            price_precision,
733            size_precision,
734            ts_init,
735        )
736        .map_err(|e| {
737            BinanceBookError::Retryable(format!(
738                "failed to parse depth snapshot for {instrument_id}: {e}"
739            ))
740        })?;
741
742        Ok(DepthSnapshot {
743            last_update_id: depth.last_update_id as u64,
744            deltas,
745            has_event_time: false,
746        })
747    }
748}
749
750fn spot_ticker_data_type(instrument_id: InstrumentId) -> DataType {
751    let mut metadata = Params::new();
752    metadata.insert(
753        "instrument_id".to_string(),
754        serde_json::Value::String(instrument_id.to_string()),
755    );
756    DataType::new(
757        "BinanceSpotTicker",
758        Some(metadata),
759        Some(instrument_id.to_string()),
760    )
761}
762
763fn upsert_instrument(
764    cache: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
765    instrument: InstrumentAny,
766) {
767    cache.insert(instrument.id(), instrument);
768}
769
770fn parse_spot_depth_snapshot(
771    depth: &BinanceDepth,
772    instrument_id: InstrumentId,
773    price_precision: u8,
774    size_precision: u8,
775    ts_init: UnixNanos,
776) -> anyhow::Result<OrderBookDeltas> {
777    let sequence = depth.last_update_id as u64;
778
779    let total_levels = depth.bids.len() + depth.asks.len();
780    let mut deltas = Vec::with_capacity(total_levels + 1);
781
782    // REST snapshots carry no event time; book sync stamps the first replayed diff's time
783    deltas.push(OrderBookDelta::clear(
784        instrument_id,
785        sequence,
786        ts_init,
787        ts_init,
788    ));
789
790    for (side, levels) in [
791        (OrderSide::Buy, &depth.bids),
792        (OrderSide::Sell, &depth.asks),
793    ] {
794        for level in levels {
795            let price = Price::from_mantissa_exponent_checked(
796                level.price_mantissa,
797                depth.price_exponent,
798                price_precision,
799            )?;
800            let size = Quantity::from_mantissa_exponent_checked(
801                level.qty_mantissa as u64,
802                depth.qty_exponent,
803                size_precision,
804            )?;
805            let order = BookOrder::new(side, price, size, 0);
806
807            deltas.push(OrderBookDelta::new(
808                instrument_id,
809                BookAction::Add,
810                order,
811                RecordFlag::F_SNAPSHOT as u8,
812                sequence,
813                ts_init,
814                ts_init,
815            ));
816        }
817    }
818
819    if let Some(delta) = deltas.last_mut() {
820        delta.flags |= RecordFlag::F_LAST as u8;
821    }
822
823    Ok(OrderBookDeltas::new(instrument_id, deltas))
824}
825
826// Spot `/api/v3/depth` request weight by level limit
827fn depth_request_weight(limit: u32) -> u32 {
828    match limit {
829        ..=100 => 5,
830        101..=500 => 25,
831        501..=1000 => 50,
832        _ => 250,
833    }
834}
835
836// The HTTP client has already spent its own retries, so an exhausted budget stays retryable
837fn depth_snapshot_error(instrument_id: InstrumentId, e: &BinanceSpotHttpError) -> BinanceBookError {
838    let message = format!("depth snapshot request for {instrument_id} failed: {e}");
839
840    if e.is_retryable() || matches!(e, BinanceSpotHttpError::RetryBudgetExceeded(_)) {
841        BinanceBookError::Retryable(message)
842    } else {
843        BinanceBookError::Permanent(message)
844    }
845}
846
847#[async_trait::async_trait(?Send)]
848impl DataClient for BinanceSpotDataClient {
849    fn client_id(&self) -> ClientId {
850        self.client_id
851    }
852
853    fn venue(&self) -> Option<Venue> {
854        Some(self.venue())
855    }
856
857    fn start(&mut self) -> anyhow::Result<()> {
858        log::info!(
859            "Started: client_id={}, product_type={:?}, environment={:?}",
860            self.client_id,
861            self.config.product_type,
862            self.config.environment,
863        );
864        Ok(())
865    }
866
867    fn stop(&mut self) -> anyhow::Result<()> {
868        log::info!("Stopping {id}", id = self.client_id);
869        self.session_tasks.begin_shutdown();
870        self.command_tasks.begin_shutdown();
871        self.ws_client.begin_shutdown();
872        self.is_connected.store(false, Ordering::Relaxed);
873        Ok(())
874    }
875
876    fn reset(&mut self) -> anyhow::Result<()> {
877        log::debug!("Resetting {id}", id = self.client_id);
878
879        self.session_tasks.begin_shutdown();
880        self.command_tasks.begin_shutdown();
881        self.ws_client.begin_shutdown();
882        self.is_connected.store(false, Ordering::Relaxed);
883
884        self.book_subscriptions.store(AHashMap::new());
885        self.l1_book_subscriptions.store(AHashMap::new());
886        self.quote_refs.store(AHashMap::new());
887        self.ticker_refs.store(AHashMap::new());
888        self.book_sync.clear();
889
890        Ok(())
891    }
892
893    fn dispose(&mut self) -> anyhow::Result<()> {
894        log::debug!("Disposing {id}", id = self.client_id);
895        self.stop()
896    }
897
898    async fn connect(&mut self) -> anyhow::Result<()> {
899        if self.is_connected() && self.session_tasks.is_open() && self.command_tasks.is_open() {
900            return Ok(());
901        }
902
903        register_binance_custom_data();
904
905        if self.spot_market_data_mode == BinanceSpotMarketDataMode::Sbe
906            && !self.ws_client.has_credentials()
907        {
908            anyhow::bail!(
909                "Binance Spot market data mode SBE requires Ed25519 API credentials. \
910                 Set the appropriate env vars for your environment, \
911                 or provide api_key/api_secret in the data client config"
912            );
913        }
914
915        self.prepare_task_groups().await?;
916        let ws_client = self.ws_client.clone();
917        let setup_guard =
918            TaskGroupGuard::new(&[&self.session_tasks, &self.command_tasks], move || {
919                ws_client.begin_shutdown();
920            });
921
922        Self::refresh_instrument_catalog(
923            &self.http_client,
924            &self.config.instrument_provider,
925            self.config.us,
926            &self.instruments,
927            &self.status_cache,
928            &self.ws_client,
929            &self.data_sender,
930            self.clock,
931            false,
932        )
933        .await?;
934
935        let session_result = async {
936            match &mut self.ws_client {
937                SpotWsClient::Sbe(ws_client) => {
938                    log::info!("Connecting to Binance Spot SBE WebSocket...");
939                    ws_client.connect().await.map_err(|e| {
940                        anyhow::anyhow!("failed to connect Binance Spot SBE WebSocket: {e}")
941                    })?;
942                    log::info!("Binance Spot SBE WebSocket connected");
943
944                    let stream = ws_client.stream();
945                    let sender = self.data_sender.clone();
946                    let insts = self.instruments.clone();
947                    let ws_insts = ws_client.instruments_cache();
948                    let book_sync = self.book_sync.clone();
949                    let l1_book_subs = self.l1_book_subscriptions.clone();
950                    let http = self.http_client.clone();
951                    let snapshot_timeout =
952                        Duration::from_secs(self.config.book_snapshot_timeout_secs);
953                    let clock = self.clock;
954                    let cancel = self.cancellation_token.clone();
955                    let command_spawner = self
956                        .command_tasks
957                        .spawner()
958                        .context("Binance Spot command task admission is closed")?;
959
960                    let future = async move {
961                        pin_mut!(stream);
962
963                        loop {
964                            tokio::select! {
965                                Some(message) = stream.next() => {
966                                    Self::handle_ws_message(
967                                        message,
968                                        &sender,
969                                        &insts,
970                                        &ws_insts,
971                                        &book_sync,
972                                        &l1_book_subs,
973                                        &http,
974                                        snapshot_timeout,
975                                        clock,
976                                        &command_spawner,
977                                    );
978                                }
979                                () = cancel.cancelled() => {
980                                    log::debug!("Spot SBE WebSocket stream task cancelled");
981                                    break;
982                                }
983                            }
984                        }
985                    };
986                    self.session_tasks
987                        .spawn(future)
988                        .context("failed to register Binance Spot SBE stream task")?;
989                }
990                SpotWsClient::JsonPublic(ws_client) => {
991                    log::info!("Connecting to Binance Spot public JSON WebSocket...");
992                    ws_client.connect().await.map_err(|e| {
993                        anyhow::anyhow!("failed to connect Binance Spot public JSON WebSocket: {e}")
994                    })?;
995                    log::info!("Binance Spot public JSON WebSocket connected");
996
997                    let stream = ws_client.stream();
998                    let sender = self.data_sender.clone();
999                    let insts = self.instruments.clone();
1000                    let ws_insts = ws_client.instruments_cache();
1001                    let book_sync = self.book_sync.clone();
1002                    let l1_book_subs = self.l1_book_subscriptions.clone();
1003                    let http = self.http_client.clone();
1004                    let snapshot_timeout =
1005                        Duration::from_secs(self.config.book_snapshot_timeout_secs);
1006                    let clock = self.clock;
1007                    let cancel = self.cancellation_token.clone();
1008                    let command_spawner = self
1009                        .command_tasks
1010                        .spawner()
1011                        .context("Binance Spot command task admission is closed")?;
1012
1013                    let future = async move {
1014                        pin_mut!(stream);
1015
1016                        loop {
1017                            tokio::select! {
1018                                Some(message) = stream.next() => {
1019                                    Self::handle_public_json_ws_message(
1020                                        message,
1021                                        &sender,
1022                                        &insts,
1023                                        &ws_insts,
1024                                        &book_sync,
1025                                        &l1_book_subs,
1026                                        &http,
1027                                        snapshot_timeout,
1028                                        clock,
1029                                        &command_spawner,
1030                                    );
1031                                }
1032                                () = cancel.cancelled() => {
1033                                    log::debug!("Spot JSON WebSocket stream task cancelled");
1034                                    break;
1035                                }
1036                            }
1037                        }
1038                    };
1039                    self.session_tasks
1040                        .spawn(future)
1041                        .context("failed to register Binance Spot JSON stream task")?;
1042                }
1043            }
1044
1045            let poll_secs = self.config.instrument_status_poll_secs;
1046            if poll_secs > 0 {
1047                let http = self.http_client.clone();
1048                let poll_sender = self.data_sender.clone();
1049                let poll_instruments = self.instruments.clone();
1050                let poll_status_cache = self.status_cache.clone();
1051                let poll_cancel = self.cancellation_token.clone();
1052                let clock = self.clock;
1053                let us = self.config.us;
1054
1055                let future = async move {
1056                    let mut interval =
1057                        tokio::time::interval(tokio::time::Duration::from_secs(poll_secs));
1058                    interval.tick().await; // Skip first immediate tick
1059
1060                    loop {
1061                        tokio::select! {
1062                            _ = interval.tick() => {
1063                                match http.request_symbol_statuses(us).await {
1064                                    Ok(statuses) => {
1065                                        let ts = clock.get_time_ns();
1066                                        let inst_guard = poll_instruments.load();
1067                                        let new_statuses = statuses
1068                                            .into_iter()
1069                                            .filter(|(instrument_id, _)| {
1070                                                inst_guard.contains_key(instrument_id)
1071                                            })
1072                                            .collect();
1073                                        drop(inst_guard);
1074
1075                                        let mut cache =
1076                                            (**poll_status_cache.load()).clone();
1077                                        diff_and_emit_statuses(
1078                                            &new_statuses, &mut cache, &poll_sender, ts, ts,
1079                                        );
1080                                        poll_status_cache.store(cache);
1081                                    }
1082                                    Err(e) => {
1083                                        log::warn!("Instrument status poll failed: {e}");
1084                                    }
1085                                }
1086                            }
1087                            () = poll_cancel.cancelled() => {
1088                                log::debug!("Instrument status polling task cancelled");
1089                                break;
1090                            }
1091                        }
1092                    }
1093                };
1094                self.session_tasks
1095                    .spawn(future)
1096                    .context("failed to register Binance Spot status polling task")?;
1097                log::debug!("Instrument status polling started: interval={poll_secs}s");
1098            }
1099
1100            let refresh_secs = self.config.instrument_refresh_interval_secs;
1101            if refresh_secs > 0 {
1102                let http = self.http_client.clone();
1103                let provider = self.config.instrument_provider.clone();
1104                let us = self.config.us;
1105                let instruments = self.instruments.clone();
1106                let statuses = self.status_cache.clone();
1107                let ws = self.ws_client.clone();
1108                let sender = self.data_sender.clone();
1109                let clock = self.clock;
1110                let cancel = self.cancellation_token.clone();
1111
1112                let future = async move {
1113                    let mut interval = tokio::time::interval(Duration::from_secs(refresh_secs));
1114                    interval.tick().await;
1115
1116                    loop {
1117                        tokio::select! {
1118                            _ = interval.tick() => {
1119                                if let Err(e) = Self::refresh_instrument_catalog(
1120                                    &http,
1121                                    &provider,
1122                                    us,
1123                                    &instruments,
1124                                    &statuses,
1125                                    &ws,
1126                                    &sender,
1127                                    clock,
1128                                    true,
1129                                ).await {
1130                                    log::warn!("Binance Spot instrument refresh failed: {e}");
1131                                }
1132                            }
1133                            () = cancel.cancelled() => {
1134                                log::debug!("Binance Spot instrument refresh task cancelled");
1135                                break;
1136                            }
1137                        }
1138                    }
1139                };
1140                self.session_tasks
1141                    .spawn(future)
1142                    .context("failed to register Binance Spot instrument refresh task")?;
1143                log::debug!("Instrument refresh started: interval={refresh_secs}s");
1144            }
1145
1146            Ok::<(), anyhow::Error>(())
1147        }
1148        .await;
1149
1150        if let Err(e) = session_result {
1151            if let Err(teardown_error) = self.teardown_partial_connect().await {
1152                return Err(e.context(format!(
1153                    "Binance Spot data startup teardown failed: {teardown_error}"
1154                )));
1155            }
1156            return Err(e);
1157        }
1158
1159        setup_guard.disarm();
1160        self.is_connected.store(true, Ordering::Release);
1161        log::info!("Connected: client_id={}", self.client_id);
1162        Ok(())
1163    }
1164
1165    async fn disconnect(&mut self) -> anyhow::Result<()> {
1166        self.teardown_partial_connect().await?;
1167
1168        self.book_subscriptions.store(AHashMap::new());
1169        self.l1_book_subscriptions.store(AHashMap::new());
1170        self.quote_refs.store(AHashMap::new());
1171        self.ticker_refs.store(AHashMap::new());
1172        self.book_sync.clear();
1173
1174        self.is_connected.store(false, Ordering::Release);
1175        log::info!("Disconnected: client_id={}", self.client_id);
1176        Ok(())
1177    }
1178
1179    fn is_connected(&self) -> bool {
1180        self.is_connected.load(Ordering::Relaxed)
1181    }
1182
1183    fn is_disconnected(&self) -> bool {
1184        !self.is_connected()
1185    }
1186
1187    fn subscribe(&mut self, cmd: SubscribeCustomData) -> anyhow::Result<()> {
1188        if cmd.data_type.type_name() != "BinanceSpotTicker" {
1189            log::warn!(
1190                "Unsupported custom data subscription: {}",
1191                cmd.data_type.type_name()
1192            );
1193            return Ok(());
1194        }
1195        anyhow::ensure!(
1196            self.spot_market_data_mode == BinanceSpotMarketDataMode::Json,
1197            "Binance Spot 24-hour ticker custom data requires JSON market-data mode"
1198        );
1199        let instrument_id = Self::required_instrument_id_metadata(&cmd.data_type)?;
1200        anyhow::ensure!(
1201            instrument_id.venue == self.venue(),
1202            "Spot ticker requires a BINANCE instrument"
1203        );
1204        let should_subscribe = {
1205            let previous = self
1206                .ticker_refs
1207                .load()
1208                .get(&instrument_id)
1209                .copied()
1210                .unwrap_or(0);
1211            self.ticker_refs
1212                .rcu(|refs| *refs.entry(instrument_id).or_insert(0) += 1);
1213            previous == 0
1214        };
1215
1216        if should_subscribe {
1217            let ws = self.ws_client.clone();
1218            let stream = format!("{}@ticker", instrument_id.symbol.as_str().to_lowercase());
1219            self.spawn_ws(
1220                async move {
1221                    ws.subscribe(vec![stream])
1222                        .await
1223                        .context("ticker subscription")
1224                },
1225                "ticker subscription",
1226            );
1227        }
1228        Ok(())
1229    }
1230
1231    fn subscribe_instruments(&mut self, _cmd: SubscribeInstruments) -> anyhow::Result<()> {
1232        log::debug!("subscribe_instruments: Binance instruments are fetched via HTTP on connect");
1233        Ok(())
1234    }
1235
1236    fn subscribe_instrument(&mut self, _cmd: SubscribeInstrument) -> anyhow::Result<()> {
1237        log::debug!("subscribe_instrument: Binance instruments are fetched via HTTP on connect");
1238        Ok(())
1239    }
1240
1241    fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
1242        if cmd.book_type == BookType::L1_MBP {
1243            anyhow::ensure!(
1244                cmd.depth.is_none_or(|depth| depth.get() == 1),
1245                "Binance Spot L1_MBP supports depth 1 only"
1246            );
1247            anyhow::ensure!(
1248                !self.book_subscriptions.contains_key(&cmd.instrument_id),
1249                "cannot subscribe L1_MBP and L2_MBP for the same Binance Spot instrument"
1250            );
1251            self.l1_book_subscriptions.rcu(|subscriptions| {
1252                *subscriptions.entry(cmd.instrument_id).or_insert(0) += 1;
1253            });
1254            self.subscribe_top_of_book(cmd.instrument_id);
1255            return Ok(());
1256        }
1257
1258        if cmd.book_type != BookType::L2_MBP {
1259            anyhow::bail!("Binance Spot supports L1_MBP and L2_MBP order book subscriptions");
1260        }
1261        anyhow::ensure!(
1262            !self.l1_book_subscriptions.contains_key(&cmd.instrument_id),
1263            "cannot subscribe L1_MBP and L2_MBP for the same Binance Spot instrument"
1264        );
1265
1266        let instrument_id = cmd.instrument_id;
1267        let ws = self.ws_client.clone();
1268        let symbol_lower = instrument_id.symbol.as_str().to_lowercase();
1269
1270        if self.spot_market_data_mode == BinanceSpotMarketDataMode::Json
1271            && let Some(depth) = cmd.depth
1272        {
1273            // Explicit depth requests use partial-book streams. Full-depth JSON
1274            // subscriptions fall through to the REST snapshot + @depth diff path.
1275            let depth = depth.get();
1276
1277            if !BOOK_DEPTHS_JSON.contains(&depth) {
1278                anyhow::bail!(
1279                    "Invalid depth {depth} for Binance Spot JSON order book. \
1280                    Valid values: {BOOK_DEPTHS_JSON:?}"
1281                );
1282            }
1283
1284            let depth_level = depth as u32;
1285            self.book_subscriptions.insert(instrument_id, depth_level);
1286
1287            let stream = format!("{symbol_lower}@depth{depth_level}");
1288            self.spawn_ws(
1289                chain_command(&self.book_command_tail, async move {
1290                    ws.subscribe(vec![stream])
1291                        .await
1292                        .context("book deltas subscription")
1293                }),
1294                "order book subscription",
1295            );
1296            return Ok(());
1297        }
1298
1299        match cmd.depth.map(|d| d.get()) {
1300            // Partial book streams are self-contained snapshots.
1301            Some(depth) => {
1302                anyhow::ensure!(
1303                    depth == 20,
1304                    "Binance Spot SBE partial books support depth 20 only; use JSON market data for other depths"
1305                );
1306                let depth_level = depth as u32;
1307                self.book_subscriptions.insert(instrument_id, depth_level);
1308
1309                let stream = format!("{symbol_lower}@depth{depth_level}");
1310                self.spawn_ws(
1311                    chain_command(&self.book_command_tail, async move {
1312                        ws.subscribe(vec![stream])
1313                            .await
1314                            .context("book deltas subscription")
1315                    }),
1316                    "order book subscription",
1317                );
1318            }
1319            // Full book diffs are seeded by a REST snapshot and replayed.
1320            None => {
1321                self.book_subscriptions.insert(instrument_id, 0);
1322
1323                // Resync from the next diff, cancelling any recovery from a prior subscription
1324                self.book_sync.subscribe(instrument_id);
1325
1326                let stream = format!("{symbol_lower}@depth");
1327                self.spawn_ws(
1328                    chain_command(&self.book_command_tail, async move {
1329                        ws.subscribe(vec![stream])
1330                            .await
1331                            .context("book deltas subscription")
1332                    }),
1333                    "order book subscription",
1334                );
1335            }
1336        }
1337        Ok(())
1338    }
1339
1340    fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
1341        self.subscribe_top_of_book(cmd.instrument_id);
1342        Ok(())
1343    }
1344
1345    fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
1346        let instrument_id = cmd.instrument_id;
1347        let ws = self.ws_client.clone();
1348
1349        let stream = format!("{}@trade", instrument_id.symbol.as_str().to_lowercase());
1350
1351        self.spawn_ws(
1352            async move {
1353                ws.subscribe(vec![stream])
1354                    .await
1355                    .context("trades subscription")
1356            },
1357            "trade subscription",
1358        );
1359        Ok(())
1360    }
1361
1362    fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
1363        anyhow::ensure!(
1364            self.spot_market_data_mode == BinanceSpotMarketDataMode::Json,
1365            "Binance Spot kline subscriptions require JSON market-data mode"
1366        );
1367        let bar_type = cmd.bar_type;
1368        let ws = self.ws_client.clone();
1369        let interval = bar_spec_to_binance_interval(bar_type.spec())?;
1370
1371        let stream = format!(
1372            "{}@kline_{}",
1373            bar_type.instrument_id().symbol.as_str().to_lowercase(),
1374            interval.as_str()
1375        );
1376
1377        self.spawn_ws(
1378            async move {
1379                ws.subscribe(vec![stream])
1380                    .await
1381                    .context("bars subscription")
1382            },
1383            "bar subscription",
1384        );
1385        Ok(())
1386    }
1387
1388    fn subscribe_instrument_status(
1389        &mut self,
1390        cmd: SubscribeInstrumentStatus,
1391    ) -> anyhow::Result<()> {
1392        log::debug!(
1393            "subscribe_instrument_status: {id} (status changes detected via periodic exchange info polling)",
1394            id = cmd.instrument_id,
1395        );
1396        Ok(())
1397    }
1398
1399    fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
1400        let instrument_id = cmd.instrument_id;
1401
1402        if let Some(count) = self
1403            .l1_book_subscriptions
1404            .load()
1405            .get(&instrument_id)
1406            .copied()
1407        {
1408            if count == 1 {
1409                self.l1_book_subscriptions.remove(&instrument_id);
1410            } else {
1411                self.l1_book_subscriptions.rcu(|subscriptions| {
1412                    if let Some(existing) = subscriptions.get_mut(&instrument_id) {
1413                        *existing -= 1;
1414                    }
1415                });
1416            }
1417            self.unsubscribe_top_of_book(instrument_id);
1418            return Ok(());
1419        }
1420        let ws = self.ws_client.clone();
1421
1422        // Stop book sync so an in-flight snapshot cannot emit after unsubscribe
1423        self.book_subscriptions.remove(&instrument_id);
1424        self.book_sync.remove(instrument_id);
1425
1426        let symbol_lower = instrument_id.symbol.as_str().to_lowercase();
1427        let streams = vec![
1428            format!("{symbol_lower}@depth"),
1429            format!("{symbol_lower}@depth5"),
1430            format!("{symbol_lower}@depth10"),
1431            format!("{symbol_lower}@depth20"),
1432        ];
1433
1434        self.spawn_ws(
1435            chain_command(&self.book_command_tail, async move {
1436                ws.unsubscribe(streams)
1437                    .await
1438                    .context("book deltas unsubscribe")
1439            }),
1440            "order book unsubscribe",
1441        );
1442        Ok(())
1443    }
1444
1445    fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
1446        self.unsubscribe_top_of_book(cmd.instrument_id);
1447        Ok(())
1448    }
1449
1450    fn unsubscribe(&mut self, cmd: &UnsubscribeCustomData) -> anyhow::Result<()> {
1451        if cmd.data_type.type_name() != "BinanceSpotTicker" {
1452            log::warn!(
1453                "Unsupported custom data unsubscription: {}",
1454                cmd.data_type.type_name()
1455            );
1456            return Ok(());
1457        }
1458        let instrument_id = Self::required_instrument_id_metadata(&cmd.data_type)?;
1459        let should_unsubscribe = match self.ticker_refs.load().get(&instrument_id).copied() {
1460            Some(1) => {
1461                self.ticker_refs.remove(&instrument_id);
1462                true
1463            }
1464            Some(count) if count > 1 => {
1465                self.ticker_refs.rcu(|refs| {
1466                    if let Some(existing) = refs.get_mut(&instrument_id) {
1467                        *existing -= 1;
1468                    }
1469                });
1470                false
1471            }
1472            _ => false,
1473        };
1474
1475        if should_unsubscribe {
1476            let ws = self.ws_client.clone();
1477            let stream = format!("{}@ticker", instrument_id.symbol.as_str().to_lowercase());
1478            self.spawn_ws(
1479                async move {
1480                    ws.unsubscribe(vec![stream])
1481                        .await
1482                        .context("ticker unsubscribe")
1483                },
1484                "ticker unsubscribe",
1485            );
1486        }
1487        Ok(())
1488    }
1489
1490    fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
1491        let instrument_id = cmd.instrument_id;
1492        let ws = self.ws_client.clone();
1493
1494        let stream = format!("{}@trade", instrument_id.symbol.as_str().to_lowercase());
1495
1496        self.spawn_ws(
1497            async move {
1498                ws.unsubscribe(vec![stream])
1499                    .await
1500                    .context("trades unsubscribe")
1501            },
1502            "trade unsubscribe",
1503        );
1504        Ok(())
1505    }
1506
1507    fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
1508        let bar_type = cmd.bar_type;
1509        let ws = self.ws_client.clone();
1510        let interval = bar_spec_to_binance_interval(bar_type.spec())?;
1511
1512        let stream = format!(
1513            "{}@kline_{}",
1514            bar_type.instrument_id().symbol.as_str().to_lowercase(),
1515            interval.as_str()
1516        );
1517
1518        self.spawn_ws(
1519            async move {
1520                ws.unsubscribe(vec![stream])
1521                    .await
1522                    .context("bars unsubscribe")
1523            },
1524            "bar unsubscribe",
1525        );
1526        Ok(())
1527    }
1528
1529    fn unsubscribe_instrument_status(
1530        &mut self,
1531        cmd: &UnsubscribeInstrumentStatus,
1532    ) -> anyhow::Result<()> {
1533        log::debug!(
1534            "unsubscribe_instrument_status: {id}",
1535            id = cmd.instrument_id,
1536        );
1537        Ok(())
1538    }
1539
1540    fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
1541        let http = self.http_client.clone();
1542        let sender = self.data_sender.clone();
1543        let instruments_cache = self.instruments.clone();
1544        let request_id = request.request_id;
1545        let client_id = request.client_id.unwrap_or(self.client_id);
1546        let venue = self.venue();
1547        let start = request.start;
1548        let end = request.end;
1549        let params = request.params;
1550        let clock = self.clock;
1551        let provider = self.config.instrument_provider.clone();
1552        let us = self.config.us;
1553        let start_nanos = datetime_to_unix_nanos(start);
1554        let end_nanos = datetime_to_unix_nanos(end);
1555
1556        self.spawn_command(async move {
1557            match http.request_instruments_with_config(&provider, us).await {
1558                Ok(instruments) => {
1559                    for instrument in &instruments {
1560                        upsert_instrument(&instruments_cache, instrument.clone());
1561                    }
1562
1563                    let response = DataResponse::Instruments(InstrumentsResponse::new(
1564                        request_id,
1565                        client_id,
1566                        venue,
1567                        instruments,
1568                        start_nanos,
1569                        end_nanos,
1570                        clock.get_time_ns(),
1571                        params,
1572                    ));
1573
1574                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1575                        log::error!("Failed to send instruments response: {e}");
1576                    }
1577                }
1578                Err(e) => log::error!("Instruments request failed: {e:?}"),
1579            }
1580        });
1581
1582        Ok(())
1583    }
1584
1585    fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
1586        let http = self.http_client.clone();
1587        let sender = self.data_sender.clone();
1588        let instruments = self.instruments.clone();
1589        let instrument_id = request.instrument_id;
1590        let request_id = request.request_id;
1591        let client_id = request.client_id.unwrap_or(self.client_id);
1592        let start = request.start;
1593        let end = request.end;
1594        let params = request.params;
1595        let clock = self.clock;
1596        let provider = self.config.instrument_provider.clone();
1597        let us = self.config.us;
1598        let start_nanos = datetime_to_unix_nanos(start);
1599        let end_nanos = datetime_to_unix_nanos(end);
1600
1601        self.spawn_command(async move {
1602            match http.request_instruments_with_config(&provider, us).await {
1603                Ok(all_instruments) => {
1604                    for instrument in &all_instruments {
1605                        upsert_instrument(&instruments, instrument.clone());
1606                    }
1607
1608                    let instrument = all_instruments
1609                        .into_iter()
1610                        .find(|i| i.id() == instrument_id);
1611
1612                    if let Some(instrument) = instrument {
1613                        let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
1614                            request_id,
1615                            client_id,
1616                            instrument.id(),
1617                            instrument,
1618                            start_nanos,
1619                            end_nanos,
1620                            clock.get_time_ns(),
1621                            params,
1622                        )));
1623
1624                        if let Err(e) = sender.send(DataEvent::Response(response)) {
1625                            log::error!("Failed to send instrument response: {e}");
1626                        }
1627                    } else {
1628                        log::error!("Instrument not found: {instrument_id}");
1629                    }
1630                }
1631                Err(e) => log::error!("Instrument request failed: {e:?}"),
1632            }
1633        });
1634
1635        Ok(())
1636    }
1637
1638    fn request_data(&self, request: RequestCustomData) -> anyhow::Result<()> {
1639        if request.data_type.type_name() != "BinanceBar" {
1640            log::warn!(
1641                "Unsupported custom data request: {}",
1642                request.data_type.type_name()
1643            );
1644            return Ok(());
1645        }
1646        let bar_type = parse_binance_bar_type(&request.data_type)?;
1647        anyhow::ensure!(
1648            bar_type.aggregation_source() == AggregationSource::External,
1649            "historical BinanceBar requests require EXTERNAL aggregation"
1650        );
1651        anyhow::ensure!(
1652            bar_type.spec().price_type == PriceType::Last,
1653            "historical BinanceBar requests require LAST price type"
1654        );
1655        anyhow::ensure!(
1656            bar_type.spec().is_time_aggregated(),
1657            "historical BinanceBar requests require time aggregation"
1658        );
1659        let http = self.http_client.clone();
1660        let sender = self.data_sender.clone();
1661        let request_id = request.request_id;
1662        let client_id = request.client_id;
1663        let data_type = request.data_type;
1664        let start = request.start;
1665        let end = request.end;
1666        let limit = request.limit.map(|value| value.get() as u32);
1667        let params = request.params;
1668        let clock = self.clock;
1669        let venue = self.venue();
1670        let start_nanos = datetime_to_unix_nanos(start);
1671        let end_nanos = datetime_to_unix_nanos(end);
1672
1673        self.spawn_command(async move {
1674            match http.request_binance_bars(bar_type, start, end, limit).await {
1675                Ok(bars) => {
1676                    let response = DataResponse::Data(CustomDataResponse::new(
1677                        request_id,
1678                        client_id,
1679                        Some(venue),
1680                        data_type,
1681                        binance_bars_to_custom_data(bar_type, bars),
1682                        start_nanos,
1683                        end_nanos,
1684                        clock.get_time_ns(),
1685                        params,
1686                    ));
1687
1688                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1689                        log::error!("Failed to send BinanceBar response: {e}");
1690                    }
1691                }
1692                Err(e) => log::error!("BinanceBar request failed for {bar_type}: {e:?}"),
1693            }
1694        });
1695        Ok(())
1696    }
1697
1698    fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1699        let http = self.http_client.clone();
1700        let sender = self.data_sender.clone();
1701        let instrument_id = request.instrument_id;
1702        let limit = request.limit.map(|n| n.get() as u32);
1703        let request_id = request.request_id;
1704        let client_id = request.client_id.unwrap_or(self.client_id);
1705        let params = request.params;
1706        let clock = self.clock;
1707        let start_nanos = datetime_to_unix_nanos(request.start);
1708        let end_nanos = datetime_to_unix_nanos(request.end);
1709        let start = request.start;
1710        let end = request.end;
1711        anyhow::ensure!(
1712            limit.is_none_or(|value| value <= 1000),
1713            "Binance Spot trade limit must not exceed 1000"
1714        );
1715
1716        self.spawn_command(async move {
1717            let result = if start.is_some() || end.is_some() {
1718                http.request_agg_trades(instrument_id, start, end, limit)
1719                    .await
1720            } else {
1721                http.request_trades(instrument_id, limit).await
1722            };
1723
1724            match result.context("failed to request trades from Binance") {
1725                Ok(trades) => {
1726                    let response = DataResponse::Trades(TradesResponse::new(
1727                        request_id,
1728                        client_id,
1729                        instrument_id,
1730                        trades,
1731                        start_nanos,
1732                        end_nanos,
1733                        clock.get_time_ns(),
1734                        params,
1735                    ));
1736
1737                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1738                        log::error!("Failed to send trades response: {e}");
1739                    }
1740                }
1741                Err(e) => log::error!("Trade request failed: {e:?}"),
1742            }
1743        });
1744
1745        Ok(())
1746    }
1747
1748    fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1749        let http = self.http_client.clone();
1750        let sender = self.data_sender.clone();
1751        let bar_type = request.bar_type;
1752        let start = request.start;
1753        let end = request.end;
1754        let limit = request.limit.map(|n| n.get() as u32);
1755        let request_id = request.request_id;
1756        let client_id = request.client_id.unwrap_or(self.client_id);
1757        let params = request.params;
1758        let clock = self.clock;
1759        let start_nanos = datetime_to_unix_nanos(start);
1760        let end_nanos = datetime_to_unix_nanos(end);
1761        anyhow::ensure!(
1762            bar_type.aggregation_source() == AggregationSource::External,
1763            "Binance historical bars require EXTERNAL aggregation"
1764        );
1765        anyhow::ensure!(
1766            bar_type.spec().price_type == PriceType::Last,
1767            "Binance historical bars require LAST price type"
1768        );
1769        anyhow::ensure!(
1770            bar_type.spec().is_time_aggregated(),
1771            "Binance historical bars require time aggregation"
1772        );
1773
1774        self.spawn_command(async move {
1775            let result = http.request_bars(bar_type, start, end, limit).await;
1776
1777            match result.context("failed to request bars from Binance") {
1778                Ok(bars) => {
1779                    let response = DataResponse::Bars(BarsResponse::new(
1780                        request_id,
1781                        client_id,
1782                        bar_type,
1783                        bars,
1784                        start_nanos,
1785                        end_nanos,
1786                        clock.get_time_ns(),
1787                        params,
1788                    ));
1789
1790                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1791                        log::error!("Failed to send bars response: {e}");
1792                    }
1793                }
1794                Err(e) => log::error!("Bar request failed: {e:?}"),
1795            }
1796        });
1797
1798        Ok(())
1799    }
1800
1801    fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
1802        let depth = request.depth.map(|value| value.get() as u32);
1803        anyhow::ensure!(
1804            depth.is_none_or(|value| (1..=5000).contains(&value)),
1805            "Binance Spot order-book depth must be between 1 and 5000"
1806        );
1807        let http = self.http_client.clone();
1808        let book_sync = self.book_sync.clone();
1809        let sender = self.data_sender.clone();
1810        let instrument_id = request.instrument_id;
1811        let request_id = request.request_id;
1812        let client_id = request.client_id.unwrap_or(self.client_id);
1813        let params = request.params;
1814        let clock = self.clock;
1815
1816        self.spawn_command(async move {
1817            // The venue applies a 100-level limit when none is given
1818            let weight = depth_request_weight(depth.unwrap_or(100));
1819            book_sync.pacer().acquire(weight).await;
1820
1821            match http.request_book_snapshot(instrument_id, depth).await {
1822                Ok(book) => {
1823                    let response = DataResponse::Book(BookResponse::new(
1824                        request_id,
1825                        client_id,
1826                        instrument_id,
1827                        book,
1828                        None,
1829                        None,
1830                        clock.get_time_ns(),
1831                        params,
1832                    ));
1833
1834                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1835                        log::error!("Failed to send book snapshot response: {e}");
1836                    }
1837                }
1838                Err(e) => log::error!("Book snapshot request failed for {instrument_id}: {e:?}"),
1839            }
1840        });
1841        Ok(())
1842    }
1843}
1844
1845impl BinanceSpotDataClient {
1846    fn subscribe_top_of_book(&self, instrument_id: InstrumentId) {
1847        let should_subscribe = {
1848            let previous = self
1849                .quote_refs
1850                .load()
1851                .get(&instrument_id)
1852                .copied()
1853                .unwrap_or(0);
1854            self.quote_refs.rcu(|refs| {
1855                *refs.entry(instrument_id).or_insert(0) += 1;
1856            });
1857            previous == 0
1858        };
1859
1860        if should_subscribe {
1861            let ws = self.ws_client.clone();
1862            let suffix = self.quote_stream_suffix();
1863            let stream = format!("{}@{suffix}", instrument_id.symbol.as_str().to_lowercase());
1864            self.spawn_ws(
1865                async move {
1866                    ws.subscribe(vec![stream])
1867                        .await
1868                        .context("top-of-book subscription")
1869                },
1870                "top-of-book subscription",
1871            );
1872        }
1873    }
1874
1875    fn unsubscribe_top_of_book(&self, instrument_id: InstrumentId) {
1876        let should_unsubscribe = match self.quote_refs.load().get(&instrument_id).copied() {
1877            Some(1) => {
1878                self.quote_refs.remove(&instrument_id);
1879                true
1880            }
1881            Some(count) if count > 1 => {
1882                self.quote_refs.rcu(|refs| {
1883                    if let Some(existing) = refs.get_mut(&instrument_id) {
1884                        *existing -= 1;
1885                    }
1886                });
1887                false
1888            }
1889            _ => false,
1890        };
1891
1892        if should_unsubscribe {
1893            let ws = self.ws_client.clone();
1894            let suffix = self.quote_stream_suffix();
1895            let stream = format!("{}@{suffix}", instrument_id.symbol.as_str().to_lowercase());
1896            self.spawn_ws(
1897                async move {
1898                    ws.unsubscribe(vec![stream])
1899                        .await
1900                        .context("top-of-book unsubscribe")
1901                },
1902                "top-of-book unsubscribe",
1903            );
1904        }
1905    }
1906}
1907
1908#[derive(Debug, Clone)]
1909enum SpotWsClient {
1910    Sbe(BinanceSpotWebSocketClient),
1911    JsonPublic(BinanceSpotPublicJsonWebSocketClient),
1912}
1913
1914impl SpotWsClient {
1915    fn has_credentials(&self) -> bool {
1916        match self {
1917            Self::Sbe(client) => client.has_credentials(),
1918            Self::JsonPublic(_) => true, // Public JSON streams require no credentials
1919        }
1920    }
1921
1922    fn replace_instruments(&self, instruments: &[InstrumentAny]) {
1923        match self {
1924            Self::Sbe(client) => client.replace_instruments(instruments),
1925            Self::JsonPublic(client) => client.replace_instruments(instruments),
1926        }
1927    }
1928
1929    async fn subscribe(&self, streams: Vec<String>) -> anyhow::Result<()> {
1930        match self {
1931            Self::Sbe(client) => client.subscribe(streams).await.map_err(Into::into),
1932            Self::JsonPublic(client) => client.subscribe(streams).await,
1933        }
1934    }
1935
1936    async fn unsubscribe(&self, streams: Vec<String>) -> anyhow::Result<()> {
1937        match self {
1938            Self::Sbe(client) => client.unsubscribe(streams).await.map_err(Into::into),
1939            Self::JsonPublic(client) => client.unsubscribe(streams).await,
1940        }
1941    }
1942
1943    async fn close(&mut self) -> anyhow::Result<()> {
1944        match self {
1945            Self::Sbe(client) => client.close().await.map_err(Into::into),
1946            Self::JsonPublic(client) => client.close().await,
1947        }
1948    }
1949
1950    fn begin_shutdown(&self) {
1951        match self {
1952            Self::Sbe(client) => client.begin_shutdown(),
1953            Self::JsonPublic(client) => client.begin_shutdown(),
1954        }
1955    }
1956}
1957
1958fn resolve_spot_json_ws_url(
1959    base_url_ws: Option<String>,
1960    environment: BinanceEnvironment,
1961    us: bool,
1962) -> String {
1963    let default_url =
1964        get_ws_base_url_with_us(BinanceProductType::Spot, environment, us).to_string();
1965
1966    match base_url_ws {
1967        Some(url) if looks_like_spot_sbe_ws_url(&url) => {
1968            log::warn!(
1969                "Spot JSON market-data mode received an SBE WebSocket URL override (`{url}`); \
1970                 using Spot JSON WebSocket default for {environment:?}: {default_url}",
1971            );
1972            default_url
1973        }
1974        Some(url) => url,
1975        None => default_url,
1976    }
1977}
1978
1979fn looks_like_spot_sbe_ws_url(base_url: &str) -> bool {
1980    let without_scheme = base_url
1981        .split_once("://")
1982        .map_or(base_url, |(_, rest)| rest);
1983    let host = without_scheme
1984        .split(['/', ':'])
1985        .next()
1986        .unwrap_or(without_scheme);
1987    host.starts_with("stream-sbe") || host.starts_with("demo-stream-sbe")
1988}
1989
1990#[cfg(test)]
1991mod tests {
1992    use std::{num::NonZeroU32, sync::Arc, time::Duration};
1993
1994    use nautilus_common::{
1995        live::{runner::replace_data_event_sender, sender::EventSender},
1996        messages::DataEvent,
1997        testing::wait_until_async,
1998    };
1999    use nautilus_core::{AtomicMap, nanos::UnixNanos, time::AtomicTime};
2000    use nautilus_live::task::TaskGroup;
2001    use nautilus_model::{
2002        data::Data,
2003        enums::{BookAction, RecordFlag},
2004        identifiers::InstrumentId,
2005        instruments::{Instrument, InstrumentAny, stubs::currency_pair_btcusdt},
2006    };
2007    use parking_lot::Mutex;
2008    use rstest::rstest;
2009    use rust_decimal_macros::dec;
2010    use ustr::Ustr;
2011
2012    use super::{
2013        BinanceBookError, BinanceDepth, BinanceEnvironment, BinanceSpotDataClient,
2014        BinanceSpotHttpError, BinanceSpotMarketDataMode, depth_request_weight,
2015        depth_snapshot_error, parse_spot_depth_snapshot, resolve_spot_json_ws_url,
2016    };
2017    use crate::{
2018        book::{
2019            pacing::SnapshotPacer,
2020            sync::{BookSyncTracker, DepthSequencing},
2021        },
2022        common::{
2023            consts::{BINANCE_CLIENT_ID, BINANCE_SPOT_WS_URL},
2024            websocket::chain_command,
2025        },
2026        config::BinanceDataClientConfig,
2027        spot::{
2028            http::{BinancePriceLevel, BinanceSpotHttpClient},
2029            sbe::stream::BestBidAskStreamEvent,
2030            websocket::streams::messages::BinanceSpotWsMessage,
2031        },
2032    };
2033
2034    #[rstest]
2035    fn handle_ws_message_uses_clock_timestamp_for_sbe_bbo_ts_init() {
2036        let ts_init = UnixNanos::from(1_800_000_000_000_000_000u64);
2037        let clock = Box::leak(Box::new(AtomicTime::new(false, ts_init)));
2038        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
2039        let instruments = Arc::new(AtomicMap::new());
2040        instruments.insert(instrument.id(), instrument.clone());
2041        let ws_instruments = Arc::new(AtomicMap::new());
2042        ws_instruments.insert(Ustr::from("BTCUSDT"), instrument);
2043        let l1_book_subscriptions = Arc::new(AtomicMap::<InstrumentId, u32>::new());
2044        let http_client = BinanceSpotHttpClient::new(
2045            BinanceEnvironment::Testnet,
2046            clock,
2047            None,
2048            None,
2049            None,
2050            None,
2051            None,
2052            None,
2053        )
2054        .unwrap();
2055        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
2056        let sender = EventSender::from(sender);
2057        let pacer = SnapshotPacer::new(NonZeroU32::new(60_000).unwrap());
2058        let book_sync =
2059            BookSyncTracker::new(DepthSequencing::Spot, sender.clone(), Arc::new(pacer));
2060        let event_time_us = 1_700_000_000_000_000;
2061        let message = BinanceSpotWsMessage::BestBidAsk(BestBidAskStreamEvent {
2062            event_time_us,
2063            book_update_id: 123,
2064            price_exponent: -2,
2065            qty_exponent: -4,
2066            bid_price_mantissa: 12_345,
2067            bid_qty_mantissa: 25_000,
2068            ask_price_mantissa: 12_350,
2069            ask_qty_mantissa: 30_000,
2070            symbol: Ustr::from("BTCUSDT"),
2071        });
2072        let command_tasks = TaskGroup::new();
2073        let command_spawner = command_tasks.spawner().unwrap();
2074
2075        BinanceSpotDataClient::handle_ws_message(
2076            message,
2077            &sender,
2078            &instruments,
2079            &ws_instruments,
2080            &book_sync,
2081            &l1_book_subscriptions,
2082            &http_client,
2083            Duration::from_secs(10),
2084            clock,
2085            &command_spawner,
2086        );
2087
2088        let DataEvent::Data(Data::Quote(quote)) = receiver.try_recv().unwrap() else {
2089            panic!("expected quote data event");
2090        };
2091        assert_eq!(quote.ts_event, UnixNanos::from_micros(event_time_us as u64));
2092        assert_eq!(quote.ts_init, ts_init);
2093    }
2094
2095    #[rstest]
2096    #[tokio::test]
2097    async fn chained_book_commands_run_in_issue_order() {
2098        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
2099        replace_data_event_sender(sender);
2100
2101        let config = BinanceDataClientConfig {
2102            spot_market_data_mode: BinanceSpotMarketDataMode::Json,
2103            ..Default::default()
2104        };
2105
2106        let client = BinanceSpotDataClient::new(*BINANCE_CLIENT_ID, config).unwrap();
2107        let order = Arc::new(Mutex::new(Vec::new()));
2108        let unsubscribe = Arc::clone(&order);
2109        let subscribe = Arc::clone(&order);
2110
2111        client.spawn_ws(
2112            chain_command(&client.book_command_tail, async move {
2113                tokio::time::sleep(Duration::from_millis(50)).await;
2114                unsubscribe.lock().push("unsubscribe");
2115                Ok(())
2116            }),
2117            "order book unsubscribe",
2118        );
2119
2120        client.spawn_ws(
2121            chain_command(&client.book_command_tail, async move {
2122                subscribe.lock().push("subscribe");
2123                Ok(())
2124            }),
2125            "order book subscription",
2126        );
2127
2128        wait_until_async(
2129            || {
2130                let finished = order.lock().len() == 2;
2131                async move { finished }
2132            },
2133            Duration::from_secs(5),
2134        )
2135        .await;
2136
2137        assert_eq!(*order.lock(), vec!["unsubscribe", "subscribe"]);
2138    }
2139
2140    #[rstest]
2141    fn parse_spot_depth_snapshot_sets_sequence_and_last_flag() {
2142        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2143        let depth = depth_snapshot(
2144            vec![price_level(10_000, 1_000)],
2145            vec![price_level(10_100, 2_000)],
2146        );
2147
2148        let deltas =
2149            parse_spot_depth_snapshot(&depth, instrument_id, 2, 3, UnixNanos::from(2)).unwrap();
2150
2151        assert_eq!(deltas.deltas.len(), 3);
2152        assert_eq!(deltas.deltas[0].sequence, 123);
2153        assert_eq!(deltas.deltas[1].sequence, 123);
2154        assert_eq!(deltas.deltas[2].sequence, 123);
2155        assert_eq!(deltas.ts_event, UnixNanos::from(2));
2156        assert_eq!(deltas.ts_init, UnixNanos::from(2));
2157        assert_eq!(deltas.deltas[1].order.price.as_decimal(), dec!(100.00));
2158        assert_eq!(deltas.deltas[1].order.size.as_decimal(), dec!(1.000));
2159        assert_eq!(deltas.deltas[1].flags, RecordFlag::F_SNAPSHOT as u8);
2160        assert_eq!(
2161            deltas.deltas[2].flags,
2162            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
2163        );
2164    }
2165
2166    #[rstest]
2167    fn parse_spot_depth_snapshot_sets_last_flag_for_bid_only_snapshot() {
2168        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2169        let depth = depth_snapshot(vec![price_level(10_000, 1_000)], vec![]);
2170
2171        let deltas =
2172            parse_spot_depth_snapshot(&depth, instrument_id, 2, 3, UnixNanos::from(2)).unwrap();
2173
2174        assert_eq!(deltas.deltas.len(), 2);
2175        assert_eq!(
2176            deltas.deltas[1].flags,
2177            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
2178        );
2179    }
2180
2181    #[rstest]
2182    fn parse_spot_depth_snapshot_returns_clear_only_for_empty_book() {
2183        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2184        let depth = depth_snapshot(vec![], vec![]);
2185
2186        let deltas =
2187            parse_spot_depth_snapshot(&depth, instrument_id, 2, 3, UnixNanos::from(2)).unwrap();
2188
2189        assert_eq!(deltas.deltas.len(), 1);
2190        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
2191        assert_eq!(deltas.deltas[0].sequence, 123);
2192        assert_eq!(
2193            deltas.deltas[0].flags,
2194            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
2195        );
2196    }
2197
2198    #[rstest]
2199    fn parse_spot_depth_snapshot_rejects_out_of_range_price() {
2200        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2201        let depth = BinanceDepth {
2202            last_update_id: 123,
2203            price_exponent: 100,
2204            qty_exponent: -3,
2205            bids: vec![price_level(i64::MAX, 1_000)],
2206            asks: vec![],
2207        };
2208
2209        let result = parse_spot_depth_snapshot(&depth, instrument_id, 2, 3, UnixNanos::from(2));
2210
2211        assert!(result.is_err());
2212    }
2213
2214    #[rstest]
2215    fn parse_spot_depth_snapshot_rejects_out_of_range_quantity() {
2216        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2217        let depth = BinanceDepth {
2218            last_update_id: 123,
2219            price_exponent: -2,
2220            qty_exponent: 100,
2221            bids: vec![price_level(10_000, i64::MAX)],
2222            asks: vec![],
2223        };
2224
2225        let result = parse_spot_depth_snapshot(&depth, instrument_id, 2, 3, UnixNanos::from(2));
2226
2227        assert!(result.is_err());
2228    }
2229
2230    fn depth_snapshot(bids: Vec<BinancePriceLevel>, asks: Vec<BinancePriceLevel>) -> BinanceDepth {
2231        BinanceDepth {
2232            last_update_id: 123,
2233            price_exponent: -2,
2234            qty_exponent: -3,
2235            bids,
2236            asks,
2237        }
2238    }
2239
2240    fn price_level(price_mantissa: i64, qty_mantissa: i64) -> BinancePriceLevel {
2241        BinancePriceLevel {
2242            price_mantissa,
2243            qty_mantissa,
2244        }
2245    }
2246
2247    #[rstest]
2248    fn test_spot_market_data_mode_default_is_sbe() {
2249        assert_eq!(
2250            BinanceSpotMarketDataMode::default(),
2251            BinanceSpotMarketDataMode::Sbe
2252        );
2253    }
2254
2255    #[rstest]
2256    fn test_resolve_spot_json_ws_url_uses_environment_default_without_override() {
2257        assert_eq!(
2258            resolve_spot_json_ws_url(None, BinanceEnvironment::Live, false),
2259            BINANCE_SPOT_WS_URL.to_string()
2260        );
2261    }
2262
2263    #[rstest]
2264    fn test_resolve_spot_json_ws_url_rewrites_sbe_override_to_spot_default() {
2265        assert_eq!(
2266            resolve_spot_json_ws_url(
2267                Some("wss://stream-sbe.binance.com/ws".to_string()),
2268                BinanceEnvironment::Live,
2269                false,
2270            ),
2271            BINANCE_SPOT_WS_URL.to_string()
2272        );
2273    }
2274
2275    #[rstest]
2276    fn test_resolve_spot_json_ws_url_preserves_non_sbe_override() {
2277        let custom = "wss://example.com/ws".to_string();
2278        assert_eq!(
2279            resolve_spot_json_ws_url(Some(custom.clone()), BinanceEnvironment::Live, false),
2280            custom
2281        );
2282    }
2283
2284    #[rstest]
2285    fn test_resolve_spot_json_ws_url_uses_binance_us_default() {
2286        assert_eq!(
2287            resolve_spot_json_ws_url(None, BinanceEnvironment::Live, true),
2288            "wss://stream.binance.us:9443/ws"
2289        );
2290    }
2291
2292    #[rstest]
2293    #[case::smallest(1, 5)]
2294    #[case::tier_one_max(100, 5)]
2295    #[case::tier_two_min(101, 25)]
2296    #[case::tier_two_max(500, 25)]
2297    #[case::tier_three_min(501, 50)]
2298    #[case::tier_three_max(1000, 50)]
2299    #[case::tier_four_min(1001, 250)]
2300    #[case::largest(5000, 250)]
2301    fn depth_request_weight_follows_venue_tiers(#[case] limit: u32, #[case] expected: u32) {
2302        assert_eq!(depth_request_weight(limit), expected);
2303    }
2304
2305    #[rstest]
2306    #[case::network(BinanceSpotHttpError::NetworkError("connection reset".to_string()), true)]
2307    #[case::server_error(
2308        BinanceSpotHttpError::UnexpectedStatus {
2309            status: 503,
2310            body: String::new(),
2311            retry_after: None,
2312        },
2313        true
2314    )]
2315    #[case::rate_limited(
2316        BinanceSpotHttpError::BinanceError {
2317            code: -1003,
2318            message: "Too many requests".to_string(),
2319            status: 429,
2320            retry_after: None,
2321        },
2322        true
2323    )]
2324    #[case::retry_budget_exceeded(
2325        BinanceSpotHttpError::RetryBudgetExceeded("elapsed budget exhausted".to_string()),
2326        true
2327    )]
2328    #[case::invalid_symbol(
2329        BinanceSpotHttpError::BinanceError {
2330            code: -1121,
2331            message: "Invalid symbol.".to_string(),
2332            status: 400,
2333            retry_after: None,
2334        },
2335        false
2336    )]
2337    fn depth_snapshot_error_retries_transient_failures(
2338        #[case] error: BinanceSpotHttpError,
2339        #[case] retryable: bool,
2340    ) {
2341        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
2342        let message = format!("depth snapshot request for {instrument_id} failed: {error}");
2343
2344        let classified = depth_snapshot_error(instrument_id, &error);
2345
2346        let expected = if retryable {
2347            BinanceBookError::Retryable(message)
2348        } else {
2349            BinanceBookError::Permanent(message)
2350        };
2351
2352        assert_eq!(classified, expected);
2353    }
2354}