Skip to main content

nautilus_sandbox/
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//! Configuration for sandbox execution client.
17
18use ahash::AHashMap;
19use nautilus_common::config::{ConfigResult, check_unique_values};
20use nautilus_execution::{
21    matching_engine::config::OrderMatchingEngineConfig,
22    models::{fee::FeeModelAny, fill::FillModelAny, latency::LatencyModelAny},
23};
24use nautilus_model::{
25    accounts::margin_model::MarginModelAny,
26    enums::{AccountType, BookType, OmsType},
27    identifiers::{AccountId, InstrumentId, Venue},
28    types::{Currency, Money},
29};
30use rust_decimal::Decimal;
31use serde::{
32    Deserialize, Deserializer, Serialize, Serializer,
33    de::{self, IgnoredAny},
34};
35
36/// Configuration for `SandboxExecutionClient` instances.
37#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
38#[serde(default, deny_unknown_fields)]
39#[cfg_attr(
40    feature = "python",
41    pyo3::pyclass(module = "nautilus_trader.adapters.sandbox", from_py_object)
42)]
43#[cfg_attr(
44    feature = "python",
45    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.sandbox")
46)]
47pub struct SandboxExecutionClientConfig {
48    /// The account ID for this client.
49    #[builder(default = AccountId::from("SANDBOX-001"))]
50    pub account_id: AccountId,
51    /// The venue for this sandbox execution client.
52    #[builder(default = Venue::new("SANDBOX"))]
53    pub venue: Venue,
54    /// The starting balances for this sandbox venue.
55    #[builder(default)]
56    pub starting_balances: Vec<Money>,
57    /// The base currency for this venue (None for multi-currency).
58    pub base_currency: Option<Currency>,
59    /// The order management system type used by the exchange.
60    #[builder(default = OmsType::Netting)]
61    pub oms_type: OmsType,
62    /// The account type for the client.
63    #[builder(default = AccountType::Margin)]
64    pub account_type: AccountType,
65    /// The account default leverage (for margin accounts).
66    #[builder(default = Decimal::ONE)]
67    pub default_leverage: Decimal,
68    /// Per-instrument leverage overrides.
69    #[builder(default)]
70    pub leverages: AHashMap<InstrumentId, Decimal>,
71    /// The order book type for the matching engine.
72    #[builder(default = BookType::L1_MBP)]
73    pub book_type: BookType,
74    /// The fill model for sandbox matching engines.
75    #[serde(
76        default,
77        skip_serializing_if = "Option::is_none",
78        serialize_with = "serialize_fill_model",
79        deserialize_with = "deserialize_fill_model"
80    )]
81    pub fill_model: Option<FillModelAny>,
82    /// The fee model for sandbox matching engines.
83    #[serde(
84        default,
85        skip_serializing_if = "Option::is_none",
86        serialize_with = "serialize_fee_model",
87        deserialize_with = "deserialize_fee_model"
88    )]
89    pub fee_model: Option<FeeModelAny>,
90    /// The margin model for the sandbox margin account.
91    #[serde(
92        default,
93        skip_serializing_if = "Option::is_none",
94        serialize_with = "serialize_margin_model",
95        deserialize_with = "deserialize_margin_model"
96    )]
97    pub margin_model: Option<MarginModelAny>,
98    /// The latency model for sandbox matching engines.
99    #[serde(
100        default,
101        skip_serializing_if = "Option::is_none",
102        serialize_with = "serialize_latency_model",
103        deserialize_with = "deserialize_latency_model"
104    )]
105    pub latency_model: Option<LatencyModelAny>,
106    /// If True, account balances won't change (frozen).
107    #[builder(default)]
108    pub frozen_account: bool,
109    /// If bars should be processed by the matching engine (and move the market).
110    #[builder(default = true)]
111    pub bar_execution: bool,
112    /// If trades should be processed by the matching engine (and move the market).
113    #[builder(default = true)]
114    pub trade_execution: bool,
115    /// If stop orders are rejected on submission if trigger price is in the market.
116    #[builder(default = true)]
117    pub reject_stop_orders: bool,
118    /// If orders with GTD time in force will be supported by the venue.
119    #[builder(default = true)]
120    pub support_gtd_orders: bool,
121    /// If contingent orders will be supported/respected by the venue.
122    #[builder(default = true)]
123    pub support_contingent_orders: bool,
124    /// If venue position IDs will be generated on order fills.
125    #[builder(default = true)]
126    pub use_position_ids: bool,
127    /// If venue order IDs and position IDs will be random UUID4's.
128    /// Trade IDs are always deterministic and not affected by this flag.
129    #[builder(default)]
130    pub use_random_ids: bool,
131    /// If the `reduce_only` execution instruction on orders will be enforced.
132    /// If false, reduce-only orders are rejected.
133    #[builder(default = true)]
134    pub use_reduce_only: bool,
135    /// If limit order queue position tracking is enabled during trade execution.
136    #[builder(default)]
137    pub queue_position: bool,
138    /// If order book liquidity consumption should be tracked per level.
139    #[builder(default)]
140    pub liquidity_consumption: bool,
141    /// If bar high/low processing order adapts to the bar's shape.
142    #[builder(default)]
143    pub bar_adaptive_high_low_ordering: bool,
144    /// If `OrderAccepted` events should be generated for market orders.
145    #[builder(default)]
146    pub use_market_order_acks: bool,
147    /// If OTO child orders wait for a full parent fill before release.
148    #[builder(default)]
149    pub oto_full_trigger: bool,
150    /// Exchange-calculated price boundary for aggressive market fills.
151    ///
152    /// A value of `0` disables protection.
153    #[builder(default)]
154    pub price_protection_points: u32,
155}
156
157impl SandboxExecutionClientConfig {
158    /// Validates that starting balances use distinct currencies.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if a starting-balance currency repeats.
163    pub fn validate(&self) -> ConfigResult<()> {
164        let currencies: Vec<_> = self
165            .starting_balances
166            .iter()
167            .map(|balance| balance.currency)
168            .collect();
169        check_unique_values("starting_balances", &currencies)
170    }
171
172    /// Creates an [`OrderMatchingEngineConfig`] from this sandbox config.
173    #[must_use]
174    pub fn to_matching_engine_config(&self) -> OrderMatchingEngineConfig {
175        let price_protection = if self.price_protection_points == 0 {
176            None
177        } else {
178            Some(self.price_protection_points)
179        };
180
181        OrderMatchingEngineConfig::builder()
182            .bar_execution(self.bar_execution)
183            .bar_adaptive_high_low_ordering(self.bar_adaptive_high_low_ordering)
184            .trade_execution(self.trade_execution)
185            .liquidity_consumption(self.liquidity_consumption)
186            .reject_stop_orders(self.reject_stop_orders)
187            .support_gtd_orders(self.support_gtd_orders)
188            .support_contingent_orders(self.support_contingent_orders)
189            .use_position_ids(self.use_position_ids)
190            .use_random_ids(self.use_random_ids)
191            .use_reduce_only(self.use_reduce_only)
192            .use_market_order_acks(self.use_market_order_acks)
193            .queue_position(self.queue_position)
194            .oto_full_trigger(self.oto_full_trigger)
195            .maybe_price_protection_points(price_protection)
196            .build()
197    }
198}
199
200impl Default for SandboxExecutionClientConfig {
201    fn default() -> Self {
202        Self::builder().build()
203    }
204}
205
206fn serialize_fill_model<S>(
207    fill_model: &Option<FillModelAny>,
208    serializer: S,
209) -> Result<S::Ok, S::Error>
210where
211    S: Serializer,
212{
213    match fill_model {
214        None => serializer.serialize_none(),
215        Some(_) => Err(serde::ser::Error::custom(
216            "SandboxExecutionClientConfig.fill_model is runtime-only and cannot be serialized",
217        )),
218    }
219}
220
221fn deserialize_fill_model<'de, D>(deserializer: D) -> Result<Option<FillModelAny>, D::Error>
222where
223    D: Deserializer<'de>,
224{
225    let value = Option::<IgnoredAny>::deserialize(deserializer)?;
226
227    match value {
228        None => Ok(None),
229        Some(_) => Err(de::Error::custom(
230            "SandboxExecutionClientConfig.fill_model must be configured at runtime, not deserialized",
231        )),
232    }
233}
234
235fn serialize_fee_model<S>(fee_model: &Option<FeeModelAny>, serializer: S) -> Result<S::Ok, S::Error>
236where
237    S: Serializer,
238{
239    match fee_model {
240        None => serializer.serialize_none(),
241        Some(_) => Err(serde::ser::Error::custom(
242            "SandboxExecutionClientConfig.fee_model is runtime-only and cannot be serialized",
243        )),
244    }
245}
246
247fn deserialize_fee_model<'de, D>(deserializer: D) -> Result<Option<FeeModelAny>, D::Error>
248where
249    D: Deserializer<'de>,
250{
251    let value = Option::<IgnoredAny>::deserialize(deserializer)?;
252
253    match value {
254        None => Ok(None),
255        Some(_) => Err(de::Error::custom(
256            "SandboxExecutionClientConfig.fee_model must be configured at runtime, not deserialized",
257        )),
258    }
259}
260
261fn serialize_margin_model<S>(
262    margin_model: &Option<MarginModelAny>,
263    serializer: S,
264) -> Result<S::Ok, S::Error>
265where
266    S: Serializer,
267{
268    match margin_model {
269        None => serializer.serialize_none(),
270        Some(_) => Err(serde::ser::Error::custom(
271            "SandboxExecutionClientConfig.margin_model is runtime-only and cannot be serialized",
272        )),
273    }
274}
275
276fn deserialize_margin_model<'de, D>(deserializer: D) -> Result<Option<MarginModelAny>, D::Error>
277where
278    D: Deserializer<'de>,
279{
280    let value = Option::<IgnoredAny>::deserialize(deserializer)?;
281    match value {
282        None => Ok(None),
283        Some(_) => Err(de::Error::custom(
284            "SandboxExecutionClientConfig.margin_model must be configured at runtime, not deserialized",
285        )),
286    }
287}
288
289fn serialize_latency_model<S>(
290    latency_model: &Option<LatencyModelAny>,
291    serializer: S,
292) -> Result<S::Ok, S::Error>
293where
294    S: Serializer,
295{
296    match latency_model {
297        None => serializer.serialize_none(),
298        Some(_) => Err(serde::ser::Error::custom(
299            "SandboxExecutionClientConfig.latency_model is runtime-only and cannot be serialized",
300        )),
301    }
302}
303
304fn deserialize_latency_model<'de, D>(deserializer: D) -> Result<Option<LatencyModelAny>, D::Error>
305where
306    D: Deserializer<'de>,
307{
308    let value = Option::<IgnoredAny>::deserialize(deserializer)?;
309
310    match value {
311        None => Ok(None),
312        Some(_) => Err(de::Error::custom(
313            "SandboxExecutionClientConfig.latency_model must be configured at runtime, not deserialized",
314        )),
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use nautilus_common::config::ConfigError;
321    use nautilus_core::DurationNanos;
322    use nautilus_execution::models::{
323        fee::{FeeModelAny, ProbabilityPriceFeeModel},
324        fill::FillModelAny,
325        latency::{LatencyModelAny, StaticLatencyModel},
326    };
327    use rstest::rstest;
328    use rust_decimal_macros::dec;
329
330    use super::*;
331
332    #[rstest]
333    fn test_exec_config_toml_empty_uses_defaults() {
334        let config: SandboxExecutionClientConfig = toml::from_str("").unwrap();
335        let expected = SandboxExecutionClientConfig::default();
336        assert_eq!(config.account_id, expected.account_id);
337        assert_eq!(config.venue, expected.venue);
338        assert_eq!(config.oms_type, expected.oms_type);
339        assert_eq!(config.account_type, expected.account_type);
340        assert_eq!(config.default_leverage, expected.default_leverage);
341        assert_eq!(config.book_type, expected.book_type);
342        assert!(config.fill_model.is_none());
343        assert!(config.fee_model.is_none());
344        assert!(config.margin_model.is_none());
345        assert!(config.latency_model.is_none());
346        assert_eq!(config.bar_execution, expected.bar_execution);
347        assert_eq!(config.trade_execution, expected.trade_execution);
348        assert_eq!(config.use_position_ids, expected.use_position_ids);
349        assert!(!config.queue_position);
350        assert!(!config.liquidity_consumption);
351        assert!(!config.bar_adaptive_high_low_ordering);
352        assert!(!config.use_market_order_acks);
353        assert!(!config.oto_full_trigger);
354        assert_eq!(config.price_protection_points, 0);
355    }
356
357    #[rstest]
358    fn test_to_matching_engine_config_forwards_matching_knobs() {
359        let config = SandboxExecutionClientConfig {
360            queue_position: true,
361            liquidity_consumption: true,
362            bar_adaptive_high_low_ordering: true,
363            use_market_order_acks: true,
364            oto_full_trigger: true,
365            price_protection_points: 100,
366            ..SandboxExecutionClientConfig::default()
367        };
368        let engine_config = config.to_matching_engine_config();
369
370        assert!(engine_config.queue_position);
371        assert!(engine_config.liquidity_consumption);
372        assert!(engine_config.bar_adaptive_high_low_ordering);
373        assert!(engine_config.use_market_order_acks);
374        assert!(engine_config.oto_full_trigger);
375        assert_eq!(engine_config.price_protection_points, Some(100));
376    }
377
378    #[rstest]
379    fn test_exec_config_toml_margin_model_is_runtime_only() {
380        let deserialized =
381            toml::from_str::<SandboxExecutionClientConfig>("margin_model = 'custom'");
382
383        let config = SandboxExecutionClientConfig {
384            margin_model: Some(MarginModelAny::default()),
385            ..Default::default()
386        };
387
388        let serialized = toml::Value::try_from(&config);
389
390        assert!(
391            deserialized
392                .unwrap_err()
393                .to_string()
394                .contains("margin_model must be configured at runtime")
395        );
396        assert!(
397            serialized
398                .unwrap_err()
399                .to_string()
400                .contains("margin_model is runtime-only")
401        );
402    }
403
404    #[rstest]
405    fn test_exec_config_toml_rejects_fill_model_field() {
406        let result =
407            toml::from_str::<SandboxExecutionClientConfig>("fill_model = \"runtime-only\"");
408
409        assert!(result.is_err());
410    }
411
412    #[rstest]
413    fn test_exec_config_toml_rejects_fee_model_field() {
414        let result = toml::from_str::<SandboxExecutionClientConfig>("fee_model = \"runtime-only\"");
415
416        assert!(result.is_err());
417    }
418
419    #[rstest]
420    fn test_exec_config_toml_rejects_latency_model_field() {
421        let result =
422            toml::from_str::<SandboxExecutionClientConfig>("latency_model = \"runtime-only\"");
423
424        assert!(result.is_err());
425    }
426
427    #[rstest]
428    fn test_exec_config_toml_rejects_serializing_runtime_fee_model() {
429        let config = SandboxExecutionClientConfig {
430            fee_model: Some(FeeModelAny::ProbabilityPrice(
431                ProbabilityPriceFeeModel::new(dec!(0.01), dec!(0.02)),
432            )),
433            ..SandboxExecutionClientConfig::default()
434        };
435
436        let result = toml::Value::try_from(&config);
437
438        assert!(result.is_err());
439    }
440
441    #[rstest]
442    fn test_exec_config_toml_rejects_serializing_runtime_fill_model() {
443        let config = SandboxExecutionClientConfig {
444            fill_model: Some(FillModelAny::Default(Default::default())),
445            ..SandboxExecutionClientConfig::default()
446        };
447
448        let result = toml::Value::try_from(&config);
449
450        assert!(result.is_err());
451    }
452
453    #[rstest]
454    fn test_exec_config_toml_rejects_serializing_runtime_latency_model() {
455        let config = SandboxExecutionClientConfig {
456            latency_model: Some(LatencyModelAny::Static(StaticLatencyModel::new(
457                DurationNanos::ZERO,
458                DurationNanos::ZERO,
459                DurationNanos::ZERO,
460                DurationNanos::ZERO,
461            ))),
462            ..SandboxExecutionClientConfig::default()
463        };
464
465        let result = toml::Value::try_from(&config);
466
467        assert!(result.is_err());
468    }
469
470    #[rstest]
471    #[case::same_currency(vec![Money::from("100 USD"), Money::from("200 USD")], true)]
472    #[case::same_balance(vec![Money::from("100 USD"), Money::from("100 USD")], true)]
473    #[case::distinct(vec![Money::from("100 USD"), Money::from("200 EUR")], false)]
474    #[case::empty(vec![], false)]
475    fn test_starting_balance_currency_uniqueness(
476        #[case] starting_balances: Vec<Money>,
477        #[case] duplicate: bool,
478    ) {
479        let config = SandboxExecutionClientConfig {
480            starting_balances: starting_balances.clone(),
481            ..Default::default()
482        };
483
484        let expected = if duplicate {
485            Err(ConfigError::duplicate("starting_balances", None))
486        } else {
487            Ok(())
488        };
489
490        assert_eq!(config.validate(), expected);
491        assert_eq!(config.starting_balances, starting_balances);
492    }
493}