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