rustrade_data/exchange/databento/historical.rs
1//! Historical data fetcher for Databento.
2//!
3//! Provides one-shot historical data queries and DBN file loading via [`DatabentoHistorical`].
4//!
5//! # Example
6//!
7//! ```ignore
8//! use rustrade_data::exchange::databento::DatabentoHistorical;
9//! use databento::historical::timeseries::GetRangeParams;
10//!
11//! let mut client = DatabentoHistorical::from_env()?;
12//!
13//! // Fetch trades for ES futures
14//! let params = GetRangeParams::builder()
15//! .dataset("GLBX.MDP3")
16//! .symbols("ES.FUT")
17//! .schema(dbn::Schema::Trades)
18//! .date_time_range(start..end)
19//! .build();
20//!
21//! let trades = client.fetch_trades(¶ms, ExchangeId::DatabentoGlbx, "ES").await?;
22//! ```
23
24use super::error::{DatabentoResultExt, decode_error};
25use super::transformer::{
26 dbn_mbp1_to_quote, dbn_ohlcv_to_candle, dbn_trade_to_public_trade,
27 ensure_databento_ohlcv_supports,
28};
29use crate::error::DataError;
30use crate::event::MarketEvent;
31use crate::subscription::candle::{Candle, CandleInterval};
32use crate::subscription::{quote::Quote, trade::PublicTrade};
33use chrono::{DateTime, Utc};
34use databento::HistoricalClient;
35use databento::dbn::decode::DynDecoder;
36use databento::dbn::enums::VersionUpgradePolicy;
37use databento::dbn::{self, RType, Schema, decode::DecodeRecord};
38use databento::historical::timeseries::GetRangeParams;
39use futures::Stream;
40use rustrade_instrument::exchange::ExchangeId;
41use std::path::Path;
42use tracing::{debug, info};
43
44/// Historical data fetcher for Databento.
45///
46/// Wraps [`HistoricalClient`] for fetching historical market data older than 24 hours.
47#[derive(Debug)]
48pub struct DatabentoHistorical {
49 client: HistoricalClient,
50}
51
52impl DatabentoHistorical {
53 /// Create a new historical client using API key from environment.
54 ///
55 /// Reads `DATABENTO_API_KEY` from environment variables.
56 ///
57 /// # Errors
58 ///
59 /// Returns error if `DATABENTO_API_KEY` is not set or client construction fails.
60 pub fn from_env() -> Result<Self, DataError> {
61 debug!("Creating Databento historical client from env");
62 let client = HistoricalClient::builder()
63 .key_from_env()
64 .with_context("reading API key from env")?
65 .build()
66 .with_context("building historical client")?;
67
68 Ok(Self { client })
69 }
70
71 /// Create a new historical client with an explicit API key.
72 ///
73 /// # Errors
74 ///
75 /// Returns error if client construction fails.
76 pub fn new(api_key: &str) -> Result<Self, DataError> {
77 debug!("Creating Databento historical client");
78 let client = HistoricalClient::builder()
79 .key(api_key)
80 .with_context("setting API key")?
81 .build()
82 .with_context("building historical client")?;
83
84 Ok(Self { client })
85 }
86
87 /// Fetch historical trades for the given parameters.
88 ///
89 /// Collects all records into memory before returning. For large queries
90 /// (millions of records), consider [`fetch_trades_stream`](Self::fetch_trades_stream)
91 /// to process records incrementally.
92 ///
93 /// # Arguments
94 ///
95 /// * `params` - Query parameters (dataset, symbols, time range)
96 /// * `exchange` - ExchangeId to tag events with (should match dataset)
97 /// * `instrument` - Instrument key to tag events with
98 ///
99 /// # Performance
100 ///
101 /// The instrument key is cloned for each record. For high-frequency data,
102 /// use [`Arc<K>`](std::sync::Arc) to avoid per-record heap allocations:
103 ///
104 /// ```ignore
105 /// let instrument = Arc::new("ES".to_string());
106 /// let trades = client.fetch_trades(¶ms, exchange, instrument).await?;
107 /// ```
108 ///
109 /// # Returns
110 ///
111 /// Vector of trades converted to [`PublicTrade`] events. Each event's
112 /// `time_received` is stamped at decode time on the local machine.
113 pub async fn fetch_trades<K: Clone>(
114 &mut self,
115 params: &GetRangeParams,
116 exchange: ExchangeId,
117 instrument: K,
118 ) -> Result<Vec<MarketEvent<K, PublicTrade>>, DataError> {
119 debug!(?params, "Fetching historical trades from Databento");
120
121 let mut decoder = self
122 .client
123 .timeseries()
124 .get_range(params)
125 .await
126 .with_context("fetching trades")?;
127
128 let mut trades = Vec::with_capacity(4096);
129
130 while let Some(record) = decoder
131 .decode_record::<dbn::TradeMsg>()
132 .await
133 .with_context("decoding trade record")?
134 {
135 match dbn_trade_to_public_trade(record) {
136 Ok((time_exchange, trade)) => {
137 trades.push(MarketEvent {
138 time_exchange,
139 time_received: Utc::now(),
140 exchange,
141 instrument: instrument.clone(),
142 kind: trade,
143 });
144 }
145 Err(e) => {
146 debug!(error = %e, "Skipping invalid trade record");
147 }
148 }
149 }
150
151 info!(count = trades.len(), "Fetched historical trades");
152 Ok(trades)
153 }
154
155 /// Fetch historical quotes (top-of-book) for the given parameters.
156 ///
157 /// Uses MBP-1 (Market By Price level 1) schema. Collects all records into
158 /// memory before returning. For large queries, consider
159 /// [`fetch_quotes_stream`](Self::fetch_quotes_stream).
160 ///
161 /// # Arguments
162 ///
163 /// * `params` - Query parameters (dataset, symbols, time range). Schema should be Mbp1.
164 /// * `exchange` - ExchangeId to tag events with
165 /// * `instrument` - Instrument key to tag events with
166 ///
167 /// # Performance
168 ///
169 /// The instrument key is cloned for each record. For high-frequency data,
170 /// use [`Arc<K>`](std::sync::Arc) to avoid per-record heap allocations.
171 ///
172 /// Each event's `time_received` is stamped at decode time on the local machine.
173 pub async fn fetch_quotes<K: Clone>(
174 &mut self,
175 params: &GetRangeParams,
176 exchange: ExchangeId,
177 instrument: K,
178 ) -> Result<Vec<MarketEvent<K, Quote>>, DataError> {
179 debug!(?params, "Fetching historical quotes from Databento");
180
181 let mut decoder = self
182 .client
183 .timeseries()
184 .get_range(params)
185 .await
186 .with_context("fetching quotes")?;
187
188 let mut quotes = Vec::with_capacity(4096);
189
190 while let Some(record) = decoder
191 .decode_record::<dbn::Mbp1Msg>()
192 .await
193 .with_context("decoding quote record")?
194 {
195 match dbn_mbp1_to_quote(record) {
196 Ok((time_exchange, quote)) => {
197 quotes.push(MarketEvent {
198 time_exchange,
199 time_received: Utc::now(),
200 exchange,
201 instrument: instrument.clone(),
202 kind: quote,
203 });
204 }
205 Err(e) => {
206 debug!(error = %e, "Skipping invalid quote record");
207 }
208 }
209 }
210
211 info!(count = quotes.len(), "Fetched historical quotes");
212 Ok(quotes)
213 }
214
215 /// Fetch historical OHLCV candles for the given parameters.
216 ///
217 /// Collects all records into memory before returning. For large queries,
218 /// consider [`fetch_candles_stream`](Self::fetch_candles_stream).
219 ///
220 /// # Schema derivation
221 ///
222 /// The DBN schema is derived **internally** from
223 /// [`DatabentoOhlcvParams::interval`] via the crate-internal
224 /// `ensure_databento_ohlcv_supports`, so the interval and schema cannot
225 /// diverge — the caller never supplies a [`Schema`]. The interval is also
226 /// load-bearing for each candle's `close_time`
227 /// (see [`Candle::close_time`](crate::subscription::candle::Candle::close_time)).
228 ///
229 /// # Time range
230 ///
231 /// [`DatabentoOhlcvParams::time_range`] is `(start, end)` with **inclusive
232 /// start and exclusive end**, filtering on the bar's `ts_event` — i.e. the
233 /// bar **open** instant, Databento's native OHLCV filter field — not the
234 /// normalised `close_time`. (Unlike the Hyperliquid path, no close-time
235 /// range-widening/trimming is applied; the range is passed through to
236 /// Databento's native open-time filter.)
237 ///
238 /// # Arguments
239 ///
240 /// * `params` - Typed OHLCV query parameters (dataset, symbols, range, interval)
241 /// * `exchange` - ExchangeId to tag events with (should match dataset)
242 /// * `instrument` - Instrument key to tag events with (use `Arc<K>` for efficiency)
243 ///
244 /// # Errors
245 ///
246 /// Returns [`DataError::UnsupportedInterval`] if `params.interval` is not one
247 /// of Databento's native intervals (`1s`/`1m`/`1h`/`1d`); [`DataError::Databento`]
248 /// if the request fails, a record fails to decode, a price field carries the
249 /// `UNDEF_PRICE` sentinel, or a candle's `close_time` overflows. A conversion
250 /// failure is **propagated**, never silently skipped (native OHLCV bars have no
251 /// skippable-malformed mode).
252 pub async fn fetch_candles<K: Clone>(
253 &mut self,
254 params: &DatabentoOhlcvParams,
255 exchange: ExchangeId,
256 instrument: K,
257 ) -> Result<Vec<MarketEvent<K, Candle>>, DataError> {
258 let schema = ensure_databento_ohlcv_supports(exchange, params.interval)?;
259 let get_range = build_ohlcv_get_range(params, schema);
260
261 debug!(?get_range, "Fetching historical candles from Databento");
262
263 let mut decoder = self
264 .client
265 .timeseries()
266 .get_range(&get_range)
267 .await
268 .with_context("fetching candles")?;
269
270 let mut candles = Vec::with_capacity(4096);
271
272 while let Some(record) = decoder
273 .decode_record::<dbn::OhlcvMsg>()
274 .await
275 .with_context("decoding OHLCV record")?
276 {
277 assert_ohlcv_rtype_matches(record.hd.rtype, schema)?;
278 let (time_exchange, candle) = dbn_ohlcv_to_candle(record, params.interval)?;
279 candles.push(MarketEvent {
280 time_exchange,
281 time_received: Utc::now(),
282 exchange,
283 instrument: instrument.clone(),
284 kind: candle,
285 });
286 }
287
288 info!(count = candles.len(), "Fetched historical candles");
289 Ok(candles)
290 }
291
292 /// Stream historical OHLCV candles without collecting into memory.
293 ///
294 /// Unlike [`fetch_candles`](Self::fetch_candles), this returns a stream that
295 /// yields records as they're decoded, avoiding memory spikes for large queries.
296 ///
297 /// See [`fetch_candles`](Self::fetch_candles) for schema-derivation and
298 /// time-range semantics.
299 ///
300 /// # Errors
301 ///
302 /// Returns [`DataError::UnsupportedInterval`] up front if `params.interval` is
303 /// unsupported, or [`DataError::Databento`] if the initial `get_range` request
304 /// fails. Per-item errors are yielded as `Err` items: a DBN decode failure (the
305 /// decoder is then in an unspecified state — drop the stream), an rtype/schema
306 /// contract violation (`assert_ohlcv_rtype_matches`; the stream keeps polling
307 /// but every record is likely to fail the same way — drop the stream), or a
308 /// candle conversion failure (an `UNDEF_PRICE` sentinel or `close_time`
309 /// overflow — surfaced, never skipped).
310 pub async fn fetch_candles_stream<K: Clone + Send + 'static>(
311 &mut self,
312 params: &DatabentoOhlcvParams,
313 exchange: ExchangeId,
314 instrument: K,
315 ) -> Result<impl Stream<Item = Result<MarketEvent<K, Candle>, DataError>>, DataError> {
316 let schema = ensure_databento_ohlcv_supports(exchange, params.interval)?;
317 let get_range = build_ohlcv_get_range(params, schema);
318
319 debug!(?get_range, "Streaming historical candles from Databento");
320
321 let decoder = self
322 .client
323 .timeseries()
324 .get_range(&get_range)
325 .await
326 .with_context("fetching candles")?;
327
328 Ok(futures::stream::unfold(
329 CandleStreamState {
330 decoder,
331 exchange,
332 instrument,
333 interval: params.interval,
334 schema,
335 },
336 |mut state| async move {
337 match state.decoder.decode_record::<dbn::OhlcvMsg>().await {
338 Ok(Some(record)) => {
339 if let Err(e) = assert_ohlcv_rtype_matches(record.hd.rtype, state.schema) {
340 return Some((Err(e), state));
341 }
342 let event = dbn_ohlcv_to_candle(record, state.interval).map(
343 |(time_exchange, candle)| MarketEvent {
344 time_exchange,
345 time_received: Utc::now(),
346 exchange: state.exchange,
347 instrument: state.instrument.clone(),
348 kind: candle,
349 },
350 );
351 Some((event, state))
352 }
353 Ok(None) => None,
354 Err(e) => Some((Err(decode_error(e.to_string())), state)),
355 }
356 },
357 ))
358 }
359
360 /// Stream historical trades without collecting into memory.
361 ///
362 /// Unlike [`fetch_trades`](Self::fetch_trades), this returns a stream that
363 /// yields records as they're decoded, avoiding memory spikes for large queries.
364 ///
365 /// # Arguments
366 ///
367 /// * `params` - Query parameters (dataset, symbols, time range)
368 /// * `exchange` - ExchangeId to tag events with
369 /// * `instrument` - Instrument key to tag events with (use `Arc<K>` for efficiency)
370 ///
371 /// # Errors
372 ///
373 /// The outer `Result` returns [`DataError::Databento`] if the initial
374 /// `get_range` request fails (e.g. authentication, network, or invalid
375 /// params).
376 ///
377 /// Per-item errors are yielded as `Err` items on the stream and indicate
378 /// a DBN decode failure. After a decode error the underlying decoder is
379 /// in an unspecified state; callers should drop the stream rather than
380 /// continue polling for more records. Records that successfully decode
381 /// but fail conversion to [`PublicTrade`] are logged at `debug` and
382 /// skipped silently.
383 pub async fn fetch_trades_stream<K: Clone + Send + 'static>(
384 &mut self,
385 params: &GetRangeParams,
386 exchange: ExchangeId,
387 instrument: K,
388 ) -> Result<impl Stream<Item = Result<MarketEvent<K, PublicTrade>, DataError>>, DataError> {
389 debug!(?params, "Streaming historical trades from Databento");
390
391 let decoder = self
392 .client
393 .timeseries()
394 .get_range(params)
395 .await
396 .with_context("fetching trades")?;
397
398 Ok(futures::stream::unfold(
399 TradeStreamState {
400 decoder,
401 exchange,
402 instrument,
403 },
404 |mut state| async move {
405 loop {
406 match state.decoder.decode_record::<dbn::TradeMsg>().await {
407 Ok(Some(record)) => match dbn_trade_to_public_trade(record) {
408 Ok((time_exchange, trade)) => {
409 let event = MarketEvent {
410 time_exchange,
411 time_received: Utc::now(),
412 exchange: state.exchange,
413 instrument: state.instrument.clone(),
414 kind: trade,
415 };
416 return Some((Ok(event), state));
417 }
418 Err(e) => {
419 debug!(error = %e, "Skipping invalid trade record");
420 continue;
421 }
422 },
423 Ok(None) => return None,
424 Err(e) => {
425 return Some((Err(decode_error(e.to_string())), state));
426 }
427 }
428 }
429 },
430 ))
431 }
432
433 /// Stream historical quotes without collecting into memory.
434 ///
435 /// Unlike [`fetch_quotes`](Self::fetch_quotes), this returns a stream that
436 /// yields records as they're decoded, avoiding memory spikes for large queries.
437 ///
438 /// # Arguments
439 ///
440 /// * `params` - Query parameters (dataset, symbols, time range). Schema should be Mbp1.
441 /// * `exchange` - ExchangeId to tag events with
442 /// * `instrument` - Instrument key to tag events with (use `Arc<K>` for efficiency)
443 ///
444 /// # Errors
445 ///
446 /// The outer `Result` returns [`DataError::Databento`] if the initial
447 /// `get_range` request fails (e.g. authentication, network, or invalid
448 /// params).
449 ///
450 /// Per-item errors are yielded as `Err` items on the stream and indicate
451 /// a DBN decode failure. After a decode error the underlying decoder is
452 /// in an unspecified state; callers should drop the stream rather than
453 /// continue polling for more records. Records that successfully decode
454 /// but fail conversion to [`Quote`] are logged at `debug` and skipped
455 /// silently.
456 pub async fn fetch_quotes_stream<K: Clone + Send + 'static>(
457 &mut self,
458 params: &GetRangeParams,
459 exchange: ExchangeId,
460 instrument: K,
461 ) -> Result<impl Stream<Item = Result<MarketEvent<K, Quote>, DataError>>, DataError> {
462 debug!(?params, "Streaming historical quotes from Databento");
463
464 let decoder = self
465 .client
466 .timeseries()
467 .get_range(params)
468 .await
469 .with_context("fetching quotes")?;
470
471 Ok(futures::stream::unfold(
472 QuoteStreamState {
473 decoder,
474 exchange,
475 instrument,
476 },
477 |mut state| async move {
478 loop {
479 match state.decoder.decode_record::<dbn::Mbp1Msg>().await {
480 Ok(Some(record)) => match dbn_mbp1_to_quote(record) {
481 Ok((time_exchange, quote)) => {
482 let event = MarketEvent {
483 time_exchange,
484 time_received: Utc::now(),
485 exchange: state.exchange,
486 instrument: state.instrument.clone(),
487 kind: quote,
488 };
489 return Some((Ok(event), state));
490 }
491 Err(e) => {
492 debug!(error = %e, "Skipping invalid quote record");
493 continue;
494 }
495 },
496 Ok(None) => return None,
497 Err(e) => {
498 return Some((Err(decode_error(e.to_string())), state));
499 }
500 }
501 }
502 },
503 ))
504 }
505
506 /// Returns the underlying client for advanced use cases.
507 pub fn client(&self) -> &HistoricalClient {
508 &self.client
509 }
510
511 /// Returns a mutable reference to the underlying client.
512 pub fn client_mut(&mut self) -> &mut HistoricalClient {
513 &mut self.client
514 }
515}
516
517/// Typed parameters for a historical OHLCV candle query.
518///
519/// Deliberately carries **no** [`Schema`]: the schema is derived internally from
520/// [`interval`](Self::interval) (via the crate-internal
521/// `ensure_databento_ohlcv_supports`), making an interval/schema mismatch
522/// unrepresentable by construction. The schema is
523/// load-bearing for each candle's `close_time`, so letting a caller supply it
524/// independently of the interval would be a footgun.
525///
526/// All fields use standard/`chrono` types so consumers need not depend on the
527/// `databento` or `time` crates to build a request.
528#[derive(Debug, Clone, PartialEq, Eq)]
529pub struct DatabentoOhlcvParams {
530 /// Dataset code (e.g. `"GLBX.MDP3"`, `"XNAS.ITCH"`).
531 pub dataset: String,
532 /// Symbols to filter for (raw symbology, e.g. `["ESM5", "NQM5"]`).
533 pub symbols: Vec<String>,
534 /// Query range as `(start, end)` — inclusive start, exclusive end — filtering
535 /// on the bar `ts_event` (open). `start` must be strictly before `end`; an
536 /// inverted or empty range is not validated here and surfaces as a
537 /// [`DataError::Databento`] from the API call. See
538 /// [`DatabentoHistorical::fetch_candles`].
539 pub time_range: (DateTime<Utc>, DateTime<Utc>),
540 /// Candle interval/resolution. Must be one of Databento's native intervals
541 /// (`Sec1`/`Min1`/`Hour1`/`Day1`), else the fetch returns
542 /// [`DataError::UnsupportedInterval`].
543 pub interval: CandleInterval,
544}
545
546/// Build a [`GetRangeParams`] for an OHLCV query from typed params + derived schema.
547fn build_ohlcv_get_range(params: &DatabentoOhlcvParams, schema: Schema) -> GetRangeParams {
548 GetRangeParams::builder()
549 // `.dataset()` takes `impl ToString` and stringifies internally, so pass
550 // `&str` to avoid an extra `String` allocation from a redundant `.clone()`.
551 .dataset(params.dataset.as_str())
552 .symbols(params.symbols.clone())
553 .schema(schema)
554 .date_time_range(params.time_range)
555 .build()
556}
557
558/// Defensive backstop: assert a decoded record's `rtype` maps to the schema we
559/// requested.
560///
561/// Because the schema is derived from the interval ([`ensure_databento_ohlcv_supports`]),
562/// this should *always* hold; a mismatch signals a `dbn` API contract violation
563/// (not caller error) and is surfaced as [`DataError`], never silently skipped.
564fn assert_ohlcv_rtype_matches(rtype: u8, schema: Schema) -> Result<(), DataError> {
565 if RType::try_into_schema(u16::from(rtype)) == Some(schema) {
566 Ok(())
567 } else {
568 Err(decode_error(format!(
569 "OHLCV record rtype {rtype:#04x} does not map to requested schema {schema:?} (dbn contract violation)"
570 )))
571 }
572}
573
574/// Load trades from a pre-downloaded DBN file.
575///
576/// Returns an iterator to avoid loading entire file into memory.
577/// Useful for backtesting with previously downloaded data.
578///
579/// # Arguments
580///
581/// * `path` - Path to `.dbn` or `.dbn.zst` file
582/// * `exchange` - ExchangeId to tag events with
583/// * `instrument` - Instrument key to tag events with (use `Arc<K>` for efficiency)
584///
585/// # ⚠️ Caller obligation: the file must contain exactly one instrument
586///
587/// Every record is tagged with the supplied `instrument`; the per-record
588/// `instrument_id` in the DBN header is **not** read. A multi-instrument file
589/// therefore decodes silently, with every event attributed to whichever key was
590/// passed — wrong prices on the wrong instrument, with no error. Use a
591/// single-instrument file, or resolve the header's `instrument_id` yourself as
592/// the live path does via `PitSymbolMap`.
593///
594/// # Errors
595///
596/// Returns [`DataError::Databento`] if the file cannot be opened or the DBN
597/// header is invalid.
598pub fn load_trades_from_dbn<K: Clone>(
599 path: &Path,
600 exchange: ExchangeId,
601 instrument: K,
602) -> Result<impl Iterator<Item = Result<MarketEvent<K, PublicTrade>, DataError>>, DataError> {
603 let decoder =
604 DynDecoder::from_file(path, VersionUpgradePolicy::AsIs).with_context("opening DBN file")?;
605
606 Ok(DbnTradeIterator {
607 decoder,
608 exchange,
609 instrument,
610 })
611}
612
613/// Load quotes from a pre-downloaded DBN file.
614///
615/// # Arguments
616///
617/// * `path` - Path to `.dbn` or `.dbn.zst` file
618/// * `exchange` - ExchangeId to tag events with
619/// * `instrument` - Instrument key to tag events with (use `Arc<K>` for efficiency)
620///
621/// # ⚠️ Caller obligation: the file must contain exactly one instrument
622///
623/// Every record is tagged with the supplied `instrument`; the per-record
624/// `instrument_id` in the DBN header is **not** read. A multi-instrument file
625/// therefore decodes silently, with every event attributed to whichever key was
626/// passed — wrong prices on the wrong instrument, with no error. Use a
627/// single-instrument file, or resolve the header's `instrument_id` yourself as
628/// the live path does via `PitSymbolMap`.
629///
630/// # Errors
631///
632/// Returns [`DataError::Databento`] if the file cannot be opened or the DBN
633/// header is invalid.
634pub fn load_quotes_from_dbn<K: Clone>(
635 path: &Path,
636 exchange: ExchangeId,
637 instrument: K,
638) -> Result<impl Iterator<Item = Result<MarketEvent<K, Quote>, DataError>>, DataError> {
639 let decoder =
640 DynDecoder::from_file(path, VersionUpgradePolicy::AsIs).with_context("opening DBN file")?;
641
642 Ok(DbnQuoteIterator {
643 decoder,
644 exchange,
645 instrument,
646 })
647}
648
649// Stream state types for async streaming
650struct TradeStreamState<K, D> {
651 decoder: D,
652 exchange: ExchangeId,
653 instrument: K,
654}
655
656struct CandleStreamState<K, D> {
657 decoder: D,
658 exchange: ExchangeId,
659 instrument: K,
660 interval: CandleInterval,
661 schema: Schema,
662}
663
664struct QuoteStreamState<K, D> {
665 decoder: D,
666 exchange: ExchangeId,
667 instrument: K,
668}
669
670struct DbnTradeIterator<K> {
671 decoder: DynDecoder<'static, std::io::BufReader<std::fs::File>>,
672 exchange: ExchangeId,
673 instrument: K,
674}
675
676impl<K: Clone> Iterator for DbnTradeIterator<K> {
677 type Item = Result<MarketEvent<K, PublicTrade>, DataError>;
678
679 fn next(&mut self) -> Option<Self::Item> {
680 loop {
681 match self.decoder.decode_record::<dbn::TradeMsg>() {
682 Ok(Some(record)) => match dbn_trade_to_public_trade(record) {
683 Ok((time_exchange, trade)) => {
684 return Some(Ok(MarketEvent {
685 time_exchange,
686 time_received: time_exchange,
687 exchange: self.exchange,
688 instrument: self.instrument.clone(),
689 kind: trade,
690 }));
691 }
692 Err(e) => {
693 debug!(error = %e, "Skipping invalid trade record");
694 continue;
695 }
696 },
697 Ok(None) => return None,
698 Err(e) => {
699 return Some(Err(decode_error(e.to_string())));
700 }
701 }
702 }
703 }
704}
705
706struct DbnQuoteIterator<K> {
707 decoder: DynDecoder<'static, std::io::BufReader<std::fs::File>>,
708 exchange: ExchangeId,
709 instrument: K,
710}
711
712impl<K: Clone> Iterator for DbnQuoteIterator<K> {
713 type Item = Result<MarketEvent<K, Quote>, DataError>;
714
715 fn next(&mut self) -> Option<Self::Item> {
716 loop {
717 match self.decoder.decode_record::<dbn::Mbp1Msg>() {
718 Ok(Some(record)) => match dbn_mbp1_to_quote(record) {
719 Ok((time_exchange, quote)) => {
720 return Some(Ok(MarketEvent {
721 time_exchange,
722 time_received: time_exchange,
723 exchange: self.exchange,
724 instrument: self.instrument.clone(),
725 kind: quote,
726 }));
727 }
728 Err(e) => {
729 debug!(error = %e, "Skipping invalid quote record");
730 continue;
731 }
732 },
733 Ok(None) => return None,
734 Err(e) => {
735 return Some(Err(decode_error(e.to_string())));
736 }
737 }
738 }
739 }
740}
741
742#[cfg(test)]
743#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
744mod tests {
745 use super::*;
746 use databento::dbn::enums::rtype;
747
748 #[test]
749 fn assert_ohlcv_rtype_matches_accepts_match_and_surfaces_mismatch() {
750 // Matching rtype/schema pair: Ok.
751 assert!(assert_ohlcv_rtype_matches(rtype::OHLCV_1M, Schema::Ohlcv1M).is_ok());
752 assert!(assert_ohlcv_rtype_matches(rtype::OHLCV_1D, Schema::Ohlcv1D).is_ok());
753
754 // Mismatch (record 1h vs requested 1m): a dbn contract violation, surfaced
755 // as DataError rather than silently skipped.
756 assert!(assert_ohlcv_rtype_matches(rtype::OHLCV_1H, Schema::Ohlcv1M).is_err());
757 // A non-OHLCV rtype is also rejected.
758 assert!(assert_ohlcv_rtype_matches(rtype::MBP_0, Schema::Ohlcv1M).is_err());
759 }
760
761 #[test]
762 fn build_ohlcv_get_range_threads_schema_and_dataset() {
763 let params = DatabentoOhlcvParams {
764 dataset: "GLBX.MDP3".to_string(),
765 symbols: vec!["ESM5".to_string()],
766 time_range: (
767 "2024-01-01T00:00:00Z".parse().unwrap(),
768 "2024-01-02T00:00:00Z".parse().unwrap(),
769 ),
770 interval: CandleInterval::Min1,
771 };
772 let get_range = build_ohlcv_get_range(¶ms, Schema::Ohlcv1M);
773 assert_eq!(get_range.schema, Schema::Ohlcv1M);
774 assert_eq!(get_range.dataset, "GLBX.MDP3");
775 }
776}