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