1use std::{
19 future::Future,
20 sync::{
21 Arc,
22 atomic::{AtomicBool, AtomicU64, Ordering},
23 },
24 time::Duration,
25};
26
27use ahash::AHashMap;
28use anyhow::Context;
29use async_trait::async_trait;
30use futures_util::StreamExt;
31use nautilus_common::{
32 clients::DataClient,
33 live::{get_data_event_sender, sender::EventSender},
34 messages::{
35 DataEvent,
36 data::{
37 BarsResponse, BookResponse, DataResponse, InstrumentResponse, InstrumentsResponse,
38 RequestBars, RequestBookSnapshot, RequestInstrument, RequestInstruments, RequestTrades,
39 SubscribeBars, SubscribeBookDeltas, SubscribeIndexPrices, SubscribeInstrument,
40 SubscribeInstrumentStatus, SubscribeInstruments, SubscribeMarkPrices, SubscribeQuotes,
41 SubscribeTrades, TradesResponse, UnsubscribeBars, UnsubscribeBookDeltas,
42 UnsubscribeIndexPrices, UnsubscribeInstrumentStatus, UnsubscribeMarkPrices,
43 UnsubscribeQuotes, UnsubscribeTrades,
44 },
45 },
46};
47use nautilus_core::{
48 AtomicMap, UnixNanos,
49 datetime::datetime_to_unix_nanos,
50 time::{AtomicTime, get_atomic_clock_realtime},
51};
52use nautilus_live::{
53 SocketControlFactory,
54 task::{TaskGroup, TaskRef, TaskSpawner},
55};
56use nautilus_model::{
57 data::{Bar, Data, OrderBookDeltas},
58 enums::{AggregationSource, BookType},
59 identifiers::{ClientId, InstrumentId, Venue},
60 instruments::{Instrument, InstrumentAny},
61};
62use parking_lot::Mutex;
63use tokio_util::sync::CancellationToken;
64use ustr::Ustr;
65
66use crate::{
67 common::{consts::KRAKEN_VENUE, lookup_instrument_in_snapshot},
68 config::KrakenDataClientConfig,
69 http::{KrakenSpotHttpClient, spot::client::KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND},
70 websocket::spot_v2::{
71 client::KrakenSpotWebSocketClient,
72 level_2::{
73 L2BookRequest, L2BookRequests, L2BookState, L2Depths, L2ResyncRequest, L2Subscription,
74 clear_deltas,
75 },
76 level_3::{
77 BookOrderIdHasher, KrakenL3WsMessage,
78 resync::retry_l3_resync,
79 runtime::{L3Sink, L3State, process_l3_message},
80 },
81 messages::KrakenSpotWsMessage,
82 parse::{parse_quote_tick, parse_trade_tick, parse_ws_bar},
83 },
84};
85
86#[allow(dead_code)]
90#[derive(Debug)]
91pub struct KrakenSpotDataClient {
92 clock: &'static AtomicTime,
93 client_id: ClientId,
94 config: KrakenDataClientConfig,
95 http: KrakenSpotHttpClient,
96 ws: KrakenSpotWebSocketClient,
97 ws_l3: Option<KrakenSpotWebSocketClient>,
98 socket_factory: SocketControlFactory,
99 l3_handler_task: Option<TaskRef>,
100 is_connected: AtomicBool,
101 cancellation_token: CancellationToken,
102 session_tasks: TaskGroup,
103 command_tasks: TaskGroup,
104 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
105 data_sender: EventSender<DataEvent>,
106}
107
108impl KrakenSpotDataClient {
109 pub fn new(client_id: ClientId, config: KrakenDataClientConfig) -> anyhow::Result<Self> {
111 let session_tasks = TaskGroup::new();
112 let cancellation_token = session_tasks.cancellation_token();
113 let command_tasks = TaskGroup::new();
114 let socket_factory = SocketControlFactory::new(client_id, Some(*KRAKEN_VENUE));
115 let proxy_url = config
116 .proxy_url
117 .as_ref()
118 .map(|value| value.expose_secret().to_owned());
119
120 let max_requests_per_second = config
121 .max_requests_per_second
122 .unwrap_or(KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND);
123 let http = match (&config.api_key, &config.api_secret) {
124 (Some(api_key), Some(api_secret)) => KrakenSpotHttpClient::with_credentials(
125 api_key.expose_secret().to_owned(),
126 api_secret.expose_secret().to_owned(),
127 config.environment,
128 config.base_url.clone(),
129 config.timeout_secs,
130 None,
131 None,
132 None,
133 proxy_url.clone(),
134 max_requests_per_second,
135 )?,
136 _ => KrakenSpotHttpClient::new(
137 config.environment,
138 config.base_url.clone(),
139 config.timeout_secs,
140 None,
141 None,
142 None,
143 proxy_url.clone(),
144 max_requests_per_second,
145 )?,
146 };
147
148 let ws =
149 KrakenSpotWebSocketClient::new(config.clone(), cancellation_token.clone(), proxy_url)
150 .with_socket_control(socket_factory.control("kraken-spot-data-streams"));
151
152 Ok(Self {
153 clock: get_atomic_clock_realtime(),
154 client_id,
155 config,
156 http,
157 ws,
158 ws_l3: None,
159 socket_factory,
160 l3_handler_task: None,
161 is_connected: AtomicBool::new(false),
162 cancellation_token,
163 session_tasks,
164 command_tasks,
165 instruments: Arc::new(AtomicMap::new()),
166 data_sender: get_data_event_sender(),
167 })
168 }
169
170 #[must_use]
172 pub fn instruments(&self) -> Vec<InstrumentAny> {
173 self.instruments.load().values().cloned().collect()
174 }
175
176 #[must_use]
178 pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
179 self.instruments.load().get(instrument_id).cloned()
180 }
181
182 async fn load_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>> {
183 let instruments = self
184 .http
185 .request_instruments(None)
186 .await
187 .context("Failed to load spot instruments")?;
188
189 self.instruments.rcu(|m| {
190 for instrument in &instruments {
191 m.insert(instrument.id(), instrument.clone());
192 }
193 });
194
195 self.http.cache_instruments(&instruments);
196
197 log::debug!(
198 "Loaded instruments: client_id={}, count={}",
199 self.client_id,
200 instruments.len()
201 );
202
203 Ok(instruments)
204 }
205
206 fn spawn_ws<F>(&self, fut: F, context: &'static str)
207 where
208 F: Future<Output = anyhow::Result<()>> + Send + 'static,
209 {
210 let future = async move {
211 if let Err(e) = fut.await {
212 log::error!("{context}: {e:?}");
213 }
214 };
215
216 if let Err(e) = self.command_tasks.spawn(future) {
217 log::warn!("Skipping Kraken Spot {context} after shutdown began: {e}");
218 }
219 }
220
221 fn spawn_command<F>(&self, future: F)
222 where
223 F: Future<Output = ()> + Send + 'static,
224 {
225 if let Err(e) = self.command_tasks.spawn(future) {
226 log::warn!("Skipping Kraken Spot data command after shutdown began: {e}");
227 }
228 }
229
230 async fn finish_tasks(&self) -> anyhow::Result<()> {
231 let (session_result, command_result) = tokio::join!(
232 self.session_tasks
233 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
234 self.command_tasks
235 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
236 );
237 session_result.context("failed to finish Kraken Spot data session tasks")?;
238 command_result.context("failed to finish Kraken Spot data command tasks")?;
239 Ok(())
240 }
241
242 async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
243 if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
244 self.session_tasks.begin_shutdown();
245 self.command_tasks.begin_shutdown();
246 self.ws
247 .close()
248 .await
249 .context("failed to close prior Kraken Spot WebSocket")?;
250
251 if let Some(ws_l3) = self.ws_l3.as_mut() {
252 ws_l3
253 .close()
254 .await
255 .context("failed to close prior Kraken Spot L3 WebSocket")?;
256 self.ws_l3 = None;
257 self.l3_handler_task = None;
258 }
259 self.finish_tasks().await?;
260 self.session_tasks
261 .start_generation()
262 .context("failed to start Kraken Spot data session task generation")?;
263 self.command_tasks
264 .start_generation()
265 .context("failed to start Kraken Spot data command task generation")?;
266 self.cancellation_token = self.session_tasks.cancellation_token();
267 self.ws = KrakenSpotWebSocketClient::new(
268 self.config.clone(),
269 self.cancellation_token.clone(),
270 self.config
271 .proxy_url
272 .as_ref()
273 .map(|value| value.expose_secret().to_owned()),
274 )
275 .with_socket_control(self.socket_factory.control("kraken-spot-data-streams"));
276 }
277 Ok(())
278 }
279
280 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
281 self.session_tasks.begin_shutdown();
282 self.command_tasks.begin_shutdown();
283 let ws_result = self.ws.close().await;
284 let ws_l3_result = if let Some(ws_l3) = self.ws_l3.as_mut() {
285 ws_l3.close().await
286 } else {
287 Ok(())
288 };
289
290 if ws_l3_result.is_ok() {
291 self.ws_l3 = None;
292 self.l3_handler_task = None;
293 }
294 let tasks_result = self.finish_tasks().await;
295 self.is_connected.store(false, Ordering::Release);
296 tasks_result?;
297 ws_result?;
298 Ok(ws_l3_result?)
299 }
300
301 fn subscribe_l3_book(&mut self, cmd: &SubscribeBookDeltas) -> anyhow::Result<()> {
302 let instrument_id = cmd.instrument_id;
303 let symbol_ustr = instrument_id.symbol.inner();
304 let depth = cmd.depth.map_or(1000, |d| d.get() as u32);
305
306 if !matches!(depth, 10 | 100 | 1000) {
307 anyhow::bail!("Invalid L3 depth {depth} for Kraken Spot, valid values: 10, 100, 1000");
308 }
309
310 if !self.config.has_api_credentials() {
311 anyhow::bail!(
312 "L3 order book requires API credentials; configure api_key and api_secret"
313 );
314 }
315
316 let handler_finished = self
317 .l3_handler_task
318 .as_ref()
319 .is_none_or(TaskRef::is_finished);
320
321 if self.ws_l3.is_none() {
322 let ws_l3 = KrakenSpotWebSocketClient::l3(
323 self.config.clone(),
324 self.cancellation_token.clone(),
325 self.config
326 .proxy_url
327 .as_ref()
328 .map(|value| value.expose_secret().to_owned()),
329 )
330 .with_socket_control(self.socket_factory.control("kraken-spot-l3-data-streams"));
331
332 self.l3_handler_task = self.spawn_l3_handler_task(ws_l3.clone(), false);
333 self.ws_l3 = Some(ws_l3);
334 } else if handler_finished && let Some(ws_l3) = self.ws_l3.as_ref() {
335 let ws_l3 = ws_l3.clone();
336 self.l3_handler_task = self.spawn_l3_handler_task(ws_l3, true);
337 }
338
339 let ws_l3 = self
340 .ws_l3
341 .as_ref()
342 .expect("ws_l3 initialized above")
343 .clone();
344
345 self.spawn_ws(
346 async move {
347 ws_l3
348 .wait_until_active(10.0)
349 .await
350 .map_err(|e| anyhow::anyhow!("L3 WebSocket failed to become active: {e}"))?;
351 ws_l3
352 .wait_until_authenticated(10.0)
353 .await
354 .map_err(|e| anyhow::anyhow!("L3 WebSocket failed to authenticate: {e}"))?;
355 ws_l3
356 .subscribe_book_l3(symbol_ustr, depth)
357 .await
358 .map_err(|e| anyhow::anyhow!("{e}"))
359 },
360 "subscribe l3 book",
361 );
362
363 Ok(())
364 }
365
366 fn spawn_l3_handler_task(
367 &self,
368 handler_client: KrakenSpotWebSocketClient,
369 restart: bool,
370 ) -> Option<TaskRef> {
371 let data_sender = self.data_sender.clone();
372 let instruments = self.instruments.clone();
373 let cancellation_token = self.cancellation_token.clone();
374 let clock = self.clock;
375 let session_spawner = match self.session_tasks.spawner() {
376 Ok(spawner) => spawner,
377 Err(e) => {
378 log::warn!("Skipping Kraken L3 handler after shutdown began: {e}");
379 return None;
380 }
381 };
382
383 let future = async move {
384 let mut handler_client = handler_client;
385
386 if restart && let Err(e) = handler_client.close().await {
387 log::error!("Failed to close prior L3 WebSocket generation: {e}");
388 return;
389 }
390
391 if let Err(e) = handler_client.connect().await {
392 log::error!("L3 WebSocket connect failed: {e}");
393 return;
394 }
395
396 if let Err(e) = handler_client.wait_until_active(10.0).await {
397 log::error!("L3 WebSocket failed to become active: {e}");
398 return;
399 }
400
401 if let Err(e) = handler_client.authenticate().await {
402 log::error!("L3 WebSocket authentication failed: {e}");
403 return;
404 }
405
406 let stream = match handler_client.stream() {
407 Ok(s) => s,
408 Err(e) => {
409 log::error!("L3 stream() failed: {e}");
410 return;
411 }
412 };
413 tokio::pin!(stream);
414
415 let mut states: AHashMap<String, L3State> = AHashMap::new();
416 let hasher = BookOrderIdHasher::new();
417 let l3_depths = handler_client.l3_depths_handle();
418 let validate_checksum = handler_client.validate_l3_checksum();
419 let resync_client = handler_client.clone();
420
421 loop {
422 tokio::select! {
423 () = cancellation_token.cancelled() => break,
424 msg = stream.next() => {
425 let Some(msg) = msg else { break };
426 let ts_init = clock.get_time_ns();
427
428 let runtime_msg = match msg {
429 KrakenSpotWsMessage::L3Snapshot(snap) => {
430 KrakenL3WsMessage::Snapshot(snap)
431 }
432 KrakenSpotWsMessage::L3Update(update) => {
433 KrakenL3WsMessage::Update(update)
434 }
435 KrakenSpotWsMessage::Reconnected => {
436 log::info!("L3 WebSocket reconnected");
437
438 for state in states.values_mut() {
439 state.open_orders.clear();
440 state.awaiting_snapshot = true;
441 }
442 continue;
443 }
444 _ => continue,
445 };
446
447 let mut sink = DataEventSink { sender: &data_sender };
448 let resync = process_l3_message(
449 runtime_msg,
450 &mut sink,
451 &instruments,
452 &l3_depths,
453 &mut states,
454 &hasher,
455 validate_checksum,
456 ts_init,
457 );
458
459 if let Some(request) = resync {
460 log::info!(
461 "Resyncing Kraken L3 book: symbol={}, depth={}, reason={}",
462 request.symbol,
463 request.depth,
464 request.reason,
465 );
466 let symbol_ustr = Ustr::from(&request.symbol);
467 let client_for_resync = resync_client.clone();
468
469 if let Err(e) = session_spawner.spawn_named(
470 "kraken-spot-l3-resync",
471 async move {
472 retry_l3_resync(
473 &client_for_resync,
474 symbol_ustr,
475 request.depth,
476 )
477 .await;
478 },
479 ) {
480 log::warn!("Skipping Kraken L3 resync after shutdown began: {e}");
481 }
482 }
483 }
484 }
485 }
486 };
487
488 match self
489 .session_tasks
490 .spawn_named("kraken-spot-l3-handler", future)
491 {
492 Ok(task) => Some(task),
493 Err(e) => {
494 log::warn!("Skipping Kraken L3 handler after shutdown began: {e}");
495 None
496 }
497 }
498 }
499
500 fn spawn_message_handler(&mut self) -> anyhow::Result<()> {
501 let stream = self.ws.stream().map_err(|e| anyhow::anyhow!("{e}"))?;
502 let data_sender = self.data_sender.clone();
503 let instruments = self.instruments.clone();
504 let book_sequence = Arc::new(AtomicU64::new(0));
505 let ohlc_buffer: OhlcBuffer = Arc::new(Mutex::new(AHashMap::new()));
506 let l2_depths = self.ws.l2_depths_handle();
507 let book_requests = self.ws.book_requests_handle();
508 let validate_l2_checksum = self.ws.validate_l2_checksum();
509 let resync_client = self.ws.clone();
510 let session_spawner = self
511 .session_tasks
512 .spawner()
513 .context("failed to acquire a task spawner for Kraken Spot L2 resync")?;
514 let cancellation_token = self.cancellation_token.clone();
515 let clock = self.clock;
516
517 let future = async move {
518 tokio::pin!(stream);
519 let mut l2_books = L2BookState::new(validate_l2_checksum);
520 let mut watchdog = tokio::time::interval(Duration::from_secs(1));
524 watchdog.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
525
526 loop {
527 let context = SpotMessageContext {
528 sender: &data_sender,
529 instruments: &instruments,
530 book_sequence: &book_sequence,
531 l2_depths: &l2_depths,
532 book_requests: &book_requests,
533 ohlc_buffer: &ohlc_buffer,
534 clock,
535 };
536
537 tokio::select! {
538 () = cancellation_token.cancelled() => {
539 log::debug!("Spot message handler cancelled");
540 Self::flush_ohlc_buffer(&ohlc_buffer, &data_sender);
541 break;
542 }
543 msg = stream.next() => {
544 match msg {
545 Some(ws_msg) => {
546 let resyncs =
547 Self::handle_ws_message(ws_msg, &context, &mut l2_books);
548
549 for request in &resyncs {
550 log::info!(
551 "Resyncing Kraken L2 book after checksum mismatch: {}",
552 request.instrument_id
553 );
554 }
555
556 Self::spawn_l2_resyncs(&session_spawner, &resync_client, resyncs);
557 }
558 None => {
559 log::debug!("Spot WebSocket stream ended");
560 Self::flush_ohlc_buffer(&ohlc_buffer, &data_sender);
561 break;
562 }
563 }
564 }
565 _ = watchdog.tick() => {
566 let resyncs = Self::check_l2_snapshots(&context, &mut l2_books);
567 Self::spawn_l2_resyncs(&session_spawner, &resync_client, resyncs);
568 }
569 }
570 }
571 };
572
573 self.session_tasks
574 .spawn(future)
575 .context("failed to register Kraken Spot message handler")
576 }
577
578 fn spawn_l2_resyncs(
582 spawner: &TaskSpawner,
583 client: &KrakenSpotWebSocketClient,
584 requests: Vec<L2ResyncRequest>,
585 ) {
586 for request in requests {
587 let client = client.clone();
588
589 if let Err(e) = spawner.spawn_named("kraken-spot-l2-resync", async move {
590 if let Err(e) = client
591 .resync_book(request.instrument_id, request.generation, request.epoch)
592 .await
593 {
594 log::error!(
595 "Failed to send the L2 resync for {}: {e}; the snapshot watchdog asks \
596 again if the book stays cleared",
597 request.instrument_id
598 );
599 }
600 }) {
601 log::warn!("Skipping Kraken L2 resync after shutdown began: {e}");
602 }
603 }
604 }
605
606 fn check_l2_snapshots(
612 context: &SpotMessageContext,
613 l2_books: &mut L2BookState,
614 ) -> Vec<L2ResyncRequest> {
615 let now = context.clock.get_time_ns();
616 let held = Self::held_l2_subscriptions(context);
617 let check = l2_books.overdue_snapshots(now, &held);
618
619 for instrument_id in check.cleared {
620 Self::emit_book_clear(context, instrument_id, now);
621 }
622
623 check.requests
624 }
625
626 fn held_l2_subscriptions(context: &SpotMessageContext) -> Vec<(InstrumentId, L2Subscription)> {
629 let instruments = context.instruments.load();
630 context
631 .l2_depths
632 .held()
633 .into_iter()
634 .filter_map(|(symbol, subscription)| {
635 lookup_instrument_in_snapshot(&instruments, &symbol)
636 .map(|instrument| (instrument.id(), subscription))
637 })
638 .collect()
639 }
640
641 fn emit_book_clear(context: &SpotMessageContext, instrument_id: InstrumentId, now: UnixNanos) {
644 let sequence = context.book_sequence.load(Ordering::Relaxed);
645 let (deltas, next_sequence) = clear_deltas(instrument_id, sequence, now, now);
646 context
647 .book_sequence
648 .store(next_sequence, Ordering::Relaxed);
649
650 if let Err(e) = context
651 .sender
652 .send(DataEvent::Data(Data::BookDeltas(Box::new(deltas))))
653 {
654 log::error!("Failed to send deltas: {e}");
655 }
656 }
657
658 fn flush_ohlc_buffer(ohlc_buffer: &OhlcBuffer, sender: &EventSender<DataEvent>) {
659 let mut buffer = ohlc_buffer.lock();
660 let bars: Vec<Bar> = buffer.drain().map(|(_, (bar, _))| bar).collect();
661 for bar in bars {
662 if let Err(e) = sender.send(DataEvent::Data(Data::Bar(bar))) {
663 log::error!("Failed to send buffered bar: {e}");
664 }
665 }
666 }
667
668 fn handle_ws_message(
669 msg: KrakenSpotWsMessage,
670 context: &SpotMessageContext,
671 l2_books: &mut L2BookState,
672 ) -> Vec<L2ResyncRequest> {
673 let mut resyncs = Vec::new();
674 let ts_init = context.clock.get_time_ns();
675
676 match msg {
677 KrakenSpotWsMessage::Ticker(tickers) => {
678 let instruments = context.instruments.load();
679
680 for ticker in &tickers {
681 let Some(instrument) =
682 lookup_instrument_in_snapshot(&instruments, ticker.symbol.as_str())
683 else {
684 log::warn!("No instrument for symbol: {}", ticker.symbol);
685 continue;
686 };
687
688 match parse_quote_tick(ticker, instrument, ts_init) {
689 Ok(quote) => {
690 if let Err(e) = context.sender.send(DataEvent::Data(Data::Quote(quote)))
691 {
692 log::error!("Failed to send quote: {e}");
693 }
694 }
695 Err(e) => log::error!("Failed to parse quote tick: {e}"),
696 }
697 }
698 }
699 KrakenSpotWsMessage::Trade(trades) => {
700 let instruments = context.instruments.load();
701
702 for trade in &trades {
703 let Some(instrument) =
704 lookup_instrument_in_snapshot(&instruments, trade.symbol.as_str())
705 else {
706 log::warn!("No instrument for symbol: {}", trade.symbol);
707 continue;
708 };
709
710 match parse_trade_tick(trade, instrument, ts_init) {
711 Ok(tick) => {
712 if let Err(e) = context.sender.send(DataEvent::Data(Data::Trade(tick)))
713 {
714 log::error!("Failed to send trade: {e}");
715 }
716 }
717 Err(e) => log::error!("Failed to parse trade tick: {e}"),
718 }
719 }
720 }
721 KrakenSpotWsMessage::Book { data, is_snapshot } => {
722 let instruments = context.instruments.load();
723
724 for book in &data {
725 let Some(instrument) =
726 lookup_instrument_in_snapshot(&instruments, book.symbol.as_str())
727 else {
728 log::warn!("No instrument for symbol: {}", book.symbol);
729 continue;
730 };
731 let sequence = context.book_sequence.load(Ordering::Relaxed);
732
733 match l2_books.process_book(
734 book,
735 instrument,
736 sequence,
737 is_snapshot,
738 context.l2_depths,
739 ts_init,
740 ) {
741 Ok(outcome) => {
742 if let Some((deltas, next_sequence)) = outcome.deltas {
743 context
744 .book_sequence
745 .store(next_sequence, Ordering::Relaxed);
746
747 if let Err(e) = context
748 .sender
749 .send(DataEvent::Data(Data::BookDeltas(Box::new(deltas))))
750 {
751 log::error!("Failed to send deltas: {e}");
752 }
753 }
754
755 if let Some(request) = outcome.resync {
761 match resyncs.iter_mut().find(|r: &&mut L2ResyncRequest| {
762 r.instrument_id == request.instrument_id
763 }) {
764 Some(existing) => *existing = request,
765 None => resyncs.push(request),
766 }
767 }
768 }
769 Err(e) => log::error!("Failed to parse book deltas: {e}"),
770 }
771 }
772 }
773 KrakenSpotWsMessage::Ohlc(ohlc_data) => {
774 let mut buffer = context.ohlc_buffer.lock();
775
776 let instruments = context.instruments.load();
777
778 for ohlc in &ohlc_data {
779 let Some(instrument) =
780 lookup_instrument_in_snapshot(&instruments, ohlc.symbol.as_str())
781 else {
782 log::warn!("No instrument for symbol: {}", ohlc.symbol);
783 continue;
784 };
785
786 match parse_ws_bar(ohlc, instrument, ts_init) {
787 Ok(new_bar) => {
788 let key: (Ustr, u32) = (ohlc.symbol, ohlc.interval);
789 let new_interval_begin = UnixNanos::from(
790 u64::try_from(ohlc.interval_begin.as_nanosecond()).unwrap_or(0),
791 );
792
793 if let Some((buffered_bar, buffered_begin)) = buffer.get(&key)
794 && new_interval_begin != *buffered_begin
795 && let Err(e) = context
796 .sender
797 .send(DataEvent::Data(Data::Bar(*buffered_bar)))
798 {
799 log::error!("Failed to send bar: {e}");
800 }
801
802 buffer.insert(key, (new_bar, new_interval_begin));
803 }
804 Err(e) => log::error!("Failed to parse bar: {e}"),
805 }
806 }
807 }
808 KrakenSpotWsMessage::Execution(_) => {}
809 KrakenSpotWsMessage::OrderResponse(_) => {}
810 KrakenSpotWsMessage::L3Snapshot(_) => {}
811 KrakenSpotWsMessage::L3Update(_) => {}
812 KrakenSpotWsMessage::SubscriptionAck {
813 req_id,
814 symbol,
815 success,
816 error,
817 } => {
818 let request = req_id.and_then(|req_id| {
822 let request = context.book_requests.lock().remove(&req_id);
823 request.map(|request| (req_id, request))
824 });
825
826 match request {
827 Some((req_id, request)) if success => {
828 Self::handle_book_confirmation(context, l2_books, req_id, request, ts_init);
829 }
830 Some((req_id, request)) => {
831 Self::handle_book_rejection(
832 context,
833 l2_books,
834 req_id,
835 request,
836 symbol,
837 error.as_deref(),
838 ts_init,
839 );
840 }
841 None => {}
842 }
843 }
844 KrakenSpotWsMessage::Reconnected => {
845 let held = Self::held_l2_subscriptions(context);
846
847 for instrument_id in l2_books.reset_after_reconnect(ts_init, &held) {
848 Self::emit_book_clear(context, instrument_id, ts_init);
849 }
850
851 log::info!("Spot WebSocket reconnected");
852 }
853 }
854
855 resyncs
856 }
857
858 fn handle_book_confirmation(
865 context: &SpotMessageContext,
866 l2_books: &mut L2BookState,
867 req_id: u64,
868 request: L2BookRequest,
869 now: UnixNanos,
870 ) {
871 let held = context.l2_depths.subscription(request.symbol.as_str());
872 let Some(subscription) = held.filter(|held| held.latest_request == req_id) else {
873 log::debug!(
874 "Ignoring the confirmation of a superseded L2 subscribe: symbol={}, \
875 req_id={req_id}",
876 request.symbol
877 );
878 return;
879 };
880
881 let instruments = context.instruments.load();
882 let Some(instrument) = lookup_instrument_in_snapshot(&instruments, request.symbol.as_str())
883 else {
884 log::debug!(
885 "No instrument for the confirmed L2 subscribe of {}, so its frames are dropped",
886 request.symbol
887 );
888 return;
889 };
890
891 if l2_books.start_stream(instrument.id(), subscription, now) {
892 Self::emit_book_clear(context, instrument.id(), now);
893 }
894 }
895
896 fn handle_book_rejection(
904 context: &SpotMessageContext,
905 l2_books: &mut L2BookState,
906 req_id: u64,
907 request: L2BookRequest,
908 symbol: Option<Ustr>,
909 error: Option<&str>,
910 now: UnixNanos,
911 ) {
912 let symbol = symbol.unwrap_or(request.symbol);
913 let reason = error.unwrap_or("no reason given");
914 let held = context.l2_depths.subscription(request.symbol.as_str());
915
916 let Some(subscription) = held.filter(|held| held.latest_request == req_id) else {
917 log::debug!(
918 "Ignoring a rejected L2 subscribe superseded by a later request or a canceled \
919 subscription: symbol={symbol}, req_id={req_id}, error={reason}"
920 );
921 return;
922 };
923
924 let instruments = context.instruments.load();
925 let Some(instrument) = lookup_instrument_in_snapshot(&instruments, request.symbol.as_str())
926 else {
927 log::error!(
928 "Kraken rejected the L2 book subscribe for {symbol}: {reason}; no instrument \
929 for the symbol, so there is no book to clear"
930 );
931 return;
932 };
933
934 let rejection = l2_books.reject_subscription(instrument.id(), subscription.generation, now);
935
936 if rejection.cleared {
937 Self::emit_book_clear(context, instrument.id(), now);
938 }
939
940 match rejection.next_request_due {
941 Some(due) => log::error!(
942 "Kraken rejected the L2 book subscribe for {symbol}: {reason}; the book stays \
943 cleared, asking again in {} s",
944 due.saturating_duration_since(now).as_u64() / 1_000_000_000
945 ),
946 None => log::error!(
947 "Kraken rejected the L2 book subscribe for {symbol}: {reason}; the snapshot \
948 request cap is reached, so the book stays cleared until the next subscription \
949 change or reconnect"
950 ),
951 }
952 }
953}
954
955#[async_trait(?Send)]
956impl DataClient for KrakenSpotDataClient {
957 fn client_id(&self) -> ClientId {
958 self.client_id
959 }
960
961 fn venue(&self) -> Option<Venue> {
962 Some(*KRAKEN_VENUE)
963 }
964
965 fn start(&mut self) -> anyhow::Result<()> {
966 log::info!(
967 "Starting Spot data client: client_id={}, environment={:?}",
968 self.client_id,
969 self.config.environment
970 );
971 Ok(())
972 }
973
974 fn stop(&mut self) -> anyhow::Result<()> {
975 log::info!("Stopping Spot data client: {}", self.client_id);
976 self.session_tasks.begin_shutdown();
977 self.command_tasks.begin_shutdown();
978 self.ws.begin_shutdown();
979 self.is_connected.store(false, Ordering::Relaxed);
980 Ok(())
981 }
982
983 fn reset(&mut self) -> anyhow::Result<()> {
984 log::info!("Resetting Spot data client: {}", self.client_id);
985 self.session_tasks.begin_shutdown();
986 self.command_tasks.begin_shutdown();
987 self.ws.begin_shutdown();
988 self.is_connected.store(false, Ordering::Relaxed);
989
990 self.instruments.store(ahash::AHashMap::new());
991 Ok(())
992 }
993
994 fn dispose(&mut self) -> anyhow::Result<()> {
995 log::debug!("Disposing Spot data client: {}", self.client_id);
996 self.stop()
997 }
998
999 fn is_connected(&self) -> bool {
1000 self.is_connected.load(Ordering::SeqCst)
1001 }
1002
1003 fn is_disconnected(&self) -> bool {
1004 !self.is_connected()
1005 }
1006
1007 async fn connect(&mut self) -> anyhow::Result<()> {
1008 if self.is_connected() && self.session_tasks.is_open() && self.command_tasks.is_open() {
1009 return Ok(());
1010 }
1011
1012 self.prepare_task_groups().await?;
1013
1014 let instruments = self.load_instruments().await?;
1015
1016 let session_result = async {
1017 self.ws
1018 .connect()
1019 .await
1020 .context("Failed to connect spot WebSocket")?;
1021 self.ws
1022 .wait_until_active(10.0)
1023 .await
1024 .context("Spot WebSocket failed to become active")?;
1025
1026 self.spawn_message_handler()?;
1027
1028 Ok::<(), anyhow::Error>(())
1029 }
1030 .await;
1031
1032 if let Err(e) = session_result {
1033 if let Err(teardown_error) = self.teardown_partial_connect().await {
1034 return Err(e.context(format!(
1035 "Kraken Spot data startup teardown failed: {teardown_error}"
1036 )));
1037 }
1038 return Err(e);
1039 }
1040
1041 for instrument in instruments {
1042 if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
1043 log::error!("Failed to send instrument: {e}");
1044 }
1045 }
1046
1047 self.is_connected.store(true, Ordering::Release);
1048 log::info!("Connected: client_id={}, product_type=Spot", self.client_id);
1049 Ok(())
1050 }
1051
1052 async fn disconnect(&mut self) -> anyhow::Result<()> {
1053 self.teardown_partial_connect().await?;
1054 self.is_connected.store(false, Ordering::Relaxed);
1055
1056 log::info!("Disconnected: client_id={}", self.client_id);
1057 Ok(())
1058 }
1059
1060 fn subscribe_instruments(&mut self, _cmd: SubscribeInstruments) -> anyhow::Result<()> {
1061 log::debug!("subscribe_instruments: Kraken instruments are fetched via HTTP on connect");
1062 Ok(())
1063 }
1064
1065 fn subscribe_instrument(&mut self, _cmd: SubscribeInstrument) -> anyhow::Result<()> {
1066 log::debug!("subscribe_instrument: Kraken instruments are fetched via HTTP on connect");
1067 Ok(())
1068 }
1069
1070 fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
1071 let instrument_id = cmd.instrument_id;
1072 let depth = cmd.depth;
1073
1074 match cmd.book_type {
1075 BookType::L2_MBP => {}
1076 BookType::L3_MBO => return self.subscribe_l3_book(&cmd),
1077 other => {
1078 log::warn!("Unsupported BookType {other:?} for Kraken Spot, skipping");
1079 return Ok(());
1080 }
1081 }
1082
1083 if let Some(d) = depth {
1084 let d_val = d.get();
1085 if !matches!(d_val, 10 | 25 | 100 | 500 | 1000) {
1086 log::warn!("Invalid depth {d_val} for Kraken Spot, valid: 10, 25, 100, 500, 1000");
1087 return Ok(());
1088 }
1089 }
1090
1091 let ws = self.ws.clone();
1092 self.spawn_ws(
1093 async move {
1094 ws.subscribe_book(instrument_id, depth.map(|d| d.get() as u32))
1095 .await
1096 .map_err(|e| anyhow::anyhow!("{e}"))
1097 },
1098 "subscribe book",
1099 );
1100
1101 Ok(())
1102 }
1103
1104 fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
1105 let instrument_id = cmd.instrument_id;
1106 let ws = self.ws.clone();
1107
1108 self.spawn_ws(
1109 async move {
1110 ws.subscribe_quotes(instrument_id)
1111 .await
1112 .map_err(|e| anyhow::anyhow!("{e}"))
1113 },
1114 "subscribe quotes",
1115 );
1116
1117 Ok(())
1118 }
1119
1120 fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
1121 let instrument_id = cmd.instrument_id;
1122 let ws = self.ws.clone();
1123
1124 self.spawn_ws(
1125 async move {
1126 ws.subscribe_trades(instrument_id)
1127 .await
1128 .map_err(|e| anyhow::anyhow!("{e}"))
1129 },
1130 "subscribe trades",
1131 );
1132
1133 Ok(())
1134 }
1135
1136 fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
1137 log::warn!(
1138 "Mark price subscription not supported for Spot instrument {}",
1139 cmd.instrument_id
1140 );
1141 Ok(())
1142 }
1143
1144 fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
1145 log::warn!(
1146 "Index price subscription not supported for Spot instrument {}",
1147 cmd.instrument_id
1148 );
1149 Ok(())
1150 }
1151
1152 fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
1153 let bar_type = cmd.bar_type;
1154
1155 if bar_type.aggregation_source() != AggregationSource::External {
1156 log::warn!("Cannot subscribe to {bar_type} bars: only EXTERNAL bars supported");
1157 return Ok(());
1158 }
1159
1160 if !bar_type.spec().is_time_aggregated() {
1161 log::warn!("Cannot subscribe to {bar_type} bars: only time-based bars supported");
1162 return Ok(());
1163 }
1164
1165 let ws = self.ws.clone();
1166 self.spawn_ws(
1167 async move {
1168 ws.subscribe_bars(bar_type)
1169 .await
1170 .map_err(|e| anyhow::anyhow!("{e}"))
1171 },
1172 "subscribe bars",
1173 );
1174
1175 Ok(())
1176 }
1177
1178 fn subscribe_instrument_status(
1179 &mut self,
1180 cmd: SubscribeInstrumentStatus,
1181 ) -> anyhow::Result<()> {
1182 log::debug!(
1183 "subscribe_instrument_status: {} (status changes detected via periodic instrument polling)",
1184 cmd.instrument_id,
1185 );
1186 Ok(())
1187 }
1188
1189 fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
1190 let instrument_id = cmd.instrument_id;
1191
1192 if self.ws_l3.as_ref().is_some_and(|ws| {
1193 ws.subscriptions_contains(&format!("level3:{}", instrument_id.symbol))
1194 }) {
1195 let symbol_ustr = instrument_id.symbol.inner();
1196
1197 if let Some(ws_l3) = self.ws_l3.clone() {
1198 self.spawn_ws(
1199 async move {
1200 ws_l3
1201 .unsubscribe_book_l3(symbol_ustr)
1202 .await
1203 .map_err(|e| anyhow::anyhow!("{e}"))?;
1204 Ok(())
1205 },
1206 "unsubscribe l3 book",
1207 );
1208 }
1209 return Ok(());
1210 }
1211
1212 let ws = self.ws.clone();
1213 self.spawn_ws(
1214 async move {
1215 ws.unsubscribe_book(instrument_id)
1216 .await
1217 .map_err(|e| anyhow::anyhow!("{e}"))
1218 },
1219 "unsubscribe book",
1220 );
1221
1222 Ok(())
1223 }
1224
1225 fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
1226 let instrument_id = cmd.instrument_id;
1227 let ws = self.ws.clone();
1228
1229 self.spawn_ws(
1230 async move {
1231 ws.unsubscribe_quotes(instrument_id)
1232 .await
1233 .map_err(|e| anyhow::anyhow!("{e}"))
1234 },
1235 "unsubscribe quotes",
1236 );
1237
1238 Ok(())
1239 }
1240
1241 fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
1242 let instrument_id = cmd.instrument_id;
1243 let ws = self.ws.clone();
1244
1245 self.spawn_ws(
1246 async move {
1247 ws.unsubscribe_trades(instrument_id)
1248 .await
1249 .map_err(|e| anyhow::anyhow!("{e}"))
1250 },
1251 "unsubscribe trades",
1252 );
1253
1254 Ok(())
1255 }
1256
1257 fn unsubscribe_mark_prices(&mut self, _cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
1258 Ok(())
1259 }
1260
1261 fn unsubscribe_index_prices(&mut self, _cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
1262 Ok(())
1263 }
1264
1265 fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
1266 let bar_type = cmd.bar_type;
1267 let ws = self.ws.clone();
1268
1269 self.spawn_ws(
1270 async move {
1271 ws.unsubscribe_bars(bar_type)
1272 .await
1273 .map_err(|e| anyhow::anyhow!("{e}"))
1274 },
1275 "unsubscribe bars",
1276 );
1277
1278 Ok(())
1279 }
1280
1281 fn unsubscribe_instrument_status(
1282 &mut self,
1283 _cmd: &UnsubscribeInstrumentStatus,
1284 ) -> anyhow::Result<()> {
1285 Ok(())
1286 }
1287
1288 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
1289 let http = self.http.clone();
1290 let sender = self.data_sender.clone();
1291 let instruments_cache = self.instruments.clone();
1292 let request_id = request.request_id;
1293 let client_id = request.client_id.unwrap_or(self.client_id);
1294 let venue = *KRAKEN_VENUE;
1295 let start_nanos = datetime_to_unix_nanos(request.start);
1296 let end_nanos = datetime_to_unix_nanos(request.end);
1297 let params = request.params;
1298 let clock = self.clock;
1299
1300 self.spawn_command(async move {
1301 match http.request_instruments(None).await {
1302 Ok(instruments) => {
1303 instruments_cache.rcu(|m| {
1304 for instrument in &instruments {
1305 m.insert(instrument.id(), instrument.clone());
1306 }
1307 });
1308 http.cache_instruments(&instruments);
1309
1310 let response = DataResponse::Instruments(InstrumentsResponse::new(
1311 request_id,
1312 client_id,
1313 venue,
1314 instruments,
1315 start_nanos,
1316 end_nanos,
1317 clock.get_time_ns(),
1318 params,
1319 ));
1320
1321 if let Err(e) = sender.send(DataEvent::Response(response)) {
1322 log::error!("Failed to send instruments response: {e}");
1323 }
1324 }
1325 Err(e) => log::error!("Instruments request failed: {e:?}"),
1326 }
1327 });
1328
1329 Ok(())
1330 }
1331
1332 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
1333 let http = self.http.clone();
1334 let sender = self.data_sender.clone();
1335 let instruments = self.instruments.clone();
1336 let instrument_id = request.instrument_id;
1337 let request_id = request.request_id;
1338 let client_id = request.client_id.unwrap_or(self.client_id);
1339 let start_nanos = datetime_to_unix_nanos(request.start);
1340 let end_nanos = datetime_to_unix_nanos(request.end);
1341 let params = request.params;
1342 let clock = self.clock;
1343
1344 self.spawn_command(async move {
1345 match http.request_instruments(None).await {
1346 Ok(all_instruments) => {
1347 instruments.rcu(|m| {
1348 for instrument in &all_instruments {
1349 m.insert(instrument.id(), instrument.clone());
1350 }
1351 });
1352 http.cache_instruments(&all_instruments);
1353
1354 let instrument = all_instruments
1355 .into_iter()
1356 .find(|i| i.id() == instrument_id);
1357
1358 if let Some(instrument) = instrument {
1359 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
1360 request_id,
1361 client_id,
1362 instrument.id(),
1363 instrument,
1364 start_nanos,
1365 end_nanos,
1366 clock.get_time_ns(),
1367 params,
1368 )));
1369
1370 if let Err(e) = sender.send(DataEvent::Response(response)) {
1371 log::error!("Failed to send instrument response: {e}");
1372 }
1373 } else {
1374 log::error!("Instrument not found: {instrument_id}");
1375 }
1376 }
1377 Err(e) => log::error!("Instrument request failed: {e:?}"),
1378 }
1379 });
1380
1381 Ok(())
1382 }
1383 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1384 let http = self.http.clone();
1385 let sender = self.data_sender.clone();
1386 let instrument_id = request.instrument_id;
1387 let start = request.start;
1388 let end = request.end;
1389 let limit = request.limit.map(|n| n.get() as u64);
1390 let request_id = request.request_id;
1391 let client_id = request.client_id.unwrap_or(self.client_id);
1392 let params = request.params;
1393 let clock = self.clock;
1394 let start_nanos = datetime_to_unix_nanos(start);
1395 let end_nanos = datetime_to_unix_nanos(end);
1396
1397 self.spawn_command(async move {
1398 match http.request_trades(instrument_id, start, end, limit).await {
1399 Ok(trades) => {
1400 let response = DataResponse::Trades(TradesResponse::new(
1401 request_id,
1402 client_id,
1403 instrument_id,
1404 trades,
1405 start_nanos,
1406 end_nanos,
1407 clock.get_time_ns(),
1408 params,
1409 ));
1410
1411 if let Err(e) = sender.send(DataEvent::Response(response)) {
1412 log::error!("Failed to send trades response: {e}");
1413 }
1414 }
1415 Err(e) => log::error!("Trades request failed: {e:?}"),
1416 }
1417 });
1418
1419 Ok(())
1420 }
1421
1422 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1423 let http = self.http.clone();
1424 let sender = self.data_sender.clone();
1425 let bar_type = request.bar_type;
1426 let start = request.start;
1427 let end = request.end;
1428 let limit = request.limit.map(|n| n.get() as u64);
1429 let request_id = request.request_id;
1430 let client_id = request.client_id.unwrap_or(self.client_id);
1431 let params = request.params;
1432 let clock = self.clock;
1433 let start_nanos = datetime_to_unix_nanos(start);
1434 let end_nanos = datetime_to_unix_nanos(end);
1435
1436 self.spawn_command(async move {
1437 match http.request_bars(bar_type, start, end, limit).await {
1438 Ok(bars) => {
1439 let response = DataResponse::Bars(BarsResponse::new(
1440 request_id,
1441 client_id,
1442 bar_type,
1443 bars,
1444 start_nanos,
1445 end_nanos,
1446 clock.get_time_ns(),
1447 params,
1448 ));
1449
1450 if let Err(e) = sender.send(DataEvent::Response(response)) {
1451 log::error!("Failed to send bars response: {e}");
1452 }
1453 }
1454 Err(e) => log::error!("Bars request failed: {e:?}"),
1455 }
1456 });
1457
1458 Ok(())
1459 }
1460
1461 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
1462 let http = self.http.clone();
1463 let sender = self.data_sender.clone();
1464 let instrument_id = request.instrument_id;
1465 let depth = request.depth.map(|n| n.get() as u32);
1466 let request_id = request.request_id;
1467 let client_id = request.client_id.unwrap_or(self.client_id);
1468 let params = request.params;
1469 let clock = self.clock;
1470
1471 self.spawn_command(async move {
1472 match http.request_book_snapshot(instrument_id, depth).await {
1473 Ok(book) => {
1474 let response = DataResponse::Book(BookResponse::new(
1475 request_id,
1476 client_id,
1477 instrument_id,
1478 book,
1479 None,
1480 None,
1481 clock.get_time_ns(),
1482 params,
1483 ));
1484
1485 if let Err(e) = sender.send(DataEvent::Response(response)) {
1486 log::error!("Failed to send book snapshot response: {e}");
1487 }
1488 }
1489 Err(e) => log::error!("Book snapshot request failed: {e:?}"),
1490 }
1491 });
1492
1493 Ok(())
1494 }
1495}
1496
1497type OhlcBufferKey = (Ustr, u32);
1498type OhlcBuffer = Arc<Mutex<AHashMap<OhlcBufferKey, (Bar, UnixNanos)>>>;
1499
1500struct DataEventSink<'a> {
1501 sender: &'a EventSender<DataEvent>,
1502}
1503
1504impl L3Sink for DataEventSink<'_> {
1505 fn emit_deltas(&mut self, deltas: OrderBookDeltas) {
1506 if let Err(e) = self
1507 .sender
1508 .send(DataEvent::Data(Data::BookDeltas(Box::new(deltas))))
1509 {
1510 log::error!("Failed to send L3 deltas: {e}");
1511 }
1512 }
1513}
1514
1515struct SpotMessageContext<'a> {
1516 sender: &'a EventSender<DataEvent>,
1517 instruments: &'a Arc<AtomicMap<InstrumentId, InstrumentAny>>,
1518 book_sequence: &'a Arc<AtomicU64>,
1519 l2_depths: &'a L2Depths,
1520 book_requests: &'a L2BookRequests,
1521 ohlc_buffer: &'a OhlcBuffer,
1522 clock: &'static AtomicTime,
1523}
1524
1525#[cfg(test)]
1526mod tests {
1527 use nautilus_common::{live::runner::set_data_event_sender, messages::DataEvent};
1528 use nautilus_model::{
1529 enums::{BookAction, RecordFlag},
1530 identifiers::Symbol,
1531 instruments::{InstrumentAny, currency_pair::CurrencyPair},
1532 types::{Currency, Price, Quantity},
1533 };
1534 use rstest::rstest;
1535 use rust_decimal::Decimal;
1536 use rust_decimal_macros::dec;
1537
1538 use super::*;
1539 use crate::{
1540 common::consts::KRAKEN_CLIENT_ID,
1541 config::KrakenDataClientConfig,
1542 websocket::spot_v2::{
1543 level_3::messages::KrakenL3Snapshot,
1544 messages::{KrakenWsBookData, KrakenWsBookLevel},
1545 },
1546 };
1547
1548 fn setup_test_env() {
1549 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1550 set_data_event_sender(sender);
1551 }
1552
1553 fn make_instrument() -> InstrumentAny {
1554 make_instrument_for("BTC/USD")
1555 }
1556
1557 fn make_instrument_for(symbol: &str) -> InstrumentAny {
1558 let (base, quote) = symbol.split_once('/').expect("a base/quote symbol");
1559 InstrumentAny::CurrencyPair(
1560 CurrencyPair::builder()
1561 .instrument_id(InstrumentId::from(format!("{symbol}.KRAKEN").as_str()))
1562 .raw_symbol(Symbol::from(symbol))
1563 .base_currency(Currency::from(base))
1564 .quote_currency(Currency::from(quote))
1565 .price_precision(1)
1566 .size_precision(8)
1567 .price_increment(Price::from("0.1"))
1568 .size_increment(Quantity::from("0.00000001"))
1569 .ts_event(UnixNanos::default())
1570 .ts_init(UnixNanos::default())
1571 .build()
1572 .unwrap(),
1573 )
1574 }
1575
1576 #[rstest]
1577 fn test_spot_data_client_new() {
1578 setup_test_env();
1579 let config = KrakenDataClientConfig::default();
1580 let client = KrakenSpotDataClient::new(*KRAKEN_CLIENT_ID, config);
1581 assert!(client.is_ok());
1582
1583 let client = client.unwrap();
1584 assert_eq!(client.client_id(), *KRAKEN_CLIENT_ID);
1585 assert_eq!(client.venue(), Some(*KRAKEN_VENUE));
1586 assert!(!client.is_connected());
1587 assert!(client.is_disconnected());
1588 assert!(client.instruments().is_empty());
1589 }
1590
1591 #[rstest]
1592 #[tokio::test]
1593 async fn test_teardown_clears_l3_client_and_handler_task() {
1594 setup_test_env();
1595 let config = KrakenDataClientConfig::default();
1596 let mut client = KrakenSpotDataClient::new(*KRAKEN_CLIENT_ID, config.clone()).unwrap();
1597 let cancellation = client.session_tasks.cancellation_token();
1598 let task = client
1599 .session_tasks
1600 .spawn_named("kraken-spot-l3-handler", async move {
1601 cancellation.cancelled().await;
1602 })
1603 .unwrap();
1604 client.l3_handler_task = Some(task.clone());
1605 client.ws_l3 = Some(KrakenSpotWebSocketClient::l3(
1606 config,
1607 client.cancellation_token.clone(),
1608 None,
1609 ));
1610
1611 client.teardown_partial_connect().await.unwrap();
1612
1613 assert!(client.ws_l3.is_none());
1614 assert!(client.l3_handler_task.is_none());
1615 assert!(task.is_finished());
1616 }
1617
1618 #[rstest]
1619 fn test_l3_snapshot_checksum_mismatch_emits_clear_and_requests_resync() {
1620 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1621 let instruments = Arc::new(AtomicMap::new());
1622 let instrument = make_instrument();
1623 instruments.insert(instrument.id(), instrument);
1624
1625 let depths = Arc::new(Mutex::new(AHashMap::new()));
1626 depths.lock().insert("BTC/USD".to_string(), 1000);
1627
1628 let snapshot: KrakenL3Snapshot = serde_json::from_str(
1629 r#"{
1630 "symbol": "BTC/USD",
1631 "bids": [{
1632 "order_id": "order-bid-1",
1633 "limit_price": 4199.0,
1634 "order_qty": 3.00000000,
1635 "timestamp": "2024-01-01T00:00:00Z"
1636 }],
1637 "asks": [{
1638 "order_id": "order-ask-1",
1639 "limit_price": 4200.0,
1640 "order_qty": 0.01000000,
1641 "timestamp": "2024-01-01T00:00:00Z"
1642 }],
1643 "checksum": 1,
1644 "timestamp": "2024-01-01T00:00:00Z"
1645 }"#,
1646 )
1647 .unwrap();
1648
1649 let mut states = AHashMap::new();
1650 let hasher = BookOrderIdHasher::new();
1651
1652 let mut sink = DataEventSink {
1653 sender: &sender.into(),
1654 };
1655
1656 let request = process_l3_message(
1657 KrakenL3WsMessage::Snapshot(snapshot),
1658 &mut sink,
1659 &instruments,
1660 &depths,
1661 &mut states,
1662 &hasher,
1663 true,
1664 get_atomic_clock_realtime().get_time_ns(),
1665 )
1666 .expect("expected resync request");
1667
1668 assert_eq!(request.symbol, "BTC/USD");
1669 assert_eq!(request.depth, 1000);
1670 assert_eq!(request.reason, "snapshot checksum mismatch");
1671
1672 let event = receiver.try_recv().expect("expected clear event");
1673 let DataEvent::Data(Data::BookDeltas(deltas)) = event else {
1674 panic!("expected deltas event");
1675 };
1676
1677 assert_eq!(deltas.deltas.len(), 1);
1678 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1679 assert!(states["BTC/USD"].awaiting_snapshot);
1680 assert!(states["BTC/USD"].open_orders.is_empty());
1681 assert!(receiver.try_recv().is_err());
1682 }
1683
1684 const BTC: &str = "BTC/USD";
1685
1686 struct L2Harness {
1689 sender: EventSender<DataEvent>,
1690 receiver: tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
1691 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
1692 book_sequence: Arc<AtomicU64>,
1693 l2_depths: L2Depths,
1694 book_requests: L2BookRequests,
1695 ohlc_buffer: OhlcBuffer,
1696 clock: &'static AtomicTime,
1697 l2_books: L2BookState,
1698 start: UnixNanos,
1699 }
1700
1701 impl L2Harness {
1702 fn new(validate_checksum: bool) -> Self {
1703 let start = UnixNanos::new(1_700_000_000_000_000_000);
1704 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1705 let instruments = Arc::new(AtomicMap::new());
1706 let btc = make_instrument();
1707 instruments.insert(btc.id(), btc);
1708
1709 Self {
1710 sender: sender.into(),
1711 receiver,
1712 instruments,
1713 book_sequence: Arc::new(AtomicU64::new(0)),
1714 l2_depths: L2Depths::default(),
1715 book_requests: Arc::new(Mutex::new(AHashMap::new())),
1716 ohlc_buffer: Arc::new(Mutex::new(AHashMap::new())),
1717 clock: Box::leak(Box::new(AtomicTime::new(false, start))),
1718 l2_books: L2BookState::new(validate_checksum),
1719 start,
1720 }
1721 }
1722
1723 fn handle(&mut self, msg: KrakenSpotWsMessage) -> Vec<L2ResyncRequest> {
1724 let context = SpotMessageContext {
1725 sender: &self.sender,
1726 instruments: &self.instruments,
1727 book_sequence: &self.book_sequence,
1728 l2_depths: &self.l2_depths,
1729 book_requests: &self.book_requests,
1730 ohlc_buffer: &self.ohlc_buffer,
1731 clock: self.clock,
1732 };
1733 KrakenSpotDataClient::handle_ws_message(msg, &context, &mut self.l2_books)
1734 }
1735
1736 fn check(&mut self) -> Vec<L2ResyncRequest> {
1737 let context = SpotMessageContext {
1738 sender: &self.sender,
1739 instruments: &self.instruments,
1740 book_sequence: &self.book_sequence,
1741 l2_depths: &self.l2_depths,
1742 book_requests: &self.book_requests,
1743 ohlc_buffer: &self.ohlc_buffer,
1744 clock: self.clock,
1745 };
1746 KrakenSpotDataClient::check_l2_snapshots(&context, &mut self.l2_books)
1747 }
1748
1749 fn at(&self, secs: u64) {
1751 self.clock
1752 .set_time(UnixNanos::new(self.start.as_u64() + secs * 1_000_000_000));
1753 }
1754
1755 fn held(&self) -> L2Subscription {
1756 self.l2_depths
1757 .subscription(BTC)
1758 .expect("a held subscription")
1759 }
1760
1761 fn record(&self, request: u64) {
1762 self.book_requests.lock().insert(
1763 request,
1764 L2BookRequest {
1765 symbol: Ustr::from(BTC),
1766 },
1767 );
1768 }
1769
1770 fn subscribe(&self, depth: u32, request: u64) -> L2Subscription {
1772 self.l2_depths.insert(BTC, depth, request);
1773 self.record(request);
1774 self.held()
1775 }
1776
1777 fn resubscribe(&self, request: u64) {
1779 let live = self.held();
1780 self.l2_depths
1781 .begin_resync(BTC, live.generation, live.snapshot_epoch, request)
1782 .expect("the recovery is admitted");
1783 self.record(request);
1784 }
1785
1786 fn answer(&mut self, request: u64, error: Option<&str>) -> Vec<L2ResyncRequest> {
1788 self.handle(KrakenSpotWsMessage::SubscriptionAck {
1789 req_id: Some(request),
1790 symbol: error.map(|_| Ustr::from(BTC)),
1791 success: error.is_none(),
1792 error: error.map(str::to_string),
1793 })
1794 }
1795
1796 fn snapshot(&mut self) -> Vec<L2ResyncRequest> {
1797 self.handle(KrakenSpotWsMessage::Book {
1798 data: vec![btc_book(None)],
1799 is_snapshot: true,
1800 })
1801 }
1802
1803 fn update(&mut self, checksum: Option<u32>) -> Vec<L2ResyncRequest> {
1804 let mut update = btc_book(checksum);
1805 update.bids = Some(vec![book_level(dec!(100), dec!(2))]);
1806 update.asks = Some(vec![]);
1807 self.handle(KrakenSpotWsMessage::Book {
1808 data: vec![update],
1809 is_snapshot: false,
1810 })
1811 }
1812
1813 fn recovery(&self) -> L2ResyncRequest {
1815 let live = self.held();
1816 L2ResyncRequest {
1817 instrument_id: make_instrument().id(),
1818 generation: live.generation,
1819 epoch: live.snapshot_epoch,
1820 }
1821 }
1822
1823 fn has_book(&self) -> bool {
1824 self.l2_books.books.contains_key(&make_instrument().id())
1825 }
1826
1827 fn events(&mut self) -> Vec<OrderBookDeltas> {
1829 let mut events = Vec::new();
1830
1831 while let Ok(event) = self.receiver.try_recv() {
1832 let DataEvent::Data(Data::BookDeltas(deltas)) = event else {
1833 panic!("expected a deltas event");
1834 };
1835 events.push(*deltas);
1836 }
1837 events
1838 }
1839 }
1840
1841 fn btc_book(checksum: Option<u32>) -> KrakenWsBookData {
1843 KrakenWsBookData {
1844 symbol: Ustr::from(BTC),
1845 bids: Some(vec![book_level(dec!(100), Decimal::ONE)]),
1846 asks: Some(vec![book_level(dec!(101), Decimal::ONE)]),
1847 checksum,
1848 timestamp: "2024-01-01T00:00:00Z".parse().unwrap(),
1849 }
1850 }
1851
1852 fn is_clear(deltas: &OrderBookDeltas) -> bool {
1853 deltas.deltas.len() == 1
1854 && deltas.deltas[0].action == BookAction::Clear
1855 && RecordFlag::F_LAST.matches(deltas.deltas[0].flags)
1856 }
1857
1858 #[rstest]
1861 fn test_l2_handler_returns_one_resync_request_per_mismatching_instrument() {
1862 let mut harness = L2Harness::new(true);
1863 harness.subscribe(10, 1);
1864 harness.answer(1, None);
1865 let bad_snapshot = btc_book(Some(1));
1866
1867 let resyncs = harness.handle(KrakenSpotWsMessage::Book {
1868 data: vec![bad_snapshot.clone(), bad_snapshot],
1869 is_snapshot: true,
1870 });
1871
1872 assert_eq!(
1873 resyncs.len(),
1874 1,
1875 "one request per mismatching instrument: {resyncs:?}"
1876 );
1877 assert!(
1878 resyncs
1879 .iter()
1880 .all(|r| r.instrument_id == make_instrument().id())
1881 );
1882 assert_eq!(
1883 resyncs[0].epoch,
1884 harness
1885 .l2_depths
1886 .subscription("BTC/USD")
1887 .unwrap()
1888 .snapshot_epoch,
1889 "the surviving request carries the latest epoch, so it is not skipped as served"
1890 );
1891 }
1892
1893 #[rstest]
1896 fn test_check_l2_snapshots_requests_only_the_overdue_book() {
1897 let mut harness = L2Harness::new(true);
1898 let eth = make_instrument_for("ETH/USD");
1899 harness.instruments.insert(eth.id(), eth);
1900 harness.subscribe(10, 1);
1901
1902 assert!(
1903 harness.check().is_empty(),
1904 "the first check starts the wait"
1905 );
1906
1907 harness.l2_depths.insert("ETH/USD", 25, 2);
1908 harness.at(10);
1909
1910 assert_eq!(harness.check(), vec![harness.recovery()]);
1911 }
1912
1913 #[rstest]
1917 #[case::unsupported_pair(Some(BTC), "Currency pair not supported BTC/USD")]
1918 #[case::rate_limit_without_a_pair(None, "Exceeded msg rate")]
1919 fn test_a_rejected_book_subscribe_is_requested_again_after_the_doubled_wait(
1920 #[case] symbol: Option<&str>,
1921 #[case] error: &str,
1922 ) {
1923 let mut harness = L2Harness::new(true);
1924 let rejected = harness.subscribe(10, 7);
1925
1926 assert!(harness.check().is_empty());
1927
1928 let resyncs = harness.handle(KrakenSpotWsMessage::SubscriptionAck {
1929 req_id: Some(7),
1930 symbol: symbol.map(Ustr::from),
1931 success: false,
1932 error: Some(error.to_string()),
1933 });
1934 assert!(resyncs.is_empty());
1935 assert!(
1936 harness.book_requests.lock().is_empty(),
1937 "the answered request is retired"
1938 );
1939
1940 harness.at(19);
1941 assert!(
1942 harness.check().is_empty(),
1943 "no request before the doubled base wait"
1944 );
1945
1946 harness.at(20);
1947 assert_eq!(
1948 harness.check(),
1949 vec![L2ResyncRequest {
1950 instrument_id: make_instrument().id(),
1951 generation: rejected.generation,
1952 epoch: 0,
1953 }],
1954 "the rejected subscribe is asked again after 20 s"
1955 );
1956
1957 let replacement = harness.subscribe(10, 8);
1958 assert!(
1959 harness.check().is_empty(),
1960 "the new generation's wait starts at this tick"
1961 );
1962 harness.at(30);
1963 assert_eq!(
1964 harness.check(),
1965 vec![L2ResyncRequest {
1966 instrument_id: make_instrument().id(),
1967 generation: replacement.generation,
1968 epoch: 0,
1969 }]
1970 );
1971 }
1972
1973 #[rstest]
1976 fn test_a_superseded_requests_rejection_leaves_a_fed_book_alone() {
1977 let mut harness = L2Harness::new(true);
1978 harness.subscribe(10, 1);
1979 harness.resubscribe(2);
1980 harness.answer(2, None);
1981 harness.snapshot();
1982 assert_eq!(harness.events().len(), 1, "the snapshot is emitted");
1983 let sequence = harness.book_sequence.load(Ordering::Relaxed);
1984
1985 harness.answer(1, Some("Already subscribed"));
1986
1987 assert!(
1988 harness.events().is_empty(),
1989 "a stale rejection emits nothing to the consumer"
1990 );
1991 assert!(harness.has_book(), "the fed book is kept");
1992 assert_eq!(harness.book_sequence.load(Ordering::Relaxed), sequence);
1993 }
1994
1995 #[rstest]
1999 fn test_a_rejection_of_the_latest_request_clears_a_fed_book() {
2000 let mut harness = L2Harness::new(true);
2001 harness.subscribe(10, 1);
2002 harness.answer(1, None);
2003 harness.snapshot();
2004 harness.events();
2005 let replacement = harness.subscribe(25, 2);
2008 harness.at(1);
2009
2010 harness.answer(2, Some("Already subscribed"));
2011
2012 let events = harness.events();
2013 assert_eq!(events.len(), 1);
2014 assert!(is_clear(&events[0]));
2015 assert!(!harness.has_book());
2016
2017 harness.update(None);
2018 assert!(
2019 harness.events().is_empty(),
2020 "the older stream's frames stay dropped"
2021 );
2022
2023 harness.at(20);
2024 assert!(harness.check().is_empty());
2025 harness.at(21);
2026 assert_eq!(
2027 harness.check(),
2028 vec![harness.recovery()],
2029 "the watchdog keeps asking"
2030 );
2031 assert_eq!(harness.recovery().generation, replacement.generation);
2032 }
2033
2034 #[rstest]
2038 fn test_a_reconnect_clears_the_consumers_books() {
2039 let mut harness = L2Harness::new(true);
2040 harness.subscribe(10, 1);
2041 harness.answer(1, None);
2042 harness.snapshot();
2043 assert_eq!(harness.events().len(), 1, "the snapshot is emitted");
2044 let sequence = harness.book_sequence.load(Ordering::Relaxed);
2045
2046 harness.handle(KrakenSpotWsMessage::Reconnected);
2047
2048 let events = harness.events();
2049 assert_eq!(events.len(), 1, "the consumer's book is cleared");
2050 assert_eq!(events[0].instrument_id, make_instrument().id());
2051 assert!(is_clear(&events[0]));
2052 assert_eq!(events[0].deltas[0].sequence, sequence);
2053 assert_eq!(harness.book_sequence.load(Ordering::Relaxed), sequence + 1);
2054 assert!(harness.l2_books.books.is_empty());
2055
2056 harness.answer(1, None);
2057 harness.answer(1, Some("Already subscribed"));
2058 assert!(harness.events().is_empty());
2059
2060 harness.snapshot();
2061 assert_eq!(
2062 harness.events().len(),
2063 1,
2064 "the replayed snapshot is emitted"
2065 );
2066 assert!(harness.has_book());
2067 }
2068
2069 #[rstest]
2073 fn test_the_tick_that_drops_a_retired_book_clears_the_consumers_book() {
2074 let mut harness = L2Harness::new(true);
2075 harness.subscribe(10, 1);
2076 harness.answer(1, None);
2077 harness.snapshot();
2078 harness.events();
2079 let sequence = harness.book_sequence.load(Ordering::Relaxed);
2080
2081 harness.subscribe(25, 2);
2082 harness.at(1);
2083 assert!(
2084 harness.check().is_empty(),
2085 "the replacement's wait starts at this tick"
2086 );
2087
2088 let events = harness.events();
2089 assert_eq!(events.len(), 1, "the retired book is cleared downstream");
2090 assert!(is_clear(&events[0]));
2091 assert_eq!(events[0].deltas[0].sequence, sequence);
2092 assert_eq!(
2093 events[0].deltas[0].ts_event,
2094 UnixNanos::new(harness.start.as_u64() + 1_000_000_000)
2095 );
2096 assert_eq!(harness.book_sequence.load(Ordering::Relaxed), sequence + 1);
2097
2098 harness.answer(2, None);
2099 assert!(harness.events().is_empty(), "the book is already cleared");
2100
2101 harness.snapshot();
2103 assert_eq!(harness.events().len(), 1, "the snapshot is emitted");
2104 harness.l2_depths.remove(BTC);
2105 assert!(harness.check().is_empty());
2106 assert!(
2107 harness.events().is_empty(),
2108 "no clear for an unsubscribed book"
2109 );
2110 }
2111
2112 #[rstest]
2118 fn test_answers_other_than_the_latest_requests_change_nothing() {
2119 let mut harness = L2Harness::new(true);
2120 harness.subscribe(10, 6);
2121 harness.subscribe(10, 7);
2122 let live = harness.subscribe(10, 8);
2123 harness.book_requests.lock().insert(
2124 11,
2125 L2BookRequest {
2126 symbol: Ustr::from("ETH/USD"),
2127 },
2128 );
2129 assert!(harness.check().is_empty());
2130
2131 harness.answer(6, Some("Already subscribed"));
2132 harness.answer(7, None);
2133 harness.answer(9, Some("Currency pair not supported BTC/USD"));
2134 harness.handle(KrakenSpotWsMessage::SubscriptionAck {
2135 req_id: Some(10),
2136 symbol: Some(Ustr::from(BTC)),
2137 success: false,
2138 error: Some("Subscription with depth 10 not Found BTC/USD".to_string()),
2139 });
2140 harness.answer(11, None);
2141
2142 assert!(harness.events().is_empty());
2143 assert_eq!(
2144 harness
2145 .book_requests
2146 .lock()
2147 .keys()
2148 .copied()
2149 .collect::<Vec<_>>(),
2150 vec![8]
2151 );
2152
2153 harness.snapshot();
2154 assert!(
2155 harness.events().is_empty(),
2156 "no answer opened the latest request's stream"
2157 );
2158
2159 harness.at(10);
2160 assert_eq!(harness.check(), vec![harness.recovery()]);
2161 assert_eq!(harness.recovery().generation, live.generation);
2162 }
2163
2164 #[rstest]
2168 fn test_a_replacements_confirmation_clears_the_consumers_book_once() {
2169 let mut harness = L2Harness::new(true);
2170 harness.subscribe(10, 1);
2171 harness.answer(1, None);
2172 harness.snapshot();
2173 harness.events();
2174
2175 harness.l2_depths.remove(BTC);
2176 let replacement = harness.subscribe(100, 2);
2177 harness.snapshot();
2178 assert!(
2179 harness.events().is_empty(),
2180 "the retired stream's snapshot is dropped"
2181 );
2182
2183 harness.at(1);
2184 harness.answer(2, None);
2185 let events = harness.events();
2186 assert_eq!(events.len(), 1);
2187 assert!(is_clear(&events[0]));
2188
2189 assert!(harness.check().is_empty());
2190 assert!(harness.events().is_empty(), "no second clear");
2191
2192 harness.at(11);
2193 assert_eq!(harness.check(), vec![harness.recovery()]);
2194 assert_eq!(harness.recovery().generation, replacement.generation);
2195 }
2196
2197 #[rstest]
2200 fn test_back_to_back_recoveries_accept_only_the_latest() {
2201 let mut harness = L2Harness::new(true);
2202 harness.subscribe(10, 1);
2203 harness.answer(1, None);
2204 harness.snapshot();
2205 harness.events();
2206 assert_eq!(harness.update(Some(1)).len(), 1, "the mismatch recovers");
2207 assert!(is_clear(&harness.events()[0]));
2208
2209 harness.resubscribe(2);
2210 harness.resubscribe(3);
2211 harness.answer(2, None);
2212 harness.snapshot();
2213 assert!(
2214 harness.events().is_empty(),
2215 "the superseded recovery's stream is dropped"
2216 );
2217
2218 harness.answer(3, None);
2219 assert!(harness.events().is_empty(), "no book to clear");
2220 harness.snapshot();
2221 assert_eq!(harness.events().len(), 1);
2222 assert!(harness.has_book());
2223 }
2224
2225 #[rstest]
2228 fn test_a_second_answer_to_one_request_is_ignored() {
2229 let mut harness = L2Harness::new(true);
2230 harness.subscribe(10, 1);
2231 harness.answer(1, None);
2232 harness.snapshot();
2233 assert_eq!(harness.events().len(), 1);
2234
2235 harness.answer(1, Some("Already subscribed"));
2236
2237 assert!(harness.events().is_empty());
2238 assert!(harness.has_book());
2239 harness.at(100);
2240 assert!(harness.check().is_empty());
2241 }
2242
2243 #[rstest]
2248 fn test_a_recovery_queued_across_a_reconnect_feeds_the_book() {
2249 let mut harness = L2Harness::new(true);
2250 harness.subscribe(10, 1);
2251 harness.answer(1, None);
2252 harness.snapshot();
2253 harness.update(Some(1));
2254 harness.events();
2255
2256 harness.resubscribe(2);
2257 harness.handle(KrakenSpotWsMessage::Reconnected);
2258 assert!(harness.events().is_empty(), "the mismatch dropped the book");
2259
2260 harness.answer(2, None);
2261 assert!(harness.events().is_empty());
2262 harness.snapshot();
2263 assert_eq!(harness.events().len(), 1);
2264
2265 harness.answer(2, Some("Already subscribed"));
2266 assert!(harness.events().is_empty());
2267 assert!(harness.has_book());
2268 }
2269
2270 #[rstest]
2275 fn test_a_replay_between_the_recovery_commands_recovers_within_the_doubled_wait() {
2276 let mut harness = L2Harness::new(true);
2277 harness.subscribe(10, 1);
2278 harness.answer(1, None);
2279 harness.snapshot();
2280 harness.update(Some(1));
2281 harness.events();
2282
2283 harness.resubscribe(2);
2284 harness.l2_depths.advance_epochs();
2285 harness.handle(KrakenSpotWsMessage::Reconnected);
2286 harness.handle(KrakenSpotWsMessage::SubscriptionAck {
2287 req_id: Some(500),
2288 symbol: Some(Ustr::from(BTC)),
2289 success: false,
2290 error: Some("Subscription with depth 10 not Found BTC/USD".to_string()),
2291 });
2292 harness.answer(1, None);
2293 harness.snapshot();
2294 assert_eq!(
2295 harness.events().len(),
2296 1,
2297 "the replayed snapshot is accepted"
2298 );
2299
2300 harness.at(1);
2301 harness.answer(2, Some("Already subscribed"));
2302 let events = harness.events();
2303 assert_eq!(events.len(), 1);
2304 assert!(is_clear(&events[0]), "the spurious clear");
2305
2306 harness.at(20);
2307 assert!(harness.check().is_empty());
2308 harness.at(21);
2309 assert_eq!(harness.check(), vec![harness.recovery()]);
2310 }
2311
2312 #[rstest]
2313 fn test_l2_update_prunes_levels_beyond_subscribed_depth() {
2314 let mut harness = L2Harness::new(false);
2315 harness.subscribe(10, 1);
2316 harness.answer(1, None);
2317
2318 let snapshot = KrakenWsBookData {
2319 symbol: Ustr::from("BTC/USD"),
2320 bids: Some(
2321 (0..10)
2322 .map(|i| book_level(Decimal::from(100 - i), Decimal::ONE))
2323 .collect(),
2324 ),
2325 asks: Some(
2326 (0..10)
2327 .map(|i| book_level(Decimal::from(101 + i), Decimal::ONE))
2328 .collect(),
2329 ),
2330 checksum: Some(0),
2331 timestamp: "2024-01-01T00:00:00Z".parse().unwrap(),
2332 };
2333 harness.handle(KrakenSpotWsMessage::Book {
2334 data: vec![snapshot],
2335 is_snapshot: true,
2336 });
2337
2338 let DataEvent::Data(Data::BookDeltas(snapshot_deltas)) = harness
2339 .receiver
2340 .try_recv()
2341 .expect("expected snapshot deltas")
2342 else {
2343 panic!("expected snapshot deltas");
2344 };
2345 assert_eq!(snapshot_deltas.deltas.len(), 21);
2346 assert_eq!(snapshot_deltas.deltas[0].action, BookAction::Clear);
2347 assert!(RecordFlag::F_LAST.matches(snapshot_deltas.deltas.last().unwrap().flags));
2348
2349 let bid_update = KrakenWsBookData {
2350 symbol: Ustr::from("BTC/USD"),
2351 bids: Some(vec![book_level(dec!(100.5), Decimal::ONE)]),
2352 asks: Some(vec![]),
2353 checksum: Some(0),
2354 timestamp: "2024-01-01T00:00:01Z".parse().unwrap(),
2355 };
2356 harness.handle(KrakenSpotWsMessage::Book {
2357 data: vec![bid_update],
2358 is_snapshot: false,
2359 });
2360
2361 let DataEvent::Data(Data::BookDeltas(bid_update_deltas)) = harness
2362 .receiver
2363 .try_recv()
2364 .expect("expected bid update deltas")
2365 else {
2366 panic!("expected bid update deltas");
2367 };
2368 assert_eq!(bid_update_deltas.deltas.len(), 2);
2369 assert_eq!(bid_update_deltas.deltas[0].action, BookAction::Update);
2370 assert_eq!(bid_update_deltas.deltas[1].action, BookAction::Delete);
2371 assert_eq!(bid_update_deltas.deltas[1].order.price, Price::from("91.0"));
2372 assert!(RecordFlag::F_LAST.matches(bid_update_deltas.deltas[1].flags));
2373
2374 let ask_update = KrakenWsBookData {
2375 symbol: Ustr::from("BTC/USD"),
2376 bids: Some(vec![]),
2377 asks: Some(vec![book_level(dec!(100.6), Decimal::ONE)]),
2378 checksum: Some(0),
2379 timestamp: "2024-01-01T00:00:02Z".parse().unwrap(),
2380 };
2381 harness.handle(KrakenSpotWsMessage::Book {
2382 data: vec![ask_update],
2383 is_snapshot: false,
2384 });
2385
2386 let DataEvent::Data(Data::BookDeltas(ask_update_deltas)) = harness
2387 .receiver
2388 .try_recv()
2389 .expect("expected ask update deltas")
2390 else {
2391 panic!("expected ask update deltas");
2392 };
2393 assert_eq!(ask_update_deltas.deltas.len(), 2);
2394 assert_eq!(ask_update_deltas.deltas[0].action, BookAction::Update);
2395 assert_eq!(ask_update_deltas.deltas[1].action, BookAction::Delete);
2396 assert_eq!(
2397 ask_update_deltas.deltas[1].order.price,
2398 Price::from("110.0")
2399 );
2400 assert!(RecordFlag::F_LAST.matches(ask_update_deltas.deltas[1].flags));
2401
2402 let book = harness
2403 .l2_books
2404 .books
2405 .get(&make_instrument().id())
2406 .expect("expected shadow book");
2407 assert_eq!(book.bids(None).count(), 10);
2408 assert_eq!(book.asks(None).count(), 10);
2409 assert_eq!(book.best_bid_price(), Some(Price::from("100.5")));
2410 assert_eq!(book.best_ask_price(), Some(Price::from("100.6")));
2411 assert!(harness.receiver.try_recv().is_err());
2412 }
2413
2414 #[rstest]
2415 fn test_spot_data_client_start_stop() {
2416 setup_test_env();
2417 let config = KrakenDataClientConfig::default();
2418 let mut client = KrakenSpotDataClient::new(*KRAKEN_CLIENT_ID, config).unwrap();
2419
2420 assert!(client.start().is_ok());
2421 assert!(client.stop().is_ok());
2422 assert!(client.is_disconnected());
2423 }
2424
2425 fn book_level(price: Decimal, qty: Decimal) -> KrakenWsBookLevel {
2426 KrakenWsBookLevel { price, qty }
2427 }
2428}