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}