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