1use 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#[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 #[builder(default = AccountId::from("SANDBOX-001"))]
50 pub account_id: AccountId,
51 #[builder(default = Venue::new("SANDBOX"))]
53 pub venue: Venue,
54 #[builder(default)]
56 pub starting_balances: Vec<Money>,
57 pub base_currency: Option<Currency>,
59 #[builder(default = OmsType::Netting)]
61 pub oms_type: OmsType,
62 #[builder(default = AccountType::Margin)]
64 pub account_type: AccountType,
65 #[builder(default = Decimal::ONE)]
67 pub default_leverage: Decimal,
68 #[builder(default)]
70 pub leverages: AHashMap<InstrumentId, Decimal>,
71 #[builder(default = BookType::L1_MBP)]
73 pub book_type: BookType,
74 #[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 #[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 #[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 #[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 #[builder(default)]
108 pub frozen_account: bool,
109 #[builder(default = true)]
111 pub bar_execution: bool,
112 #[builder(default = true)]
114 pub trade_execution: bool,
115 #[builder(default = true)]
117 pub reject_stop_orders: bool,
118 #[builder(default = true)]
120 pub support_gtd_orders: bool,
121 #[builder(default = true)]
123 pub support_contingent_orders: bool,
124 #[builder(default = true)]
126 pub use_position_ids: bool,
127 #[builder(default)]
130 pub use_random_ids: bool,
131 #[builder(default = true)]
134 pub use_reduce_only: bool,
135 #[builder(default)]
137 pub queue_position: bool,
138 #[builder(default)]
140 pub liquidity_consumption: bool,
141 #[builder(default)]
143 pub bar_adaptive_high_low_ordering: bool,
144 #[builder(default)]
146 pub use_market_order_acks: bool,
147 #[builder(default)]
149 pub oto_full_trigger: bool,
150 #[builder(default)]
154 pub price_protection_points: u32,
155}
156
157impl SandboxExecutionClientConfig {
158 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", ¤cies)
170 }
171
172 #[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}