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::{DurationNanos, 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 an error if any field fails validation (see [`ExecutionEngineConfig::validate`]).
111    pub fn build(self) -> ConfigResult<ExecutionEngineConfig> {
112        let config = self.build_inner();
113        config.validate()?;
114        Ok(config)
115    }
116}
117
118impl ExecutionEngineConfig {
119    /// Validates the execution engine configuration, collecting every field violation.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if any field fails validation. Multiple validation failures are collected
124    /// into one error.
125    pub fn validate(&self) -> ConfigResult<()> {
126        let mut errors = ConfigErrorCollector::new();
127
128        if let Some(secs) = self.snapshot_positions_interval_secs {
129            errors.check(
130                secs.is_finite() && secs > 0.0,
131                ConfigError::range(
132                    "snapshot_positions_interval_secs",
133                    format!("must be a positive finite value, was {secs}"),
134                ),
135            );
136        }
137
138        for (field, value) in [
139            (
140                "purge_closed_orders_interval_mins",
141                self.purge_closed_orders_interval_mins,
142            ),
143            (
144                "purge_closed_positions_interval_mins",
145                self.purge_closed_positions_interval_mins,
146            ),
147            (
148                "purge_account_events_interval_mins",
149                self.purge_account_events_interval_mins,
150            ),
151        ] {
152            if let Some(mins) = value {
153                let reason = if mins == 0 {
154                    format!("must be a positive number of minutes, was {mins}")
155                } else {
156                    format!("must be positive and fit in `u64` nanoseconds, was {mins} minutes")
157                };
158                errors.check(
159                    mins > 0 && DurationNanos::try_from_mins(u64::from(mins)).is_ok(),
160                    ConfigError::range(field, reason),
161                );
162            }
163        }
164
165        for (field, value) in [
166            (
167                "purge_closed_orders_buffer_mins",
168                self.purge_closed_orders_buffer_mins,
169            ),
170            (
171                "purge_closed_positions_buffer_mins",
172                self.purge_closed_positions_buffer_mins,
173            ),
174            (
175                "purge_account_events_lookback_mins",
176                self.purge_account_events_lookback_mins,
177            ),
178        ] {
179            if let Some(mins) = value {
180                errors.check(
181                    DurationNanos::try_from_mins(u64::from(mins)).is_ok(),
182                    ConfigError::range(
183                        field,
184                        format!("must fit in `u64` nanoseconds, was {mins} minutes"),
185                    ),
186                );
187            }
188        }
189
190        errors.into_result()
191    }
192}
193
194impl Default for ExecutionEngineConfig {
195    fn default() -> Self {
196        Self::builder()
197            .build()
198            .expect("default `ExecutionEngineConfig` should be valid")
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use rstest::rstest;
205
206    use super::*;
207
208    #[rstest]
209    fn test_default_config_is_valid() {
210        assert!(ExecutionEngineConfig::builder().build().is_ok());
211    }
212
213    #[rstest]
214    fn test_carry_replay_events_on_reopen_defaults_false() {
215        assert!(!ExecutionEngineConfig::default().carry_replay_events_on_reopen);
216
217        let config: ExecutionEngineConfig =
218            serde_json::from_str("{}").expect("empty config should deserialize");
219        assert!(!config.carry_replay_events_on_reopen);
220
221        let config = ExecutionEngineConfig::builder()
222            .carry_replay_events_on_reopen(true)
223            .build()
224            .unwrap();
225        assert!(config.carry_replay_events_on_reopen);
226    }
227
228    #[rstest]
229    #[case(0.0)]
230    #[case(-1.0)]
231    #[case(f64::INFINITY)]
232    #[case(f64::NAN)]
233    fn test_invalid_snapshot_positions_interval_secs_rejected(#[case] secs: f64) {
234        let result = ExecutionEngineConfig::builder()
235            .snapshot_positions_interval_secs(secs)
236            .build();
237        assert!(
238            matches!(result, Err(ConfigError::Range { field, .. }) if field == "snapshot_positions_interval_secs")
239        );
240    }
241
242    #[rstest]
243    fn test_positive_snapshot_positions_interval_secs_accepted() {
244        let result = ExecutionEngineConfig::builder()
245            .snapshot_positions_interval_secs(5.0)
246            .build();
247        assert!(result.is_ok());
248    }
249
250    #[rstest]
251    fn test_zero_purge_closed_orders_interval_rejected() {
252        let result = ExecutionEngineConfig::builder()
253            .purge_closed_orders_interval_mins(0)
254            .build();
255        assert!(
256            matches!(result, Err(ConfigError::Range { field, .. }) if field == "purge_closed_orders_interval_mins")
257        );
258    }
259
260    #[rstest]
261    fn test_zero_purge_closed_positions_interval_rejected() {
262        let result = ExecutionEngineConfig::builder()
263            .purge_closed_positions_interval_mins(0)
264            .build();
265        assert!(
266            matches!(result, Err(ConfigError::Range { field, .. }) if field == "purge_closed_positions_interval_mins")
267        );
268    }
269
270    #[rstest]
271    fn test_zero_purge_account_events_interval_rejected() {
272        let result = ExecutionEngineConfig::builder()
273            .purge_account_events_interval_mins(0)
274            .build();
275        assert!(
276            matches!(result, Err(ConfigError::Range { field, .. }) if field == "purge_account_events_interval_mins")
277        );
278    }
279
280    #[rstest]
281    fn test_positive_purge_intervals_accepted() {
282        // A zero buffer is valid (no grace period), only the intervals must be positive
283        let result = ExecutionEngineConfig::builder()
284            .purge_closed_orders_interval_mins(10)
285            .purge_closed_positions_interval_mins(10)
286            .purge_account_events_interval_mins(10)
287            .purge_closed_orders_buffer_mins(0)
288            .build();
289        assert!(result.is_ok());
290    }
291
292    #[rstest]
293    fn test_overflowing_purge_intervals_rejected() {
294        let result = ExecutionEngineConfig::builder()
295            .purge_closed_orders_interval_mins(u32::MAX)
296            .purge_closed_positions_interval_mins(u32::MAX)
297            .purge_account_events_interval_mins(u32::MAX)
298            .build();
299        assert_eq!(
300            result.unwrap_err(),
301            ConfigError::Multiple {
302                errors: vec![
303                    ConfigError::range(
304                        "purge_closed_orders_interval_mins",
305                        "must be positive and fit in `u64` nanoseconds, was 4294967295 minutes",
306                    ),
307                    ConfigError::range(
308                        "purge_closed_positions_interval_mins",
309                        "must be positive and fit in `u64` nanoseconds, was 4294967295 minutes",
310                    ),
311                    ConfigError::range(
312                        "purge_account_events_interval_mins",
313                        "must be positive and fit in `u64` nanoseconds, was 4294967295 minutes",
314                    ),
315                ],
316            }
317        );
318    }
319
320    #[rstest]
321    #[case("purge_closed_orders_buffer_mins", 0)]
322    #[case("purge_closed_orders_buffer_mins", 307_445_734)]
323    #[case("purge_closed_positions_buffer_mins", 0)]
324    #[case("purge_closed_positions_buffer_mins", 307_445_734)]
325    #[case("purge_account_events_lookback_mins", 0)]
326    #[case("purge_account_events_lookback_mins", 307_445_734)]
327    fn test_purge_retention_minute_boundaries_accepted(#[case] field: &str, #[case] mins: u32) {
328        let mut config = ExecutionEngineConfig::default();
329
330        match field {
331            "purge_closed_orders_buffer_mins" => {
332                config.purge_closed_orders_buffer_mins = Some(mins);
333            }
334            "purge_closed_positions_buffer_mins" => {
335                config.purge_closed_positions_buffer_mins = Some(mins);
336            }
337            "purge_account_events_lookback_mins" => {
338                config.purge_account_events_lookback_mins = Some(mins);
339            }
340            _ => unreachable!(),
341        }
342
343        assert!(config.validate().is_ok());
344    }
345
346    #[rstest]
347    #[case("purge_closed_orders_buffer_mins")]
348    #[case("purge_closed_positions_buffer_mins")]
349    #[case("purge_account_events_lookback_mins")]
350    fn test_overflowing_purge_retention_minutes_rejected(#[case] field: &str) {
351        let mut config = ExecutionEngineConfig::default();
352
353        match field {
354            "purge_closed_orders_buffer_mins" => {
355                config.purge_closed_orders_buffer_mins = Some(307_445_735);
356            }
357            "purge_closed_positions_buffer_mins" => {
358                config.purge_closed_positions_buffer_mins = Some(307_445_735);
359            }
360            "purge_account_events_lookback_mins" => {
361                config.purge_account_events_lookback_mins = Some(307_445_735);
362            }
363            _ => unreachable!(),
364        }
365
366        assert!(matches!(
367            config.validate(),
368            Err(ConfigError::Range { field: error_field, .. }) if error_field == field
369        ));
370    }
371
372    #[rstest]
373    fn test_multiple_violations_collected() {
374        let result = ExecutionEngineConfig::builder()
375            .snapshot_positions_interval_secs(0.0)
376            .purge_closed_orders_interval_mins(0)
377            .build();
378        let ConfigError::Multiple { errors } = result.unwrap_err() else {
379            panic!("expected ConfigError::Multiple");
380        };
381        assert_eq!(errors.len(), 2);
382        assert!(errors.iter().any(
383            |e| matches!(e, ConfigError::Range { field, .. } if field == "snapshot_positions_interval_secs")
384        ));
385        assert!(errors.iter().any(
386            |e| matches!(e, ConfigError::Range { field, .. } if field == "purge_closed_orders_interval_mins")
387        ));
388    }
389}