Skip to main content

nautilus_persistence/
config.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//! Persistence configuration shared by live and backtest runtimes.
17
18use std::fmt::Display;
19
20use nautilus_common::config::{ConfigError, ConfigErrorCollector, ConfigResult};
21use nautilus_core::{DurationNanos, Params, UnixNanos};
22use nautilus_model::{
23    data::{NautilusDataType, NautilusRecordType},
24    instruments::NautilusInstrumentType,
25};
26use serde::{Deserialize, Serialize};
27
28use crate::{
29    catalog::factory::PARQUET_CATALOG_FACTORY_NAME, common::backend_name::backend_type,
30    writer::factory::WriterBackendType,
31};
32
33backend_type!(
34    /// Catalog backend used to satisfy runtime data catalog requests.
35    ///
36    /// Serializes as its name, so an external backend round-trips as the plain factory name that
37    /// registered it, matching [`WriterBackendType`].
38    CatalogBackendType {
39        Parquet => PARQUET_CATALOG_FACTORY_NAME,
40    }
41);
42
43/// Configuration for a catalog available to request-time historical data loading.
44#[cfg_attr(
45    feature = "python",
46    expect(
47        clippy::unsafe_derive_deserialize,
48        reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
49    )
50)]
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
52#[serde(deny_unknown_fields)]
53#[cfg_attr(
54    feature = "python",
55    pyo3::pyclass(module = "nautilus_trader.persistence", from_py_object, eq)
56)]
57#[cfg_attr(
58    feature = "python",
59    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")
60)]
61pub struct DataCatalogConfig {
62    /// The path to the data catalog.
63    path: String,
64    /// The catalog registration name.
65    name: Option<String>,
66    /// The fsspec file system protocol for the data catalog.
67    #[serde(default = "default_fs_protocol")]
68    #[builder(default = default_fs_protocol())]
69    fs_protocol: String,
70    /// The catalog backend implementation to use.
71    #[serde(default)]
72    #[builder(default)]
73    catalog_backend: CatalogBackendType,
74    /// Backend-specific catalog parameters.
75    params: Option<Params>,
76    #[serde(default)]
77    fs_rust_storage_options: Option<ahash::AHashMap<String, String>>,
78    /// Whether the catalog rejects response write-back.
79    #[serde(default)]
80    #[builder(default)]
81    read_only: bool,
82}
83
84impl DataCatalogConfig {
85    /// Creates a new [`DataCatalogConfig`] instance.
86    #[must_use]
87    pub fn new(
88        path: String,
89        fs_protocol: Option<String>,
90        catalog_backend: Option<CatalogBackendType>,
91    ) -> Self {
92        Self {
93            path,
94            name: None,
95            fs_protocol: fs_protocol.unwrap_or_else(default_fs_protocol),
96            catalog_backend: catalog_backend.unwrap_or_default(),
97            params: None,
98            fs_rust_storage_options: None,
99            read_only: false,
100        }
101    }
102
103    /// Sets native object-store connection options.
104    #[must_use]
105    pub fn with_storage_options(
106        mut self,
107        options: Option<ahash::AHashMap<String, String>>,
108    ) -> Self {
109        self.fs_rust_storage_options = options;
110        self
111    }
112
113    /// Returns native object-store options.
114    #[must_use]
115    pub fn fs_rust_storage_options(&self) -> Option<&ahash::AHashMap<String, String>> {
116        self.fs_rust_storage_options.as_ref()
117    }
118
119    /// Creates the configured catalog through the built-in factory registry.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the backend is unavailable or its connection cannot be opened.
124    pub fn create_catalog(&self) -> anyhow::Result<crate::catalog::traits::DataCatalog> {
125        let mut connect = crate::catalog::factory::CatalogConnectConfig::from_path_and_protocol(
126            &self.path,
127            Some(&self.fs_protocol),
128            self.fs_rust_storage_options.clone(),
129        );
130        connect.params.clone_from(&self.params);
131        let factories = crate::backend::default_catalog_factories();
132        let name = self.catalog_backend.to_string();
133        factories
134            .get(&name)
135            .ok_or_else(|| anyhow::anyhow!("No catalog factory registered for '{name}'"))?(
136            &connect
137        )
138    }
139
140    /// Returns a copy with catalog registration name set.
141    #[must_use]
142    pub fn with_name(mut self, name: Option<String>) -> Self {
143        self.name = name;
144        self
145    }
146
147    /// Returns a copy with backend-specific catalog parameters set.
148    #[must_use]
149    pub fn with_params(mut self, params: Option<Params>) -> Self {
150        self.params = params;
151        self
152    }
153
154    /// Returns a copy with read-only response write-back behavior set.
155    #[must_use]
156    pub const fn with_read_only(mut self, read_only: bool) -> Self {
157        self.read_only = read_only;
158        self
159    }
160
161    /// Returns the path to the data catalog.
162    #[must_use]
163    pub fn path(&self) -> &str {
164        &self.path
165    }
166
167    /// Returns the catalog registration name.
168    #[must_use]
169    pub fn name(&self) -> Option<&str> {
170        self.name.as_deref()
171    }
172
173    /// Returns whether the catalog rejects response write-back.
174    #[must_use]
175    pub const fn read_only(&self) -> bool {
176        self.read_only
177    }
178
179    /// Returns the fsspec file system protocol for the data catalog.
180    #[must_use]
181    pub fn fs_protocol(&self) -> &str {
182        &self.fs_protocol
183    }
184
185    /// Returns the catalog backend implementation to use.
186    #[must_use]
187    pub const fn catalog_backend(&self) -> &CatalogBackendType {
188        &self.catalog_backend
189    }
190
191    /// Returns backend-specific catalog parameters.
192    #[must_use]
193    pub const fn params(&self) -> Option<&Params> {
194        self.params.as_ref()
195    }
196}
197
198/// Configuration for file rotation in streaming output.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum RotationConfig {
202    /// Rotate based on file size.
203    Size {
204        /// Maximum buffer size in bytes before rotation.
205        max_size: u64,
206    },
207    /// Rotate based on a time interval.
208    Interval {
209        /// Interval in nanoseconds.
210        interval_ns: DurationNanos,
211    },
212    /// Rotate based on scheduled dates.
213    ScheduledDates {
214        /// Interval in nanoseconds.
215        interval_ns: DurationNanos,
216        /// Start of the scheduled rotation period.
217        schedule_ns: UnixNanos,
218    },
219    /// No automatic rotation.
220    NoRotation,
221}
222
223impl RotationConfig {
224    /// Converts the public streaming configuration into the writer's runtime form.
225    #[must_use]
226    pub fn to_writer_rotation_config(&self) -> crate::writer::feather::RotationConfig {
227        match self {
228            Self::Size { max_size } => crate::writer::feather::RotationConfig::Size {
229                max_size: *max_size,
230            },
231            Self::Interval { interval_ns } => crate::writer::feather::RotationConfig::Interval {
232                interval_ns: interval_ns.as_u64(),
233            },
234            Self::ScheduledDates {
235                interval_ns,
236                schedule_ns,
237            } => crate::writer::feather::RotationConfig::scheduled_utc(
238                interval_ns.as_u64(),
239                *schedule_ns,
240            ),
241            Self::NoRotation => crate::writer::feather::RotationConfig::NoRotation,
242        }
243    }
244}
245
246/// Record filter entry for streaming persistence.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct StreamingRecordFilterConfig {
250    /// Record family to write.
251    pub record_type: NautilusRecordType,
252    /// Optional identifiers within the record family.
253    pub identifiers: Option<Vec<String>>,
254}
255
256/// Configuration streaming live or backtest runs to a persistence writer.
257#[cfg_attr(
258    feature = "python",
259    expect(
260        clippy::unsafe_derive_deserialize,
261        reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
262    )
263)]
264#[cfg_attr(
265    feature = "python",
266    pyo3::pyclass(frozen, module = "nautilus_trader.persistence", from_py_object)
267)]
268#[cfg_attr(
269    feature = "python",
270    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")
271)]
272#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
273#[builder(finish_fn(name = build_inner, vis = ""))]
274#[serde(deny_unknown_fields)]
275pub struct StreamingConfig {
276    /// Path to the data catalog.
277    pub catalog_path: String,
278    /// Filesystem protocol for the catalog.
279    pub fs_protocol: String,
280    /// Flush interval in milliseconds.
281    pub flush_interval_ms: u64,
282    /// Whether to replace existing files.
283    pub replace_existing: bool,
284    /// Rotation configuration.
285    pub rotation_config: RotationConfig,
286    /// Writer backend (`Feather`, `Parquet`, or external factory name).
287    #[serde(default)]
288    #[builder(default)]
289    pub writer_backend: WriterBackendType,
290    /// Optional data families to write.
291    pub data_types: Option<Vec<NautilusDataType>>,
292    /// Optional record families to write.
293    pub record_types: Option<Vec<NautilusRecordType>>,
294    /// Optional instrument families to write.
295    pub instrument_types: Option<Vec<NautilusInstrumentType>>,
296    /// Optional record family and identifier filters.
297    pub record_filters: Option<Vec<StreamingRecordFilterConfig>>,
298    /// Backend-specific writer parameters.
299    pub params: Option<Params>,
300}
301
302impl<S: streaming_config_builder::IsComplete> StreamingConfigBuilder<S> {
303    /// Validates and builds the [`StreamingConfig`].
304    ///
305    /// # Errors
306    ///
307    /// Returns a [`ConfigError`] if any field fails validation
308    /// (see [`StreamingConfig::validate`]).
309    pub fn build(self) -> ConfigResult<StreamingConfig> {
310        let config = self.build_inner();
311        config.validate()?;
312        Ok(config)
313    }
314}
315
316impl StreamingConfig {
317    /// Creates new [`StreamingConfig`] instance.
318    #[must_use]
319    pub fn new(
320        catalog_path: String,
321        fs_protocol: String,
322        flush_interval_ms: u64,
323        replace_existing: bool,
324        rotation_config: RotationConfig,
325    ) -> Self {
326        Self {
327            catalog_path,
328            fs_protocol,
329            flush_interval_ms,
330            replace_existing,
331            rotation_config,
332            writer_backend: WriterBackendType::default(),
333            data_types: None,
334            record_types: None,
335            instrument_types: None,
336            record_filters: None,
337            params: None,
338        }
339    }
340
341    /// Validates the streaming configuration, collecting every field violation.
342    ///
343    /// # Errors
344    ///
345    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
346    /// invalid) if any field fails validation.
347    pub fn validate(&self) -> ConfigResult<()> {
348        let mut errors = ConfigErrorCollector::new();
349
350        errors.check(
351            !self.catalog_path.trim().is_empty(),
352            ConfigError::empty_field("catalog_path"),
353        );
354        errors.check(
355            !self.fs_protocol.trim().is_empty(),
356            ConfigError::empty_field("fs_protocol"),
357        );
358
359        let flush_interval_ms = self.flush_interval_ms;
360        errors.check(
361            flush_interval_ms > 0,
362            ConfigError::range(
363                "flush_interval_ms",
364                format!("must be a positive number of milliseconds, was {flush_interval_ms}"),
365            ),
366        );
367
368        if let Some(data_types) = &self.data_types {
369            errors.check(
370                !data_types.is_empty(),
371                ConfigError::invalid_value(
372                    "data_types",
373                    "must not be an empty list; omit the field for unfiltered streaming",
374                ),
375            );
376        }
377
378        if let Some(record_types) = &self.record_types {
379            errors.check(
380                !record_types.is_empty(),
381                ConfigError::invalid_value(
382                    "record_types",
383                    "must not be an empty list; omit the field for unfiltered streaming",
384                ),
385            );
386        }
387
388        if let Some(instrument_types) = &self.instrument_types {
389            errors.check(
390                !instrument_types.is_empty(),
391                ConfigError::invalid_value(
392                    "instrument_types",
393                    "must not be an empty list; omit the field for unfiltered streaming",
394                ),
395            );
396        }
397
398        if let Some(record_filters) = &self.record_filters {
399            errors.check(
400                !record_filters.is_empty(),
401                ConfigError::invalid_value(
402                    "record_filters",
403                    "must not be an empty list; omit the field for unfiltered streaming",
404                ),
405            );
406        }
407
408        errors.into_result()
409    }
410}
411
412pub(crate) fn default_fs_protocol() -> String {
413    "file".to_string()
414}
415
416#[cfg(test)]
417mod tests {
418    use rstest::rstest;
419    use serde_json::json;
420
421    use super::*;
422
423    #[rstest]
424    fn catalog_backend_type_preserves_external_factory_case() {
425        assert_eq!(
426            "CaseSensitiveExternal"
427                .parse::<CatalogBackendType>()
428                .unwrap(),
429            CatalogBackendType::External("CaseSensitiveExternal".to_string()),
430        );
431    }
432
433    #[rstest]
434    fn catalog_backend_type_accepts_parquet_without_changing_default() {
435        assert_eq!(
436            "parquet".parse::<CatalogBackendType>().unwrap(),
437            CatalogBackendType::Parquet
438        );
439        assert_eq!(CatalogBackendType::Parquet.to_string(), "Parquet");
440        assert_eq!(CatalogBackendType::default(), CatalogBackendType::Parquet);
441    }
442
443    #[rstest]
444    fn catalog_backend_type_rejects_empty_name() {
445        assert!("".parse::<CatalogBackendType>().is_err());
446    }
447
448    #[rstest]
449    fn catalog_backend_type_display_roundtrips_built_ins() {
450        assert_eq!(CatalogBackendType::Parquet.to_string(), "Parquet");
451        assert_eq!(CatalogBackendType::Parquet.to_string(), "Parquet");
452        assert_eq!(
453            "Parquet".parse::<CatalogBackendType>().unwrap(),
454            CatalogBackendType::Parquet,
455        );
456        assert_eq!(
457            "Parquet".parse::<CatalogBackendType>().unwrap(),
458            CatalogBackendType::Parquet,
459        );
460    }
461
462    #[rstest]
463    fn data_catalog_config_preserves_backend_params() {
464        let mut params = Params::new();
465        params.insert("batch_size".to_string(), json!(1024));
466        let config = DataCatalogConfig::new(
467            "/data/catalog".to_string(),
468            Some("file".to_string()),
469            Some(CatalogBackendType::Parquet),
470        )
471        .with_params(Some(params));
472
473        assert_eq!(
474            config.params().and_then(|p| p.get_u64("batch_size")),
475            Some(1024)
476        );
477    }
478
479    #[rstest]
480    fn data_catalog_config_toml_defaults_backend_fields() {
481        let config: DataCatalogConfig = toml::from_str(
482            r#"
483path = "/data/catalog"
484"#,
485        )
486        .unwrap();
487
488        assert_eq!(config.path(), "/data/catalog");
489        assert_eq!(config.fs_protocol(), "file");
490        assert_eq!(config.catalog_backend(), &CatalogBackendType::Parquet);
491        assert_eq!(config.params(), None);
492        assert!(!config.read_only());
493    }
494
495    #[rstest]
496    fn data_catalog_config_reads_read_only_flag() {
497        let config: DataCatalogConfig = toml::from_str(
498            r#"
499path = "/data/catalog"
500read_only = true
501"#,
502        )
503        .unwrap();
504
505        assert!(config.read_only());
506    }
507
508    #[rstest]
509    fn streaming_config_builder_valid() {
510        let config = StreamingConfig::builder()
511            .catalog_path("/data/catalog".to_string())
512            .fs_protocol("file".to_string())
513            .flush_interval_ms(1_000)
514            .replace_existing(false)
515            .rotation_config(RotationConfig::NoRotation)
516            .build();
517
518        assert!(config.is_ok());
519    }
520
521    #[rstest]
522    fn streaming_config_builder_preserves_backend_params() {
523        let mut params = Params::new();
524        params.insert("promote_on_close".to_string(), json!(true));
525        let config = StreamingConfig::builder()
526            .catalog_path("/data/catalog".to_string())
527            .fs_protocol("file".to_string())
528            .flush_interval_ms(1_000)
529            .replace_existing(false)
530            .rotation_config(RotationConfig::NoRotation)
531            .params(params)
532            .build()
533            .unwrap();
534
535        assert_eq!(
536            config
537                .params
538                .as_ref()
539                .and_then(|p| p.get_bool("promote_on_close")),
540            Some(true)
541        );
542    }
543
544    #[rstest]
545    fn streaming_config_zero_flush_interval_rejected() {
546        let result = StreamingConfig::builder()
547            .catalog_path("/data/catalog".to_string())
548            .fs_protocol("file".to_string())
549            .flush_interval_ms(0)
550            .replace_existing(false)
551            .rotation_config(RotationConfig::NoRotation)
552            .build();
553
554        assert!(
555            matches!(result, Err(ConfigError::Range { field, .. }) if field == "flush_interval_ms")
556        );
557    }
558
559    #[rstest]
560    fn streaming_config_empty_catalog_path_rejected() {
561        let result = StreamingConfig::builder()
562            .catalog_path(String::new())
563            .fs_protocol("file".to_string())
564            .flush_interval_ms(1_000)
565            .replace_existing(false)
566            .rotation_config(RotationConfig::NoRotation)
567            .build();
568
569        assert!(
570            matches!(result, Err(ConfigError::EmptyField { field }) if field == "catalog_path")
571        );
572    }
573
574    #[rstest]
575    fn streaming_config_empty_filter_lists_rejected() {
576        let result = StreamingConfig::builder()
577            .catalog_path("/data/catalog".to_string())
578            .fs_protocol("file".to_string())
579            .flush_interval_ms(1_000)
580            .replace_existing(false)
581            .rotation_config(RotationConfig::NoRotation)
582            .data_types(vec![])
583            .record_types(vec![])
584            .instrument_types(vec![])
585            .record_filters(vec![])
586            .build();
587
588        match result.unwrap_err() {
589            ConfigError::Multiple { errors } => {
590                assert_eq!(errors.len(), 4);
591                assert!(errors.iter().all(|e| matches!(
592                    e,
593                    ConfigError::InvalidValue { field, .. }
594                        if field == "data_types"
595                            || field == "record_types"
596                            || field == "instrument_types"
597                            || field == "record_filters"
598                )));
599            }
600            error => panic!("Expected multiple config errors, received {error:?}"),
601        }
602    }
603
604    #[rstest]
605    fn streaming_config_toml_round_trip() {
606        let config: StreamingConfig = toml::from_str(
607            r#"
608catalog_path = "/data/catalog"
609fs_protocol = "file"
610flush_interval_ms = 1000
611replace_existing = false
612
613[rotation_config.size]
614max_size = 1048576
615"#,
616        )
617        .unwrap();
618
619        assert_eq!(config.catalog_path, "/data/catalog");
620        assert_eq!(config.fs_protocol, "file");
621        assert_eq!(config.flush_interval_ms, 1000);
622        assert!(!config.replace_existing);
623        assert_eq!(config.params, None);
624        assert!(matches!(
625            config.rotation_config,
626            RotationConfig::Size {
627                max_size: 1_048_576
628            }
629        ));
630    }
631
632    #[rstest]
633    fn streaming_config_with_no_rotation_toml() {
634        let config: StreamingConfig = toml::from_str(
635            r#"
636catalog_path = "/data/catalog"
637fs_protocol = "file"
638flush_interval_ms = 500
639replace_existing = true
640rotation_config = "no_rotation"
641"#,
642        )
643        .unwrap();
644
645        assert!(matches!(config.rotation_config, RotationConfig::NoRotation));
646        assert!(config.replace_existing);
647    }
648}