Skip to main content

nautilus_kraken/data/
spot.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Kraken Spot data client implementation.
17
18use 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
81/// `L3Sink` implementation that forwards deltas to the data engine.
82struct 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/// Kraken Spot data client.
104///
105/// Provides real-time market data from Kraken Spot markets through WebSocket v2.
106#[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    /// Creates a new [`KrakenSpotDataClient`] instance.
125    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    /// Returns the cached instruments.
164    #[must_use]
165    pub fn instruments(&self) -> Vec<InstrumentAny> {
166        self.instruments.load().values().cloned().collect()
167    }
168
169    /// Returns a cached instrument by ID.
170    #[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}