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