Skip to main content

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(&params, 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(&params, 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(&params, Schema::Ohlcv1M);
773        assert_eq!(get_range.schema, Schema::Ohlcv1M);
774        assert_eq!(get_range.dataset, "GLBX.MDP3");
775    }
776}