1use 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
117const SNAPSHOT_DEPTH: u32 = 5000;
119
120const SNAPSHOT_WEIGHT_PER_MINUTE: NonZeroU32 = NonZeroU32::new(3_000).expect("non-zero");
122
123#[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 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, 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 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 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) => {} 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("e.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(¶ms)
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 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
826fn 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
836fn 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; 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 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 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 None => {
1321 self.book_subscriptions.insert(instrument_id, 0);
1322
1323 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 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 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, }
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}