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