Skip to main content

nautilus_coinapi/
data.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//! Live CoinAPI data client for instruments, order books, quotes, trades, OHLCV bars, and
17//! historical requests.
18
19use std::{
20    collections::HashMap,
21    future::Future,
22    num::NonZeroUsize,
23    sync::{Arc, atomic::AtomicBool},
24    time::Duration,
25};
26
27use ahash::{AHashMap, AHashSet};
28use anyhow::Context;
29use arc_swap::ArcSwap;
30use async_trait::async_trait;
31use jiff::Timestamp;
32use nautilus_common::{
33    clients::DataClient,
34    live::{dst::time, runner::get_data_event_sender, sender::EventSender},
35    messages::{
36        DataEvent, DataResponse,
37        data::{
38            BarsResponse, BookResponse, InstrumentResponse, InstrumentsResponse, QuotesResponse,
39            RequestBars, RequestBookSnapshot, RequestInstrument, RequestInstruments, RequestQuotes,
40            RequestTrades, SubscribeBars, SubscribeBookDeltas, SubscribeBookDepth, SubscribeQuotes,
41            SubscribeTrades, TradesResponse, UnsubscribeBars, UnsubscribeBookDeltas,
42            UnsubscribeBookDepth, UnsubscribeQuotes, UnsubscribeTrades,
43        },
44    },
45};
46use nautilus_core::{
47    UnixNanos,
48    datetime::datetime_to_unix_nanos,
49    string::secret::SecretString,
50    time::{AtomicTime, get_atomic_clock_realtime},
51};
52use nautilus_live::{
53    book::{
54        BookSequenceOutcome,
55        snapshot::{SnapshotGate, snapshot_expired},
56    },
57    task::{TaskGroup, TaskSpawner},
58};
59use nautilus_model::{
60    data::{DEPTH10_LEN, Data},
61    enums::{AggregationSource, BookType},
62    identifiers::{ClientId, InstrumentId, Venue},
63    instruments::{Instrument, InstrumentAny},
64    types::fixed::check_fixed_precision,
65};
66use parking_lot::Mutex;
67use tokio_util::sync::CancellationToken;
68use ustr::Ustr;
69
70use crate::{
71    book::{
72        recovery::{spawn_recovery_monitor, spawn_recovery_task, start_recovery},
73        sync::{BookFrame, BookSubscription, BookSubscriptions},
74    },
75    common::{consts::COINAPI, credential::resolve_api_key, symbol::VenueSymbol},
76    config::{CoinApiDataClientConfig, CoinApiInstrumentSelection},
77    http::{
78        client::{CoinApiHttpClient, HistoryPages},
79        parse::{self as http_parse, CoinApiInstrument},
80        query::{BookQuery, HistoryRange},
81    },
82    websocket::{
83        client::CoinApiWebSocketClient,
84        messages::{Channel, CoinApiWsMessage, DataType, Ohlcv, ScopeChange, Subscription},
85        parse::{
86            bar_spec_to_period_id, parse_bar, parse_book_depth, parse_quote_tick, parse_trade_tick,
87        },
88    },
89};
90
91const TASK_GRACE: Duration = Duration::from_secs(1);
92const TASK_FORCE: Duration = Duration::from_secs(2);
93const SUBSCRIPTION_RETRY_DELAY_INITIAL: Duration = Duration::from_secs(1);
94const SUBSCRIPTION_RETRY_DELAY_MAX: Duration = Duration::from_secs(30);
95
96// Fixed-depth channels in cost order, with the levels each lists per side
97const BOOK_DEPTH_CHANNELS: [(DataType, usize); 3] = [
98    (DataType::Book5, 5),
99    (DataType::Book20, 20),
100    (DataType::Book50, 50),
101];
102
103/// CoinAPI data client streaming order books, quotes, trades, and OHLCV bars for configured
104/// native instruments.
105///
106/// Connecting loads the configured instruments, emits them, and retains the CoinAPI `symbol_id`
107/// of each one for subscriptions and historical requests. Every subscription shares one WebSocket
108/// connection, which replays its scope after a transport reconnect. Instrument refreshes update
109/// precision and REST symbol IDs; subscriptions retain their connect-time symbol IDs until the
110/// data client disconnects and connects again.
111///
112/// Book deltas stream from the full `book` channel for L2 books and from `book_l3` for L3 books,
113/// with sequence gap detection and recovery. Depth snapshots stream from the cheapest of `book5`,
114/// `book20`, and `book50` that lists the requested depth, ten levels by default. CoinAPI allows one
115/// book channel per connection, so every book subscription must use the same channel.
116#[derive(Debug)]
117pub struct CoinApiDataClient {
118    client_id: ClientId,
119    config: CoinApiDataClientConfig,
120    api_key: SecretString,
121    http: Arc<CoinApiHttpClient>,
122    ws: Option<Arc<CoinApiWebSocketClient>>,
123    subscription_tx: Option<SubscriptionSender>,
124    is_connected: bool,
125    session_tasks: TaskGroup,
126    command_tasks: TaskGroup,
127    data_sender: EventSender<DataEvent>,
128    instruments: Arc<InstrumentIndex>,
129    books: Arc<BookSubscriptions>,
130    ohlcv: Arc<Mutex<OhlcvCache>>,
131    clock: &'static AtomicTime,
132}
133
134impl CoinApiDataClient {
135    // panics-doc-ok (transitive via get_data_event_sender)
136    /// Creates a client without connecting.
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if no valid API key resolves from the config or `COINAPI_API_KEY`, when an
141    /// instrument selection is empty, repeats an instrument, or names an instrument that does not
142    /// map to its exchange, or when the REST or WebSocket configuration is invalid.
143    ///
144    /// # Panics
145    ///
146    /// Panics if the data event sender is not initialized on this thread.
147    pub fn new(client_id: ClientId, config: CoinApiDataClientConfig) -> anyhow::Result<Self> {
148        validate_selections(&config.instruments)?;
149        validate_price_precisions(&config.instruments, &config.price_precisions)?;
150        let api_key = resolve_api_key(config.api_key.clone())?;
151        let http = CoinApiHttpClient::new(api_key.clone(), config.http_config())?;
152
153        // Validates the WebSocket endpoint now; connect creates the client that owns the stream
154        CoinApiWebSocketClient::new(api_key.clone(), config.websocket_config())?;
155
156        Ok(Self {
157            client_id,
158            config,
159            api_key,
160            http: Arc::new(http),
161            ws: None,
162            subscription_tx: None,
163            is_connected: false,
164            session_tasks: TaskGroup::new(),
165            command_tasks: TaskGroup::new(),
166            data_sender: get_data_event_sender(),
167            instruments: Arc::new(InstrumentIndex::default()),
168            books: Arc::new(BookSubscriptions::default()),
169            ohlcv: Arc::new(Mutex::new(OhlcvCache::new())),
170            clock: get_atomic_clock_realtime(),
171        })
172    }
173
174    // A `depth` subscribes fixed-depth snapshots; otherwise the book streams synchronized deltas
175    fn subscribe_book(
176        &self,
177        instrument_id: InstrumentId,
178        data_type: DataType,
179        depth: Option<usize>,
180    ) -> anyhow::Result<()> {
181        let _write = self.books.writes.lock();
182
183        if let Some(active) = self.books.entries.get_cloned(&instrument_id) {
184            let active_depth = active.depth;
185            anyhow::ensure!(
186                active.data_type == data_type && active_depth == depth,
187                "Conflicting CoinAPI book subscription for {instrument_id}: active {} with depth \
188                 {active_depth:?}, requested {} with depth {depth:?}",
189                active.data_type.as_ref(),
190                data_type.as_ref()
191            );
192            return Ok(());
193        }
194
195        if let Some(active) = self
196            .books
197            .entries
198            .load()
199            .values()
200            .find(|active| active.data_type != data_type)
201        {
202            anyhow::bail!(
203                "CoinAPI streams one book channel per connection: {} is active, requested {}",
204                active.data_type.as_ref(),
205                data_type.as_ref()
206            );
207        }
208
209        let (commands, subscription) =
210            self.subscription(instrument_id, channel(data_type, None))?;
211
212        let snapshot = if depth.is_some() {
213            None
214        } else {
215            Some(self.expect_book_snapshot(instrument_id, subscription)?)
216        };
217
218        let registration = BookSubscription {
219            data_type,
220            depth,
221            active: Arc::new(AtomicBool::new(false)),
222        };
223
224        if let Err(e) = send_subscription(
225            &commands,
226            ScopeChange::Subscribe(subscription),
227            snapshot,
228            Some(Arc::clone(&registration.active)),
229        ) {
230            self.books.sync.remove(instrument_id);
231            return Err(e);
232        }
233
234        self.books.entries.insert(instrument_id, registration);
235        Ok(())
236    }
237
238    // Holds snapshots behind a closed gate until the subscribing hello is written, then recovers
239    // the book when its first snapshot misses the deadline
240    fn expect_book_snapshot(
241        &self,
242        instrument_id: InstrumentId,
243        subscription: Subscription,
244    ) -> anyhow::Result<SnapshotWait> {
245        let ws = self
246            .ws
247            .clone()
248            .with_context(|| format!("{} is not connected", self.client_id))?;
249        let spawner = self.session_tasks.spawner()?;
250        let shutdown = spawner.cancellation_token();
251        let tracker = self.books.sync.clone();
252        let ohlcv = Arc::clone(&self.ohlcv);
253        let timeout = self.book_snapshot_timeout();
254
255        let gate = SnapshotGate::default();
256        gate.lock().close();
257        let cancel = tracker.record_subscription(
258            instrument_id,
259            subscription,
260            time::Instant::now(),
261            gate.clone(),
262        );
263        let guard = cancel.clone().drop_guard();
264        let (written_tx, written_rx) = tokio::sync::oneshot::channel();
265
266        let task = {
267            let spawner = spawner.clone();
268
269            async move {
270                let _guard = guard;
271
272                tokio::select! {
273                    biased;
274                    () = shutdown.cancelled() => return,
275                    () = cancel.cancelled() => return,
276                    written = written_rx => {
277                        if written.is_err() {
278                            return;
279                        }
280                    }
281                }
282
283                tokio::select! {
284                    biased;
285                    () = shutdown.cancelled() => return,
286                    expired = snapshot_expired(&cancel, timeout) => {
287                        if !expired {
288                            return;
289                        }
290                    }
291                }
292
293                log::warn!(
294                    "Initial book snapshot missing for {instrument_id}; requesting a fresh snapshot"
295                );
296
297                if let Some((recovery, subscription)) =
298                    tracker.claim_subscription_recovery(instrument_id, &cancel)
299                {
300                    spawn_recovery_task(
301                        instrument_id,
302                        subscription,
303                        recovery,
304                        ws,
305                        ohlcv,
306                        timeout,
307                        &spawner,
308                    );
309                }
310            }
311        };
312
313        if let Err(e) = spawner.spawn(task) {
314            self.books.sync.remove(instrument_id);
315            return Err(e.into());
316        }
317
318        Ok(SnapshotWait {
319            gate,
320            written: written_tx,
321        })
322    }
323
324    // Removes the instrument's depth snapshots when `depth` is set, otherwise its deltas
325    fn unsubscribe_book(&self, instrument_id: InstrumentId, depth: bool) -> anyhow::Result<()> {
326        let _write = self.books.writes.lock();
327
328        let Some(registration) = self
329            .books
330            .entries
331            .get_cloned(&instrument_id)
332            .filter(|registration| registration.depth.is_some() == depth)
333        else {
334            return Ok(());
335        };
336
337        let (commands, subscription) =
338            self.subscription(instrument_id, channel(registration.data_type, None))?;
339
340        // Removing the book first cancels its recovery before the scope change is queued
341        self.books.sync.remove(instrument_id);
342        self.books.entries.remove(&instrument_id);
343
344        send_subscription(
345            &commands,
346            ScopeChange::Unsubscribe(subscription),
347            None,
348            None,
349        )
350    }
351
352    fn book_snapshot_timeout(&self) -> Duration {
353        Duration::from_secs(self.config.book_snapshot_timeout_secs)
354    }
355
356    fn subscribe_channel(
357        &self,
358        instrument_id: InstrumentId,
359        channel: Channel,
360    ) -> anyhow::Result<()> {
361        let (commands, subscription) = self.subscription(instrument_id, channel)?;
362
363        // A period retained from an earlier subscription must not close against this one
364        if let Some(period_id) = channel.period_id {
365            self.ohlcv
366                .lock()
367                .remove(&(subscription.symbol_id, period_id));
368        }
369
370        send_subscription(&commands, ScopeChange::Subscribe(subscription), None, None)
371    }
372
373    fn unsubscribe_channel(
374        &self,
375        instrument_id: InstrumentId,
376        channel: Channel,
377    ) -> anyhow::Result<()> {
378        let (commands, subscription) = self.subscription(instrument_id, channel)?;
379        send_subscription(
380            &commands,
381            ScopeChange::Unsubscribe(subscription),
382            None,
383            None,
384        )
385    }
386
387    fn subscription(
388        &self,
389        instrument_id: InstrumentId,
390        channel: Channel,
391    ) -> anyhow::Result<(SubscriptionSender, Subscription)> {
392        let commands = self
393            .subscription_tx
394            .clone()
395            .with_context(|| format!("{} is not connected", self.client_id))?;
396        let symbol_id = self
397            .instruments
398            .stream_symbol_id(instrument_id)
399            .with_context(|| format!("{instrument_id} is not a loaded CoinAPI instrument"))?;
400
401        Ok((commands, Subscription { channel, symbol_id }))
402    }
403
404    // Resolves a loaded instrument and a window ending at `end`, or now when absent
405    fn history(
406        &self,
407        instrument_id: InstrumentId,
408        start: Option<Timestamp>,
409        end: Option<Timestamp>,
410        limit: Option<NonZeroUsize>,
411    ) -> anyhow::Result<(Ustr, InstrumentAny, HistoryRange)> {
412        let (symbol_id, instrument) = self.loaded(instrument_id)?;
413        let time_start = start.context("CoinAPI historical requests require a start time")?;
414        let time_end = end.unwrap_or_else(|| self.clock.get_time_ns().to_datetime_utc());
415
416        let range = HistoryRange {
417            time_start,
418            time_end,
419            limit,
420        };
421
422        Ok((symbol_id, instrument, range))
423    }
424
425    fn loaded(&self, instrument_id: InstrumentId) -> anyhow::Result<(Ustr, InstrumentAny)> {
426        self.instruments
427            .loaded(instrument_id)
428            .with_context(|| format!("{instrument_id} is not a loaded CoinAPI instrument"))
429    }
430
431    fn spawn_command<F>(&self, description: &'static str, future: F)
432    where
433        F: Future<Output = anyhow::Result<()>> + Send + 'static,
434    {
435        let cancellation = self.command_tasks.cancellation_token();
436
437        let future = async move {
438            tokio::select! {
439                biased;
440                () = cancellation.cancelled() => {}
441                result = future => {
442                    if let Err(e) = result {
443                        log::error!("{description} failed: {e:#}");
444                    }
445                }
446            }
447        };
448
449        if let Err(e) = self.command_tasks.spawn(future) {
450            log::warn!("Skipping {description} after shutdown began: {e}");
451        }
452    }
453
454    fn begin_shutdown(&mut self) {
455        self.session_tasks.begin_shutdown();
456        self.command_tasks.begin_shutdown();
457        self.is_connected = false;
458    }
459
460    async fn finish_shutdown(&self) -> anyhow::Result<()> {
461        let (session, command) = tokio::join!(
462            self.session_tasks.finish_shutdown(TASK_GRACE, TASK_FORCE),
463            self.command_tasks.finish_shutdown(TASK_GRACE, TASK_FORCE),
464        );
465        session.map_err(|e| anyhow::anyhow!("Failed to stop CoinAPI stream task: {e}"))?;
466        command.map_err(|e| anyhow::anyhow!("Failed to stop CoinAPI command tasks: {e}"))?;
467        Ok(())
468    }
469}
470
471#[async_trait(?Send)]
472impl DataClient for CoinApiDataClient {
473    fn client_id(&self) -> ClientId {
474        self.client_id
475    }
476
477    fn venue(&self) -> Option<Venue> {
478        None
479    }
480
481    fn start(&mut self) -> anyhow::Result<()> {
482        log::debug!("Starting {}", self.client_id);
483        Ok(())
484    }
485
486    fn stop(&mut self) -> anyhow::Result<()> {
487        log::debug!("Stopping {}", self.client_id);
488        self.begin_shutdown();
489
490        // Dropping the last handle closes the connection
491        self.ws = None;
492        self.subscription_tx = None;
493        self.books.clear();
494        Ok(())
495    }
496
497    fn reset(&mut self) -> anyhow::Result<()> {
498        log::debug!("Resetting {}", self.client_id);
499        self.begin_shutdown();
500        self.ws = None;
501        self.subscription_tx = None;
502        self.books.clear();
503        self.ohlcv.lock().clear();
504        Ok(())
505    }
506
507    fn dispose(&mut self) -> anyhow::Result<()> {
508        log::debug!("Disposing {}", self.client_id);
509        self.begin_shutdown();
510        self.ws = None;
511        self.subscription_tx = None;
512        self.books.clear();
513        Ok(())
514    }
515
516    fn is_connected(&self) -> bool {
517        self.is_connected
518    }
519
520    fn is_disconnected(&self) -> bool {
521        !self.is_connected
522    }
523
524    async fn connect(&mut self) -> anyhow::Result<()> {
525        if self.is_connected {
526            return Ok(());
527        }
528
529        log::info!("Connecting {}", self.client_id);
530
531        if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
532            self.begin_shutdown();
533            self.finish_shutdown().await?;
534            self.session_tasks
535                .start_generation()
536                .map_err(|e| anyhow::anyhow!("Failed to reopen CoinAPI stream tasks: {e}"))?;
537            self.command_tasks
538                .start_generation()
539                .map_err(|e| anyhow::anyhow!("Failed to reopen CoinAPI command tasks: {e}"))?;
540        }
541
542        let loaded = load_instruments(
543            &self.http,
544            &self.config.instruments,
545            &self.config.price_precisions,
546            self.clock.get_time_ns(),
547        )
548        .await
549        .context("Failed to load CoinAPI instruments")?;
550
551        self.instruments.begin_stream(&loaded);
552
553        for instrument in loaded {
554            if let Err(e) = self
555                .data_sender
556                .send(DataEvent::Instrument(instrument.instrument))
557            {
558                log::warn!("Failed to send instrument: {e}");
559            }
560        }
561
562        let (mut ws, rx) =
563            CoinApiWebSocketClient::new(self.api_key.clone(), self.config.websocket_config())?;
564
565        self.books = Arc::new(BookSubscriptions {
566            sync: self.books.sync.clone(),
567            ..BookSubscriptions::default()
568        });
569
570        ws.bind_books(Arc::clone(&self.books));
571        let ws = Arc::new(ws);
572
573        let books = BookStreams {
574            subscriptions: Arc::clone(&self.books),
575            ws: Arc::clone(&ws),
576            tasks: self.session_tasks.spawner()?,
577            snapshot_timeout: self.book_snapshot_timeout(),
578        };
579
580        self.session_tasks.spawn(run_stream(
581            rx,
582            self.session_tasks.cancellation_token(),
583            self.data_sender.clone(),
584            Arc::clone(&self.instruments),
585            books,
586            Arc::clone(&self.ohlcv),
587            self.clock,
588        ))?;
589
590        let (subscription_tx, subscription_rx) = tokio::sync::mpsc::unbounded_channel();
591
592        self.session_tasks.spawn(run_subscriptions(
593            Arc::clone(&ws),
594            subscription_rx,
595            self.session_tasks.cancellation_token(),
596        ))?;
597
598        self.ws = Some(ws);
599        self.subscription_tx = Some(subscription_tx);
600        self.is_connected = true;
601        log::info!("Connected {}", self.client_id);
602        Ok(())
603    }
604
605    async fn disconnect(&mut self) -> anyhow::Result<()> {
606        log::info!("Disconnecting {}", self.client_id);
607        self.begin_shutdown();
608
609        // Draining tasks first prevents a queued subscription from reopening the connection
610        let result = self.finish_shutdown().await;
611
612        self.subscription_tx = None;
613
614        if let Some(ws) = self.ws.take() {
615            ws.close().await;
616        }
617
618        self.books.clear();
619        self.ohlcv.lock().clear();
620
621        log::info!("Disconnected {}", self.client_id);
622        result
623    }
624
625    fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
626        let data_type = match cmd.book_type {
627            BookType::L2_MBP => DataType::Book,
628            BookType::L3_MBO => DataType::BookL3,
629            BookType::L1_MBP => anyhow::bail!("CoinAPI streams L2_MBP and L3_MBO book deltas"),
630        };
631
632        // Every channel sends the full book, so a depth limit cannot reduce the stream
633        self.subscribe_book(cmd.instrument_id, data_type, None)
634    }
635
636    fn subscribe_book_depth(&mut self, cmd: SubscribeBookDepth) -> anyhow::Result<()> {
637        anyhow::ensure!(
638            cmd.book_type == BookType::L2_MBP,
639            "CoinAPI depth snapshots are L2_MBP, was {}",
640            cmd.book_type
641        );
642
643        let depth = cmd.depth.map_or(DEPTH10_LEN, NonZeroUsize::get);
644        let (data_type, _) = BOOK_DEPTH_CHANNELS
645            .into_iter()
646            .find(|(_, levels)| depth <= *levels)
647            .context(format!(
648                "CoinAPI depth snapshots list at most 50 levels, was {depth}"
649            ))?;
650
651        self.subscribe_book(cmd.instrument_id, data_type, Some(depth))
652    }
653
654    fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
655        self.subscribe_channel(cmd.instrument_id, channel(DataType::Quote, None))
656    }
657
658    fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
659        self.subscribe_channel(cmd.instrument_id, channel(DataType::Trade, None))
660    }
661
662    fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
663        let bar_type = cmd.bar_type;
664        anyhow::ensure!(
665            bar_type.aggregation_source() == AggregationSource::External,
666            "CoinAPI streams only external bars, was {bar_type}"
667        );
668
669        let period_id = bar_spec_to_period_id(bar_type.spec())?;
670        self.subscribe_channel(
671            bar_type.instrument_id(),
672            channel(DataType::Ohlcv, Some(period_id)),
673        )
674    }
675
676    fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
677        self.unsubscribe_book(cmd.instrument_id, false)
678    }
679
680    fn unsubscribe_book_depth(&mut self, cmd: &UnsubscribeBookDepth) -> anyhow::Result<()> {
681        self.unsubscribe_book(cmd.instrument_id, true)
682    }
683
684    fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
685        self.unsubscribe_channel(cmd.instrument_id, channel(DataType::Quote, None))
686    }
687
688    fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
689        self.unsubscribe_channel(cmd.instrument_id, channel(DataType::Trade, None))
690    }
691
692    fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
693        let period_id = bar_spec_to_period_id(cmd.bar_type.spec())?;
694        self.unsubscribe_channel(
695            cmd.bar_type.instrument_id(),
696            channel(DataType::Ohlcv, Some(period_id)),
697        )
698    }
699
700    fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
701        let selections = select_instruments(&self.config.instruments, |instrument_id| {
702            request
703                .venue
704                .is_none_or(|venue| instrument_id.venue == venue)
705        });
706
707        let http = Arc::clone(&self.http);
708        let price_precisions = self.config.price_precisions.clone();
709        let index = Arc::clone(&self.instruments);
710        let sender = self.data_sender.clone();
711        let client_id = request.client_id.unwrap_or(self.client_id);
712
713        // Names the provider when the request names no venue
714        let venue = request.venue.unwrap_or_else(|| Venue::new(COINAPI));
715        let start = datetime_to_unix_nanos(request.start);
716        let end = datetime_to_unix_nanos(request.end);
717        let clock = self.clock;
718
719        self.spawn_command("CoinAPI instruments request", async move {
720            let loaded =
721                load_instruments(&http, &selections, &price_precisions, clock.get_time_ns())
722                    .await?;
723
724            let instruments = loaded
725                .iter()
726                .map(|instrument| {
727                    index.insert(instrument);
728                    instrument.instrument.clone()
729                })
730                .collect();
731
732            let response = DataResponse::Instruments(InstrumentsResponse::new(
733                request.request_id,
734                client_id,
735                venue,
736                instruments,
737                start,
738                end,
739                clock.get_time_ns(),
740                request.params,
741            ));
742            send_response(&sender, response);
743            Ok(())
744        });
745
746        Ok(())
747    }
748
749    fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
750        let instrument_id = request.instrument_id;
751        let selections = select_instruments(&self.config.instruments, |candidate| {
752            *candidate == instrument_id
753        });
754
755        anyhow::ensure!(
756            !selections.is_empty(),
757            "{instrument_id} is not a configured CoinAPI instrument"
758        );
759
760        let http = Arc::clone(&self.http);
761        let price_precisions = self.config.price_precisions.clone();
762        let index = Arc::clone(&self.instruments);
763        let sender = self.data_sender.clone();
764        let client_id = request.client_id.unwrap_or(self.client_id);
765        let start = datetime_to_unix_nanos(request.start);
766        let end = datetime_to_unix_nanos(request.end);
767        let clock = self.clock;
768
769        self.spawn_command("CoinAPI instrument request", async move {
770            let loaded =
771                load_instruments(&http, &selections, &price_precisions, clock.get_time_ns())
772                    .await?;
773            let instrument = loaded
774                .into_iter()
775                .next()
776                .context("CoinAPI instrument load returned nothing")?;
777            index.insert(&instrument);
778
779            let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
780                request.request_id,
781                client_id,
782                instrument_id,
783                instrument.instrument,
784                start,
785                end,
786                clock.get_time_ns(),
787                request.params,
788            )));
789            send_response(&sender, response);
790            Ok(())
791        });
792
793        Ok(())
794    }
795
796    fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
797        let instrument_id = request.instrument_id;
798        let (symbol_id, instrument) = self.loaded(instrument_id)?;
799
800        let query = BookQuery {
801            limit_levels: request
802                .depth
803                .map(|depth| u32::try_from(depth.get()).unwrap_or(u32::MAX)),
804        };
805
806        let http = Arc::clone(&self.http);
807        let sender = self.data_sender.clone();
808        let client_id = request.client_id.unwrap_or(self.client_id);
809        let clock = self.clock;
810
811        self.spawn_command("CoinAPI book snapshot request", async move {
812            let book = http.get_book(&symbol_id, &query).await?;
813            let book = http_parse::parse_order_book(&book, &instrument)
814                .with_context(|| format!("Invalid CoinAPI book for {symbol_id}"))?;
815
816            let response = DataResponse::Book(BookResponse::new(
817                request.request_id,
818                client_id,
819                instrument_id,
820                book,
821                None,
822                None,
823                clock.get_time_ns(),
824                request.params,
825            ));
826            send_response(&sender, response);
827            Ok(())
828        });
829
830        Ok(())
831    }
832
833    fn request_quotes(&self, request: RequestQuotes) -> anyhow::Result<()> {
834        let instrument_id = request.instrument_id;
835        let (symbol_id, instrument, range) =
836            self.history(instrument_id, request.start, request.end, request.limit)?;
837
838        let http = Arc::clone(&self.http);
839        let sender = self.data_sender.clone();
840        let client_id = request.client_id.unwrap_or(self.client_id);
841        let start = datetime_to_unix_nanos(request.start);
842        let end = datetime_to_unix_nanos(request.end);
843        let clock = self.clock;
844
845        self.spawn_command("CoinAPI quotes request", async move {
846            let pages = http.get_quotes_range(&symbol_id, &range).await?;
847
848            let quotes = parse_history(&pages, "quote", symbol_id, |quote| {
849                http_parse::parse_quote_tick(quote, &instrument)
850            });
851
852            let response = DataResponse::Quotes(QuotesResponse::new(
853                request.request_id,
854                client_id,
855                instrument_id,
856                quotes,
857                start,
858                end,
859                clock.get_time_ns(),
860                request.params,
861            ));
862            send_response(&sender, response);
863            Ok(())
864        });
865
866        Ok(())
867    }
868
869    fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
870        let instrument_id = request.instrument_id;
871        let (symbol_id, instrument, range) =
872            self.history(instrument_id, request.start, request.end, request.limit)?;
873
874        let http = Arc::clone(&self.http);
875        let sender = self.data_sender.clone();
876        let client_id = request.client_id.unwrap_or(self.client_id);
877        let start = datetime_to_unix_nanos(request.start);
878        let end = datetime_to_unix_nanos(request.end);
879        let clock = self.clock;
880
881        self.spawn_command("CoinAPI trades request", async move {
882            let pages = http.get_trades_range(&symbol_id, &range).await?;
883
884            let trades = parse_history(&pages, "trade", symbol_id, |trade| {
885                http_parse::parse_trade_tick(trade, &instrument)
886            });
887
888            let response = DataResponse::Trades(TradesResponse::new(
889                request.request_id,
890                client_id,
891                instrument_id,
892                trades,
893                start,
894                end,
895                clock.get_time_ns(),
896                request.params,
897            ));
898            send_response(&sender, response);
899            Ok(())
900        });
901
902        Ok(())
903    }
904
905    fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
906        let bar_type = request.bar_type;
907        anyhow::ensure!(
908            bar_type.aggregation_source() == AggregationSource::External,
909            "CoinAPI provides only external bars, was {bar_type}"
910        );
911
912        let period_id = bar_spec_to_period_id(bar_type.spec())?;
913        let (symbol_id, instrument, range) = self.history(
914            bar_type.instrument_id(),
915            request.start,
916            request.end,
917            request.limit,
918        )?;
919
920        let http = Arc::clone(&self.http);
921        let sender = self.data_sender.clone();
922        let client_id = request.client_id.unwrap_or(self.client_id);
923        let start = datetime_to_unix_nanos(request.start);
924        let end = datetime_to_unix_nanos(request.end);
925        let clock = self.clock;
926
927        self.spawn_command("CoinAPI bars request", async move {
928            let mut pages = http.get_ohlcv_range(&symbol_id, &period_id, &range).await?;
929
930            // A period still in progress, or closing after the window, is not a complete bar
931            let closed_by = range.time_end.min(clock.get_time_ns().to_datetime_utc());
932            pages
933                .records
934                .retain(|ohlcv| ohlcv.time_period_end <= closed_by);
935
936            let bars = parse_history(&pages, "OHLCV period", symbol_id, |ohlcv| {
937                http_parse::parse_bar(ohlcv, bar_type, &instrument)
938            });
939
940            let response = DataResponse::Bars(BarsResponse::new(
941                request.request_id,
942                client_id,
943                bar_type,
944                bars,
945                start,
946                end,
947                clock.get_time_ns(),
948                request.params,
949            ));
950            send_response(&sender, response);
951            Ok(())
952        });
953
954        Ok(())
955    }
956}
957
958// Scope changes apply in call order through one task, so a quick unsubscribe cannot overtake its
959// subscribe
960type SubscriptionSender = tokio::sync::mpsc::UnboundedSender<SubscriptionCommand>;
961
962#[derive(Debug)]
963struct SubscriptionCommand {
964    change: ScopeChange,
965    snapshot: Option<SnapshotWait>,
966    book_active: Option<Arc<AtomicBool>>,
967}
968
969// Opens a book's snapshot gate once its subscribing hello is written, then starts its deadline
970#[derive(Debug)]
971struct SnapshotWait {
972    gate: SnapshotGate,
973    written: tokio::sync::oneshot::Sender<()>,
974}
975
976fn send_subscription(
977    commands: &SubscriptionSender,
978    change: ScopeChange,
979    snapshot: Option<SnapshotWait>,
980    book_active: Option<Arc<AtomicBool>>,
981) -> anyhow::Result<()> {
982    commands
983        .send(SubscriptionCommand {
984            change,
985            snapshot,
986            book_active,
987        })
988        .map_err(|_| anyhow::anyhow!("CoinAPI subscription task has stopped"))
989}
990
991// Applies every change queued by the time it wakes as one batch, so a burst of subscriptions shares
992// one hello
993async fn run_subscriptions(
994    ws: Arc<CoinApiWebSocketClient>,
995    mut commands: tokio::sync::mpsc::UnboundedReceiver<SubscriptionCommand>,
996    cancellation: CancellationToken,
997) {
998    loop {
999        let first = tokio::select! {
1000            biased;
1001            () = cancellation.cancelled() => break,
1002            command = commands.recv() => match command {
1003                Some(command) => command,
1004                None => break,
1005            },
1006        };
1007
1008        let mut batch = vec![first];
1009        while let Ok(command) = commands.try_recv() {
1010            batch.push(command);
1011        }
1012
1013        let changes: Vec<ScopeChange> = batch.iter().map(|command| command.change).collect();
1014        let registrations: Vec<_> = batch
1015            .iter()
1016            .map(|command| command.book_active.clone())
1017            .collect();
1018        let mut delay = SUBSCRIPTION_RETRY_DELAY_INITIAL;
1019
1020        // The engine records the change once queued, so a transport failure must not drop it
1021        let rejected = loop {
1022            let result = tokio::select! {
1023                biased;
1024                () = cancellation.cancelled() => return,
1025                result = ws.apply_registered(&changes, &registrations) => result,
1026            };
1027
1028            match result {
1029                Ok(rejected) => break rejected,
1030                Err(e) => {
1031                    log::warn!("CoinAPI subscription change failed, retrying in {delay:?}: {e}");
1032                    tokio::select! {
1033                        biased;
1034                        () = cancellation.cancelled() => return,
1035                        () = time::sleep(delay) => {}
1036                    }
1037                    delay = (delay * 2).min(SUBSCRIPTION_RETRY_DELAY_MAX);
1038                }
1039            }
1040        };
1041
1042        for (change, e) in &rejected {
1043            log::error!("CoinAPI rejected {change:?}: {e}");
1044        }
1045
1046        for command in batch {
1047            let Some(snapshot) = command.snapshot else {
1048                continue;
1049            };
1050
1051            if rejected
1052                .iter()
1053                .all(|(rejected, _)| *rejected != command.change)
1054            {
1055                snapshot.gate.open();
1056                let _ = snapshot.written.send(());
1057            }
1058        }
1059    }
1060}
1061
1062// Book state and recovery handles the stream task shares with subscription commands
1063#[derive(Debug)]
1064struct BookStreams {
1065    subscriptions: Arc<BookSubscriptions>,
1066    ws: Arc<CoinApiWebSocketClient>,
1067    tasks: TaskSpawner,
1068    snapshot_timeout: Duration,
1069}
1070
1071// Latest update for each (symbol_id, period_id) OHLCV stream
1072pub(crate) type OhlcvCache = AHashMap<(Ustr, Ustr), Ohlcv>;
1073
1074#[derive(Debug, Default)]
1075struct InstrumentIndex {
1076    state: ArcSwap<InstrumentIndexState>,
1077}
1078
1079#[derive(Debug, Clone, Default)]
1080struct InstrumentIndexState {
1081    by_symbol_id: AHashMap<Ustr, InstrumentAny>,
1082    symbol_ids: AHashMap<InstrumentId, Ustr>,
1083    stream_symbol_ids: AHashMap<InstrumentId, Ustr>,
1084}
1085
1086impl InstrumentIndex {
1087    fn insert(&self, loaded: &CoinApiInstrument) {
1088        self.state.rcu(|state| {
1089            let mut next = (**state).clone();
1090            next.insert(loaded);
1091            Arc::new(next)
1092        });
1093    }
1094
1095    fn begin_stream(&self, loaded: &[CoinApiInstrument]) {
1096        let mut state = InstrumentIndexState::default();
1097        for instrument in loaded {
1098            state
1099                .stream_symbol_ids
1100                .insert(instrument.instrument.id(), instrument.symbol_id);
1101            state.insert(instrument);
1102        }
1103
1104        self.state.store(Arc::new(state));
1105    }
1106
1107    fn stream_symbol_id(&self, instrument_id: InstrumentId) -> Option<Ustr> {
1108        self.state
1109            .load()
1110            .stream_symbol_ids
1111            .get(&instrument_id)
1112            .copied()
1113    }
1114
1115    fn loaded(&self, instrument_id: InstrumentId) -> Option<(Ustr, InstrumentAny)> {
1116        let state = self.state.load();
1117        let symbol_id = *state.symbol_ids.get(&instrument_id)?;
1118        Some((symbol_id, state.by_symbol_id.get(&symbol_id)?.clone()))
1119    }
1120
1121    fn instrument(&self, symbol_id: Ustr) -> Option<InstrumentAny> {
1122        self.state.load().by_symbol_id.get(&symbol_id).cloned()
1123    }
1124}
1125
1126impl InstrumentIndexState {
1127    fn insert(&mut self, loaded: &CoinApiInstrument) {
1128        let instrument_id = loaded.instrument.id();
1129        let stream_symbol_id = self.stream_symbol_ids.get(&instrument_id).copied();
1130
1131        if let Some(previous) = self.symbol_ids.get(&instrument_id)
1132            && *previous != loaded.symbol_id
1133            && Some(*previous) != stream_symbol_id
1134        {
1135            self.by_symbol_id.remove(previous);
1136        }
1137
1138        self.by_symbol_id
1139            .insert(loaded.symbol_id, loaded.instrument.clone());
1140        self.symbol_ids.insert(instrument_id, loaded.symbol_id);
1141
1142        if let Some(symbol_id) = stream_symbol_id
1143            && symbol_id != loaded.symbol_id
1144        {
1145            self.by_symbol_id
1146                .insert(symbol_id, loaded.instrument.clone());
1147        }
1148    }
1149}
1150
1151fn validate_selections(selections: &[CoinApiInstrumentSelection]) -> anyhow::Result<()> {
1152    let mut seen = AHashSet::new();
1153
1154    for selection in selections {
1155        anyhow::ensure!(
1156            !selection.instrument_ids.is_empty(),
1157            "CoinAPI selection for {} lists no instruments",
1158            selection.exchange_id
1159        );
1160
1161        for instrument_id in &selection.instrument_ids {
1162            let exchange_id = VenueSymbol::from_native(*instrument_id)?.exchange_id;
1163            anyhow::ensure!(
1164                exchange_id == selection.exchange_id,
1165                "{instrument_id} maps to CoinAPI exchange {exchange_id}, not {}",
1166                selection.exchange_id
1167            );
1168            anyhow::ensure!(
1169                seen.insert(*instrument_id),
1170                "{instrument_id} is selected more than once"
1171            );
1172        }
1173    }
1174
1175    Ok(())
1176}
1177
1178fn validate_price_precisions(
1179    selections: &[CoinApiInstrumentSelection],
1180    price_precisions: &HashMap<InstrumentId, u8>,
1181) -> anyhow::Result<()> {
1182    for (instrument_id, precision) in price_precisions {
1183        anyhow::ensure!(
1184            selections
1185                .iter()
1186                .any(|selection| selection.instrument_ids.contains(instrument_id)),
1187            "price precision for {instrument_id}, which is not a selected instrument"
1188        );
1189        check_fixed_precision(*precision)
1190            .with_context(|| format!("invalid price precision for {instrument_id}"))?;
1191    }
1192
1193    Ok(())
1194}
1195
1196// Narrows each selection to the matching instruments and drops selections left empty
1197fn select_instruments(
1198    selections: &[CoinApiInstrumentSelection],
1199    predicate: impl Fn(&InstrumentId) -> bool,
1200) -> Vec<CoinApiInstrumentSelection> {
1201    selections
1202        .iter()
1203        .filter_map(|selection| {
1204            let instrument_ids: Vec<InstrumentId> = selection
1205                .instrument_ids
1206                .iter()
1207                .copied()
1208                .filter(&predicate)
1209                .collect();
1210
1211            (!instrument_ids.is_empty()).then(|| CoinApiInstrumentSelection {
1212                instrument_ids,
1213                ..selection.clone()
1214            })
1215        })
1216        .collect()
1217}
1218
1219// Requests each selection's exchange once and requires every selected instrument
1220async fn load_instruments(
1221    http: &CoinApiHttpClient,
1222    selections: &[CoinApiInstrumentSelection],
1223    price_precisions: &HashMap<InstrumentId, u8>,
1224    ts_init: UnixNanos,
1225) -> anyhow::Result<Vec<CoinApiInstrument>> {
1226    let mut selected = Vec::new();
1227
1228    for selection in selections {
1229        let loaded = http
1230            .request_instruments(
1231                &selection.exchange_id,
1232                &selection.query,
1233                price_precisions,
1234                ts_init,
1235            )
1236            .await?;
1237
1238        for instrument_id in &selection.instrument_ids {
1239            let instrument = loaded
1240                .iter()
1241                .find(|instrument| instrument.instrument.id() == *instrument_id)
1242                .with_context(|| {
1243                    format!(
1244                        "CoinAPI {} returned no instrument {instrument_id}",
1245                        selection.exchange_id
1246                    )
1247                })?;
1248
1249            selected.push(instrument.clone());
1250        }
1251    }
1252
1253    Ok(selected)
1254}
1255
1256fn channel(data_type: DataType, period_id: Option<Ustr>) -> Channel {
1257    Channel {
1258        data_type,
1259        period_id,
1260    }
1261}
1262
1263fn send_response(sender: &EventSender<DataEvent>, response: DataResponse) {
1264    if let Err(e) = sender.send(DataEvent::Response(response)) {
1265        log::error!("Failed to send CoinAPI response: {e}");
1266    }
1267}
1268
1269// Converts each record, dropping and counting those that fail rather than failing the request
1270fn parse_history<T, D>(
1271    pages: &HistoryPages<T>,
1272    kind: &str,
1273    symbol_id: Ustr,
1274    parse: impl Fn(&T) -> anyhow::Result<D>,
1275) -> Vec<D> {
1276    let total = pages.records.len();
1277
1278    if pages.is_truncated {
1279        log::warn!(
1280            "CoinAPI credit budget truncated the {kind} history for {symbol_id} after {total} records"
1281        );
1282    }
1283
1284    let mut first_error = None;
1285
1286    let parsed: Vec<D> = pages
1287        .records
1288        .iter()
1289        .filter_map(|record| {
1290            parse(record)
1291                .map_err(|e| {
1292                    first_error.get_or_insert(e);
1293                })
1294                .ok()
1295        })
1296        .collect();
1297
1298    if let Some(e) = first_error {
1299        let dropped = total - parsed.len();
1300        log::warn!(
1301            "Dropped {dropped} of {total} CoinAPI {kind} records for {symbol_id}, first failure: {e:#}"
1302        );
1303    }
1304
1305    parsed
1306}
1307
1308async fn run_stream(
1309    mut rx: tokio::sync::mpsc::UnboundedReceiver<CoinApiWsMessage>,
1310    cancellation: CancellationToken,
1311    sender: EventSender<DataEvent>,
1312    instruments: Arc<InstrumentIndex>,
1313    books: BookStreams,
1314    ohlcv: Arc<Mutex<OhlcvCache>>,
1315    clock: &'static AtomicTime,
1316) {
1317    loop {
1318        let message = tokio::select! {
1319            biased;
1320            () = cancellation.cancelled() => break,
1321            message = rx.recv() => match message {
1322                Some(message) => message,
1323                None => break,
1324            },
1325        };
1326
1327        handle_message(message, &sender, &instruments, &books, &ohlcv, clock);
1328    }
1329}
1330
1331fn handle_message(
1332    message: CoinApiWsMessage,
1333    sender: &EventSender<DataEvent>,
1334    instruments: &InstrumentIndex,
1335    books: &BookStreams,
1336    ohlcv: &Arc<Mutex<OhlcvCache>>,
1337    clock: &AtomicTime,
1338) {
1339    let (symbol_id, kind, parsed) = match message {
1340        CoinApiWsMessage::Book(book) => {
1341            let Some(instrument) = instruments.instrument(book.symbol_id) else {
1342                return log_unknown_symbol(book.symbol_id);
1343            };
1344
1345            if book.data_type == DataType::Book {
1346                return handle_book_frame(
1347                    BookFrame::Levels(&book),
1348                    &instrument,
1349                    books,
1350                    ohlcv,
1351                    sender,
1352                );
1353            }
1354
1355            let Some(depth) = books
1356                .subscriptions
1357                .entries
1358                .load()
1359                .get(&instrument.id())
1360                .and_then(|registration| registration.depth)
1361            else {
1362                return;
1363            };
1364
1365            let parsed = parse_book_depth(&book, depth, &instrument).map(|mut depth| {
1366                depth.sequence = books
1367                    .subscriptions
1368                    .sync
1369                    .depth_sequence(instrument.id(), book.sequence);
1370                Data::BookDepth(Box::new(depth))
1371            });
1372
1373            (book.symbol_id, "book depth", parsed)
1374        }
1375        CoinApiWsMessage::BookL3(book) => {
1376            let Some(instrument) = instruments.instrument(book.symbol_id) else {
1377                return log_unknown_symbol(book.symbol_id);
1378            };
1379
1380            return handle_book_frame(BookFrame::Orders(&book), &instrument, books, ohlcv, sender);
1381        }
1382        CoinApiWsMessage::Quote(quote) => {
1383            let Some(instrument) = instruments.instrument(quote.symbol_id) else {
1384                return log_unknown_symbol(quote.symbol_id);
1385            };
1386
1387            let parsed = parse_quote_tick(&quote, &instrument).map(Data::Quote);
1388            (quote.symbol_id, "quote", parsed)
1389        }
1390        CoinApiWsMessage::Trade(trade) => {
1391            let Some(instrument) = instruments.instrument(trade.symbol_id) else {
1392                return log_unknown_symbol(trade.symbol_id);
1393            };
1394
1395            let parsed = parse_trade_tick(&trade, &instrument).map(Data::Trade);
1396            (trade.symbol_id, "trade", parsed)
1397        }
1398        CoinApiWsMessage::Ohlcv(update) => {
1399            let Some(closed) = close_period(&mut ohlcv.lock(), update) else {
1400                return;
1401            };
1402
1403            let Some(instrument) = instruments.instrument(closed.symbol_id) else {
1404                return log_unknown_symbol(closed.symbol_id);
1405            };
1406
1407            let parsed = parse_bar(&closed, &instrument, clock.get_time_ns()).map(Data::Bar);
1408            (closed.symbol_id, "OHLCV period", parsed)
1409        }
1410        CoinApiWsMessage::Error(message) => {
1411            log::error!("CoinAPI retired the stream and its subscriptions: {message}");
1412            return;
1413        }
1414        CoinApiWsMessage::Reconnected => {
1415            // Updates missed during the outage could leave a retained period incomplete
1416            ohlcv.lock().clear();
1417            reset_books(books, ohlcv);
1418            log::info!("CoinAPI stream reconnected and replayed its subscriptions");
1419            return;
1420        }
1421    };
1422
1423    match parsed {
1424        Ok(data) => {
1425            if let Err(e) = sender.send(DataEvent::Data(data)) {
1426                log::error!("Failed to send CoinAPI data: {e}");
1427            }
1428        }
1429        Err(e) => log::warn!("Dropping CoinAPI {kind} for {symbol_id}: {e:#}"),
1430    }
1431}
1432
1433fn handle_book_frame(
1434    frame: BookFrame<'_>,
1435    instrument: &InstrumentAny,
1436    books: &BookStreams,
1437    ohlcv: &Arc<Mutex<OhlcvCache>>,
1438    sender: &EventSender<DataEvent>,
1439) {
1440    let instrument_id = instrument.id();
1441
1442    match books
1443        .subscriptions
1444        .sync
1445        .apply(instrument_id, frame, instrument, time::Instant::now())
1446    {
1447        (BookSequenceOutcome::Accept, Some(deltas)) => {
1448            if let Err(e) = sender.send(DataEvent::Data(Data::BookDeltas(Box::new(deltas)))) {
1449                log::error!("Failed to send CoinAPI data: {e}");
1450            }
1451        }
1452        (BookSequenceOutcome::Recover, _) => start_recovery(
1453            instrument_id,
1454            &books.subscriptions.sync,
1455            &books.ws,
1456            ohlcv,
1457            books.snapshot_timeout,
1458            &books.tasks,
1459        ),
1460        _ => {}
1461    }
1462}
1463
1464// Sequences restart per connection, so each book waits for the snapshot its replayed pair brings
1465fn reset_books(books: &BookStreams, ohlcv: &Arc<Mutex<OhlcvCache>>) {
1466    let deadline =
1467        (!books.snapshot_timeout.is_zero()).then(|| time::Instant::now() + books.snapshot_timeout);
1468
1469    if books.subscriptions.sync.reset_on_reconnect(deadline) > 0 {
1470        spawn_recovery_monitor(
1471            books.subscriptions.sync.clone(),
1472            Arc::clone(&books.ws),
1473            Arc::clone(ohlcv),
1474            books.snapshot_timeout,
1475            &books.tasks,
1476        );
1477    }
1478}
1479
1480fn log_unknown_symbol(symbol_id: Ustr) {
1481    log::warn!("Dropping CoinAPI message for unloaded symbol {symbol_id}");
1482}
1483
1484// CoinAPI repeats a period's running totals and sends no close flag; an update for a later
1485// period closes the retained one
1486fn close_period(cache: &mut OhlcvCache, update: Ohlcv) -> Option<Ohlcv> {
1487    let key = (update.symbol_id, update.period_id);
1488    match cache.get(&key) {
1489        Some(retained) if update.time_period_start < retained.time_period_start => None,
1490        Some(retained) if update.time_period_start == retained.time_period_start => {
1491            cache.insert(key, update);
1492            None
1493        }
1494        _ => cache.insert(key, update),
1495    }
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500    use nautilus_model::types::Price;
1501    use rstest::rstest;
1502
1503    use super::*;
1504
1505    #[cfg(not(all(feature = "simulation", madsim)))]
1506    #[rstest]
1507    #[case::stop(DataClient::stop, false)]
1508    #[case::reset(DataClient::reset, true)]
1509    #[case::dispose(DataClient::dispose, false)]
1510    #[tokio::test]
1511    async fn shutdown_cancels_tasks_and_clears_book_state(
1512        #[case] shutdown: fn(&mut CoinApiDataClient) -> anyhow::Result<()>,
1513        #[case] clears_bars: bool,
1514    ) {
1515        let (sender, _events) = tokio::sync::mpsc::unbounded_channel();
1516        nautilus_common::live::runner::replace_data_event_sender(sender);
1517
1518        let mut client = CoinApiDataClient::new(
1519            ClientId::from("COINAPI"),
1520            CoinApiDataClientConfig {
1521                api_key: Some("test-key".into()),
1522                ..CoinApiDataClientConfig::default()
1523            },
1524        )
1525        .unwrap();
1526
1527        let instrument_id = InstrumentId::from("BTC-USD.COINBASE");
1528
1529        let subscription = Subscription {
1530            channel: channel(DataType::Book, None),
1531            symbol_id: "COINBASE_SPOT_BTC_USD".into(),
1532        };
1533
1534        let symbols: Vec<crate::http::models::Symbol> =
1535            serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1536        let (loaded, _) = http_parse::parse_instruments(&symbols, UnixNanos::default());
1537        let instrument = loaded
1538            .into_iter()
1539            .find(|loaded| loaded.instrument.id() == instrument_id)
1540            .unwrap()
1541            .instrument;
1542        let mut frame: crate::websocket::messages::Book =
1543            serde_json::from_str(include_str!("../test_data/ws_book.json")).unwrap();
1544        frame.sequence = 100;
1545        frame.is_snapshot = Some(true);
1546        let gate = SnapshotGate::default();
1547        gate.open();
1548        let book_cancel = client.books.sync.record_subscription(
1549            instrument_id,
1550            subscription,
1551            time::Instant::now(),
1552            gate.clone(),
1553        );
1554        client.books.entries.insert(
1555            instrument_id,
1556            BookSubscription {
1557                data_type: DataType::Book,
1558                depth: None,
1559                active: Arc::new(AtomicBool::new(false)),
1560            },
1561        );
1562
1563        let (outcome, deltas) = client.books.sync.apply(
1564            instrument_id,
1565            BookFrame::Levels(&frame),
1566            &instrument,
1567            time::Instant::now(),
1568        );
1569        assert_eq!(outcome, BookSequenceOutcome::Accept);
1570        assert_eq!(deltas.unwrap().sequence, 100);
1571        assert_eq!(client.books.sync.depth_sequence(instrument_id, 17), 17);
1572        let retained = update(1, "84523.69");
1573        let key = (retained.symbol_id, retained.period_id);
1574        client.ohlcv.lock().insert(key, retained.clone());
1575        client.is_connected = true;
1576        let (ws, _messages) =
1577            CoinApiWebSocketClient::new(client.api_key.clone(), client.config.websocket_config())
1578                .unwrap();
1579        client.ws = Some(Arc::new(ws));
1580        let (commands, mut command_receiver) = tokio::sync::mpsc::unbounded_channel();
1581        client.subscription_tx = Some(commands);
1582        let mut completions = Vec::new();
1583
1584        for group in [&client.session_tasks, &client.command_tasks] {
1585            let cancel = group.cancellation_token();
1586            let (done, completion) = tokio::sync::oneshot::channel();
1587
1588            group
1589                .spawn(async move {
1590                    cancel.cancelled().await;
1591                    done.send(()).unwrap();
1592                })
1593                .unwrap();
1594
1595            completions.push(completion);
1596        }
1597
1598        let retired = Arc::clone(&client.books);
1599        shutdown(&mut client).unwrap();
1600
1601        for completion in completions {
1602            tokio::time::timeout(Duration::from_secs(1), completion)
1603                .await
1604                .unwrap()
1605                .unwrap();
1606        }
1607
1608        assert!(client.is_disconnected());
1609        assert!(client.ws.is_none());
1610        assert!(client.subscription_tx.is_none());
1611        assert!(command_receiver.recv().await.is_none());
1612        assert!(client.books.entries.is_empty());
1613        assert!(book_cancel.is_cancelled());
1614        assert_eq!(client.books.sync.reset_on_reconnect(None), 0);
1615        assert!(!client.session_tasks.is_open());
1616        assert!(!client.command_tasks.is_open());
1617
1618        {
1619            let bars = client.ohlcv.lock();
1620
1621            if clears_bars {
1622                assert!(bars.is_empty());
1623            } else {
1624                assert_eq!(bars.len(), 1);
1625                assert_eq!(bars[&key], retained);
1626            }
1627        }
1628
1629        client.connect().await.unwrap();
1630        let active = Arc::new(AtomicBool::new(true));
1631        let replacement_cancel = client.books.sync.record_subscription(
1632            instrument_id,
1633            subscription,
1634            time::Instant::now(),
1635            gate,
1636        );
1637        client.books.entries.insert(
1638            instrument_id,
1639            BookSubscription {
1640                data_type: DataType::Book,
1641                depth: None,
1642                active: Arc::clone(&active),
1643            },
1644        );
1645
1646        retired.retire();
1647
1648        let replacement = client.books.entries.get_cloned(&instrument_id).unwrap();
1649        assert_eq!(replacement.data_type, DataType::Book);
1650        assert_eq!(replacement.depth, None);
1651        assert!(Arc::ptr_eq(&replacement.active, &active));
1652        assert!(
1653            replacement
1654                .active
1655                .load(std::sync::atomic::Ordering::Relaxed)
1656        );
1657        assert!(!replacement_cancel.is_cancelled());
1658        assert!(!Arc::ptr_eq(&retired, &client.books));
1659        frame.sequence = 1;
1660        let (outcome, deltas) = client.books.sync.apply(
1661            instrument_id,
1662            BookFrame::Levels(&frame),
1663            &instrument,
1664            time::Instant::now(),
1665        );
1666        assert_eq!(outcome, BookSequenceOutcome::Accept);
1667        assert_eq!(deltas.unwrap().sequence, 101);
1668        frame.sequence = 2;
1669        frame.is_snapshot = Some(false);
1670        let (outcome, deltas) = client.books.sync.apply(
1671            instrument_id,
1672            BookFrame::Levels(&frame),
1673            &instrument,
1674            time::Instant::now(),
1675        );
1676        assert_eq!(outcome, BookSequenceOutcome::Accept);
1677        assert_eq!(deltas.unwrap().sequence, 102);
1678        assert_eq!(client.books.sync.depth_sequence(instrument_id, 1), 18);
1679        client.disconnect().await.unwrap();
1680    }
1681
1682    fn update(start_second: i64, close: &str) -> Ohlcv {
1683        let mut update: Ohlcv =
1684            serde_json::from_str(include_str!("../test_data/ws_ohlcv.json")).unwrap();
1685        update.time_period_start = jiff::Timestamp::from_second(start_second).unwrap();
1686        update.time_period_end = jiff::Timestamp::from_second(start_second + 1).unwrap();
1687        update.price_close = close.parse().unwrap();
1688        update
1689    }
1690
1691    fn selection(exchange_id: &str, instrument_ids: &[&str]) -> CoinApiInstrumentSelection {
1692        CoinApiInstrumentSelection {
1693            exchange_id: exchange_id.into(),
1694            query: crate::http::query::SymbolsQuery::default(),
1695            instrument_ids: instrument_ids
1696                .iter()
1697                .map(|id| InstrumentId::from(*id))
1698                .collect(),
1699        }
1700    }
1701
1702    #[rstest]
1703    #[case(vec![], None)]
1704    #[case(
1705        vec![selection("COINBASE", &["BTC-USD.COINBASE"]), selection("BINANCEFTS", &["BTCUSDT-PERP.BINANCE"])],
1706        None
1707    )]
1708    #[case(
1709        vec![selection("COINBASE", &[])],
1710        Some("CoinAPI selection for COINBASE lists no instruments")
1711    )]
1712    #[case(
1713        vec![selection("BINANCE", &["BTCUSDT-PERP.BINANCE"])],
1714        Some("BTCUSDT-PERP.BINANCE maps to CoinAPI exchange BINANCEFTS, not BINANCE")
1715    )]
1716    #[case(
1717        vec![selection("COINBASE", &["BTC-USD.COINBASE"]), selection("COINBASE", &["BTC-USD.COINBASE"])],
1718        Some("BTC-USD.COINBASE is selected more than once")
1719    )]
1720    #[case(
1721        vec![selection("DERIBIT", &["BTC-PERPETUAL.DERIBIT"])],
1722        Some("instrument ID BTC-PERPETUAL.DERIBIT has no CoinAPI mapping")
1723    )]
1724    fn selections_validate_mapping_and_uniqueness(
1725        #[case] selections: Vec<CoinApiInstrumentSelection>,
1726        #[case] expected: Option<&str>,
1727    ) {
1728        let result = validate_selections(&selections);
1729
1730        assert_eq!(result.err().map(|e| e.to_string()).as_deref(), expected);
1731    }
1732
1733    #[rstest]
1734    #[case(&[("BTC-USD.COINBASE", 3)], None)]
1735    #[case(
1736        &[("ETH-USD.COINBASE", 3)],
1737        Some("price precision for ETH-USD.COINBASE, which is not a selected instrument")
1738    )]
1739    #[case(&[("BTC-USD.COINBASE", 19)], Some("invalid price precision for BTC-USD.COINBASE"))]
1740    fn price_precisions_require_selected_instruments_and_valid_precision(
1741        #[case] precisions: &[(&str, u8)],
1742        #[case] expected: Option<&str>,
1743    ) {
1744        let selections = vec![selection("COINBASE", &["BTC-USD.COINBASE"])];
1745        let precisions = precisions
1746            .iter()
1747            .map(|(id, precision)| (InstrumentId::from(*id), *precision))
1748            .collect();
1749
1750        let result = validate_price_precisions(&selections, &precisions);
1751
1752        assert_eq!(result.err().map(|e| e.to_string()).as_deref(), expected);
1753    }
1754
1755    #[rstest]
1756    fn select_instruments_narrows_and_drops_empty_selections() {
1757        let selections = vec![
1758            selection("COINBASE", &["BTC-USD.COINBASE", "ETH-USD.COINBASE"]),
1759            selection("BINANCE", &["BTCUSDT.BINANCE"]),
1760        ];
1761
1762        let selected = select_instruments(&selections, |id| {
1763            id.symbol.as_str() != "BTCUSDT" && id.symbol.as_str() != "ETH-USD"
1764        });
1765
1766        assert_eq!(selected.len(), 1);
1767        assert_eq!(selected[0].exchange_id, "COINBASE");
1768        assert_eq!(
1769            selected[0].instrument_ids,
1770            vec![InstrumentId::from("BTC-USD.COINBASE")]
1771        );
1772    }
1773
1774    #[rstest]
1775    fn instrument_index_replaces_changed_symbol_id() {
1776        let symbols: Vec<crate::http::models::Symbol> =
1777            serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1778        let (instruments, _) =
1779            crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1780        let original = instruments
1781            .into_iter()
1782            .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1783            .unwrap();
1784
1785        let suffixed = CoinApiInstrument {
1786            symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1787            ..original.clone()
1788        };
1789
1790        let index = InstrumentIndex::default();
1791
1792        index.insert(&original);
1793        index.insert(&suffixed);
1794
1795        let instrument_id = original.instrument.id();
1796        assert_eq!(
1797            index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1798            Some(suffixed.symbol_id)
1799        );
1800        assert_eq!(
1801            index.instrument(suffixed.symbol_id),
1802            Some(original.instrument)
1803        );
1804        assert_eq!(index.instrument(original.symbol_id), None);
1805    }
1806
1807    #[rstest]
1808    fn stream_symbol_survives_refresh_until_new_session() {
1809        let symbols: Vec<crate::http::models::Symbol> =
1810            serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1811        let (loaded, _) = crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1812        let original = loaded
1813            .into_iter()
1814            .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1815            .unwrap();
1816
1817        let refreshed = CoinApiInstrument {
1818            symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1819            ..original.clone()
1820        };
1821
1822        let latest = CoinApiInstrument {
1823            symbol_id: "COINBASE_SPOT_BTC_USD_22F0063".into(),
1824            ..original.clone()
1825        };
1826
1827        let index = InstrumentIndex::default();
1828        let instrument_id = original.instrument.id();
1829        index.begin_stream(std::slice::from_ref(&original));
1830        index.insert(&refreshed);
1831        index.insert(&latest);
1832
1833        assert_eq!(
1834            index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1835            Some(latest.symbol_id)
1836        );
1837        assert_eq!(
1838            index.stream_symbol_id(instrument_id),
1839            Some(original.symbol_id)
1840        );
1841        assert_eq!(
1842            index.instrument(original.symbol_id),
1843            Some(latest.instrument.clone())
1844        );
1845        assert_eq!(index.instrument(refreshed.symbol_id), None);
1846        assert_eq!(
1847            index.instrument(latest.symbol_id),
1848            Some(latest.instrument.clone())
1849        );
1850
1851        index.begin_stream(std::slice::from_ref(&latest));
1852
1853        assert_eq!(
1854            index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1855            Some(latest.symbol_id)
1856        );
1857        assert_eq!(
1858            index.stream_symbol_id(instrument_id),
1859            Some(latest.symbol_id)
1860        );
1861        assert_eq!(index.instrument(original.symbol_id), None);
1862        assert_eq!(index.instrument(latest.symbol_id), Some(latest.instrument));
1863    }
1864
1865    #[rstest]
1866    fn concurrent_refresh_publishes_coherent_aliases() {
1867        let symbols: Vec<crate::http::models::Symbol> =
1868            serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1869        let (loaded, _) = crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1870        let original = loaded
1871            .into_iter()
1872            .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1873            .unwrap();
1874        let coarse: Vec<crate::http::models::Symbol> = serde_json::from_str(
1875            &include_str!("../test_data/symbols.json").replace("0.01000000000000000000", "0.1"),
1876        )
1877        .unwrap();
1878        let (coarse, _) = crate::http::parse::parse_instruments(&coarse, UnixNanos::default());
1879        let coarse = coarse
1880            .into_iter()
1881            .find(|loaded| loaded.symbol_id == original.symbol_id)
1882            .unwrap();
1883
1884        let updates = [
1885            CoinApiInstrument {
1886                symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1887                ..coarse
1888            },
1889            CoinApiInstrument {
1890                symbol_id: "COINBASE_SPOT_BTC_USD_22F0063".into(),
1891                ..original.clone()
1892            },
1893        ];
1894
1895        assert_eq!(updates[0].instrument.price_increment(), Price::from("0.1"));
1896        assert_eq!(updates[1].instrument.price_increment(), Price::from("0.01"));
1897        let index = InstrumentIndex::default();
1898        index.begin_stream(std::slice::from_ref(&original));
1899        let barrier = std::sync::Barrier::new(updates.len());
1900        std::thread::scope(|scope| {
1901            for update in &updates {
1902                scope.spawn(|| {
1903                    barrier.wait();
1904
1905                    for _ in 0..1000 {
1906                        index.insert(update);
1907                        let state = index.state.load();
1908                        let symbol_id = state.symbol_ids[&original.instrument.id()];
1909                        assert_eq!(state.by_symbol_id.len(), 2);
1910                        assert_eq!(
1911                            serde_json::to_value(&state.by_symbol_id[&original.symbol_id]).unwrap(),
1912                            serde_json::to_value(&state.by_symbol_id[&symbol_id]).unwrap()
1913                        );
1914                        assert_eq!(
1915                            state.stream_symbol_ids[&original.instrument.id()],
1916                            original.symbol_id
1917                        );
1918                        let expected = updates
1919                            .iter()
1920                            .find(|update| update.symbol_id == symbol_id)
1921                            .unwrap();
1922                        assert_eq!(
1923                            serde_json::to_value(&state.by_symbol_id[&symbol_id]).unwrap(),
1924                            serde_json::to_value(&expected.instrument).unwrap()
1925                        );
1926                    }
1927                });
1928            }
1929        });
1930    }
1931
1932    #[rstest]
1933    fn close_period_emits_latest_update_of_prior_period_once() {
1934        let mut cache = OhlcvCache::new();
1935
1936        let first = close_period(&mut cache, update(10, "1"));
1937        let repeat = close_period(&mut cache, update(10, "2"));
1938        let next = close_period(&mut cache, update(11, "3"));
1939        let late = close_period(&mut cache, update(10, "4"));
1940        let skipped = close_period(&mut cache, update(13, "5"));
1941
1942        assert_eq!(first, None);
1943        assert_eq!(repeat, None);
1944        assert_eq!(next, Some(update(10, "2")));
1945        assert_eq!(late, None);
1946        assert_eq!(skipped, Some(update(11, "3")));
1947        assert_eq!(cache.len(), 1);
1948        assert_eq!(cache.values().next(), Some(&update(13, "5")));
1949    }
1950}