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