1use 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#[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 pub(crate) credential: Credential,
110 pub publishers_filepath: PathBuf,
112 pub historical_base_url: Option<String>,
114 pub live_gateway_addr: Option<String>,
116 pub venue_dataset_map: IndexMap<String, String>,
118 pub use_exchange_as_venue: bool,
120 pub bars_timestamp_on_close: bool,
122 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 #[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), historical_base_url: None,
153 live_gateway_addr: None,
154 }
155 }
156
157 #[must_use]
159 pub fn api_key(&self) -> &str {
160 self.credential.api_key()
161 }
162
163 #[must_use]
165 pub fn api_key_masked(&self) -> String {
166 self.credential.api_key_masked()
167 }
168}
169
170#[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 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 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 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 #[must_use]
262 pub fn api_key(&self) -> &str {
263 self.config.api_key()
264 }
265
266 #[must_use]
268 pub fn api_key_masked(&self) -> String {
269 self.config.api_key_masked()
270 }
271
272 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 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 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 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 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 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 fn client_id(&self) -> ClientId {
498 self.client_id
499 }
500
501 fn venue(&self) -> Option<Venue> {
503 None
504 }
505
506 fn start(&mut self) -> anyhow::Result<()> {
512 log::debug!("Starting");
513 Ok(())
514 }
515
516 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 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 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 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 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 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) .symbols(symbol)
665 .build();
666
667 self.send_subscription_to_dataset(&dataset, None, subscription, start_after_subscribe)?;
668
669 Ok(())
670 })
671 }
672
673 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 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 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 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 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 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 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 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 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 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 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 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 (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 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(¶ms));
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")] #[case("GLBX", "EQUS.MINI")] 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 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(¶ms));
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(¶ms)).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(¶ms));
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(¶ms), 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(¶ms), 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}