1use std::{
19 future::Future,
20 sync::{
21 Arc, Mutex,
22 atomic::{AtomicBool, AtomicU64, Ordering},
23 },
24};
25
26use ahash::AHashMap;
27use anyhow::Context;
28use async_trait::async_trait;
29use futures_util::StreamExt;
30use nautilus_common::{
31 clients::DataClient,
32 live::{get_data_event_sender, get_runtime},
33 messages::{
34 DataEvent,
35 data::{
36 BarsResponse, BookResponse, DataResponse, InstrumentResponse, InstrumentsResponse,
37 RequestBars, RequestBookSnapshot, RequestInstrument, RequestInstruments, RequestTrades,
38 SubscribeBars, SubscribeBookDeltas, SubscribeIndexPrices, SubscribeInstrument,
39 SubscribeInstrumentStatus, SubscribeInstruments, SubscribeMarkPrices, SubscribeQuotes,
40 SubscribeTrades, TradesResponse, UnsubscribeBars, UnsubscribeBookDeltas,
41 UnsubscribeIndexPrices, UnsubscribeInstrumentStatus, UnsubscribeMarkPrices,
42 UnsubscribeQuotes, UnsubscribeTrades,
43 },
44 },
45};
46use nautilus_core::{
47 AtomicMap, UnixNanos,
48 datetime::datetime_to_unix_nanos,
49 time::{AtomicTime, get_atomic_clock_realtime},
50};
51use nautilus_model::{
52 data::{Bar, Data, OrderBookDeltas_API},
53 enums::{AggregationSource, BookType},
54 identifiers::{ClientId, InstrumentId, Venue},
55 instruments::{Instrument, InstrumentAny},
56};
57use tokio::task::JoinHandle;
58use tokio_util::sync::CancellationToken;
59use ustr::Ustr;
60
61type OhlcBufferKey = (Ustr, u32);
62type OhlcBuffer = Arc<Mutex<AHashMap<OhlcBufferKey, (Bar, UnixNanos)>>>;
63
64use crate::{
65 common::{consts::KRAKEN_VENUE, lookup_instrument_in_snapshot},
66 config::KrakenDataClientConfig,
67 http::{KrakenSpotHttpClient, spot::client::KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND},
68 websocket::spot_v2::{
69 client::KrakenSpotWebSocketClient,
70 level_2::{L2BookState, L2Depths},
71 level_3::{
72 BookOrderIdHasher, KrakenL3WsMessage,
73 resync::retry_l3_resync,
74 runtime::{L3Sink, L3State, process_l3_message},
75 },
76 messages::KrakenSpotWsMessage,
77 parse::{parse_quote_tick, parse_trade_tick, parse_ws_bar},
78 },
79};
80
81struct DataEventSink<'a> {
83 sender: &'a tokio::sync::mpsc::UnboundedSender<DataEvent>,
84}
85
86impl L3Sink for DataEventSink<'_> {
87 fn emit_deltas(&mut self, deltas: OrderBookDeltas_API) {
88 if let Err(e) = self.sender.send(DataEvent::Data(Data::Deltas(deltas))) {
89 log::error!("Failed to send L3 deltas: {e}");
90 }
91 }
92}
93
94struct SpotMessageContext<'a> {
95 sender: &'a tokio::sync::mpsc::UnboundedSender<DataEvent>,
96 instruments: &'a Arc<AtomicMap<InstrumentId, InstrumentAny>>,
97 book_sequence: &'a Arc<AtomicU64>,
98 l2_depths: &'a L2Depths,
99 ohlc_buffer: &'a OhlcBuffer,
100 clock: &'static AtomicTime,
101}
102
103#[allow(dead_code)]
107#[derive(Debug)]
108pub struct KrakenSpotDataClient {
109 clock: &'static AtomicTime,
110 client_id: ClientId,
111 config: KrakenDataClientConfig,
112 http: KrakenSpotHttpClient,
113 ws: KrakenSpotWebSocketClient,
114 ws_l3: Option<KrakenSpotWebSocketClient>,
115 l3_handler_alive: Arc<AtomicBool>,
116 is_connected: AtomicBool,
117 cancellation_token: CancellationToken,
118 tasks: Vec<JoinHandle<()>>,
119 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
120 data_sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
121}
122
123impl KrakenSpotDataClient {
124 pub fn new(client_id: ClientId, config: KrakenDataClientConfig) -> anyhow::Result<Self> {
126 let cancellation_token = CancellationToken::new();
127
128 let http = KrakenSpotHttpClient::new(
129 config.environment,
130 config.base_url.clone(),
131 config.timeout_secs,
132 None,
133 None,
134 None,
135 config.proxy_url.clone(),
136 config
137 .max_requests_per_second
138 .unwrap_or(KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND),
139 )?;
140
141 let ws = KrakenSpotWebSocketClient::new(
142 config.clone(),
143 cancellation_token.clone(),
144 config.proxy_url.clone(),
145 );
146
147 Ok(Self {
148 clock: get_atomic_clock_realtime(),
149 client_id,
150 config,
151 http,
152 ws,
153 ws_l3: None,
154 l3_handler_alive: Arc::new(AtomicBool::new(false)),
155 is_connected: AtomicBool::new(false),
156 cancellation_token,
157 tasks: Vec::new(),
158 instruments: Arc::new(AtomicMap::new()),
159 data_sender: get_data_event_sender(),
160 })
161 }
162
163 #[must_use]
165 pub fn instruments(&self) -> Vec<InstrumentAny> {
166 self.instruments.load().values().cloned().collect()
167 }
168
169 #[must_use]
171 pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
172 self.instruments.load().get(instrument_id).cloned()
173 }
174
175 async fn load_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>> {
176 let instruments = self
177 .http
178 .request_instruments(None)
179 .await
180 .context("Failed to load spot instruments")?;
181
182 self.instruments.rcu(|m| {
183 for instrument in &instruments {
184 m.insert(instrument.id(), instrument.clone());
185 }
186 });
187
188 self.http.cache_instruments(&instruments);
189
190 log::debug!(
191 "Loaded instruments: client_id={}, count={}",
192 self.client_id,
193 instruments.len()
194 );
195
196 Ok(instruments)
197 }
198
199 fn spawn_ws<F>(&self, fut: F, context: &'static str)
200 where
201 F: Future<Output = anyhow::Result<()>> + Send + 'static,
202 {
203 get_runtime().spawn(async move {
204 if let Err(e) = fut.await {
205 log::error!("{context}: {e:?}");
206 }
207 });
208 }
209
210 fn subscribe_l3_book(&mut self, cmd: &SubscribeBookDeltas) -> anyhow::Result<()> {
211 let instrument_id = cmd.instrument_id;
212 let symbol_ustr = instrument_id.symbol.inner();
213 let depth = cmd.depth.map_or(1000, |d| d.get() as u32);
214
215 if !matches!(depth, 10 | 100 | 1000) {
216 anyhow::bail!("Invalid L3 depth {depth} for Kraken Spot, valid values: 10, 100, 1000");
217 }
218
219 if !self.config.has_api_credentials() {
220 anyhow::bail!(
221 "L3 order book requires API credentials; configure api_key and api_secret"
222 );
223 }
224
225 let handler_dead = !self.l3_handler_alive.load(Ordering::Relaxed);
226 if self.ws_l3.is_none() || handler_dead {
227 if let Some(dead) = self.ws_l3.take() {
228 get_runtime().spawn(async move {
229 let mut dead = dead;
230 let _ = dead.close().await;
231 });
232 }
233
234 let ws_l3 = KrakenSpotWebSocketClient::l3(
235 self.config.clone(),
236 self.cancellation_token.clone(),
237 self.config.proxy_url.clone(),
238 );
239
240 self.spawn_l3_handler_task(ws_l3.clone());
241 self.ws_l3 = Some(ws_l3);
242 }
243
244 let ws_l3 = self
245 .ws_l3
246 .as_ref()
247 .expect("ws_l3 initialised above")
248 .clone();
249
250 self.spawn_ws(
251 async move {
252 ws_l3
253 .wait_until_active(10.0)
254 .await
255 .map_err(|e| anyhow::anyhow!("L3 WebSocket failed to become active: {e}"))?;
256 ws_l3
257 .wait_until_authenticated(10.0)
258 .await
259 .map_err(|e| anyhow::anyhow!("L3 WebSocket failed to authenticate: {e}"))?;
260 ws_l3
261 .subscribe_book_l3(symbol_ustr, depth)
262 .await
263 .map_err(|e| anyhow::anyhow!("{e}"))
264 },
265 "subscribe l3 book",
266 );
267
268 Ok(())
269 }
270
271 fn spawn_l3_handler_task(&mut self, handler_client: KrakenSpotWebSocketClient) {
272 let data_sender = self.data_sender.clone();
273 let instruments = self.instruments.clone();
274 let cancellation_token = self.cancellation_token.clone();
275 let clock = self.clock;
276 let alive = self.l3_handler_alive.clone();
277
278 alive.store(true, Ordering::Relaxed);
279
280 let handle = get_runtime().spawn(async move {
281 struct AliveGuard(Arc<AtomicBool>);
282 impl Drop for AliveGuard {
283 fn drop(&mut self) {
284 self.0.store(false, Ordering::Relaxed);
285 }
286 }
287 let _alive_guard = AliveGuard(alive);
288
289 let mut handler_client = handler_client;
290
291 if let Err(e) = handler_client.connect().await {
292 log::error!("L3 WebSocket connect failed: {e}");
293 return;
294 }
295
296 if let Err(e) = handler_client.wait_until_active(10.0).await {
297 log::error!("L3 WebSocket failed to become active: {e}");
298 return;
299 }
300
301 if let Err(e) = handler_client.authenticate().await {
302 log::error!("L3 WebSocket authentication failed: {e}");
303 return;
304 }
305
306 let stream = match handler_client.stream() {
307 Ok(s) => s,
308 Err(e) => {
309 log::error!("L3 stream() failed: {e}");
310 return;
311 }
312 };
313 tokio::pin!(stream);
314
315 let mut states: AHashMap<String, L3State> = AHashMap::new();
316 let hasher = BookOrderIdHasher::new();
317 let l3_depths = handler_client.l3_depths_handle();
318 let validate_checksum = handler_client.validate_l3_checksum();
319 let resync_client = handler_client.clone();
320
321 loop {
322 tokio::select! {
323 () = cancellation_token.cancelled() => break,
324 msg = stream.next() => {
325 let Some(msg) = msg else { break };
326 let ts_init = clock.get_time_ns();
327
328 let runtime_msg = match msg {
329 KrakenSpotWsMessage::L3Snapshot(snap) => {
330 KrakenL3WsMessage::Snapshot(snap)
331 }
332 KrakenSpotWsMessage::L3Update(update) => {
333 KrakenL3WsMessage::Update(update)
334 }
335 KrakenSpotWsMessage::Reconnected => {
336 log::info!("L3 WebSocket reconnected");
337
338 for state in states.values_mut() {
339 state.open_orders.clear();
340 state.awaiting_snapshot = true;
341 }
342 continue;
343 }
344 _ => continue,
345 };
346
347 let mut sink = DataEventSink { sender: &data_sender };
348 let resync = process_l3_message(
349 runtime_msg,
350 &mut sink,
351 &instruments,
352 &l3_depths,
353 &mut states,
354 &hasher,
355 validate_checksum,
356 ts_init,
357 );
358
359 if let Some(request) = resync {
360 log::warn!(
361 "Resyncing Kraken L3 book: symbol={}, depth={}, reason={}",
362 request.symbol,
363 request.depth,
364 request.reason,
365 );
366 let symbol_ustr = Ustr::from(&request.symbol);
367 let client_for_resync = resync_client.clone();
368
369 get_runtime().spawn(async move {
370 retry_l3_resync(
371 &client_for_resync,
372 symbol_ustr,
373 request.depth,
374 )
375 .await;
376 });
377 }
378 }
379 }
380 }
381 });
382
383 self.tasks.push(handle);
384 }
385
386 fn spawn_message_handler(&mut self) -> anyhow::Result<()> {
387 let stream = self.ws.stream().map_err(|e| anyhow::anyhow!("{e}"))?;
388 let data_sender = self.data_sender.clone();
389 let instruments = self.instruments.clone();
390 let book_sequence = Arc::new(AtomicU64::new(0));
391 let ohlc_buffer: OhlcBuffer = Arc::new(Mutex::new(AHashMap::new()));
392 let l2_depths = self.ws.l2_depths_handle();
393 let cancellation_token = self.cancellation_token.clone();
394 let clock = self.clock;
395
396 let handle = get_runtime().spawn(async move {
397 tokio::pin!(stream);
398 let mut l2_books = L2BookState::default();
399
400 loop {
401 tokio::select! {
402 () = cancellation_token.cancelled() => {
403 log::debug!("Spot message handler cancelled");
404 Self::flush_ohlc_buffer(&ohlc_buffer, &data_sender);
405 break;
406 }
407 msg = stream.next() => {
408 match msg {
409 Some(ws_msg) => {
410 let context = SpotMessageContext {
411 sender: &data_sender,
412 instruments: &instruments,
413 book_sequence: &book_sequence,
414 l2_depths: &l2_depths,
415 ohlc_buffer: &ohlc_buffer,
416 clock,
417 };
418 Self::handle_ws_message(ws_msg, &context, &mut l2_books);
419 }
420 None => {
421 log::debug!("Spot WebSocket stream ended");
422 Self::flush_ohlc_buffer(&ohlc_buffer, &data_sender);
423 break;
424 }
425 }
426 }
427 }
428 }
429 });
430
431 self.tasks.push(handle);
432 Ok(())
433 }
434
435 fn flush_ohlc_buffer(
436 ohlc_buffer: &OhlcBuffer,
437 sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
438 ) {
439 let Ok(mut buffer) = ohlc_buffer.lock() else {
440 return;
441 };
442 let bars: Vec<Bar> = buffer.drain().map(|(_, (bar, _))| bar).collect();
443 for bar in bars {
444 if let Err(e) = sender.send(DataEvent::Data(Data::Bar(bar))) {
445 log::error!("Failed to send buffered bar: {e}");
446 }
447 }
448 }
449
450 fn handle_ws_message(
451 msg: KrakenSpotWsMessage,
452 context: &SpotMessageContext,
453 l2_books: &mut L2BookState,
454 ) {
455 let ts_init = context.clock.get_time_ns();
456
457 match msg {
458 KrakenSpotWsMessage::Ticker(tickers) => {
459 let instruments = context.instruments.load();
460
461 for ticker in &tickers {
462 let Some(instrument) =
463 lookup_instrument_in_snapshot(&instruments, ticker.symbol.as_str())
464 else {
465 log::warn!("No instrument for symbol: {}", ticker.symbol);
466 continue;
467 };
468
469 match parse_quote_tick(ticker, instrument, ts_init) {
470 Ok(quote) => {
471 if let Err(e) = context.sender.send(DataEvent::Data(Data::Quote(quote)))
472 {
473 log::error!("Failed to send quote: {e}");
474 }
475 }
476 Err(e) => log::error!("Failed to parse quote tick: {e}"),
477 }
478 }
479 }
480 KrakenSpotWsMessage::Trade(trades) => {
481 let instruments = context.instruments.load();
482
483 for trade in &trades {
484 let Some(instrument) =
485 lookup_instrument_in_snapshot(&instruments, trade.symbol.as_str())
486 else {
487 log::warn!("No instrument for symbol: {}", trade.symbol);
488 continue;
489 };
490
491 match parse_trade_tick(trade, instrument, ts_init) {
492 Ok(tick) => {
493 if let Err(e) = context.sender.send(DataEvent::Data(Data::Trade(tick)))
494 {
495 log::error!("Failed to send trade: {e}");
496 }
497 }
498 Err(e) => log::error!("Failed to parse trade tick: {e}"),
499 }
500 }
501 }
502 KrakenSpotWsMessage::Book { data, is_snapshot } => {
503 let instruments = context.instruments.load();
504
505 for book in &data {
506 let Some(instrument) =
507 lookup_instrument_in_snapshot(&instruments, book.symbol.as_str())
508 else {
509 log::warn!("No instrument for symbol: {}", book.symbol);
510 continue;
511 };
512 let sequence = context.book_sequence.load(Ordering::Relaxed);
513 let depth = context.l2_depths.get(book.symbol.as_str());
514 match l2_books.process_book(
515 book,
516 instrument,
517 sequence,
518 is_snapshot,
519 depth,
520 ts_init,
521 ) {
522 Ok(Some((deltas, next_sequence))) => {
523 context
524 .book_sequence
525 .store(next_sequence, Ordering::Relaxed);
526 let api_deltas = OrderBookDeltas_API::new(deltas);
527
528 if let Err(e) = context
529 .sender
530 .send(DataEvent::Data(Data::Deltas(api_deltas)))
531 {
532 log::error!("Failed to send deltas: {e}");
533 }
534 }
535 Ok(None) => {}
536 Err(e) => log::error!("Failed to parse book deltas: {e}"),
537 }
538 }
539 }
540 KrakenSpotWsMessage::Ohlc(ohlc_data) => {
541 let Ok(mut buffer) = context.ohlc_buffer.lock() else {
542 log::error!("OHLC buffer lock poisoned");
543 return;
544 };
545
546 let instruments = context.instruments.load();
547
548 for ohlc in &ohlc_data {
549 let Some(instrument) =
550 lookup_instrument_in_snapshot(&instruments, ohlc.symbol.as_str())
551 else {
552 log::warn!("No instrument for symbol: {}", ohlc.symbol);
553 continue;
554 };
555
556 match parse_ws_bar(ohlc, instrument, ts_init) {
557 Ok(new_bar) => {
558 let key: (Ustr, u32) = (ohlc.symbol, ohlc.interval);
559 let new_interval_begin = UnixNanos::from(
560 ohlc.interval_begin.timestamp_nanos_opt().unwrap_or(0) as u64,
561 );
562
563 if let Some((buffered_bar, buffered_begin)) = buffer.get(&key)
564 && new_interval_begin != *buffered_begin
565 && let Err(e) = context
566 .sender
567 .send(DataEvent::Data(Data::Bar(*buffered_bar)))
568 {
569 log::error!("Failed to send bar: {e}");
570 }
571
572 buffer.insert(key, (new_bar, new_interval_begin));
573 }
574 Err(e) => log::error!("Failed to parse bar: {e}"),
575 }
576 }
577 }
578 KrakenSpotWsMessage::Execution(_) => {}
579 KrakenSpotWsMessage::OrderResponse(_) => {}
580 KrakenSpotWsMessage::L3Snapshot(_) => {}
581 KrakenSpotWsMessage::L3Update(_) => {}
582 KrakenSpotWsMessage::Reconnected => {
583 log::info!("Spot WebSocket reconnected");
584 }
585 }
586 }
587}
588
589#[async_trait(?Send)]
590impl DataClient for KrakenSpotDataClient {
591 fn client_id(&self) -> ClientId {
592 self.client_id
593 }
594
595 fn venue(&self) -> Option<Venue> {
596 Some(*KRAKEN_VENUE)
597 }
598
599 fn start(&mut self) -> anyhow::Result<()> {
600 log::info!(
601 "Starting Spot data client: client_id={}, environment={:?}",
602 self.client_id,
603 self.config.environment
604 );
605 Ok(())
606 }
607
608 fn stop(&mut self) -> anyhow::Result<()> {
609 log::info!("Stopping Spot data client: {}", self.client_id);
610 self.cancellation_token.cancel();
611 self.is_connected.store(false, Ordering::Relaxed);
612 Ok(())
613 }
614
615 fn reset(&mut self) -> anyhow::Result<()> {
616 log::info!("Resetting Spot data client: {}", self.client_id);
617 self.cancellation_token.cancel();
618
619 for task in self.tasks.drain(..) {
620 task.abort();
621 }
622
623 let mut ws = self.ws.clone();
624 get_runtime().spawn(async move {
625 let _ = ws.close().await;
626 });
627
628 if let Some(mut ws_l3) = self.ws_l3.take() {
629 get_runtime().spawn(async move {
630 let _ = ws_l3.close().await;
631 });
632 }
633
634 self.instruments.store(ahash::AHashMap::new());
635
636 self.is_connected.store(false, Ordering::Relaxed);
637 self.cancellation_token = CancellationToken::new();
638 Ok(())
639 }
640
641 fn dispose(&mut self) -> anyhow::Result<()> {
642 log::debug!("Disposing Spot data client: {}", self.client_id);
643 self.stop()
644 }
645
646 fn is_connected(&self) -> bool {
647 self.is_connected.load(Ordering::SeqCst)
648 }
649
650 fn is_disconnected(&self) -> bool {
651 !self.is_connected()
652 }
653
654 async fn connect(&mut self) -> anyhow::Result<()> {
655 if self.is_connected() {
656 return Ok(());
657 }
658
659 let instruments = self.load_instruments().await?;
660
661 self.ws
662 .connect()
663 .await
664 .context("Failed to connect spot WebSocket")?;
665 self.ws
666 .wait_until_active(10.0)
667 .await
668 .context("Spot WebSocket failed to become active")?;
669
670 self.spawn_message_handler()?;
671
672 for instrument in instruments {
673 if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
674 log::error!("Failed to send instrument: {e}");
675 }
676 }
677
678 self.is_connected.store(true, Ordering::Release);
679 log::info!("Connected: client_id={}, product_type=Spot", self.client_id);
680 Ok(())
681 }
682
683 async fn disconnect(&mut self) -> anyhow::Result<()> {
684 if self.is_disconnected() {
685 return Ok(());
686 }
687
688 self.cancellation_token.cancel();
689 let _ = self.ws.close().await;
690
691 if let Some(mut ws_l3) = self.ws_l3.take() {
692 let _ = ws_l3.close().await;
693 }
694
695 for handle in self.tasks.drain(..) {
696 if let Err(e) = handle.await {
697 log::error!("Error joining WebSocket task: {e:?}");
698 }
699 }
700
701 self.cancellation_token = CancellationToken::new();
702 self.is_connected.store(false, Ordering::Relaxed);
703
704 log::info!("Disconnected: client_id={}", self.client_id);
705 Ok(())
706 }
707
708 fn subscribe_instruments(&mut self, _cmd: SubscribeInstruments) -> anyhow::Result<()> {
709 log::debug!("subscribe_instruments: Kraken instruments are fetched via HTTP on connect");
710 Ok(())
711 }
712
713 fn subscribe_instrument(&mut self, _cmd: SubscribeInstrument) -> anyhow::Result<()> {
714 log::debug!("subscribe_instrument: Kraken instruments are fetched via HTTP on connect");
715 Ok(())
716 }
717
718 fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
719 let instrument_id = cmd.instrument_id;
720 let depth = cmd.depth;
721
722 match cmd.book_type {
723 BookType::L2_MBP => {}
724 BookType::L3_MBO => return self.subscribe_l3_book(&cmd),
725 other => {
726 log::warn!("Unsupported BookType {other:?} for Kraken Spot, skipping");
727 return Ok(());
728 }
729 }
730
731 if let Some(d) = depth {
732 let d_val = d.get();
733 if !matches!(d_val, 10 | 25 | 100 | 500 | 1000) {
734 log::warn!("Invalid depth {d_val} for Kraken Spot, valid: 10, 25, 100, 500, 1000");
735 return Ok(());
736 }
737 }
738
739 let ws = self.ws.clone();
740 self.spawn_ws(
741 async move {
742 ws.subscribe_book(instrument_id, depth.map(|d| d.get() as u32))
743 .await
744 .map_err(|e| anyhow::anyhow!("{e}"))
745 },
746 "subscribe book",
747 );
748
749 Ok(())
750 }
751
752 fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
753 let instrument_id = cmd.instrument_id;
754 let ws = self.ws.clone();
755
756 self.spawn_ws(
757 async move {
758 ws.subscribe_quotes(instrument_id)
759 .await
760 .map_err(|e| anyhow::anyhow!("{e}"))
761 },
762 "subscribe quotes",
763 );
764
765 Ok(())
766 }
767
768 fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
769 let instrument_id = cmd.instrument_id;
770 let ws = self.ws.clone();
771
772 self.spawn_ws(
773 async move {
774 ws.subscribe_trades(instrument_id)
775 .await
776 .map_err(|e| anyhow::anyhow!("{e}"))
777 },
778 "subscribe trades",
779 );
780
781 Ok(())
782 }
783
784 fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
785 log::warn!(
786 "Mark price subscription not supported for Spot instrument {}",
787 cmd.instrument_id
788 );
789 Ok(())
790 }
791
792 fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
793 log::warn!(
794 "Index price subscription not supported for Spot instrument {}",
795 cmd.instrument_id
796 );
797 Ok(())
798 }
799
800 fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
801 let bar_type = cmd.bar_type;
802
803 if bar_type.aggregation_source() != AggregationSource::External {
804 log::warn!("Cannot subscribe to {bar_type} bars: only EXTERNAL bars supported");
805 return Ok(());
806 }
807
808 if !bar_type.spec().is_time_aggregated() {
809 log::warn!("Cannot subscribe to {bar_type} bars: only time-based bars supported");
810 return Ok(());
811 }
812
813 let ws = self.ws.clone();
814 self.spawn_ws(
815 async move {
816 ws.subscribe_bars(bar_type)
817 .await
818 .map_err(|e| anyhow::anyhow!("{e}"))
819 },
820 "subscribe bars",
821 );
822
823 Ok(())
824 }
825
826 fn subscribe_instrument_status(
827 &mut self,
828 cmd: SubscribeInstrumentStatus,
829 ) -> anyhow::Result<()> {
830 log::debug!(
831 "subscribe_instrument_status: {} (status changes detected via periodic instrument polling)",
832 cmd.instrument_id,
833 );
834 Ok(())
835 }
836
837 fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
838 let instrument_id = cmd.instrument_id;
839
840 if self.ws_l3.as_ref().is_some_and(|ws| {
841 ws.subscriptions_contains(&format!("level3:{}", instrument_id.symbol))
842 }) {
843 let symbol_ustr = instrument_id.symbol.inner();
844
845 if let Some(ws_l3) = self.ws_l3.clone() {
846 self.spawn_ws(
847 async move {
848 ws_l3
849 .unsubscribe_book_l3(symbol_ustr)
850 .await
851 .map_err(|e| anyhow::anyhow!("{e}"))?;
852 Ok(())
853 },
854 "unsubscribe l3 book",
855 );
856 }
857 return Ok(());
858 }
859
860 let ws = self.ws.clone();
861 self.spawn_ws(
862 async move {
863 ws.unsubscribe_book(instrument_id)
864 .await
865 .map_err(|e| anyhow::anyhow!("{e}"))
866 },
867 "unsubscribe book",
868 );
869
870 Ok(())
871 }
872
873 fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
874 let instrument_id = cmd.instrument_id;
875 let ws = self.ws.clone();
876
877 self.spawn_ws(
878 async move {
879 ws.unsubscribe_quotes(instrument_id)
880 .await
881 .map_err(|e| anyhow::anyhow!("{e}"))
882 },
883 "unsubscribe quotes",
884 );
885
886 Ok(())
887 }
888
889 fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
890 let instrument_id = cmd.instrument_id;
891 let ws = self.ws.clone();
892
893 self.spawn_ws(
894 async move {
895 ws.unsubscribe_trades(instrument_id)
896 .await
897 .map_err(|e| anyhow::anyhow!("{e}"))
898 },
899 "unsubscribe trades",
900 );
901
902 Ok(())
903 }
904
905 fn unsubscribe_mark_prices(&mut self, _cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
906 Ok(())
907 }
908
909 fn unsubscribe_index_prices(&mut self, _cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
910 Ok(())
911 }
912
913 fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
914 let bar_type = cmd.bar_type;
915 let ws = self.ws.clone();
916
917 self.spawn_ws(
918 async move {
919 ws.unsubscribe_bars(bar_type)
920 .await
921 .map_err(|e| anyhow::anyhow!("{e}"))
922 },
923 "unsubscribe bars",
924 );
925
926 Ok(())
927 }
928
929 fn unsubscribe_instrument_status(
930 &mut self,
931 _cmd: &UnsubscribeInstrumentStatus,
932 ) -> anyhow::Result<()> {
933 Ok(())
934 }
935
936 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
937 let http = self.http.clone();
938 let sender = self.data_sender.clone();
939 let instruments_cache = self.instruments.clone();
940 let request_id = request.request_id;
941 let client_id = request.client_id.unwrap_or(self.client_id);
942 let venue = *KRAKEN_VENUE;
943 let start_nanos = datetime_to_unix_nanos(request.start);
944 let end_nanos = datetime_to_unix_nanos(request.end);
945 let params = request.params;
946 let clock = self.clock;
947
948 get_runtime().spawn(async move {
949 match http.request_instruments(None).await {
950 Ok(instruments) => {
951 instruments_cache.rcu(|m| {
952 for instrument in &instruments {
953 m.insert(instrument.id(), instrument.clone());
954 }
955 });
956 http.cache_instruments(&instruments);
957
958 let response = DataResponse::Instruments(InstrumentsResponse::new(
959 request_id,
960 client_id,
961 venue,
962 instruments,
963 start_nanos,
964 end_nanos,
965 clock.get_time_ns(),
966 params,
967 ));
968
969 if let Err(e) = sender.send(DataEvent::Response(response)) {
970 log::error!("Failed to send instruments response: {e}");
971 }
972 }
973 Err(e) => log::error!("Instruments request failed: {e:?}"),
974 }
975 });
976
977 Ok(())
978 }
979
980 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
981 let http = self.http.clone();
982 let sender = self.data_sender.clone();
983 let instruments = self.instruments.clone();
984 let instrument_id = request.instrument_id;
985 let request_id = request.request_id;
986 let client_id = request.client_id.unwrap_or(self.client_id);
987 let start_nanos = datetime_to_unix_nanos(request.start);
988 let end_nanos = datetime_to_unix_nanos(request.end);
989 let params = request.params;
990 let clock = self.clock;
991
992 get_runtime().spawn(async move {
993 match http.request_instruments(None).await {
994 Ok(all_instruments) => {
995 instruments.rcu(|m| {
996 for instrument in &all_instruments {
997 m.insert(instrument.id(), instrument.clone());
998 }
999 });
1000 http.cache_instruments(&all_instruments);
1001
1002 let instrument = all_instruments
1003 .into_iter()
1004 .find(|i| i.id() == instrument_id);
1005
1006 if let Some(instrument) = instrument {
1007 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
1008 request_id,
1009 client_id,
1010 instrument.id(),
1011 instrument,
1012 start_nanos,
1013 end_nanos,
1014 clock.get_time_ns(),
1015 params,
1016 )));
1017
1018 if let Err(e) = sender.send(DataEvent::Response(response)) {
1019 log::error!("Failed to send instrument response: {e}");
1020 }
1021 } else {
1022 log::error!("Instrument not found: {instrument_id}");
1023 }
1024 }
1025 Err(e) => log::error!("Instrument request failed: {e:?}"),
1026 }
1027 });
1028
1029 Ok(())
1030 }
1031 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1032 let http = self.http.clone();
1033 let sender = self.data_sender.clone();
1034 let instrument_id = request.instrument_id;
1035 let start = request.start;
1036 let end = request.end;
1037 let limit = request.limit.map(|n| n.get() as u64);
1038 let request_id = request.request_id;
1039 let client_id = request.client_id.unwrap_or(self.client_id);
1040 let params = request.params;
1041 let clock = self.clock;
1042 let start_nanos = datetime_to_unix_nanos(start);
1043 let end_nanos = datetime_to_unix_nanos(end);
1044
1045 get_runtime().spawn(async move {
1046 match http.request_trades(instrument_id, start, end, limit).await {
1047 Ok(trades) => {
1048 let response = DataResponse::Trades(TradesResponse::new(
1049 request_id,
1050 client_id,
1051 instrument_id,
1052 trades,
1053 start_nanos,
1054 end_nanos,
1055 clock.get_time_ns(),
1056 params,
1057 ));
1058
1059 if let Err(e) = sender.send(DataEvent::Response(response)) {
1060 log::error!("Failed to send trades response: {e}");
1061 }
1062 }
1063 Err(e) => log::error!("Trades request failed: {e:?}"),
1064 }
1065 });
1066
1067 Ok(())
1068 }
1069
1070 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1071 let http = self.http.clone();
1072 let sender = self.data_sender.clone();
1073 let bar_type = request.bar_type;
1074 let start = request.start;
1075 let end = request.end;
1076 let limit = request.limit.map(|n| n.get() as u64);
1077 let request_id = request.request_id;
1078 let client_id = request.client_id.unwrap_or(self.client_id);
1079 let params = request.params;
1080 let clock = self.clock;
1081 let start_nanos = datetime_to_unix_nanos(start);
1082 let end_nanos = datetime_to_unix_nanos(end);
1083
1084 get_runtime().spawn(async move {
1085 match http.request_bars(bar_type, start, end, limit).await {
1086 Ok(bars) => {
1087 let response = DataResponse::Bars(BarsResponse::new(
1088 request_id,
1089 client_id,
1090 bar_type,
1091 bars,
1092 start_nanos,
1093 end_nanos,
1094 clock.get_time_ns(),
1095 params,
1096 ));
1097
1098 if let Err(e) = sender.send(DataEvent::Response(response)) {
1099 log::error!("Failed to send bars response: {e}");
1100 }
1101 }
1102 Err(e) => log::error!("Bars request failed: {e:?}"),
1103 }
1104 });
1105
1106 Ok(())
1107 }
1108
1109 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
1110 let http = self.http.clone();
1111 let sender = self.data_sender.clone();
1112 let instrument_id = request.instrument_id;
1113 let depth = request.depth.map(|n| n.get() as u32);
1114 let request_id = request.request_id;
1115 let client_id = request.client_id.unwrap_or(self.client_id);
1116 let params = request.params;
1117 let clock = self.clock;
1118
1119 get_runtime().spawn(async move {
1120 match http.request_book_snapshot(instrument_id, depth).await {
1121 Ok(book) => {
1122 let response = DataResponse::Book(BookResponse::new(
1123 request_id,
1124 client_id,
1125 instrument_id,
1126 book,
1127 None,
1128 None,
1129 clock.get_time_ns(),
1130 params,
1131 ));
1132
1133 if let Err(e) = sender.send(DataEvent::Response(response)) {
1134 log::error!("Failed to send book snapshot response: {e}");
1135 }
1136 }
1137 Err(e) => log::error!("Book snapshot request failed: {e:?}"),
1138 }
1139 });
1140
1141 Ok(())
1142 }
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use nautilus_common::{live::runner::set_data_event_sender, messages::DataEvent};
1148 use nautilus_model::{
1149 enums::{BookAction, RecordFlag},
1150 identifiers::Symbol,
1151 instruments::{InstrumentAny, currency_pair::CurrencyPair},
1152 types::{Currency, Price, Quantity},
1153 };
1154 use rstest::rstest;
1155
1156 use super::*;
1157 use crate::{
1158 common::consts::KRAKEN_CLIENT_ID,
1159 config::KrakenDataClientConfig,
1160 websocket::spot_v2::{
1161 level_3::messages::KrakenL3Snapshot,
1162 messages::{KrakenWsBookData, KrakenWsBookLevel},
1163 },
1164 };
1165
1166 fn setup_test_env() {
1167 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1168 set_data_event_sender(sender);
1169 }
1170
1171 fn make_instrument() -> InstrumentAny {
1172 InstrumentAny::CurrencyPair(CurrencyPair::new(
1173 InstrumentId::from("BTC/USD.KRAKEN"),
1174 Symbol::from("BTC/USD"),
1175 Currency::BTC(),
1176 Currency::USD(),
1177 1,
1178 8,
1179 Price::from("0.1"),
1180 Quantity::from("0.00000001"),
1181 None,
1182 None,
1183 None,
1184 None,
1185 None,
1186 None,
1187 None,
1188 None,
1189 None,
1190 None,
1191 None,
1192 None,
1193 None,
1194 None,
1195 UnixNanos::default(),
1196 UnixNanos::default(),
1197 ))
1198 }
1199
1200 #[rstest]
1201 fn test_spot_data_client_new() {
1202 setup_test_env();
1203 let config = KrakenDataClientConfig::default();
1204 let client = KrakenSpotDataClient::new(*KRAKEN_CLIENT_ID, config);
1205 assert!(client.is_ok());
1206
1207 let client = client.unwrap();
1208 assert_eq!(client.client_id(), *KRAKEN_CLIENT_ID);
1209 assert_eq!(client.venue(), Some(*KRAKEN_VENUE));
1210 assert!(!client.is_connected());
1211 assert!(client.is_disconnected());
1212 assert!(client.instruments().is_empty());
1213 }
1214
1215 #[rstest]
1216 fn test_l3_snapshot_checksum_mismatch_emits_clear_and_requests_resync() {
1217 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1218 let instruments = Arc::new(AtomicMap::new());
1219 let instrument = make_instrument();
1220 instruments.insert(instrument.id(), instrument);
1221
1222 let depths = Arc::new(Mutex::new(AHashMap::new()));
1223 depths
1224 .lock()
1225 .expect("depths lock poisoned")
1226 .insert("BTC/USD".to_string(), 1000);
1227
1228 let snapshot: KrakenL3Snapshot = serde_json::from_str(
1229 r#"{
1230 "symbol": "BTC/USD",
1231 "bids": [{
1232 "order_id": "order-bid-1",
1233 "limit_price": 4199.0,
1234 "order_qty": 3.00000000,
1235 "timestamp": "2024-01-01T00:00:00Z"
1236 }],
1237 "asks": [{
1238 "order_id": "order-ask-1",
1239 "limit_price": 4200.0,
1240 "order_qty": 0.01000000,
1241 "timestamp": "2024-01-01T00:00:00Z"
1242 }],
1243 "checksum": 1,
1244 "timestamp": "2024-01-01T00:00:00Z"
1245 }"#,
1246 )
1247 .unwrap();
1248
1249 let mut states = AHashMap::new();
1250 let hasher = BookOrderIdHasher::new();
1251 let mut sink = DataEventSink { sender: &sender };
1252 let request = process_l3_message(
1253 KrakenL3WsMessage::Snapshot(snapshot),
1254 &mut sink,
1255 &instruments,
1256 &depths,
1257 &mut states,
1258 &hasher,
1259 true,
1260 get_atomic_clock_realtime().get_time_ns(),
1261 )
1262 .expect("expected resync request");
1263
1264 assert_eq!(request.symbol, "BTC/USD");
1265 assert_eq!(request.depth, 1000);
1266 assert_eq!(request.reason, "snapshot checksum mismatch");
1267
1268 let event = receiver.try_recv().expect("expected clear event");
1269 let DataEvent::Data(Data::Deltas(deltas)) = event else {
1270 panic!("expected deltas event");
1271 };
1272
1273 assert_eq!(deltas.deltas.len(), 1);
1274 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1275 assert!(states["BTC/USD"].awaiting_snapshot);
1276 assert!(states["BTC/USD"].open_orders.is_empty());
1277 assert!(receiver.try_recv().is_err());
1278 }
1279
1280 #[rstest]
1281 fn test_l2_update_prunes_levels_beyond_subscribed_depth() {
1282 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1283 let instruments = Arc::new(AtomicMap::new());
1284 let instrument = make_instrument();
1285 let instrument_id = instrument.id();
1286 instruments.insert(instrument_id, instrument);
1287
1288 let book_sequence = Arc::new(AtomicU64::new(0));
1289 let l2_depths = L2Depths::default();
1290 l2_depths.insert("BTC/USD", 10);
1291 let mut l2_books = L2BookState::default();
1292 let ohlc_buffer = Arc::new(Mutex::new(AHashMap::new()));
1293 let context = SpotMessageContext {
1294 sender: &sender,
1295 instruments: &instruments,
1296 book_sequence: &book_sequence,
1297 l2_depths: &l2_depths,
1298 ohlc_buffer: &ohlc_buffer,
1299 clock: get_atomic_clock_realtime(),
1300 };
1301
1302 let snapshot = KrakenWsBookData {
1303 symbol: Ustr::from("BTC/USD"),
1304 bids: Some(
1305 (0..10)
1306 .map(|i| book_level(100.0 - f64::from(i), 1.0))
1307 .collect(),
1308 ),
1309 asks: Some(
1310 (0..10)
1311 .map(|i| book_level(101.0 + f64::from(i), 1.0))
1312 .collect(),
1313 ),
1314 checksum: Some(0),
1315 timestamp: "2024-01-01T00:00:00Z".parse().unwrap(),
1316 };
1317 KrakenSpotDataClient::handle_ws_message(
1318 KrakenSpotWsMessage::Book {
1319 data: vec![snapshot],
1320 is_snapshot: true,
1321 },
1322 &context,
1323 &mut l2_books,
1324 );
1325
1326 let DataEvent::Data(Data::Deltas(snapshot_deltas)) =
1327 receiver.try_recv().expect("expected snapshot deltas")
1328 else {
1329 panic!("expected snapshot deltas");
1330 };
1331 assert_eq!(snapshot_deltas.deltas.len(), 21);
1332 assert_eq!(snapshot_deltas.deltas[0].action, BookAction::Clear);
1333 assert!(RecordFlag::F_LAST.matches(snapshot_deltas.deltas.last().unwrap().flags));
1334
1335 let bid_update = KrakenWsBookData {
1336 symbol: Ustr::from("BTC/USD"),
1337 bids: Some(vec![book_level(100.5, 1.0)]),
1338 asks: Some(vec![]),
1339 checksum: Some(0),
1340 timestamp: "2024-01-01T00:00:01Z".parse().unwrap(),
1341 };
1342 KrakenSpotDataClient::handle_ws_message(
1343 KrakenSpotWsMessage::Book {
1344 data: vec![bid_update],
1345 is_snapshot: false,
1346 },
1347 &context,
1348 &mut l2_books,
1349 );
1350
1351 let DataEvent::Data(Data::Deltas(bid_update_deltas)) =
1352 receiver.try_recv().expect("expected bid update deltas")
1353 else {
1354 panic!("expected bid update deltas");
1355 };
1356 assert_eq!(bid_update_deltas.deltas.len(), 2);
1357 assert_eq!(bid_update_deltas.deltas[0].action, BookAction::Update);
1358 assert_eq!(bid_update_deltas.deltas[1].action, BookAction::Delete);
1359 assert_eq!(bid_update_deltas.deltas[1].order.price, Price::from("91.0"));
1360 assert!(RecordFlag::F_LAST.matches(bid_update_deltas.deltas[1].flags));
1361
1362 let ask_update = KrakenWsBookData {
1363 symbol: Ustr::from("BTC/USD"),
1364 bids: Some(vec![]),
1365 asks: Some(vec![book_level(100.6, 1.0)]),
1366 checksum: Some(0),
1367 timestamp: "2024-01-01T00:00:02Z".parse().unwrap(),
1368 };
1369 KrakenSpotDataClient::handle_ws_message(
1370 KrakenSpotWsMessage::Book {
1371 data: vec![ask_update],
1372 is_snapshot: false,
1373 },
1374 &context,
1375 &mut l2_books,
1376 );
1377
1378 let DataEvent::Data(Data::Deltas(ask_update_deltas)) =
1379 receiver.try_recv().expect("expected ask update deltas")
1380 else {
1381 panic!("expected ask update deltas");
1382 };
1383 assert_eq!(ask_update_deltas.deltas.len(), 2);
1384 assert_eq!(ask_update_deltas.deltas[0].action, BookAction::Update);
1385 assert_eq!(ask_update_deltas.deltas[1].action, BookAction::Delete);
1386 assert_eq!(
1387 ask_update_deltas.deltas[1].order.price,
1388 Price::from("110.0")
1389 );
1390 assert!(RecordFlag::F_LAST.matches(ask_update_deltas.deltas[1].flags));
1391
1392 let book = l2_books
1393 .books
1394 .get(&instrument_id)
1395 .expect("expected shadow book");
1396 assert_eq!(book.bids(None).count(), 10);
1397 assert_eq!(book.asks(None).count(), 10);
1398 assert_eq!(book.best_bid_price(), Some(Price::from("100.5")));
1399 assert_eq!(book.best_ask_price(), Some(Price::from("100.6")));
1400 assert!(receiver.try_recv().is_err());
1401 }
1402
1403 #[rstest]
1404 fn test_spot_data_client_start_stop() {
1405 setup_test_env();
1406 let config = KrakenDataClientConfig::default();
1407 let mut client = KrakenSpotDataClient::new(*KRAKEN_CLIENT_ID, config).unwrap();
1408
1409 assert!(client.start().is_ok());
1410 assert!(client.stop().is_ok());
1411 assert!(client.is_disconnected());
1412 }
1413
1414 fn book_level(price: f64, qty: f64) -> KrakenWsBookLevel {
1415 KrakenWsBookLevel { price, qty }
1416 }
1417}