1use nautilus_core::UnixNanos;
19use nautilus_model::data::{Bar, QuoteTick, TradeTick};
20use nautilus_persistence::{
21 backend::catalog::{ParquetDataCatalog, parse_filename_timestamps},
22 catalog::types::{CatalogDataType, data_type_from_data_path_prefix},
23};
24
25use super::{
26 CatalogReplayData, CatalogReplayRecord, CatalogSliceCoverage, CatalogSlicePlan,
27 CatalogSliceQuery, ReplayCatalog, ReplayTimeRange,
28};
29
30#[derive(Debug)]
32pub struct ParquetReplayCatalog<'a> {
33 catalog: &'a mut ParquetDataCatalog,
34}
35
36impl<'a> ParquetReplayCatalog<'a> {
37 pub const fn new(catalog: &'a mut ParquetDataCatalog) -> Self {
39 Self { catalog }
40 }
41}
42
43impl ReplayCatalog for ParquetReplayCatalog<'_> {
44 type Error = anyhow::Error;
45
46 fn plan_slice(
47 &mut self,
48 query: &CatalogSliceQuery,
49 ) -> Result<CatalogSliceCoverage, Self::Error> {
50 let data_type = CatalogDataType::Data(data_type_from_data_path_prefix(&query.data_cls)?);
51 let mut files = self.catalog.query_files(
52 &data_type,
53 query.identifiers_option(),
54 Some(query.start),
55 Some(query.end),
56 )?;
57 files.sort();
58
59 let intervals = files
60 .iter()
61 .filter_map(|file| {
62 parse_filename_timestamps(file).map(|(start, end)| {
63 ReplayTimeRange::new(UnixNanos::from(start), UnixNanos::from(end))
64 })
65 })
66 .collect();
67
68 Ok(CatalogSliceCoverage { files, intervals })
69 }
70
71 fn load_slice(
72 &mut self,
73 plan: &CatalogSlicePlan,
74 ) -> Result<Vec<CatalogReplayRecord>, Self::Error> {
75 let identifiers = plan.query.identifiers_option();
76 let start = Some(plan.query.start);
77 let end = Some(plan.query.end);
78 let files = Some(plan.coverage.files.clone());
79
80 match plan.query.data_cls.as_str() {
81 "quotes" => Ok(catalog_replay_records(
82 self.catalog.query_typed_data::<QuoteTick>(
83 identifiers,
84 start,
85 end,
86 None,
87 files,
88 false,
89 )?,
90 )),
91 "trades" => Ok(catalog_replay_records(
92 self.catalog.query_typed_data::<TradeTick>(
93 identifiers,
94 start,
95 end,
96 None,
97 files,
98 false,
99 )?,
100 )),
101 "bars" => Ok(catalog_replay_records(
102 self.catalog.query_typed_data::<Bar>(
103 identifiers,
104 start,
105 end,
106 None,
107 files,
108 false,
109 )?,
110 )),
111 data_cls => {
112 anyhow::bail!("catalog replay loading for {data_cls} is not supported")
113 }
114 }
115 }
116}
117
118fn catalog_replay_records<T>(records: Vec<T>) -> Vec<CatalogReplayRecord>
119where
120 T: Into<CatalogReplayData>,
121{
122 records
123 .into_iter()
124 .map(Into::into)
125 .map(CatalogReplayRecord::from_data)
126 .collect()
127}
128
129#[cfg(test)]
130mod tests {
131 use std::{
132 fs::{self, File},
133 path::Path,
134 };
135
136 use nautilus_model::{
137 data::{Bar, BarSpecification, BarType, QuoteTick, TradeTick},
138 enums::{AggregationSource, AggressorSide, BarAggregation, PriceType},
139 identifiers::{InstrumentId, TradeId},
140 types::{Price, Quantity},
141 };
142 use nautilus_persistence::backend::catalog::{ParquetDataCatalog, timestamps_to_filename};
143 use rstest::rstest;
144 use tempfile::TempDir;
145
146 use super::*;
147
148 #[rstest]
149 fn parquet_replay_catalog_plans_selected_slice_files() {
150 let temp_dir = TempDir::new().unwrap();
151 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
152
153 create_catalog_file(temp_dir.path(), "quotes", "AUDUSD.SIM", 1_000, 2_000);
154 create_catalog_file(temp_dir.path(), "quotes", "AUDUSD.SIM", 10_000, 11_000);
155 create_catalog_file(temp_dir.path(), "quotes", "ETHUSDT.BINANCE", 5_000, 6_000);
156
157 let query = CatalogSliceQuery {
158 data_cls: "quotes".to_string(),
159 identifiers: vec!["AUD/USD.SIM".to_string()],
160 start: UnixNanos::from(1_500),
161 end: UnixNanos::from(2_500),
162 required: true,
163 };
164 let coverage = ParquetReplayCatalog::new(&mut catalog)
165 .plan_slice(&query)
166 .unwrap();
167
168 assert_eq!(coverage.files.len(), 1);
169 assert!(
170 coverage.files[0].contains("data/quotes/AUDUSD.SIM/"),
171 "planned file should come from AUD/USD.SIM partition, was {}",
172 coverage.files[0],
173 );
174 assert_eq!(
175 coverage.intervals,
176 vec![ReplayTimeRange::new(
177 UnixNanos::from(1_000),
178 UnixNanos::from(2_000)
179 )]
180 );
181
182 let full_window_query = CatalogSliceQuery {
183 start: UnixNanos::from(0),
184 end: UnixNanos::from(12_000),
185 ..query.clone()
186 };
187 let full_window_coverage = ParquetReplayCatalog::new(&mut catalog)
188 .plan_slice(&full_window_query)
189 .unwrap();
190
191 assert_eq!(full_window_coverage.files.len(), 2);
192 assert_eq!(
193 full_window_coverage.intervals,
194 vec![
195 ReplayTimeRange::new(UnixNanos::from(1_000), UnixNanos::from(2_000)),
196 ReplayTimeRange::new(UnixNanos::from(10_000), UnixNanos::from(11_000)),
197 ]
198 );
199
200 let missing_query = CatalogSliceQuery {
201 start: UnixNanos::from(20_000),
202 end: UnixNanos::from(21_000),
203 ..query
204 };
205 let missing_coverage = ParquetReplayCatalog::new(&mut catalog)
206 .plan_slice(&missing_query)
207 .unwrap();
208
209 assert!(missing_coverage.is_missing());
210 assert!(missing_coverage.intervals.is_empty());
211 }
212
213 #[rstest]
214 fn parquet_replay_catalog_loads_selected_quote_records() {
215 let temp_dir = TempDir::new().unwrap();
216 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
217 let instrument_id = InstrumentId::from("AUD/USD.SIM");
218 let quotes = vec![
219 QuoteTick::new(
220 instrument_id,
221 Price::from("1.0001"),
222 Price::from("1.0002"),
223 Quantity::from("100"),
224 Quantity::from("100"),
225 UnixNanos::from(1_000),
226 UnixNanos::from(1_000),
227 ),
228 QuoteTick::new(
229 instrument_id,
230 Price::from("1.0003"),
231 Price::from("1.0004"),
232 Quantity::from("200"),
233 Quantity::from("200"),
234 UnixNanos::from(2_000),
235 UnixNanos::from(2_000),
236 ),
237 QuoteTick::new(
238 instrument_id,
239 Price::from("1.0005"),
240 Price::from("1.0006"),
241 Quantity::from("300"),
242 Quantity::from("300"),
243 UnixNanos::from(3_000),
244 UnixNanos::from(3_000),
245 ),
246 ];
247 catalog
248 .write_to_parquet("es, None, None, None)
249 .expect("write quotes");
250
251 let query = CatalogSliceQuery {
252 data_cls: "quotes".to_string(),
253 identifiers: vec!["AUD/USD.SIM".to_string()],
254 start: UnixNanos::from(1_500),
255 end: UnixNanos::from(2_500),
256 required: true,
257 };
258 let mut replay_catalog = ParquetReplayCatalog::new(&mut catalog);
259 let coverage = replay_catalog.plan_slice(&query).expect("plan slice");
260 let plan = catalog_slice_plan(query, coverage);
261
262 let records = replay_catalog.load_slice(&plan).expect("load slice");
263
264 assert_eq!(
265 records,
266 vec![CatalogReplayRecord::from_data(CatalogReplayData::Quote(
267 quotes[1]
268 ))],
269 );
270 }
271
272 #[rstest]
273 fn parquet_replay_catalog_loads_selected_trade_records() {
274 let temp_dir = TempDir::new().unwrap();
275 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
276 let instrument_id = InstrumentId::from("AUD/USD.SIM");
277 let trades = vec![
278 TradeTick::new(
279 instrument_id,
280 Price::from("1.0001"),
281 Quantity::from("100"),
282 AggressorSide::Buy,
283 TradeId::from("T-1"),
284 UnixNanos::from(1_000),
285 UnixNanos::from(1_000),
286 ),
287 TradeTick::new(
288 instrument_id,
289 Price::from("1.0002"),
290 Quantity::from("200"),
291 AggressorSide::Sell,
292 TradeId::from("T-2"),
293 UnixNanos::from(2_000),
294 UnixNanos::from(2_000),
295 ),
296 TradeTick::new(
297 instrument_id,
298 Price::from("1.0003"),
299 Quantity::from("300"),
300 AggressorSide::Buy,
301 TradeId::from("T-3"),
302 UnixNanos::from(3_000),
303 UnixNanos::from(3_000),
304 ),
305 ];
306 catalog
307 .write_to_parquet(&trades, None, None, None)
308 .expect("write trades");
309
310 let query = CatalogSliceQuery {
311 data_cls: "trades".to_string(),
312 identifiers: vec!["AUD/USD.SIM".to_string()],
313 start: UnixNanos::from(1_500),
314 end: UnixNanos::from(2_500),
315 required: true,
316 };
317 let mut replay_catalog = ParquetReplayCatalog::new(&mut catalog);
318 let coverage = replay_catalog.plan_slice(&query).expect("plan slice");
319 let plan = catalog_slice_plan(query, coverage);
320
321 let records = replay_catalog.load_slice(&plan).expect("load slice");
322
323 assert_eq!(
324 records,
325 vec![CatalogReplayRecord::from_data(CatalogReplayData::Trade(
326 trades[1]
327 ))],
328 );
329 }
330
331 #[rstest]
332 fn parquet_replay_catalog_loads_selected_bar_records() {
333 let temp_dir = TempDir::new().unwrap();
334 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
335 let instrument_id = InstrumentId::from("AUD/USD.SIM");
336 let bar_type = BarType::new(
337 instrument_id,
338 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
339 AggregationSource::External,
340 );
341 let bars = vec![
342 Bar::new(
343 bar_type,
344 Price::from("1.0000"),
345 Price::from("1.0002"),
346 Price::from("1.0000"),
347 Price::from("1.0001"),
348 Quantity::from("100"),
349 UnixNanos::from(1_000),
350 UnixNanos::from(1_000),
351 ),
352 Bar::new(
353 bar_type,
354 Price::from("1.0001"),
355 Price::from("1.0004"),
356 Price::from("1.0001"),
357 Price::from("1.0003"),
358 Quantity::from("200"),
359 UnixNanos::from(2_000),
360 UnixNanos::from(2_000),
361 ),
362 Bar::new(
363 bar_type,
364 Price::from("1.0003"),
365 Price::from("1.0006"),
366 Price::from("1.0003"),
367 Price::from("1.0005"),
368 Quantity::from("300"),
369 UnixNanos::from(3_000),
370 UnixNanos::from(3_000),
371 ),
372 ];
373 catalog
374 .write_to_parquet(&bars, None, None, None)
375 .expect("write bars");
376
377 let query = CatalogSliceQuery {
378 data_cls: "bars".to_string(),
379 identifiers: vec!["AUD/USD.SIM".to_string()],
380 start: UnixNanos::from(1_500),
381 end: UnixNanos::from(2_500),
382 required: true,
383 };
384 let mut replay_catalog = ParquetReplayCatalog::new(&mut catalog);
385 let coverage = replay_catalog.plan_slice(&query).expect("plan slice");
386 let plan = catalog_slice_plan(query, coverage);
387
388 let records = replay_catalog.load_slice(&plan).expect("load slice");
389
390 assert_eq!(
391 records,
392 vec![CatalogReplayRecord::from_data(CatalogReplayData::Bar(
393 bars[1]
394 ))],
395 );
396 }
397
398 #[rstest]
399 fn parquet_replay_catalog_rejects_unsupported_load_slice() {
400 let temp_dir = TempDir::new().unwrap();
401 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
402 let plan = CatalogSlicePlan {
403 query: CatalogSliceQuery {
404 data_cls: "order_book_deltas".to_string(),
405 identifiers: vec!["AUD/USD.SIM".to_string()],
406 start: UnixNanos::from(1_000),
407 end: UnixNanos::from(2_000),
408 required: true,
409 },
410 coverage: CatalogSliceCoverage::from_files(vec![
411 "data/order_book_deltas/AUDUSD.SIM/1000_2000.parquet".to_string(),
412 ]),
413 };
414
415 let err = ParquetReplayCatalog::new(&mut catalog)
416 .load_slice(&plan)
417 .expect_err("unsupported data class must fail");
418
419 assert_eq!(
420 err.to_string(),
421 "catalog replay loading for order_book_deltas is not supported",
422 );
423 }
424
425 fn catalog_slice_plan(
426 query: CatalogSliceQuery,
427 coverage: CatalogSliceCoverage,
428 ) -> CatalogSlicePlan {
429 CatalogSlicePlan { query, coverage }
430 }
431
432 fn create_catalog_file(
433 base_path: &Path,
434 data_cls: &str,
435 identifier: &str,
436 start: u64,
437 end: u64,
438 ) {
439 let directory = base_path.join("data").join(data_cls).join(identifier);
440 fs::create_dir_all(&directory).unwrap();
441
442 let filename = timestamps_to_filename(UnixNanos::from(start), UnixNanos::from(end));
443 File::create(directory.join(filename)).unwrap();
444 }
445}