Skip to main content

nautilus_common/cache/
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
16use nautilus_core::correctness::{CorrectnessResultExt, FAILED, check_positive_usize};
17use serde::{Deserialize, Deserializer, Serialize, de::Error};
18
19use crate::{enums::SerializationEncoding, msgbus::database::DatabaseConfig};
20
21/// Configuration for `Cache` instances.
22#[cfg_attr(
23    feature = "python",
24    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common", from_py_object)
25)]
26#[cfg_attr(
27    feature = "python",
28    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")
29)]
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
31#[serde(default, deny_unknown_fields)]
32pub struct CacheConfig {
33    /// The configuration for the cache backing database.
34    pub database: Option<DatabaseConfig>,
35    /// The encoding for database operations, controls the type of serializer used.
36    #[builder(default = SerializationEncoding::MsgPack)]
37    pub encoding: SerializationEncoding,
38    /// If timestamps should be persisted as ISO 8601 strings.
39    #[builder(default)]
40    pub timestamps_as_iso8601: bool,
41    /// The buffer interval (milliseconds) between pipelined/batched transactions.
42    pub buffer_interval_ms: Option<usize>,
43    /// The batch size for bulk read operations (e.g., MGET).
44    /// If set, bulk reads will be batched into chunks of this size.
45    pub bulk_read_batch_size: Option<usize>,
46    /// If a 'trader-' prefix is used for keys.
47    #[builder(default = true)]
48    pub use_trader_prefix: bool,
49    /// If the trader's instance ID is used for keys.
50    #[builder(default)]
51    pub use_instance_id: bool,
52    /// If the database should be flushed on start.
53    #[builder(default)]
54    pub flush_on_start: bool,
55    /// If instrument data should be dropped from the cache's memory on reset.
56    #[builder(default = true)]
57    pub drop_instruments_on_reset: bool,
58    /// The maximum length for internal tick deques.
59    #[builder(default = 10_000, with = |value: usize| positive_tick_capacity(value))]
60    #[serde(deserialize_with = "deserialize_positive_usize")]
61    pub tick_capacity: usize,
62    /// The maximum length for internal bar deques.
63    #[builder(default = 10_000, with = |value: usize| positive_bar_capacity(value))]
64    #[serde(deserialize_with = "deserialize_positive_usize")]
65    pub bar_capacity: usize,
66    /// If account events should be persisted to a backing database.
67    #[builder(default = true)]
68    pub persist_account_events: bool,
69    /// If market data should be persisted to disk.
70    #[builder(default)]
71    pub save_market_data: bool,
72}
73
74impl Default for CacheConfig {
75    fn default() -> Self {
76        Self::builder().build()
77    }
78}
79
80impl CacheConfig {
81    /// Creates a new [`CacheConfig`] instance.
82    ///
83    /// # Panics
84    ///
85    /// Panics if `tick_capacity` or `bar_capacity` is zero.
86    #[expect(clippy::too_many_arguments)]
87    #[must_use]
88    pub fn new(
89        database: Option<DatabaseConfig>,
90        encoding: SerializationEncoding,
91        timestamps_as_iso8601: bool,
92        buffer_interval_ms: Option<usize>,
93        bulk_read_batch_size: Option<usize>,
94        use_trader_prefix: bool,
95        use_instance_id: bool,
96        flush_on_start: bool,
97        drop_instruments_on_reset: bool,
98        tick_capacity: usize,
99        bar_capacity: usize,
100        persist_account_events: bool,
101        save_market_data: bool,
102    ) -> Self {
103        check_positive_usize(tick_capacity, stringify!(tick_capacity)).expect_display(FAILED);
104        check_positive_usize(bar_capacity, stringify!(bar_capacity)).expect_display(FAILED);
105
106        Self {
107            database,
108            encoding,
109            timestamps_as_iso8601,
110            buffer_interval_ms,
111            bulk_read_batch_size,
112            use_trader_prefix,
113            use_instance_id,
114            flush_on_start,
115            drop_instruments_on_reset,
116            tick_capacity,
117            bar_capacity,
118            persist_account_events,
119            save_market_data,
120        }
121    }
122
123    /// Checks whether all cache settings are valid.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if a capacity setting is not positive.
128    pub fn validate(&self) -> anyhow::Result<()> {
129        check_positive_usize(self.tick_capacity, stringify!(tick_capacity))?;
130        check_positive_usize(self.bar_capacity, stringify!(bar_capacity))?;
131        Ok(())
132    }
133}
134
135fn positive_tick_capacity(value: usize) -> usize {
136    check_positive_usize(value, stringify!(tick_capacity)).expect_display(FAILED);
137    value
138}
139
140fn positive_bar_capacity(value: usize) -> usize {
141    check_positive_usize(value, stringify!(bar_capacity)).expect_display(FAILED);
142    value
143}
144
145fn deserialize_positive_usize<'de, D>(deserializer: D) -> Result<usize, D::Error>
146where
147    D: Deserializer<'de>,
148{
149    let value = usize::deserialize(deserializer)?;
150    check_positive_usize(value, "capacity").map_err(D::Error::custom)?;
151    Ok(value)
152}
153
154#[cfg(test)]
155mod tests {
156    use rstest::rstest;
157
158    use super::*;
159
160    #[rstest]
161    #[case(0, 1)]
162    #[case(1, 0)]
163    #[should_panic]
164    fn test_new_rejects_zero_capacities(#[case] tick_capacity: usize, #[case] bar_capacity: usize) {
165        let _ = CacheConfig::new(
166            None,
167            SerializationEncoding::MsgPack,
168            false,
169            None,
170            None,
171            true,
172            false,
173            false,
174            true,
175            tick_capacity,
176            bar_capacity,
177            true,
178            false,
179        );
180    }
181
182    #[rstest]
183    #[should_panic(expected = "invalid usize for 'tick_capacity' not positive")]
184    fn test_builder_rejects_zero_tick_capacity() {
185        let _ = CacheConfig::builder().tick_capacity(0).build();
186    }
187
188    #[rstest]
189    #[should_panic(expected = "invalid usize for 'bar_capacity' not positive")]
190    fn test_builder_rejects_zero_bar_capacity() {
191        let _ = CacheConfig::builder().bar_capacity(0).build();
192    }
193
194    #[rstest]
195    #[case(0, 1, "invalid usize for 'tick_capacity' not positive, was 0")]
196    #[case(1, 0, "invalid usize for 'bar_capacity' not positive, was 0")]
197    fn test_validate_rejects_zero_capacities(
198        #[case] tick_capacity: usize,
199        #[case] bar_capacity: usize,
200        #[case] expected: &str,
201    ) {
202        let config = CacheConfig {
203            tick_capacity,
204            bar_capacity,
205            ..Default::default()
206        };
207
208        let err = config.validate().expect_err("zero capacity is invalid");
209
210        assert_eq!(err.to_string(), expected);
211    }
212
213    #[rstest]
214    #[case(r#"{"tick_capacity":0}"#)]
215    #[case(r#"{"bar_capacity":0}"#)]
216    fn test_deserialize_rejects_zero_capacities(#[case] raw: &str) {
217        let err = serde_json::from_str::<CacheConfig>(raw)
218            .expect_err("zero capacity should fail deserialization");
219
220        assert!(
221            err.to_string()
222                .contains("invalid usize for 'capacity' not positive")
223        );
224    }
225
226    #[rstest]
227    fn test_deserialize_uses_positive_default_capacities() {
228        let config = serde_json::from_str::<CacheConfig>("{}").unwrap();
229
230        assert_eq!(config.tick_capacity, 10_000);
231        assert_eq!(config.bar_capacity, 10_000);
232    }
233}