Skip to main content

nautilus_persistence/backend/parquet/catalog/
coverage.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//! Interval coverage and missing-interval checks for the Parquet catalog.
17
18use super::{
19    Cow, ParquetDataCatalog, extract_bar_type_instrument_id, parse_filename_timestamps,
20    query::is_parquet_bar_prefix, query_interval_diff, urisafe_instrument_id, urlencoding,
21};
22use crate::catalog::types::{CatalogDataType, parquet_catalog_data_type_path_prefixes};
23
24impl ParquetDataCatalog {
25    /// Finds the missing time intervals for a specific data type and instrument ID.
26    ///
27    /// This method compares a requested time range against the existing data coverage
28    /// and returns the gaps that need to be filled. This is useful for determining
29    /// what data needs to be fetched or backfilled.
30    ///
31    /// # Parameters
32    ///
33    /// - `start`: Start timestamp of the requested range (Unix nanoseconds).
34    /// - `end`: End timestamp of the requested range (Unix nanoseconds).
35    /// - `data_type`: The stored family to inspect.
36    /// - `instrument_id`: Optional instrument ID to target a specific instrument's data.
37    ///
38    /// # Returns
39    ///
40    /// Returns a vector of (start, end) tuples representing the missing intervals,
41    /// or an error if the operation fails.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if:
46    /// - The directory path cannot be constructed.
47    /// - Interval retrieval fails.
48    /// - Gap calculation fails.
49    ///
50    /// # Examples
51    ///
52    /// ```rust,no_run
53    /// use nautilus_model::data::NautilusDataType;
54    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
55    ///
56    /// let mut catalog = ParquetDataCatalog::new(
57    ///     std::path::Path::new("/tmp/nautilus_data"),
58    ///     None,
59    ///     None,
60    ///     None,
61    ///     None,
62    /// );
63    ///
64    /// // Find missing intervals for quote data
65    /// let missing = catalog.get_missing_intervals_for_request(
66    ///     1609459200000000000, // start
67    ///     1609545600000000000, // end
68    ///     &NautilusDataType::QuoteTick.into(),
69    ///     Some("BTCUSD"),
70    /// )?;
71    ///
72    /// for (start, end) in missing {
73    ///     println!("Missing data from {} to {}", start, end);
74    /// }
75    /// # Ok::<(), anyhow::Error>(())
76    /// ```
77    pub fn get_missing_intervals_for_request(
78        &self,
79        start: u64,
80        end: u64,
81        data_type: &CatalogDataType,
82        identifier: Option<&str>,
83    ) -> anyhow::Result<Vec<(u64, u64)>> {
84        let intervals = self.get_intervals(data_type, identifier)?;
85
86        Ok(query_interval_diff(start, end, &intervals))
87    }
88
89    /// Gets the first (earliest) timestamp for a specific data type and identifier.
90    ///
91    /// This method finds the earliest timestamp covered by existing data files for
92    /// the specified data type and identifier. This is useful for determining
93    /// the oldest data available or for incremental data updates.
94    ///
95    /// # Parameters
96    ///
97    /// - `data_type`: The stored family to inspect.
98    /// - `identifier`: Optional identifier to target a specific instrument's data. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
99    ///
100    /// # Returns
101    ///
102    /// Returns `Some(timestamp)` if data exists, `None` if no data is found,
103    /// or an error if the operation fails.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if:
108    /// - The directory path cannot be constructed.
109    /// - Interval retrieval fails.
110    ///
111    /// # Note
112    ///
113    /// Unlike the Python implementation, this method does not check subclasses of the
114    /// data type. The Python version checks `[data_cls, *data_cls.__subclasses__()]` to
115    /// handle cases where subclasses might use different directory names. Since Rust
116    /// works with string names rather than types, subclass checking is not possible.
117    /// In practice, most subclasses map to the same directory name via `class_to_filename`,
118    /// so this difference is typically not significant.
119    ///
120    /// # Examples
121    ///
122    /// ```rust,no_run
123    /// use nautilus_model::data::NautilusDataType;
124    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
125    ///
126    /// let mut catalog = ParquetDataCatalog::new(
127    ///     std::path::Path::new("/tmp/nautilus_data"),
128    ///     None,
129    ///     None,
130    ///     None,
131    ///     None,
132    /// );
133    ///
134    /// // Get the first timestamp for quote data
135    /// if let Some(first_ts) =
136    ///     catalog.query_first_timestamp(&NautilusDataType::QuoteTick.into(), Some("BTCUSD"))?
137    /// {
138    ///     println!("First quote timestamp: {}", first_ts);
139    /// } else {
140    ///     println!("No quote data found");
141    /// }
142    /// # Ok::<(), anyhow::Error>(())
143    /// ```
144    pub fn query_first_timestamp(
145        &self,
146        data_type: &CatalogDataType,
147        identifier: Option<&str>,
148    ) -> anyhow::Result<Option<u64>> {
149        let intervals = self.get_intervals(data_type, identifier)?;
150
151        Ok(intervals.first().map(|interval| interval.0))
152    }
153
154    /// Gets the last (most recent) timestamp for a specific data type and identifier.
155    ///
156    /// This method finds the latest timestamp covered by existing data files for
157    /// the specified data type and identifier. This is useful for determining
158    /// the most recent data available or for incremental data updates.
159    ///
160    /// # Parameters
161    ///
162    /// - `data_type`: The stored family to inspect.
163    /// - `identifier`: Optional identifier to target a specific instrument's data. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
164    ///
165    /// # Returns
166    ///
167    /// Returns `Some(timestamp)` if data exists, `None` if no data is found,
168    /// or an error if the operation fails.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if:
173    /// - The directory path cannot be constructed.
174    /// - Interval retrieval fails.
175    ///
176    /// # Note
177    ///
178    /// Unlike the Python implementation, this method does not check subclasses of the
179    /// data type. The Python version checks `[data_cls, *data_cls.__subclasses__()]` to
180    /// handle cases where subclasses might use different directory names. Since Rust
181    /// works with string names rather than types, subclass checking is not possible.
182    /// In practice, most subclasses map to the same directory name via `class_to_filename`,
183    /// so this difference is typically not significant.
184    ///
185    /// # Examples
186    ///
187    /// ```rust,no_run
188    /// use nautilus_model::data::NautilusDataType;
189    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
190    ///
191    /// let mut catalog = ParquetDataCatalog::new(
192    ///     std::path::Path::new("/tmp/nautilus_data"),
193    ///     None,
194    ///     None,
195    ///     None,
196    ///     None,
197    /// );
198    ///
199    /// // Get the last timestamp for quote data
200    /// if let Some(last_ts) =
201    ///     catalog.query_last_timestamp(&NautilusDataType::QuoteTick.into(), Some("BTCUSD"))?
202    /// {
203    ///     println!("Last quote timestamp: {}", last_ts);
204    /// } else {
205    ///     println!("No quote data found");
206    /// }
207    /// # Ok::<(), anyhow::Error>(())
208    /// ```
209    pub fn query_last_timestamp(
210        &self,
211        data_type: &CatalogDataType,
212        identifier: Option<&str>,
213    ) -> anyhow::Result<Option<u64>> {
214        let intervals = self.get_intervals(data_type, identifier)?;
215
216        Ok(intervals.last().map(|interval| interval.1))
217    }
218
219    /// Gets the time intervals covered by Parquet files for a specific data type and identifier.
220    ///
221    /// This method returns all time intervals covered by existing data files for the
222    /// specified data type and identifier. The intervals are sorted by start time and
223    /// represent the complete data coverage available.
224    ///
225    /// # Parameters
226    ///
227    /// - `data_type`: The stored family to inspect.
228    /// - `identifier`: Optional identifier to target a specific instrument's data. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
229    ///
230    /// # Returns
231    ///
232    /// Returns a vector of (start, end) tuples representing the covered intervals,
233    /// sorted by start time, or an error if the operation fails.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if:
238    /// - The directory path cannot be constructed.
239    /// - Directory listing fails.
240    /// - Filename parsing fails.
241    ///
242    /// # Examples
243    ///
244    /// ```rust,no_run
245    /// use nautilus_model::data::NautilusDataType;
246    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
247    ///
248    /// let mut catalog = ParquetDataCatalog::new(
249    ///     std::path::Path::new("/tmp/nautilus_data"),
250    ///     None,
251    ///     None,
252    ///     None,
253    ///     None,
254    /// );
255    ///
256    /// // Get all intervals for quote data
257    /// let intervals = catalog.get_intervals(&NautilusDataType::QuoteTick.into(), Some("BTCUSD"))?;
258    /// for (start, end) in intervals {
259    ///     println!("Data available from {} to {}", start, end);
260    /// }
261    /// # Ok::<(), anyhow::Error>(())
262    /// ```
263    pub fn get_intervals(
264        &self,
265        data_type: &CatalogDataType,
266        identifier: Option<&str>,
267    ) -> anyhow::Result<Vec<(u64, u64)>> {
268        let prefixes = parquet_catalog_data_type_path_prefixes(data_type);
269
270        if let [data_cls] = prefixes.as_slice() {
271            return self.get_prefix_intervals(data_cls.as_ref(), identifier);
272        }
273
274        let mut intervals = Vec::new();
275        for data_cls in &prefixes {
276            intervals.extend(self.get_prefix_intervals(data_cls.as_ref(), identifier)?);
277        }
278
279        intervals.sort_by_key(|&(start, _)| start);
280        Ok(merge_overlapping(intervals))
281    }
282
283    fn get_prefix_intervals(
284        &self,
285        data_cls: &str,
286        identifier: Option<&str>,
287    ) -> anyhow::Result<Vec<(u64, u64)>> {
288        let directory = self.make_path(data_cls, identifier)?;
289        let intervals = self.get_directory_intervals(&directory)?;
290
291        if identifier.is_none() {
292            // `get_directory_intervals` already recursed through every per-identifier
293            // subdirectory via `object_store.list`, so intervals from different
294            // identifiers can overlap. Merge overlaps into a disjoint sorted union
295            // so callers like `query_last_timestamp` see the true max end and
296            // `consolidate_data_by_period` sees contiguous coverage.
297            return Ok(merge_overlapping(intervals));
298        }
299
300        // For bars, fall back to partial matching when the exact directory
301        // doesn't exist (callers may pass an instrument_id like "EUR/USD.SIM"
302        // but bars are stored under bar_type dirs like "EURUSD.SIM-1-MINUTE-...")
303
304        if !intervals.is_empty() || !is_parquet_bar_prefix(data_cls) {
305            return Ok(intervals);
306        }
307
308        let safe_id = urisafe_instrument_id(identifier.unwrap());
309
310        // Use relative path so list_directory_stems doesn't double-prefix
311        // for remote catalogs (make_path already includes base_path)
312        let bars_subdir = format!("data/{data_cls}");
313        let subdirs = self.list_directory_stems(&bars_subdir)?;
314
315        let mut all_intervals = Vec::new();
316
317        for subdir in &subdirs {
318            let decoded = urlencoding::decode(subdir).unwrap_or(Cow::Borrowed(subdir));
319
320            if extract_bar_type_instrument_id(&decoded) == Some(safe_id.as_str()) {
321                // Use decoded name to avoid double percent-encoding
322                // (to_object_path uses Path::from which re-encodes)
323                let subdir_path = self.make_path(data_cls, Some(&decoded))?;
324                all_intervals.extend(self.get_directory_intervals(&subdir_path)?);
325            }
326        }
327
328        all_intervals.sort_by_key(|&(start, _)| start);
329
330        // Merge overlapping intervals from different bar types so that
331        // last().1 reliably gives the maximum end timestamp
332        Ok(merge_overlapping(all_intervals))
333    }
334
335    /// Gets the time intervals covered by Parquet files in a specific directory.
336    ///
337    /// This method scans a directory for Parquet files and extracts the timestamp ranges
338    /// from their filenames. It's used internally by other methods to determine data coverage
339    /// and is essential for interval-based operations like gap detection and consolidation.
340    ///
341    /// # Parameters
342    ///
343    /// - `directory`: The directory path to scan for Parquet files.
344    ///
345    /// # Returns
346    ///
347    /// Returns a vector of (start, end) tuples representing the time intervals covered
348    /// by files in the directory, sorted by start timestamp. Returns an empty vector
349    /// if the directory doesn't exist or contains no valid Parquet files.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error if:
354    /// - Object store listing operations fail.
355    /// - Directory access is denied.
356    ///
357    /// # Notes
358    ///
359    /// - Only files with valid timestamp-based filenames are included.
360    /// - Files with unparsable names are silently ignored.
361    /// - The method works with both local and remote object stores.
362    /// - Results are automatically sorted by start timestamp.
363    ///
364    /// # Examples
365    ///
366    /// ```rust,no_run
367    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
368    ///
369    /// let mut catalog = ParquetDataCatalog::new(
370    ///     std::path::Path::new("/tmp/nautilus_data"),
371    ///     None,
372    ///     None,
373    ///     None,
374    ///     None,
375    /// );
376    /// let intervals = catalog.get_directory_intervals("data/quotes/EURUSD")?;
377    ///
378    /// for (start, end) in intervals {
379    ///     println!("File covers {} to {}", start, end);
380    /// }
381    /// # Ok::<(), anyhow::Error>(())
382    /// ```
383    pub fn get_directory_intervals(&self, directory: &str) -> anyhow::Result<Vec<(u64, u64)>> {
384        // Use object store for all operations
385        // Convert directory to object path format (consistent with how files are written)
386        // For local stores with empty base_path, to_object_path returns path as-is.
387        // For remote stores, to_object_path preserves or prepends the catalog base path.
388        let object_dir = self.to_object_path(directory)?;
389        let list_result = self.list_objects(object_dir.as_ref())?;
390
391        let mut intervals = Vec::new();
392
393        for object in list_result {
394            let path_str = object.location.to_string();
395            if path_str.ends_with(".parquet")
396                && let Some(interval) = parse_filename_timestamps(&path_str)
397            {
398                intervals.push(interval);
399            }
400        }
401
402        intervals.sort_by_key(|&(start, _)| start);
403
404        Ok(intervals)
405    }
406}
407
408/// Merges overlapping intervals (sorted by start) into a disjoint sorted union.
409///
410/// Adjacent intervals stay separate, unlike [`crate::common::coverage::merge_closed_intervals`]:
411/// these intervals describe stored files, and `are_intervals_contiguous` checks that consecutive
412/// files abut exactly, which merging them away would hide.
413fn merge_overlapping(intervals: Vec<(u64, u64)>) -> Vec<(u64, u64)> {
414    let mut merged: Vec<(u64, u64)> = Vec::new();
415
416    for interval in intervals {
417        if let Some(last) = merged.last_mut()
418            && interval.0 <= last.1
419        {
420            last.1 = last.1.max(interval.1);
421            continue;
422        }
423        merged.push(interval);
424    }
425
426    merged
427}