1use 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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
432fn 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
441fn 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
470fn 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 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}