Skip to main content

nautilus_databento/
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//! Provides a unified data client that combines Databento's live streaming and historical data capabilities.
17//!
18//! This module implements a data client that manages connections to multiple Databento datasets,
19//! handles live market data subscriptions, and provides access to historical data on demand.
20
21use std::{
22    path::PathBuf,
23    str::FromStr,
24    sync::{
25        Arc,
26        atomic::{AtomicBool, Ordering},
27    },
28    time::Duration,
29};
30
31use ahash::AHashMap;
32use databento::{dbn, live::Subscription};
33use indexmap::IndexMap;
34use nautilus_common::{
35    clients::{DataClient, execute_bar_subscriptions, execute_instrument_subscriptions},
36    live::{runner::get_data_event_sender, sender::EventSender},
37    messages::{
38        DataEvent, DataResponse,
39        data::{
40            BarsResponse, BookDeltasResponse, BookDepthResponse, DataRequestFailed,
41            DataResponseEnvelope, InstrumentResponse, InstrumentsResponse, IntoInstrumentIds,
42            QuotesResponse, RequestBars, RequestBookDeltas, RequestBookDepth, RequestInstrument,
43            RequestInstruments, RequestQuotes, RequestTrades, SubscribeBars, SubscribeBookDeltas,
44            SubscribeBookDepth, SubscribeCustomData, SubscribeInstrument,
45            SubscribeInstrumentStatus, SubscribeQuotes, SubscribeTrades, SubscriptionError,
46            TradesResponse, UnsubscribeBars, UnsubscribeBookDeltas, UnsubscribeBookDepth,
47            UnsubscribeCustomData, UnsubscribeInstrumentStatus, UnsubscribeQuotes,
48            UnsubscribeTrades,
49        },
50    },
51};
52use nautilus_core::{
53    AtomicMap, DurationNanos, Params, UnixNanos,
54    datetime::datetime_to_unix_nanos,
55    time::{AtomicTime, get_atomic_clock_realtime},
56};
57use nautilus_live::task::TaskGroup;
58use nautilus_model::{
59    data::{BarSpecification, BarType, CustomData, Data, DataType, custom::CustomDataTrait},
60    enums::{BarAggregation, PriceType},
61    identifiers::{ClientId, InstrumentId, Symbol, Venue},
62    instruments::{Instrument, InstrumentAny},
63};
64use parking_lot::Mutex;
65use tokio_util::sync::CancellationToken;
66
67use crate::{
68    common::{Credential, DATABENTO_VENUE, ohlcv_schema_from_aggregation},
69    historical::{DatabentoHistoricalClient, RangeQueryParams},
70    live::{DatabentoFeedHandler, DatabentoMessage, HandlerCommand},
71    loader::DatabentoDataLoader,
72    symbology::{check_consistent_symbology, infer_symbology_type, instrument_id_to_symbol_string},
73    types::{DatabentoImbalance, DatabentoStatistics, Dataset, PublisherId},
74};
75
76const PRICE_PRECISION_PARAM: &str = "price_precision";
77const SCHEMA_PARAM: &str = "schema";
78const MBP10_DEPTH: usize = 10;
79const QUOTE_SCHEMAS: &[dbn::Schema] = &[
80    dbn::Schema::Mbp1,
81    dbn::Schema::Bbo1S,
82    dbn::Schema::Bbo1M,
83    dbn::Schema::Cmbp1,
84    dbn::Schema::Cbbo1S,
85    dbn::Schema::Cbbo1M,
86    dbn::Schema::Tbbo,
87    dbn::Schema::Tcbbo,
88];
89const TRADE_SCHEMAS: &[dbn::Schema] = &[
90    dbn::Schema::Trades,
91    dbn::Schema::Tbbo,
92    dbn::Schema::Tcbbo,
93    dbn::Schema::Mbp1,
94    dbn::Schema::Cmbp1,
95];
96
97/// Configuration for the Databento data client.
98#[derive(Debug, Clone)]
99#[cfg_attr(
100    feature = "python",
101    pyo3::pyclass(module = "nautilus_trader.adapters.databento", from_py_object)
102)]
103#[cfg_attr(
104    feature = "python",
105    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.databento")
106)]
107pub struct DatabentoDataClientConfig {
108    /// Databento API credential.
109    pub(crate) credential: Credential,
110    /// Path to publishers.json file.
111    pub publishers_filepath: PathBuf,
112    /// Override for the Databento Historical API base URL.
113    pub historical_base_url: Option<String>,
114    /// Override for the Databento live gateway address.
115    pub live_gateway_addr: Option<String>,
116    /// Venue-to-dataset overrides applied on top of the publishers.json mappings.
117    pub venue_dataset_map: IndexMap<String, String>,
118    /// Whether to use exchange as venue for GLBX instruments.
119    pub use_exchange_as_venue: bool,
120    /// Whether to timestamp bars on close.
121    pub bars_timestamp_on_close: bool,
122    /// Reconnection timeout in minutes (None for infinite retries).
123    pub reconnect_timeout_mins: Option<u64>,
124}
125
126#[cfg(feature = "python")]
127nautilus_core::impl_pyo3_config_getters!(DatabentoDataClientConfig {
128    publishers_filepath: PathBuf,
129    historical_base_url: Option<String>,
130    live_gateway_addr: Option<String>,
131    use_exchange_as_venue: bool,
132    bars_timestamp_on_close: bool,
133    venue_dataset_map: IndexMap<String, String>,
134});
135
136impl DatabentoDataClientConfig {
137    /// Creates a new [`DatabentoDataClientConfig`] instance.
138    #[must_use]
139    pub fn new(
140        api_key: impl Into<String>,
141        publishers_filepath: PathBuf,
142        use_exchange_as_venue: bool,
143        bars_timestamp_on_close: bool,
144    ) -> Self {
145        Self {
146            credential: Credential::new(api_key),
147            publishers_filepath,
148            venue_dataset_map: IndexMap::new(),
149            use_exchange_as_venue,
150            bars_timestamp_on_close,
151            reconnect_timeout_mins: Some(10), // Default: 10 minutes
152            historical_base_url: None,
153            live_gateway_addr: None,
154        }
155    }
156
157    /// Returns the API key associated with this config.
158    #[must_use]
159    pub fn api_key(&self) -> &str {
160        self.credential.api_key()
161    }
162
163    /// Returns a masked version of the API key for logging purposes.
164    #[must_use]
165    pub fn api_key_masked(&self) -> String {
166        self.credential.api_key_masked()
167    }
168}
169
170/// A Databento data client that combines live streaming and historical data functionality.
171///
172/// This client uses the existing `DatabentoFeedHandler` for live data subscriptions
173/// and `DatabentoHistoricalClient` for historical data requests. It supports multiple
174/// datasets simultaneously, with separate feed handlers per dataset.
175#[cfg_attr(feature = "python", pyo3::pyclass)]
176#[cfg_attr(
177    feature = "python",
178    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.databento")
179)]
180#[derive(Debug)]
181pub struct DatabentoDataClient {
182    client_id: ClientId,
183    config: DatabentoDataClientConfig,
184    is_connected: AtomicBool,
185    historical: DatabentoHistoricalClient,
186    loader: DatabentoDataLoader,
187    cmd_channels: Arc<Mutex<AHashMap<String, tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>>,
188    task_handles: TaskGroup,
189    cancellation_token: CancellationToken,
190    publisher_venue_map: Arc<IndexMap<PublisherId, Venue>>,
191    symbol_venue_map: Arc<AtomicMap<Symbol, Venue>>,
192    data_sender: EventSender<DataEvent>,
193}
194
195impl DatabentoDataClient {
196    /// Creates a new [`DatabentoDataClient`] instance.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if client creation or publisher configuration loading fails.
201    pub fn new(
202        client_id: ClientId,
203        config: DatabentoDataClientConfig,
204        clock: &'static AtomicTime,
205    ) -> anyhow::Result<Self> {
206        let historical = match &config.historical_base_url {
207            Some(base_url) => DatabentoHistoricalClient::new_with_base_url(
208                config.credential.clone(),
209                config.publishers_filepath.clone(),
210                clock,
211                config.use_exchange_as_venue,
212                base_url,
213            )?,
214            None => DatabentoHistoricalClient::new(
215                config.credential.clone(),
216                config.publishers_filepath.clone(),
217                clock,
218                config.use_exchange_as_venue,
219            )?,
220        };
221
222        // Create data loader for venue-to-dataset mapping
223        let mut loader = DatabentoDataLoader::new(Some(config.publishers_filepath.clone()))?;
224        for (venue, dataset) in &config.venue_dataset_map {
225            loader.set_dataset_for_venue(
226                Dataset::from(dataset.as_str()),
227                Venue::from(venue.as_str()),
228            );
229        }
230
231        // Load publisher configuration
232        let file_content = std::fs::read_to_string(&config.publishers_filepath)?;
233        let publishers_vec: Vec<crate::types::DatabentoPublisher> =
234            serde_json::from_str(&file_content)?;
235
236        let publisher_venue_map = publishers_vec
237            .into_iter()
238            .map(|p| (p.publisher_id, Venue::from(p.venue.as_str())))
239            .collect::<IndexMap<u16, Venue>>();
240
241        let data_sender = get_data_event_sender();
242
243        let task_handles = TaskGroup::new();
244
245        Ok(Self {
246            client_id,
247            config,
248            is_connected: AtomicBool::new(false),
249            historical,
250            loader,
251            cmd_channels: Arc::new(Mutex::new(AHashMap::new())),
252            cancellation_token: task_handles.cancellation_token(),
253            task_handles,
254            publisher_venue_map: Arc::new(publisher_venue_map),
255            symbol_venue_map: Arc::new(AtomicMap::new()),
256            data_sender,
257        })
258    }
259
260    /// Returns the API key associated with this client.
261    #[must_use]
262    pub fn api_key(&self) -> &str {
263        self.config.api_key()
264    }
265
266    /// Returns a masked version of the API key for logging purposes.
267    #[must_use]
268    pub fn api_key_masked(&self) -> String {
269        self.config.api_key_masked()
270    }
271
272    /// Gets the dataset for a given venue using the data loader.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if the venue-to-dataset mapping cannot be found.
277    fn get_dataset_for_venue(&self, venue: Venue) -> anyhow::Result<String> {
278        self.loader
279            .get_dataset_for_venue(&venue)
280            .map(ToString::to_string)
281            .ok_or_else(|| anyhow::anyhow!("No dataset found for venue: {venue}"))
282    }
283
284    /// Gets or creates a feed handler for the specified dataset.
285    fn get_or_create_feed_handler(&self, dataset: &str) -> bool {
286        let mut channels = self.cmd_channels.lock();
287
288        if !channels.contains_key(dataset) {
289            log::debug!("Creating new feed handler for dataset: {dataset}");
290            let cmd_tx = self.initialize_live_feed(dataset.to_string());
291            channels.insert(dataset.to_string(), cmd_tx);
292
293            log::debug!("Feed handler created for dataset: {dataset}, channel stored");
294            return true;
295        }
296
297        false
298    }
299
300    fn send_subscription_to_dataset(
301        &self,
302        dataset: &str,
303        price_precisions: impl IntoIterator<Item = (Symbol, u8)>,
304        subscription: Subscription,
305        start_after_subscribe: bool,
306    ) -> anyhow::Result<()> {
307        let tx = {
308            let channels = self.cmd_channels.lock();
309            channels
310                .get(dataset)
311                .cloned()
312                .ok_or_else(|| anyhow::anyhow!("No feed handler found for dataset: {dataset}"))?
313        };
314
315        send_subscription_commands(
316            &tx,
317            dataset,
318            price_precisions,
319            subscription,
320            start_after_subscribe,
321        )
322    }
323
324    fn subscribe_schema(
325        &self,
326        instrument_id: InstrumentId,
327        schema: dbn::Schema,
328    ) -> anyhow::Result<()> {
329        let symbol = instrument_id.symbol.to_string();
330        // Records resolve to the underlying contract, so a continuous or parent symbol would
331        // publish on topics that its subscriber does not listen on
332        anyhow::ensure!(
333            infer_symbology_type(&symbol) == dbn::SType::RawSymbol,
334            "Unsupported symbol {symbol} for a Databento {schema} subscription, only raw symbols \
335             are available (not continuous or parent symbols)",
336        );
337
338        let dataset = self.get_dataset_for_venue(instrument_id.venue)?;
339        let start_after_subscribe = self.get_or_create_feed_handler(&dataset);
340
341        self.symbol_venue_map
342            .insert(instrument_id.symbol, instrument_id.venue);
343
344        let subscription = Subscription::builder()
345            .schema(schema)
346            .symbols(symbol)
347            .build();
348
349        self.send_subscription_to_dataset(&dataset, None, subscription, start_after_subscribe)
350    }
351
352    fn send_close_to_active_feeds(&self) {
353        let channels = self.cmd_channels.lock();
354        for (dataset, tx) in channels.iter() {
355            if let Err(e) = tx.send(HandlerCommand::Close) {
356                log::warn!("Failed to send close command to dataset {dataset}: {e}");
357            }
358        }
359    }
360
361    fn clear_feed_channels(&self) {
362        let mut channels = self.cmd_channels.lock();
363        channels.clear();
364    }
365
366    fn abort_active_tasks(&self) {
367        self.task_handles.begin_shutdown();
368    }
369
370    fn spawn_task<F>(&self, future: F)
371    where
372        F: std::future::Future<Output = ()> + Send + 'static,
373    {
374        if let Err(e) = self.task_handles.spawn(future) {
375            log::debug!("Skipping Databento task after shutdown began: {e}");
376        }
377    }
378
379    /// Initializes the live feed handler for streaming data.
380    fn initialize_live_feed(
381        &self,
382        dataset: String,
383    ) -> tokio::sync::mpsc::UnboundedSender<HandlerCommand> {
384        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
385        let (msg_tx, msg_rx) = tokio::sync::mpsc::unbounded_channel();
386        let feed_dataset = dataset.clone();
387        let feed_channels = self.cmd_channels.clone();
388
389        let mut feed_handler = DatabentoFeedHandler::new(
390            self.config.credential.clone(),
391            dataset,
392            cmd_rx,
393            msg_tx,
394            (*self.publisher_venue_map).clone(),
395            self.symbol_venue_map.clone(),
396            self.config.use_exchange_as_venue,
397            self.config.bars_timestamp_on_close,
398            self.config.reconnect_timeout_mins,
399        );
400
401        if let Some(addr) = &self.config.live_gateway_addr {
402            feed_handler = feed_handler.with_gateway_addr(addr.clone());
403        }
404
405        let feed_future = async move {
406            // run() logs its terminal error and forwards it as `DatabentoMessage::Error`
407            let _ = feed_handler.run().await;
408            feed_channels.lock().remove(&feed_dataset);
409        };
410
411        let cancellation_token = self.cancellation_token.clone();
412        let data_sender = self.data_sender.clone();
413
414        // Spawn message processing task with cancellation support
415        let msg_future = async move {
416            let mut msg_rx = msg_rx;
417
418            loop {
419                tokio::select! {
420                    msg = msg_rx.recv() => {
421                        match msg {
422                            Some(DatabentoMessage::Data(data)) => {
423                                log::debug!("Received data: {data:?}");
424                                if let Err(e) = data_sender.send(DataEvent::Data(data)) {
425                                    log::error!("Failed to send data event: {e}");
426                                }
427                            }
428                            Some(DatabentoMessage::Instrument(instrument)) => {
429                                log::debug!("Received instrument definition: {}", instrument.id());
430                                if let Err(e) = data_sender.send(DataEvent::Data(Data::Instrument(instrument))) {
431                                    log::error!("Failed to send instrument: {e}");
432                                }
433                            }
434                            Some(DatabentoMessage::Status(status)) => {
435                                log::debug!("Received status: {status:?}");
436                                if let Err(e) =
437                                    data_sender.send(DataEvent::Data(Data::InstrumentStatus(status)))
438                                {
439                                    log::error!("Failed to send status data event: {e}");
440                                }
441                            }
442                            Some(DatabentoMessage::Imbalance(imbalance)) => {
443                                log::debug!("Received imbalance: {imbalance:?}");
444                                let instrument_id = imbalance.instrument_id;
445                                let data = Data::Custom(custom_data_for_instrument(Arc::new(imbalance), instrument_id));
446                                if let Err(e) = data_sender.send(DataEvent::Data(data)) {
447                                    log::error!("Failed to send imbalance data event: {e}");
448                                }
449                            }
450                            Some(DatabentoMessage::Statistics(statistics)) => {
451                                log::debug!("Received statistics: {statistics:?}");
452                                let instrument_id = statistics.instrument_id;
453                                let data = Data::Custom(custom_data_for_instrument(Arc::new(statistics), instrument_id));
454                                if let Err(e) = data_sender.send(DataEvent::Data(data)) {
455                                    log::error!("Failed to send statistics data event: {e}");
456                                }
457                            }
458                            Some(DatabentoMessage::SubscriptionAck(ack)) => {
459                                log::debug!("Received subscription ack: {}", ack.message);
460                            }
461                            Some(DatabentoMessage::Error(error)) => {
462                                log::error!("Feed handler error: {error}");
463                            }
464                            Some(DatabentoMessage::Close) => {
465                                log::debug!("Feed handler closed");
466                                break;
467                            }
468                            None => {
469                                log::debug!("Message channel closed");
470                                break;
471                            }
472                        }
473                    }
474                    () = cancellation_token.cancelled() => {
475                        log::debug!("Message processing cancelled");
476                        break;
477                    }
478                }
479            }
480        };
481
482        if let Err(e) = self.task_handles.spawn(feed_future) {
483            log::warn!("Skipping Databento feed task after shutdown began: {e}");
484        }
485
486        if let Err(e) = self.task_handles.spawn(msg_future) {
487            log::warn!("Skipping Databento message task after shutdown began: {e}");
488        }
489
490        cmd_tx
491    }
492}
493
494#[async_trait::async_trait(?Send)]
495impl DataClient for DatabentoDataClient {
496    /// Returns the client identifier.
497    fn client_id(&self) -> ClientId {
498        self.client_id
499    }
500
501    /// Returns the venue associated with this client (None for multi-venue clients).
502    fn venue(&self) -> Option<Venue> {
503        None
504    }
505
506    /// Starts the data client.
507    ///
508    /// # Errors
509    ///
510    /// Returns an error if the client fails to start.
511    fn start(&mut self) -> anyhow::Result<()> {
512        log::debug!("Starting");
513        Ok(())
514    }
515
516    /// Stops the data client and cancels all active subscriptions.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if the client fails to stop cleanly.
521    fn stop(&mut self) -> anyhow::Result<()> {
522        log::debug!("Stopping");
523
524        self.send_close_to_active_feeds();
525        self.clear_feed_channels();
526        self.cancellation_token.cancel();
527        self.abort_active_tasks();
528        self.is_connected.store(false, Ordering::Relaxed);
529
530        Ok(())
531    }
532
533    fn reset(&mut self) -> anyhow::Result<()> {
534        log::debug!("Resetting");
535        self.send_close_to_active_feeds();
536        self.clear_feed_channels();
537        self.abort_active_tasks();
538        self.is_connected.store(false, Ordering::Relaxed);
539        Ok(())
540    }
541
542    fn dispose(&mut self) -> anyhow::Result<()> {
543        log::debug!("Disposing");
544        self.stop()
545    }
546
547    async fn connect(&mut self) -> anyhow::Result<()> {
548        log::debug!("Connecting...");
549
550        if !self.task_handles.is_open() {
551            self.task_handles
552                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
553                .await
554                .map_err(|e| anyhow::anyhow!("Failed to terminate Databento tasks: {e}"))?;
555            self.task_handles
556                .start_generation()
557                .map_err(|e| anyhow::anyhow!("Failed to start Databento task generation: {e}"))?;
558            self.cancellation_token = self.task_handles.cancellation_token();
559        }
560
561        self.is_connected.store(true, Ordering::Relaxed);
562
563        log::info!("Connected");
564        Ok(())
565    }
566
567    async fn disconnect(&mut self) -> anyhow::Result<()> {
568        log::debug!("Disconnecting...");
569
570        self.send_close_to_active_feeds();
571        self.clear_feed_channels();
572        self.task_handles.begin_shutdown();
573
574        let tasks_result = self
575            .task_handles
576            .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
577            .await
578            .map_err(|e| anyhow::anyhow!("Failed to terminate Databento tasks: {e}"));
579
580        self.is_connected.store(false, Ordering::Relaxed);
581
582        log::info!("Disconnected");
583        tasks_result
584    }
585
586    /// Returns whether the client is currently connected.
587    fn is_connected(&self) -> bool {
588        self.is_connected.load(Ordering::Relaxed)
589    }
590
591    fn is_disconnected(&self) -> bool {
592        !self.is_connected()
593    }
594
595    /// Subscribes to instrument definition data for the specified instrument.
596    ///
597    /// # Errors
598    ///
599    /// Returns an error if the subscription request fails.
600    fn subscribe_instrument(&mut self, cmd: SubscribeInstrument) -> anyhow::Result<()> {
601        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
602            let dataset = self.get_dataset_for_venue(instrument_id.venue)?;
603            let start_after_subscribe = self.get_or_create_feed_handler(&dataset);
604
605            self.symbol_venue_map
606                .insert(instrument_id.symbol, instrument_id.venue);
607            let symbol = instrument_id.symbol.to_string();
608
609            let subscription = Subscription::builder()
610                .schema(databento::dbn::Schema::Definition)
611                .symbols(symbol)
612                .build();
613
614            self.send_subscription_to_dataset(&dataset, None, subscription, start_after_subscribe)?;
615
616            Ok(())
617        })
618    }
619
620    /// Subscribes to quote tick data for the specified instruments.
621    ///
622    /// # Errors
623    ///
624    /// Returns an error if the subscription request fails.
625    fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
626        self.subscribe_batch(
627            cmd.instrument_ids,
628            cmd.params.as_ref(),
629            dbn::Schema::Mbp1,
630            QUOTE_SCHEMAS,
631        )
632    }
633
634    /// Subscribes to trade tick data for the specified instruments.
635    ///
636    /// # Errors
637    ///
638    /// Returns an error if the subscription request fails.
639    fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
640        self.subscribe_batch(
641            cmd.instrument_ids,
642            cmd.params.as_ref(),
643            dbn::Schema::Trades,
644            TRADE_SCHEMAS,
645        )
646    }
647
648    /// Subscribes to order book delta updates for the specified instruments.
649    ///
650    /// # Errors
651    ///
652    /// Returns an error if the subscription request fails.
653    fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
654        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
655            let dataset = self.get_dataset_for_venue(instrument_id.venue)?;
656            let start_after_subscribe = self.get_or_create_feed_handler(&dataset);
657
658            self.symbol_venue_map
659                .insert(instrument_id.symbol, instrument_id.venue);
660            let symbol = instrument_id.symbol.to_string();
661
662            let subscription = Subscription::builder()
663                .schema(databento::dbn::Schema::Mbo) // Market by order for book deltas
664                .symbols(symbol)
665                .build();
666
667            self.send_subscription_to_dataset(&dataset, None, subscription, start_after_subscribe)?;
668
669            Ok(())
670        })
671    }
672
673    /// Subscribes to `DatabentoStatistics` or `DatabentoImbalance` for the instrument given by the
674    /// identifier of the data type.
675    ///
676    /// # Errors
677    ///
678    /// Returns an error if the data type is not supported, has metadata, has a missing or invalid
679    /// instrument ID identifier, or the subscription request fails.
680    fn subscribe(&mut self, cmd: SubscribeCustomData) -> anyhow::Result<()> {
681        let schema = custom_data_schema(&cmd.data_type)?;
682        let instrument_id = custom_data_instrument_id(&cmd.data_type)?;
683        self.subscribe_schema(instrument_id, schema)
684    }
685
686    /// Subscribes to 10-level order book depth snapshots for the specified instrument.
687    ///
688    /// # Errors
689    ///
690    /// Returns an error if the requested depth is not 10, the symbol is not a raw symbol, or the
691    /// subscription request fails.
692    fn subscribe_book_depth(&mut self, cmd: SubscribeBookDepth) -> anyhow::Result<()> {
693        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
694            let cmd = cmd.with_instrument_ids(instrument_id);
695            if let Some(depth) = cmd.depth {
696                anyhow::ensure!(
697                    depth.get() == MBP10_DEPTH,
698                    "Unsupported book depth {depth} for Databento `mbp-10`, only a depth of {MBP10_DEPTH} is available",
699                );
700            }
701
702            self.subscribe_schema(instrument_id, dbn::Schema::Mbp10)
703        })
704    }
705
706    /// Subscribes to OHLCV bars for the specified bar type.
707    ///
708    /// # Errors
709    ///
710    /// Returns an error if the bar type has no matching OHLCV schema or the subscription request
711    /// fails.
712    fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
713        execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
714            let schema = ohlcv_schema(bar_type)?;
715            self.subscribe_schema(bar_type.instrument_id(), schema)
716        })
717    }
718
719    /// Subscribes to instrument status updates for the specified instruments.
720    ///
721    /// # Errors
722    ///
723    /// Returns an error if the subscription request fails.
724    fn subscribe_instrument_status(
725        &mut self,
726        cmd: SubscribeInstrumentStatus,
727    ) -> anyhow::Result<()> {
728        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
729            let dataset = self.get_dataset_for_venue(instrument_id.venue)?;
730            let start_after_subscribe = self.get_or_create_feed_handler(&dataset);
731
732            self.symbol_venue_map
733                .insert(instrument_id.symbol, instrument_id.venue);
734            let symbol = instrument_id.symbol.to_string();
735
736            let subscription = Subscription::builder()
737                .schema(databento::dbn::Schema::Status)
738                .symbols(symbol)
739                .build();
740
741            self.send_subscription_to_dataset(&dataset, None, subscription, start_after_subscribe)?;
742
743            Ok(())
744        })
745    }
746
747    // Unsubscribe methods
748    fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
749        log::debug!(
750            "Databento does not support granular unsubscribing, ignoring quote unsubscribe request for {:?}",
751            cmd.instrument_ids
752        );
753        Ok(())
754    }
755
756    fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
757        // Note: Databento live API doesn't support granular unsubscribing.
758        // The feed handler manages subscriptions and can handle reconnections
759        // with the appropriate subscription state.
760        log::debug!(
761            "Databento does not support granular unsubscribing - ignoring unsubscribe request for {:?}",
762            cmd.instrument_ids
763        );
764
765        Ok(())
766    }
767
768    fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
769        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
770            // Note: Databento live API doesn't support granular unsubscribing.
771            // The feed handler manages subscriptions and can handle reconnections
772            // with the appropriate subscription state.
773            log::debug!(
774                "Databento does not support granular unsubscribing - ignoring unsubscribe request for {instrument_id}"
775            );
776
777            Ok(())
778        })
779    }
780
781    fn unsubscribe(&mut self, cmd: &UnsubscribeCustomData) -> anyhow::Result<()> {
782        // Note: Databento live API doesn't support granular unsubscribing.
783        // The feed handler manages subscriptions and can handle reconnections
784        // with the appropriate subscription state.
785        log::debug!(
786            "Databento does not support granular unsubscribing - ignoring unsubscribe request for {}",
787            cmd.data_type
788        );
789
790        Ok(())
791    }
792
793    fn unsubscribe_book_depth(&mut self, cmd: &UnsubscribeBookDepth) -> anyhow::Result<()> {
794        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
795            // Note: Databento live API doesn't support granular unsubscribing.
796            // The feed handler manages subscriptions and can handle reconnections
797            // with the appropriate subscription state.
798            log::debug!(
799                "Databento does not support granular unsubscribing - ignoring unsubscribe request for {instrument_id}"
800            );
801
802            Ok(())
803        })
804    }
805
806    fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
807        execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
808            // Note: Databento live API doesn't support granular unsubscribing.
809            // The feed handler manages subscriptions and can handle reconnections
810            // with the appropriate subscription state.
811            log::debug!(
812                "Databento does not support granular unsubscribing - ignoring unsubscribe request for {bar_type}"
813            );
814
815            Ok(())
816        })
817    }
818
819    fn unsubscribe_instrument_status(
820        &mut self,
821        cmd: &UnsubscribeInstrumentStatus,
822    ) -> anyhow::Result<()> {
823        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
824            // Note: Databento live API doesn't support granular unsubscribing.
825            // The feed handler manages subscriptions and can handle reconnections
826            // with the appropriate subscription state.
827            log::debug!(
828                "Databento does not support granular unsubscribing - ignoring unsubscribe request for {instrument_id}"
829            );
830
831            Ok(())
832        })
833    }
834
835    fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
836        log::debug!("Request instruments: {request:?}");
837
838        let historical_client = self.historical.clone();
839        let data_sender = self.data_sender.clone();
840        let dataset = request
841            .venue
842            .map(|venue| self.get_dataset_for_venue(venue))
843            .transpose()?
844            .unwrap_or_else(|| "GLBX.MDP3".to_string());
845        let request_id = request.request_id;
846        let client_id = request.client_id.unwrap_or(self.client_id);
847        let venue = request.venue.unwrap_or(*DATABENTO_VENUE);
848        let start_nanos = datetime_to_unix_nanos(request.start);
849        let end_nanos = datetime_to_unix_nanos(request.end);
850        let request_params = request.params;
851        let (query_start, query_end) = resolve_request_time_range(start_nanos, end_nanos);
852
853        self.spawn_task(async move {
854            let query_params = instruments_query_params(dataset, query_start, query_end);
855
856            match historical_client.get_range_instruments(query_params).await {
857                Ok(instruments) => {
858                    log::debug!("Retrieved {} instruments", instruments.len());
859
860                    let response = DataResponse::Instruments(InstrumentsResponse::new(
861                        request_id,
862                        client_id,
863                        venue,
864                        instruments,
865                        start_nanos,
866                        end_nanos,
867                        get_atomic_clock_realtime().get_time_ns(),
868                        request_params,
869                    ));
870
871                    send_data_response(&data_sender, response, "instruments");
872                }
873                Err(e) => {
874                    send_request_result(&data_sender, request_id, None, &[e.to_string()]);
875                }
876            }
877        });
878
879        Ok(())
880    }
881
882    fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
883        let groups = self.group_request_instruments(&request.instrument_ids)?;
884        let historical_client = self.historical.clone();
885        let data_sender = self.data_sender.clone();
886        let client_id = request.client_id.unwrap_or(self.client_id);
887        let start = datetime_to_unix_nanos(request.start);
888        let end = datetime_to_unix_nanos(request.end);
889        let (query_start, query_end) = resolve_request_time_range(start, end);
890        self.spawn_task(async move {
891            let mut data = Vec::new();
892            let mut failures = Vec::new();
893
894            for (dataset, ids) in groups {
895                let symbols = historical_client.prepare_symbols_from_instrument_ids(&ids);
896
897                let params = RangeQueryParams {
898                    dataset: dataset.clone(),
899                    symbols,
900                    start: query_start,
901                    end: query_end,
902                    limit: None,
903                    price_precision: None,
904                };
905
906                match historical_client.get_range_instruments(params).await {
907                    Ok(instruments) => {
908                        for id in ids {
909                            if let Some(instrument) = requested_instrument(&instruments, id) {
910                                data.push(instrument);
911                            } else {
912                                failures.push(format!("Instrument not found: {id}"));
913                            }
914                        }
915                    }
916                    Err(e) => failures.push(format!("Dataset {dataset}: {e}")),
917                }
918            }
919
920            let ts_init = get_atomic_clock_realtime().get_time_ns();
921
922            let response = if request.instrument_ids.len() == 1 && data.len() == 1 {
923                let instrument = data.remove(0);
924                Some(DataResponse::Instrument(Box::new(InstrumentResponse::new(
925                    request.request_id,
926                    client_id,
927                    instrument.id(),
928                    instrument,
929                    start,
930                    end,
931                    ts_init,
932                    request.params,
933                ))))
934            } else if request.instrument_ids.len() > 1 {
935                Some(DataResponse::Instruments(InstrumentsResponse::new(
936                    request.request_id,
937                    client_id,
938                    *DATABENTO_VENUE,
939                    data,
940                    start,
941                    end,
942                    ts_init,
943                    request.params,
944                )))
945            } else {
946                None
947            };
948
949            send_request_result(&data_sender, request.request_id, response, &failures);
950        });
951
952        Ok(())
953    }
954
955    fn request_quotes(&self, request: RequestQuotes) -> anyhow::Result<()> {
956        anyhow::ensure!(
957            request.instrument_ids.len() == 1 || request.limit.is_none(),
958            "A record limit is unsupported for multi-instrument requests"
959        );
960        let groups = self.group_request_instruments(&request.instrument_ids)?;
961        let historical_client = self.historical.clone();
962        let data_sender = self.data_sender.clone();
963        let client_id = request.client_id.unwrap_or(self.client_id);
964        let start = datetime_to_unix_nanos(request.start);
965        let end = datetime_to_unix_nanos(request.end);
966        let limit = request.limit.map(|value| value.get() as u64);
967        let precision = price_precision_from_params(request.params.as_ref())?;
968        let schema = schema_from_params(request.params.as_ref(), dbn::Schema::Mbp1, QUOTE_SCHEMAS)?
969            .to_string();
970        let (query_start, query_end) = resolve_request_time_range(start, end);
971        self.spawn_task(async move {
972            let mut data = Vec::new();
973            let mut failures = Vec::new();
974
975            for (dataset, ids) in groups {
976                for &id in &ids {
977                    seed_price_precision_if_needed(
978                        &historical_client,
979                        &dataset,
980                        id,
981                        query_start,
982                        query_end,
983                        precision,
984                    )
985                    .await;
986                }
987
988                let symbols = historical_client.prepare_symbols_from_instrument_ids(&ids);
989
990                let params = RangeQueryParams {
991                    dataset: dataset.clone(),
992                    symbols,
993                    start: query_start,
994                    end: query_end,
995                    limit,
996                    price_precision: precision,
997                };
998
999                match historical_client
1000                    .get_range_quotes(params, Some(schema.clone()))
1001                    .await
1002                {
1003                    Ok(rows) => data.extend(rows),
1004                    Err(e) => failures.push(format!("Dataset {dataset}: {e}")),
1005                }
1006            }
1007
1008            data.sort_by_key(|row| (row.ts_init, row.instrument_id));
1009            let response = DataResponse::Quotes(QuotesResponse::new(
1010                request.request_id,
1011                client_id,
1012                request.instrument_ids,
1013                data,
1014                start,
1015                end,
1016                get_atomic_clock_realtime().get_time_ns(),
1017                request.params,
1018            ));
1019            send_request_result(&data_sender, request.request_id, Some(response), &failures);
1020        });
1021
1022        Ok(())
1023    }
1024
1025    fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1026        anyhow::ensure!(
1027            request.instrument_ids.len() == 1 || request.limit.is_none(),
1028            "A record limit is unsupported for multi-instrument requests"
1029        );
1030        let groups = self.group_request_instruments(&request.instrument_ids)?;
1031        let historical_client = self.historical.clone();
1032        let data_sender = self.data_sender.clone();
1033        let client_id = request.client_id.unwrap_or(self.client_id);
1034        let start = datetime_to_unix_nanos(request.start);
1035        let end = datetime_to_unix_nanos(request.end);
1036        let limit = request.limit.map(|value| value.get() as u64);
1037        let precision = price_precision_from_params(request.params.as_ref())?;
1038        let schema =
1039            schema_from_params(request.params.as_ref(), dbn::Schema::Trades, TRADE_SCHEMAS)?
1040                .to_string();
1041        let (query_start, query_end) = resolve_request_time_range(start, end);
1042        self.spawn_task(async move {
1043            let mut data = Vec::new();
1044            let mut failures = Vec::new();
1045
1046            for (dataset, ids) in groups {
1047                for &id in &ids {
1048                    seed_price_precision_if_needed(
1049                        &historical_client,
1050                        &dataset,
1051                        id,
1052                        query_start,
1053                        query_end,
1054                        precision,
1055                    )
1056                    .await;
1057                }
1058
1059                let symbols = historical_client.prepare_symbols_from_instrument_ids(&ids);
1060
1061                let params = RangeQueryParams {
1062                    dataset: dataset.clone(),
1063                    symbols,
1064                    start: query_start,
1065                    end: query_end,
1066                    limit,
1067                    price_precision: precision,
1068                };
1069
1070                match historical_client
1071                    .get_range_trades(params, Some(schema.clone()))
1072                    .await
1073                {
1074                    Ok(rows) => data.extend(rows),
1075                    Err(e) => failures.push(format!("Dataset {dataset}: {e}")),
1076                }
1077            }
1078
1079            data.sort_by_key(|row| (row.ts_init, row.instrument_id));
1080            let response = DataResponse::Trades(TradesResponse::new(
1081                request.request_id,
1082                client_id,
1083                request.instrument_ids,
1084                data,
1085                start,
1086                end,
1087                get_atomic_clock_realtime().get_time_ns(),
1088                request.params,
1089            ));
1090            send_request_result(&data_sender, request.request_id, Some(response), &failures);
1091        });
1092
1093        Ok(())
1094    }
1095
1096    fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1097        let bar_type = request.single_bar_type()?;
1098        let aggregation = bar_type.spec().aggregation;
1099        anyhow::ensure!(
1100            matches!(
1101                aggregation,
1102                BarAggregation::Second
1103                    | BarAggregation::Minute
1104                    | BarAggregation::Hour
1105                    | BarAggregation::Day
1106            ),
1107            "Unsupported bar aggregation: {aggregation:?}"
1108        );
1109
1110        log::debug!("Request bars: {request:?}");
1111
1112        let historical_client = self.historical.clone();
1113        let data_sender = self.data_sender.clone();
1114        let instrument_id = bar_type.instrument_id();
1115        let dataset = self.get_dataset_for_venue(instrument_id.venue)?;
1116        let symbols = historical_client.prepare_symbols_from_instrument_ids(&[instrument_id]);
1117        let request_id = request.request_id;
1118        let client_id = request.client_id.unwrap_or(self.client_id);
1119        let start_nanos = datetime_to_unix_nanos(request.start);
1120        let end_nanos = datetime_to_unix_nanos(request.end);
1121        let limit = request.limit.map(|limit| limit.get() as u64);
1122        let request_params = request.params;
1123        let price_precision = price_precision_from_params(request_params.as_ref())?;
1124        let timestamp_on_close = self.config.bars_timestamp_on_close;
1125        let spec = BarSpecification::new(1, aggregation, PriceType::Last);
1126        let interval = DurationNanos::try_from(spec.timedelta()).expect("supported OHLCV interval");
1127        let (start, end) = resolve_request_bounds(start_nanos, end_nanos);
1128
1129        // Databento indexes bars by open, while ts_init always records the close
1130        let (query_start, query_end) = resolve_request_time_range(
1131            Some(start.saturating_sub(interval)),
1132            Some(end.saturating_sub(interval)),
1133        );
1134
1135        self.spawn_task(async move {
1136            seed_price_precision_if_needed(
1137                &historical_client,
1138                dataset.as_str(),
1139                instrument_id,
1140                query_start,
1141                query_end,
1142                price_precision,
1143            )
1144            .await;
1145
1146            let params = RangeQueryParams {
1147                dataset,
1148                symbols,
1149                start: query_start,
1150                end: query_end,
1151                limit,
1152                price_precision,
1153            };
1154
1155            match historical_client
1156                .get_range_bars(params, aggregation, timestamp_on_close)
1157                .await
1158            {
1159                Ok(bars) => {
1160                    log::debug!("Retrieved {} bars", bars.len());
1161                    let response = DataResponse::Bars(BarsResponse::new(
1162                        request_id,
1163                        client_id,
1164                        bar_type,
1165                        bars,
1166                        start_nanos,
1167                        end_nanos,
1168                        get_atomic_clock_realtime().get_time_ns(),
1169                        request_params,
1170                    ));
1171
1172                    send_data_response(&data_sender, response, "bars");
1173                }
1174                Err(e) => {
1175                    send_request_result(&data_sender, request_id, None, &[e.to_string()]);
1176                }
1177            }
1178        });
1179
1180        Ok(())
1181    }
1182
1183    fn request_book_depth(&self, request: RequestBookDepth) -> anyhow::Result<()> {
1184        let instrument_id = request.single_instrument_id()?;
1185
1186        log::debug!("Request book depth: {request:?}");
1187
1188        let historical_client = self.historical.clone();
1189        let data_sender = self.data_sender.clone();
1190        let dataset = self.get_dataset_for_venue(instrument_id.venue)?;
1191        let symbols = historical_client.prepare_symbols_from_instrument_ids(&[instrument_id]);
1192        let request_id = request.request_id;
1193        let client_id = request.client_id.unwrap_or(self.client_id);
1194        let start_nanos = datetime_to_unix_nanos(request.start);
1195        let end_nanos = datetime_to_unix_nanos(request.end);
1196        let limit = request.limit.map(|limit| limit.get() as u64);
1197        let depth = request.depth.map(|depth| depth.get());
1198        let request_params = request.params;
1199        let price_precision = price_precision_from_params(request_params.as_ref())?;
1200        let (query_start, query_end) = resolve_request_time_range(start_nanos, end_nanos);
1201
1202        self.spawn_task(async move {
1203            seed_price_precision_if_needed(
1204                &historical_client,
1205                dataset.as_str(),
1206                instrument_id,
1207                query_start,
1208                query_end,
1209                price_precision,
1210            )
1211            .await;
1212
1213            let params = RangeQueryParams {
1214                dataset,
1215                symbols,
1216                start: query_start,
1217                end: query_end,
1218                limit,
1219                price_precision,
1220            };
1221
1222            match historical_client
1223                .get_range_order_book_depth(params, depth)
1224                .await
1225            {
1226                Ok(depths) => {
1227                    log::debug!("Retrieved {} order book depths", depths.len());
1228                    let response = DataResponse::BookDepth(BookDepthResponse::new(
1229                        request_id,
1230                        client_id,
1231                        instrument_id,
1232                        depths,
1233                        start_nanos,
1234                        end_nanos,
1235                        get_atomic_clock_realtime().get_time_ns(),
1236                        request_params,
1237                    ));
1238
1239                    send_data_response(&data_sender, response, "book depth");
1240                }
1241                Err(e) => {
1242                    send_request_result(&data_sender, request_id, None, &[e.to_string()]);
1243                }
1244            }
1245        });
1246
1247        Ok(())
1248    }
1249
1250    fn request_book_deltas(&self, request: RequestBookDeltas) -> anyhow::Result<()> {
1251        let instrument_id = request.single_instrument_id()?;
1252
1253        log::debug!("Request book deltas: {request:?}");
1254
1255        let historical_client = self.historical.clone();
1256        let data_sender = self.data_sender.clone();
1257        let dataset = self.get_dataset_for_venue(instrument_id.venue)?;
1258        let symbols = historical_client.prepare_symbols_from_instrument_ids(&[instrument_id]);
1259        let request_id = request.request_id;
1260        let client_id = request.client_id.unwrap_or(self.client_id);
1261        let start_nanos = datetime_to_unix_nanos(request.start);
1262        let end_nanos = datetime_to_unix_nanos(request.end);
1263        let limit = request.limit.map(|limit| limit.get() as u64);
1264        let request_params = request.params;
1265        let price_precision = price_precision_from_params(request_params.as_ref())?;
1266        let (query_start, query_end) = resolve_request_time_range(start_nanos, end_nanos);
1267
1268        self.spawn_task(async move {
1269            seed_price_precision_if_needed(
1270                &historical_client,
1271                dataset.as_str(),
1272                instrument_id,
1273                query_start,
1274                query_end,
1275                price_precision,
1276            )
1277            .await;
1278
1279            let params = RangeQueryParams {
1280                dataset,
1281                symbols,
1282                start: query_start,
1283                end: query_end,
1284                limit,
1285                price_precision,
1286            };
1287
1288            match historical_client.get_range_order_book_deltas(params).await {
1289                Ok(deltas) => {
1290                    log::debug!("Retrieved {} order book deltas", deltas.len());
1291                    let response = BookDeltasResponse::new(
1292                        request_id,
1293                        client_id,
1294                        instrument_id,
1295                        deltas,
1296                        start_nanos,
1297                        end_nanos,
1298                        get_atomic_clock_realtime().get_time_ns(),
1299                        request_params,
1300                    );
1301
1302                    send_book_deltas_response(&data_sender, response);
1303                }
1304                Err(e) => {
1305                    send_request_result(&data_sender, request_id, None, &[e.to_string()]);
1306                }
1307            }
1308        });
1309
1310        Ok(())
1311    }
1312}
1313
1314impl DatabentoDataClient {
1315    fn subscribe_batch(
1316        &self,
1317        instrument_ids: Vec<InstrumentId>,
1318        params: Option<&Params>,
1319        default_schema: dbn::Schema,
1320        schemas: &[dbn::Schema],
1321    ) -> anyhow::Result<()> {
1322        let price_precision = price_precision_from_params(params)?;
1323        let schema = schema_from_params(params, default_schema, schemas)?;
1324        let ids = instrument_ids.into_instrument_ids()?;
1325        let groups = self.group_instruments(&ids)?;
1326
1327        let mut accepted = Vec::new();
1328        let mut failure = None;
1329
1330        for (dataset, ids) in &groups {
1331            match self.subscribe_dataset(dataset, ids, schema, price_precision) {
1332                Ok(()) => accepted.extend_from_slice(ids),
1333                Err(e) => {
1334                    log::error!(
1335                        "Databento subscription enqueue failed: dataset={dataset}, schema={schema}, instrument_ids={ids:?}"
1336                    );
1337
1338                    if failure.is_none() {
1339                        failure = Some(e);
1340                    }
1341                }
1342            }
1343        }
1344
1345        match failure {
1346            Some(e) => Err(SubscriptionError::instruments(accepted, e).into()),
1347            None => Ok(()),
1348        }
1349    }
1350
1351    fn group_request_instruments(
1352        &self,
1353        ids: &[InstrumentId],
1354    ) -> anyhow::Result<IndexMap<String, Vec<InstrumentId>>> {
1355        let groups = self.group_instruments(ids)?;
1356        for ids in groups.values() {
1357            let symbols = ids.iter().map(|id| id.symbol.as_str()).collect::<Vec<_>>();
1358            check_consistent_symbology(&symbols)?;
1359        }
1360
1361        Ok(groups)
1362    }
1363
1364    fn group_instruments(
1365        &self,
1366        ids: &[InstrumentId],
1367    ) -> anyhow::Result<IndexMap<String, Vec<InstrumentId>>> {
1368        let mut groups: IndexMap<String, Vec<InstrumentId>> = IndexMap::new();
1369        let mut symbol_venues = AHashMap::new();
1370
1371        for &id in ids {
1372            let dataset = self.get_dataset_for_venue(id.venue)?;
1373            if let Some(venue) = symbol_venues.insert(id.symbol, id.venue) {
1374                anyhow::ensure!(
1375                    venue == id.venue,
1376                    "Identifiers share raw symbol {} across different venues",
1377                    id.symbol
1378                );
1379            }
1380
1381            groups.entry(dataset).or_default().push(id);
1382        }
1383
1384        Ok(groups)
1385    }
1386
1387    fn subscribe_dataset(
1388        &self,
1389        dataset: &str,
1390        ids: &[InstrumentId],
1391        schema: dbn::Schema,
1392        price_precision: Option<u8>,
1393    ) -> anyhow::Result<()> {
1394        let symbols: Vec<String> = ids.iter().map(|id| id.symbol.to_string()).collect();
1395        let precisions: Vec<(Symbol, u8)> = ids
1396            .iter()
1397            .filter_map(|id| price_precision.map(|precision| (id.symbol, precision)))
1398            .collect();
1399        let subscription = Subscription::builder()
1400            .schema(schema)
1401            .symbols(symbols)
1402            .build();
1403        let start_after_subscribe = self.get_or_create_feed_handler(dataset);
1404        let previous_venues: Vec<_> = ids
1405            .iter()
1406            .map(|id| (id.symbol, self.symbol_venue_map.get_cloned(&id.symbol)))
1407            .collect();
1408
1409        for id in ids {
1410            self.symbol_venue_map.insert(id.symbol, id.venue);
1411        }
1412
1413        if let Err(e) = self.send_subscription_to_dataset(
1414            dataset,
1415            precisions,
1416            subscription,
1417            start_after_subscribe,
1418        ) {
1419            for (symbol, previous) in previous_venues {
1420                if let Some(venue) = previous {
1421                    self.symbol_venue_map.insert(symbol, venue);
1422                } else {
1423                    self.symbol_venue_map.remove(&symbol);
1424                }
1425            }
1426
1427            return Err(e);
1428        }
1429
1430        Ok(())
1431    }
1432}
1433
1434fn instruments_query_params(
1435    dataset: String,
1436    start_nanos: UnixNanos,
1437    end_nanos: Option<UnixNanos>,
1438) -> RangeQueryParams {
1439    RangeQueryParams {
1440        dataset,
1441        symbols: vec!["ALL_SYMBOLS".to_string()],
1442        start: start_nanos,
1443        end: end_nanos,
1444        limit: None,
1445        price_precision: None,
1446    }
1447}
1448
1449fn instrument_query_params(
1450    dataset: String,
1451    instrument_id: InstrumentId,
1452    start_nanos: UnixNanos,
1453    end_nanos: Option<UnixNanos>,
1454) -> RangeQueryParams {
1455    RangeQueryParams {
1456        dataset,
1457        symbols: vec![instrument_id_to_symbol_string(
1458            instrument_id,
1459            &mut AHashMap::new(),
1460        )],
1461        start: start_nanos,
1462        end: end_nanos,
1463        limit: None,
1464        price_precision: None,
1465    }
1466}
1467
1468fn resolve_request_time_range(
1469    start_nanos: Option<UnixNanos>,
1470    end_nanos: Option<UnixNanos>,
1471) -> (UnixNanos, Option<UnixNanos>) {
1472    let (start, end) = resolve_request_bounds(start_nanos, end_nanos);
1473
1474    // Engine bounds include the end; Databento's query excludes it
1475    (start, Some(end.saturating_add(DurationNanos::new(1))))
1476}
1477
1478fn resolve_request_bounds(
1479    start_nanos: Option<UnixNanos>,
1480    end_nanos: Option<UnixNanos>,
1481) -> (UnixNanos, UnixNanos) {
1482    let end = end_nanos.unwrap_or_else(|| get_atomic_clock_realtime().get_time_ns());
1483    let mut start = start_nanos.unwrap_or_else(|| start_of_utc_day(end));
1484
1485    if start > end {
1486        start = end;
1487    }
1488
1489    (start, end)
1490}
1491
1492fn start_of_utc_day(timestamp: UnixNanos) -> UnixNanos {
1493    timestamp.floor(DurationNanos::from_days(1))
1494}
1495
1496async fn seed_price_precision_if_needed(
1497    historical_client: &DatabentoHistoricalClient,
1498    dataset: &str,
1499    instrument_id: InstrumentId,
1500    start_nanos: UnixNanos,
1501    end_nanos: Option<UnixNanos>,
1502    price_precision: Option<u8>,
1503) {
1504    if price_precision.is_some()
1505        || historical_client
1506            .price_precision(instrument_id.symbol)
1507            .is_some()
1508    {
1509        return;
1510    }
1511
1512    let query_start = start_of_utc_day(start_nanos);
1513    let query_params =
1514        instrument_query_params(dataset.to_string(), instrument_id, query_start, end_nanos);
1515
1516    if let Err(e) = historical_client.get_range_instruments(query_params).await {
1517        log::warn!("Failed to seed price precision for {instrument_id}: {e}");
1518    }
1519}
1520
1521fn send_request_result(
1522    data_sender: &EventSender<DataEvent>,
1523    request_id: nautilus_core::UUID4,
1524    response: Option<DataResponse>,
1525    failures: &[String],
1526) {
1527    let event = if failures.is_empty() {
1528        let Some(response) = response else {
1529            return;
1530        };
1531
1532        let envelope = DataResponseEnvelope::new(response, true, None);
1533        DataEvent::ResponseEnvelope(envelope)
1534    } else {
1535        DataEvent::RequestFailed(DataRequestFailed {
1536            request_id,
1537            reason: failures.join("; "),
1538            response,
1539        })
1540    };
1541
1542    if let Err(e) = data_sender.send(event) {
1543        log::error!("Failed to send request result: {e}");
1544    }
1545}
1546
1547fn send_data_response(data_sender: &EventSender<DataEvent>, response: DataResponse, label: &str) {
1548    let envelope = DataResponseEnvelope::new(response, true, None);
1549    if let Err(e) = data_sender.send(DataEvent::ResponseEnvelope(envelope)) {
1550        log::error!("Failed to send {label} response: {e}");
1551    }
1552}
1553
1554fn send_book_deltas_response(sender: &EventSender<DataEvent>, response: BookDeltasResponse) {
1555    let responses = partition_book_deltas_response(response);
1556    let last = responses.len() - 1;
1557    for (index, response) in responses.into_iter().enumerate() {
1558        let envelope =
1559            DataResponseEnvelope::new(DataResponse::BookDeltas(response), index == last, None);
1560
1561        if let Err(e) = sender.send(DataEvent::ResponseEnvelope(envelope)) {
1562            log::error!("Failed to send book deltas response: {e}");
1563        }
1564    }
1565}
1566
1567fn partition_book_deltas_response(response: BookDeltasResponse) -> Vec<BookDeltasResponse> {
1568    if response.data.is_empty() {
1569        return vec![response];
1570    }
1571
1572    let mut partitions = IndexMap::new();
1573    for delta in response.data.iter().copied() {
1574        partitions
1575            .entry(delta.instrument_id)
1576            .or_insert_with(Vec::new)
1577            .push(delta);
1578    }
1579
1580    partitions
1581        .into_iter()
1582        .map(|(instrument_id, data)| {
1583            let mut child = response.clone();
1584            child.instrument_id = instrument_id;
1585            child.data = data.into();
1586            child
1587        })
1588        .collect()
1589}
1590
1591fn requested_instrument(
1592    instruments: &[InstrumentAny],
1593    instrument_id: InstrumentId,
1594) -> Option<InstrumentAny> {
1595    instruments
1596        .iter()
1597        .rev()
1598        .find(|instrument| instrument.id() == instrument_id)
1599        .cloned()
1600}
1601
1602fn price_precision_from_params(params: Option<&Params>) -> anyhow::Result<Option<u8>> {
1603    let Some(price_precision) = params.and_then(|params| params.get_u64(PRICE_PRECISION_PARAM))
1604    else {
1605        return Ok(None);
1606    };
1607
1608    Ok(Some(u8::try_from(price_precision).map_err(|_| {
1609        anyhow::anyhow!(
1610            "`{PRICE_PRECISION_PARAM}` must be less than or equal to {}",
1611            u8::MAX
1612        )
1613    })?))
1614}
1615
1616fn custom_data_for_instrument(
1617    data: Arc<dyn CustomDataTrait>,
1618    instrument_id: InstrumentId,
1619) -> CustomData {
1620    let data_type = DataType::new(data.type_name(), None, Some(instrument_id.to_string()));
1621    CustomData::new(data, data_type)
1622}
1623
1624fn custom_data_schema(data_type: &DataType) -> anyhow::Result<dbn::Schema> {
1625    match data_type.type_name() {
1626        name if name == DatabentoStatistics::type_name_static() => Ok(dbn::Schema::Statistics),
1627        name if name == DatabentoImbalance::type_name_static() => Ok(dbn::Schema::Imbalance),
1628        name => anyhow::bail!(
1629            "Unsupported custom data type for Databento live subscription: {name}, expected {} or {}",
1630            DatabentoStatistics::type_name_static(),
1631            DatabentoImbalance::type_name_static(),
1632        ),
1633    }
1634}
1635
1636fn custom_data_instrument_id(data_type: &DataType) -> anyhow::Result<InstrumentId> {
1637    // Records are published without metadata, so metadata would change the subscribed topic
1638    anyhow::ensure!(
1639        data_type
1640            .metadata()
1641            .is_none_or(|metadata| metadata.is_empty()),
1642        "{} subscriptions do not support metadata, received {}",
1643        data_type.type_name(),
1644        data_type.metadata_str(),
1645    );
1646
1647    let identifier = data_type.identifier().ok_or_else(|| {
1648        anyhow::anyhow!(
1649            "{} subscriptions require an instrument ID identifier",
1650            data_type.type_name()
1651        )
1652    })?;
1653
1654    InstrumentId::from_str(identifier)
1655        .map_err(|e| anyhow::anyhow!("Invalid instrument ID identifier `{identifier}`: {e}"))
1656}
1657
1658fn ohlcv_schema(bar_type: BarType) -> anyhow::Result<dbn::Schema> {
1659    let spec = bar_type.spec();
1660    anyhow::ensure!(
1661        spec.price_type == PriceType::Last,
1662        "Unsupported bar price type for Databento OHLCV: {spec}, only LAST is emitted",
1663    );
1664    anyhow::ensure!(
1665        spec.step.get() == 1,
1666        "Unsupported bar step for Databento OHLCV: {spec}, only a step of 1 is available",
1667    );
1668
1669    ohlcv_schema_from_aggregation(spec.aggregation)
1670}
1671
1672fn schema_from_params(
1673    params: Option<&Params>,
1674    default_schema: dbn::Schema,
1675    allowed_schemas: &[dbn::Schema],
1676) -> anyhow::Result<dbn::Schema> {
1677    let schema = if let Some(schema) = params.and_then(|params| params.get_str(SCHEMA_PARAM)) {
1678        dbn::Schema::from_str(schema)?
1679    } else {
1680        default_schema
1681    };
1682
1683    if allowed_schemas.contains(&schema) {
1684        return Ok(schema);
1685    }
1686
1687    let allowed = allowed_schemas
1688        .iter()
1689        .map(dbn::Schema::as_str)
1690        .collect::<Vec<_>>()
1691        .join(", ");
1692    anyhow::bail!(
1693        "Invalid `{SCHEMA_PARAM}` '{}'. Must be one of: {allowed}",
1694        schema.as_str()
1695    );
1696}
1697
1698fn send_subscription_commands(
1699    tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1700    dataset: &str,
1701    price_precisions: impl IntoIterator<Item = (Symbol, u8)>,
1702    subscription: Subscription,
1703    start_after_subscribe: bool,
1704) -> anyhow::Result<()> {
1705    for (symbol, precision) in price_precisions {
1706        tx.send(HandlerCommand::SetPricePrecision(symbol, precision))
1707            .map_err(|e| anyhow::anyhow!("Failed to send command to dataset {dataset}: {e}"))?;
1708    }
1709
1710    tx.send(HandlerCommand::Subscribe(subscription))
1711        .map_err(|e| anyhow::anyhow!("Failed to send command to dataset {dataset}: {e}"))?;
1712
1713    if start_after_subscribe {
1714        tx.send(HandlerCommand::Start)
1715            .map_err(|e| anyhow::anyhow!("Failed to send command to dataset {dataset}: {e}"))?;
1716    }
1717
1718    Ok(())
1719}
1720
1721#[cfg(test)]
1722mod tests {
1723    use std::{num::NonZeroUsize, path::PathBuf};
1724
1725    use databento::dbn::{
1726        MappingInterval, SymbolMapping,
1727        decode::{DbnMetadata, DecodeRecord, dbn::Decoder},
1728        encode::EncodeRecord,
1729    };
1730    use nautilus_common::{
1731        live::runner::replace_data_event_sender,
1732        msgbus::{self, switchboard::get_custom_topic},
1733    };
1734    use nautilus_core::UUID4;
1735    use nautilus_model::{
1736        data::{BatchView, NautilusDataType, OrderBookDelta, QuoteTick},
1737        enums::BookType,
1738        identifiers::{ClientId, InstrumentId},
1739        instruments::{CurrencyPair, InstrumentAny},
1740        types::{Currency, Price, Quantity},
1741    };
1742    use rstest::rstest;
1743    use serde_json::json;
1744    use time::macros::date;
1745    use tokio::{
1746        io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
1747        net::TcpListener,
1748    };
1749
1750    use super::*;
1751    use crate::enums::{DatabentoStatisticType, DatabentoStatisticUpdateAction};
1752
1753    #[derive(Clone, Copy)]
1754    enum SubscribeKind {
1755        Quotes,
1756        Trades,
1757    }
1758
1759    fn currency_pair(instrument_id: &str) -> InstrumentAny {
1760        currency_pair_with_ts_init(instrument_id, UnixNanos::default())
1761    }
1762
1763    fn test_data_client() -> DatabentoDataClient {
1764        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
1765        replace_data_event_sender(sender);
1766
1767        let config = DatabentoDataClientConfig::new(
1768            "32-character-with-lots-of-filler",
1769            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json"),
1770            true,
1771            true,
1772        );
1773        DatabentoDataClient::new(
1774            ClientId::from("DATABENTO-TEST"),
1775            config,
1776            get_atomic_clock_realtime(),
1777        )
1778        .expect("test client should initialize")
1779    }
1780
1781    #[rstest]
1782    #[case::quotes(NautilusDataType::QuoteTick, true)]
1783    #[case::trades(NautilusDataType::TradeTick, true)]
1784    #[case::bars_close(NautilusDataType::Bar, true)]
1785    #[case::bars_open(NautilusDataType::Bar, false)]
1786    #[tokio::test]
1787    #[ignore = "Requires DATABENTO_API_KEY and GLBX.MDP3 historical access"]
1788    async fn test_historical_request_list_live(
1789        #[case] kind: NautilusDataType,
1790        #[case] timestamp_on_close: bool,
1791    ) {
1792        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1793        replace_data_event_sender(sender);
1794        let api_key = std::env::var("DATABENTO_API_KEY").unwrap();
1795
1796        let config = DatabentoDataClientConfig::new(
1797            api_key,
1798            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json"),
1799            true,
1800            timestamp_on_close,
1801        );
1802        let client_id = ClientId::from("DATABENTO-HISTORY");
1803        let client =
1804            DatabentoDataClient::new(client_id, config, get_atomic_clock_realtime()).unwrap();
1805        let mut ids = vec![
1806            InstrumentId::from("ESM4.GLBX"),
1807            InstrumentId::from("NQM4.GLBX"),
1808        ];
1809        let start = UnixNanos::from(1_717_423_200_000_000_000);
1810
1811        let end = if kind == NautilusDataType::Bar {
1812            ids.truncate(1);
1813            start
1814        } else {
1815            start + DurationNanos::from_secs(1)
1816        };
1817
1818        let request_id = UUID4::new();
1819        let params = Params::new([(PRICE_PRECISION_PARAM.to_owned(), json!(2))].into());
1820        let bar_type = "ESM4.GLBX-1-MINUTE-LAST-EXTERNAL"
1821            .parse::<BarType>()
1822            .unwrap();
1823
1824        match kind {
1825            NautilusDataType::QuoteTick => client
1826                .request_quotes(RequestQuotes::new(
1827                    ids.clone(),
1828                    Some(start.to_datetime_utc()),
1829                    Some(end.to_datetime_utc()),
1830                    None,
1831                    Some(client_id),
1832                    request_id,
1833                    UnixNanos::from(37),
1834                    Some(params.clone()),
1835                ))
1836                .unwrap(),
1837            NautilusDataType::TradeTick => client
1838                .request_trades(RequestTrades::new(
1839                    ids.clone(),
1840                    Some(start.to_datetime_utc()),
1841                    Some(end.to_datetime_utc()),
1842                    None,
1843                    Some(client_id),
1844                    request_id,
1845                    UnixNanos::from(37),
1846                    Some(params.clone()),
1847                ))
1848                .unwrap(),
1849            NautilusDataType::Bar => client
1850                .request_bars(RequestBars::new(
1851                    [bar_type],
1852                    Some(start.to_datetime_utc()),
1853                    Some(end.to_datetime_utc()),
1854                    None,
1855                    Some(client_id),
1856                    request_id,
1857                    UnixNanos::from(37),
1858                    Some(params.clone()),
1859                ))
1860                .unwrap(),
1861            _ => panic!("unexpected live request kind"),
1862        }
1863
1864        let event = tokio::time::timeout(Duration::from_secs(60), receiver.recv())
1865            .await
1866            .unwrap()
1867            .unwrap();
1868
1869        let DataEvent::ResponseEnvelope(envelope) = event else {
1870            panic!("expected successful historical envelope, received {event:?}");
1871        };
1872
1873        let response = envelope.response().unwrap();
1874
1875        let (response_ids, rows) = match (kind, response) {
1876            (NautilusDataType::QuoteTick, DataResponse::Quotes(response)) => (
1877                response.instrument_ids.clone(),
1878                response
1879                    .data
1880                    .iter()
1881                    .map(|row| (row.ts_init, row.instrument_id))
1882                    .collect::<Vec<_>>(),
1883            ),
1884            (NautilusDataType::TradeTick, DataResponse::Trades(response)) => (
1885                response.instrument_ids.clone(),
1886                response
1887                    .data
1888                    .iter()
1889                    .map(|row| (row.ts_init, row.instrument_id))
1890                    .collect::<Vec<_>>(),
1891            ),
1892            (NautilusDataType::Bar, DataResponse::Bars(response)) => {
1893                let expected_event = if timestamp_on_close {
1894                    start
1895                } else {
1896                    start - DurationNanos::from_mins(1)
1897                };
1898
1899                assert_eq!(response.bar_type, bar_type);
1900                assert_eq!(response.data.len(), 1);
1901                assert_eq!(response.data[0].bar_type, bar_type);
1902                assert_eq!(response.data[0].ts_init, start);
1903                assert_eq!(response.data[0].ts_event, expected_event);
1904                (
1905                    vec![response.bar_type.instrument_id()],
1906                    response
1907                        .data
1908                        .iter()
1909                        .map(|row| (row.ts_init, row.bar_type.instrument_id()))
1910                        .collect::<Vec<_>>(),
1911                )
1912            }
1913            _ => panic!("unexpected historical response variant"),
1914        };
1915
1916        let mut actual_ids = rows.iter().map(|(_, id)| *id).collect::<Vec<_>>();
1917        actual_ids.sort_unstable();
1918        actual_ids.dedup();
1919        let mut expected_ids = ids.clone();
1920        expected_ids.sort_unstable();
1921
1922        assert_eq!(envelope.correlation_id(), &request_id);
1923        assert!(envelope.completed());
1924        assert_eq!(envelope.sub_correlation_id(), None);
1925        assert_eq!(envelope.failure_reason(), None);
1926        assert_eq!(response.correlation_id(), &request_id);
1927        assert_eq!(response.client_id(), &client_id);
1928        assert_eq!(response.start(), Some(start));
1929        assert_eq!(response.end(), Some(end));
1930        assert_eq!(response.params(), Some(&params));
1931        assert_eq!(response_ids, ids);
1932        assert_eq!(actual_ids, expected_ids);
1933        assert!(rows.is_sorted());
1934        assert!(rows.iter().all(|(ts, _)| *ts >= start && *ts <= end));
1935        assert_eq!(
1936            receiver.try_recv().unwrap_err(),
1937            tokio::sync::mpsc::error::TryRecvError::Empty
1938        );
1939    }
1940
1941    #[rstest]
1942    #[case::raw("ESM4.GLBX", "NQM4.GLBX")]
1943    #[case::parent("ES.FUT.GLBX", "NQ.FUT.GLBX")]
1944    fn test_historical_request_groups_keep_dataset_and_input_order(
1945        #[case] first: &str,
1946        #[case] third: &str,
1947    ) {
1948        let client = test_data_client();
1949        let first = InstrumentId::from(first);
1950        let second = InstrumentId::from("AAPL.EQUS");
1951        let third = InstrumentId::from(third);
1952        let groups = client
1953            .group_request_instruments(&[first, second, third])
1954            .unwrap();
1955        assert_eq!(
1956            groups.into_iter().collect::<Vec<_>>(),
1957            vec![
1958                ("GLBX.MDP3".to_owned(), vec![first, third]),
1959                ("EQUS.MINI".to_owned(), vec![second])
1960            ]
1961        );
1962    }
1963
1964    #[rstest]
1965    #[case::instruments(NautilusDataType::Instrument)]
1966    #[case::quotes(NautilusDataType::QuoteTick)]
1967    #[case::trades(NautilusDataType::TradeTick)]
1968    #[tokio::test]
1969    async fn test_historical_requests_reject_mixed_symbology_before_dispatch(
1970        #[case] kind: NautilusDataType,
1971        #[values(false, true)] earlier_dataset: bool,
1972    ) {
1973        let client = test_data_client();
1974        let mut ids = Vec::new();
1975        if earlier_dataset {
1976            ids.push(InstrumentId::from("AAPL.EQUS"));
1977        }
1978
1979        ids.extend([
1980            InstrumentId::from("ES.FUT.GLBX"),
1981            InstrumentId::from("ESM4.GLBX"),
1982        ]);
1983        let request_id = UUID4::new();
1984
1985        let result = match kind {
1986            NautilusDataType::Instrument => client.request_instrument(RequestInstrument::new(
1987                ids,
1988                None,
1989                None,
1990                None,
1991                request_id,
1992                UnixNanos::from(37),
1993                None,
1994            )),
1995            NautilusDataType::QuoteTick => client.request_quotes(RequestQuotes::new(
1996                ids,
1997                None,
1998                None,
1999                None,
2000                None,
2001                request_id,
2002                UnixNanos::from(37),
2003                None,
2004            )),
2005            NautilusDataType::TradeTick => client.request_trades(RequestTrades::new(
2006                ids,
2007                None,
2008                None,
2009                None,
2010                None,
2011                request_id,
2012                UnixNanos::from(37),
2013                None,
2014            )),
2015            _ => panic!("unexpected historical request kind"),
2016        };
2017
2018        assert_eq!(
2019            result.unwrap_err().to_string(),
2020            "Inconsistent symbology types: 'parent' for ES.FUT vs 'raw_symbol' for ESM4"
2021        );
2022        assert!(client.task_handles.is_empty());
2023    }
2024
2025    #[rstest]
2026    #[case::quotes(true)]
2027    #[case::trades(false)]
2028    fn test_historical_request_lists_reject_limit_before_dispatch(#[case] quotes: bool) {
2029        let client = test_data_client();
2030        let ids = [
2031            InstrumentId::from("ESM4.GLBX"),
2032            InstrumentId::from("AAPL.EQUS"),
2033        ];
2034
2035        let result = if quotes {
2036            client.request_quotes(RequestQuotes::new(
2037                ids,
2038                None,
2039                None,
2040                NonZeroUsize::new(5),
2041                None,
2042                UUID4::new(),
2043                UnixNanos::from(37),
2044                None,
2045            ))
2046        } else {
2047            client.request_trades(RequestTrades::new(
2048                ids,
2049                None,
2050                None,
2051                NonZeroUsize::new(5),
2052                None,
2053                UUID4::new(),
2054                UnixNanos::from(37),
2055                None,
2056            ))
2057        };
2058
2059        assert_eq!(
2060            result.unwrap_err().to_string(),
2061            "A record limit is unsupported for multi-instrument requests"
2062        );
2063    }
2064
2065    #[rstest]
2066    fn test_historical_bars_reject_unsupported_aggregation_before_dispatch() {
2067        let client = test_data_client();
2068
2069        let request = RequestBars::new(
2070            BarType::from("ESM4.GLBX-100-TICK-LAST-EXTERNAL"),
2071            None,
2072            None,
2073            None,
2074            None,
2075            UUID4::new(),
2076            UnixNanos::from(37),
2077            None,
2078        );
2079        assert_eq!(
2080            client.request_bars(request).unwrap_err().to_string(),
2081            "Unsupported bar aggregation: Tick"
2082        );
2083        assert!(client.task_handles.is_empty());
2084    }
2085
2086    #[rstest]
2087    fn test_dataset_failure_preserves_successful_group_rows() {
2088        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
2089        let sender: EventSender<DataEvent> = sender.into();
2090        let root = UUID4::new();
2091        let first = InstrumentId::from("ESM6.GLBX");
2092        let second = InstrumentId::from("AAPL.EQUS");
2093
2094        let quote = QuoteTick {
2095            instrument_id: first,
2096            ts_init: UnixNanos::from(17),
2097            ..Default::default()
2098        };
2099
2100        let response = QuotesResponse::new(
2101            root,
2102            ClientId::from("HISTORY"),
2103            [first, second],
2104            vec![quote],
2105            Some(UnixNanos::from(11)),
2106            Some(UnixNanos::from(29)),
2107            UnixNanos::from(31),
2108            None,
2109        );
2110
2111        send_request_result(
2112            &sender,
2113            root,
2114            Some(DataResponse::Quotes(response)),
2115            &["Dataset EQUS: source unavailable".to_owned()],
2116        );
2117
2118        let DataEvent::RequestFailed(failure) = receiver.try_recv().unwrap() else {
2119            panic!("Expected failed dataset result");
2120        };
2121
2122        assert_eq!(failure.request_id, root);
2123        assert_eq!(failure.reason, "Dataset EQUS: source unavailable");
2124
2125        let Some(DataResponse::Quotes(response)) = failure.response else {
2126            panic!("Expected successful sibling rows");
2127        };
2128
2129        assert_eq!(response.correlation_id, root);
2130        assert_eq!(response.client_id, ClientId::from("HISTORY"));
2131        assert_eq!(response.instrument_ids, vec![first, second]);
2132        assert_eq!(response.data.as_ref(), &[quote]);
2133        assert_eq!(response.start, Some(UnixNanos::from(11)));
2134        assert_eq!(response.end, Some(UnixNanos::from(29)));
2135        assert_eq!(response.ts_init, UnixNanos::from(31));
2136        assert_eq!(response.params, None);
2137        assert_eq!(
2138            receiver.try_recv().unwrap_err(),
2139            tokio::sync::mpsc::error::TryRecvError::Empty
2140        );
2141    }
2142
2143    #[rstest]
2144    fn test_book_reply_completes_after_all_partitioned_payloads() {
2145        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
2146        let sender: EventSender<DataEvent> = sender.into();
2147        let root = UUID4::new();
2148        let first = InstrumentId::from("ESM6.GLBX");
2149        let second = InstrumentId::from("ESU6.GLBX");
2150        let a = OrderBookDelta::clear(first, 11, UnixNanos::from(13), UnixNanos::from(17));
2151        let b = OrderBookDelta::clear(second, 19, UnixNanos::from(23), UnixNanos::from(29));
2152        let c = OrderBookDelta::clear(first, 31, UnixNanos::from(37), UnixNanos::from(41));
2153
2154        let response = BookDeltasResponse::new(
2155            root,
2156            ClientId::from("HISTORY"),
2157            InstrumentId::from("ES.FUT.GLBX"),
2158            vec![a, b, c],
2159            None,
2160            None,
2161            UnixNanos::from(43),
2162            None,
2163        );
2164        send_book_deltas_response(&sender, response);
2165
2166        let DataEvent::ResponseEnvelope(left) = receiver.try_recv().unwrap() else {
2167            panic!("expected first envelope");
2168        };
2169
2170        let DataEvent::ResponseEnvelope(right) = receiver.try_recv().unwrap() else {
2171            panic!("expected final envelope");
2172        };
2173
2174        let Some(DataResponse::BookDeltas(left_payload)) = left.response() else {
2175            panic!("expected first book");
2176        };
2177
2178        let Some(DataResponse::BookDeltas(right_payload)) = right.response() else {
2179            panic!("expected second book");
2180        };
2181
2182        assert_eq!(
2183            (left.correlation_id(), right.correlation_id()),
2184            (&root, &root)
2185        );
2186        assert!(!left.completed());
2187        assert!(right.completed());
2188        assert_eq!(left_payload.instrument_id, first);
2189        assert_eq!(right_payload.instrument_id, second);
2190        assert_eq!(left_payload.data.as_ref(), &vec![a, c]);
2191        assert_eq!(right_payload.data.as_ref(), &vec![b]);
2192        assert_eq!(
2193            receiver.try_recv().unwrap_err(),
2194            tokio::sync::mpsc::error::TryRecvError::Empty
2195        );
2196    }
2197
2198    #[rstest]
2199    #[tokio::test]
2200    async fn test_stop_closes_task_admission_until_disconnect_drains() {
2201        let mut client = test_data_client();
2202
2203        client
2204            .task_handles
2205            .spawn(async { std::future::pending::<()>().await })
2206            .unwrap();
2207        client.is_connected.store(true, Ordering::Relaxed);
2208
2209        client.stop().unwrap();
2210
2211        assert!(!client.task_handles.is_open());
2212        assert!(client.is_disconnected());
2213
2214        client.disconnect().await.unwrap();
2215        assert!(client.task_handles.is_empty());
2216    }
2217
2218    #[rstest]
2219    #[case("EQUS", "EQUS.PLUS")] // overrides the apply_default EQUS -> EQUS.MINI mapping
2220    #[case("GLBX", "EQUS.MINI")] // overrides the apply_default GLBX -> GLBX.MDP3 mapping
2221    fn test_venue_dataset_map_overrides_default(#[case] venue: &str, #[case] dataset: &str) {
2222        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
2223        replace_data_event_sender(sender);
2224
2225        let mut config = DatabentoDataClientConfig::new(
2226            "32-character-with-lots-of-filler",
2227            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json"),
2228            true,
2229            true,
2230        );
2231        config.venue_dataset_map = IndexMap::from([(venue.to_string(), dataset.to_string())]);
2232
2233        let client = DatabentoDataClient::new(
2234            ClientId::from("DATABENTO-TEST"),
2235            config,
2236            get_atomic_clock_realtime(),
2237        )
2238        .expect("test client should initialize");
2239
2240        assert_eq!(
2241            client.get_dataset_for_venue(Venue::from(venue)).unwrap(),
2242            dataset
2243        );
2244
2245        // The override is targeted: an unrelated venue keeps its default.
2246        assert_eq!(
2247            client.get_dataset_for_venue(Venue::from("XCBO")).unwrap(),
2248            "OPRA.PILLAR"
2249        );
2250    }
2251
2252    #[rstest]
2253    fn test_config_base_url_overrides_default_to_none() {
2254        let config = DatabentoDataClientConfig::new(
2255            "32-character-with-lots-of-filler",
2256            PathBuf::from("test_publishers.json"),
2257            true,
2258            true,
2259        );
2260
2261        assert!(config.historical_base_url.is_none());
2262        assert!(config.live_gateway_addr.is_none());
2263    }
2264
2265    #[rstest]
2266    fn test_historical_base_url_override_invalid_errors() {
2267        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
2268        replace_data_event_sender(sender);
2269
2270        let mut config = DatabentoDataClientConfig::new(
2271            "32-character-with-lots-of-filler",
2272            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json"),
2273            true,
2274            true,
2275        );
2276        config.historical_base_url = Some("not a url".to_string());
2277
2278        let result = DatabentoDataClient::new(
2279            ClientId::from("DATABENTO-TEST"),
2280            config,
2281            get_atomic_clock_realtime(),
2282        );
2283
2284        assert!(result.is_err());
2285    }
2286
2287    fn subscribe_quotes_cmd(params: Option<Params>) -> SubscribeQuotes {
2288        SubscribeQuotes::new(
2289            InstrumentId::from("ESM4.GLBX"),
2290            Some(ClientId::from("DATABENTO-TEST")),
2291            None,
2292            UUID4::new(),
2293            UnixNanos::default(),
2294            None,
2295            params,
2296        )
2297    }
2298
2299    fn subscribe_trades_cmd(params: Option<Params>) -> SubscribeTrades {
2300        SubscribeTrades::new(
2301            InstrumentId::from("ESM4.GLBX"),
2302            Some(ClientId::from("DATABENTO-TEST")),
2303            None,
2304            UUID4::new(),
2305            UnixNanos::default(),
2306            None,
2307            params,
2308        )
2309    }
2310
2311    fn subscribe_book_depth_cmd(depth: Option<NonZeroUsize>) -> SubscribeBookDepth {
2312        SubscribeBookDepth::new(
2313            InstrumentId::from("ESM4.GLBX"),
2314            BookType::L2_MBP,
2315            Some(ClientId::from("DATABENTO-TEST")),
2316            None,
2317            UUID4::new(),
2318            UnixNanos::default(),
2319            depth,
2320            true,
2321            None,
2322            None,
2323        )
2324    }
2325
2326    fn currency_pair_with_ts_init(instrument_id: &str, ts_init: UnixNanos) -> InstrumentAny {
2327        let instrument_id = InstrumentId::from(instrument_id);
2328        InstrumentAny::CurrencyPair(
2329            CurrencyPair::builder()
2330                .instrument_id(instrument_id)
2331                .raw_symbol(instrument_id.symbol)
2332                .base_currency(Currency::from("BTC"))
2333                .quote_currency(Currency::from("USDT"))
2334                .price_precision(2)
2335                .size_precision(6)
2336                .price_increment(Price::from("0.01"))
2337                .size_increment(Quantity::from("0.000001"))
2338                .ts_event(UnixNanos::default())
2339                .ts_init(ts_init)
2340                .build()
2341                .unwrap(),
2342        )
2343    }
2344
2345    #[rstest]
2346    fn test_instruments_query_params_requests_all_symbols() {
2347        let start = UnixNanos::from(1_000_000_000);
2348        let end = UnixNanos::from(2_000_000_000);
2349
2350        let params = instruments_query_params("GLBX.MDP3".to_string(), start, Some(end));
2351
2352        assert_eq!(params.dataset, "GLBX.MDP3");
2353        assert_eq!(params.symbols, vec!["ALL_SYMBOLS"]);
2354        assert_eq!(params.start, start);
2355        assert_eq!(params.end, Some(end));
2356        assert_eq!(params.limit, None);
2357        assert_eq!(params.price_precision, None);
2358    }
2359
2360    #[rstest]
2361    fn test_instrument_query_params_requests_single_symbol() {
2362        let instrument_id = InstrumentId::from("ESM4.GLBX");
2363
2364        let start = UnixNanos::from(1_000_000_000);
2365        let end = UnixNanos::from(2_000_000_000);
2366
2367        let params =
2368            instrument_query_params("GLBX.MDP3".to_string(), instrument_id, start, Some(end));
2369
2370        assert_eq!(params.dataset, "GLBX.MDP3");
2371        assert_eq!(params.symbols, vec!["ESM4"]);
2372        assert_eq!(params.start, start);
2373        assert_eq!(params.end, Some(end));
2374        assert_eq!(params.limit, None);
2375        assert_eq!(params.price_precision, None);
2376    }
2377
2378    #[rstest]
2379    #[case::intraday(1_782_302_400_000_000_001)]
2380    #[case::midnight(1_782_259_200_000_000_000)]
2381    #[case::day_end(1_782_345_599_999_999_999)]
2382    #[tokio::test]
2383    async fn test_seed_price_precision_includes_midnight(#[case] start: u64) {
2384        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2385            .join("test_data/test_data.definition.futures_contract.dbn.zst");
2386        let mut decoder = Decoder::from_zstd_file(path).unwrap();
2387        let mut metadata = decoder.metadata().clone();
2388        let record = decoder
2389            .decode_record::<dbn::InstrumentDefMsg>()
2390            .unwrap()
2391            .unwrap();
2392        metadata.symbols = vec!["ESU6".to_string()];
2393
2394        metadata.mappings = vec![SymbolMapping {
2395            raw_symbol: "ESU6".to_string(),
2396            intervals: vec![MappingInterval {
2397                start_date: date!(2026 - 06 - 24),
2398                end_date: date!(2026 - 06 - 25),
2399                symbol: record.hd.instrument_id.to_string(),
2400            }],
2401        }];
2402
2403        let mut body = Vec::new();
2404        {
2405            let mut encoder = dbn::encode::dbn::Encoder::with_zstd(&mut body, &metadata).unwrap();
2406            encoder.encode_record(record).unwrap();
2407            encoder.flush().unwrap();
2408        }
2409
2410        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2411        let base_url = format!("http://{}/", listener.local_addr().unwrap());
2412
2413        let server = tokio::spawn(serve_historical_response(listener, body));
2414
2415        let client = DatabentoHistoricalClient::new_with_base_url(
2416            Credential::new("32-character-with-lots-of-filler"),
2417            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json"),
2418            get_atomic_clock_realtime(),
2419            false,
2420            &base_url,
2421        )
2422        .unwrap();
2423        let instrument_id = InstrumentId::from("ESU6.GLBX");
2424        let end = UnixNanos::from(1_782_345_600_000_000_123);
2425
2426        tokio::time::timeout(
2427            std::time::Duration::from_secs(5),
2428            seed_price_precision_if_needed(
2429                &client,
2430                "GLBX.MDP3",
2431                instrument_id,
2432                start.into(),
2433                Some(end),
2434                None,
2435            ),
2436        )
2437        .await
2438        .unwrap();
2439        let request = server.await.unwrap();
2440        let fields: AHashMap<_, _> = request
2441            .split('&')
2442            .map(|field| field.split_once('=').unwrap())
2443            .collect();
2444
2445        assert_eq!(fields["dataset"], "GLBX.MDP3");
2446        assert_eq!(fields["schema"], "definition");
2447        assert_eq!(fields["symbols"], "ESU6");
2448        assert_eq!(fields["start"], "1782259200000000000");
2449        assert_eq!(fields["end"], end.to_string());
2450        assert_eq!(client.price_precision(instrument_id.symbol), Some(2));
2451    }
2452
2453    async fn serve_historical_response(listener: TcpListener, body: Vec<u8>) -> String {
2454        let (stream, _) = listener.accept().await.unwrap();
2455        let mut stream = BufReader::new(stream);
2456        let mut line = String::new();
2457        stream.read_line(&mut line).await.unwrap();
2458        assert_eq!(line, "POST /v0/timeseries.get_range HTTP/1.1\r\n");
2459        let mut content_length = None;
2460
2461        loop {
2462            line.clear();
2463            stream.read_line(&mut line).await.unwrap();
2464
2465            if line == "\r\n" {
2466                break;
2467            }
2468
2469            if let Some((name, value)) = line.split_once(':')
2470                && name.eq_ignore_ascii_case("content-length")
2471            {
2472                content_length = Some(value.trim().parse::<usize>().unwrap());
2473            }
2474        }
2475
2476        let mut request = vec![0; content_length.unwrap()];
2477        stream.read_exact(&mut request).await.unwrap();
2478        let headers = format!(
2479            "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2480            body.len(),
2481        );
2482        stream.write_all(headers.as_bytes()).await.unwrap();
2483        stream.write_all(&body).await.unwrap();
2484        stream.shutdown().await.unwrap();
2485        String::from_utf8(request).unwrap()
2486    }
2487
2488    #[rstest]
2489    #[case::explicit(Some(5), None)]
2490    #[case::cached(None, Some(3))]
2491    #[tokio::test]
2492    async fn test_seed_price_precision_skips_known_precision(
2493        #[case] price_precision: Option<u8>,
2494        #[case] cached_precision: Option<u8>,
2495    ) {
2496        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2497        let base_url = format!("http://{}/", listener.local_addr().unwrap());
2498
2499        let client = DatabentoHistoricalClient::new_with_base_url(
2500            Credential::new("32-character-with-lots-of-filler"),
2501            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json"),
2502            get_atomic_clock_realtime(),
2503            false,
2504            &base_url,
2505        )
2506        .unwrap();
2507        let instrument_id = InstrumentId::from("ESU6.GLBX");
2508        if let Some(precision) = cached_precision {
2509            client.set_price_precision(instrument_id.symbol, precision);
2510        }
2511
2512        tokio::select! {
2513            () = seed_price_precision_if_needed(
2514                &client,
2515                "GLBX.MDP3",
2516                instrument_id,
2517                1_706_443_200_000_000_001.into(),
2518                Some(1_706_486_400_000_000_123.into()),
2519                price_precision,
2520            ) => {}
2521            result = listener.accept() => panic!("Unexpected definition request: {result:?}"),
2522            () = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
2523                panic!("Precision seeding did not finish");
2524            }
2525        }
2526
2527        assert_eq!(
2528            client.price_precision(instrument_id.symbol),
2529            cached_precision
2530        );
2531    }
2532
2533    #[rstest]
2534    fn test_resolve_request_time_range_defaults_to_end_day() {
2535        let end = UnixNanos::from(1_706_443_200_000_000_001);
2536
2537        let (start, resolved_end) = resolve_request_time_range(None, Some(end));
2538
2539        assert_eq!(start, UnixNanos::from(1_706_400_000_000_000_000));
2540        assert_eq!(resolved_end, Some(end + DurationNanos::new(1)));
2541    }
2542
2543    #[rstest]
2544    #[case::range(17, 23, 17, 24)]
2545    #[case::point(17, 17, 17, 18)]
2546    #[case::zero(0, 0, 0, 1)]
2547    #[case::inverted(23, 17, 17, 18)]
2548    fn test_resolve_request_time_range_converts_inclusive_bounds(
2549        #[case] start: u64,
2550        #[case] end: u64,
2551        #[case] expected_start: u64,
2552        #[case] expected_end: u64,
2553    ) {
2554        let (start, end) = resolve_request_time_range(Some(start.into()), Some(end.into()));
2555
2556        assert_eq!(start, UnixNanos::from(expected_start));
2557        assert_eq!(end, Some(UnixNanos::from(expected_end)));
2558    }
2559
2560    #[rstest]
2561    #[case::quotes(false, true, false)]
2562    #[case::bars_close(true, true, false)]
2563    #[case::bars_open(true, false, false)]
2564    #[case::bars_default_start(true, true, true)]
2565    #[tokio::test]
2566    async fn test_historical_request_sends_inclusive_bounds(
2567        #[case] bars: bool,
2568        #[case] timestamp_on_close: bool,
2569        #[case] missing_start: bool,
2570    ) {
2571        let filename = if bars {
2572            "test_data.ohlcv-1m.dbn.zst"
2573        } else {
2574            "test_data.mbp-1.dbn.zst"
2575        };
2576
2577        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2578            .join("test_data")
2579            .join(filename);
2580        let decoder = Decoder::from_zstd_file(path).unwrap();
2581        let mut metadata = decoder.metadata().clone();
2582        metadata.dataset = "GLBX.MDP3".to_owned();
2583        let mut body = Vec::new();
2584        {
2585            let mut encoder = dbn::encode::dbn::Encoder::with_zstd(&mut body, &metadata).unwrap();
2586            encoder.flush().unwrap();
2587        }
2588
2589        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2590        let base_url = format!("http://{}/", listener.local_addr().unwrap());
2591
2592        let server = tokio::spawn(serve_historical_response(listener, body));
2593        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
2594        replace_data_event_sender(sender);
2595
2596        let mut config = DatabentoDataClientConfig::new(
2597            "32-character-with-lots-of-filler",
2598            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json"),
2599            true,
2600            timestamp_on_close,
2601        );
2602        config.historical_base_url = Some(base_url);
2603        let client_id = ClientId::from("DATABENTO-BOUNDS");
2604        let client =
2605            DatabentoDataClient::new(client_id, config, get_atomic_clock_realtime()).unwrap();
2606        let id = InstrumentId::from("ESM4.GLBX");
2607        let start = UnixNanos::from(1_717_423_200_000_000_000);
2608        let request_start = (!missing_start).then_some(start);
2609        let end = start + DurationNanos::from_secs(60) + DurationNanos::new(123);
2610        let request_id = UUID4::new();
2611        let params = Params::new([(PRICE_PRECISION_PARAM.to_owned(), json!(2))].into());
2612
2613        if bars {
2614            let bar_type = "ESM4.GLBX-1-MINUTE-LAST-EXTERNAL"
2615                .parse::<BarType>()
2616                .unwrap();
2617            client
2618                .request_bars(RequestBars::new(
2619                    bar_type,
2620                    request_start.map(|timestamp| timestamp.to_datetime_utc()),
2621                    Some(end.to_datetime_utc()),
2622                    None,
2623                    Some(client_id),
2624                    request_id,
2625                    UnixNanos::from(37),
2626                    Some(params.clone()),
2627                ))
2628                .unwrap();
2629        } else {
2630            client
2631                .request_quotes(RequestQuotes::new(
2632                    id,
2633                    request_start.map(|timestamp| timestamp.to_datetime_utc()),
2634                    Some(end.to_datetime_utc()),
2635                    None,
2636                    Some(client_id),
2637                    request_id,
2638                    UnixNanos::from(37),
2639                    Some(params.clone()),
2640                ))
2641                .unwrap();
2642        }
2643
2644        let event = tokio::time::timeout(Duration::from_secs(5), receiver.recv())
2645            .await
2646            .unwrap()
2647            .unwrap();
2648
2649        let DataEvent::ResponseEnvelope(envelope) = event else {
2650            panic!("expected completed response, received {event:?}");
2651        };
2652
2653        let response = envelope.response().unwrap();
2654        let request = server.await.unwrap();
2655        let fields: AHashMap<_, _> = request
2656            .split('&')
2657            .map(|field| field.split_once('=').unwrap())
2658            .collect();
2659
2660        let offset = if bars {
2661            DurationNanos::from_secs(60)
2662        } else {
2663            DurationNanos::new(0)
2664        };
2665
2666        let expected_start = if missing_start {
2667            UnixNanos::from(1_717_372_800_000_000_000)
2668        } else {
2669            start
2670        };
2671
2672        assert_eq!(fields["dataset"], "GLBX.MDP3");
2673        assert_eq!(fields["schema"], if bars { "ohlcv-1m" } else { "mbp-1" });
2674        assert_eq!(fields["symbols"], "ESM4");
2675        assert_eq!(fields["start"], (expected_start - offset).to_string());
2676        assert_eq!(
2677            fields["end"],
2678            (end - offset + DurationNanos::new(1)).to_string()
2679        );
2680        assert_eq!(envelope.correlation_id(), &request_id);
2681        assert!(envelope.completed());
2682        assert_eq!(envelope.failure_reason(), None);
2683        assert_eq!(response.start(), request_start);
2684        assert_eq!(response.end(), Some(end));
2685        assert_eq!(response.client_id(), &client_id);
2686        assert_eq!(response.params(), Some(&params));
2687        assert_eq!(response.record_count(), Some(0));
2688    }
2689
2690    #[rstest]
2691    fn test_requested_instrument_filters_exact_id() {
2692        let requested_id = InstrumentId::from("BTCUSDT.BINANCE");
2693        let instruments = vec![
2694            currency_pair("ETHUSDT.BINANCE"),
2695            currency_pair("BTCUSDT.BINANCE"),
2696        ];
2697
2698        let instrument = requested_instrument(&instruments, requested_id).expect("instrument");
2699
2700        assert_eq!(instrument.id(), requested_id);
2701    }
2702
2703    #[rstest]
2704    fn test_requested_instrument_returns_latest_matching_id() {
2705        let requested_id = InstrumentId::from("BTCUSDT.BINANCE");
2706        let instruments = vec![
2707            currency_pair_with_ts_init("BTCUSDT.BINANCE", UnixNanos::from(1)),
2708            currency_pair_with_ts_init("BTCUSDT.BINANCE", UnixNanos::from(2)),
2709        ];
2710
2711        let instrument = requested_instrument(&instruments, requested_id).expect("instrument");
2712
2713        assert_eq!(instrument.ts_init(), UnixNanos::from(2));
2714    }
2715
2716    #[rstest]
2717    fn test_requested_instrument_returns_none_on_miss() {
2718        let instruments = vec![currency_pair("ETHUSDT.BINANCE")];
2719
2720        let instrument = requested_instrument(&instruments, InstrumentId::from("BTCUSDT.BINANCE"));
2721
2722        assert!(instrument.is_none());
2723    }
2724
2725    #[rstest]
2726    fn test_price_precision_from_params() {
2727        let params = Params::new([(PRICE_PRECISION_PARAM.to_string(), json!(5))].into());
2728
2729        let price_precision = price_precision_from_params(Some(&params)).unwrap();
2730
2731        assert_eq!(price_precision, Some(5));
2732    }
2733
2734    #[rstest]
2735    fn test_price_precision_from_params_rejects_out_of_range_value() {
2736        let params = Params::new(
2737            [(
2738                PRICE_PRECISION_PARAM.to_string(),
2739                json!(u64::from(u8::MAX) + 1),
2740            )]
2741            .into(),
2742        );
2743
2744        let result = price_precision_from_params(Some(&params));
2745
2746        assert!(result.is_err());
2747    }
2748
2749    #[rstest]
2750    fn test_partition_book_deltas_response_by_child_instrument(
2751        #[values(false, true)] narrowed: bool,
2752    ) {
2753        let correlation_id = UUID4::new();
2754        let client_id = ClientId::from("DATABENTO-TEST");
2755        let parent = InstrumentId::from("ES.FUT.GLBX");
2756        let child_a = InstrumentId::from("ESM6.GLBX");
2757        let child_b = InstrumentId::from("ESU6.GLBX");
2758        let deltas = vec![
2759            OrderBookDelta::clear(child_a, 1, UnixNanos::from(1_000), UnixNanos::from(1_000)),
2760            OrderBookDelta::clear(child_b, 2, UnixNanos::from(2_000), UnixNanos::from(2_000)),
2761            OrderBookDelta::clear(child_a, 3, UnixNanos::from(3_000), UnixNanos::from(3_000)),
2762        ];
2763        let params = Params::new([(PRICE_PRECISION_PARAM.to_string(), json!(5))].into());
2764
2765        let rows = if narrowed {
2766            let hidden = OrderBookDelta::clear(
2767                InstrumentId::from("HIDDEN.GLBX"),
2768                4,
2769                UnixNanos::from(5_000),
2770                UnixNanos::from(5_000),
2771            );
2772            let mut backing = vec![hidden];
2773            backing.extend_from_slice(&deltas);
2774            backing.push(hidden);
2775            BatchView::new(Arc::new(backing), 1..4)
2776        } else {
2777            deltas.clone().into()
2778        };
2779
2780        let response = BookDeltasResponse::new(
2781            correlation_id,
2782            client_id,
2783            parent,
2784            rows,
2785            Some(UnixNanos::from(500)),
2786            Some(UnixNanos::from(4_000)),
2787            UnixNanos::from(5_000),
2788            Some(params.clone()),
2789        );
2790
2791        let responses = partition_book_deltas_response(response);
2792
2793        assert_eq!(responses.len(), 2);
2794        assert_eq!(responses[0].correlation_id, correlation_id);
2795        assert_eq!(responses[1].correlation_id, correlation_id);
2796        assert_eq!(responses[0].client_id, client_id);
2797        assert_eq!(responses[1].client_id, client_id);
2798        assert_eq!(responses[0].instrument_id, child_a);
2799        assert_eq!(responses[1].instrument_id, child_b);
2800        assert_eq!(responses[0].data.as_ref(), &vec![deltas[0], deltas[2]]);
2801        assert_eq!(responses[1].data.as_ref(), &vec![deltas[1]]);
2802        assert_eq!(responses[0].start, Some(UnixNanos::from(500)));
2803        assert_eq!(responses[1].start, Some(UnixNanos::from(500)));
2804        assert_eq!(responses[0].end, Some(UnixNanos::from(4_000)));
2805        assert_eq!(responses[1].end, Some(UnixNanos::from(4_000)));
2806        assert_eq!(responses[0].ts_init, UnixNanos::from(5_000));
2807        assert_eq!(responses[1].ts_init, UnixNanos::from(5_000));
2808        assert_eq!(responses[0].params, Some(params.clone()));
2809        assert_eq!(responses[1].params, Some(params));
2810    }
2811
2812    #[rstest]
2813    fn test_partition_book_deltas_response_preserves_homogeneous_response() {
2814        let correlation_id = UUID4::new();
2815        let client_id = ClientId::from("DATABENTO-TEST");
2816        let instrument_id = InstrumentId::from("ESM6.GLBX");
2817        let delta = OrderBookDelta::clear(
2818            instrument_id,
2819            1,
2820            UnixNanos::from(1_000),
2821            UnixNanos::from(1_000),
2822        );
2823        let response = BookDeltasResponse::new(
2824            correlation_id,
2825            client_id,
2826            instrument_id,
2827            vec![delta],
2828            Some(UnixNanos::from(500)),
2829            Some(UnixNanos::from(1_500)),
2830            UnixNanos::from(2_000),
2831            None,
2832        );
2833
2834        let responses = partition_book_deltas_response(response);
2835
2836        assert_eq!(responses.len(), 1);
2837        assert_eq!(responses[0].correlation_id, correlation_id);
2838        assert_eq!(responses[0].client_id, client_id);
2839        assert_eq!(responses[0].instrument_id, instrument_id);
2840        assert_eq!(responses[0].data.as_ref(), &vec![delta]);
2841        assert_eq!(responses[0].start, Some(UnixNanos::from(500)));
2842        assert_eq!(responses[0].end, Some(UnixNanos::from(1_500)));
2843        assert_eq!(responses[0].ts_init, UnixNanos::from(2_000));
2844        assert_eq!(responses[0].params, None);
2845    }
2846
2847    #[rstest]
2848    fn test_partition_book_deltas_response_preserves_empty_parent_response() {
2849        let correlation_id = UUID4::new();
2850        let parent = InstrumentId::from("ES.FUT.GLBX");
2851        let response = BookDeltasResponse::new(
2852            correlation_id,
2853            ClientId::from("DATABENTO-TEST"),
2854            parent,
2855            Vec::new(),
2856            None,
2857            None,
2858            UnixNanos::from(1_000),
2859            None,
2860        );
2861
2862        let responses = partition_book_deltas_response(response);
2863
2864        assert_eq!(responses.len(), 1);
2865        let response = &responses[0];
2866        assert_eq!(response.correlation_id, correlation_id);
2867        assert_eq!(response.instrument_id, parent);
2868        assert!(response.data.is_empty());
2869    }
2870
2871    #[rstest]
2872    fn test_schema_from_params_returns_default() {
2873        let schema = schema_from_params(None, dbn::Schema::Mbp1, QUOTE_SCHEMAS).unwrap();
2874
2875        assert_eq!(schema, dbn::Schema::Mbp1);
2876    }
2877
2878    #[rstest]
2879    fn test_schema_from_params_accepts_allowed_value() {
2880        let params = Params::new([(SCHEMA_PARAM.to_string(), json!("tbbo"))].into());
2881
2882        let schema = schema_from_params(Some(&params), dbn::Schema::Mbp1, QUOTE_SCHEMAS).unwrap();
2883
2884        assert_eq!(schema, dbn::Schema::Tbbo);
2885    }
2886
2887    #[rstest]
2888    fn test_schema_from_params_rejects_disallowed_value() {
2889        let params = Params::new([(SCHEMA_PARAM.to_string(), json!("mbo"))].into());
2890
2891        let result = schema_from_params(Some(&params), dbn::Schema::Mbp1, QUOTE_SCHEMAS);
2892
2893        assert!(result.is_err());
2894    }
2895
2896    #[rstest]
2897    #[case::quotes(SubscribeKind::Quotes)]
2898    #[case::trades(SubscribeKind::Trades)]
2899    fn test_invalid_subscribe_params_do_not_create_feed_handler(#[case] kind: SubscribeKind) {
2900        let mut client = test_data_client();
2901        let params = Params::new([(SCHEMA_PARAM.to_string(), json!("definition"))].into());
2902        let result = match kind {
2903            SubscribeKind::Quotes => client.subscribe_quotes(subscribe_quotes_cmd(Some(params))),
2904            SubscribeKind::Trades => client.subscribe_trades(subscribe_trades_cmd(Some(params))),
2905        };
2906
2907        assert!(result.is_err());
2908        assert!(client.cmd_channels.lock().is_empty());
2909    }
2910
2911    #[rstest]
2912    fn test_bar_list_reports_exact_full_types_and_continues_after_invalid_member() {
2913        let mut client = test_data_client();
2914        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2915        client
2916            .cmd_channels
2917            .lock()
2918            .insert("GLBX.MDP3".to_string(), tx);
2919        let first = BarType::from("ESM4.GLBX-1-SECOND-LAST-EXTERNAL");
2920        let invalid = BarType::from("ESM4.GLBX-5-MINUTE-LAST-EXTERNAL");
2921        let last = BarType::from("ESM4.GLBX-1-MINUTE-LAST-EXTERNAL");
2922
2923        let command = SubscribeBars::new(
2924            [first, invalid, last],
2925            Some(client.client_id),
2926            None,
2927            UUID4::new(),
2928            UnixNanos::from(17),
2929            Some(UUID4::new()),
2930            None,
2931        );
2932        let error = client.subscribe_bars(command).unwrap_err();
2933        let partial = error.downcast_ref::<SubscriptionError>().unwrap();
2934        assert_eq!(partial.bar_types_accepted, vec![first, last]);
2935        assert_eq!(partial.instrument_ids_accepted, Vec::<InstrumentId>::new());
2936
2937        for schema in [dbn::Schema::Ohlcv1S, dbn::Schema::Ohlcv1M] {
2938            let HandlerCommand::Subscribe(actual) = rx.try_recv().unwrap() else {
2939                panic!("expected bar schema subscription");
2940            };
2941
2942            let expected = Subscription::builder()
2943                .schema(schema)
2944                .symbols("ESM4")
2945                .build();
2946            assert_eq!(actual, expected);
2947        }
2948
2949        assert_eq!(
2950            rx.try_recv().unwrap_err(),
2951            tokio::sync::mpsc::error::TryRecvError::Empty
2952        );
2953    }
2954
2955    macro_rules! batch_tests {
2956        ($batch_test:ident, $failure_test:ident, $validation_test:ident, $command:ident, $subscribe:ident, $schema:ident) => {
2957            #[rstest]
2958            fn $batch_test() {
2959                let mut client = test_data_client();
2960                let (glbx_tx, mut glbx_rx) = tokio::sync::mpsc::unbounded_channel();
2961                let (opra_tx, mut opra_rx) = tokio::sync::mpsc::unbounded_channel();
2962                client
2963                    .cmd_channels
2964                    .lock()
2965                    .insert("GLBX.MDP3".to_string(), glbx_tx);
2966                client
2967                    .cmd_channels
2968                    .lock()
2969                    .insert("OPRA.PILLAR".to_string(), opra_tx);
2970                let first = InstrumentId::from("ESM4.GLBX");
2971                let second = InstrumentId::from("NQM4.GLBX");
2972                let third = InstrumentId::from("SPY_OPTION.XCBO");
2973                let params = Params::new([(PRICE_PRECISION_PARAM.to_string(), json!(5))].into());
2974                let command = $command(Some(params))
2975                    .with_instrument_ids([second, third, first, second]);
2976                let original = serde_json::to_value(&command).unwrap();
2977                client.$subscribe(command.clone()).unwrap();
2978                assert!(
2979                    matches!(glbx_rx.try_recv().unwrap(), HandlerCommand::SetPricePrecision(symbol, 5) if symbol == second.symbol)
2980                );
2981                assert!(
2982                    matches!(glbx_rx.try_recv().unwrap(), HandlerCommand::SetPricePrecision(symbol, 5) if symbol == first.symbol)
2983                );
2984
2985                let HandlerCommand::Subscribe(glbx_subscription) = glbx_rx.try_recv().unwrap()
2986                else {
2987                    panic!("expected GLBX subscription")
2988                };
2989
2990                assert!(
2991                    matches!(opra_rx.try_recv().unwrap(), HandlerCommand::SetPricePrecision(symbol, 5) if symbol == third.symbol)
2992                );
2993
2994                let HandlerCommand::Subscribe(opra_subscription) = opra_rx.try_recv().unwrap()
2995                else {
2996                    panic!("expected OPRA subscription")
2997                };
2998
2999                let expected_glbx = Subscription::builder()
3000                    .schema(dbn::Schema::$schema)
3001                    .symbols(vec![second.symbol.to_string(), first.symbol.to_string()])
3002                    .build();
3003                let expected_opra = Subscription::builder()
3004                    .schema(dbn::Schema::$schema)
3005                    .symbols(vec![third.symbol.to_string()])
3006                    .build();
3007                assert_eq!(glbx_subscription, expected_glbx);
3008                assert_eq!(opra_subscription, expected_opra);
3009                assert_eq!(
3010                    glbx_rx.try_recv().unwrap_err(),
3011                    tokio::sync::mpsc::error::TryRecvError::Empty
3012                );
3013                assert_eq!(
3014                    opra_rx.try_recv().unwrap_err(),
3015                    tokio::sync::mpsc::error::TryRecvError::Empty
3016                );
3017                assert_eq!(client.symbol_venue_map.len(), 3);
3018
3019                for id in [first, second, third] {
3020                    assert_eq!(
3021                        client.symbol_venue_map.get_cloned(&id.symbol),
3022                        Some(id.venue)
3023                    );
3024                }
3025
3026                assert_eq!(serde_json::to_value(command).unwrap(), original);
3027            }
3028
3029            #[rstest]
3030            #[case::failure_first(true)]
3031            #[case::failure_middle(false)]
3032            fn $failure_test(
3033                #[case] failure_first: bool,
3034                #[values(false, true)] previous_mapping: bool,
3035            ) {
3036                let mut client = test_data_client();
3037                let (glbx_tx, mut glbx_rx) = tokio::sync::mpsc::unbounded_channel();
3038                let (opra_tx, opra_rx) = tokio::sync::mpsc::unbounded_channel();
3039                let (equs_tx, mut equs_rx) = tokio::sync::mpsc::unbounded_channel();
3040                drop(opra_rx);
3041                client
3042                    .cmd_channels
3043                    .lock()
3044                    .insert("GLBX.MDP3".to_string(), glbx_tx);
3045                client
3046                    .cmd_channels
3047                    .lock()
3048                    .insert("OPRA.PILLAR".to_string(), opra_tx);
3049                client
3050                    .cmd_channels
3051                    .lock()
3052                    .insert("EQUS.MINI".to_string(), equs_tx);
3053                let first = InstrumentId::from("ESM4.GLBX");
3054                let second = InstrumentId::from("SPY_OPTION.XCBO");
3055                let third = InstrumentId::from("AAPL.EQUS");
3056                let previous = previous_mapping.then_some(Venue::from("PREVIOUS"));
3057                if let Some(venue) = previous {
3058                    client.symbol_venue_map.insert(second.symbol, venue);
3059                }
3060                let ids = if failure_first {
3061                    [second, first, third]
3062                } else {
3063                    [first, second, third]
3064                };
3065                let command = $command(None).with_instrument_ids(ids);
3066                let result = client.$subscribe(command);
3067                let error = result.unwrap_err();
3068                let partial = error
3069                    .downcast_ref::<SubscriptionError>()
3070                    .expect("partial subscription error");
3071
3072                assert_eq!(partial.instrument_ids_accepted, vec![first, third]);
3073                assert_eq!(partial.bar_types_accepted, Vec::<BarType>::new());
3074                assert_eq!(
3075                    partial.source.to_string(),
3076                    "Failed to send command to dataset OPRA.PILLAR: channel closed"
3077                );
3078                assert_eq!(client.symbol_venue_map.get_cloned(&second.symbol), previous);
3079
3080                for (id, receiver) in [(first, &mut glbx_rx), (third, &mut equs_rx)] {
3081                    let HandlerCommand::Subscribe(accepted) = receiver.try_recv().unwrap() else {
3082                        panic!("expected accepted subscription");
3083                    };
3084                    let expected = Subscription::builder()
3085                        .schema(dbn::Schema::$schema)
3086                        .symbols(vec![id.symbol.to_string()])
3087                        .build();
3088                    assert_eq!(accepted, expected);
3089                    assert_eq!(client.symbol_venue_map.get_cloned(&id.symbol), Some(id.venue));
3090                    assert_eq!(
3091                        receiver.try_recv().unwrap_err(),
3092                        tokio::sync::mpsc::error::TryRecvError::Empty
3093                    );
3094                }
3095            }
3096
3097            #[rstest]
3098            #[case::unknown_dataset("UNKNOWN.UNKNOWN", "No dataset found for venue: UNKNOWN")]
3099            #[case::ambiguous_symbol(
3100                "ESM4.XCBO",
3101                "Identifiers share raw symbol ESM4 across different venues"
3102            )]
3103            fn $validation_test(#[case] invalid_id: &str, #[case] error: &str) {
3104                let mut client = test_data_client();
3105                let command = $command(None).with_instrument_ids([
3106                    InstrumentId::from("ESM4.GLBX"),
3107                    InstrumentId::from(invalid_id),
3108                ]);
3109                let result = client.$subscribe(command);
3110                assert_eq!(result.unwrap_err().to_string(), error);
3111                assert!(client.cmd_channels.lock().is_empty());
3112                assert!(client.symbol_venue_map.is_empty());
3113            }
3114        };
3115    }
3116
3117    batch_tests!(
3118        test_quote_batches_send_one_subscription_per_dataset,
3119        test_quote_batch_enqueue_failure_can_follow_accepted_dataset,
3120        test_quote_batches_validate_all_members_before_dispatch,
3121        subscribe_quotes_cmd,
3122        subscribe_quotes,
3123        Mbp1
3124    );
3125
3126    batch_tests!(
3127        test_trade_batches_send_one_subscription_per_dataset,
3128        test_trade_batch_enqueue_failure_can_follow_accepted_dataset,
3129        test_trade_batches_validate_all_members_before_dispatch,
3130        subscribe_trades_cmd,
3131        subscribe_trades,
3132        Trades
3133    );
3134
3135    #[rstest]
3136    fn test_trade_batch_unsubscribe_is_noop() {
3137        let mut client = test_data_client();
3138
3139        let command = UnsubscribeTrades::new(
3140            [
3141                InstrumentId::from("ESM4.GLBX"),
3142                InstrumentId::from("SPY_OPTION.XCBO"),
3143            ],
3144            Some(ClientId::from("DATABENTO")),
3145            None,
3146            UUID4::new(),
3147            UnixNanos::from(37),
3148            None,
3149            None,
3150        );
3151        client.unsubscribe_trades(&command).unwrap();
3152        assert!(client.cmd_channels.lock().is_empty());
3153        assert!(client.symbol_venue_map.is_empty());
3154    }
3155
3156    #[rstest]
3157    fn test_send_subscription_commands_starts_after_subscribe() {
3158        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3159        let subscription = Subscription::builder()
3160            .schema(dbn::Schema::Mbp1)
3161            .symbols(vec!["ESM4"])
3162            .build();
3163
3164        send_subscription_commands(
3165            &tx,
3166            "GLBX.MDP3",
3167            Some((Symbol::from("ESM4"), 2)),
3168            subscription,
3169            true,
3170        )
3171        .unwrap();
3172
3173        assert!(matches!(
3174            rx.try_recv().unwrap(),
3175            HandlerCommand::SetPricePrecision(symbol, 2) if symbol == Symbol::from("ESM4")
3176        ));
3177        assert!(matches!(
3178            rx.try_recv().unwrap(),
3179            HandlerCommand::Subscribe(sub) if sub.schema == dbn::Schema::Mbp1
3180        ));
3181        assert!(matches!(rx.try_recv().unwrap(), HandlerCommand::Start));
3182    }
3183
3184    #[rstest]
3185    #[case::second("ESM4.GLBX-1-SECOND-LAST-EXTERNAL", dbn::Schema::Ohlcv1S)]
3186    #[case::minute("ESM4.GLBX-1-MINUTE-LAST-EXTERNAL", dbn::Schema::Ohlcv1M)]
3187    #[case::hour("ESM4.GLBX-1-HOUR-LAST-EXTERNAL", dbn::Schema::Ohlcv1H)]
3188    #[case::day("ESM4.GLBX-1-DAY-LAST-EXTERNAL", dbn::Schema::Ohlcv1D)]
3189    fn test_ohlcv_schema_maps_aggregation(#[case] bar_type: &str, #[case] expected: dbn::Schema) {
3190        let schema = ohlcv_schema(BarType::from(bar_type)).unwrap();
3191
3192        assert_eq!(schema, expected);
3193    }
3194
3195    #[rstest]
3196    #[case::step("ESM4.GLBX-5-MINUTE-LAST-EXTERNAL")]
3197    #[case::aggregation("ESM4.GLBX-100-TICK-LAST-EXTERNAL")]
3198    #[case::bid("ESM4.GLBX-1-MINUTE-BID-EXTERNAL")]
3199    #[case::mid("ESM4.GLBX-1-MINUTE-MID-EXTERNAL")]
3200    fn test_ohlcv_schema_rejects_unsupported_bar_type(#[case] bar_type: &str) {
3201        assert!(ohlcv_schema(BarType::from(bar_type)).is_err());
3202    }
3203
3204    #[rstest]
3205    #[case::statistics("DatabentoStatistics", dbn::Schema::Statistics)]
3206    #[case::imbalance("DatabentoImbalance", dbn::Schema::Imbalance)]
3207    fn test_subscribe_custom_data_sends_schema_subscription(
3208        #[case] type_name: &str,
3209        #[case] expected: dbn::Schema,
3210    ) {
3211        let mut client = test_data_client();
3212        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3213        client
3214            .cmd_channels
3215            .lock()
3216            .insert("GLBX.MDP3".to_string(), tx);
3217        let command = SubscribeCustomData::new(
3218            Some(ClientId::from("DATABENTO-TEST")),
3219            None,
3220            DataType::new(type_name, None, Some("ESM4.GLBX".to_string())),
3221            UUID4::new(),
3222            UnixNanos::default(),
3223            None,
3224            None,
3225        );
3226
3227        client.subscribe(command).unwrap();
3228
3229        let HandlerCommand::Subscribe(subscription) = rx.try_recv().unwrap() else {
3230            panic!("expected subscription");
3231        };
3232        assert_eq!(subscription.schema, expected);
3233        assert_eq!(
3234            subscription.symbols,
3235            databento::Symbols::Symbols(vec!["ESM4".to_string()])
3236        );
3237        assert!(rx.try_recv().is_err());
3238    }
3239
3240    #[rstest]
3241    #[case::unsupported_type("DatabentoOther", Some("ESM4.GLBX"))]
3242    #[case::missing_identifier("DatabentoStatistics", None)]
3243    #[case::invalid_identifier("DatabentoStatistics", Some("ESM4"))]
3244    fn test_subscribe_custom_data_rejects_invalid_data_type(
3245        #[case] type_name: &str,
3246        #[case] identifier: Option<&str>,
3247    ) {
3248        let mut client = test_data_client();
3249        let command = SubscribeCustomData::new(
3250            Some(ClientId::from("DATABENTO-TEST")),
3251            None,
3252            DataType::new(type_name, None, identifier.map(str::to_string)),
3253            UUID4::new(),
3254            UnixNanos::default(),
3255            None,
3256            None,
3257        );
3258
3259        assert!(client.subscribe(command).is_err());
3260        assert!(client.cmd_channels.lock().is_empty());
3261    }
3262
3263    #[rstest]
3264    fn test_custom_data_for_instrument_matches_subscribed_topic() {
3265        let instrument_id = InstrumentId::from("ESM4.GLBX");
3266        let statistics = DatabentoStatistics::new(
3267            instrument_id,
3268            DatabentoStatisticType::SettlementPrice,
3269            DatabentoStatisticUpdateAction::Added,
3270            Some(Price::from("5000.25")),
3271            None,
3272            1,
3273            2,
3274            3,
3275            UnixNanos::from(4),
3276            5,
3277            UnixNanos::from(6),
3278            UnixNanos::from(7),
3279            UnixNanos::from(8),
3280        );
3281        let subscribed = DataType::new("DatabentoStatistics", None, Some("ESM4.GLBX".to_string()));
3282
3283        let custom = custom_data_for_instrument(Arc::new(statistics), instrument_id);
3284
3285        assert_eq!(custom.data_type, subscribed);
3286        assert_eq!(
3287            get_custom_topic(&custom.data_type),
3288            get_custom_topic(&subscribed)
3289        );
3290        assert_eq!(custom.data_type.identifier(), Some("ESM4.GLBX"));
3291    }
3292
3293    #[rstest]
3294    fn test_subscribe_schema_sends_raw_symbol() {
3295        let client = test_data_client();
3296        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3297        client
3298            .cmd_channels
3299            .lock()
3300            .insert("GLBX.MDP3".to_string(), tx);
3301
3302        client
3303            .subscribe_schema(InstrumentId::from("ESM4.GLBX"), dbn::Schema::Ohlcv1M)
3304            .unwrap();
3305
3306        let HandlerCommand::Subscribe(subscription) = rx.try_recv().unwrap() else {
3307            panic!("expected subscription");
3308        };
3309        assert_eq!(subscription.stype_in, dbn::SType::RawSymbol);
3310        assert_eq!(
3311            subscription.symbols,
3312            databento::Symbols::Symbols(vec!["ESM4".to_string()])
3313        );
3314    }
3315
3316    #[rstest]
3317    #[case::continuous("ES.c.0.GLBX")]
3318    #[case::parent("ES.FUT.GLBX")]
3319    #[case::instrument_id("12345.GLBX")]
3320    fn test_subscribe_schema_rejects_non_raw_symbols(#[case] instrument_id: &str) {
3321        let client = test_data_client();
3322
3323        let result = client.subscribe_schema(InstrumentId::from(instrument_id), dbn::Schema::Mbp10);
3324
3325        assert!(result.is_err());
3326        assert!(client.cmd_channels.lock().is_empty());
3327    }
3328
3329    #[rstest]
3330    fn test_subscribe_custom_data_rejects_metadata() {
3331        let mut client = test_data_client();
3332        let metadata = Params::new([("venue".to_string(), json!("GLBX"))].into());
3333
3334        let command = SubscribeCustomData::new(
3335            Some(ClientId::from("DATABENTO-TEST")),
3336            None,
3337            DataType::new(
3338                "DatabentoStatistics",
3339                Some(metadata),
3340                Some("ESM4.GLBX".to_string()),
3341            ),
3342            UUID4::new(),
3343            UnixNanos::default(),
3344            None,
3345            None,
3346        );
3347
3348        assert!(client.subscribe(command).is_err());
3349        assert!(client.cmd_channels.lock().is_empty());
3350    }
3351
3352    #[rstest]
3353    fn test_subscribed_custom_data_reaches_subscriber() {
3354        let instrument_id = InstrumentId::from("ESM4.GLBX");
3355        let subscribed = DataType::new("DatabentoStatistics", None, Some("ESM4.GLBX".to_string()));
3356        let other = DataType::new("DatabentoStatistics", None, Some("ESU4.GLBX".to_string()));
3357        let handler = msgbus::stubs::get_message_saving_handler::<CustomData>(None);
3358        msgbus::subscribe_any(get_custom_topic(&subscribed).into(), handler.clone(), None);
3359        let statistics = DatabentoStatistics::new(
3360            instrument_id,
3361            DatabentoStatisticType::SettlementPrice,
3362            DatabentoStatisticUpdateAction::Added,
3363            Some(Price::from("5000.25")),
3364            None,
3365            1,
3366            2,
3367            3,
3368            UnixNanos::from(4),
3369            5,
3370            UnixNanos::from(6),
3371            UnixNanos::from(7),
3372            UnixNanos::from(8),
3373        );
3374        let custom = custom_data_for_instrument(Arc::new(statistics), instrument_id);
3375
3376        msgbus::publish_any(get_custom_topic(&custom.data_type), &custom);
3377        msgbus::publish_any(get_custom_topic(&other), &custom);
3378
3379        let received = msgbus::stubs::get_saved_messages::<CustomData>(&handler);
3380        assert_eq!(received, vec![custom]);
3381    }
3382
3383    #[rstest]
3384    fn test_subscribe_book_depth_accepts_depth_ten() {
3385        let mut client = test_data_client();
3386        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3387        client
3388            .cmd_channels
3389            .lock()
3390            .insert("GLBX.MDP3".to_string(), tx);
3391
3392        client
3393            .subscribe_book_depth(subscribe_book_depth_cmd(NonZeroUsize::new(10)))
3394            .unwrap();
3395
3396        let HandlerCommand::Subscribe(subscription) = rx.try_recv().unwrap() else {
3397            panic!("expected subscription");
3398        };
3399        assert_eq!(subscription.schema, dbn::Schema::Mbp10);
3400    }
3401
3402    #[rstest]
3403    #[case::five(5)]
3404    #[case::twenty(20)]
3405    fn test_subscribe_book_depth_rejects_other_depths(#[case] depth: usize) {
3406        let mut client = test_data_client();
3407
3408        let result =
3409            client.subscribe_book_depth(subscribe_book_depth_cmd(NonZeroUsize::new(depth)));
3410
3411        assert!(result.is_err());
3412        assert!(client.cmd_channels.lock().is_empty());
3413    }
3414
3415    #[rstest]
3416    fn test_subscribe_bars_sends_ohlcv_subscription() {
3417        let mut client = test_data_client();
3418        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3419        client
3420            .cmd_channels
3421            .lock()
3422            .insert("GLBX.MDP3".to_string(), tx);
3423        let command = SubscribeBars::new(
3424            BarType::from("ESM4.GLBX-1-MINUTE-LAST-EXTERNAL"),
3425            Some(ClientId::from("DATABENTO-TEST")),
3426            None,
3427            UUID4::new(),
3428            UnixNanos::default(),
3429            None,
3430            None,
3431        );
3432
3433        client.subscribe_bars(command).unwrap();
3434
3435        let HandlerCommand::Subscribe(subscription) = rx.try_recv().unwrap() else {
3436            panic!("expected subscription");
3437        };
3438        assert_eq!(subscription.schema, dbn::Schema::Ohlcv1M);
3439        assert_eq!(
3440            subscription.symbols,
3441            databento::Symbols::Symbols(vec!["ESM4".to_string()])
3442        );
3443        assert!(rx.try_recv().is_err());
3444    }
3445
3446    #[rstest]
3447    fn test_subscribe_book_depth_sends_mbp10_subscription() {
3448        let mut client = test_data_client();
3449        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3450        client
3451            .cmd_channels
3452            .lock()
3453            .insert("GLBX.MDP3".to_string(), tx);
3454        client
3455            .subscribe_book_depth(subscribe_book_depth_cmd(None))
3456            .unwrap();
3457
3458        let HandlerCommand::Subscribe(subscription) = rx.try_recv().unwrap() else {
3459            panic!("expected subscription");
3460        };
3461        assert_eq!(subscription.schema, dbn::Schema::Mbp10);
3462        assert_eq!(
3463            subscription.symbols,
3464            databento::Symbols::Symbols(vec!["ESM4".to_string()])
3465        );
3466        assert!(rx.try_recv().is_err());
3467    }
3468
3469    #[rstest]
3470    fn test_send_subscription_commands_without_precision_or_start() {
3471        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3472        let subscription = Subscription::builder()
3473            .schema(dbn::Schema::Mbp1)
3474            .symbols(vec!["ESM4"])
3475            .build();
3476
3477        send_subscription_commands(&tx, "GLBX.MDP3", None, subscription, false).unwrap();
3478
3479        assert!(matches!(
3480            rx.try_recv().unwrap(),
3481            HandlerCommand::Subscribe(sub) if sub.schema == dbn::Schema::Mbp1
3482        ));
3483        assert!(matches!(
3484            rx.try_recv(),
3485            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
3486        ));
3487    }
3488}