Skip to main content

nautilus_databento/python/
loader.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Python bindings for the Databento data loader.
17
18use std::{collections::HashMap, path::PathBuf};
19
20use databento::dbn;
21use jiff::civil::Time;
22use nautilus_core::python::{IntoPyObjectNautilusExt, to_pyvalue_err};
23use nautilus_model::{
24    data::{Bar, InstrumentStatus, OrderBookDelta, OrderBookDepth10, QuoteTick, TradeTick},
25    identifiers::{InstrumentId, Symbol, Venue},
26    python::instruments::instrument_any_to_pyobject,
27};
28use pyo3::{prelude::*, types::PyList};
29use ustr::Ustr;
30
31use crate::{
32    decode::DatabentoDecodeConfig,
33    loader::DatabentoDataLoader,
34    types::{DatabentoImbalance, DatabentoPublisher, DatabentoStatistics, PublisherId},
35};
36
37#[expect(clippy::needless_pass_by_value)]
38#[pymethods]
39#[pyo3_stub_gen::derive::gen_stub_pymethods]
40impl DatabentoDataLoader {
41    /// A Nautilus data loader for Databento Binary Encoding (DBN) format data.
42    ///
43    /// # Supported Schemas
44    ///  - `MBO` -> `OrderBookDelta`
45    ///  - `MBP_1` -> `(QuoteTick, Option<TradeTick>)`
46    ///  - `MBP_10` -> `OrderBookDepth10`
47    ///  - `BBO_1S` -> `QuoteTick`
48    ///  - `BBO_1M` -> `QuoteTick`
49    ///  - `CMBP_1` -> `(QuoteTick, Option<TradeTick>)`
50    ///  - `CBBO_1S` -> `QuoteTick`
51    ///  - `CBBO_1M` -> `QuoteTick`
52    ///  - `TCBBO` -> `(QuoteTick, TradeTick)`
53    ///  - `TBBO` -> `(QuoteTick, TradeTick)`
54    ///  - `TRADES` -> `TradeTick`
55    ///  - `OHLCV_1S` -> `Bar`
56    ///  - `OHLCV_1M` -> `Bar`
57    ///  - `OHLCV_1H` -> `Bar`
58    ///  - `OHLCV_1D` -> `Bar`
59    ///  - `OHLCV_EOD` -> `Bar`
60    ///  - `DEFINITION` -> `Instrument`
61    ///  - `IMBALANCE` -> `DatabentoImbalance`
62    ///  - `STATISTICS` -> `DatabentoStatistics`
63    ///  - `STATUS` -> `InstrumentStatus`
64    ///
65    /// # References
66    ///
67    /// <https://databento.com/docs/schemas-and-data-formats>
68    #[new]
69    #[pyo3(signature = (publishers_filepath=None))]
70    fn py_new(publishers_filepath: Option<PathBuf>) -> PyResult<Self> {
71        Self::new(publishers_filepath).map_err(to_pyvalue_err)
72    }
73
74    /// Load the publishers data from the file at the given `filepath`.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the file cannot be read or parsed as JSON.
79    #[pyo3(name = "load_publishers")]
80    fn py_load_publishers(&mut self, publishers_filepath: PathBuf) -> PyResult<()> {
81        self.load_publishers(publishers_filepath)
82            .map_err(to_pyvalue_err)
83    }
84
85    /// Returns the internal Databento publishers currently held by the loader.
86    #[must_use]
87    #[pyo3(name = "get_publishers")]
88    fn py_get_publishers(&self) -> HashMap<u16, DatabentoPublisher> {
89        self.get_publishers()
90            .iter()
91            .map(|(&key, value)| (key, value.clone()))
92            .collect::<HashMap<u16, DatabentoPublisher>>()
93    }
94
95    /// Sets the `venue` to map to the given `dataset`.
96    #[pyo3(name = "set_dataset_for_venue")]
97    fn py_set_dataset_for_venue(&mut self, dataset: String, venue: Venue) {
98        self.set_dataset_for_venue(Ustr::from(&dataset), venue);
99    }
100
101    /// Returns the dataset which matches the given `venue` (if found).
102    #[must_use]
103    #[pyo3(name = "get_dataset_for_venue")]
104    fn py_get_dataset_for_venue(&self, venue: &Venue) -> Option<String> {
105        self.get_dataset_for_venue(venue).map(ToString::to_string)
106    }
107
108    /// Returns the venue which matches the given `publisher_id` (if found).
109    #[must_use]
110    #[pyo3(name = "get_venue_for_publisher")]
111    fn py_get_venue_for_publisher(&self, publisher_id: PublisherId) -> Option<String> {
112        self.get_venue_for_publisher(publisher_id)
113            .map(ToString::to_string)
114    }
115
116    /// Caches a `price_precision` for the given `symbol`.
117    ///
118    /// When market data is read without an explicit `price_precision` argument,
119    /// the loader resolves precision per record from this cache. Definitions
120    /// loaded via `Self.load_instruments` are inserted automatically.
121    #[pyo3(name = "set_price_precision")]
122    fn py_set_price_precision(&mut self, symbol: &str, price_precision: u8) {
123        self.set_price_precision(Symbol::from(symbol), price_precision);
124    }
125
126    /// Returns the cached price precisions keyed by symbol.
127    #[must_use]
128    #[pyo3(name = "get_price_precisions")]
129    fn py_get_price_precisions(&self) -> HashMap<String, u8> {
130        self.get_price_precisions()
131            .iter()
132            .map(|(symbol, precision)| (symbol.to_string(), *precision))
133            .collect()
134    }
135
136    #[pyo3(name = "schema_for_file")]
137    fn py_schema_for_file(&self, filepath: PathBuf) -> PyResult<Option<String>> {
138        self.schema_from_file(&filepath).map_err(to_pyvalue_err)
139    }
140
141    /// Loads all instrument definitions from a DBN file.
142    ///
143    /// When `skip_on_error` is true, instruments that fail to decode are logged
144    /// as warnings and skipped. When false (default), any decode error is propagated.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if loading instruments fails.
149    #[pyo3(name = "load_instruments")]
150    #[pyo3(signature = (filepath, use_exchange_as_venue, skip_on_error=false, expiration_overrides=None))]
151    fn py_load_instruments(
152        &mut self,
153        py: Python,
154        filepath: PathBuf,
155        use_exchange_as_venue: bool,
156        skip_on_error: bool,
157        expiration_overrides: Option<HashMap<String, HashMap<String, String>>>,
158    ) -> PyResult<Py<PyAny>> {
159        let decode_config = build_decode_config(expiration_overrides)?;
160        let iter = self
161            .load_instruments(
162                &filepath,
163                use_exchange_as_venue,
164                skip_on_error,
165                decode_config.as_ref(),
166            )
167            .map_err(to_pyvalue_err)?;
168
169        let mut data = Vec::new();
170
171        for instrument in iter {
172            let py_object = instrument_any_to_pyobject(py, instrument)?;
173            data.push(py_object);
174        }
175
176        let list = PyList::new(py, &data)?;
177
178        Ok(list.into_py_any_unwrap(py))
179    }
180
181    // Cannot include trades
182    /// Loads order book delta messages from a DBN MBO schema file.
183    ///
184    /// Cannot include trades.
185    ///
186    /// # Errors
187    ///
188    /// Returns an error if loading order book deltas fails.
189    #[pyo3(name = "load_order_book_deltas")]
190    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
191    fn py_load_order_book_deltas(
192        &self,
193        filepath: PathBuf,
194        instrument_id: Option<InstrumentId>,
195        price_precision: Option<u8>,
196    ) -> PyResult<Vec<OrderBookDelta>> {
197        self.load_order_book_deltas(&filepath, instrument_id, price_precision)
198            .map_err(to_pyvalue_err)
199    }
200
201    /// Loads order book depth10 snapshots from a DBN MBP-10 schema file.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if loading order book depth10 fails.
206    #[pyo3(name = "load_order_book_depth10")]
207    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
208    fn py_load_order_book_depth10(
209        &self,
210        filepath: PathBuf,
211        instrument_id: Option<InstrumentId>,
212        price_precision: Option<u8>,
213    ) -> PyResult<Vec<OrderBookDepth10>> {
214        self.load_order_book_depth10(&filepath, instrument_id, price_precision)
215            .map_err(to_pyvalue_err)
216    }
217
218    /// Loads quote tick messages from a DBN MBP-1 or TBBO schema file.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if loading quotes fails.
223    #[pyo3(name = "load_quotes")]
224    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
225    fn py_load_quotes(
226        &self,
227        filepath: PathBuf,
228        instrument_id: Option<InstrumentId>,
229        price_precision: Option<u8>,
230    ) -> PyResult<Vec<QuoteTick>> {
231        self.load_quotes(&filepath, instrument_id, price_precision)
232            .map_err(to_pyvalue_err)
233    }
234
235    /// Loads best bid/offer quote messages from a DBN BBO schema file.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if loading BBO quotes fails.
240    #[pyo3(name = "load_bbo_quotes")]
241    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
242    fn py_load_bbo_quotes(
243        &self,
244        filepath: PathBuf,
245        instrument_id: Option<InstrumentId>,
246        price_precision: Option<u8>,
247    ) -> PyResult<Vec<QuoteTick>> {
248        self.load_bbo_quotes(&filepath, instrument_id, price_precision)
249            .map_err(to_pyvalue_err)
250    }
251
252    /// Loads consolidated MBP-1 quote messages from a DBN CMBP-1 schema file.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if loading consolidated MBP-1 quotes fails.
257    #[pyo3(name = "load_cmbp_quotes")]
258    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
259    fn py_load_cmbp_quotes(
260        &self,
261        filepath: PathBuf,
262        instrument_id: Option<InstrumentId>,
263        price_precision: Option<u8>,
264    ) -> PyResult<Vec<QuoteTick>> {
265        self.load_cmbp_quotes(&filepath, instrument_id, price_precision)
266            .map_err(to_pyvalue_err)
267    }
268
269    /// Loads consolidated best bid/offer quote messages from a DBN CBBO schema file.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if loading consolidated BBO quotes fails.
274    #[pyo3(name = "load_cbbo_quotes")]
275    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
276    fn py_load_cbbo_quotes(
277        &self,
278        filepath: PathBuf,
279        instrument_id: Option<InstrumentId>,
280        price_precision: Option<u8>,
281    ) -> PyResult<Vec<QuoteTick>> {
282        self.load_cbbo_quotes(&filepath, instrument_id, price_precision)
283            .map_err(to_pyvalue_err)
284    }
285
286    /// Loads trade messages from a DBN TBBO schema file.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if loading TBBO trades fails.
291    #[pyo3(name = "load_tbbo_trades")]
292    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
293    fn py_load_tbbo_trades(
294        &self,
295        filepath: PathBuf,
296        instrument_id: Option<InstrumentId>,
297        price_precision: Option<u8>,
298    ) -> PyResult<Vec<TradeTick>> {
299        self.load_tbbo_trades(&filepath, instrument_id, price_precision)
300            .map_err(to_pyvalue_err)
301    }
302
303    /// Loads trade messages from a DBN TCBBO schema file.
304    ///
305    /// # Errors
306    ///
307    /// Returns an error if loading TCBBO trades fails.
308    #[pyo3(name = "load_tcbbo_trades")]
309    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
310    fn py_load_tcbbo_trades(
311        &self,
312        filepath: PathBuf,
313        instrument_id: Option<InstrumentId>,
314        price_precision: Option<u8>,
315    ) -> PyResult<Vec<TradeTick>> {
316        self.load_tcbbo_trades(&filepath, instrument_id, price_precision)
317            .map_err(to_pyvalue_err)
318    }
319
320    /// Loads trade messages from a DBN TRADES schema file.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if loading trades fails.
325    #[pyo3(name = "load_trades")]
326    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
327    fn py_load_trades(
328        &self,
329        filepath: PathBuf,
330        instrument_id: Option<InstrumentId>,
331        price_precision: Option<u8>,
332    ) -> PyResult<Vec<TradeTick>> {
333        self.load_trades(&filepath, instrument_id, price_precision)
334            .map_err(to_pyvalue_err)
335    }
336
337    /// Loads OHLCV bar messages from a DBN OHLCV schema file.
338    ///
339    /// # Errors
340    ///
341    /// Returns an error if loading bars fails.
342    #[pyo3(name = "load_bars")]
343    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None, timestamp_on_close=true))]
344    fn py_load_bars(
345        &self,
346        filepath: PathBuf,
347        instrument_id: Option<InstrumentId>,
348        price_precision: Option<u8>,
349        timestamp_on_close: bool,
350    ) -> PyResult<Vec<Bar>> {
351        self.load_bars(
352            &filepath,
353            instrument_id,
354            price_precision,
355            Some(timestamp_on_close),
356        )
357        .map_err(to_pyvalue_err)
358    }
359
360    #[pyo3(name = "load_status")]
361    #[pyo3(signature = (filepath, instrument_id=None))]
362    fn py_load_status(
363        &self,
364        filepath: PathBuf,
365        instrument_id: Option<InstrumentId>,
366    ) -> PyResult<Vec<InstrumentStatus>> {
367        let iter = self
368            .load_status_records::<dbn::StatusMsg>(&filepath, instrument_id)
369            .map_err(to_pyvalue_err)?;
370
371        let mut data = Vec::new();
372
373        for result in iter {
374            match result {
375                Ok(item) => data.push(item),
376                Err(e) => return Err(to_pyvalue_err(e)),
377            }
378        }
379
380        Ok(data)
381    }
382
383    #[pyo3(name = "load_imbalance")]
384    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
385    fn py_load_imbalance(
386        &self,
387        filepath: PathBuf,
388        instrument_id: Option<InstrumentId>,
389        price_precision: Option<u8>,
390    ) -> PyResult<Vec<DatabentoImbalance>> {
391        let iter = self
392            .read_imbalance_records::<dbn::ImbalanceMsg>(&filepath, instrument_id, price_precision)
393            .map_err(to_pyvalue_err)?;
394
395        let mut data = Vec::new();
396
397        for result in iter {
398            match result {
399                Ok(item) => data.push(item),
400                Err(e) => return Err(to_pyvalue_err(e)),
401            }
402        }
403
404        Ok(data)
405    }
406
407    #[pyo3(name = "load_statistics")]
408    #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
409    fn py_load_statistics(
410        &self,
411        filepath: PathBuf,
412        instrument_id: Option<InstrumentId>,
413        price_precision: Option<u8>,
414    ) -> PyResult<Vec<DatabentoStatistics>> {
415        let iter = self
416            .read_statistics_records::<dbn::StatMsg>(&filepath, instrument_id, price_precision)
417            .map_err(to_pyvalue_err)?;
418
419        let mut data = Vec::new();
420
421        for result in iter {
422            match result {
423                Ok(item) => data.push(item),
424                Err(e) => return Err(to_pyvalue_err(e)),
425            }
426        }
427
428        Ok(data)
429    }
430}
431
432// Returns `None` when no overrides are supplied, so the loader applies its built-in defaults
433fn build_decode_config(
434    expiration_overrides: Option<HashMap<String, HashMap<String, String>>>,
435) -> PyResult<Option<DatabentoDecodeConfig>> {
436    expiration_overrides
437        .map(|overrides| decode_config_from_overrides(overrides).map_err(to_pyvalue_err))
438        .transpose()
439}
440
441// Builds a decode config from a dataset -> (underlying -> wall-clock time) mapping. The reserved
442// underlying key "default" sets a dataset's default time; other keys are per-underlying overrides.
443fn decode_config_from_overrides(
444    overrides: HashMap<String, HashMap<String, String>>,
445) -> Result<DatabentoDecodeConfig, String> {
446    let mut config = DatabentoDecodeConfig::default();
447
448    for (dataset_name, times) in overrides {
449        let dataset = dataset_name
450            .parse::<dbn::Dataset>()
451            .map_err(|_| format!("Unknown dataset '{dataset_name}'"))?;
452        let rule = config
453            .option_expiration
454            .get_mut(&dataset)
455            .ok_or_else(|| format!("No expiration correction rule for dataset '{dataset_name}'"))?;
456
457        for (underlying, value) in times {
458            let time = parse_expiration_time(&value)?;
459            if underlying == "default" {
460                rule.default_time = time;
461            } else {
462                rule.overrides.insert(Ustr::from(&underlying), time);
463            }
464        }
465    }
466
467    Ok(config)
468}
469
470// Parses a `HH:MM` or `HH:MM:SS` wall-clock time
471fn parse_expiration_time(value: &str) -> Result<Time, String> {
472    value
473        .parse::<Time>()
474        .map_err(|_| format!("Invalid expiration time '{value}', expected 'HH:MM' or 'HH:MM:SS'"))
475}
476
477#[cfg(test)]
478mod tests {
479    use std::collections::HashMap;
480
481    use databento::dbn;
482    use jiff::civil::Time;
483    use rstest::rstest;
484    use ustr::Ustr;
485
486    use super::decode_config_from_overrides;
487
488    fn overrides(
489        dataset: &str,
490        entries: &[(&str, &str)],
491    ) -> HashMap<String, HashMap<String, String>> {
492        let inner = entries
493            .iter()
494            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
495            .collect();
496        HashMap::from([(dataset.to_string(), inner)])
497    }
498
499    #[rstest]
500    fn test_default_key_sets_dataset_default_time() {
501        let config =
502            decode_config_from_overrides(overrides("OPRA.PILLAR", &[("default", "15:30")]))
503                .unwrap();
504        let rule = config
505            .option_expiration
506            .get(&dbn::Dataset::OpraPillar)
507            .unwrap();
508        assert_eq!(rule.default_time, Time::constant(15, 30, 0, 0));
509        assert!(rule.overrides.is_empty());
510    }
511
512    #[rstest]
513    fn test_underlying_key_sets_override_and_keeps_default() {
514        let config =
515            decode_config_from_overrides(overrides("OPRA.PILLAR", &[("SPX", "09:30:00")])).unwrap();
516        let rule = config
517            .option_expiration
518            .get(&dbn::Dataset::OpraPillar)
519            .unwrap();
520        assert_eq!(
521            rule.overrides.get(&Ustr::from("SPX")).copied(),
522            Some(Time::constant(9, 30, 0, 0))
523        );
524        assert_eq!(rule.default_time, Time::constant(16, 0, 0, 0));
525    }
526
527    #[rstest]
528    fn test_unknown_dataset_errors() {
529        let err = decode_config_from_overrides(overrides("NOT.ADATASET", &[("default", "16:00")]))
530            .unwrap_err();
531        assert!(err.contains("Unknown dataset"), "was: {err}");
532    }
533
534    #[rstest]
535    fn test_dataset_without_rule_errors() {
536        // GLBX is a valid dataset but has no built-in expiration rule, so it cannot be tuned
537        let err = decode_config_from_overrides(overrides("GLBX.MDP3", &[("default", "16:00")]))
538            .unwrap_err();
539        assert!(err.contains("No expiration correction rule"), "was: {err}");
540    }
541
542    #[rstest]
543    fn test_invalid_time_errors() {
544        let err = decode_config_from_overrides(overrides("OPRA.PILLAR", &[("default", "nope")]))
545            .unwrap_err();
546        assert!(err.contains("Invalid expiration time"), "was: {err}");
547    }
548}