1use std::{
32 fmt::{Debug, Display},
33 path::PathBuf,
34 str::FromStr,
35 sync::Arc,
36 time::Duration,
37};
38
39use ahash::{AHashMap, HashSet, HashSetExt};
40use databento::{
41 dbn::{self, PitSymbolMap, SymbolIndex},
42 live::Subscription,
43};
44use indexmap::IndexMap;
45use nautilus_core::{
46 AtomicMap, UnixNanos, consts::NAUTILUS_USER_AGENT, string::secret::SecretString,
47 time::get_atomic_clock_realtime,
48};
49use nautilus_model::{
50 data::{Data, InstrumentStatus, OrderBookDelta, OrderBookDeltas},
51 enums::RecordFlag,
52 identifiers::{InstrumentId, Symbol, Venue},
53 instruments::{Instrument, InstrumentAny},
54 types::Currency,
55};
56use nautilus_network::backoff::ExponentialBackoff;
57use time::OffsetDateTime;
58
59use super::{
60 decode::{
61 decode_imbalance_msg, decode_statistics_msg, decode_status_msg, is_supported_stat_type,
62 },
63 types::{DatabentoImbalance, DatabentoStatistics, SubscriptionAckEvent},
64};
65use crate::{
66 common::{Credential, build_publisher_venue_map, load_publishers},
67 decode::{decode_instrument_def_msg, decode_record},
68 symbology::{check_consistent_symbology, infer_symbology_type},
69 types::PublisherId,
70};
71
72#[derive(Debug)]
73pub enum HandlerCommand {
74 Subscribe(Subscription),
75 SetPricePrecision(Symbol, u8),
76 Start,
77 Close,
78}
79
80#[derive(Debug)]
81pub enum DatabentoMessage {
82 Data(Data),
83 Instrument(Box<InstrumentAny>),
84 Status(InstrumentStatus),
85 Imbalance(DatabentoImbalance),
86 Statistics(DatabentoStatistics),
87 SubscriptionAck(SubscriptionAckEvent),
88 Error(anyhow::Error),
89 Close,
90}
91
92#[cfg_attr(
93 feature = "python",
94 pyo3::pyclass(module = "nautilus_trader.adapters.databento")
95)]
96#[cfg_attr(
97 feature = "python",
98 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.databento")
99)]
100pub struct DatabentoLiveClient {
101 credential: Credential,
102 pub dataset: String,
103 is_running: bool,
104 is_closed: bool,
105 cmd_tx: tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
106 cmd_rx: Option<tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>>,
107 publisher_venue_map: IndexMap<PublisherId, Venue>,
108 symbol_venue_map: Arc<AtomicMap<Symbol, Venue>>,
109 use_exchange_as_venue: bool,
110 bars_timestamp_on_close: bool,
111 reconnect_timeout_mins: Option<u64>,
112}
113
114impl Debug for DatabentoLiveClient {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.debug_struct(stringify!(DatabentoLiveClient))
117 .field("credential", &self.credential)
118 .field("dataset", &self.dataset)
119 .field("is_running", &self.is_running)
120 .field("is_closed", &self.is_closed)
121 .finish()
122 }
123}
124
125impl DatabentoLiveClient {
126 pub fn new(
132 key: String,
133 dataset: String,
134 publishers_filepath: PathBuf,
135 use_exchange_as_venue: bool,
136 bars_timestamp_on_close: Option<bool>,
137 reconnect_timeout_mins: Option<i64>,
138 ) -> anyhow::Result<Self> {
139 let publishers = load_publishers(publishers_filepath)?;
140 let publisher_venue_map = build_publisher_venue_map(&publishers);
141
142 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
143
144 let reconnect_timeout_mins = reconnect_timeout_mins
145 .and_then(|mins| if mins >= 0 { Some(mins as u64) } else { None });
146
147 Ok(Self {
148 credential: Credential::new(key),
149 dataset,
150 cmd_tx,
151 cmd_rx: Some(cmd_rx),
152 is_running: false,
153 is_closed: false,
154 publisher_venue_map,
155 symbol_venue_map: Arc::new(AtomicMap::new()),
156 use_exchange_as_venue,
157 bars_timestamp_on_close: bars_timestamp_on_close.unwrap_or(true),
158 reconnect_timeout_mins,
159 })
160 }
161
162 #[must_use]
163 pub const fn is_running(&self) -> bool {
164 self.is_running
165 }
166
167 #[must_use]
168 pub const fn is_closed(&self) -> bool {
169 self.is_closed
170 }
171
172 #[expect(clippy::needless_pass_by_value)]
179 pub fn subscribe(
180 &mut self,
181 schema: String,
182 instrument_ids: Vec<InstrumentId>,
183 start: Option<u64>,
184 snapshot: Option<bool>,
185 price_precisions: Option<Vec<Option<u8>>>,
186 stype_in: Option<String>,
187 ) -> anyhow::Result<()> {
188 if let Some(precisions) = &price_precisions
189 && precisions.len() != instrument_ids.len()
190 {
191 anyhow::bail!(
192 "`price_precisions` length ({}) must match `instrument_ids` length ({})",
193 precisions.len(),
194 instrument_ids.len()
195 );
196 }
197
198 let symbols: Vec<String> = instrument_ids
199 .iter()
200 .map(|id| id.symbol.to_string())
201 .collect();
202 let first_symbol = symbols
203 .first()
204 .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
205 let stype_in = match stype_in {
206 Some(stype_in) => dbn::SType::from_str(&stype_in)?,
207 None => infer_symbology_type(first_symbol),
208 };
209 let symbols: Vec<&str> = symbols.iter().map(String::as_str).collect();
210 check_consistent_symbology(symbols.as_slice())?;
211 let mut sub = Subscription::builder()
212 .symbols(symbols)
213 .schema(dbn::Schema::from_str(&schema)?)
214 .stype_in(stype_in)
215 .build();
216
217 if let Some(start) = start {
218 sub.start = Some(OffsetDateTime::from_unix_timestamp_nanos(i128::from(
219 start,
220 ))?);
221 }
222 sub.use_snapshot = snapshot.unwrap_or(false);
223
224 self.symbol_venue_map.rcu(|m| {
225 for id in &instrument_ids {
226 m.entry(id.symbol).or_insert(id.venue);
227 }
228 });
229
230 if let Some(precisions) = price_precisions {
231 for (instrument_id, precision) in instrument_ids.iter().zip(precisions) {
232 if let Some(precision) = precision {
233 self.send_command(HandlerCommand::SetPricePrecision(
234 instrument_id.symbol,
235 precision,
236 ))?;
237 }
238 }
239 }
240
241 self.send_command(HandlerCommand::Subscribe(sub))
242 }
243
244 pub fn start(
250 &mut self,
251 ) -> anyhow::Result<(
252 DatabentoFeedHandler,
253 tokio::sync::mpsc::UnboundedReceiver<DatabentoMessage>,
254 )> {
255 if self.is_closed {
256 anyhow::bail!("Client already closed");
257 }
258
259 if self.is_running {
260 anyhow::bail!("Client already running");
261 }
262
263 log::debug!("Starting client");
264
265 let (msg_tx, msg_rx) = tokio::sync::mpsc::unbounded_channel::<DatabentoMessage>();
266 let cmd_rx = self
267 .cmd_rx
268 .take()
269 .ok_or_else(|| anyhow::anyhow!("Command receiver already taken"))?;
270
271 let feed_handler = DatabentoFeedHandler::new(
272 self.credential.clone(),
273 self.dataset.clone(),
274 cmd_rx,
275 msg_tx,
276 self.publisher_venue_map.clone(),
277 self.symbol_venue_map.clone(),
278 self.use_exchange_as_venue,
279 self.bars_timestamp_on_close,
280 self.reconnect_timeout_mins,
281 );
282
283 self.send_command(HandlerCommand::Start)?;
284 self.is_running = true;
285
286 Ok((feed_handler, msg_rx))
287 }
288
289 pub fn close(&mut self) -> anyhow::Result<()> {
296 if !self.is_running {
297 anyhow::bail!("Client never started");
298 }
299
300 if self.is_closed {
301 anyhow::bail!("Client already closed");
302 }
303
304 log::debug!("Closing client");
305
306 if !self.cmd_tx.is_closed() {
307 self.send_command(HandlerCommand::Close)?;
308 }
309
310 self.is_running = false;
311 self.is_closed = true;
312
313 Ok(())
314 }
315
316 fn send_command(&self, cmd: HandlerCommand) -> anyhow::Result<()> {
317 self.cmd_tx.send(cmd).map_err(|e| {
318 anyhow::Error::new(CommandSendError {
319 details: e.to_string(),
320 })
321 })
322 }
323}
324
325#[cfg(any(test, feature = "python"))]
326pub(crate) fn is_command_send_error(error: &anyhow::Error) -> bool {
327 error.is::<CommandSendError>()
328}
329
330#[derive(Debug)]
331struct CommandSendError {
332 details: String,
333}
334
335impl Display for CommandSendError {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 write!(
338 f,
339 "Failed to send command to Databento feed handler: {}",
340 self.details
341 )
342 }
343}
344
345impl std::error::Error for CommandSendError {}
346
347pub struct DatabentoFeedHandler {
359 credential: Credential,
360 dataset: String,
361 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
362 msg_tx: tokio::sync::mpsc::UnboundedSender<DatabentoMessage>,
363 publisher_venue_map: IndexMap<PublisherId, Venue>,
364 symbol_venue_map: Arc<AtomicMap<Symbol, Venue>>,
365 replay: bool,
366 use_exchange_as_venue: bool,
367 bars_timestamp_on_close: bool,
368 reconnect_timeout_mins: Option<u64>,
369 backoff: ExponentialBackoff,
370 subscriptions: Vec<Subscription>,
371 buffered_commands: Vec<HandlerCommand>,
372 price_precision_overrides: AHashMap<Symbol, u8>,
373 gateway_addr: Option<String>,
374 success_threshold: Duration,
375}
376
377impl Debug for DatabentoFeedHandler {
378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379 f.debug_struct(stringify!(DatabentoFeedHandler))
380 .field("credential", &self.credential)
381 .field("dataset", &self.dataset)
382 .field("replay", &self.replay)
383 .field("reconnect_timeout_mins", &self.reconnect_timeout_mins)
384 .field("subscriptions", &self.subscriptions.len())
385 .finish()
386 }
387}
388
389impl DatabentoFeedHandler {
390 #[must_use]
396 #[expect(clippy::too_many_arguments)]
397 pub fn new(
398 credential: Credential,
399 dataset: String,
400 rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
401 tx: tokio::sync::mpsc::UnboundedSender<DatabentoMessage>,
402 publisher_venue_map: IndexMap<PublisherId, Venue>,
403 symbol_venue_map: Arc<AtomicMap<Symbol, Venue>>,
404 use_exchange_as_venue: bool,
405 bars_timestamp_on_close: bool,
406 reconnect_timeout_mins: Option<u64>,
407 ) -> Self {
408 let delay_max = if reconnect_timeout_mins.is_some() {
412 Duration::from_secs(60)
413 } else {
414 Duration::from_secs(600)
415 };
416
417 let backoff = ExponentialBackoff::new(Duration::from_secs(1), delay_max, 2.0, 1000, false)
418 .expect("hardcoded backoff parameters are valid");
419
420 Self {
421 credential,
422 dataset,
423 cmd_rx: rx,
424 msg_tx: tx,
425 publisher_venue_map,
426 symbol_venue_map,
427 replay: false,
428 use_exchange_as_venue,
429 bars_timestamp_on_close,
430 reconnect_timeout_mins,
431 backoff,
432 subscriptions: Vec::new(),
433 buffered_commands: Vec::new(),
434 price_precision_overrides: AHashMap::new(),
435 gateway_addr: None,
436 success_threshold: Duration::from_secs(60),
437 }
438 }
439
440 #[must_use]
442 pub fn with_gateway_addr(mut self, addr: String) -> Self {
443 self.gateway_addr = Some(addr);
444 self
445 }
446
447 #[must_use]
452 pub fn with_success_threshold(mut self, threshold: Duration) -> Self {
453 self.success_threshold = threshold;
454 self
455 }
456
457 pub async fn run(&mut self) -> anyhow::Result<()> {
470 log::debug!("Running feed handler");
471
472 let mut reconnect_start: Option<tokio::time::Instant> = None;
473 let mut attempt = 0;
474
475 loop {
476 attempt += 1;
477
478 match self.run_session(attempt).await {
479 Ok(ran_successfully) => {
480 if ran_successfully {
481 log::info!("Resetting reconnection cycle after successful session");
482 reconnect_start = None;
483 attempt = 0;
484 self.backoff.reset();
485 } else {
486 log::debug!("Session ended normally");
487 break Ok(());
488 }
489 }
490 Err(e) => {
491 let cycle_start = reconnect_start.get_or_insert_with(tokio::time::Instant::now);
492
493 if let Some(timeout_mins) = self.reconnect_timeout_mins {
494 let elapsed = cycle_start.elapsed();
495 let timeout = Duration::from_mins(timeout_mins);
496
497 if elapsed >= timeout {
498 log::error!("Giving up reconnection after {timeout_mins} minutes");
499 self.send_msg(DatabentoMessage::Error(anyhow::anyhow!(
500 "Reconnection timeout after {timeout_mins} minutes: {e}"
501 )));
502 break Err(e);
503 }
504 }
505
506 let delay = self.backoff.next_duration();
507
508 log::warn!(
509 "Connection lost (attempt {}): {}. Reconnecting in {}s...",
510 attempt,
511 e,
512 delay.as_secs()
513 );
514
515 let sleep = tokio::time::sleep(delay);
516 tokio::pin!(sleep);
517
518 loop {
519 tokio::select! {
520 () = &mut sleep => break,
521 cmd = self.cmd_rx.recv() => {
522 match cmd {
523 Some(HandlerCommand::Close) => {
524 log::debug!("Close received during backoff");
525 return Ok(());
526 }
527 None => {
528 log::debug!("Command channel closed during backoff");
529 return Ok(());
530 }
531 Some(cmd) => {
532 log::debug!("Buffering command received during backoff: {cmd:?}");
533 self.buffered_commands.push(cmd);
534 }
535 }
536 }
537 }
538 }
539 }
540 }
541 }
542 }
543
544 async fn run_session(&mut self, attempt: usize) -> anyhow::Result<bool> {
553 if attempt > 1 {
554 log::info!("Reconnecting (attempt {attempt})...");
555 }
556
557 let session_start = tokio::time::Instant::now();
558 let clock = get_atomic_clock_realtime();
559 let mut symbol_map = PitSymbolMap::new();
560 let mut instrument_id_map: AHashMap<u32, InstrumentId> = AHashMap::new();
561 let mut instrument_def_price_precision_map: AHashMap<u32, u8> = AHashMap::new();
562 let mut subscription_price_precision_map: AHashMap<u32, u8> = AHashMap::new();
563
564 let mut buffering_start = None;
565 let mut buffered_deltas: AHashMap<InstrumentId, Vec<OrderBookDelta>> = AHashMap::new();
566 let mut initialized_books = HashSet::new();
567 let mut trade_sources: AHashMap<InstrumentId, u8> = AHashMap::new();
568 let timeout = Duration::from_secs(5); let gateway_addr = self.gateway_addr.clone();
571 let api_key = SecretString::from(self.credential.api_key().to_owned());
572 let dataset = self.dataset.clone();
573
574 let result = tokio::time::timeout(timeout, async move {
575 let base = databento::LiveClient::builder();
576 let base = if let Some(addr) = gateway_addr {
577 base.addr(addr).await?
578 } else {
579 base
580 };
581 base.user_agent_extension(NAUTILUS_USER_AGENT.into())
582 .key(api_key.expose_secret().to_owned())?
583 .dataset(dataset)
584 .build()
585 .await
586 })
587 .await?;
588
589 let mut client = match result {
590 Ok(client) => {
591 if attempt > 1 {
592 log::info!("Reconnected successfully");
593 } else {
594 log::info!("Connected");
595 }
596 client
597 }
598 Err(e) => {
599 anyhow::bail!("Failed to connect to Databento LSG: {e}");
600 }
601 };
602
603 let mut start_buffered = false;
605
606 if !self.buffered_commands.is_empty() {
607 log::debug!(
608 "Processing {} buffered commands",
609 self.buffered_commands.len()
610 );
611
612 for cmd in self.buffered_commands.drain(..) {
613 match cmd {
614 HandlerCommand::Subscribe(sub) => {
615 if !self.replay && sub.start.is_some() {
616 self.replay = true;
617 }
618 self.subscriptions.push(sub);
619 }
620 HandlerCommand::SetPricePrecision(symbol, precision) => {
621 self.price_precision_overrides.insert(symbol, precision);
622 }
623 HandlerCommand::Start => {
624 start_buffered = true;
625 }
626 HandlerCommand::Close => {
627 log::warn!("Close command was buffered, shutting down");
628 return Ok(false);
629 }
630 }
631 }
632 }
633
634 let mut running = false;
635
636 if !self.subscriptions.is_empty() {
637 log::info!(
638 "Resubscribing to {} subscriptions",
639 self.subscriptions.len()
640 );
641
642 let replay = self.subscriptions.iter().any(|sub| sub.start.is_some());
644
645 for sub in self.subscriptions.clone() {
646 client.subscribe(sub).await?;
647 }
648 for sub in &mut self.subscriptions {
650 sub.start = None;
651 }
652
653 buffering_start = replay.then(|| clock.get_time_ns());
654 client.start().await?;
655 running = true;
656 log::info!("Resubscription complete");
657 } else if start_buffered {
658 log::debug!("Starting session from buffered Start command");
659 buffering_start = if self.replay {
660 Some(clock.get_time_ns())
661 } else {
662 None
663 };
664 client.start().await?;
665 running = true;
666 }
667
668 loop {
669 if self.msg_tx.is_closed() {
670 log::debug!("Message channel was closed: stopping");
671 return Ok(false);
672 }
673
674 if !running {
679 match self.cmd_rx.recv().await {
680 Some(HandlerCommand::Subscribe(sub)) => {
681 log::debug!("Received command: Subscribe");
682
683 if !self.replay && sub.start.is_some() {
684 self.replay = true;
685 }
686 client.subscribe(sub.clone()).await?;
687 let mut sub_for_reconnect = sub;
688 sub_for_reconnect.start = None;
689 self.subscriptions.push(sub_for_reconnect);
690 continue;
691 }
692 Some(HandlerCommand::SetPricePrecision(symbol, precision)) => {
693 log::debug!(
694 "Received command: SetPricePrecision for {symbol} to {precision}"
695 );
696 self.price_precision_overrides.insert(symbol, precision);
697 continue;
698 }
699 Some(HandlerCommand::Start) => {
700 log::debug!("Received command: Start");
701 buffering_start = if self.replay {
702 Some(clock.get_time_ns())
703 } else {
704 None
705 };
706 client.start().await?;
707 running = true;
708 continue;
709 }
710 Some(HandlerCommand::Close) => {
711 self.send_close_msg();
712 return Ok(false);
713 }
714 None => {
715 log::debug!("Command channel disconnected");
716 return Ok(false);
717 }
718 }
719 }
720
721 let record_opt = tokio::select! {
722 cmd = self.cmd_rx.recv() =>
723 match cmd {
724 Some(HandlerCommand::Subscribe(sub)) => {
725 log::debug!("Received command: Subscribe");
726
727 if sub.start.is_some() {
728 self.replay = true;
729 log::error!(
730 "Ignoring `start` on {} subscribe, session already running, Databento drops replay anchors sent after session start",
731 self.dataset,
732 );
733 }
734 client.subscribe(sub.clone()).await?;
735 let mut sub_for_reconnect = sub;
736 sub_for_reconnect.start = None;
737 self.subscriptions.push(sub_for_reconnect);
738 continue;
739 }
740 Some(HandlerCommand::SetPricePrecision(symbol, precision)) => {
741 log::debug!(
742 "Received command: SetPricePrecision for {symbol} to {precision}"
743 );
744 self.price_precision_overrides.insert(symbol, precision);
745 continue;
746 }
747 Some(HandlerCommand::Start) => {
748 log::warn!("Received Start command but session already running");
749 continue;
750 }
751 Some(HandlerCommand::Close) => {
752 self.send_close_msg();
753 client.close().await?;
754 log::debug!("Closed inner client");
755 return Ok(false);
756 }
757 None => {
758 log::debug!("Command channel disconnected");
759 return Ok(false);
760 }
761 },
762 result = client.next_record() => result,
763 };
764
765 let record = match record_opt {
766 Ok(Some(record)) => record,
767 Ok(None) => {
768 if session_start.elapsed() >= self.success_threshold {
769 log::debug!("Session ended after successful run");
770 return Ok(true);
771 }
772 anyhow::bail!("Session ended by gateway");
773 }
774 Err(e) => {
775 if session_start.elapsed() >= self.success_threshold {
776 log::debug!("Connection error after successful run: {e}");
777 return Ok(true);
778 }
779 anyhow::bail!("Connection error: {e}");
780 }
781 };
782
783 let ts_init = clock.get_time_ns();
784
785 if let Some(msg) = record.get::<dbn::ErrorMsg>() {
787 handle_error_msg(msg);
788 } else if let Some(msg) = record.get::<dbn::SystemMsg>() {
789 if let Some(ack) = handle_system_msg(msg, ts_init) {
790 self.send_msg(DatabentoMessage::SubscriptionAck(ack));
791 }
792 } else if let Some(msg) = record.get::<dbn::SymbolMappingMsg>() {
793 instrument_id_map.remove(&msg.hd.instrument_id);
795 instrument_def_price_precision_map.remove(&msg.hd.instrument_id);
796 update_price_precision_map_with_symbol_mapping_msg(
797 msg,
798 &self.price_precision_overrides,
799 &mut subscription_price_precision_map,
800 )?;
801 handle_symbol_mapping_msg(msg, &mut symbol_map, &mut instrument_id_map)?;
802 } else if let Some(msg) = record.get::<dbn::InstrumentDefMsg>() {
803 if self.use_exchange_as_venue {
804 let exchange = msg.exchange()?;
805 if !exchange.is_empty() {
806 update_instrument_id_map_with_exchange(
807 &symbol_map,
808 &self.symbol_venue_map,
809 &mut instrument_id_map,
810 msg.hd.instrument_id,
811 exchange,
812 )?;
813 }
814 }
815 let maybe_data = handle_instrument_def_msg(
816 msg,
817 &record,
818 &symbol_map,
819 &self.publisher_venue_map,
820 &self.symbol_venue_map,
821 &mut instrument_id_map,
822 ts_init,
823 )?;
824
825 if let Some(data) = maybe_data {
826 instrument_def_price_precision_map
827 .insert(msg.hd.instrument_id, data.price_precision());
828 self.send_msg(DatabentoMessage::Instrument(Box::new(data)));
829 }
830 } else if let Some(msg) = record.get::<dbn::StatusMsg>() {
831 let data = handle_status_msg(
832 msg,
833 &record,
834 &symbol_map,
835 &self.publisher_venue_map,
836 &self.symbol_venue_map,
837 &mut instrument_id_map,
838 ts_init,
839 )?;
840 self.send_msg(DatabentoMessage::Status(data));
841 } else if let Some(msg) = record.get::<dbn::ImbalanceMsg>() {
842 let data = handle_imbalance_msg(
843 msg,
844 &record,
845 &symbol_map,
846 &self.publisher_venue_map,
847 &self.symbol_venue_map,
848 &mut instrument_id_map,
849 &instrument_def_price_precision_map,
850 &subscription_price_precision_map,
851 &self.price_precision_overrides,
852 ts_init,
853 )?;
854 self.send_msg(DatabentoMessage::Imbalance(data));
855 } else if let Some(msg) = record.get::<dbn::StatMsg>() {
856 let maybe_data = handle_statistics_msg(
857 msg,
858 &record,
859 &symbol_map,
860 &self.publisher_venue_map,
861 &self.symbol_venue_map,
862 &mut instrument_id_map,
863 &instrument_def_price_precision_map,
864 &subscription_price_precision_map,
865 &self.price_precision_overrides,
866 ts_init,
867 )?;
868
869 if let Some(data) = maybe_data {
870 self.send_msg(DatabentoMessage::Statistics(data));
871 }
872 } else {
873 let res = handle_record(
875 record,
876 &symbol_map,
877 &self.publisher_venue_map,
878 &self.symbol_venue_map,
879 &mut instrument_id_map,
880 &instrument_def_price_precision_map,
881 &subscription_price_precision_map,
882 &self.price_precision_overrides,
883 ts_init,
884 &initialized_books,
885 self.bars_timestamp_on_close,
886 );
887 let (mut data1, data2) = match res {
888 Ok(decoded) => decoded,
889 Err(e) => {
890 log::error!("Error decoding record: {e}");
891 continue;
892 }
893 };
894
895 if let Some(msg) = record.get::<dbn::MboMsg>() {
896 if let Some(Data::BookDelta(delta)) = &data1 {
897 initialized_books.insert(delta.instrument_id);
898
899 log::trace!(
900 "Buffering delta: {} {buffering_start:?} flags={}",
901 delta.ts_event,
902 msg.flags.raw(),
903 );
904
905 match process_mbo_delta(
906 *delta,
907 msg.flags.raw(),
908 &mut buffering_start,
909 &mut buffered_deltas,
910 ) {
911 Some(deltas) => data1 = Some(Data::BookDeltas(Box::new(deltas))),
912 None => continue,
913 }
914 } else {
915 let instrument_id = update_instrument_id_map(
922 &record,
923 &symbol_map,
924 &self.publisher_venue_map,
925 &self.symbol_venue_map,
926 &mut instrument_id_map,
927 )?;
928
929 if let Some(deltas) = flush_mbo_event_boundary(
930 instrument_id,
931 msg.ts_recv.into(),
932 msg.flags.raw(),
933 &mut buffering_start,
934 &mut buffered_deltas,
935 ) {
936 self.send_msg(DatabentoMessage::Data(Data::BookDeltas(Box::new(
937 deltas,
938 ))));
939 }
940
941 if buffering_start.is_some_and(|start| msg.ts_recv <= start) {
943 continue;
944 }
945 }
946 }
947
948 let rtype = record.header().rtype;
949
950 for data in [data1, data2].into_iter().flatten() {
951 if claim_trade_source(&data, rtype, &mut trade_sources) {
952 self.send_msg(DatabentoMessage::Data(data));
953 }
954 }
955 }
956 }
957 }
958
959 fn send_msg(&self, msg: DatabentoMessage) {
961 log::trace!("Sending {msg:?}");
962 match self.msg_tx.send(msg) {
963 Ok(()) => {}
964 Err(e) => log::debug!("Error sending message: {e}"),
965 }
966 }
967
968 fn send_close_msg(&self) {
969 if let Err(e) = self.msg_tx.send(DatabentoMessage::Close) {
970 log::debug!("Could not send close message: {e}");
971 }
972 }
973}
974
975fn handle_error_msg(msg: &dbn::ErrorMsg) {
977 log::error!("{msg:?}");
978}
979
980fn handle_system_msg(msg: &dbn::SystemMsg, ts_received: UnixNanos) -> Option<SubscriptionAckEvent> {
982 match msg.code() {
983 Ok(dbn::SystemCode::SubscriptionAck) => {
984 let message = msg.msg().unwrap_or("<invalid utf-8>");
985 log::debug!("Subscription acknowledged: {message}");
986
987 let schema = parse_ack_message(message);
988
989 Some(SubscriptionAckEvent {
990 schema,
991 message: message.to_string(),
992 ts_received,
993 })
994 }
995 Ok(dbn::SystemCode::Heartbeat) => {
996 log::trace!("Heartbeat received");
997 None
998 }
999 Ok(dbn::SystemCode::SlowReaderWarning) => {
1000 let message = msg.msg().unwrap_or("<invalid utf-8>");
1001 log::warn!("Slow reader warning: {message}");
1002 None
1003 }
1004 Ok(dbn::SystemCode::ReplayCompleted) => {
1005 let message = msg.msg().unwrap_or("<invalid utf-8>");
1006 log::debug!("Replay completed: {message}");
1007 None
1008 }
1009 _ => {
1010 log::debug!("{msg:?}");
1011 None
1012 }
1013 }
1014}
1015
1016fn parse_ack_message(message: &str) -> String {
1018 message
1020 .strip_circumfix("Subscription request ", " data succeeded")
1021 .and_then(|rest| rest.split_once(" for "))
1022 .map(|(_, schema)| schema.trim().to_string())
1023 .unwrap_or_default()
1024}
1025
1026fn handle_symbol_mapping_msg(
1032 msg: &dbn::SymbolMappingMsg,
1033 symbol_map: &mut PitSymbolMap,
1034 instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1035) -> anyhow::Result<()> {
1036 symbol_map
1037 .on_symbol_mapping(msg)
1038 .map_err(|e| anyhow::anyhow!("on_symbol_mapping failed for {msg:?}: {e}"))?;
1039 instrument_id_map.remove(&msg.hd.instrument_id);
1040 Ok(())
1041}
1042
1043fn update_price_precision_map_with_symbol_mapping_msg(
1044 msg: &dbn::SymbolMappingMsg,
1045 price_precision_overrides: &AHashMap<Symbol, u8>,
1046 subscription_price_precision_map: &mut AHashMap<u32, u8>,
1047) -> anyhow::Result<()> {
1048 subscription_price_precision_map.remove(&msg.hd.instrument_id);
1049
1050 if price_precision_overrides.is_empty() {
1051 return Ok(());
1052 }
1053
1054 let stype_in_symbol = msg
1055 .stype_in_symbol()
1056 .map_err(|e| anyhow::anyhow!("Error decoding `stype_in_symbol`: {e}"))?;
1057 let stype_out_symbol = msg
1058 .stype_out_symbol()
1059 .map_err(|e| anyhow::anyhow!("Error decoding `stype_out_symbol`: {e}"))?;
1060
1061 let price_precision = [stype_in_symbol, stype_out_symbol]
1062 .into_iter()
1063 .find_map(|symbol| {
1064 price_precision_overrides
1065 .get(&Symbol::from_str_unchecked(symbol))
1066 .copied()
1067 });
1068
1069 if let Some(price_precision) = price_precision {
1070 subscription_price_precision_map.insert(msg.hd.instrument_id, price_precision);
1071 }
1072
1073 Ok(())
1074}
1075
1076fn update_instrument_id_map_with_exchange(
1078 symbol_map: &PitSymbolMap,
1079 symbol_venue_map: &AtomicMap<Symbol, Venue>,
1080 instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1081 raw_instrument_id: u32,
1082 exchange: &str,
1083) -> anyhow::Result<InstrumentId> {
1084 let raw_symbol = symbol_map.get(raw_instrument_id).ok_or_else(|| {
1085 anyhow::anyhow!("Cannot resolve raw_symbol for instrument_id {raw_instrument_id}")
1086 })?;
1087 let symbol = Symbol::from(raw_symbol.as_str());
1088 let venue = Venue::from_code(exchange)
1089 .map_err(|e| anyhow::anyhow!("Invalid venue code '{exchange}': {e}"))?;
1090 let instrument_id = InstrumentId::new(symbol, venue);
1091 symbol_venue_map.rcu(|m| {
1092 m.entry(symbol).or_insert(venue);
1093 });
1094 instrument_id_map.insert(raw_instrument_id, instrument_id);
1095 Ok(instrument_id)
1096}
1097
1098fn update_instrument_id_map(
1099 record: &dbn::RecordRef,
1100 symbol_map: &PitSymbolMap,
1101 publisher_venue_map: &IndexMap<PublisherId, Venue>,
1102 symbol_venue_map: &AtomicMap<Symbol, Venue>,
1103 instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1104) -> anyhow::Result<InstrumentId> {
1105 let header = record.header();
1106
1107 if let Some(&instrument_id) = instrument_id_map.get(&header.instrument_id) {
1109 return Ok(instrument_id);
1110 }
1111
1112 let raw_symbol = symbol_map.get_for_rec(record).ok_or_else(|| {
1113 anyhow::anyhow!(
1114 "Cannot resolve `raw_symbol` from `symbol_map` for instrument_id {}",
1115 header.instrument_id
1116 )
1117 })?;
1118
1119 let symbol = Symbol::from_str_unchecked(raw_symbol);
1120
1121 let publisher_id = header.publisher_id;
1122 let venue = if let Some(venue) = symbol_venue_map.get_cloned(&symbol) {
1123 venue
1124 } else {
1125 let venue = publisher_venue_map
1126 .get(&publisher_id)
1127 .ok_or_else(|| anyhow::anyhow!("No venue found for `publisher_id` {publisher_id}"))?;
1128 *venue
1129 };
1130 let instrument_id = InstrumentId::new(symbol, venue);
1131
1132 instrument_id_map.insert(header.instrument_id, instrument_id);
1133 Ok(instrument_id)
1134}
1135
1136fn handle_instrument_def_msg(
1142 msg: &dbn::InstrumentDefMsg,
1143 record: &dbn::RecordRef,
1144 symbol_map: &PitSymbolMap,
1145 publisher_venue_map: &IndexMap<PublisherId, Venue>,
1146 symbol_venue_map: &AtomicMap<Symbol, Venue>,
1147 instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1148 ts_init: UnixNanos,
1149) -> anyhow::Result<Option<InstrumentAny>> {
1150 let instrument_id = update_instrument_id_map(
1151 record,
1152 symbol_map,
1153 publisher_venue_map,
1154 symbol_venue_map,
1155 instrument_id_map,
1156 )?;
1157
1158 decode_instrument_def_msg(msg, instrument_id, Some(ts_init), None)
1159}
1160
1161fn handle_status_msg(
1162 msg: &dbn::StatusMsg,
1163 record: &dbn::RecordRef,
1164 symbol_map: &PitSymbolMap,
1165 publisher_venue_map: &IndexMap<PublisherId, Venue>,
1166 symbol_venue_map: &AtomicMap<Symbol, Venue>,
1167 instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1168 ts_init: UnixNanos,
1169) -> anyhow::Result<InstrumentStatus> {
1170 let instrument_id = update_instrument_id_map(
1171 record,
1172 symbol_map,
1173 publisher_venue_map,
1174 symbol_venue_map,
1175 instrument_id_map,
1176 )?;
1177
1178 decode_status_msg(msg, instrument_id, Some(ts_init))
1179}
1180
1181#[expect(clippy::too_many_arguments)]
1182fn handle_imbalance_msg(
1183 msg: &dbn::ImbalanceMsg,
1184 record: &dbn::RecordRef,
1185 symbol_map: &PitSymbolMap,
1186 publisher_venue_map: &IndexMap<PublisherId, Venue>,
1187 symbol_venue_map: &AtomicMap<Symbol, Venue>,
1188 instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1189 instrument_def_price_precision_map: &AHashMap<u32, u8>,
1190 subscription_price_precision_map: &AHashMap<u32, u8>,
1191 price_precision_overrides: &AHashMap<Symbol, u8>,
1192 ts_init: UnixNanos,
1193) -> anyhow::Result<DatabentoImbalance> {
1194 let instrument_id = update_instrument_id_map(
1195 record,
1196 symbol_map,
1197 publisher_venue_map,
1198 symbol_venue_map,
1199 instrument_id_map,
1200 )?;
1201
1202 let price_precision = resolve_price_precision(
1203 msg.hd.instrument_id,
1204 instrument_id,
1205 instrument_def_price_precision_map,
1206 subscription_price_precision_map,
1207 price_precision_overrides,
1208 );
1209
1210 decode_imbalance_msg(msg, instrument_id, price_precision, Some(ts_init))
1211}
1212
1213#[expect(clippy::too_many_arguments)]
1214fn handle_statistics_msg(
1215 msg: &dbn::StatMsg,
1216 record: &dbn::RecordRef,
1217 symbol_map: &PitSymbolMap,
1218 publisher_venue_map: &IndexMap<PublisherId, Venue>,
1219 symbol_venue_map: &AtomicMap<Symbol, Venue>,
1220 instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1221 instrument_def_price_precision_map: &AHashMap<u32, u8>,
1222 subscription_price_precision_map: &AHashMap<u32, u8>,
1223 price_precision_overrides: &AHashMap<Symbol, u8>,
1224 ts_init: UnixNanos,
1225) -> anyhow::Result<Option<DatabentoStatistics>> {
1226 if !is_supported_stat_type(msg.stat_type) {
1228 log::warn!("Skipping unsupported `stat_type` {}", msg.stat_type);
1229 return Ok(None);
1230 }
1231
1232 let instrument_id = update_instrument_id_map(
1233 record,
1234 symbol_map,
1235 publisher_venue_map,
1236 symbol_venue_map,
1237 instrument_id_map,
1238 )?;
1239
1240 let price_precision = resolve_price_precision(
1241 msg.hd.instrument_id,
1242 instrument_id,
1243 instrument_def_price_precision_map,
1244 subscription_price_precision_map,
1245 price_precision_overrides,
1246 );
1247
1248 decode_statistics_msg(msg, instrument_id, price_precision, Some(ts_init))
1249}
1250
1251#[expect(clippy::too_many_arguments)]
1252fn handle_record(
1253 record: dbn::RecordRef,
1254 symbol_map: &PitSymbolMap,
1255 publisher_venue_map: &IndexMap<PublisherId, Venue>,
1256 symbol_venue_map: &AtomicMap<Symbol, Venue>,
1257 instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1258 instrument_def_price_precision_map: &AHashMap<u32, u8>,
1259 subscription_price_precision_map: &AHashMap<u32, u8>,
1260 price_precision_overrides: &AHashMap<Symbol, u8>,
1261 ts_init: UnixNanos,
1262 initialized_books: &HashSet<InstrumentId>,
1263 bars_timestamp_on_close: bool,
1264) -> anyhow::Result<(Option<Data>, Option<Data>)> {
1265 let instrument_id = update_instrument_id_map(
1266 &record,
1267 symbol_map,
1268 publisher_venue_map,
1269 symbol_venue_map,
1270 instrument_id_map,
1271 )?;
1272
1273 let price_precision = resolve_price_precision(
1274 record.header().instrument_id,
1275 instrument_id,
1276 instrument_def_price_precision_map,
1277 subscription_price_precision_map,
1278 price_precision_overrides,
1279 );
1280
1281 let include_trades = if record.get::<dbn::Mbp1Msg>().is_some()
1284 || record.get::<dbn::TbboMsg>().is_some()
1285 || record.get::<dbn::Cmbp1Msg>().is_some()
1286 {
1287 true } else {
1289 initialized_books.contains(&instrument_id) };
1291
1292 decode_record(
1293 &record,
1294 instrument_id,
1295 price_precision,
1296 Some(ts_init),
1297 include_trades,
1298 bars_timestamp_on_close,
1299 )
1300}
1301
1302fn claim_trade_source(
1312 data: &Data,
1313 rtype: u8,
1314 trade_sources: &mut AHashMap<InstrumentId, u8>,
1315) -> bool {
1316 match data {
1317 Data::Trade(trade) => *trade_sources.entry(trade.instrument_id).or_insert(rtype) == rtype,
1318 _ => true,
1319 }
1320}
1321
1322fn resolve_price_precision(
1323 record_instrument_id: u32,
1324 instrument_id: InstrumentId,
1325 instrument_def_price_precision_map: &AHashMap<u32, u8>,
1326 subscription_price_precision_map: &AHashMap<u32, u8>,
1327 price_precision_overrides: &AHashMap<Symbol, u8>,
1328) -> u8 {
1329 instrument_def_price_precision_map
1330 .get(&record_instrument_id)
1331 .copied()
1332 .or_else(|| {
1333 subscription_price_precision_map
1334 .get(&record_instrument_id)
1335 .copied()
1336 })
1337 .or_else(|| {
1338 price_precision_overrides
1339 .get(&instrument_id.symbol)
1340 .copied()
1341 })
1342 .unwrap_or(Currency::USD().precision)
1343}
1344
1345fn process_mbo_delta(
1350 delta: OrderBookDelta,
1351 flags: u8,
1352 buffering_start: &mut Option<UnixNanos>,
1353 buffered_deltas: &mut AHashMap<InstrumentId, Vec<OrderBookDelta>>,
1354) -> Option<OrderBookDeltas> {
1355 let is_last = RecordFlag::F_LAST.matches(flags);
1356 let is_snapshot = RecordFlag::F_SNAPSHOT.matches(flags);
1357
1358 if is_last
1360 && !is_snapshot
1361 && buffering_start.is_none()
1362 && !buffered_deltas.contains_key(&delta.instrument_id)
1363 {
1364 let deltas = OrderBookDeltas::new(delta.instrument_id, vec![delta]);
1365 return Some(deltas);
1366 }
1367
1368 let buffer = buffered_deltas.entry(delta.instrument_id).or_default();
1369 buffer.push(delta);
1370
1371 if is_snapshot {
1372 return None;
1373 }
1374
1375 flush_mbo_event_boundary(
1376 delta.instrument_id,
1377 delta.ts_event,
1378 flags,
1379 buffering_start,
1380 buffered_deltas,
1381 )
1382}
1383
1384fn flush_mbo_event_boundary(
1392 instrument_id: InstrumentId,
1393 ts_event: UnixNanos,
1394 flags: u8,
1395 buffering_start: &mut Option<UnixNanos>,
1396 buffered_deltas: &mut AHashMap<InstrumentId, Vec<OrderBookDelta>>,
1397) -> Option<OrderBookDeltas> {
1398 if !RecordFlag::F_LAST.matches(flags) || RecordFlag::F_SNAPSHOT.matches(flags) {
1399 return None;
1400 }
1401
1402 let buffer = buffered_deltas.get(&instrument_id)?;
1403
1404 if buffer.is_empty() {
1405 return None;
1406 }
1407
1408 if let Some(start_ns) = *buffering_start {
1409 if ts_event <= start_ns {
1410 return None;
1411 }
1412 *buffering_start = None;
1413 }
1414
1415 let buffer = buffered_deltas.remove(&instrument_id)?;
1416 let deltas = OrderBookDeltas::new(instrument_id, buffer);
1417 Some(deltas)
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422 use std::path::PathBuf;
1423
1424 use databento::{dbn::enums::rtype, live::Subscription};
1425 use indexmap::IndexMap;
1426 use rstest::*;
1427 use time::macros::datetime;
1428
1429 use super::*;
1430
1431 fn stub_delta(instrument_id: InstrumentId, ts: u64) -> OrderBookDelta {
1432 use nautilus_model::{
1433 data::BookOrder,
1434 enums::{BookAction, OrderSide},
1435 types::{Price, Quantity},
1436 };
1437
1438 OrderBookDelta::new(
1439 instrument_id,
1440 BookAction::Delete,
1441 BookOrder::new(
1442 OrderSide::Sell,
1443 Price::from("100.00"),
1444 Quantity::from("5"),
1445 42,
1446 ),
1447 0, 1,
1449 ts.into(),
1450 ts.into(),
1451 )
1452 }
1453
1454 #[rstest]
1455 fn test_boundary_flag_on_recordless_message_flushes_buffered_event() {
1456 let instrument_id = InstrumentId::from("TEST.GLBX");
1460 let mut buffering_start = None;
1461 let mut buffered = AHashMap::new();
1462
1463 let buffered_result = process_mbo_delta(
1464 stub_delta(instrument_id, 1),
1465 0,
1466 &mut buffering_start,
1467 &mut buffered,
1468 );
1469 assert!(buffered_result.is_none());
1470
1471 let flushed = flush_mbo_event_boundary(
1472 instrument_id,
1473 2.into(),
1474 RecordFlag::F_LAST as u8,
1475 &mut buffering_start,
1476 &mut buffered,
1477 );
1478 assert!(flushed.is_some());
1479 assert!(buffered.is_empty());
1480 }
1481
1482 #[rstest]
1483 fn test_boundary_flag_with_empty_buffer_is_noop() {
1484 let instrument_id = InstrumentId::from("TEST.GLBX");
1485 let start = UnixNanos::from(1);
1486 let mut buffering_start = Some(start);
1487 let mut buffered: AHashMap<InstrumentId, Vec<OrderBookDelta>> = AHashMap::new();
1488 buffered.insert(instrument_id, Vec::new());
1489
1490 let flushed = flush_mbo_event_boundary(
1491 instrument_id,
1492 2.into(),
1493 RecordFlag::F_LAST as u8,
1494 &mut buffering_start,
1495 &mut buffered,
1496 );
1497
1498 assert!(flushed.is_none());
1499 assert_eq!(buffering_start, Some(start));
1500 }
1501
1502 #[rstest]
1503 fn test_empty_boundary_preserves_replay_gate_for_buffered_instrument() {
1504 let empty_id = InstrumentId::from("EMPTY.GLBX");
1505 let buffered_id = InstrumentId::from("BUFFERED.GLBX");
1506 let start = UnixNanos::from(10);
1507 let mut buffering_start = Some(start);
1508 let mut buffered = AHashMap::new();
1509 let _ = process_mbo_delta(
1510 stub_delta(buffered_id, 1),
1511 0,
1512 &mut buffering_start,
1513 &mut buffered,
1514 );
1515
1516 let empty_boundary = flush_mbo_event_boundary(
1517 empty_id,
1518 11.into(),
1519 RecordFlag::F_LAST as u8,
1520 &mut buffering_start,
1521 &mut buffered,
1522 );
1523 let buffered_boundary = flush_mbo_event_boundary(
1524 buffered_id,
1525 5.into(),
1526 RecordFlag::F_LAST as u8,
1527 &mut buffering_start,
1528 &mut buffered,
1529 );
1530
1531 assert!(empty_boundary.is_none());
1532 assert!(buffered_boundary.is_none());
1533 assert_eq!(buffering_start, Some(start));
1534 assert!(buffered.contains_key(&buffered_id));
1535 }
1536
1537 #[rstest]
1538 fn test_boundary_flag_respects_replay_buffering_gate() {
1539 let instrument_id = InstrumentId::from("TEST.GLBX");
1540 let mut buffering_start = Some(UnixNanos::from(10));
1541 let mut buffered = AHashMap::new();
1542 let _ = process_mbo_delta(
1543 stub_delta(instrument_id, 1),
1544 0,
1545 &mut buffering_start,
1546 &mut buffered,
1547 );
1548
1549 let flushed = flush_mbo_event_boundary(
1551 instrument_id,
1552 5.into(),
1553 RecordFlag::F_LAST as u8,
1554 &mut buffering_start,
1555 &mut buffered,
1556 );
1557 assert!(flushed.is_none());
1558 assert!(!buffered.is_empty());
1559
1560 let flushed = flush_mbo_event_boundary(
1562 instrument_id,
1563 11.into(),
1564 RecordFlag::F_LAST as u8,
1565 &mut buffering_start,
1566 &mut buffered,
1567 );
1568 assert!(flushed.is_some());
1569 assert!(buffering_start.is_none());
1570 }
1571
1572 #[rstest]
1573 fn test_boundary_snapshot_flag_never_flushes() {
1574 let instrument_id = InstrumentId::from("TEST.GLBX");
1575 let mut buffering_start = None;
1576 let mut buffered = AHashMap::new();
1577 let _ = process_mbo_delta(
1578 stub_delta(instrument_id, 1),
1579 0,
1580 &mut buffering_start,
1581 &mut buffered,
1582 );
1583
1584 let flags = RecordFlag::F_LAST as u8 | RecordFlag::F_SNAPSHOT as u8;
1585 let flushed = flush_mbo_event_boundary(
1586 instrument_id,
1587 2.into(),
1588 flags,
1589 &mut buffering_start,
1590 &mut buffered,
1591 );
1592 assert!(flushed.is_none());
1593 }
1594
1595 fn stub_trade(instrument_id: InstrumentId) -> Data {
1596 use nautilus_model::{
1597 data::TradeTick,
1598 enums::AggressorSide,
1599 identifiers::TradeId,
1600 types::{Price, Quantity},
1601 };
1602
1603 Data::Trade(TradeTick::new(
1604 instrument_id,
1605 Price::from("100.00"),
1606 Quantity::from("5"),
1607 AggressorSide::Buy,
1608 TradeId::new("1"),
1609 1.into(),
1610 1.into(),
1611 ))
1612 }
1613
1614 #[rstest]
1615 fn test_trade_source_is_first_record_type_to_deliver_a_trade() {
1616 let trade = stub_trade(InstrumentId::from("TEST.GLBX"));
1617 let mut trade_sources = AHashMap::new();
1618
1619 let sent: Vec<bool> = [rtype::MBP_1, rtype::MBP_0, rtype::MBO, rtype::MBP_1]
1620 .into_iter()
1621 .map(|rtype| claim_trade_source(&trade, rtype, &mut trade_sources))
1622 .collect();
1623
1624 assert_eq!(sent, [true, false, false, true]);
1625 }
1626
1627 #[rstest]
1628 fn test_trade_source_is_kept_per_instrument() {
1629 let es_trade = stub_trade(InstrumentId::from("ESM4.GLBX"));
1630 let nq_trade = stub_trade(InstrumentId::from("NQM4.GLBX"));
1631 let mut trade_sources = AHashMap::new();
1632
1633 let sent: Vec<bool> = [
1634 (&es_trade, rtype::MBP_0),
1635 (&nq_trade, rtype::MBO),
1636 (&es_trade, rtype::MBO),
1637 (&nq_trade, rtype::MBP_0),
1638 ]
1639 .into_iter()
1640 .map(|(trade, rtype)| claim_trade_source(trade, rtype, &mut trade_sources))
1641 .collect();
1642
1643 assert_eq!(sent, [true, true, false, false]);
1644 }
1645
1646 #[rstest]
1647 fn test_trade_source_passes_data_other_than_trades() {
1648 let instrument_id = InstrumentId::from("TEST.GLBX");
1649 let mut trade_sources = AHashMap::new();
1650 let _ = claim_trade_source(&stub_trade(instrument_id), rtype::MBP_0, &mut trade_sources);
1651 let delta = Data::BookDelta(stub_delta(instrument_id, 1));
1652
1653 let sent = claim_trade_source(&delta, rtype::MBO, &mut trade_sources);
1654
1655 assert!(sent);
1656 }
1657
1658 fn create_test_handler(reconnect_timeout_mins: Option<u64>) -> DatabentoFeedHandler {
1659 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1660 let (msg_tx, _msg_rx) = tokio::sync::mpsc::unbounded_channel();
1661
1662 DatabentoFeedHandler::new(
1663 Credential::new("test_key"),
1664 "GLBX.MDP3".to_string(),
1665 cmd_rx,
1666 msg_tx,
1667 IndexMap::new(),
1668 Arc::new(AtomicMap::new()),
1669 false,
1670 false,
1671 reconnect_timeout_mins,
1672 )
1673 }
1674
1675 fn create_test_client() -> DatabentoLiveClient {
1676 DatabentoLiveClient::new(
1677 "test-api-key".to_string(),
1678 "GLBX.MDP3".to_string(),
1679 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json"),
1680 true,
1681 None,
1682 None,
1683 )
1684 .unwrap()
1685 }
1686
1687 #[rstest]
1688 #[case(Some(10))]
1689 #[case(None)]
1690 fn test_backoff_initialization(#[case] reconnect_timeout_mins: Option<u64>) {
1691 let handler = create_test_handler(reconnect_timeout_mins);
1692
1693 assert_eq!(handler.reconnect_timeout_mins, reconnect_timeout_mins);
1694 assert!(handler.subscriptions.is_empty());
1695 assert!(handler.buffered_commands.is_empty());
1696 }
1697
1698 #[rstest]
1699 fn test_subscription_with_and_without_start() {
1700 let start_time = datetime!(2024-01-01 00:00:00 UTC);
1701 let sub_with_start = Subscription::builder()
1702 .symbols("ES.FUT")
1703 .schema(databento::dbn::Schema::Mbp1)
1704 .start(start_time)
1705 .build();
1706
1707 let mut sub_without_start = sub_with_start.clone();
1708 sub_without_start.start = None;
1709
1710 assert!(sub_with_start.start.is_some());
1711 assert!(sub_without_start.start.is_none());
1712 assert_eq!(sub_with_start.schema, sub_without_start.schema);
1713 assert_eq!(sub_with_start.symbols, sub_without_start.symbols);
1714 }
1715
1716 #[rstest]
1717 fn test_handler_initialization_state() {
1718 let handler = create_test_handler(Some(10));
1719
1720 assert!(!handler.replay);
1721 assert_eq!(handler.dataset, "GLBX.MDP3");
1722 assert_eq!(handler.credential.api_key(), "test_key");
1723 assert!(handler.subscriptions.is_empty());
1724 assert!(handler.buffered_commands.is_empty());
1725 }
1726
1727 #[rstest]
1728 fn test_handler_with_no_timeout() {
1729 let handler = create_test_handler(None);
1730
1731 assert_eq!(handler.reconnect_timeout_mins, None);
1732 assert!(!handler.replay);
1733 }
1734
1735 #[rstest]
1736 fn test_handler_with_zero_timeout() {
1737 let handler = create_test_handler(Some(0));
1738
1739 assert_eq!(handler.reconnect_timeout_mins, Some(0));
1740 assert!(!handler.replay);
1741 }
1742
1743 #[rstest]
1744 fn test_subscribe_uses_explicit_parent_stype() {
1745 let mut client = create_test_client();
1746
1747 client
1748 .subscribe(
1749 "definition".to_string(),
1750 vec![InstrumentId::from("ES.FUT.GLBX")],
1751 None,
1752 None,
1753 None,
1754 Some("parent".to_string()),
1755 )
1756 .unwrap();
1757
1758 let command = client.cmd_rx.as_mut().unwrap().try_recv().unwrap();
1759 match command {
1760 HandlerCommand::Subscribe(sub) => {
1761 assert_eq!(sub.schema, dbn::Schema::Definition);
1762 assert_eq!(sub.stype_in, dbn::SType::Parent);
1763 assert_eq!(sub.symbols.to_api_string(), "ES.FUT");
1764 }
1765 other => panic!("expected HandlerCommand::Subscribe, was {other:?}"),
1766 }
1767 }
1768
1769 #[rstest]
1770 fn test_subscribe_rejects_invalid_stype() {
1771 let mut client = create_test_client();
1772
1773 let err = client
1774 .subscribe(
1775 "definition".to_string(),
1776 vec![InstrumentId::from("ES.FUT.GLBX")],
1777 None,
1778 None,
1779 None,
1780 Some("not-a-stype".to_string()),
1781 )
1782 .unwrap_err();
1783
1784 assert!(err.to_string().contains("not-a-stype"));
1785 assert!(!is_command_send_error(&err));
1786 assert!(matches!(
1787 client.cmd_rx.as_mut().unwrap().try_recv(),
1788 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1789 ));
1790 }
1791
1792 #[rstest]
1793 fn test_subscribe_classifies_command_send_errors() {
1794 let mut client = create_test_client();
1795 client.cmd_rx = None;
1796
1797 let err = client
1798 .subscribe(
1799 "definition".to_string(),
1800 vec![InstrumentId::from("ES.FUT.GLBX")],
1801 None,
1802 None,
1803 None,
1804 Some("parent".to_string()),
1805 )
1806 .unwrap_err();
1807
1808 assert!(is_command_send_error(&err));
1809 }
1810
1811 #[rstest]
1812 fn test_close_after_handler_exit_marks_closed() {
1813 let mut client = create_test_client();
1814 let (handler, _msg_rx) = client.start().unwrap();
1815 drop(handler);
1816
1817 client.close().unwrap();
1818
1819 assert!(!client.is_running());
1820 assert!(client.is_closed());
1821 }
1822
1823 fn test_delta(instrument_id: InstrumentId, ts_event: u64) -> OrderBookDelta {
1824 OrderBookDelta::clear(instrument_id, 0, ts_event.into(), 0.into())
1825 }
1826
1827 #[rstest]
1828 fn test_mbo_delta_without_f_last_buffers() {
1829 let instrument_id = InstrumentId::from("ESM4.GLBX");
1830 let delta = test_delta(instrument_id, 1_000_000_000);
1831 let mut buffering_start = None;
1832 let mut buffered = AHashMap::new();
1833
1834 let result = process_mbo_delta(delta, 0, &mut buffering_start, &mut buffered);
1835
1836 assert!(result.is_none());
1837 assert_eq!(buffered[&instrument_id].len(), 1);
1838 }
1839
1840 #[rstest]
1841 fn test_mbo_single_f_last_emits_without_buffering() {
1842 let instrument_id = InstrumentId::from("ESM4.GLBX");
1843 let ts_event = 1_000_000_000;
1844 let mut delta = test_delta(instrument_id, ts_event);
1845 delta.flags = 128;
1846 delta.sequence = 42;
1847 let mut buffering_start = None;
1848 let mut buffered = AHashMap::new();
1849
1850 let result = process_mbo_delta(delta, delta.flags, &mut buffering_start, &mut buffered);
1851
1852 let emitted = result.expect("single F_LAST delta should emit");
1853 assert_eq!(emitted.instrument_id, instrument_id);
1854 assert_eq!(emitted.deltas.len(), 1);
1855 assert_eq!(emitted.flags, 128);
1856 assert_eq!(emitted.sequence, 42);
1857 assert_eq!(emitted.ts_event, UnixNanos::from(ts_event));
1858 assert_eq!(emitted.deltas[0].instrument_id, instrument_id);
1859 assert_eq!(emitted.deltas[0].flags, 128);
1860 assert_eq!(emitted.deltas[0].sequence, 42);
1861 assert_eq!(emitted.deltas[0].ts_event, UnixNanos::from(ts_event));
1862 assert!(buffering_start.is_none());
1863 assert!(buffered.is_empty());
1864 }
1865
1866 #[rstest]
1867 fn test_mbo_delta_with_f_last_emits() {
1868 let instrument_id = InstrumentId::from("ESM4.GLBX");
1869 let mut buffering_start = None;
1870 let mut buffered = AHashMap::new();
1871
1872 let _ = process_mbo_delta(
1873 test_delta(instrument_id, 1_000_000_000),
1874 0,
1875 &mut buffering_start,
1876 &mut buffered,
1877 );
1878
1879 let result = process_mbo_delta(
1880 test_delta(instrument_id, 2_000_000_000),
1881 128, &mut buffering_start,
1883 &mut buffered,
1884 );
1885
1886 assert!(result.is_some());
1887 assert_eq!(result.unwrap().deltas.len(), 2);
1888 assert!(buffered.is_empty());
1889 }
1890
1891 #[rstest]
1892 fn test_mbo_snapshot_with_f_last_buffers() {
1893 let instrument_id = InstrumentId::from("ESM4.GLBX");
1894 let mut buffering_start = None;
1895 let mut buffered = AHashMap::new();
1896
1897 let result = process_mbo_delta(
1898 test_delta(instrument_id, 1_000_000_000),
1899 128 | 32, &mut buffering_start,
1901 &mut buffered,
1902 );
1903
1904 assert!(result.is_none());
1905 assert_eq!(buffered[&instrument_id].len(), 1);
1906 }
1907
1908 #[rstest]
1909 fn test_mbo_replay_buffers_until_past_start() {
1910 let instrument_id = InstrumentId::from("ESM4.GLBX");
1911 let start_ns = 5_000_000_000u64;
1912 let mut buffering_start = Some(start_ns.into());
1913 let mut buffered = AHashMap::new();
1914
1915 let result = process_mbo_delta(
1916 test_delta(instrument_id, 4_000_000_000),
1917 128, &mut buffering_start,
1919 &mut buffered,
1920 );
1921 assert!(result.is_none());
1922
1923 let result = process_mbo_delta(
1924 test_delta(instrument_id, 5_000_000_000),
1925 128,
1926 &mut buffering_start,
1927 &mut buffered,
1928 );
1929 assert!(result.is_none());
1930
1931 let result = process_mbo_delta(
1933 test_delta(instrument_id, 6_000_000_000),
1934 128,
1935 &mut buffering_start,
1936 &mut buffered,
1937 );
1938 assert!(result.is_some());
1939 assert!(buffering_start.is_none());
1940 }
1941
1942 #[rstest]
1943 fn test_mbo_multiple_deltas_accumulated() {
1944 let instrument_id = InstrumentId::from("ESM4.GLBX");
1945 let mut buffering_start = None;
1946 let mut buffered = AHashMap::new();
1947
1948 for i in 0..5 {
1949 process_mbo_delta(
1950 test_delta(instrument_id, 1_000_000_000 + i),
1951 0,
1952 &mut buffering_start,
1953 &mut buffered,
1954 );
1955 }
1956
1957 let result = process_mbo_delta(
1958 test_delta(instrument_id, 2_000_000_000),
1959 128,
1960 &mut buffering_start,
1961 &mut buffered,
1962 );
1963
1964 assert!(result.is_some());
1965 assert_eq!(result.unwrap().deltas.len(), 6);
1966 }
1967
1968 #[rstest]
1969 fn test_mbo_multi_instrument_isolation() {
1970 let id_a = InstrumentId::from("ESM4.GLBX");
1971 let id_b = InstrumentId::from("NQM4.GLBX");
1972 let mut buffering_start = None;
1973 let mut buffered = AHashMap::new();
1974
1975 process_mbo_delta(
1976 test_delta(id_a, 1_000_000_000),
1977 0,
1978 &mut buffering_start,
1979 &mut buffered,
1980 );
1981 process_mbo_delta(
1982 test_delta(id_b, 1_000_000_000),
1983 0,
1984 &mut buffering_start,
1985 &mut buffered,
1986 );
1987
1988 let result = process_mbo_delta(
1990 test_delta(id_a, 2_000_000_000),
1991 128,
1992 &mut buffering_start,
1993 &mut buffered,
1994 );
1995
1996 assert!(result.is_some());
1997 assert_eq!(result.unwrap().instrument_id, id_a);
1998 assert!(buffered.contains_key(&id_b));
1999 assert!(!buffered.contains_key(&id_a));
2000 }
2001
2002 mod property_tests {
2003 use proptest::prelude::*;
2004 use rstest::rstest;
2005
2006 use super::*;
2007
2008 proptest! {
2009 #[rstest]
2010 fn mbo_buffering_conserves_deltas(
2011 num_non_last in 0usize..=20,
2012 ) {
2013 let instrument_id = InstrumentId::from("ESM4.GLBX");
2014 let mut buffering_start = None;
2015 let mut buffered = AHashMap::new();
2016 let total = num_non_last + 1;
2017
2018 for i in 0..num_non_last {
2019 let result = process_mbo_delta(
2020 test_delta(instrument_id, 1_000_000_000 + i as u64),
2021 0, &mut buffering_start,
2023 &mut buffered,
2024 );
2025 prop_assert!(result.is_none());
2026 }
2027
2028 let result = process_mbo_delta(
2029 test_delta(instrument_id, 2_000_000_000),
2030 128, &mut buffering_start,
2032 &mut buffered,
2033 );
2034
2035 prop_assert!(result.is_some());
2036 let emitted = result.unwrap();
2037 prop_assert_eq!(emitted.deltas.len(), total);
2038 prop_assert!(buffered.is_empty());
2039 }
2040
2041 #[rstest]
2042 fn mbo_snapshots_never_emit(
2043 num_snapshots in 1usize..=20,
2044 ) {
2045 let instrument_id = InstrumentId::from("ESM4.GLBX");
2046 let mut buffering_start = None;
2047 let mut buffered = AHashMap::new();
2048
2049 for i in 0..num_snapshots {
2050 let result = process_mbo_delta(
2051 test_delta(instrument_id, 1_000_000_000 + i as u64),
2052 128 | 32, &mut buffering_start,
2054 &mut buffered,
2055 );
2056 prop_assert!(result.is_none());
2057 }
2058
2059 prop_assert_eq!(buffered[&instrument_id].len(), num_snapshots);
2060 }
2061
2062 #[rstest]
2063 fn mbo_replay_delays_emission(
2064 start_offset in 1u64..=100,
2065 num_before in 1usize..=10,
2066 ) {
2067 let instrument_id = InstrumentId::from("ESM4.GLBX");
2068 let start_ns = 1_000_000_000u64 * start_offset;
2069 let mut buffering_start = Some(start_ns.into());
2070 let mut buffered = AHashMap::new();
2071
2072 for i in 0..num_before {
2073 let ts = start_ns - (num_before as u64 - i as u64);
2074 let result = process_mbo_delta(
2075 test_delta(instrument_id, ts),
2076 128, &mut buffering_start,
2078 &mut buffered,
2079 );
2080 prop_assert!(result.is_none());
2081 }
2082
2083 let result = process_mbo_delta(
2084 test_delta(instrument_id, start_ns + 1),
2085 128,
2086 &mut buffering_start,
2087 &mut buffered,
2088 );
2089
2090 prop_assert!(result.is_some());
2091 prop_assert!(buffering_start.is_none());
2092 let total = num_before + 1;
2093 prop_assert_eq!(result.unwrap().deltas.len(), total);
2094 }
2095 }
2096 }
2097}