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;
28use anyhow::Context;
29use arc_swap::ArcSwap;
30use async_trait::async_trait;
31use jiff::Timestamp;
32use nautilus_common::{
33    clients::{DataClient, execute_bar_subscriptions, execute_instrument_subscriptions},
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},
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        config.validate()?;
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::Data(instrument.instrument.into()))
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        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
627            let cmd = cmd.with_instrument_ids(instrument_id);
628
629            let data_type = match cmd.book_type {
630                BookType::L2_MBP => DataType::Book,
631                BookType::L3_MBO => DataType::BookL3,
632                BookType::L1_MBP => anyhow::bail!("CoinAPI streams L2_MBP and L3_MBO book deltas"),
633            };
634
635            // Every channel sends the full book, so a depth limit cannot reduce the stream
636            self.subscribe_book(instrument_id, data_type, None)
637        })
638    }
639
640    fn subscribe_book_depth(&mut self, cmd: SubscribeBookDepth) -> anyhow::Result<()> {
641        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
642            let cmd = cmd.with_instrument_ids(instrument_id);
643            anyhow::ensure!(
644                cmd.book_type == BookType::L2_MBP,
645                "CoinAPI depth snapshots are L2_MBP, was {}",
646                cmd.book_type
647            );
648
649            let depth = cmd.depth.map_or(DEPTH10_LEN, NonZeroUsize::get);
650            let (data_type, _) = BOOK_DEPTH_CHANNELS
651                .into_iter()
652                .find(|(_, levels)| depth <= *levels)
653                .context(format!(
654                    "CoinAPI depth snapshots list at most 50 levels, was {depth}"
655                ))?;
656
657            self.subscribe_book(instrument_id, data_type, Some(depth))
658        })
659    }
660
661    fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
662        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
663            self.subscribe_channel(instrument_id, channel(DataType::Quote, None))
664        })
665    }
666
667    fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
668        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
669            self.subscribe_channel(instrument_id, channel(DataType::Trade, None))
670        })
671    }
672
673    fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
674        execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
675            anyhow::ensure!(
676                bar_type.aggregation_source() == AggregationSource::External,
677                "CoinAPI streams only external bars, was {bar_type}"
678            );
679
680            let period_id = bar_spec_to_period_id(bar_type.spec())?;
681            self.subscribe_channel(
682                bar_type.instrument_id(),
683                channel(DataType::Ohlcv, Some(period_id)),
684            )
685        })
686    }
687
688    fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
689        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
690            self.unsubscribe_book(instrument_id, false)
691        })
692    }
693
694    fn unsubscribe_book_depth(&mut self, cmd: &UnsubscribeBookDepth) -> anyhow::Result<()> {
695        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
696            self.unsubscribe_book(instrument_id, true)
697        })
698    }
699
700    fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
701        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
702            self.unsubscribe_channel(instrument_id, channel(DataType::Quote, None))
703        })
704    }
705
706    fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
707        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
708            self.unsubscribe_channel(instrument_id, channel(DataType::Trade, None))
709        })
710    }
711
712    fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
713        execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
714            let period_id = bar_spec_to_period_id(bar_type.spec())?;
715            self.unsubscribe_channel(
716                bar_type.instrument_id(),
717                channel(DataType::Ohlcv, Some(period_id)),
718            )
719        })
720    }
721
722    fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
723        let selections = select_instruments(&self.config.instruments, |instrument_id| {
724            request
725                .venue
726                .is_none_or(|venue| instrument_id.venue == venue)
727        });
728
729        let http = Arc::clone(&self.http);
730        let price_precisions = self.config.price_precisions.clone();
731        let index = Arc::clone(&self.instruments);
732        let sender = self.data_sender.clone();
733        let client_id = request.client_id.unwrap_or(self.client_id);
734
735        // Names the provider when the request names no venue
736        let venue = request.venue.unwrap_or_else(|| Venue::new(COINAPI));
737        let start = datetime_to_unix_nanos(request.start);
738        let end = datetime_to_unix_nanos(request.end);
739        let clock = self.clock;
740
741        self.spawn_command("CoinAPI instruments request", async move {
742            let loaded =
743                load_instruments(&http, &selections, &price_precisions, clock.get_time_ns())
744                    .await?;
745
746            index.extend(&loaded);
747            let instruments = loaded
748                .iter()
749                .map(|instrument| instrument.instrument.clone())
750                .collect();
751
752            let response = DataResponse::Instruments(InstrumentsResponse::new(
753                request.request_id,
754                client_id,
755                venue,
756                instruments,
757                start,
758                end,
759                clock.get_time_ns(),
760                request.params,
761            ));
762            send_response(&sender, response);
763            Ok(())
764        });
765
766        Ok(())
767    }
768
769    fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
770        let instrument_id = request.single_instrument_id()?;
771        let selections = select_instruments(&self.config.instruments, |candidate| {
772            *candidate == instrument_id
773        });
774
775        anyhow::ensure!(
776            !selections.is_empty(),
777            "{instrument_id} is not a configured CoinAPI instrument"
778        );
779
780        let http = Arc::clone(&self.http);
781        let price_precisions = self.config.price_precisions.clone();
782        let index = Arc::clone(&self.instruments);
783        let sender = self.data_sender.clone();
784        let client_id = request.client_id.unwrap_or(self.client_id);
785        let start = datetime_to_unix_nanos(request.start);
786        let end = datetime_to_unix_nanos(request.end);
787        let clock = self.clock;
788
789        self.spawn_command("CoinAPI instrument request", async move {
790            let loaded =
791                load_instruments(&http, &selections, &price_precisions, clock.get_time_ns())
792                    .await?;
793            let instrument = loaded
794                .into_iter()
795                .next()
796                .context("CoinAPI instrument load returned nothing")?;
797            index.insert(&instrument);
798
799            let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
800                request.request_id,
801                client_id,
802                instrument_id,
803                instrument.instrument,
804                start,
805                end,
806                clock.get_time_ns(),
807                request.params,
808            )));
809            send_response(&sender, response);
810            Ok(())
811        });
812
813        Ok(())
814    }
815
816    fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
817        let instrument_id = request.single_instrument_id()?;
818
819        let (symbol_id, instrument) = self.loaded(instrument_id)?;
820
821        let query = BookQuery {
822            limit_levels: request
823                .depth
824                .map(|depth| u32::try_from(depth.get()).unwrap_or(u32::MAX)),
825        };
826
827        let http = Arc::clone(&self.http);
828        let sender = self.data_sender.clone();
829        let client_id = request.client_id.unwrap_or(self.client_id);
830        let clock = self.clock;
831
832        self.spawn_command("CoinAPI book snapshot request", async move {
833            let book = http.get_book(&symbol_id, &query).await?;
834            let book = http_parse::parse_order_book(&book, &instrument)
835                .with_context(|| format!("Invalid CoinAPI book for {symbol_id}"))?;
836
837            let response = DataResponse::Book(BookResponse::new(
838                request.request_id,
839                client_id,
840                instrument_id,
841                book,
842                None,
843                None,
844                clock.get_time_ns(),
845                request.params,
846            ));
847            send_response(&sender, response);
848            Ok(())
849        });
850
851        Ok(())
852    }
853
854    fn request_quotes(&self, request: RequestQuotes) -> anyhow::Result<()> {
855        let instrument_id = request.single_instrument_id()?;
856
857        let (symbol_id, instrument, range) =
858            self.history(instrument_id, request.start, request.end, request.limit)?;
859
860        let http = Arc::clone(&self.http);
861        let sender = self.data_sender.clone();
862        let client_id = request.client_id.unwrap_or(self.client_id);
863        let start = datetime_to_unix_nanos(request.start);
864        let end = datetime_to_unix_nanos(request.end);
865        let clock = self.clock;
866
867        self.spawn_command("CoinAPI quotes request", async move {
868            let pages = http.get_quotes_range(&symbol_id, &range).await?;
869
870            let quotes = parse_history(&pages, "quote", symbol_id, |quote| {
871                http_parse::parse_quote_tick(quote, &instrument)
872            });
873
874            let response = DataResponse::Quotes(QuotesResponse::new(
875                request.request_id,
876                client_id,
877                instrument_id,
878                quotes,
879                start,
880                end,
881                clock.get_time_ns(),
882                request.params,
883            ));
884            send_response(&sender, response);
885            Ok(())
886        });
887
888        Ok(())
889    }
890
891    fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
892        let instrument_id = request.single_instrument_id()?;
893
894        let (symbol_id, instrument, range) =
895            self.history(instrument_id, request.start, request.end, request.limit)?;
896
897        let http = Arc::clone(&self.http);
898        let sender = self.data_sender.clone();
899        let client_id = request.client_id.unwrap_or(self.client_id);
900        let start = datetime_to_unix_nanos(request.start);
901        let end = datetime_to_unix_nanos(request.end);
902        let clock = self.clock;
903
904        self.spawn_command("CoinAPI trades request", async move {
905            let pages = http.get_trades_range(&symbol_id, &range).await?;
906
907            let trades = parse_history(&pages, "trade", symbol_id, |trade| {
908                http_parse::parse_trade_tick(trade, &instrument)
909            });
910
911            let response = DataResponse::Trades(TradesResponse::new(
912                request.request_id,
913                client_id,
914                instrument_id,
915                trades,
916                start,
917                end,
918                clock.get_time_ns(),
919                request.params,
920            ));
921            send_response(&sender, response);
922            Ok(())
923        });
924
925        Ok(())
926    }
927
928    fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
929        let bar_type = request.single_bar_type()?;
930
931        anyhow::ensure!(
932            bar_type.aggregation_source() == AggregationSource::External,
933            "CoinAPI provides only external bars, was {bar_type}"
934        );
935
936        let period_id = bar_spec_to_period_id(bar_type.spec())?;
937        let (symbol_id, instrument, range) = self.history(
938            bar_type.instrument_id(),
939            request.start,
940            request.end,
941            request.limit,
942        )?;
943
944        let http = Arc::clone(&self.http);
945        let sender = self.data_sender.clone();
946        let client_id = request.client_id.unwrap_or(self.client_id);
947        let start = datetime_to_unix_nanos(request.start);
948        let end = datetime_to_unix_nanos(request.end);
949        let clock = self.clock;
950
951        self.spawn_command("CoinAPI bars request", async move {
952            let mut pages = http.get_ohlcv_range(&symbol_id, &period_id, &range).await?;
953
954            // A period still in progress, or closing after the window, is not a complete bar
955            let closed_by = range.time_end.min(clock.get_time_ns().to_datetime_utc());
956            pages
957                .records
958                .retain(|ohlcv| ohlcv.time_period_end <= closed_by);
959
960            let bars = parse_history(&pages, "OHLCV period", symbol_id, |ohlcv| {
961                http_parse::parse_bar(ohlcv, bar_type, &instrument)
962            });
963
964            let response = DataResponse::Bars(BarsResponse::new(
965                request.request_id,
966                client_id,
967                bar_type,
968                bars,
969                start,
970                end,
971                clock.get_time_ns(),
972                request.params,
973            ));
974            send_response(&sender, response);
975            Ok(())
976        });
977
978        Ok(())
979    }
980}
981
982// Scope changes apply in call order through one task, so a quick unsubscribe cannot overtake its
983// subscribe
984type SubscriptionSender = tokio::sync::mpsc::UnboundedSender<SubscriptionCommand>;
985
986#[derive(Debug)]
987struct SubscriptionCommand {
988    change: ScopeChange,
989    snapshot: Option<SnapshotWait>,
990    book_active: Option<Arc<AtomicBool>>,
991}
992
993// Opens a book's snapshot gate once its subscribing hello is written, then starts its deadline
994#[derive(Debug)]
995struct SnapshotWait {
996    gate: SnapshotGate,
997    written: tokio::sync::oneshot::Sender<()>,
998}
999
1000fn send_subscription(
1001    commands: &SubscriptionSender,
1002    change: ScopeChange,
1003    snapshot: Option<SnapshotWait>,
1004    book_active: Option<Arc<AtomicBool>>,
1005) -> anyhow::Result<()> {
1006    commands
1007        .send(SubscriptionCommand {
1008            change,
1009            snapshot,
1010            book_active,
1011        })
1012        .map_err(|_| anyhow::anyhow!("CoinAPI subscription task has stopped"))
1013}
1014
1015// Applies every change queued by the time it wakes as one batch, so a burst of subscriptions shares
1016// one hello
1017async fn run_subscriptions(
1018    ws: Arc<CoinApiWebSocketClient>,
1019    mut commands: tokio::sync::mpsc::UnboundedReceiver<SubscriptionCommand>,
1020    cancellation: CancellationToken,
1021) {
1022    loop {
1023        let first = tokio::select! {
1024            biased;
1025            () = cancellation.cancelled() => break,
1026            command = commands.recv() => match command {
1027                Some(command) => command,
1028                None => break,
1029            },
1030        };
1031
1032        let mut batch = vec![first];
1033        while let Ok(command) = commands.try_recv() {
1034            batch.push(command);
1035        }
1036
1037        let changes: Vec<ScopeChange> = batch.iter().map(|command| command.change).collect();
1038        let registrations: Vec<_> = batch
1039            .iter()
1040            .map(|command| command.book_active.clone())
1041            .collect();
1042        let mut delay = SUBSCRIPTION_RETRY_DELAY_INITIAL;
1043
1044        // The engine records the change once queued, so a transport failure must not drop it
1045        let rejected = loop {
1046            let result = tokio::select! {
1047                biased;
1048                () = cancellation.cancelled() => return,
1049                result = ws.apply_registered(&changes, &registrations) => result,
1050            };
1051
1052            match result {
1053                Ok(rejected) => break rejected,
1054                Err(e) => {
1055                    log::warn!("CoinAPI subscription change failed, retrying in {delay:?}: {e}");
1056                    tokio::select! {
1057                        biased;
1058                        () = cancellation.cancelled() => return,
1059                        () = time::sleep(delay) => {}
1060                    }
1061                    delay = (delay * 2).min(SUBSCRIPTION_RETRY_DELAY_MAX);
1062                }
1063            }
1064        };
1065
1066        for (change, e) in &rejected {
1067            log::error!("CoinAPI rejected {change:?}: {e}");
1068        }
1069
1070        for command in batch {
1071            let Some(snapshot) = command.snapshot else {
1072                continue;
1073            };
1074
1075            if rejected
1076                .iter()
1077                .all(|(rejected, _)| *rejected != command.change)
1078            {
1079                snapshot.gate.open();
1080                let _ = snapshot.written.send(());
1081            }
1082        }
1083    }
1084}
1085
1086// Book state and recovery handles the stream task shares with subscription commands
1087#[derive(Debug)]
1088struct BookStreams {
1089    subscriptions: Arc<BookSubscriptions>,
1090    ws: Arc<CoinApiWebSocketClient>,
1091    tasks: TaskSpawner,
1092    snapshot_timeout: Duration,
1093}
1094
1095// Latest update for each (symbol_id, period_id) OHLCV stream
1096pub(crate) type OhlcvCache = AHashMap<(Ustr, Ustr), Ohlcv>;
1097
1098#[derive(Debug, Default)]
1099struct InstrumentIndex {
1100    state: ArcSwap<InstrumentIndexState>,
1101}
1102
1103#[derive(Debug, Clone, Default)]
1104struct InstrumentIndexState {
1105    by_symbol_id: AHashMap<Ustr, InstrumentAny>,
1106    symbol_ids: AHashMap<InstrumentId, Ustr>,
1107    stream_symbol_ids: AHashMap<InstrumentId, Ustr>,
1108}
1109
1110impl InstrumentIndex {
1111    fn insert(&self, loaded: &CoinApiInstrument) {
1112        self.extend(std::slice::from_ref(loaded));
1113    }
1114
1115    fn extend(&self, loaded: &[CoinApiInstrument]) {
1116        if loaded.is_empty() {
1117            return;
1118        }
1119
1120        self.state.rcu(|state| {
1121            let mut next = (**state).clone();
1122            for instrument in loaded {
1123                next.insert(instrument);
1124            }
1125
1126            Arc::new(next)
1127        });
1128    }
1129
1130    fn begin_stream(&self, loaded: &[CoinApiInstrument]) {
1131        let mut state = InstrumentIndexState::default();
1132        for instrument in loaded {
1133            state
1134                .stream_symbol_ids
1135                .insert(instrument.instrument.id(), instrument.symbol_id);
1136            state.insert(instrument);
1137        }
1138
1139        self.state.store(Arc::new(state));
1140    }
1141
1142    fn stream_symbol_id(&self, instrument_id: InstrumentId) -> Option<Ustr> {
1143        self.state
1144            .load()
1145            .stream_symbol_ids
1146            .get(&instrument_id)
1147            .copied()
1148    }
1149
1150    fn loaded(&self, instrument_id: InstrumentId) -> Option<(Ustr, InstrumentAny)> {
1151        let state = self.state.load();
1152        let symbol_id = *state.symbol_ids.get(&instrument_id)?;
1153        Some((symbol_id, state.by_symbol_id.get(&symbol_id)?.clone()))
1154    }
1155
1156    fn instrument(&self, symbol_id: Ustr) -> Option<InstrumentAny> {
1157        self.state.load().by_symbol_id.get(&symbol_id).cloned()
1158    }
1159}
1160
1161impl InstrumentIndexState {
1162    fn insert(&mut self, loaded: &CoinApiInstrument) {
1163        let instrument_id = loaded.instrument.id();
1164        let stream_symbol_id = self.stream_symbol_ids.get(&instrument_id).copied();
1165
1166        if let Some(previous) = self.symbol_ids.get(&instrument_id)
1167            && *previous != loaded.symbol_id
1168            && Some(*previous) != stream_symbol_id
1169        {
1170            self.by_symbol_id.remove(previous);
1171        }
1172
1173        self.by_symbol_id
1174            .insert(loaded.symbol_id, loaded.instrument.clone());
1175        self.symbol_ids.insert(instrument_id, loaded.symbol_id);
1176
1177        if let Some(symbol_id) = stream_symbol_id
1178            && symbol_id != loaded.symbol_id
1179        {
1180            self.by_symbol_id
1181                .insert(symbol_id, loaded.instrument.clone());
1182        }
1183    }
1184}
1185
1186fn validate_price_precisions(
1187    selections: &[CoinApiInstrumentSelection],
1188    price_precisions: &HashMap<InstrumentId, u8>,
1189) -> anyhow::Result<()> {
1190    for (instrument_id, precision) in price_precisions {
1191        anyhow::ensure!(
1192            selections
1193                .iter()
1194                .any(|selection| selection.instrument_ids.contains(instrument_id)),
1195            "price precision for {instrument_id}, which is not a selected instrument"
1196        );
1197        check_fixed_precision(*precision)
1198            .with_context(|| format!("invalid price precision for {instrument_id}"))?;
1199    }
1200
1201    Ok(())
1202}
1203
1204// Narrows each selection to the matching instruments and drops selections left empty
1205fn select_instruments(
1206    selections: &[CoinApiInstrumentSelection],
1207    predicate: impl Fn(&InstrumentId) -> bool,
1208) -> Vec<CoinApiInstrumentSelection> {
1209    selections
1210        .iter()
1211        .filter_map(|selection| {
1212            let instrument_ids: Vec<InstrumentId> = selection
1213                .instrument_ids
1214                .iter()
1215                .copied()
1216                .filter(&predicate)
1217                .collect();
1218
1219            (!instrument_ids.is_empty()).then(|| CoinApiInstrumentSelection {
1220                instrument_ids,
1221                ..selection.clone()
1222            })
1223        })
1224        .collect()
1225}
1226
1227// Requests each selection's exchange once and requires every selected instrument
1228async fn load_instruments(
1229    http: &CoinApiHttpClient,
1230    selections: &[CoinApiInstrumentSelection],
1231    price_precisions: &HashMap<InstrumentId, u8>,
1232    ts_init: UnixNanos,
1233) -> anyhow::Result<Vec<CoinApiInstrument>> {
1234    let mut selected = Vec::new();
1235
1236    for selection in selections {
1237        let loaded = http
1238            .request_instruments(
1239                &selection.exchange_id,
1240                &selection.query,
1241                price_precisions,
1242                ts_init,
1243            )
1244            .await?;
1245
1246        for instrument_id in &selection.instrument_ids {
1247            let instrument = loaded
1248                .iter()
1249                .find(|instrument| instrument.instrument.id() == *instrument_id)
1250                .with_context(|| {
1251                    format!(
1252                        "CoinAPI {} returned no instrument {instrument_id}",
1253                        selection.exchange_id
1254                    )
1255                })?;
1256
1257            selected.push(instrument.clone());
1258        }
1259    }
1260
1261    Ok(selected)
1262}
1263
1264fn channel(data_type: DataType, period_id: Option<Ustr>) -> Channel {
1265    Channel {
1266        data_type,
1267        period_id,
1268    }
1269}
1270
1271fn send_response(sender: &EventSender<DataEvent>, response: DataResponse) {
1272    if let Err(e) = sender.send(DataEvent::Response(response)) {
1273        log::error!("Failed to send CoinAPI response: {e}");
1274    }
1275}
1276
1277// Converts each record, dropping and counting those that fail rather than failing the request
1278fn parse_history<T, D>(
1279    pages: &HistoryPages<T>,
1280    kind: &str,
1281    symbol_id: Ustr,
1282    parse: impl Fn(&T) -> anyhow::Result<D>,
1283) -> Vec<D> {
1284    let total = pages.records.len();
1285
1286    if pages.is_truncated {
1287        log::warn!(
1288            "CoinAPI credit budget truncated the {kind} history for {symbol_id} after {total} records"
1289        );
1290    }
1291
1292    let mut first_error = None;
1293
1294    let parsed: Vec<D> = pages
1295        .records
1296        .iter()
1297        .filter_map(|record| {
1298            parse(record)
1299                .map_err(|e| {
1300                    first_error.get_or_insert(e);
1301                })
1302                .ok()
1303        })
1304        .collect();
1305
1306    if let Some(e) = first_error {
1307        let dropped = total - parsed.len();
1308        log::warn!(
1309            "Dropped {dropped} of {total} CoinAPI {kind} records for {symbol_id}, first failure: {e:#}"
1310        );
1311    }
1312
1313    parsed
1314}
1315
1316async fn run_stream(
1317    mut rx: tokio::sync::mpsc::UnboundedReceiver<CoinApiWsMessage>,
1318    cancellation: CancellationToken,
1319    sender: EventSender<DataEvent>,
1320    instruments: Arc<InstrumentIndex>,
1321    books: BookStreams,
1322    ohlcv: Arc<Mutex<OhlcvCache>>,
1323    clock: &'static AtomicTime,
1324) {
1325    loop {
1326        let message = tokio::select! {
1327            biased;
1328            () = cancellation.cancelled() => break,
1329            message = rx.recv() => match message {
1330                Some(message) => message,
1331                None => break,
1332            },
1333        };
1334
1335        handle_message(message, &sender, &instruments, &books, &ohlcv, clock);
1336    }
1337}
1338
1339fn handle_message(
1340    message: CoinApiWsMessage,
1341    sender: &EventSender<DataEvent>,
1342    instruments: &InstrumentIndex,
1343    books: &BookStreams,
1344    ohlcv: &Arc<Mutex<OhlcvCache>>,
1345    clock: &AtomicTime,
1346) {
1347    let (symbol_id, kind, parsed) = match message {
1348        CoinApiWsMessage::Book(book) => {
1349            let Some(instrument) = instruments.instrument(book.symbol_id) else {
1350                return log_unknown_symbol(book.symbol_id);
1351            };
1352
1353            if book.data_type == DataType::Book {
1354                return handle_book_frame(
1355                    BookFrame::Levels(&book),
1356                    &instrument,
1357                    books,
1358                    ohlcv,
1359                    sender,
1360                );
1361            }
1362
1363            let Some(depth) = books
1364                .subscriptions
1365                .entries
1366                .load()
1367                .get(&instrument.id())
1368                .and_then(|registration| registration.depth)
1369            else {
1370                return;
1371            };
1372
1373            let parsed = parse_book_depth(&book, depth, &instrument).map(|mut depth| {
1374                depth.sequence = books
1375                    .subscriptions
1376                    .sync
1377                    .depth_sequence(instrument.id(), book.sequence);
1378                Data::BookDepth(Box::new(depth))
1379            });
1380
1381            (book.symbol_id, "book depth", parsed)
1382        }
1383        CoinApiWsMessage::BookL3(book) => {
1384            let Some(instrument) = instruments.instrument(book.symbol_id) else {
1385                return log_unknown_symbol(book.symbol_id);
1386            };
1387
1388            return handle_book_frame(BookFrame::Orders(&book), &instrument, books, ohlcv, sender);
1389        }
1390        CoinApiWsMessage::Quote(quote) => {
1391            let Some(instrument) = instruments.instrument(quote.symbol_id) else {
1392                return log_unknown_symbol(quote.symbol_id);
1393            };
1394
1395            let parsed = parse_quote_tick(&quote, &instrument).map(Data::Quote);
1396            (quote.symbol_id, "quote", parsed)
1397        }
1398        CoinApiWsMessage::Trade(trade) => {
1399            let Some(instrument) = instruments.instrument(trade.symbol_id) else {
1400                return log_unknown_symbol(trade.symbol_id);
1401            };
1402
1403            let parsed = parse_trade_tick(&trade, &instrument).map(Data::Trade);
1404            (trade.symbol_id, "trade", parsed)
1405        }
1406        CoinApiWsMessage::Ohlcv(update) => {
1407            let Some(closed) = close_period(&mut ohlcv.lock(), update) else {
1408                return;
1409            };
1410
1411            let Some(instrument) = instruments.instrument(closed.symbol_id) else {
1412                return log_unknown_symbol(closed.symbol_id);
1413            };
1414
1415            let parsed = parse_bar(&closed, &instrument, clock.get_time_ns()).map(Data::Bar);
1416            (closed.symbol_id, "OHLCV period", parsed)
1417        }
1418        CoinApiWsMessage::Error(message) => {
1419            log::error!("CoinAPI retired the stream and its subscriptions: {message}");
1420            return;
1421        }
1422        CoinApiWsMessage::Reconnected => {
1423            // Updates missed during the outage could leave a retained period incomplete
1424            ohlcv.lock().clear();
1425            reset_books(books, ohlcv);
1426            log::info!("CoinAPI stream reconnected and replayed its subscriptions");
1427            return;
1428        }
1429    };
1430
1431    match parsed {
1432        Ok(data) => {
1433            if let Err(e) = sender.send(DataEvent::Data(data)) {
1434                log::error!("Failed to send CoinAPI data: {e}");
1435            }
1436        }
1437        Err(e) => log::warn!("Dropping CoinAPI {kind} for {symbol_id}: {e:#}"),
1438    }
1439}
1440
1441fn handle_book_frame(
1442    frame: BookFrame<'_>,
1443    instrument: &InstrumentAny,
1444    books: &BookStreams,
1445    ohlcv: &Arc<Mutex<OhlcvCache>>,
1446    sender: &EventSender<DataEvent>,
1447) {
1448    let instrument_id = instrument.id();
1449
1450    match books
1451        .subscriptions
1452        .sync
1453        .apply(instrument_id, frame, instrument, time::Instant::now())
1454    {
1455        (BookSequenceOutcome::Accept, Some(deltas)) => {
1456            if let Err(e) = sender.send(DataEvent::Data(Data::BookDeltas(Box::new(deltas)))) {
1457                log::error!("Failed to send CoinAPI data: {e}");
1458            }
1459        }
1460        (BookSequenceOutcome::Recover, _) => start_recovery(
1461            instrument_id,
1462            &books.subscriptions.sync,
1463            &books.ws,
1464            ohlcv,
1465            books.snapshot_timeout,
1466            &books.tasks,
1467        ),
1468        _ => {}
1469    }
1470}
1471
1472// Sequences restart per connection, so each book waits for the snapshot its replayed pair brings
1473fn reset_books(books: &BookStreams, ohlcv: &Arc<Mutex<OhlcvCache>>) {
1474    let deadline =
1475        (!books.snapshot_timeout.is_zero()).then(|| time::Instant::now() + books.snapshot_timeout);
1476
1477    if books.subscriptions.sync.reset_on_reconnect(deadline) > 0 {
1478        spawn_recovery_monitor(
1479            books.subscriptions.sync.clone(),
1480            Arc::clone(&books.ws),
1481            Arc::clone(ohlcv),
1482            books.snapshot_timeout,
1483            &books.tasks,
1484        );
1485    }
1486}
1487
1488fn log_unknown_symbol(symbol_id: Ustr) {
1489    log::warn!("Dropping CoinAPI message for unloaded symbol {symbol_id}");
1490}
1491
1492// CoinAPI repeats a period's running totals and sends no close flag; an update for a later
1493// period closes the retained one
1494fn close_period(cache: &mut OhlcvCache, update: Ohlcv) -> Option<Ohlcv> {
1495    let key = (update.symbol_id, update.period_id);
1496    match cache.get(&key) {
1497        Some(retained) if update.time_period_start < retained.time_period_start => None,
1498        Some(retained) if update.time_period_start == retained.time_period_start => {
1499            cache.insert(key, update);
1500            None
1501        }
1502        _ => cache.insert(key, update),
1503    }
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508    use nautilus_model::types::Price;
1509    use rstest::rstest;
1510
1511    use super::*;
1512
1513    #[cfg(not(all(feature = "simulation", madsim)))]
1514    #[rstest]
1515    #[case::stop(DataClient::stop, false)]
1516    #[case::reset(DataClient::reset, true)]
1517    #[case::dispose(DataClient::dispose, false)]
1518    #[tokio::test]
1519    async fn shutdown_cancels_tasks_and_clears_book_state(
1520        #[case] shutdown: fn(&mut CoinApiDataClient) -> anyhow::Result<()>,
1521        #[case] clears_bars: bool,
1522    ) {
1523        let (sender, _events) = tokio::sync::mpsc::unbounded_channel();
1524        nautilus_common::live::runner::replace_data_event_sender(sender);
1525
1526        let mut client = CoinApiDataClient::new(
1527            ClientId::from("COINAPI"),
1528            CoinApiDataClientConfig {
1529                api_key: Some("test-key".into()),
1530                ..CoinApiDataClientConfig::default()
1531            },
1532        )
1533        .unwrap();
1534
1535        let instrument_id = InstrumentId::from("BTC-USD.COINBASE");
1536
1537        let subscription = Subscription {
1538            channel: channel(DataType::Book, None),
1539            symbol_id: "COINBASE_SPOT_BTC_USD".into(),
1540        };
1541
1542        let symbols: Vec<crate::http::models::Symbol> =
1543            serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1544        let (loaded, _) = http_parse::parse_instruments(&symbols, UnixNanos::default());
1545        let instrument = loaded
1546            .into_iter()
1547            .find(|loaded| loaded.instrument.id() == instrument_id)
1548            .unwrap()
1549            .instrument;
1550        let mut frame: crate::websocket::messages::Book =
1551            serde_json::from_str(include_str!("../test_data/ws_book.json")).unwrap();
1552        frame.sequence = 100;
1553        frame.is_snapshot = Some(true);
1554        let gate = SnapshotGate::default();
1555        gate.open();
1556        let book_cancel = client.books.sync.record_subscription(
1557            instrument_id,
1558            subscription,
1559            time::Instant::now(),
1560            gate.clone(),
1561        );
1562        client.books.entries.insert(
1563            instrument_id,
1564            BookSubscription {
1565                data_type: DataType::Book,
1566                depth: None,
1567                active: Arc::new(AtomicBool::new(false)),
1568            },
1569        );
1570
1571        let (outcome, deltas) = client.books.sync.apply(
1572            instrument_id,
1573            BookFrame::Levels(&frame),
1574            &instrument,
1575            time::Instant::now(),
1576        );
1577        assert_eq!(outcome, BookSequenceOutcome::Accept);
1578        assert_eq!(deltas.unwrap().sequence, 100);
1579        assert_eq!(client.books.sync.depth_sequence(instrument_id, 17), 17);
1580        let retained = update(1, "84523.69");
1581        let key = (retained.symbol_id, retained.period_id);
1582        client.ohlcv.lock().insert(key, retained.clone());
1583        client.is_connected = true;
1584        let (ws, _messages) =
1585            CoinApiWebSocketClient::new(client.api_key.clone(), client.config.websocket_config())
1586                .unwrap();
1587        client.ws = Some(Arc::new(ws));
1588        let (commands, mut command_receiver) = tokio::sync::mpsc::unbounded_channel();
1589        client.subscription_tx = Some(commands);
1590        let mut completions = Vec::new();
1591
1592        for group in [&client.session_tasks, &client.command_tasks] {
1593            let cancel = group.cancellation_token();
1594            let (done, completion) = tokio::sync::oneshot::channel();
1595
1596            group
1597                .spawn(async move {
1598                    cancel.cancelled().await;
1599                    done.send(()).unwrap();
1600                })
1601                .unwrap();
1602
1603            completions.push(completion);
1604        }
1605
1606        let retired = Arc::clone(&client.books);
1607        shutdown(&mut client).unwrap();
1608
1609        for completion in completions {
1610            tokio::time::timeout(Duration::from_secs(1), completion)
1611                .await
1612                .unwrap()
1613                .unwrap();
1614        }
1615
1616        assert!(client.is_disconnected());
1617        assert!(client.ws.is_none());
1618        assert!(client.subscription_tx.is_none());
1619        assert!(command_receiver.recv().await.is_none());
1620        assert!(client.books.entries.is_empty());
1621        assert!(book_cancel.is_cancelled());
1622        assert_eq!(client.books.sync.reset_on_reconnect(None), 0);
1623        assert!(!client.session_tasks.is_open());
1624        assert!(!client.command_tasks.is_open());
1625
1626        {
1627            let bars = client.ohlcv.lock();
1628
1629            if clears_bars {
1630                assert!(bars.is_empty());
1631            } else {
1632                assert_eq!(bars.len(), 1);
1633                assert_eq!(bars[&key], retained);
1634            }
1635        }
1636
1637        client.connect().await.unwrap();
1638        let active = Arc::new(AtomicBool::new(true));
1639        let replacement_cancel = client.books.sync.record_subscription(
1640            instrument_id,
1641            subscription,
1642            time::Instant::now(),
1643            gate,
1644        );
1645        client.books.entries.insert(
1646            instrument_id,
1647            BookSubscription {
1648                data_type: DataType::Book,
1649                depth: None,
1650                active: Arc::clone(&active),
1651            },
1652        );
1653
1654        retired.retire();
1655
1656        let replacement = client.books.entries.get_cloned(&instrument_id).unwrap();
1657        assert_eq!(replacement.data_type, DataType::Book);
1658        assert_eq!(replacement.depth, None);
1659        assert!(Arc::ptr_eq(&replacement.active, &active));
1660        assert!(
1661            replacement
1662                .active
1663                .load(std::sync::atomic::Ordering::Relaxed)
1664        );
1665        assert!(!replacement_cancel.is_cancelled());
1666        assert!(!Arc::ptr_eq(&retired, &client.books));
1667        frame.sequence = 1;
1668        let (outcome, deltas) = client.books.sync.apply(
1669            instrument_id,
1670            BookFrame::Levels(&frame),
1671            &instrument,
1672            time::Instant::now(),
1673        );
1674        assert_eq!(outcome, BookSequenceOutcome::Accept);
1675        assert_eq!(deltas.unwrap().sequence, 101);
1676        frame.sequence = 2;
1677        frame.is_snapshot = Some(false);
1678        let (outcome, deltas) = client.books.sync.apply(
1679            instrument_id,
1680            BookFrame::Levels(&frame),
1681            &instrument,
1682            time::Instant::now(),
1683        );
1684        assert_eq!(outcome, BookSequenceOutcome::Accept);
1685        assert_eq!(deltas.unwrap().sequence, 102);
1686        assert_eq!(client.books.sync.depth_sequence(instrument_id, 1), 18);
1687        client.disconnect().await.unwrap();
1688    }
1689
1690    fn update(start_second: i64, close: &str) -> Ohlcv {
1691        let mut update: Ohlcv =
1692            serde_json::from_str(include_str!("../test_data/ws_ohlcv.json")).unwrap();
1693        update.time_period_start = jiff::Timestamp::from_second(start_second).unwrap();
1694        update.time_period_end = jiff::Timestamp::from_second(start_second + 1).unwrap();
1695        update.price_close = close.parse().unwrap();
1696        update
1697    }
1698
1699    fn selection(exchange_id: &str, instrument_ids: &[&str]) -> CoinApiInstrumentSelection {
1700        CoinApiInstrumentSelection {
1701            exchange_id: exchange_id.into(),
1702            query: crate::http::query::SymbolsQuery::default(),
1703            instrument_ids: instrument_ids
1704                .iter()
1705                .map(|id| InstrumentId::from(*id))
1706                .collect(),
1707        }
1708    }
1709
1710    #[rstest]
1711    #[case(vec![], None)]
1712    #[case(
1713        vec![selection("COINBASE", &["BTC-USD.COINBASE"]), selection("BINANCEFTS", &["BTCUSDT-PERP.BINANCE"])],
1714        None
1715    )]
1716    #[case(
1717        vec![selection("COINBASE", &[])],
1718        Some("CoinAPI selection for COINBASE lists no instruments")
1719    )]
1720    #[case(
1721        vec![selection("BINANCE", &["BTCUSDT-PERP.BINANCE"])],
1722        Some("BTCUSDT-PERP.BINANCE maps to CoinAPI exchange BINANCEFTS, not BINANCE")
1723    )]
1724    #[case(
1725        vec![selection("COINBASE", &["BTC-USD.COINBASE"]), selection("COINBASE", &["BTC-USD.COINBASE"])],
1726        Some("duplicate instruments")
1727    )]
1728    #[case(
1729        vec![selection("DERIBIT", &["BTC-PERPETUAL.DERIBIT"])],
1730        Some("instrument ID BTC-PERPETUAL.DERIBIT has no CoinAPI mapping")
1731    )]
1732    fn selections_validate_mapping_and_uniqueness(
1733        #[case] selections: Vec<CoinApiInstrumentSelection>,
1734        #[case] expected: Option<&str>,
1735    ) {
1736        let config = CoinApiDataClientConfig {
1737            instruments: selections,
1738            ..Default::default()
1739        };
1740
1741        let result = config.validate();
1742
1743        assert_eq!(result.err().map(|e| e.to_string()).as_deref(), expected);
1744    }
1745
1746    #[rstest]
1747    #[case(&[("BTC-USD.COINBASE", 3)], None)]
1748    #[case(
1749        &[("ETH-USD.COINBASE", 3)],
1750        Some("price precision for ETH-USD.COINBASE, which is not a selected instrument")
1751    )]
1752    #[case(&[("BTC-USD.COINBASE", 19)], Some("invalid price precision for BTC-USD.COINBASE"))]
1753    fn price_precisions_require_selected_instruments_and_valid_precision(
1754        #[case] precisions: &[(&str, u8)],
1755        #[case] expected: Option<&str>,
1756    ) {
1757        let selections = vec![selection("COINBASE", &["BTC-USD.COINBASE"])];
1758        let precisions = precisions
1759            .iter()
1760            .map(|(id, precision)| (InstrumentId::from(*id), *precision))
1761            .collect();
1762
1763        let result = validate_price_precisions(&selections, &precisions);
1764
1765        assert_eq!(result.err().map(|e| e.to_string()).as_deref(), expected);
1766    }
1767
1768    #[rstest]
1769    fn select_instruments_narrows_and_drops_empty_selections() {
1770        let selections = vec![
1771            selection("COINBASE", &["BTC-USD.COINBASE", "ETH-USD.COINBASE"]),
1772            selection("BINANCE", &["BTCUSDT.BINANCE"]),
1773        ];
1774
1775        let selected = select_instruments(&selections, |id| {
1776            id.symbol.as_str() != "BTCUSDT" && id.symbol.as_str() != "ETH-USD"
1777        });
1778
1779        assert_eq!(selected.len(), 1);
1780        assert_eq!(selected[0].exchange_id, "COINBASE");
1781        assert_eq!(
1782            selected[0].instrument_ids,
1783            vec![InstrumentId::from("BTC-USD.COINBASE")]
1784        );
1785    }
1786
1787    #[rstest]
1788    fn instrument_index_replaces_changed_symbol_id() {
1789        let symbols: Vec<crate::http::models::Symbol> =
1790            serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1791        let (instruments, _) =
1792            crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1793        let original = instruments
1794            .into_iter()
1795            .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1796            .unwrap();
1797
1798        let suffixed = CoinApiInstrument {
1799            symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1800            ..original.clone()
1801        };
1802
1803        let index = InstrumentIndex::default();
1804
1805        index.insert(&original);
1806        index.insert(&suffixed);
1807
1808        let instrument_id = original.instrument.id();
1809        assert_eq!(
1810            index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1811            Some(suffixed.symbol_id)
1812        );
1813        assert_eq!(
1814            index.instrument(suffixed.symbol_id),
1815            Some(original.instrument)
1816        );
1817        assert_eq!(index.instrument(original.symbol_id), None);
1818    }
1819
1820    #[rstest]
1821    fn stream_symbol_survives_refresh_until_new_session() {
1822        let symbols: Vec<crate::http::models::Symbol> =
1823            serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1824        let (loaded, _) = crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1825        let original = loaded
1826            .into_iter()
1827            .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1828            .unwrap();
1829
1830        let refreshed = CoinApiInstrument {
1831            symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1832            ..original.clone()
1833        };
1834
1835        let latest = CoinApiInstrument {
1836            symbol_id: "COINBASE_SPOT_BTC_USD_22F0063".into(),
1837            ..original.clone()
1838        };
1839
1840        let index = InstrumentIndex::default();
1841        let instrument_id = original.instrument.id();
1842        index.begin_stream(std::slice::from_ref(&original));
1843        index.insert(&refreshed);
1844        index.insert(&latest);
1845
1846        assert_eq!(
1847            index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1848            Some(latest.symbol_id)
1849        );
1850        assert_eq!(
1851            index.stream_symbol_id(instrument_id),
1852            Some(original.symbol_id)
1853        );
1854        assert_eq!(
1855            index.instrument(original.symbol_id),
1856            Some(latest.instrument.clone())
1857        );
1858        assert_eq!(index.instrument(refreshed.symbol_id), None);
1859        assert_eq!(
1860            index.instrument(latest.symbol_id),
1861            Some(latest.instrument.clone())
1862        );
1863
1864        index.begin_stream(std::slice::from_ref(&latest));
1865
1866        assert_eq!(
1867            index.loaded(instrument_id).map(|(symbol_id, _)| symbol_id),
1868            Some(latest.symbol_id)
1869        );
1870        assert_eq!(
1871            index.stream_symbol_id(instrument_id),
1872            Some(latest.symbol_id)
1873        );
1874        assert_eq!(index.instrument(original.symbol_id), None);
1875        assert_eq!(index.instrument(latest.symbol_id), Some(latest.instrument));
1876    }
1877
1878    #[rstest]
1879    fn batch_refresh_retains_stream_aliases_and_disjoint_updates() {
1880        let symbols: Vec<crate::http::models::Symbol> =
1881            serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1882        let (loaded, _) = crate::http::parse::parse_instruments(&symbols, 17.into());
1883        assert!(loaded.len() >= 2);
1884        let index = InstrumentIndex::default();
1885        index.begin_stream(&loaded);
1886
1887        let (refreshed, _) = crate::http::parse::parse_instruments(&symbols, 29.into());
1888
1889        let refreshed: Vec<_> = refreshed
1890            .into_iter()
1891            .enumerate()
1892            .map(|(i, loaded)| CoinApiInstrument {
1893                symbol_id: Ustr::from(&format!("REFRESHED_{i}")),
1894                ..loaded
1895            })
1896            .collect();
1897
1898        let barrier = std::sync::Barrier::new(2);
1899        let split = refreshed.len() / 2;
1900        std::thread::scope(|scope| {
1901            for batch in [&refreshed[..split], &refreshed[split..]] {
1902                scope.spawn(|| {
1903                    barrier.wait();
1904                    index.extend(batch);
1905                });
1906            }
1907        });
1908
1909        for (original, refreshed) in loaded.iter().zip(&refreshed) {
1910            let id = original.instrument.id();
1911            assert_eq!(index.stream_symbol_id(id), Some(original.symbol_id));
1912            assert_eq!(index.loaded(id).unwrap().0, refreshed.symbol_id);
1913
1914            for alias in [original.symbol_id, refreshed.symbol_id] {
1915                assert_eq!(
1916                    serde_json::to_value(index.instrument(alias).unwrap()).unwrap(),
1917                    serde_json::to_value(&refreshed.instrument).unwrap()
1918                );
1919            }
1920        }
1921
1922        let state = index.state.load();
1923        assert_eq!(state.symbol_ids.len(), loaded.len());
1924        assert_eq!(state.stream_symbol_ids.len(), loaded.len());
1925        assert_eq!(state.by_symbol_id.len(), loaded.len() * 2);
1926    }
1927
1928    #[rstest]
1929    fn concurrent_refresh_publishes_coherent_aliases() {
1930        let symbols: Vec<crate::http::models::Symbol> =
1931            serde_json::from_str(include_str!("../test_data/symbols.json")).unwrap();
1932        let (loaded, _) = crate::http::parse::parse_instruments(&symbols, UnixNanos::default());
1933        let original = loaded
1934            .into_iter()
1935            .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
1936            .unwrap();
1937        let coarse: Vec<crate::http::models::Symbol> = serde_json::from_str(
1938            &include_str!("../test_data/symbols.json").replace("0.01000000000000000000", "0.1"),
1939        )
1940        .unwrap();
1941        let (coarse, _) = crate::http::parse::parse_instruments(&coarse, UnixNanos::default());
1942        let coarse = coarse
1943            .into_iter()
1944            .find(|loaded| loaded.symbol_id == original.symbol_id)
1945            .unwrap();
1946
1947        let updates = [
1948            CoinApiInstrument {
1949                symbol_id: "COINBASE_SPOT_BTC_USD_22F0062".into(),
1950                ..coarse
1951            },
1952            CoinApiInstrument {
1953                symbol_id: "COINBASE_SPOT_BTC_USD_22F0063".into(),
1954                ..original.clone()
1955            },
1956        ];
1957
1958        assert_eq!(updates[0].instrument.price_increment(), Price::from("0.1"));
1959        assert_eq!(updates[1].instrument.price_increment(), Price::from("0.01"));
1960        let index = InstrumentIndex::default();
1961        index.begin_stream(std::slice::from_ref(&original));
1962        let barrier = std::sync::Barrier::new(updates.len());
1963        std::thread::scope(|scope| {
1964            for update in &updates {
1965                scope.spawn(|| {
1966                    barrier.wait();
1967
1968                    for _ in 0..1000 {
1969                        index.insert(update);
1970                        let state = index.state.load();
1971                        let symbol_id = state.symbol_ids[&original.instrument.id()];
1972                        assert_eq!(state.by_symbol_id.len(), 2);
1973                        assert_eq!(
1974                            serde_json::to_value(&state.by_symbol_id[&original.symbol_id]).unwrap(),
1975                            serde_json::to_value(&state.by_symbol_id[&symbol_id]).unwrap()
1976                        );
1977                        assert_eq!(
1978                            state.stream_symbol_ids[&original.instrument.id()],
1979                            original.symbol_id
1980                        );
1981                        let expected = updates
1982                            .iter()
1983                            .find(|update| update.symbol_id == symbol_id)
1984                            .unwrap();
1985                        assert_eq!(
1986                            serde_json::to_value(&state.by_symbol_id[&symbol_id]).unwrap(),
1987                            serde_json::to_value(&expected.instrument).unwrap()
1988                        );
1989                    }
1990                });
1991            }
1992        });
1993    }
1994
1995    #[rstest]
1996    fn close_period_emits_latest_update_of_prior_period_once() {
1997        let mut cache = OhlcvCache::new();
1998
1999        let first = close_period(&mut cache, update(10, "1"));
2000        let repeat = close_period(&mut cache, update(10, "2"));
2001        let next = close_period(&mut cache, update(11, "3"));
2002        let late = close_period(&mut cache, update(10, "4"));
2003        let skipped = close_period(&mut cache, update(13, "5"));
2004
2005        assert_eq!(first, None);
2006        assert_eq!(repeat, None);
2007        assert_eq!(next, Some(update(10, "2")));
2008        assert_eq!(late, None);
2009        assert_eq!(skipped, Some(update(11, "3")));
2010        assert_eq!(cache.len(), 1);
2011        assert_eq!(cache.values().next(), Some(&update(13, "5")));
2012    }
2013}