1use std::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, NautilusDataType},
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::CatalogBackendType;
47#[cfg(feature = "streaming")]
48use nautilus_persistence::config::DataCatalogConfig;
49use nautilus_portfolio::config::PortfolioConfig;
50use nautilus_risk::engine::config::RiskEngineConfig;
51use nautilus_system::config::NautilusKernelConfig;
52#[cfg(feature = "streaming")]
53use nautilus_system::config::StreamingConfig;
54use nautilus_trading::ImportableControllerConfig;
55use rust_decimal::Decimal;
56use ustr::Ustr;
57
58use crate::modules::{SimulationModuleAny, SimulationModuleHandle};
59
60pub(crate) const MAX_BACKTEST_CHUNK_SIZE: usize = 1_000_000;
61
62#[cfg_attr(
64 feature = "python",
65 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
66)]
67#[cfg_attr(
68 feature = "python",
69 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
70)]
71#[expect(
72 clippy::struct_excessive_bools,
73 reason = "config fields mirror the existing Rust and Python backtest engine surfaces"
74)]
75#[derive(Debug, Clone, bon::Builder)]
76pub struct BacktestEngineConfig {
77 #[builder(default = Environment::Backtest)]
79 pub environment: Environment,
80 #[builder(default)]
82 pub trader_id: TraderId,
83 #[builder(default)]
85 pub load_state: bool,
86 #[builder(default)]
88 pub save_state: bool,
89 #[builder(default)]
93 pub shutdown_on_error: bool,
94 #[builder(default)]
96 pub logging: LoggerConfig,
97 pub instance_id: Option<UUID4>,
99 #[builder(default = Duration::from_mins(1))]
101 pub timeout_connection: Duration,
102 #[builder(default = Duration::from_secs(30))]
104 pub timeout_reconciliation: Duration,
105 #[builder(default = Duration::from_secs(10))]
107 pub timeout_portfolio: Duration,
108 #[builder(default = Duration::from_secs(10))]
110 pub timeout_disconnection: Duration,
111 #[builder(default = Duration::from_secs(10))]
113 pub delay_post_stop: Duration,
114 #[builder(default = Duration::from_secs(5))]
116 pub timeout_shutdown: Duration,
117 pub cache: Option<CacheConfig>,
123 pub msgbus: Option<MessageBusConfig>,
125 pub data_engine: Option<DataEngineConfig>,
127 pub risk_engine: Option<RiskEngineConfig>,
129 pub exec_engine: Option<ExecutionEngineConfig>,
131 pub portfolio: Option<PortfolioConfig>,
133 pub controller: Option<ImportableControllerConfig>,
135 #[cfg(feature = "streaming")]
137 pub streaming: Option<StreamingConfig>,
138 #[cfg(feature = "streaming")]
140 #[builder(default)]
141 pub catalogs: Vec<DataCatalogConfig>,
142 #[builder(default)]
144 pub bypass_logging: bool,
145 #[builder(default = true)]
147 pub run_analysis: bool,
148}
149
150impl NautilusKernelConfig for BacktestEngineConfig {
151 fn environment(&self) -> Environment {
152 self.environment
153 }
154
155 fn trader_id(&self) -> TraderId {
156 self.trader_id
157 }
158
159 fn load_state(&self) -> bool {
160 self.load_state
161 }
162
163 fn save_state(&self) -> bool {
164 self.save_state
165 }
166
167 fn shutdown_on_error(&self) -> bool {
168 self.shutdown_on_error
169 }
170
171 fn logging(&self) -> LoggerConfig {
172 self.logging.clone()
173 }
174
175 fn instance_id(&self) -> Option<UUID4> {
176 self.instance_id
177 }
178
179 fn timeout_connection(&self) -> Duration {
180 self.timeout_connection
181 }
182
183 fn timeout_reconciliation(&self) -> Duration {
184 self.timeout_reconciliation
185 }
186
187 fn timeout_portfolio(&self) -> Duration {
188 self.timeout_portfolio
189 }
190
191 fn timeout_disconnection(&self) -> Duration {
192 self.timeout_disconnection
193 }
194
195 fn delay_post_stop(&self) -> Duration {
196 self.delay_post_stop
197 }
198
199 fn timeout_shutdown(&self) -> Duration {
200 self.timeout_shutdown
201 }
202
203 fn cache(&self) -> Option<CacheConfig> {
204 self.cache.clone()
205 }
206
207 fn msgbus(&self) -> Option<MessageBusConfig> {
208 self.msgbus.clone()
209 }
210
211 fn data_engine(&self) -> Option<DataEngineConfig> {
212 self.data_engine.clone()
213 }
214
215 fn risk_engine(&self) -> Option<RiskEngineConfig> {
216 self.risk_engine.clone()
217 }
218
219 fn exec_engine(&self) -> Option<ExecutionEngineConfig> {
220 self.exec_engine.clone()
221 }
222
223 fn portfolio(&self) -> Option<PortfolioConfig> {
224 self.portfolio
225 }
226
227 #[cfg(feature = "streaming")]
228 fn streaming(&self) -> Option<StreamingConfig> {
229 self.streaming.clone()
230 }
231
232 #[cfg(feature = "streaming")]
233 fn catalogs(&self) -> Vec<DataCatalogConfig> {
234 self.catalogs.clone()
235 }
236}
237
238impl Default for BacktestEngineConfig {
239 fn default() -> Self {
240 Self::builder().build()
241 }
242}
243
244#[allow(missing_debug_implementations)]
262#[expect(
263 clippy::struct_excessive_bools,
264 reason = "venue config fields mirror the existing imperative backtest API"
265)]
266#[derive(bon::Builder)]
267#[builder(finish_fn(name = build_inner, vis = ""))]
268pub struct SimulatedVenueConfig {
269 pub venue: Venue,
271 pub oms_type: OmsType,
273 pub account_type: AccountType,
275 pub book_type: BookType,
277 pub starting_balances: Vec<Money>,
279 pub base_currency: Option<Currency>,
281 pub default_leverage: Option<Decimal>,
283 #[builder(default)]
285 pub leverages: AHashMap<InstrumentId, Decimal>,
286 pub margin_model: Option<MarginModelHandle>,
288 #[builder(default)]
290 pub modules: Vec<SimulationModuleHandle>,
291 #[builder(default)]
293 pub fill_model: FillModelHandle,
294 #[builder(default)]
296 pub fee_model: FeeModelHandle,
297 pub latency_model: Option<LatencyModelHandle>,
299 #[builder(default = false)]
301 pub routing: bool,
302 #[builder(default = true)]
304 pub reject_stop_orders: bool,
305 #[builder(default = true)]
307 pub support_gtd_orders: bool,
308 #[builder(default = true)]
310 pub support_contingent_orders: bool,
311 #[builder(default = true)]
313 pub use_position_ids: bool,
314 #[builder(default = false)]
316 pub use_random_ids: bool,
317 #[builder(default = true)]
319 pub use_reduce_only: bool,
320 #[builder(default = true)]
322 pub use_message_queue: bool,
323 #[builder(default = false)]
325 pub use_market_order_acks: bool,
326 #[builder(default = true)]
328 pub bar_execution: bool,
329 #[builder(default = false)]
331 pub bar_adaptive_high_low_ordering: bool,
332 #[builder(default = true)]
334 pub trade_execution: bool,
335 #[builder(default = false)]
337 pub liquidity_consumption: bool,
338 #[builder(default = false)]
340 pub allow_cash_borrowing: bool,
341 #[builder(default = false)]
343 pub frozen_account: bool,
344 #[builder(default = false)]
346 pub queue_position: bool,
347 #[builder(default = false)]
349 pub oto_full_trigger: bool,
350 #[builder(default = true)]
352 pub defer_option_settlement: bool,
353 #[builder(default = 0)]
355 pub price_protection_points: u32,
356 #[builder(default = false)]
358 pub liquidation_enabled: bool,
359 #[builder(default = 1.0)]
361 pub liquidation_trigger_ratio: f64,
362 #[builder(default = true)]
364 pub liquidation_cancel_open_orders: bool,
365}
366
367impl<S: simulated_venue_config_builder::IsComplete> SimulatedVenueConfigBuilder<S> {
368 pub fn build(self) -> ConfigResult<SimulatedVenueConfig> {
375 let config = self.build_inner();
376 config.validate()?;
377 Ok(config)
378 }
379}
380
381impl SimulatedVenueConfig {
382 pub fn validate(&self) -> ConfigResult<()> {
389 let mut errors = ConfigErrorCollector::new();
390
391 if self.starting_balances.is_empty() {
392 errors.push(ConfigError::empty_field("starting_balances"));
393 }
394
395 if let Some(default_leverage) = self.default_leverage {
396 errors.check(
397 default_leverage > Decimal::ZERO,
398 ConfigError::range(
399 "default_leverage",
400 format!("must be positive, was {default_leverage}"),
401 ),
402 );
403 }
404
405 for (instrument_id, leverage) in &self.leverages {
406 errors.check(
407 *leverage > Decimal::ZERO,
408 ConfigError::range(
409 "leverages",
410 format!("leverage for {instrument_id} must be positive, was {leverage}"),
411 ),
412 );
413 }
414
415 errors.check(
416 self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
417 ConfigError::range(
418 "liquidation_trigger_ratio",
419 format!(
420 "must be a positive finite value, was {}",
421 self.liquidation_trigger_ratio
422 ),
423 ),
424 );
425
426 errors.into_result()
427 }
428}
429
430#[cfg_attr(
432 feature = "python",
433 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
434)]
435#[cfg_attr(
436 feature = "python",
437 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
438)]
439#[expect(
440 clippy::struct_excessive_bools,
441 reason = "venue config fields mirror the existing Rust and Python backtest surfaces"
442)]
443#[derive(Debug, Clone, bon::Builder)]
444#[builder(finish_fn(name = build_inner, vis = ""))]
445pub struct BacktestVenueConfig {
446 #[builder(into)]
448 name: Ustr,
449 oms_type: OmsType,
451 account_type: AccountType,
453 book_type: BookType,
455 #[builder(default)]
457 starting_balances: Vec<String>,
458 #[builder(default)]
460 routing: bool,
461 #[builder(default)]
463 frozen_account: bool,
464 #[builder(default = true)]
466 reject_stop_orders: bool,
467 #[builder(default = true)]
469 support_gtd_orders: bool,
470 #[builder(default = true)]
473 support_contingent_orders: bool,
474 #[builder(default = true)]
476 use_position_ids: bool,
477 #[builder(default)]
480 use_random_ids: bool,
481 #[builder(default = true)]
484 use_reduce_only: bool,
485 #[builder(default = true)]
487 bar_execution: bool,
488 #[builder(default)]
495 bar_adaptive_high_low_ordering: bool,
496 #[builder(default = true)]
498 trade_execution: bool,
499 #[builder(default)]
501 use_market_order_acks: bool,
502 #[builder(default)]
504 liquidity_consumption: bool,
505 #[builder(default)]
507 allow_cash_borrowing: bool,
508 #[builder(default)]
510 queue_position: bool,
511 #[builder(default)]
513 oto_trigger_mode: OtoTriggerMode,
514 base_currency: Option<Currency>,
516 default_leverage: Option<Decimal>,
518 leverages: Option<AHashMap<InstrumentId, Decimal>>,
520 margin_model: Option<MarginModelAny>,
522 #[builder(default)]
524 modules: Vec<SimulationModuleAny>,
525 fill_model: Option<FillModelAny>,
527 latency_model: Option<LatencyModelAny>,
529 fee_model: Option<FeeModelAny>,
531 #[builder(default)]
534 price_protection_points: u32,
535 #[builder(default)]
537 liquidation_enabled: bool,
538 #[builder(default = 1.0)]
541 liquidation_trigger_ratio: f64,
542 #[builder(default = true)]
544 liquidation_cancel_open_orders: bool,
545}
546
547impl<S: backtest_venue_config_builder::IsComplete> BacktestVenueConfigBuilder<S> {
548 pub fn build(self) -> ConfigResult<BacktestVenueConfig> {
555 let config = self.build_inner();
556 config.validate()?;
557 Ok(config)
558 }
559}
560
561impl BacktestVenueConfig {
562 pub fn validate(&self) -> ConfigResult<()> {
569 let mut errors = ConfigErrorCollector::new();
570
571 if self.name.is_empty() {
572 errors.push(ConfigError::empty_field("name"));
573 } else if let Err(e) = Venue::new_checked(self.name.as_str()) {
574 errors.push(ConfigError::invalid_value(
575 "name",
576 format!("must be a valid venue identifier ({e})"),
577 ));
578 }
579
580 if let Some(default_leverage) = self.default_leverage {
581 errors.check(
582 default_leverage > Decimal::ZERO,
583 ConfigError::range(
584 "default_leverage",
585 format!("must be positive, was {default_leverage}"),
586 ),
587 );
588 }
589
590 if let Some(leverages) = &self.leverages {
591 for (instrument_id, leverage) in leverages {
592 errors.check(
593 *leverage > Decimal::ZERO,
594 ConfigError::range(
595 "leverages",
596 format!("leverage for {instrument_id} must be positive, was {leverage}"),
597 ),
598 );
599 }
600 }
601 errors.check(
602 self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
603 ConfigError::range(
604 "liquidation_trigger_ratio",
605 format!(
606 "must be a positive finite value, was {}",
607 self.liquidation_trigger_ratio
608 ),
609 ),
610 );
611
612 for balance in &self.starting_balances {
613 if let Err(reason) = balance.parse::<Money>() {
614 errors.push(ConfigError::invalid_format(
615 "starting_balances",
616 format!("a valid money string, was '{balance}' ({reason})"),
617 ));
618 }
619 }
620
621 errors.into_result()
622 }
623
624 #[must_use]
625 pub fn name(&self) -> Ustr {
626 self.name
627 }
628
629 #[must_use]
630 pub fn oms_type(&self) -> OmsType {
631 self.oms_type
632 }
633
634 #[must_use]
635 pub fn account_type(&self) -> AccountType {
636 self.account_type
637 }
638
639 #[must_use]
640 pub fn book_type(&self) -> BookType {
641 self.book_type
642 }
643
644 #[must_use]
645 pub fn starting_balances(&self) -> &[String] {
646 &self.starting_balances
647 }
648
649 #[must_use]
650 pub fn routing(&self) -> bool {
651 self.routing
652 }
653
654 #[must_use]
655 pub fn frozen_account(&self) -> bool {
656 self.frozen_account
657 }
658
659 #[must_use]
660 pub fn reject_stop_orders(&self) -> bool {
661 self.reject_stop_orders
662 }
663
664 #[must_use]
665 pub fn support_gtd_orders(&self) -> bool {
666 self.support_gtd_orders
667 }
668
669 #[must_use]
670 pub fn support_contingent_orders(&self) -> bool {
671 self.support_contingent_orders
672 }
673
674 #[must_use]
675 pub fn use_position_ids(&self) -> bool {
676 self.use_position_ids
677 }
678
679 #[must_use]
680 pub fn use_random_ids(&self) -> bool {
681 self.use_random_ids
682 }
683
684 #[must_use]
685 pub fn use_reduce_only(&self) -> bool {
686 self.use_reduce_only
687 }
688
689 #[must_use]
690 pub fn bar_execution(&self) -> bool {
691 self.bar_execution
692 }
693
694 #[must_use]
695 pub fn bar_adaptive_high_low_ordering(&self) -> bool {
696 self.bar_adaptive_high_low_ordering
697 }
698
699 #[must_use]
700 pub fn trade_execution(&self) -> bool {
701 self.trade_execution
702 }
703
704 #[must_use]
705 pub fn use_market_order_acks(&self) -> bool {
706 self.use_market_order_acks
707 }
708
709 #[must_use]
710 pub fn liquidity_consumption(&self) -> bool {
711 self.liquidity_consumption
712 }
713
714 #[must_use]
715 pub fn allow_cash_borrowing(&self) -> bool {
716 self.allow_cash_borrowing
717 }
718
719 #[must_use]
720 pub fn queue_position(&self) -> bool {
721 self.queue_position
722 }
723
724 #[must_use]
725 pub fn oto_trigger_mode(&self) -> OtoTriggerMode {
726 self.oto_trigger_mode
727 }
728
729 #[must_use]
730 pub fn base_currency(&self) -> Option<Currency> {
731 self.base_currency
732 }
733
734 #[must_use]
735 pub fn default_leverage(&self) -> Option<Decimal> {
736 self.default_leverage
737 }
738
739 #[must_use]
740 pub fn leverages(&self) -> Option<&AHashMap<InstrumentId, Decimal>> {
741 self.leverages.as_ref()
742 }
743
744 #[must_use]
745 pub fn margin_model(&self) -> Option<&MarginModelAny> {
746 self.margin_model.as_ref()
747 }
748
749 #[must_use]
750 pub fn modules(&self) -> &[SimulationModuleAny] {
751 &self.modules
752 }
753
754 #[must_use]
755 pub fn fill_model(&self) -> Option<&FillModelAny> {
756 self.fill_model.as_ref()
757 }
758
759 #[must_use]
760 pub fn latency_model(&self) -> Option<&LatencyModelAny> {
761 self.latency_model.as_ref()
762 }
763
764 #[must_use]
765 pub fn fee_model(&self) -> Option<&FeeModelAny> {
766 self.fee_model.as_ref()
767 }
768
769 #[must_use]
770 pub fn price_protection_points(&self) -> u32 {
771 self.price_protection_points
772 }
773
774 #[must_use]
775 pub fn liquidation_enabled(&self) -> bool {
776 self.liquidation_enabled
777 }
778
779 #[must_use]
780 pub fn liquidation_trigger_ratio(&self) -> f64 {
781 self.liquidation_trigger_ratio
782 }
783
784 #[must_use]
785 pub fn liquidation_cancel_open_orders(&self) -> bool {
786 self.liquidation_cancel_open_orders
787 }
788}
789
790#[derive(Debug, Clone, bon::Builder)]
792#[builder(finish_fn(name = build_inner, vis = ""))]
793#[cfg_attr(
794 feature = "python",
795 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
796)]
797#[cfg_attr(
798 feature = "python",
799 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
800)]
801pub struct BacktestDataConfig {
802 data_type: NautilusDataType,
804 catalog_path: String,
806 #[builder(default)]
808 #[cfg(feature = "streaming")]
809 catalog_backend: CatalogBackendType,
810 catalog_fs_protocol: Option<String>,
812 catalog_fs_storage_options: Option<AHashMap<String, String>>,
814 catalog_fs_rust_storage_options: Option<AHashMap<String, String>>,
816 instrument_id: Option<InstrumentId>,
818 instrument_ids: Option<Vec<InstrumentId>>,
820 start_time: Option<UnixNanos>,
822 end_time: Option<UnixNanos>,
824 filter_expr: Option<String>,
826 client_id: Option<ClientId>,
828 metadata: Option<AHashMap<String, String>>,
830 bar_spec: Option<BarSpecification>,
832 bar_types: Option<Vec<String>>,
834 #[builder(default)]
836 optimize_file_loading: bool,
837}
838
839impl<S: backtest_data_config_builder::IsComplete> BacktestDataConfigBuilder<S> {
840 pub fn build(self) -> ConfigResult<BacktestDataConfig> {
847 let config = self.build_inner();
848 config.validate()?;
849 Ok(config)
850 }
851}
852
853impl BacktestDataConfig {
854 #[must_use]
856 #[cfg(feature = "streaming")]
857 pub fn catalog_backend(&self) -> CatalogBackendType {
858 self.catalog_backend.clone()
859 }
860
861 pub fn validate(&self) -> ConfigResult<()> {
868 let mut errors = ConfigErrorCollector::new();
869
870 errors.check(
871 matches!(
872 self.data_type,
873 NautilusDataType::OrderBookDelta
874 | NautilusDataType::OrderBookDepth
875 | NautilusDataType::QuoteTick
876 | NautilusDataType::TradeTick
877 | NautilusDataType::Bar
878 | NautilusDataType::MarkPriceUpdate
879 | NautilusDataType::IndexPriceUpdate
880 | NautilusDataType::FundingRateUpdate
881 | NautilusDataType::OptionGreeks
882 | NautilusDataType::InstrumentStatus
883 | NautilusDataType::InstrumentClose
884 | NautilusDataType::Instrument
885 ),
886 ConfigError::unsupported_value(
887 "data_type",
888 format!("{} is not supported by BacktestDataConfig", self.data_type),
889 ),
890 );
891
892 if self.catalog_path.trim().is_empty() {
893 errors.push(ConfigError::empty_field("catalog_path"));
894 }
895
896 if let (Some(start), Some(end)) = (self.start_time, self.end_time) {
897 errors.check(
898 start <= end,
899 ConfigError::range(
900 "start_time",
901 format!("must be <= end_time, was {start} > {end}"),
902 ),
903 );
904 }
905
906 let has_identifier = self.instrument_id.is_some()
907 || self
908 .instrument_ids
909 .as_ref()
910 .is_some_and(|ids| !ids.is_empty())
911 || self.bar_types.as_ref().is_some_and(|bars| !bars.is_empty());
912 errors.check(
913 has_identifier,
914 ConfigError::required_one_of(["instrument_id", "instrument_ids", "bar_types"]),
915 );
916
917 errors.into_result()
918 }
919
920 #[must_use]
921 pub const fn data_type(&self) -> &NautilusDataType {
922 &self.data_type
923 }
924
925 #[must_use]
926 pub fn catalog_path(&self) -> &str {
927 &self.catalog_path
928 }
929
930 #[must_use]
931 pub fn catalog_fs_protocol(&self) -> Option<&str> {
932 self.catalog_fs_protocol.as_deref()
933 }
934
935 #[must_use]
936 pub fn catalog_fs_storage_options(&self) -> Option<&AHashMap<String, String>> {
937 self.catalog_fs_storage_options.as_ref()
938 }
939
940 #[must_use]
941 pub fn catalog_fs_rust_storage_options(&self) -> Option<&AHashMap<String, String>> {
942 self.catalog_fs_rust_storage_options.as_ref()
943 }
944
945 #[must_use]
946 pub fn instrument_id(&self) -> Option<InstrumentId> {
947 self.instrument_id
948 }
949
950 #[must_use]
951 pub fn instrument_ids(&self) -> Option<&[InstrumentId]> {
952 self.instrument_ids.as_deref()
953 }
954
955 #[must_use]
956 pub fn start_time(&self) -> Option<UnixNanos> {
957 self.start_time
958 }
959
960 #[must_use]
961 pub fn end_time(&self) -> Option<UnixNanos> {
962 self.end_time
963 }
964
965 #[must_use]
966 pub fn filter_expr(&self) -> Option<&str> {
967 self.filter_expr.as_deref()
968 }
969
970 #[must_use]
971 pub fn client_id(&self) -> Option<ClientId> {
972 self.client_id
973 }
974
975 #[must_use]
976 pub fn metadata(&self) -> Option<&AHashMap<String, String>> {
977 self.metadata.as_ref()
978 }
979
980 #[must_use]
981 pub fn bar_spec(&self) -> Option<BarSpecification> {
982 self.bar_spec
983 }
984
985 #[must_use]
986 pub fn bar_types(&self) -> Option<&[String]> {
987 self.bar_types.as_deref()
988 }
989
990 #[must_use]
991 pub fn optimize_file_loading(&self) -> bool {
992 self.optimize_file_loading
993 }
994
995 #[must_use]
1001 pub fn query_identifiers(&self) -> Option<Vec<String>> {
1002 if self.data_type == NautilusDataType::Bar {
1003 if let Some(bar_types) = &self.bar_types
1004 && !bar_types.is_empty()
1005 {
1006 return Some(bar_types.clone());
1007 }
1008
1009 if let Some(bar_spec) = &self.bar_spec {
1011 if let Some(id) = self.instrument_id {
1012 return Some(vec![format!("{id}-{bar_spec}-EXTERNAL")]);
1013 }
1014
1015 if let Some(ids) = &self.instrument_ids {
1016 let bar_types: Vec<String> = ids
1017 .iter()
1018 .map(|id| format!("{id}-{bar_spec}-EXTERNAL"))
1019 .collect();
1020
1021 if !bar_types.is_empty() {
1022 return Some(bar_types);
1023 }
1024 }
1025 }
1026 }
1027
1028 if let Some(id) = self.instrument_id {
1030 return Some(vec![id.to_string()]);
1031 }
1032
1033 if let Some(ids) = &self.instrument_ids {
1034 let strs: Vec<String> = ids.iter().map(ToString::to_string).collect();
1035 if !strs.is_empty() {
1036 return Some(strs);
1037 }
1038 }
1039
1040 None
1041 }
1042
1043 pub fn get_instrument_ids(&self) -> anyhow::Result<Vec<InstrumentId>> {
1051 if let Some(id) = self.instrument_id {
1052 return Ok(vec![id]);
1053 }
1054
1055 if let Some(ids) = &self.instrument_ids {
1056 return Ok(ids.clone());
1057 }
1058
1059 if let Some(bar_types) = &self.bar_types {
1060 let ids = bar_types
1061 .iter()
1062 .map(|bt| {
1063 bt.parse::<BarType>()
1064 .map(|b| b.instrument_id())
1065 .map_err(|_| anyhow::anyhow!("Invalid bar type string: '{bt}'"))
1066 })
1067 .collect::<anyhow::Result<Vec<_>>>()?;
1068 return Ok(ids);
1069 }
1070 Ok(Vec::new())
1071 }
1072}
1073
1074#[derive(Debug, Clone, bon::Builder)]
1077#[builder(finish_fn(name = build_inner, vis = ""))]
1078#[cfg_attr(
1079 feature = "python",
1080 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
1081)]
1082#[cfg_attr(
1083 feature = "python",
1084 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
1085)]
1086pub struct BacktestRunConfig {
1087 #[builder(default = UUID4::new().to_string())]
1089 id: String,
1090 venues: Vec<BacktestVenueConfig>,
1092 data: Vec<BacktestDataConfig>,
1094 #[builder(default)]
1096 engine: BacktestEngineConfig,
1097 chunk_size: Option<usize>,
1101 #[builder(default)]
1103 raise_exception: bool,
1104 #[builder(default = true)]
1108 dispose_on_completion: bool,
1109 start: Option<UnixNanos>,
1112 end: Option<UnixNanos>,
1115}
1116
1117impl<S: backtest_run_config_builder::IsComplete> BacktestRunConfigBuilder<S> {
1118 pub fn build(self) -> ConfigResult<BacktestRunConfig> {
1125 let config = self.build_inner();
1126 config.validate()?;
1127 Ok(config)
1128 }
1129}
1130
1131impl BacktestRunConfig {
1132 pub fn validate(&self) -> ConfigResult<()> {
1139 let mut errors = ConfigErrorCollector::new();
1140
1141 if self.venues.is_empty() {
1142 errors.push(ConfigError::empty_field("venues"));
1143 }
1144
1145 if let (Some(start), Some(end)) = (self.start, self.end) {
1146 errors.check(
1147 start <= end,
1148 ConfigError::range("start", format!("must be <= end, was {start} > {end}")),
1149 );
1150 }
1151
1152 if let Some(chunk_size) = self.chunk_size {
1153 errors.check(
1154 (1..=MAX_BACKTEST_CHUNK_SIZE).contains(&chunk_size),
1155 ConfigError::range(
1156 "chunk_size",
1157 format!("must be in range [1, {MAX_BACKTEST_CHUNK_SIZE}], was {chunk_size}"),
1158 ),
1159 );
1160 }
1161
1162 errors.into_result()
1163 }
1164
1165 #[must_use]
1166 pub fn id(&self) -> &str {
1167 &self.id
1168 }
1169
1170 #[must_use]
1171 pub fn venues(&self) -> &[BacktestVenueConfig] {
1172 &self.venues
1173 }
1174
1175 #[must_use]
1176 pub fn data(&self) -> &[BacktestDataConfig] {
1177 &self.data
1178 }
1179
1180 #[must_use]
1181 pub fn engine(&self) -> &BacktestEngineConfig {
1182 &self.engine
1183 }
1184
1185 #[must_use]
1186 pub fn chunk_size(&self) -> Option<usize> {
1187 self.chunk_size
1188 }
1189
1190 #[must_use]
1191 pub fn raise_exception(&self) -> bool {
1192 self.raise_exception
1193 }
1194
1195 #[must_use]
1196 pub fn dispose_on_completion(&self) -> bool {
1197 self.dispose_on_completion
1198 }
1199
1200 #[must_use]
1201 pub fn start(&self) -> Option<UnixNanos> {
1202 self.start
1203 }
1204
1205 #[must_use]
1206 pub fn end(&self) -> Option<UnixNanos> {
1207 self.end
1208 }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213 use rstest::rstest;
1214
1215 use super::*;
1216
1217 macro_rules! minimal_builder {
1218 () => {
1219 BacktestVenueConfig::builder()
1220 .name("SIM")
1221 .oms_type(OmsType::Netting)
1222 .account_type(AccountType::Margin)
1223 .book_type(BookType::L1_MBP)
1224 };
1225 }
1226
1227 #[rstest]
1228 #[case(NautilusDataType::OrderBookDelta)]
1229 #[case(NautilusDataType::OrderBookDepth)]
1230 #[case(NautilusDataType::QuoteTick)]
1231 #[case(NautilusDataType::TradeTick)]
1232 #[case(NautilusDataType::Bar)]
1233 #[case(NautilusDataType::MarkPriceUpdate)]
1234 #[case(NautilusDataType::IndexPriceUpdate)]
1235 #[case(NautilusDataType::FundingRateUpdate)]
1236 #[case(NautilusDataType::OptionGreeks)]
1237 #[case(NautilusDataType::InstrumentStatus)]
1238 #[case(NautilusDataType::InstrumentClose)]
1239 fn test_data_config_accepts_supported_family(#[case] data_type: NautilusDataType) {
1240 let config = BacktestDataConfig::builder()
1241 .data_type(data_type.clone())
1242 .catalog_path("/tmp/catalog".to_string())
1243 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1244 .build()
1245 .unwrap();
1246
1247 assert_eq!(config.data_type(), &data_type);
1248 }
1249
1250 #[rstest]
1251 fn test_data_config_accepts_the_instrument_family() {
1252 let config = BacktestDataConfig::builder()
1253 .data_type(NautilusDataType::Instrument)
1254 .catalog_path("/tmp/catalog".to_string())
1255 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1256 .build()
1257 .unwrap();
1258
1259 assert_eq!(config.data_type(), &NautilusDataType::Instrument);
1260 }
1261
1262 #[rstest]
1263 #[case(NautilusDataType::Custom { type_name: "Signal".to_string() })]
1264 fn test_data_config_rejects_unsupported_family(#[case] data_type: NautilusDataType) {
1265 let error = BacktestDataConfig::builder()
1266 .data_type(data_type.clone())
1267 .catalog_path("/tmp/catalog".to_string())
1268 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1269 .build()
1270 .unwrap_err();
1271
1272 assert_eq!(
1273 error,
1274 ConfigError::unsupported_value(
1275 "data_type",
1276 format!("{data_type} is not supported by BacktestDataConfig"),
1277 ),
1278 );
1279 }
1280
1281 macro_rules! minimal_simulated_builder {
1282 () => {
1283 SimulatedVenueConfig::builder()
1284 .venue(Venue::from("SIM"))
1285 .oms_type(OmsType::Netting)
1286 .account_type(AccountType::Margin)
1287 .book_type(BookType::L1_MBP)
1288 .starting_balances(vec![Money::from("1_000_000 USD")])
1289 };
1290 }
1291
1292 #[rstest]
1293 fn test_minimal_config_is_valid() {
1294 assert!(minimal_builder!().build().is_ok());
1295 }
1296
1297 #[rstest]
1298 fn test_default_leverage_is_optional() {
1299 let config = minimal_builder!().build().unwrap();
1300
1301 assert_eq!(config.default_leverage(), None);
1302 }
1303
1304 #[rstest]
1305 fn test_empty_name_rejected() {
1306 let result = BacktestVenueConfig::builder()
1307 .name("")
1308 .oms_type(OmsType::Netting)
1309 .account_type(AccountType::Margin)
1310 .book_type(BookType::L1_MBP)
1311 .build();
1312 assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "name"));
1313 }
1314
1315 #[rstest]
1316 #[case(" ")]
1317 #[case("vénue")]
1318 fn test_invalid_venue_name_rejected(#[case] name: &str) {
1319 let result = BacktestVenueConfig::builder()
1320 .name(name)
1321 .oms_type(OmsType::Netting)
1322 .account_type(AccountType::Margin)
1323 .book_type(BookType::L1_MBP)
1324 .build();
1325 assert!(matches!(result, Err(ConfigError::InvalidValue { field, .. }) if field == "name"));
1326 }
1327
1328 #[rstest]
1329 #[case(Decimal::ZERO)]
1330 #[case(Decimal::from(-1))]
1331 fn test_non_positive_default_leverage_rejected(#[case] leverage: Decimal) {
1332 let result = minimal_builder!().default_leverage(leverage).build();
1333 assert!(
1334 matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1335 );
1336 }
1337
1338 #[rstest]
1339 fn test_non_positive_instrument_leverage_rejected() {
1340 let mut leverages = AHashMap::new();
1341 leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
1342 let result = minimal_builder!().leverages(leverages).build();
1343 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1344 }
1345
1346 #[rstest]
1347 #[case(Decimal::ZERO)]
1348 #[case(Decimal::from(-1))]
1349 fn test_simulated_non_positive_instrument_leverage_rejected(#[case] leverage: Decimal) {
1350 let mut leverages = AHashMap::new();
1351 leverages.insert(InstrumentId::from("ESZ21.GLBX"), leverage);
1352 let result = minimal_simulated_builder!().leverages(leverages).build();
1353 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1354 }
1355
1356 #[rstest]
1357 fn test_simulated_positive_instrument_leverage_accepted() {
1358 let mut leverages = AHashMap::new();
1359 leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::from(10));
1360 let result = minimal_simulated_builder!().leverages(leverages).build();
1361 assert!(result.is_ok());
1362 }
1363
1364 #[rstest]
1365 #[case(0.0)]
1366 #[case(-1.0)]
1367 #[case(f64::INFINITY)]
1368 #[case(f64::NAN)]
1369 fn test_invalid_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1370 let result = minimal_builder!().liquidation_trigger_ratio(ratio).build();
1371 assert!(
1372 matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1373 );
1374 }
1375
1376 #[rstest]
1377 fn test_unparsable_starting_balance_rejected() {
1378 let result = minimal_builder!()
1379 .starting_balances(vec!["not a balance".to_string()])
1380 .build();
1381 assert!(
1382 matches!(result, Err(ConfigError::InvalidFormat { field, .. }) if field == "starting_balances")
1383 );
1384 }
1385
1386 #[rstest]
1387 fn test_valid_starting_balance_accepted() {
1388 let result = minimal_builder!()
1389 .starting_balances(vec!["1_000_000 USD".to_string()])
1390 .build();
1391 assert!(result.is_ok());
1392 }
1393
1394 #[rstest]
1395 fn test_multiple_violations_collected() {
1396 let result = BacktestVenueConfig::builder()
1397 .name("")
1398 .oms_type(OmsType::Netting)
1399 .account_type(AccountType::Margin)
1400 .book_type(BookType::L1_MBP)
1401 .default_leverage(Decimal::ZERO)
1402 .starting_balances(vec!["bad".to_string()])
1403 .build();
1404 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1405 panic!("expected ConfigError::Multiple");
1406 };
1407 assert_eq!(errors.len(), 3);
1408 assert!(
1409 errors
1410 .iter()
1411 .any(|e| matches!(e, ConfigError::EmptyField { field } if field == "name"))
1412 );
1413 assert!(
1414 errors.iter().any(
1415 |e| matches!(e, ConfigError::Range { field, .. } if field == "default_leverage")
1416 )
1417 );
1418 assert!(errors.iter().any(
1419 |e| matches!(e, ConfigError::InvalidFormat { field, .. } if field == "starting_balances")
1420 ));
1421 }
1422
1423 #[rstest]
1424 fn test_minimal_data_config_is_valid() {
1425 let result = BacktestDataConfig::builder()
1426 .data_type(NautilusDataType::QuoteTick)
1427 .catalog_path("/tmp/catalog".to_string())
1428 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1429 .build();
1430 assert!(result.is_ok());
1431 }
1432
1433 #[rstest]
1434 #[case("")]
1435 #[case(" ")]
1436 fn test_empty_catalog_path_rejected(#[case] catalog_path: &str) {
1437 let result = BacktestDataConfig::builder()
1438 .data_type(NautilusDataType::QuoteTick)
1439 .catalog_path(catalog_path.to_string())
1440 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1441 .build();
1442 assert!(
1443 matches!(result, Err(ConfigError::EmptyField { field }) if field == "catalog_path")
1444 );
1445 }
1446
1447 #[rstest]
1448 fn test_inverted_time_range_rejected() {
1449 let result = BacktestDataConfig::builder()
1450 .data_type(NautilusDataType::QuoteTick)
1451 .catalog_path("/tmp/catalog".to_string())
1452 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1453 .start_time(UnixNanos::from(5_000_000_000u64))
1454 .end_time(UnixNanos::from(1_000_000_000u64))
1455 .build();
1456 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start_time"));
1457 }
1458
1459 #[rstest]
1460 fn test_equal_time_range_accepted() {
1461 let result = BacktestDataConfig::builder()
1462 .data_type(NautilusDataType::QuoteTick)
1463 .catalog_path("/tmp/catalog".to_string())
1464 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1465 .start_time(UnixNanos::from(1_000_000_000u64))
1466 .end_time(UnixNanos::from(1_000_000_000u64))
1467 .build();
1468 assert!(result.is_ok());
1469 }
1470
1471 #[rstest]
1472 fn test_missing_identifier_rejected() {
1473 let result = BacktestDataConfig::builder()
1474 .data_type(NautilusDataType::QuoteTick)
1475 .catalog_path("/tmp/catalog".to_string())
1476 .build();
1477 assert!(matches!(result, Err(ConfigError::RequiredOneOf { fields }) if fields.len() == 3));
1478 }
1479
1480 #[rstest]
1481 fn test_empty_instrument_ids_rejected() {
1482 let result = BacktestDataConfig::builder()
1483 .data_type(NautilusDataType::QuoteTick)
1484 .catalog_path("/tmp/catalog".to_string())
1485 .instrument_ids(vec![])
1486 .build();
1487 assert!(matches!(result, Err(ConfigError::RequiredOneOf { .. })));
1488 }
1489
1490 #[rstest]
1491 fn test_bar_types_satisfies_identifier_requirement() {
1492 let result = BacktestDataConfig::builder()
1493 .data_type(NautilusDataType::Bar)
1494 .catalog_path("/tmp/catalog".to_string())
1495 .bar_types(vec!["ETH/USDT.BINANCE-1-MINUTE-LAST-EXTERNAL".to_string()])
1496 .build();
1497 assert!(result.is_ok());
1498 }
1499
1500 #[rstest]
1501 fn test_data_config_multiple_violations_collected() {
1502 let result = BacktestDataConfig::builder()
1503 .data_type(NautilusDataType::QuoteTick)
1504 .catalog_path(String::new())
1505 .start_time(UnixNanos::from(5_000_000_000u64))
1506 .end_time(UnixNanos::from(1_000_000_000u64))
1507 .build();
1508 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1509 panic!("expected ConfigError::Multiple");
1510 };
1511 assert_eq!(errors.len(), 3);
1512 }
1513
1514 macro_rules! minimal_sim_builder {
1515 () => {
1516 SimulatedVenueConfig::builder()
1517 .venue(Venue::from("SIM"))
1518 .oms_type(OmsType::Netting)
1519 .account_type(AccountType::Margin)
1520 .book_type(BookType::L1_MBP)
1521 .starting_balances(vec![Money::from("1_000_000 USD")])
1522 };
1523 }
1524
1525 #[rstest]
1526 fn test_minimal_sim_config_is_valid() {
1527 let config = minimal_sim_builder!().build().unwrap();
1528 assert!(config.defer_option_settlement);
1529 }
1530
1531 #[rstest]
1532 fn test_empty_starting_balances_rejected() {
1533 let result = SimulatedVenueConfig::builder()
1534 .venue(Venue::from("SIM"))
1535 .oms_type(OmsType::Netting)
1536 .account_type(AccountType::Margin)
1537 .book_type(BookType::L1_MBP)
1538 .starting_balances(vec![])
1539 .build();
1540 assert!(
1541 matches!(result, Err(ConfigError::EmptyField { field }) if field == "starting_balances")
1542 );
1543 }
1544
1545 #[rstest]
1546 #[case(Decimal::ZERO)]
1547 #[case(Decimal::from(-1))]
1548 fn test_non_positive_sim_default_leverage_rejected(#[case] leverage: Decimal) {
1549 let result = minimal_sim_builder!().default_leverage(leverage).build();
1550 assert!(
1551 matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1552 );
1553 }
1554
1555 #[rstest]
1556 fn test_positive_sim_default_leverage_accepted() {
1557 assert!(
1558 minimal_sim_builder!()
1559 .default_leverage(Decimal::from(5))
1560 .build()
1561 .is_ok()
1562 );
1563 }
1564
1565 #[rstest]
1566 #[case(0.0)]
1567 #[case(-1.0)]
1568 #[case(f64::INFINITY)]
1569 #[case(f64::NAN)]
1570 fn test_invalid_sim_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1571 let result = minimal_sim_builder!()
1572 .liquidation_trigger_ratio(ratio)
1573 .build();
1574 assert!(
1575 matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1576 );
1577 }
1578
1579 fn minimal_venue() -> BacktestVenueConfig {
1580 minimal_builder!().build().unwrap()
1581 }
1582
1583 #[rstest]
1584 fn test_minimal_run_config_is_valid() {
1585 let result = BacktestRunConfig::builder()
1586 .venues(vec![minimal_venue()])
1587 .data(vec![])
1588 .build();
1589 assert!(result.is_ok());
1590 }
1591
1592 #[rstest]
1593 fn test_run_config_requires_venues() {
1594 let result = BacktestRunConfig::builder()
1595 .venues(vec![])
1596 .data(vec![])
1597 .build();
1598 assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "venues"));
1599 }
1600
1601 #[rstest]
1602 fn test_run_config_inverted_time_range_rejected() {
1603 let result = BacktestRunConfig::builder()
1604 .venues(vec![minimal_venue()])
1605 .data(vec![])
1606 .start(UnixNanos::from(5_000_000_000u64))
1607 .end(UnixNanos::from(1_000_000_000u64))
1608 .build();
1609 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start"));
1610 }
1611
1612 #[rstest]
1613 fn test_run_config_equal_time_range_accepted() {
1614 let result = BacktestRunConfig::builder()
1615 .venues(vec![minimal_venue()])
1616 .data(vec![])
1617 .start(UnixNanos::from(1_000_000_000u64))
1618 .end(UnixNanos::from(1_000_000_000u64))
1619 .build();
1620 assert!(result.is_ok());
1621 }
1622
1623 #[rstest]
1624 fn test_run_config_accepts_chunk_size() {
1625 let config = BacktestRunConfig::builder()
1626 .venues(vec![minimal_venue()])
1627 .data(vec![])
1628 .chunk_size(10)
1629 .build()
1630 .unwrap();
1631 assert_eq!(config.chunk_size(), Some(10));
1632 }
1633
1634 #[rstest]
1635 fn test_run_config_accepts_maximum_chunk_size() {
1636 let config = BacktestRunConfig::builder()
1637 .venues(vec![minimal_venue()])
1638 .data(vec![])
1639 .chunk_size(MAX_BACKTEST_CHUNK_SIZE)
1640 .build()
1641 .unwrap();
1642
1643 assert_eq!(config.chunk_size(), Some(MAX_BACKTEST_CHUNK_SIZE));
1644 }
1645
1646 #[rstest]
1647 fn test_run_config_zero_chunk_size_rejected() {
1648 let result = BacktestRunConfig::builder()
1649 .venues(vec![minimal_venue()])
1650 .data(vec![])
1651 .chunk_size(0)
1652 .build();
1653 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1654 }
1655
1656 #[rstest]
1657 #[case(MAX_BACKTEST_CHUNK_SIZE + 1)]
1658 #[case(usize::MAX)]
1659 fn test_run_config_rejects_oversized_chunk_size(#[case] chunk_size: usize) {
1660 let result = BacktestRunConfig::builder()
1661 .venues(vec![minimal_venue()])
1662 .data(vec![])
1663 .chunk_size(chunk_size)
1664 .build();
1665
1666 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1667 }
1668
1669 #[rstest]
1670 fn test_run_config_multiple_violations_collected() {
1671 let result = BacktestRunConfig::builder()
1672 .venues(vec![])
1673 .data(vec![])
1674 .start(UnixNanos::from(5_000_000_000u64))
1675 .end(UnixNanos::from(1_000_000_000u64))
1676 .build();
1677 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1678 panic!("expected ConfigError::Multiple");
1679 };
1680 assert_eq!(errors.len(), 2);
1681 }
1682}