1use std::{fmt::Display, str::FromStr, time::Duration};
19
20use ahash::AHashMap;
21use nautilus_common::{
22 cache::CacheConfig,
23 config::{ConfigError, ConfigErrorCollector, ConfigResult},
24 enums::Environment,
25 logging::logger::LoggerConfig,
26 msgbus::MessageBusConfig,
27};
28use nautilus_core::{UUID4, UnixNanos};
29use nautilus_data::engine::config::DataEngineConfig;
30use nautilus_execution::{
31 engine::config::ExecutionEngineConfig,
32 models::{
33 fee::{FeeModelAny, FeeModelHandle},
34 fill::{FillModelAny, FillModelHandle},
35 latency::{LatencyModelAny, LatencyModelHandle},
36 },
37};
38use nautilus_model::{
39 accounts::margin_model::{MarginModelAny, MarginModelHandle},
40 data::{BarSpecification, BarType},
41 enums::{AccountType, BookType, OmsType, OtoTriggerMode},
42 identifiers::{ClientId, InstrumentId, TraderId, Venue},
43 types::{Currency, Money},
44};
45#[cfg(feature = "streaming")]
46use nautilus_persistence::config::DataCatalogConfig;
47use nautilus_portfolio::config::PortfolioConfig;
48use nautilus_risk::engine::config::RiskEngineConfig;
49use nautilus_system::config::{NautilusKernelConfig, StreamingConfig};
50use nautilus_trading::ImportableControllerConfig;
51use rust_decimal::Decimal;
52use ustr::Ustr;
53
54use crate::modules::{SimulationModuleAny, SimulationModuleHandle};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum NautilusDataType {
59 QuoteTick,
60 TradeTick,
61 Bar,
62 OrderBookDelta,
63 OrderBookDepth10,
64 MarkPriceUpdate,
65 IndexPriceUpdate,
66 FundingRateUpdate,
67 InstrumentStatus,
68 OptionGreeks,
69 InstrumentClose,
70}
71
72impl Display for NautilusDataType {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 std::fmt::Debug::fmt(self, f)
75 }
76}
77
78impl FromStr for NautilusDataType {
79 type Err = anyhow::Error;
80
81 fn from_str(s: &str) -> anyhow::Result<Self> {
82 match s {
83 stringify!(QuoteTick) => Ok(Self::QuoteTick),
84 stringify!(TradeTick) => Ok(Self::TradeTick),
85 stringify!(Bar) => Ok(Self::Bar),
86 stringify!(OrderBookDelta) => Ok(Self::OrderBookDelta),
87 stringify!(OrderBookDepth10) => Ok(Self::OrderBookDepth10),
88 stringify!(MarkPriceUpdate) => Ok(Self::MarkPriceUpdate),
89 stringify!(IndexPriceUpdate) => Ok(Self::IndexPriceUpdate),
90 stringify!(FundingRateUpdate) => Ok(Self::FundingRateUpdate),
91 stringify!(InstrumentStatus) => Ok(Self::InstrumentStatus),
92 stringify!(OptionGreeks) => Ok(Self::OptionGreeks),
93 stringify!(InstrumentClose) => Ok(Self::InstrumentClose),
94 _ => anyhow::bail!("Invalid `NautilusDataType`: '{s}'"),
95 }
96 }
97}
98
99#[cfg_attr(
101 feature = "python",
102 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
103)]
104#[cfg_attr(
105 feature = "python",
106 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
107)]
108#[expect(
109 clippy::struct_excessive_bools,
110 reason = "config fields mirror the existing Rust and Python backtest engine surfaces"
111)]
112#[derive(Debug, Clone, bon::Builder)]
113pub struct BacktestEngineConfig {
114 #[builder(default = Environment::Backtest)]
116 pub environment: Environment,
117 #[builder(default)]
119 pub trader_id: TraderId,
120 #[builder(default)]
122 pub load_state: bool,
123 #[builder(default)]
125 pub save_state: bool,
126 #[builder(default)]
130 pub shutdown_on_error: bool,
131 #[builder(default)]
133 pub logging: LoggerConfig,
134 pub instance_id: Option<UUID4>,
136 #[builder(default = Duration::from_mins(1))]
138 pub timeout_connection: Duration,
139 #[builder(default = Duration::from_secs(30))]
141 pub timeout_reconciliation: Duration,
142 #[builder(default = Duration::from_secs(10))]
144 pub timeout_portfolio: Duration,
145 #[builder(default = Duration::from_secs(10))]
147 pub timeout_disconnection: Duration,
148 #[builder(default = Duration::from_secs(10))]
150 pub delay_post_stop: Duration,
151 #[builder(default = Duration::from_secs(5))]
153 pub timeout_shutdown: Duration,
154 pub cache: Option<CacheConfig>,
160 pub msgbus: Option<MessageBusConfig>,
162 pub data_engine: Option<DataEngineConfig>,
164 pub risk_engine: Option<RiskEngineConfig>,
166 pub exec_engine: Option<ExecutionEngineConfig>,
168 pub portfolio: Option<PortfolioConfig>,
170 pub controller: Option<ImportableControllerConfig>,
172 pub streaming: Option<StreamingConfig>,
174 #[cfg(feature = "streaming")]
176 #[builder(default)]
177 pub catalogs: Vec<DataCatalogConfig>,
178 #[builder(default)]
180 pub bypass_logging: bool,
181 #[builder(default = true)]
183 pub run_analysis: bool,
184}
185
186impl NautilusKernelConfig for BacktestEngineConfig {
187 fn environment(&self) -> Environment {
188 self.environment
189 }
190
191 fn trader_id(&self) -> TraderId {
192 self.trader_id
193 }
194
195 fn load_state(&self) -> bool {
196 self.load_state
197 }
198
199 fn save_state(&self) -> bool {
200 self.save_state
201 }
202
203 fn shutdown_on_error(&self) -> bool {
204 self.shutdown_on_error
205 }
206
207 fn logging(&self) -> LoggerConfig {
208 self.logging.clone()
209 }
210
211 fn instance_id(&self) -> Option<UUID4> {
212 self.instance_id
213 }
214
215 fn timeout_connection(&self) -> Duration {
216 self.timeout_connection
217 }
218
219 fn timeout_reconciliation(&self) -> Duration {
220 self.timeout_reconciliation
221 }
222
223 fn timeout_portfolio(&self) -> Duration {
224 self.timeout_portfolio
225 }
226
227 fn timeout_disconnection(&self) -> Duration {
228 self.timeout_disconnection
229 }
230
231 fn delay_post_stop(&self) -> Duration {
232 self.delay_post_stop
233 }
234
235 fn timeout_shutdown(&self) -> Duration {
236 self.timeout_shutdown
237 }
238
239 fn cache(&self) -> Option<CacheConfig> {
240 self.cache.clone()
241 }
242
243 fn msgbus(&self) -> Option<MessageBusConfig> {
244 self.msgbus.clone()
245 }
246
247 fn data_engine(&self) -> Option<DataEngineConfig> {
248 self.data_engine.clone()
249 }
250
251 fn risk_engine(&self) -> Option<RiskEngineConfig> {
252 self.risk_engine.clone()
253 }
254
255 fn exec_engine(&self) -> Option<ExecutionEngineConfig> {
256 self.exec_engine.clone()
257 }
258
259 fn portfolio(&self) -> Option<PortfolioConfig> {
260 self.portfolio
261 }
262
263 fn streaming(&self) -> Option<StreamingConfig> {
264 self.streaming.clone()
265 }
266
267 #[cfg(feature = "streaming")]
268 fn catalogs(&self) -> Vec<DataCatalogConfig> {
269 self.catalogs.clone()
270 }
271}
272
273impl Default for BacktestEngineConfig {
274 fn default() -> Self {
275 Self::builder().build()
276 }
277}
278
279#[allow(missing_debug_implementations)]
288#[expect(
289 clippy::struct_excessive_bools,
290 reason = "venue config fields mirror the existing imperative backtest API"
291)]
292#[derive(bon::Builder)]
293#[builder(finish_fn(name = build_inner, vis = ""))]
294pub struct SimulatedVenueConfig {
295 pub venue: Venue,
296 pub oms_type: OmsType,
297 pub account_type: AccountType,
298 pub book_type: BookType,
299 pub starting_balances: Vec<Money>,
300 pub base_currency: Option<Currency>,
301 pub default_leverage: Option<Decimal>,
304 #[builder(default)]
305 pub leverages: AHashMap<InstrumentId, Decimal>,
306 pub margin_model: Option<MarginModelHandle>,
307 #[builder(default)]
308 pub modules: Vec<SimulationModuleHandle>,
309 #[builder(default)]
310 pub fill_model: FillModelHandle,
311 #[builder(default)]
312 pub fee_model: FeeModelHandle,
313 pub latency_model: Option<LatencyModelHandle>,
314 #[builder(default = false)]
315 pub routing: bool,
316 #[builder(default = true)]
317 pub reject_stop_orders: bool,
318 #[builder(default = true)]
319 pub support_gtd_orders: bool,
320 #[builder(default = true)]
321 pub support_contingent_orders: bool,
322 #[builder(default = true)]
323 pub use_position_ids: bool,
324 #[builder(default = false)]
325 pub use_random_ids: bool,
326 #[builder(default = true)]
327 pub use_reduce_only: bool,
328 #[builder(default = true)]
329 pub use_message_queue: bool,
330 #[builder(default = false)]
331 pub use_market_order_acks: bool,
332 #[builder(default = true)]
333 pub bar_execution: bool,
334 #[builder(default = false)]
335 pub bar_adaptive_high_low_ordering: bool,
336 #[builder(default = true)]
337 pub trade_execution: bool,
338 #[builder(default = false)]
339 pub liquidity_consumption: bool,
340 #[builder(default = false)]
341 pub allow_cash_borrowing: bool,
342 #[builder(default = false)]
343 pub frozen_account: bool,
344 #[builder(default = false)]
345 pub queue_position: bool,
346 #[builder(default = false)]
347 pub oto_full_trigger: bool,
348 #[builder(default = 0)]
349 pub price_protection_points: u32,
350 #[builder(default = false)]
352 pub liquidation_enabled: bool,
353 #[builder(default = 1.0)]
356 pub liquidation_trigger_ratio: f64,
357 #[builder(default = true)]
359 pub liquidation_cancel_open_orders: bool,
360}
361
362impl<S: simulated_venue_config_builder::IsComplete> SimulatedVenueConfigBuilder<S> {
363 pub fn build(self) -> ConfigResult<SimulatedVenueConfig> {
370 let config = self.build_inner();
371 config.validate()?;
372 Ok(config)
373 }
374}
375
376impl SimulatedVenueConfig {
377 pub fn validate(&self) -> ConfigResult<()> {
384 let mut errors = ConfigErrorCollector::new();
385
386 if self.starting_balances.is_empty() {
387 errors.push(ConfigError::empty_field("starting_balances"));
388 }
389
390 if let Some(default_leverage) = self.default_leverage {
391 errors.check(
392 default_leverage > Decimal::ZERO,
393 ConfigError::range(
394 "default_leverage",
395 format!("must be positive, was {default_leverage}"),
396 ),
397 );
398 }
399
400 for (instrument_id, leverage) in &self.leverages {
401 errors.check(
402 *leverage > Decimal::ZERO,
403 ConfigError::range(
404 "leverages",
405 format!("leverage for {instrument_id} must be positive, was {leverage}"),
406 ),
407 );
408 }
409
410 errors.check(
411 self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
412 ConfigError::range(
413 "liquidation_trigger_ratio",
414 format!(
415 "must be a positive finite value, was {}",
416 self.liquidation_trigger_ratio
417 ),
418 ),
419 );
420
421 errors.into_result()
422 }
423}
424
425#[cfg_attr(
427 feature = "python",
428 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
429)]
430#[cfg_attr(
431 feature = "python",
432 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
433)]
434#[expect(
435 clippy::struct_excessive_bools,
436 reason = "venue config fields mirror the existing Rust and Python backtest surfaces"
437)]
438#[derive(Debug, Clone, bon::Builder)]
439#[builder(finish_fn(name = build_inner, vis = ""))]
440pub struct BacktestVenueConfig {
441 #[builder(into)]
443 name: Ustr,
444 oms_type: OmsType,
446 account_type: AccountType,
448 book_type: BookType,
450 #[builder(default)]
452 starting_balances: Vec<String>,
453 #[builder(default)]
455 routing: bool,
456 #[builder(default)]
458 frozen_account: bool,
459 #[builder(default = true)]
461 reject_stop_orders: bool,
462 #[builder(default = true)]
464 support_gtd_orders: bool,
465 #[builder(default = true)]
468 support_contingent_orders: bool,
469 #[builder(default = true)]
471 use_position_ids: bool,
472 #[builder(default)]
475 use_random_ids: bool,
476 #[builder(default = true)]
478 use_reduce_only: bool,
479 #[builder(default = true)]
481 bar_execution: bool,
482 #[builder(default)]
489 bar_adaptive_high_low_ordering: bool,
490 #[builder(default = true)]
492 trade_execution: bool,
493 #[builder(default)]
495 use_market_order_acks: bool,
496 #[builder(default)]
498 liquidity_consumption: bool,
499 #[builder(default)]
501 allow_cash_borrowing: bool,
502 #[builder(default)]
504 queue_position: bool,
505 #[builder(default)]
507 oto_trigger_mode: OtoTriggerMode,
508 base_currency: Option<Currency>,
510 default_leverage: Option<Decimal>,
512 leverages: Option<AHashMap<InstrumentId, Decimal>>,
514 margin_model: Option<MarginModelAny>,
516 #[builder(default)]
518 modules: Vec<SimulationModuleAny>,
519 fill_model: Option<FillModelAny>,
521 latency_model: Option<LatencyModelAny>,
523 fee_model: Option<FeeModelAny>,
525 #[builder(default)]
528 price_protection_points: u32,
529 #[builder(default)]
531 liquidation_enabled: bool,
532 #[builder(default = 1.0)]
535 liquidation_trigger_ratio: f64,
536 #[builder(default = true)]
538 liquidation_cancel_open_orders: bool,
539}
540
541impl<S: backtest_venue_config_builder::IsComplete> BacktestVenueConfigBuilder<S> {
542 pub fn build(self) -> ConfigResult<BacktestVenueConfig> {
549 let config = self.build_inner();
550 config.validate()?;
551 Ok(config)
552 }
553}
554
555impl BacktestVenueConfig {
556 pub fn validate(&self) -> ConfigResult<()> {
563 let mut errors = ConfigErrorCollector::new();
564
565 if self.name.is_empty() {
566 errors.push(ConfigError::empty_field("name"));
567 } else if let Err(e) = Venue::new_checked(self.name.as_str()) {
568 errors.push(ConfigError::invalid_value(
569 "name",
570 format!("must be a valid venue identifier ({e})"),
571 ));
572 }
573
574 if let Some(default_leverage) = self.default_leverage {
575 errors.check(
576 default_leverage > Decimal::ZERO,
577 ConfigError::range(
578 "default_leverage",
579 format!("must be positive, was {default_leverage}"),
580 ),
581 );
582 }
583
584 if let Some(leverages) = &self.leverages {
585 for (instrument_id, leverage) in leverages {
586 errors.check(
587 *leverage > Decimal::ZERO,
588 ConfigError::range(
589 "leverages",
590 format!("leverage for {instrument_id} must be positive, was {leverage}"),
591 ),
592 );
593 }
594 }
595 errors.check(
596 self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
597 ConfigError::range(
598 "liquidation_trigger_ratio",
599 format!(
600 "must be a positive finite value, was {}",
601 self.liquidation_trigger_ratio
602 ),
603 ),
604 );
605
606 for balance in &self.starting_balances {
607 if let Err(reason) = balance.parse::<Money>() {
608 errors.push(ConfigError::invalid_format(
609 "starting_balances",
610 format!("a valid money string, was '{balance}' ({reason})"),
611 ));
612 }
613 }
614
615 errors.into_result()
616 }
617
618 #[must_use]
619 pub fn name(&self) -> Ustr {
620 self.name
621 }
622
623 #[must_use]
624 pub fn oms_type(&self) -> OmsType {
625 self.oms_type
626 }
627
628 #[must_use]
629 pub fn account_type(&self) -> AccountType {
630 self.account_type
631 }
632
633 #[must_use]
634 pub fn book_type(&self) -> BookType {
635 self.book_type
636 }
637
638 #[must_use]
639 pub fn starting_balances(&self) -> &[String] {
640 &self.starting_balances
641 }
642
643 #[must_use]
644 pub fn routing(&self) -> bool {
645 self.routing
646 }
647
648 #[must_use]
649 pub fn frozen_account(&self) -> bool {
650 self.frozen_account
651 }
652
653 #[must_use]
654 pub fn reject_stop_orders(&self) -> bool {
655 self.reject_stop_orders
656 }
657
658 #[must_use]
659 pub fn support_gtd_orders(&self) -> bool {
660 self.support_gtd_orders
661 }
662
663 #[must_use]
664 pub fn support_contingent_orders(&self) -> bool {
665 self.support_contingent_orders
666 }
667
668 #[must_use]
669 pub fn use_position_ids(&self) -> bool {
670 self.use_position_ids
671 }
672
673 #[must_use]
674 pub fn use_random_ids(&self) -> bool {
675 self.use_random_ids
676 }
677
678 #[must_use]
679 pub fn use_reduce_only(&self) -> bool {
680 self.use_reduce_only
681 }
682
683 #[must_use]
684 pub fn bar_execution(&self) -> bool {
685 self.bar_execution
686 }
687
688 #[must_use]
689 pub fn bar_adaptive_high_low_ordering(&self) -> bool {
690 self.bar_adaptive_high_low_ordering
691 }
692
693 #[must_use]
694 pub fn trade_execution(&self) -> bool {
695 self.trade_execution
696 }
697
698 #[must_use]
699 pub fn use_market_order_acks(&self) -> bool {
700 self.use_market_order_acks
701 }
702
703 #[must_use]
704 pub fn liquidity_consumption(&self) -> bool {
705 self.liquidity_consumption
706 }
707
708 #[must_use]
709 pub fn allow_cash_borrowing(&self) -> bool {
710 self.allow_cash_borrowing
711 }
712
713 #[must_use]
714 pub fn queue_position(&self) -> bool {
715 self.queue_position
716 }
717
718 #[must_use]
719 pub fn oto_trigger_mode(&self) -> OtoTriggerMode {
720 self.oto_trigger_mode
721 }
722
723 #[must_use]
724 pub fn base_currency(&self) -> Option<Currency> {
725 self.base_currency
726 }
727
728 #[must_use]
729 pub fn default_leverage(&self) -> Option<Decimal> {
730 self.default_leverage
731 }
732
733 #[must_use]
734 pub fn leverages(&self) -> Option<&AHashMap<InstrumentId, Decimal>> {
735 self.leverages.as_ref()
736 }
737
738 #[must_use]
739 pub fn margin_model(&self) -> Option<&MarginModelAny> {
740 self.margin_model.as_ref()
741 }
742
743 #[must_use]
744 pub fn modules(&self) -> &[SimulationModuleAny] {
745 &self.modules
746 }
747
748 #[must_use]
749 pub fn fill_model(&self) -> Option<&FillModelAny> {
750 self.fill_model.as_ref()
751 }
752
753 #[must_use]
754 pub fn latency_model(&self) -> Option<&LatencyModelAny> {
755 self.latency_model.as_ref()
756 }
757
758 #[must_use]
759 pub fn fee_model(&self) -> Option<&FeeModelAny> {
760 self.fee_model.as_ref()
761 }
762
763 #[must_use]
764 pub fn price_protection_points(&self) -> u32 {
765 self.price_protection_points
766 }
767
768 #[must_use]
769 pub fn liquidation_enabled(&self) -> bool {
770 self.liquidation_enabled
771 }
772
773 #[must_use]
774 pub fn liquidation_trigger_ratio(&self) -> f64 {
775 self.liquidation_trigger_ratio
776 }
777
778 #[must_use]
779 pub fn liquidation_cancel_open_orders(&self) -> bool {
780 self.liquidation_cancel_open_orders
781 }
782}
783
784#[derive(Debug, Clone, bon::Builder)]
786#[builder(finish_fn(name = build_inner, vis = ""))]
787#[cfg_attr(
788 feature = "python",
789 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
790)]
791#[cfg_attr(
792 feature = "python",
793 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
794)]
795pub struct BacktestDataConfig {
796 data_type: NautilusDataType,
798 catalog_path: String,
800 catalog_fs_protocol: Option<String>,
802 catalog_fs_storage_options: Option<AHashMap<String, String>>,
804 catalog_fs_rust_storage_options: Option<AHashMap<String, String>>,
806 instrument_id: Option<InstrumentId>,
808 instrument_ids: Option<Vec<InstrumentId>>,
810 start_time: Option<UnixNanos>,
812 end_time: Option<UnixNanos>,
814 filter_expr: Option<String>,
816 client_id: Option<ClientId>,
818 metadata: Option<AHashMap<String, String>>,
820 bar_spec: Option<BarSpecification>,
822 bar_types: Option<Vec<String>>,
824 #[builder(default)]
826 optimize_file_loading: bool,
827}
828
829impl<S: backtest_data_config_builder::IsComplete> BacktestDataConfigBuilder<S> {
830 pub fn build(self) -> ConfigResult<BacktestDataConfig> {
837 let config = self.build_inner();
838 config.validate()?;
839 Ok(config)
840 }
841}
842
843impl BacktestDataConfig {
844 pub fn validate(&self) -> ConfigResult<()> {
851 let mut errors = ConfigErrorCollector::new();
852
853 if self.catalog_path.trim().is_empty() {
854 errors.push(ConfigError::empty_field("catalog_path"));
855 }
856
857 if let (Some(start), Some(end)) = (self.start_time, self.end_time) {
858 errors.check(
859 start <= end,
860 ConfigError::range(
861 "start_time",
862 format!("must be <= end_time, was {start} > {end}"),
863 ),
864 );
865 }
866
867 let has_identifier = self.instrument_id.is_some()
868 || self
869 .instrument_ids
870 .as_ref()
871 .is_some_and(|ids| !ids.is_empty())
872 || self.bar_types.as_ref().is_some_and(|bars| !bars.is_empty());
873 errors.check(
874 has_identifier,
875 ConfigError::required_one_of(["instrument_id", "instrument_ids", "bar_types"]),
876 );
877
878 errors.into_result()
879 }
880
881 #[must_use]
882 pub const fn data_type(&self) -> NautilusDataType {
883 self.data_type
884 }
885
886 #[must_use]
887 pub fn catalog_path(&self) -> &str {
888 &self.catalog_path
889 }
890
891 #[must_use]
892 pub fn catalog_fs_protocol(&self) -> Option<&str> {
893 self.catalog_fs_protocol.as_deref()
894 }
895
896 #[must_use]
897 pub fn catalog_fs_storage_options(&self) -> Option<&AHashMap<String, String>> {
898 self.catalog_fs_storage_options.as_ref()
899 }
900
901 #[must_use]
902 pub fn catalog_fs_rust_storage_options(&self) -> Option<&AHashMap<String, String>> {
903 self.catalog_fs_rust_storage_options.as_ref()
904 }
905
906 #[must_use]
907 pub fn instrument_id(&self) -> Option<InstrumentId> {
908 self.instrument_id
909 }
910
911 #[must_use]
912 pub fn instrument_ids(&self) -> Option<&[InstrumentId]> {
913 self.instrument_ids.as_deref()
914 }
915
916 #[must_use]
917 pub fn start_time(&self) -> Option<UnixNanos> {
918 self.start_time
919 }
920
921 #[must_use]
922 pub fn end_time(&self) -> Option<UnixNanos> {
923 self.end_time
924 }
925
926 #[must_use]
927 pub fn filter_expr(&self) -> Option<&str> {
928 self.filter_expr.as_deref()
929 }
930
931 #[must_use]
932 pub fn client_id(&self) -> Option<ClientId> {
933 self.client_id
934 }
935
936 #[must_use]
937 pub fn metadata(&self) -> Option<&AHashMap<String, String>> {
938 self.metadata.as_ref()
939 }
940
941 #[must_use]
942 pub fn bar_spec(&self) -> Option<BarSpecification> {
943 self.bar_spec
944 }
945
946 #[must_use]
947 pub fn bar_types(&self) -> Option<&[String]> {
948 self.bar_types.as_deref()
949 }
950
951 #[must_use]
952 pub fn optimize_file_loading(&self) -> bool {
953 self.optimize_file_loading
954 }
955
956 #[must_use]
962 pub fn query_identifiers(&self) -> Option<Vec<String>> {
963 if self.data_type == NautilusDataType::Bar {
964 if let Some(bar_types) = &self.bar_types
965 && !bar_types.is_empty()
966 {
967 return Some(bar_types.clone());
968 }
969
970 if let Some(bar_spec) = &self.bar_spec {
972 if let Some(id) = self.instrument_id {
973 return Some(vec![format!("{id}-{bar_spec}-EXTERNAL")]);
974 }
975
976 if let Some(ids) = &self.instrument_ids {
977 let bar_types: Vec<String> = ids
978 .iter()
979 .map(|id| format!("{id}-{bar_spec}-EXTERNAL"))
980 .collect();
981
982 if !bar_types.is_empty() {
983 return Some(bar_types);
984 }
985 }
986 }
987 }
988
989 if let Some(id) = self.instrument_id {
991 return Some(vec![id.to_string()]);
992 }
993
994 if let Some(ids) = &self.instrument_ids {
995 let strs: Vec<String> = ids.iter().map(ToString::to_string).collect();
996 if !strs.is_empty() {
997 return Some(strs);
998 }
999 }
1000
1001 None
1002 }
1003
1004 pub fn get_instrument_ids(&self) -> anyhow::Result<Vec<InstrumentId>> {
1012 if let Some(id) = self.instrument_id {
1013 return Ok(vec![id]);
1014 }
1015
1016 if let Some(ids) = &self.instrument_ids {
1017 return Ok(ids.clone());
1018 }
1019
1020 if let Some(bar_types) = &self.bar_types {
1021 let ids = bar_types
1022 .iter()
1023 .map(|bt| {
1024 bt.parse::<BarType>()
1025 .map(|b| b.instrument_id())
1026 .map_err(|_| anyhow::anyhow!("Invalid bar type string: '{bt}'"))
1027 })
1028 .collect::<anyhow::Result<Vec<_>>>()?;
1029 return Ok(ids);
1030 }
1031 Ok(Vec::new())
1032 }
1033}
1034
1035#[derive(Debug, Clone, bon::Builder)]
1038#[builder(finish_fn(name = build_inner, vis = ""))]
1039#[cfg_attr(
1040 feature = "python",
1041 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
1042)]
1043#[cfg_attr(
1044 feature = "python",
1045 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
1046)]
1047pub struct BacktestRunConfig {
1048 #[builder(default = UUID4::new().to_string())]
1050 id: String,
1051 venues: Vec<BacktestVenueConfig>,
1053 data: Vec<BacktestDataConfig>,
1055 #[builder(default)]
1057 engine: BacktestEngineConfig,
1058 chunk_size: Option<usize>,
1061 #[builder(default)]
1063 raise_exception: bool,
1064 #[builder(default = true)]
1068 dispose_on_completion: bool,
1069 start: Option<UnixNanos>,
1072 end: Option<UnixNanos>,
1075}
1076
1077impl<S: backtest_run_config_builder::IsComplete> BacktestRunConfigBuilder<S> {
1078 pub fn build(self) -> ConfigResult<BacktestRunConfig> {
1085 let config = self.build_inner();
1086 config.validate()?;
1087 Ok(config)
1088 }
1089}
1090
1091impl BacktestRunConfig {
1092 pub fn validate(&self) -> ConfigResult<()> {
1099 let mut errors = ConfigErrorCollector::new();
1100
1101 if self.venues.is_empty() {
1102 errors.push(ConfigError::empty_field("venues"));
1103 }
1104
1105 if let (Some(start), Some(end)) = (self.start, self.end) {
1106 errors.check(
1107 start <= end,
1108 ConfigError::range("start", format!("must be <= end, was {start} > {end}")),
1109 );
1110 }
1111
1112 if let Some(chunk_size) = self.chunk_size {
1113 errors.check(
1114 chunk_size > 0,
1115 ConfigError::range("chunk_size", format!("must be positive, was {chunk_size}")),
1116 );
1117 }
1118
1119 errors.into_result()
1120 }
1121
1122 #[must_use]
1123 pub fn id(&self) -> &str {
1124 &self.id
1125 }
1126
1127 #[must_use]
1128 pub fn venues(&self) -> &[BacktestVenueConfig] {
1129 &self.venues
1130 }
1131
1132 #[must_use]
1133 pub fn data(&self) -> &[BacktestDataConfig] {
1134 &self.data
1135 }
1136
1137 #[must_use]
1138 pub fn engine(&self) -> &BacktestEngineConfig {
1139 &self.engine
1140 }
1141
1142 #[must_use]
1143 pub fn chunk_size(&self) -> Option<usize> {
1144 self.chunk_size
1145 }
1146
1147 #[must_use]
1148 pub fn raise_exception(&self) -> bool {
1149 self.raise_exception
1150 }
1151
1152 #[must_use]
1153 pub fn dispose_on_completion(&self) -> bool {
1154 self.dispose_on_completion
1155 }
1156
1157 #[must_use]
1158 pub fn start(&self) -> Option<UnixNanos> {
1159 self.start
1160 }
1161
1162 #[must_use]
1163 pub fn end(&self) -> Option<UnixNanos> {
1164 self.end
1165 }
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170 use rstest::rstest;
1171
1172 use super::*;
1173
1174 macro_rules! minimal_builder {
1175 () => {
1176 BacktestVenueConfig::builder()
1177 .name("SIM")
1178 .oms_type(OmsType::Netting)
1179 .account_type(AccountType::Margin)
1180 .book_type(BookType::L1_MBP)
1181 };
1182 }
1183
1184 macro_rules! minimal_simulated_builder {
1185 () => {
1186 SimulatedVenueConfig::builder()
1187 .venue(Venue::from("SIM"))
1188 .oms_type(OmsType::Netting)
1189 .account_type(AccountType::Margin)
1190 .book_type(BookType::L1_MBP)
1191 .starting_balances(vec![Money::from("1_000_000 USD")])
1192 };
1193 }
1194
1195 #[rstest]
1196 fn test_minimal_config_is_valid() {
1197 assert!(minimal_builder!().build().is_ok());
1198 }
1199
1200 #[rstest]
1201 fn test_default_leverage_is_optional() {
1202 let config = minimal_builder!().build().unwrap();
1203
1204 assert_eq!(config.default_leverage(), None);
1205 }
1206
1207 #[rstest]
1208 fn test_empty_name_rejected() {
1209 let result = BacktestVenueConfig::builder()
1210 .name("")
1211 .oms_type(OmsType::Netting)
1212 .account_type(AccountType::Margin)
1213 .book_type(BookType::L1_MBP)
1214 .build();
1215 assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "name"));
1216 }
1217
1218 #[rstest]
1219 #[case(" ")]
1220 #[case("vénue")]
1221 fn test_invalid_venue_name_rejected(#[case] name: &str) {
1222 let result = BacktestVenueConfig::builder()
1223 .name(name)
1224 .oms_type(OmsType::Netting)
1225 .account_type(AccountType::Margin)
1226 .book_type(BookType::L1_MBP)
1227 .build();
1228 assert!(matches!(result, Err(ConfigError::InvalidValue { field, .. }) if field == "name"));
1229 }
1230
1231 #[rstest]
1232 #[case(Decimal::ZERO)]
1233 #[case(Decimal::from(-1))]
1234 fn test_non_positive_default_leverage_rejected(#[case] leverage: Decimal) {
1235 let result = minimal_builder!().default_leverage(leverage).build();
1236 assert!(
1237 matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1238 );
1239 }
1240
1241 #[rstest]
1242 fn test_non_positive_instrument_leverage_rejected() {
1243 let mut leverages = AHashMap::new();
1244 leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
1245 let result = minimal_builder!().leverages(leverages).build();
1246 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1247 }
1248
1249 #[rstest]
1250 #[case(Decimal::ZERO)]
1251 #[case(Decimal::from(-1))]
1252 fn test_simulated_non_positive_instrument_leverage_rejected(#[case] leverage: Decimal) {
1253 let mut leverages = AHashMap::new();
1254 leverages.insert(InstrumentId::from("ESZ21.GLBX"), leverage);
1255 let result = minimal_simulated_builder!().leverages(leverages).build();
1256 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1257 }
1258
1259 #[rstest]
1260 fn test_simulated_positive_instrument_leverage_accepted() {
1261 let mut leverages = AHashMap::new();
1262 leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::from(10));
1263 let result = minimal_simulated_builder!().leverages(leverages).build();
1264 assert!(result.is_ok());
1265 }
1266
1267 #[rstest]
1268 #[case(0.0)]
1269 #[case(-1.0)]
1270 #[case(f64::INFINITY)]
1271 #[case(f64::NAN)]
1272 fn test_invalid_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1273 let result = minimal_builder!().liquidation_trigger_ratio(ratio).build();
1274 assert!(
1275 matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1276 );
1277 }
1278
1279 #[rstest]
1280 fn test_unparsable_starting_balance_rejected() {
1281 let result = minimal_builder!()
1282 .starting_balances(vec!["not a balance".to_string()])
1283 .build();
1284 assert!(
1285 matches!(result, Err(ConfigError::InvalidFormat { field, .. }) if field == "starting_balances")
1286 );
1287 }
1288
1289 #[rstest]
1290 fn test_valid_starting_balance_accepted() {
1291 let result = minimal_builder!()
1292 .starting_balances(vec!["1_000_000 USD".to_string()])
1293 .build();
1294 assert!(result.is_ok());
1295 }
1296
1297 #[rstest]
1298 fn test_multiple_violations_collected() {
1299 let result = BacktestVenueConfig::builder()
1300 .name("")
1301 .oms_type(OmsType::Netting)
1302 .account_type(AccountType::Margin)
1303 .book_type(BookType::L1_MBP)
1304 .default_leverage(Decimal::ZERO)
1305 .starting_balances(vec!["bad".to_string()])
1306 .build();
1307 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1308 panic!("expected ConfigError::Multiple");
1309 };
1310 assert_eq!(errors.len(), 3);
1311 assert!(
1312 errors
1313 .iter()
1314 .any(|e| matches!(e, ConfigError::EmptyField { field } if field == "name"))
1315 );
1316 assert!(
1317 errors.iter().any(
1318 |e| matches!(e, ConfigError::Range { field, .. } if field == "default_leverage")
1319 )
1320 );
1321 assert!(errors.iter().any(
1322 |e| matches!(e, ConfigError::InvalidFormat { field, .. } if field == "starting_balances")
1323 ));
1324 }
1325
1326 #[rstest]
1327 fn test_minimal_data_config_is_valid() {
1328 let result = BacktestDataConfig::builder()
1329 .data_type(NautilusDataType::QuoteTick)
1330 .catalog_path("/tmp/catalog".to_string())
1331 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1332 .build();
1333 assert!(result.is_ok());
1334 }
1335
1336 #[rstest]
1337 #[case("")]
1338 #[case(" ")]
1339 fn test_empty_catalog_path_rejected(#[case] catalog_path: &str) {
1340 let result = BacktestDataConfig::builder()
1341 .data_type(NautilusDataType::QuoteTick)
1342 .catalog_path(catalog_path.to_string())
1343 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1344 .build();
1345 assert!(
1346 matches!(result, Err(ConfigError::EmptyField { field }) if field == "catalog_path")
1347 );
1348 }
1349
1350 #[rstest]
1351 fn test_inverted_time_range_rejected() {
1352 let result = BacktestDataConfig::builder()
1353 .data_type(NautilusDataType::QuoteTick)
1354 .catalog_path("/tmp/catalog".to_string())
1355 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1356 .start_time(UnixNanos::from(5_000_000_000u64))
1357 .end_time(UnixNanos::from(1_000_000_000u64))
1358 .build();
1359 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start_time"));
1360 }
1361
1362 #[rstest]
1363 fn test_equal_time_range_accepted() {
1364 let result = BacktestDataConfig::builder()
1365 .data_type(NautilusDataType::QuoteTick)
1366 .catalog_path("/tmp/catalog".to_string())
1367 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1368 .start_time(UnixNanos::from(1_000_000_000u64))
1369 .end_time(UnixNanos::from(1_000_000_000u64))
1370 .build();
1371 assert!(result.is_ok());
1372 }
1373
1374 #[rstest]
1375 fn test_missing_identifier_rejected() {
1376 let result = BacktestDataConfig::builder()
1377 .data_type(NautilusDataType::QuoteTick)
1378 .catalog_path("/tmp/catalog".to_string())
1379 .build();
1380 assert!(matches!(result, Err(ConfigError::RequiredOneOf { fields }) if fields.len() == 3));
1381 }
1382
1383 #[rstest]
1384 fn test_empty_instrument_ids_rejected() {
1385 let result = BacktestDataConfig::builder()
1386 .data_type(NautilusDataType::QuoteTick)
1387 .catalog_path("/tmp/catalog".to_string())
1388 .instrument_ids(vec![])
1389 .build();
1390 assert!(matches!(result, Err(ConfigError::RequiredOneOf { .. })));
1391 }
1392
1393 #[rstest]
1394 fn test_bar_types_satisfies_identifier_requirement() {
1395 let result = BacktestDataConfig::builder()
1396 .data_type(NautilusDataType::Bar)
1397 .catalog_path("/tmp/catalog".to_string())
1398 .bar_types(vec!["ETH/USDT.BINANCE-1-MINUTE-LAST-EXTERNAL".to_string()])
1399 .build();
1400 assert!(result.is_ok());
1401 }
1402
1403 #[rstest]
1404 fn test_data_config_multiple_violations_collected() {
1405 let result = BacktestDataConfig::builder()
1406 .data_type(NautilusDataType::QuoteTick)
1407 .catalog_path(String::new())
1408 .start_time(UnixNanos::from(5_000_000_000u64))
1409 .end_time(UnixNanos::from(1_000_000_000u64))
1410 .build();
1411 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1412 panic!("expected ConfigError::Multiple");
1413 };
1414 assert_eq!(errors.len(), 3);
1415 }
1416
1417 macro_rules! minimal_sim_builder {
1418 () => {
1419 SimulatedVenueConfig::builder()
1420 .venue(Venue::from("SIM"))
1421 .oms_type(OmsType::Netting)
1422 .account_type(AccountType::Margin)
1423 .book_type(BookType::L1_MBP)
1424 .starting_balances(vec![Money::from("1_000_000 USD")])
1425 };
1426 }
1427
1428 #[rstest]
1429 fn test_minimal_sim_config_is_valid() {
1430 assert!(minimal_sim_builder!().build().is_ok());
1431 }
1432
1433 #[rstest]
1434 fn test_empty_starting_balances_rejected() {
1435 let result = SimulatedVenueConfig::builder()
1436 .venue(Venue::from("SIM"))
1437 .oms_type(OmsType::Netting)
1438 .account_type(AccountType::Margin)
1439 .book_type(BookType::L1_MBP)
1440 .starting_balances(vec![])
1441 .build();
1442 assert!(
1443 matches!(result, Err(ConfigError::EmptyField { field }) if field == "starting_balances")
1444 );
1445 }
1446
1447 #[rstest]
1448 #[case(Decimal::ZERO)]
1449 #[case(Decimal::from(-1))]
1450 fn test_non_positive_sim_default_leverage_rejected(#[case] leverage: Decimal) {
1451 let result = minimal_sim_builder!().default_leverage(leverage).build();
1452 assert!(
1453 matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1454 );
1455 }
1456
1457 #[rstest]
1458 fn test_positive_sim_default_leverage_accepted() {
1459 assert!(
1460 minimal_sim_builder!()
1461 .default_leverage(Decimal::from(5))
1462 .build()
1463 .is_ok()
1464 );
1465 }
1466
1467 #[rstest]
1468 #[case(0.0)]
1469 #[case(-1.0)]
1470 #[case(f64::INFINITY)]
1471 #[case(f64::NAN)]
1472 fn test_invalid_sim_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1473 let result = minimal_sim_builder!()
1474 .liquidation_trigger_ratio(ratio)
1475 .build();
1476 assert!(
1477 matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1478 );
1479 }
1480
1481 fn minimal_venue() -> BacktestVenueConfig {
1482 minimal_builder!().build().unwrap()
1483 }
1484
1485 #[rstest]
1486 fn test_minimal_run_config_is_valid() {
1487 let result = BacktestRunConfig::builder()
1488 .venues(vec![minimal_venue()])
1489 .data(vec![])
1490 .build();
1491 assert!(result.is_ok());
1492 }
1493
1494 #[rstest]
1495 fn test_run_config_requires_venues() {
1496 let result = BacktestRunConfig::builder()
1497 .venues(vec![])
1498 .data(vec![])
1499 .build();
1500 assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "venues"));
1501 }
1502
1503 #[rstest]
1504 fn test_run_config_inverted_time_range_rejected() {
1505 let result = BacktestRunConfig::builder()
1506 .venues(vec![minimal_venue()])
1507 .data(vec![])
1508 .start(UnixNanos::from(5_000_000_000u64))
1509 .end(UnixNanos::from(1_000_000_000u64))
1510 .build();
1511 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start"));
1512 }
1513
1514 #[rstest]
1515 fn test_run_config_equal_time_range_accepted() {
1516 let result = BacktestRunConfig::builder()
1517 .venues(vec![minimal_venue()])
1518 .data(vec![])
1519 .start(UnixNanos::from(1_000_000_000u64))
1520 .end(UnixNanos::from(1_000_000_000u64))
1521 .build();
1522 assert!(result.is_ok());
1523 }
1524
1525 #[rstest]
1526 fn test_run_config_accepts_chunk_size() {
1527 let config = BacktestRunConfig::builder()
1528 .venues(vec![minimal_venue()])
1529 .data(vec![])
1530 .chunk_size(10)
1531 .build()
1532 .unwrap();
1533 assert_eq!(config.chunk_size(), Some(10));
1534 }
1535
1536 #[rstest]
1537 fn test_run_config_zero_chunk_size_rejected() {
1538 let result = BacktestRunConfig::builder()
1539 .venues(vec![minimal_venue()])
1540 .data(vec![])
1541 .chunk_size(0)
1542 .build();
1543 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1544 }
1545
1546 #[rstest]
1547 fn test_run_config_multiple_violations_collected() {
1548 let result = BacktestRunConfig::builder()
1549 .venues(vec![])
1550 .data(vec![])
1551 .start(UnixNanos::from(5_000_000_000u64))
1552 .end(UnixNanos::from(1_000_000_000u64))
1553 .build();
1554 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1555 panic!("expected ConfigError::Multiple");
1556 };
1557 assert_eq!(errors.len(), 2);
1558 }
1559}