Skip to main content

rustrade_data/exchange/databento/
live.rs

1//! Live streaming connector for Databento.
2//!
3//! Provides real-time market data streaming via [`DatabentoLive`].
4//!
5//! # Architecture
6//!
7//! - **One connection per dataset**: Each `DatabentoLive` connects to one dataset (e.g., GLBX.MDP3).
8//!   For multiple datasets, create multiple instances.
9//! - **Multiple symbols per connection**: Databento recommends consolidating subscriptions within
10//!   a dataset. Subscribe to multiple symbols before calling `start()`.
11//! - **Symbol resolution**: DBN records contain numeric `instrument_id`. We use `PitSymbolMap` to
12//!   resolve these to symbol strings, then map to user-provided instrument keys.
13//!
14//! # Example
15//!
16//! ```ignore
17//! use rustrade_data::exchange::databento::DatabentoLive;
18//! use rustrade_instrument::exchange::ExchangeId;
19//! use databento::dbn::Schema;
20//! use std::collections::HashMap;
21//! use futures::StreamExt;
22//!
23//! // Map symbols to your instrument keys
24//! let instruments: HashMap<String, String> = [
25//!     ("ESM5".to_string(), "ES-front".to_string()),
26//!     ("NQM5".to_string(), "NQ-front".to_string()),
27//! ].into_iter().collect();
28//!
29//! let mut client = DatabentoLive::from_env(
30//!     "GLBX.MDP3",
31//!     ExchangeId::DatabentoGlbx,
32//!     instruments,
33//! ).await?;
34//!
35//! // Subscribe to symbols (can call multiple times)
36//! client.subscribe(&["ESM5", "NQM5"], Schema::Trades).await?;
37//!
38//! // Start streaming (consumes client)
39//! let mut stream = client.start().await?;
40//!
41//! while let Some(event) = stream.next().await {
42//!     match event {
43//!         Ok(market_event) => println!("{:?}", market_event),
44//!         Err(e) => eprintln!("Error: {}", e),
45//!     }
46//! }
47//! ```
48
49use super::error::{DatabentoErrorKind, DatabentoResultExt};
50use super::transformer::{
51    dbn_mbp1_to_orderbook_l1, dbn_ohlcv_to_candle, dbn_trade_to_public_trade,
52    ensure_databento_ohlcv_supports, rtype_to_candle_interval,
53};
54use crate::error::DataError;
55use crate::event::{DataKind, MarketEvent};
56use crate::subscription::candle::CandleInterval;
57use chrono::Utc;
58use databento::LiveClient;
59use databento::dbn::{Mbp1Msg, OhlcvMsg, PitSymbolMap, RecordRef, Schema, TradeMsg};
60use databento::live::Subscription;
61use futures::Stream;
62use rustrade_instrument::exchange::ExchangeId;
63use std::collections::HashMap;
64use tracing::{debug, trace, warn};
65
66/// Live streaming client for Databento.
67///
68/// Wraps [`LiveClient`] for real-time market data streaming. Each instance connects
69/// to a single dataset but can subscribe to multiple symbols within that dataset.
70///
71/// # Connection Limits
72///
73/// - Standard tier: 10 concurrent connections per dataset
74/// - Enterprise tier: 50 concurrent connections per dataset
75///
76/// Consolidate symbol subscriptions within one `DatabentoLive` instance per dataset
77/// rather than creating multiple instances for the same dataset.
78///
79/// # Performance
80///
81/// The instrument key is cloned for each record. For high-frequency data, use
82/// [`Arc<K>`](std::sync::Arc) to avoid per-record heap allocations:
83///
84/// ```ignore
85/// use std::sync::Arc;
86///
87/// let instruments: HashMap<String, Arc<String>> = [
88///     ("ESM5".to_string(), Arc::new("ES-front".to_string())),
89/// ].into_iter().collect();
90/// ```
91#[derive(Debug)]
92pub struct DatabentoLive<K> {
93    client: LiveClient,
94    instruments: HashMap<String, K>,
95    exchange: ExchangeId,
96}
97
98impl<K> DatabentoLive<K> {
99    /// Create a new live client using API key from environment.
100    ///
101    /// Reads `DATABENTO_API_KEY` from environment variables.
102    ///
103    /// # Arguments
104    ///
105    /// * `dataset` - Databento dataset identifier (e.g., "GLBX.MDP3", "XNAS.ITCH")
106    /// * `exchange` - ExchangeId to tag events with (should match dataset)
107    /// * `instruments` - Map from Databento symbol strings to user's instrument keys
108    ///
109    /// # Errors
110    ///
111    /// Returns error if `DATABENTO_API_KEY` is not set or client construction fails.
112    pub async fn from_env(
113        dataset: &str,
114        exchange: ExchangeId,
115        instruments: HashMap<String, K>,
116    ) -> Result<Self, DataError> {
117        debug!(dataset, "Creating Databento live client from env");
118
119        let client = LiveClient::builder()
120            .key_from_env()
121            .with_context("reading API key from env")?
122            .dataset(dataset)
123            .build()
124            .await
125            .with_context("building live client")?;
126
127        Ok(Self {
128            client,
129            instruments,
130            exchange,
131        })
132    }
133
134    /// Create a new live client with an explicit API key.
135    ///
136    /// # Errors
137    ///
138    /// Returns error if client construction fails.
139    pub async fn new(
140        api_key: &str,
141        dataset: &str,
142        exchange: ExchangeId,
143        instruments: HashMap<String, K>,
144    ) -> Result<Self, DataError> {
145        debug!(dataset, "Creating Databento live client");
146
147        let client = LiveClient::builder()
148            .key(api_key)
149            .with_context("setting API key")?
150            .dataset(dataset)
151            .build()
152            .await
153            .with_context("building live client")?;
154
155        Ok(Self {
156            client,
157            instruments,
158            exchange,
159        })
160    }
161
162    /// Subscribe to symbols with a specific schema.
163    ///
164    /// Can be called multiple times before `start()` to add more subscriptions.
165    /// All subscriptions are multiplexed over the same connection.
166    ///
167    /// # Arguments
168    ///
169    /// * `symbols` - Symbol identifiers (e.g., `["ESM5", "NQM5"]`)
170    /// * `schema` - Data schema to subscribe to (e.g., `Schema::Trades`, `Schema::Mbp1`)
171    ///
172    /// # OHLCV note
173    ///
174    /// This is the low-level escape hatch: it subscribes to any `Schema` as-is and
175    /// performs **no** interval validation. Subscribing to an OHLCV schema here
176    /// (e.g. `Schema::Ohlcv1H`/`Schema::Ohlcv1D`) bypasses the `Sec1`/`Min1`
177    /// live-only check in [`subscribe_candles`](Self::subscribe_candles), and any
178    /// resulting bars whose `rtype` maps to a [`CandleInterval`] will be emitted as
179    /// `DataKind::Candle`. Prefer [`subscribe_candles`](Self::subscribe_candles) for
180    /// candles unless you deliberately want that lower-level behaviour.
181    ///
182    /// # Errors
183    ///
184    /// Returns error if subscription request fails.
185    pub async fn subscribe(&mut self, symbols: &[&str], schema: Schema) -> Result<(), DataError> {
186        debug!(?symbols, ?schema, "Subscribing to Databento live feed");
187
188        let subscription = Subscription::builder()
189            .symbols(symbols)
190            .schema(schema)
191            .build();
192
193        self.client
194            .subscribe(subscription)
195            .await
196            .with_context("subscribing to live feed")?;
197
198        Ok(())
199    }
200
201    /// Subscribe to OHLCV candles for `symbols` at the given interval.
202    ///
203    /// Validates the interval and maps it to the Databento OHLCV schema before
204    /// subscribing. The resulting stream emits [`DataKind::Candle`] events whose
205    /// interval is derived per-record from each bar's `rtype`, so a single
206    /// connection may safely carry multiple OHLCV intervals.
207    ///
208    /// # Live interval scope
209    ///
210    /// Only `Sec1`/`Min1` are accepted live. Databento's live gateway does not
211    /// reliably stream the larger `Hour1`/`Day1` bars, so those are
212    /// **historical-only** here — use [`DatabentoHistorical::fetch_candles`].
213    /// They are rejected at subscribe time (observable failure) rather than
214    /// yielding a silently empty stream.
215    ///
216    /// [`DatabentoHistorical::fetch_candles`]: super::historical::DatabentoHistorical::fetch_candles
217    ///
218    /// # Errors
219    ///
220    /// Returns [`DataError::UnsupportedInterval`] for any interval other than
221    /// `Sec1`/`Min1`, or [`DataError::Databento`] if the subscription request fails.
222    pub async fn subscribe_candles(
223        &mut self,
224        symbols: &[&str],
225        interval: CandleInterval,
226    ) -> Result<(), DataError> {
227        let schema = ensure_databento_live_ohlcv_supports(self.exchange, interval)?;
228        self.subscribe(symbols, schema).await
229    }
230
231    /// Returns a reference to the underlying client for advanced use cases.
232    pub fn client(&self) -> &LiveClient {
233        &self.client
234    }
235
236    /// Returns a mutable reference to the underlying client.
237    pub fn client_mut(&mut self) -> &mut LiveClient {
238        &mut self.client
239    }
240}
241
242impl<K: Clone + Send + 'static> DatabentoLive<K> {
243    /// Start streaming and return an owned stream of market events.
244    ///
245    /// Consumes `self` because `LiveClient` requires mutable access for polling.
246    /// The stream will emit events until the connection closes or an error occurs.
247    ///
248    /// # Returns
249    ///
250    /// A stream of `MarketEvent<K, DataKind>` where `DataKind` is one of `Trade`,
251    /// `OrderBookL1`, or `Candle` depending on the subscribed schema(s).
252    ///
253    /// Records for symbols not in the `instruments` map are skipped with a warning.
254    ///
255    /// # Errors
256    ///
257    /// Returns error if starting the stream fails. Stream items may also contain
258    /// errors for individual record processing failures.
259    pub async fn start(
260        mut self,
261    ) -> Result<impl Stream<Item = Result<MarketEvent<K, DataKind>, DataError>>, DataError> {
262        debug!("Starting Databento live stream");
263
264        self.client
265            .start()
266            .await
267            .with_context("starting live stream")?;
268
269        let stream = futures::stream::unfold(
270            StreamState {
271                client: self.client,
272                symbol_map: PitSymbolMap::new(),
273                instruments: self.instruments,
274                exchange: self.exchange,
275            },
276            |mut state| async move {
277                loop {
278                    let record = match state.client.next_record().await {
279                        Ok(Some(rec)) => rec,
280                        Ok(None) => {
281                            debug!("Databento live stream ended");
282                            return None;
283                        }
284                        Err(e) => {
285                            let err = DataError::Databento {
286                                kind: DatabentoErrorKind::Network,
287                                context: "receiving record".to_string(),
288                                message: e.to_string(),
289                            };
290                            return Some((Err(err), state));
291                        }
292                    };
293
294                    // Update symbol map with every record (InstrumentDefMsg populates mappings)
295                    if let Err(e) = state.symbol_map.on_record(record) {
296                        warn!(error = %e, "Failed to update symbol map");
297                    }
298
299                    // Try to convert to market event
300                    if let Some(result) = process_record(
301                        record,
302                        &state.symbol_map,
303                        &state.instruments,
304                        state.exchange,
305                    ) {
306                        return Some((result, state));
307                    }
308
309                    // Record type not handled or symbol not in instruments map, continue polling
310                }
311            },
312        );
313
314        Ok(stream)
315    }
316}
317
318struct StreamState<K> {
319    client: LiveClient,
320    symbol_map: PitSymbolMap,
321    instruments: HashMap<String, K>,
322    exchange: ExchangeId,
323}
324
325/// Process a DBN record into a MarketEvent if applicable.
326///
327/// Returns `None` for record types we don't handle (e.g., InstrumentDefMsg, SymbolMappingMsg)
328/// or for symbols not in the instruments map.
329fn process_record<K: Clone>(
330    record: RecordRef<'_>,
331    symbol_map: &PitSymbolMap,
332    instruments: &HashMap<String, K>,
333    exchange: ExchangeId,
334) -> Option<Result<MarketEvent<K, DataKind>, DataError>> {
335    // Try TradeMsg first
336    if let Some(trade) = record.get::<TradeMsg>() {
337        // Resolve symbol from instrument_id via PitSymbolMap
338        let symbol = symbol_map.get(trade.hd.instrument_id)?;
339
340        let instrument = match instruments.get(symbol) {
341            Some(key) => key.clone(),
342            None => {
343                trace!(%symbol, "Skipping trade for unknown symbol");
344                return None;
345            }
346        };
347
348        match dbn_trade_to_public_trade(trade) {
349            Ok((time_exchange, public_trade)) => {
350                return Some(Ok(MarketEvent {
351                    time_exchange,
352                    time_received: Utc::now(),
353                    exchange,
354                    instrument,
355                    kind: DataKind::Trade(public_trade),
356                }));
357            }
358            Err(e) => {
359                // A record has exactly one rtype; no need to try Mbp1Msg.
360                debug!(error = %e, %symbol, "Skipping invalid trade record");
361                return None;
362            }
363        }
364    }
365
366    // Try Mbp1Msg for quotes/L1
367    if let Some(mbp1) = record.get::<Mbp1Msg>() {
368        let symbol = symbol_map.get(mbp1.hd.instrument_id)?;
369
370        let instrument = match instruments.get(symbol) {
371            Some(key) => key.clone(),
372            None => {
373                trace!(%symbol, "Skipping quote for unknown symbol");
374                return None;
375            }
376        };
377
378        match dbn_mbp1_to_orderbook_l1(mbp1) {
379            Ok((time_exchange, l1)) => {
380                return Some(Ok(MarketEvent {
381                    time_exchange,
382                    time_received: Utc::now(),
383                    exchange,
384                    instrument,
385                    kind: DataKind::OrderBookL1(l1),
386                }));
387            }
388            Err(e) => {
389                debug!(error = %e, %symbol, "Skipping invalid quote record");
390                return None;
391            }
392        }
393    }
394
395    // Try OhlcvMsg for candles
396    if let Some(ohlcv) = record.get::<OhlcvMsg>() {
397        // Derive the interval from this record's own rtype: one connection may
398        // interleave multiple OHLCV schemas. `None` => an rtype with no
399        // CandleInterval (ohlcv-eod / ohlcv-deprecated); skip it observably
400        // rather than crashing the stream (a caller can subscribe ohlcv-eod).
401        let interval = match rtype_to_candle_interval(ohlcv.hd.rtype) {
402            Some(interval) => interval,
403            None => {
404                debug!(
405                    rtype = ohlcv.hd.rtype,
406                    "Skipping OHLCV record with no CandleInterval (eod/deprecated)"
407                );
408                return None;
409            }
410        };
411
412        let symbol = symbol_map.get(ohlcv.hd.instrument_id)?;
413
414        let instrument = match instruments.get(symbol) {
415            Some(key) => key.clone(),
416            None => {
417                trace!(%symbol, "Skipping candle for unknown symbol");
418                return None;
419            }
420        };
421
422        // Native OHLCV bars are always final/closed, so every record yields a
423        // candle. Conversion only fails on timestamp/close_time overflow, which
424        // the close_time contract requires be surfaced, never skipped.
425        return Some(
426            dbn_ohlcv_to_candle(ohlcv, interval).map(|(time_exchange, candle)| MarketEvent {
427                time_exchange,
428                time_received: Utc::now(),
429                exchange,
430                instrument,
431                kind: DataKind::Candle(candle),
432            }),
433        );
434    }
435
436    // Other record types (InstrumentDefMsg, etc.) are silently skipped
437    None
438}
439
440/// Validate and map a candle interval for the **live** feed (`Sec1`/`Min1` only).
441///
442/// First rejects the intervals Databento serves on no OHLCV path (via
443/// [`ensure_databento_ohlcv_supports`]), then additionally rejects `Hour1`/`Day1`:
444/// those are valid Databento schemas but are not reliably emitted by the live
445/// gateway, so they are historical-only here. Widening this is an additive
446/// change if a live key later confirms the larger bars stream.
447fn ensure_databento_live_ohlcv_supports(
448    exchange: ExchangeId,
449    interval: CandleInterval,
450) -> Result<Schema, DataError> {
451    let schema = ensure_databento_ohlcv_supports(exchange, interval)?;
452    match interval {
453        CandleInterval::Sec1 | CandleInterval::Min1 => Ok(schema),
454        _ => Err(DataError::UnsupportedInterval { exchange, interval }),
455    }
456}
457
458#[cfg(test)]
459// Test code may unwrap freely since panics indicate test failure
460#[allow(clippy::unwrap_used)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn live_ohlcv_accepts_only_sec1_and_min1() {
466        let ex = ExchangeId::DatabentoGlbx;
467        assert_eq!(
468            ensure_databento_live_ohlcv_supports(ex, CandleInterval::Sec1).unwrap(),
469            Schema::Ohlcv1S
470        );
471        assert_eq!(
472            ensure_databento_live_ohlcv_supports(ex, CandleInterval::Min1).unwrap(),
473            Schema::Ohlcv1M
474        );
475
476        // 1h/1d are valid Databento schemas but historical-only on the live feed:
477        // rejected observably, not silently subscribed.
478        for interval in [CandleInterval::Hour1, CandleInterval::Day1] {
479            assert!(
480                matches!(
481                    ensure_databento_live_ohlcv_supports(ex, interval),
482                    Err(DataError::UnsupportedInterval { interval: i, .. }) if i == interval
483                ),
484                "expected {interval} to be rejected live"
485            );
486        }
487
488        // A non-native interval is rejected too.
489        assert!(ensure_databento_live_ohlcv_supports(ex, CandleInterval::Min5).is_err());
490    }
491}