Skip to main content

nautilus_execution/engine/
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_common::config::{ConfigError, ConfigErrorCollector, ConfigResult};
17use nautilus_core::{datetime::checked_mins_to_nanos, serialization::default_true};
18use nautilus_model::identifiers::ClientId;
19use serde::{Deserialize, Serialize};
20
21/// Configuration for `ExecutionEngine` instances.
22#[cfg_attr(
23    feature = "python",
24    pyo3::pyclass(module = "nautilus_trader.execution", from_py_object)
25)]
26#[cfg_attr(
27    feature = "python",
28    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
29)]
30#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
31#[builder(finish_fn(name = build_inner, vis = ""))]
32#[serde(deny_unknown_fields)]
33pub struct ExecutionEngineConfig {
34    /// If the cache should be loaded on initialization.
35    #[serde(default = "default_true")]
36    #[builder(default = true)]
37    pub load_cache: bool,
38    /// If the execution engine should maintain own/user order books based on commands and events.
39    #[serde(default)]
40    #[builder(default)]
41    pub manage_own_order_books: bool,
42    /// If order state snapshot lists are persisted to a backing database.
43    /// Snapshots will be taken at every order state update (when events are applied).
44    #[serde(default)]
45    #[builder(default)]
46    pub snapshot_orders: bool,
47    /// If position state snapshot lists are persisted to a backing database.
48    /// Snapshots will be taken at position opened, changed, and closed (when events are applied).
49    #[serde(default)]
50    #[builder(default)]
51    pub snapshot_positions: bool,
52    /// The interval (seconds) at which additional position state snapshots are persisted.
53    /// If `None` then no additional snapshots will be taken.
54    #[serde(default)]
55    pub snapshot_positions_interval_secs: Option<f64>,
56    /// If position replay events and fill voids are carried across NETTING close/reopen cycles.
57    /// Enable to keep fills from earlier cycles correctable by an `OrderFillVoided`.
58    #[serde(default)]
59    #[builder(default)]
60    pub carry_replay_events_on_reopen: bool,
61    /// If order fills exceeding order quantity are allowed (logs warning instead of raising).
62    /// Useful when position reconciliation races with exchange fill events.
63    #[serde(default)]
64    #[builder(default)]
65    pub allow_overfills: bool,
66    /// If unclaimed venue orders should be filtered during execution reconciliation.
67    #[serde(default)]
68    #[builder(default)]
69    pub filter_unclaimed_external_orders: bool,
70    /// The client IDs declared for external stream processing.
71    ///
72    /// The execution engine will not attempt to send trading commands to these
73    /// client IDs, assuming an external process will consume the serialized
74    /// command messages from the bus and handle execution.
75    #[serde(default)]
76    pub external_clients: Option<Vec<ClientId>>,
77    /// The interval (minutes) between purging closed orders from the in-memory cache.
78    #[serde(default)]
79    pub purge_closed_orders_interval_mins: Option<u32>,
80    /// The time buffer (minutes) before closed orders can be purged.
81    #[serde(default)]
82    pub purge_closed_orders_buffer_mins: Option<u32>,
83    /// The interval (minutes) between purging closed positions from the in-memory cache.
84    #[serde(default)]
85    pub purge_closed_positions_interval_mins: Option<u32>,
86    /// The time buffer (minutes) before closed positions can be purged.
87    #[serde(default)]
88    pub purge_closed_positions_buffer_mins: Option<u32>,
89    /// The interval (minutes) between purging account events from the in-memory cache.
90    #[serde(default)]
91    pub purge_account_events_interval_mins: Option<u32>,
92    /// The time buffer (minutes) before account events can be purged.
93    #[serde(default)]
94    pub purge_account_events_lookback_mins: Option<u32>,
95    /// If purge operations should also delete from the backing database.
96    #[serde(default)]
97    #[builder(default)]
98    pub purge_from_database: bool,
99    /// If debug mode is active (will provide extra debug logging).
100    #[serde(default)]
101    #[builder(default)]
102    pub debug: bool,
103}
104
105impl<S: execution_engine_config_builder::IsComplete> ExecutionEngineConfigBuilder<S> {
106    /// Validates and builds the [`ExecutionEngineConfig`].
107    ///
108    /// # Errors
109    ///
110    /// Returns a [`ConfigError`] if any field fails validation
111    /// (see [`ExecutionEngineConfig::validate`]).
112    pub fn build(self) -> ConfigResult<ExecutionEngineConfig> {
113        let config = self.build_inner();
114        config.validate()?;
115        Ok(config)
116    }
117}
118
119impl ExecutionEngineConfig {
120    /// Validates the execution engine configuration, collecting every field violation.
121    ///
122    /// # Errors
123    ///
124    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
125    /// invalid) if any field fails validation.
126    pub fn validate(&self) -> ConfigResult<()> {
127        let mut errors = ConfigErrorCollector::new();
128
129        if let Some(secs) = self.snapshot_positions_interval_secs {
130            errors.check(
131                secs.is_finite() && secs > 0.0,
132                ConfigError::range(
133                    "snapshot_positions_interval_secs",
134                    format!("must be a positive finite value, was {secs}"),
135                ),
136            );
137        }
138
139        for (field, value) in [
140            (
141                "purge_closed_orders_interval_mins",
142                self.purge_closed_orders_interval_mins,
143            ),
144            (
145                "purge_closed_positions_interval_mins",
146                self.purge_closed_positions_interval_mins,
147            ),
148            (
149                "purge_account_events_interval_mins",
150                self.purge_account_events_interval_mins,
151            ),
152        ] {
153            if let Some(mins) = value {
154                let reason = if mins == 0 {
155                    format!("must be a positive number of minutes, was {mins}")
156                } else {
157                    format!("must be positive and fit in `u64` nanoseconds, was {mins} minutes")
158                };
159                errors.check(
160                    mins > 0 && checked_mins_to_nanos(u64::from(mins)).is_some(),
161                    ConfigError::range(field, reason),
162                );
163            }
164        }
165
166        for (field, value) in [
167            (
168                "purge_closed_orders_buffer_mins",
169                self.purge_closed_orders_buffer_mins,
170            ),
171            (
172                "purge_closed_positions_buffer_mins",
173                self.purge_closed_positions_buffer_mins,
174            ),
175            (
176                "purge_account_events_lookback_mins",
177                self.purge_account_events_lookback_mins,
178            ),
179        ] {
180            if let Some(mins) = value {
181                errors.check(
182                    checked_mins_to_nanos(u64::from(mins)).is_some(),
183                    ConfigError::range(
184                        field,
185                        format!("must fit in `u64` nanoseconds, was {mins} minutes"),
186                    ),
187                );
188            }
189        }
190
191        errors.into_result()
192    }
193}
194
195impl Default for ExecutionEngineConfig {
196    fn default() -> Self {
197        Self::builder()
198            .build()
199            .expect("default `ExecutionEngineConfig` should be valid")
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use rstest::rstest;
206
207    use super::*;
208
209    #[rstest]
210    fn test_default_config_is_valid() {
211        assert!(ExecutionEngineConfig::builder().build().is_ok());
212    }
213
214    #[rstest]
215    fn test_carry_replay_events_on_reopen_defaults_false() {
216        assert!(!ExecutionEngineConfig::default().carry_replay_events_on_reopen);
217
218        let config: ExecutionEngineConfig =
219            serde_json::from_str("{}").expect("empty config should deserialize");
220        assert!(!config.carry_replay_events_on_reopen);
221
222        let config = ExecutionEngineConfig::builder()
223            .carry_replay_events_on_reopen(true)
224            .build()
225            .unwrap();
226        assert!(config.carry_replay_events_on_reopen);
227    }
228
229    #[rstest]
230    #[case(0.0)]
231    #[case(-1.0)]
232    #[case(f64::INFINITY)]
233    #[case(f64::NAN)]
234    fn test_invalid_snapshot_positions_interval_secs_rejected(#[case] secs: f64) {
235        let result = ExecutionEngineConfig::builder()
236            .snapshot_positions_interval_secs(secs)
237            .build();
238        assert!(
239            matches!(result, Err(ConfigError::Range { field, .. }) if field == "snapshot_positions_interval_secs")
240        );
241    }
242
243    #[rstest]
244    fn test_positive_snapshot_positions_interval_secs_accepted() {
245        let result = ExecutionEngineConfig::builder()
246            .snapshot_positions_interval_secs(5.0)
247            .build();
248        assert!(result.is_ok());
249    }
250
251    #[rstest]
252    fn test_zero_purge_closed_orders_interval_rejected() {
253        let result = ExecutionEngineConfig::builder()
254            .purge_closed_orders_interval_mins(0)
255            .build();
256        assert!(
257            matches!(result, Err(ConfigError::Range { field, .. }) if field == "purge_closed_orders_interval_mins")
258        );
259    }
260
261    #[rstest]
262    fn test_zero_purge_closed_positions_interval_rejected() {
263        let result = ExecutionEngineConfig::builder()
264            .purge_closed_positions_interval_mins(0)
265            .build();
266        assert!(
267            matches!(result, Err(ConfigError::Range { field, .. }) if field == "purge_closed_positions_interval_mins")
268        );
269    }
270
271    #[rstest]
272    fn test_zero_purge_account_events_interval_rejected() {
273        let result = ExecutionEngineConfig::builder()
274            .purge_account_events_interval_mins(0)
275            .build();
276        assert!(
277            matches!(result, Err(ConfigError::Range { field, .. }) if field == "purge_account_events_interval_mins")
278        );
279    }
280
281    #[rstest]
282    fn test_positive_purge_intervals_accepted() {
283        // A zero buffer is valid (no grace period), only the intervals must be positive
284        let result = ExecutionEngineConfig::builder()
285            .purge_closed_orders_interval_mins(10)
286            .purge_closed_positions_interval_mins(10)
287            .purge_account_events_interval_mins(10)
288            .purge_closed_orders_buffer_mins(0)
289            .build();
290        assert!(result.is_ok());
291    }
292
293    #[rstest]
294    fn test_overflowing_purge_intervals_rejected() {
295        let result = ExecutionEngineConfig::builder()
296            .purge_closed_orders_interval_mins(u32::MAX)
297            .purge_closed_positions_interval_mins(u32::MAX)
298            .purge_account_events_interval_mins(u32::MAX)
299            .build();
300        assert_eq!(
301            result.unwrap_err(),
302            ConfigError::Multiple {
303                errors: vec![
304                    ConfigError::range(
305                        "purge_closed_orders_interval_mins",
306                        "must be positive and fit in `u64` nanoseconds, was 4294967295 minutes",
307                    ),
308                    ConfigError::range(
309                        "purge_closed_positions_interval_mins",
310                        "must be positive and fit in `u64` nanoseconds, was 4294967295 minutes",
311                    ),
312                    ConfigError::range(
313                        "purge_account_events_interval_mins",
314                        "must be positive and fit in `u64` nanoseconds, was 4294967295 minutes",
315                    ),
316                ],
317            }
318        );
319    }
320
321    #[rstest]
322    #[case("purge_closed_orders_buffer_mins", 0)]
323    #[case("purge_closed_orders_buffer_mins", 307_445_734)]
324    #[case("purge_closed_positions_buffer_mins", 0)]
325    #[case("purge_closed_positions_buffer_mins", 307_445_734)]
326    #[case("purge_account_events_lookback_mins", 0)]
327    #[case("purge_account_events_lookback_mins", 307_445_734)]
328    fn test_purge_retention_minute_boundaries_accepted(#[case] field: &str, #[case] mins: u32) {
329        let mut config = ExecutionEngineConfig::default();
330
331        match field {
332            "purge_closed_orders_buffer_mins" => {
333                config.purge_closed_orders_buffer_mins = Some(mins);
334            }
335            "purge_closed_positions_buffer_mins" => {
336                config.purge_closed_positions_buffer_mins = Some(mins);
337            }
338            "purge_account_events_lookback_mins" => {
339                config.purge_account_events_lookback_mins = Some(mins);
340            }
341            _ => unreachable!(),
342        }
343
344        assert!(config.validate().is_ok());
345    }
346
347    #[rstest]
348    #[case("purge_closed_orders_buffer_mins")]
349    #[case("purge_closed_positions_buffer_mins")]
350    #[case("purge_account_events_lookback_mins")]
351    fn test_overflowing_purge_retention_minutes_rejected(#[case] field: &str) {
352        let mut config = ExecutionEngineConfig::default();
353
354        match field {
355            "purge_closed_orders_buffer_mins" => {
356                config.purge_closed_orders_buffer_mins = Some(307_445_735);
357            }
358            "purge_closed_positions_buffer_mins" => {
359                config.purge_closed_positions_buffer_mins = Some(307_445_735);
360            }
361            "purge_account_events_lookback_mins" => {
362                config.purge_account_events_lookback_mins = Some(307_445_735);
363            }
364            _ => unreachable!(),
365        }
366
367        assert!(matches!(
368            config.validate(),
369            Err(ConfigError::Range { field: error_field, .. }) if error_field == field
370        ));
371    }
372
373    #[rstest]
374    fn test_multiple_violations_collected() {
375        let result = ExecutionEngineConfig::builder()
376            .snapshot_positions_interval_secs(0.0)
377            .purge_closed_orders_interval_mins(0)
378            .build();
379        let ConfigError::Multiple { errors } = result.unwrap_err() else {
380            panic!("expected ConfigError::Multiple");
381        };
382        assert_eq!(errors.len(), 2);
383        assert!(errors.iter().any(
384            |e| matches!(e, ConfigError::Range { field, .. } if field == "snapshot_positions_interval_secs")
385        ));
386        assert!(errors.iter().any(
387            |e| matches!(e, ConfigError::Range { field, .. } if field == "purge_closed_orders_interval_mins")
388        ));
389    }
390}