Skip to main content

nautilus_event_store/replay/
catalog.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//! Replay catalog adapter backed by Parquet data catalogs.
17
18use 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/// Read-only replay catalog adapter backed by [`ParquetDataCatalog`].
31#[derive(Debug)]
32pub struct ParquetReplayCatalog<'a> {
33    catalog: &'a mut ParquetDataCatalog,
34}
35
36impl<'a> ParquetReplayCatalog<'a> {
37    /// Creates a replay catalog adapter over an existing Parquet catalog.
38    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(&quotes, 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}