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::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(
25        module = "nautilus_trader.core.nautilus_pyo3.execution",
26        from_py_object
27    )
28)]
29#[cfg_attr(
30    feature = "python",
31    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
32)]
33#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
34#[builder(finish_fn(name = build_inner, vis = ""))]
35#[serde(deny_unknown_fields)]
36pub struct ExecutionEngineConfig {
37    /// If the cache should be loaded on initialization.
38    #[serde(default = "default_true")]
39    #[builder(default = true)]
40    pub load_cache: bool,
41    /// If the execution engine should maintain own/user order books based on commands and events.
42    #[serde(default)]
43    #[builder(default)]
44    pub manage_own_order_books: bool,
45    /// If order state snapshot lists are persisted to a backing database.
46    /// Snapshots will be taken at every order state update (when events are applied).
47    #[serde(default)]
48    #[builder(default)]
49    pub snapshot_orders: bool,
50    /// If position state snapshot lists are persisted to a backing database.
51    /// Snapshots will be taken at position opened, changed and closed (when events are applied).
52    #[serde(default)]
53    #[builder(default)]
54    pub snapshot_positions: bool,
55    /// The interval (seconds) at which additional position state snapshots are persisted.
56    /// If `None` then no additional snapshots will be taken.
57    #[serde(default)]
58    pub snapshot_positions_interval_secs: Option<f64>,
59    /// If order fills exceeding order quantity are allowed (logs warning instead of raising).
60    /// Useful when position reconciliation races with exchange fill events.
61    #[serde(default)]
62    #[builder(default)]
63    pub allow_overfills: bool,
64    /// If unclaimed venue orders should be filtered during execution reconciliation.
65    #[serde(default)]
66    #[builder(default)]
67    pub filter_unclaimed_external_orders: bool,
68    /// The client IDs declared for external stream processing.
69    ///
70    /// The execution engine will not attempt to send trading commands to these
71    /// client IDs, assuming an external process will consume the serialized
72    /// command messages from the bus and handle execution.
73    #[serde(default)]
74    pub external_clients: Option<Vec<ClientId>>,
75    /// The interval (minutes) between purging closed orders from the in-memory cache.
76    #[serde(default)]
77    pub purge_closed_orders_interval_mins: Option<u32>,
78    /// The time buffer (minutes) before closed orders can be purged.
79    #[serde(default)]
80    pub purge_closed_orders_buffer_mins: Option<u32>,
81    /// The interval (minutes) between purging closed positions from the in-memory cache.
82    #[serde(default)]
83    pub purge_closed_positions_interval_mins: Option<u32>,
84    /// The time buffer (minutes) before closed positions can be purged.
85    #[serde(default)]
86    pub purge_closed_positions_buffer_mins: Option<u32>,
87    /// The interval (minutes) between purging account events from the in-memory cache.
88    #[serde(default)]
89    pub purge_account_events_interval_mins: Option<u32>,
90    /// The time buffer (minutes) before account events can be purged.
91    #[serde(default)]
92    pub purge_account_events_lookback_mins: Option<u32>,
93    /// If purge operations should also delete from the backing database.
94    #[serde(default)]
95    #[builder(default)]
96    pub purge_from_database: bool,
97    /// If debug mode is active (will provide extra debug logging).
98    #[serde(default)]
99    #[builder(default)]
100    pub debug: bool,
101}
102
103impl<S: execution_engine_config_builder::IsComplete> ExecutionEngineConfigBuilder<S> {
104    /// Validates and builds the [`ExecutionEngineConfig`].
105    ///
106    /// # Errors
107    ///
108    /// Returns a [`ConfigError`] if any field fails validation
109    /// (see [`ExecutionEngineConfig::validate`]).
110    pub fn build(self) -> ConfigResult<ExecutionEngineConfig> {
111        let config = self.build_inner();
112        config.validate()?;
113        Ok(config)
114    }
115}
116
117impl ExecutionEngineConfig {
118    /// Validates the execution engine configuration, collecting every field violation.
119    ///
120    /// # Errors
121    ///
122    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
123    /// invalid) if any field fails validation.
124    pub fn validate(&self) -> ConfigResult<()> {
125        let mut errors = ConfigErrorCollector::new();
126
127        if let Some(secs) = self.snapshot_positions_interval_secs {
128            errors.check(
129                secs.is_finite() && secs > 0.0,
130                ConfigError::range(
131                    "snapshot_positions_interval_secs",
132                    format!("must be a positive finite value, was {secs}"),
133                ),
134            );
135        }
136
137        for (field, value) in [
138            (
139                "purge_closed_orders_interval_mins",
140                self.purge_closed_orders_interval_mins,
141            ),
142            (
143                "purge_closed_positions_interval_mins",
144                self.purge_closed_positions_interval_mins,
145            ),
146            (
147                "purge_account_events_interval_mins",
148                self.purge_account_events_interval_mins,
149            ),
150        ] {
151            if let Some(mins) = value {
152                errors.check(
153                    mins > 0,
154                    ConfigError::range(
155                        field,
156                        format!("must be a positive number of minutes, was {mins}"),
157                    ),
158                );
159            }
160        }
161
162        errors.into_result()
163    }
164}
165
166impl Default for ExecutionEngineConfig {
167    fn default() -> Self {
168        Self::builder()
169            .build()
170            .expect("default `ExecutionEngineConfig` should be valid")
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use rstest::rstest;
177
178    use super::*;
179
180    #[rstest]
181    fn test_default_config_is_valid() {
182        assert!(ExecutionEngineConfig::builder().build().is_ok());
183    }
184
185    #[rstest]
186    #[case(0.0)]
187    #[case(-1.0)]
188    #[case(f64::INFINITY)]
189    #[case(f64::NAN)]
190    fn test_invalid_snapshot_positions_interval_secs_rejected(#[case] secs: f64) {
191        let result = ExecutionEngineConfig::builder()
192            .snapshot_positions_interval_secs(secs)
193            .build();
194        assert!(
195            matches!(result, Err(ConfigError::Range { field, .. }) if field == "snapshot_positions_interval_secs")
196        );
197    }
198
199    #[rstest]
200    fn test_positive_snapshot_positions_interval_secs_accepted() {
201        let result = ExecutionEngineConfig::builder()
202            .snapshot_positions_interval_secs(5.0)
203            .build();
204        assert!(result.is_ok());
205    }
206
207    #[rstest]
208    fn test_zero_purge_closed_orders_interval_rejected() {
209        let result = ExecutionEngineConfig::builder()
210            .purge_closed_orders_interval_mins(0)
211            .build();
212        assert!(
213            matches!(result, Err(ConfigError::Range { field, .. }) if field == "purge_closed_orders_interval_mins")
214        );
215    }
216
217    #[rstest]
218    fn test_zero_purge_closed_positions_interval_rejected() {
219        let result = ExecutionEngineConfig::builder()
220            .purge_closed_positions_interval_mins(0)
221            .build();
222        assert!(
223            matches!(result, Err(ConfigError::Range { field, .. }) if field == "purge_closed_positions_interval_mins")
224        );
225    }
226
227    #[rstest]
228    fn test_zero_purge_account_events_interval_rejected() {
229        let result = ExecutionEngineConfig::builder()
230            .purge_account_events_interval_mins(0)
231            .build();
232        assert!(
233            matches!(result, Err(ConfigError::Range { field, .. }) if field == "purge_account_events_interval_mins")
234        );
235    }
236
237    #[rstest]
238    fn test_positive_purge_intervals_accepted() {
239        // A zero buffer is valid (no grace period), only the intervals must be positive
240        let result = ExecutionEngineConfig::builder()
241            .purge_closed_orders_interval_mins(10)
242            .purge_closed_positions_interval_mins(10)
243            .purge_account_events_interval_mins(10)
244            .purge_closed_orders_buffer_mins(0)
245            .build();
246        assert!(result.is_ok());
247    }
248
249    #[rstest]
250    fn test_multiple_violations_collected() {
251        let result = ExecutionEngineConfig::builder()
252            .snapshot_positions_interval_secs(0.0)
253            .purge_closed_orders_interval_mins(0)
254            .build();
255        let ConfigError::Multiple { errors } = result.unwrap_err() else {
256            panic!("expected ConfigError::Multiple");
257        };
258        assert_eq!(errors.len(), 2);
259        assert!(errors.iter().any(
260            |e| matches!(e, ConfigError::Range { field, .. } if field == "snapshot_positions_interval_secs")
261        ));
262        assert!(errors.iter().any(
263            |e| matches!(e, ConfigError::Range { field, .. } if field == "purge_closed_orders_interval_mins")
264        ));
265    }
266}