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, string::secret::SecretString};
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 pub fee_model: FeeModelHandle,
300 pub latency_model: Option<LatencyModelHandle>,
302 #[builder(default = false)]
304 pub routing: bool,
305 #[builder(default = true)]
307 pub reject_stop_orders: bool,
308 #[builder(default = true)]
310 pub support_gtd_orders: bool,
311 #[builder(default = true)]
313 pub support_contingent_orders: bool,
314 #[builder(default = true)]
316 pub use_position_ids: bool,
317 #[builder(default = false)]
319 pub use_random_ids: bool,
320 #[builder(default = true)]
322 pub use_reduce_only: bool,
323 #[builder(default = true)]
325 pub use_message_queue: bool,
326 #[builder(default = false)]
328 pub use_market_order_acks: bool,
329 #[builder(default = true)]
331 pub bar_execution: bool,
332 #[builder(default = false)]
334 pub bar_adaptive_high_low_ordering: bool,
335 #[builder(default = true)]
337 pub trade_execution: bool,
338 #[builder(default = false)]
340 pub liquidity_consumption: bool,
341 #[builder(default = false)]
343 pub allow_cash_borrowing: bool,
344 #[builder(default = false)]
346 pub frozen_account: bool,
347 #[builder(default = false)]
349 pub queue_position: bool,
350 #[builder(default = false)]
352 pub oto_full_trigger: bool,
353 #[builder(default = true)]
355 pub defer_option_settlement: bool,
356 #[builder(default = 0)]
358 pub price_protection_points: u32,
359 #[builder(default = false)]
361 pub liquidation_enabled: bool,
362 #[builder(default = 1.0)]
364 pub liquidation_trigger_ratio: f64,
365 #[builder(default = true)]
367 pub liquidation_cancel_open_orders: bool,
368}
369
370impl<S: simulated_venue_config_builder::IsComplete> SimulatedVenueConfigBuilder<S> {
371 pub fn build(self) -> ConfigResult<SimulatedVenueConfig> {
377 let config = self.build_inner();
378 config.validate()?;
379 Ok(config)
380 }
381}
382
383impl SimulatedVenueConfig {
384 pub fn validate(&self) -> ConfigResult<()> {
391 let mut errors = ConfigErrorCollector::new();
392
393 if self.starting_balances.is_empty() {
394 errors.push(ConfigError::empty_field("starting_balances"));
395 }
396
397 if let Some(default_leverage) = self.default_leverage {
398 errors.check(
399 default_leverage > Decimal::ZERO,
400 ConfigError::range(
401 "default_leverage",
402 format!("must be positive, was {default_leverage}"),
403 ),
404 );
405 }
406
407 for (instrument_id, leverage) in &self.leverages {
408 errors.check(
409 *leverage > Decimal::ZERO,
410 ConfigError::range(
411 "leverages",
412 format!("leverage for {instrument_id} must be positive, was {leverage}"),
413 ),
414 );
415 }
416
417 errors.check(
418 self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
419 ConfigError::range(
420 "liquidation_trigger_ratio",
421 format!(
422 "must be a positive finite value, was {}",
423 self.liquidation_trigger_ratio
424 ),
425 ),
426 );
427
428 errors.into_result()
429 }
430}
431
432#[cfg_attr(
434 feature = "python",
435 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
436)]
437#[cfg_attr(
438 feature = "python",
439 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
440)]
441#[expect(
442 clippy::struct_excessive_bools,
443 reason = "venue config fields mirror the existing Rust and Python backtest surfaces"
444)]
445#[derive(Debug, Clone, bon::Builder)]
446#[builder(finish_fn(name = build_inner, vis = ""))]
447pub struct BacktestVenueConfig {
448 #[builder(into)]
450 name: Ustr,
451 oms_type: OmsType,
453 account_type: AccountType,
455 book_type: BookType,
457 #[builder(default)]
459 starting_balances: Vec<String>,
460 #[builder(default)]
462 routing: bool,
463 #[builder(default)]
465 frozen_account: bool,
466 #[builder(default = true)]
468 reject_stop_orders: bool,
469 #[builder(default = true)]
471 support_gtd_orders: bool,
472 #[builder(default = true)]
475 support_contingent_orders: bool,
476 #[builder(default = true)]
478 use_position_ids: bool,
479 #[builder(default)]
482 use_random_ids: bool,
483 #[builder(default = true)]
486 use_reduce_only: bool,
487 #[builder(default = true)]
489 bar_execution: bool,
490 #[builder(default)]
497 bar_adaptive_high_low_ordering: bool,
498 #[builder(default = true)]
500 trade_execution: bool,
501 #[builder(default)]
503 use_market_order_acks: bool,
504 #[builder(default)]
506 liquidity_consumption: bool,
507 #[builder(default)]
509 allow_cash_borrowing: bool,
510 #[builder(default)]
512 queue_position: bool,
513 #[builder(default)]
515 oto_trigger_mode: OtoTriggerMode,
516 base_currency: Option<Currency>,
518 default_leverage: Option<Decimal>,
520 leverages: Option<AHashMap<InstrumentId, Decimal>>,
522 margin_model: Option<MarginModelAny>,
524 #[builder(default)]
526 modules: Vec<SimulationModuleAny>,
527 fill_model: Option<FillModelAny>,
529 latency_model: Option<LatencyModelAny>,
531 fee_model: Option<FeeModelAny>,
536 #[builder(default)]
539 price_protection_points: u32,
540 #[builder(default)]
542 liquidation_enabled: bool,
543 #[builder(default = 1.0)]
546 liquidation_trigger_ratio: f64,
547 #[builder(default = true)]
549 liquidation_cancel_open_orders: bool,
550}
551
552impl<S: backtest_venue_config_builder::IsComplete> BacktestVenueConfigBuilder<S> {
553 pub fn build(self) -> ConfigResult<BacktestVenueConfig> {
559 let config = self.build_inner();
560 config.validate()?;
561 Ok(config)
562 }
563}
564
565impl BacktestVenueConfig {
566 pub fn validate(&self) -> ConfigResult<()> {
573 let mut errors = ConfigErrorCollector::new();
574
575 if self.name.is_empty() {
576 errors.push(ConfigError::empty_field("name"));
577 } else if let Err(e) = Venue::new_checked(self.name.as_str()) {
578 errors.push(ConfigError::invalid_value(
579 "name",
580 format!("must be a valid venue identifier ({e})"),
581 ));
582 }
583
584 if let Some(default_leverage) = self.default_leverage {
585 errors.check(
586 default_leverage > Decimal::ZERO,
587 ConfigError::range(
588 "default_leverage",
589 format!("must be positive, was {default_leverage}"),
590 ),
591 );
592 }
593
594 if let Some(leverages) = &self.leverages {
595 for (instrument_id, leverage) in leverages {
596 errors.check(
597 *leverage > Decimal::ZERO,
598 ConfigError::range(
599 "leverages",
600 format!("leverage for {instrument_id} must be positive, was {leverage}"),
601 ),
602 );
603 }
604 }
605 errors.check(
606 self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
607 ConfigError::range(
608 "liquidation_trigger_ratio",
609 format!(
610 "must be a positive finite value, was {}",
611 self.liquidation_trigger_ratio
612 ),
613 ),
614 );
615
616 for balance in &self.starting_balances {
617 if let Err(reason) = balance.parse::<Money>() {
618 errors.push(ConfigError::invalid_format(
619 "starting_balances",
620 format!("a valid money string, was '{balance}' ({reason})"),
621 ));
622 }
623 }
624
625 errors.into_result()
626 }
627
628 #[must_use]
629 pub fn name(&self) -> Ustr {
630 self.name
631 }
632
633 #[must_use]
634 pub fn oms_type(&self) -> OmsType {
635 self.oms_type
636 }
637
638 #[must_use]
639 pub fn account_type(&self) -> AccountType {
640 self.account_type
641 }
642
643 #[must_use]
644 pub fn book_type(&self) -> BookType {
645 self.book_type
646 }
647
648 #[must_use]
649 pub fn starting_balances(&self) -> &[String] {
650 &self.starting_balances
651 }
652
653 #[must_use]
654 pub fn routing(&self) -> bool {
655 self.routing
656 }
657
658 #[must_use]
659 pub fn frozen_account(&self) -> bool {
660 self.frozen_account
661 }
662
663 #[must_use]
664 pub fn reject_stop_orders(&self) -> bool {
665 self.reject_stop_orders
666 }
667
668 #[must_use]
669 pub fn support_gtd_orders(&self) -> bool {
670 self.support_gtd_orders
671 }
672
673 #[must_use]
674 pub fn support_contingent_orders(&self) -> bool {
675 self.support_contingent_orders
676 }
677
678 #[must_use]
679 pub fn use_position_ids(&self) -> bool {
680 self.use_position_ids
681 }
682
683 #[must_use]
684 pub fn use_random_ids(&self) -> bool {
685 self.use_random_ids
686 }
687
688 #[must_use]
689 pub fn use_reduce_only(&self) -> bool {
690 self.use_reduce_only
691 }
692
693 #[must_use]
694 pub fn bar_execution(&self) -> bool {
695 self.bar_execution
696 }
697
698 #[must_use]
699 pub fn bar_adaptive_high_low_ordering(&self) -> bool {
700 self.bar_adaptive_high_low_ordering
701 }
702
703 #[must_use]
704 pub fn trade_execution(&self) -> bool {
705 self.trade_execution
706 }
707
708 #[must_use]
709 pub fn use_market_order_acks(&self) -> bool {
710 self.use_market_order_acks
711 }
712
713 #[must_use]
714 pub fn liquidity_consumption(&self) -> bool {
715 self.liquidity_consumption
716 }
717
718 #[must_use]
719 pub fn allow_cash_borrowing(&self) -> bool {
720 self.allow_cash_borrowing
721 }
722
723 #[must_use]
724 pub fn queue_position(&self) -> bool {
725 self.queue_position
726 }
727
728 #[must_use]
729 pub fn oto_trigger_mode(&self) -> OtoTriggerMode {
730 self.oto_trigger_mode
731 }
732
733 #[must_use]
734 pub fn base_currency(&self) -> Option<Currency> {
735 self.base_currency
736 }
737
738 #[must_use]
739 pub fn default_leverage(&self) -> Option<Decimal> {
740 self.default_leverage
741 }
742
743 #[must_use]
744 pub fn leverages(&self) -> Option<&AHashMap<InstrumentId, Decimal>> {
745 self.leverages.as_ref()
746 }
747
748 #[must_use]
749 pub fn margin_model(&self) -> Option<&MarginModelAny> {
750 self.margin_model.as_ref()
751 }
752
753 #[must_use]
754 pub fn modules(&self) -> &[SimulationModuleAny] {
755 &self.modules
756 }
757
758 #[must_use]
759 pub fn fill_model(&self) -> Option<&FillModelAny> {
760 self.fill_model.as_ref()
761 }
762
763 #[must_use]
764 pub fn latency_model(&self) -> Option<&LatencyModelAny> {
765 self.latency_model.as_ref()
766 }
767
768 #[must_use]
769 pub fn fee_model(&self) -> Option<&FeeModelAny> {
770 self.fee_model.as_ref()
771 }
772
773 #[must_use]
774 pub fn price_protection_points(&self) -> u32 {
775 self.price_protection_points
776 }
777
778 #[must_use]
779 pub fn liquidation_enabled(&self) -> bool {
780 self.liquidation_enabled
781 }
782
783 #[must_use]
784 pub fn liquidation_trigger_ratio(&self) -> f64 {
785 self.liquidation_trigger_ratio
786 }
787
788 #[must_use]
789 pub fn liquidation_cancel_open_orders(&self) -> bool {
790 self.liquidation_cancel_open_orders
791 }
792}
793
794#[derive(Debug, Clone, bon::Builder)]
796#[builder(finish_fn(name = build_inner, vis = ""))]
797#[cfg_attr(
798 feature = "python",
799 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
800)]
801#[cfg_attr(
802 feature = "python",
803 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
804)]
805pub struct BacktestDataConfig {
806 data_type: NautilusDataType,
808 catalog_path: String,
810 #[builder(default)]
812 #[cfg(feature = "streaming")]
813 catalog_backend: CatalogBackendType,
814 catalog_fs_protocol: Option<String>,
816 catalog_fs_storage_options: Option<AHashMap<String, SecretString>>,
818 catalog_fs_rust_storage_options: Option<AHashMap<String, SecretString>>,
820 instrument_id: Option<InstrumentId>,
822 instrument_ids: Option<Vec<InstrumentId>>,
824 start_time: Option<UnixNanos>,
826 end_time: Option<UnixNanos>,
828 filter_expr: Option<String>,
830 client_id: Option<ClientId>,
832 metadata: Option<AHashMap<String, String>>,
834 bar_spec: Option<BarSpecification>,
836 bar_types: Option<Vec<String>>,
838 #[builder(default)]
840 optimize_file_loading: bool,
841 #[builder(default = true)]
843 batch_deltas: bool,
844}
845
846impl<S: backtest_data_config_builder::IsComplete> BacktestDataConfigBuilder<S> {
847 pub fn build(self) -> ConfigResult<BacktestDataConfig> {
853 let config = self.build_inner();
854 config.validate()?;
855 Ok(config)
856 }
857}
858
859impl BacktestDataConfig {
860 #[must_use]
862 #[cfg(feature = "streaming")]
863 pub fn catalog_backend(&self) -> CatalogBackendType {
864 self.catalog_backend.clone()
865 }
866
867 pub fn validate(&self) -> ConfigResult<()> {
874 let mut errors = ConfigErrorCollector::new();
875
876 errors.check(
877 matches!(
878 self.data_type,
879 NautilusDataType::OrderBookDelta
880 | NautilusDataType::OrderBookDepth
881 | NautilusDataType::QuoteTick
882 | NautilusDataType::TradeTick
883 | NautilusDataType::Bar
884 | NautilusDataType::MarkPriceUpdate
885 | NautilusDataType::IndexPriceUpdate
886 | NautilusDataType::FundingRateUpdate
887 | NautilusDataType::OptionGreeks
888 | NautilusDataType::InstrumentStatus
889 | NautilusDataType::InstrumentClose
890 | NautilusDataType::Instrument
891 ),
892 ConfigError::unsupported_value(
893 "data_type",
894 format!("{} is not supported by BacktestDataConfig", self.data_type),
895 ),
896 );
897
898 if self.catalog_path.trim().is_empty() {
899 errors.push(ConfigError::empty_field("catalog_path"));
900 }
901
902 if let (Some(start), Some(end)) = (self.start_time, self.end_time) {
903 errors.check(
904 start <= end,
905 ConfigError::range(
906 "start_time",
907 format!("must be <= end_time, was {start} > {end}"),
908 ),
909 );
910 }
911
912 let has_identifier = self.instrument_id.is_some()
913 || self
914 .instrument_ids
915 .as_ref()
916 .is_some_and(|ids| !ids.is_empty())
917 || self.bar_types.as_ref().is_some_and(|bars| !bars.is_empty());
918 errors.check(
919 has_identifier,
920 ConfigError::required_one_of(["instrument_id", "instrument_ids", "bar_types"]),
921 );
922
923 errors.into_result()
924 }
925
926 #[must_use]
927 pub const fn data_type(&self) -> &NautilusDataType {
928 &self.data_type
929 }
930
931 #[must_use]
932 pub fn catalog_path(&self) -> &str {
933 &self.catalog_path
934 }
935
936 #[must_use]
937 pub fn catalog_fs_protocol(&self) -> Option<&str> {
938 self.catalog_fs_protocol.as_deref()
939 }
940
941 #[must_use]
942 pub fn catalog_fs_storage_options(&self) -> Option<&AHashMap<String, SecretString>> {
943 self.catalog_fs_storage_options.as_ref()
944 }
945
946 #[must_use]
947 pub fn catalog_fs_rust_storage_options(&self) -> Option<&AHashMap<String, SecretString>> {
948 self.catalog_fs_rust_storage_options.as_ref()
949 }
950
951 #[must_use]
952 pub fn instrument_id(&self) -> Option<InstrumentId> {
953 self.instrument_id
954 }
955
956 #[must_use]
957 pub fn instrument_ids(&self) -> Option<&[InstrumentId]> {
958 self.instrument_ids.as_deref()
959 }
960
961 #[must_use]
962 pub fn start_time(&self) -> Option<UnixNanos> {
963 self.start_time
964 }
965
966 #[must_use]
967 pub fn end_time(&self) -> Option<UnixNanos> {
968 self.end_time
969 }
970
971 #[must_use]
972 pub fn filter_expr(&self) -> Option<&str> {
973 self.filter_expr.as_deref()
974 }
975
976 #[must_use]
977 pub fn client_id(&self) -> Option<ClientId> {
978 self.client_id
979 }
980
981 #[must_use]
982 pub fn metadata(&self) -> Option<&AHashMap<String, String>> {
983 self.metadata.as_ref()
984 }
985
986 #[must_use]
987 pub fn bar_spec(&self) -> Option<BarSpecification> {
988 self.bar_spec
989 }
990
991 #[must_use]
992 pub fn bar_types(&self) -> Option<&[String]> {
993 self.bar_types.as_deref()
994 }
995
996 #[must_use]
997 pub fn optimize_file_loading(&self) -> bool {
998 self.optimize_file_loading
999 }
1000
1001 #[must_use]
1002 pub fn batch_deltas(&self) -> bool {
1003 self.batch_deltas
1004 }
1005
1006 #[must_use]
1012 pub fn query_identifiers(&self) -> Option<Vec<String>> {
1013 if self.data_type == NautilusDataType::Bar {
1014 if let Some(bar_types) = &self.bar_types
1015 && !bar_types.is_empty()
1016 {
1017 return Some(bar_types.clone());
1018 }
1019
1020 if let Some(bar_spec) = &self.bar_spec {
1022 if let Some(id) = self.instrument_id {
1023 return Some(vec![format!("{id}-{bar_spec}-EXTERNAL")]);
1024 }
1025
1026 if let Some(ids) = &self.instrument_ids {
1027 let bar_types: Vec<String> = ids
1028 .iter()
1029 .map(|id| format!("{id}-{bar_spec}-EXTERNAL"))
1030 .collect();
1031
1032 if !bar_types.is_empty() {
1033 return Some(bar_types);
1034 }
1035 }
1036 }
1037 }
1038
1039 if let Some(id) = self.instrument_id {
1041 return Some(vec![id.to_string()]);
1042 }
1043
1044 if let Some(ids) = &self.instrument_ids {
1045 let strs: Vec<String> = ids.iter().map(ToString::to_string).collect();
1046 if !strs.is_empty() {
1047 return Some(strs);
1048 }
1049 }
1050
1051 None
1052 }
1053
1054 pub fn get_instrument_ids(&self) -> anyhow::Result<Vec<InstrumentId>> {
1062 if let Some(id) = self.instrument_id {
1063 return Ok(vec![id]);
1064 }
1065
1066 if let Some(ids) = &self.instrument_ids {
1067 return Ok(ids.clone());
1068 }
1069
1070 if let Some(bar_types) = &self.bar_types {
1071 let ids = bar_types
1072 .iter()
1073 .map(|bt| {
1074 bt.parse::<BarType>()
1075 .map(|b| b.instrument_id())
1076 .map_err(|_| anyhow::anyhow!("Invalid bar type string: '{bt}'"))
1077 })
1078 .collect::<anyhow::Result<Vec<_>>>()?;
1079 return Ok(ids);
1080 }
1081 Ok(Vec::new())
1082 }
1083}
1084
1085#[derive(Debug, Clone, bon::Builder)]
1088#[builder(finish_fn(name = build_inner, vis = ""))]
1089#[cfg_attr(
1090 feature = "python",
1091 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
1092)]
1093#[cfg_attr(
1094 feature = "python",
1095 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
1096)]
1097pub struct BacktestRunConfig {
1098 #[builder(default = UUID4::new().to_string())]
1100 id: String,
1101 venues: Vec<BacktestVenueConfig>,
1103 data: Vec<BacktestDataConfig>,
1105 #[builder(default)]
1107 engine: BacktestEngineConfig,
1108 chunk_size: Option<usize>,
1112 #[builder(default)]
1114 raise_exception: bool,
1115 #[builder(default = true)]
1119 dispose_on_completion: bool,
1120 start: Option<UnixNanos>,
1123 end: Option<UnixNanos>,
1126}
1127
1128impl<S: backtest_run_config_builder::IsComplete> BacktestRunConfigBuilder<S> {
1129 pub fn build(self) -> ConfigResult<BacktestRunConfig> {
1135 let config = self.build_inner();
1136 config.validate()?;
1137 Ok(config)
1138 }
1139}
1140
1141impl BacktestRunConfig {
1142 pub fn validate(&self) -> ConfigResult<()> {
1149 let mut errors = ConfigErrorCollector::new();
1150
1151 if self.venues.is_empty() {
1152 errors.push(ConfigError::empty_field("venues"));
1153 }
1154
1155 if let (Some(start), Some(end)) = (self.start, self.end) {
1156 errors.check(
1157 start <= end,
1158 ConfigError::range("start", format!("must be <= end, was {start} > {end}")),
1159 );
1160 }
1161
1162 if let Some(chunk_size) = self.chunk_size {
1163 errors.check(
1164 (1..=MAX_BACKTEST_CHUNK_SIZE).contains(&chunk_size),
1165 ConfigError::range(
1166 "chunk_size",
1167 format!("must be in range [1, {MAX_BACKTEST_CHUNK_SIZE}], was {chunk_size}"),
1168 ),
1169 );
1170 }
1171
1172 errors.into_result()
1173 }
1174
1175 #[must_use]
1176 pub fn id(&self) -> &str {
1177 &self.id
1178 }
1179
1180 #[must_use]
1181 pub fn venues(&self) -> &[BacktestVenueConfig] {
1182 &self.venues
1183 }
1184
1185 #[must_use]
1186 pub fn data(&self) -> &[BacktestDataConfig] {
1187 &self.data
1188 }
1189
1190 #[must_use]
1191 pub fn engine(&self) -> &BacktestEngineConfig {
1192 &self.engine
1193 }
1194
1195 #[must_use]
1196 pub fn chunk_size(&self) -> Option<usize> {
1197 self.chunk_size
1198 }
1199
1200 #[must_use]
1201 pub fn raise_exception(&self) -> bool {
1202 self.raise_exception
1203 }
1204
1205 #[must_use]
1206 pub fn dispose_on_completion(&self) -> bool {
1207 self.dispose_on_completion
1208 }
1209
1210 #[must_use]
1211 pub fn start(&self) -> Option<UnixNanos> {
1212 self.start
1213 }
1214
1215 #[must_use]
1216 pub fn end(&self) -> Option<UnixNanos> {
1217 self.end
1218 }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use nautilus_core::string::secret::REDACTED;
1224 use nautilus_execution::models::fee::MakerTakerFeeModel;
1225 use rstest::rstest;
1226
1227 use super::*;
1228
1229 macro_rules! minimal_builder {
1230 () => {
1231 BacktestVenueConfig::builder()
1232 .name("SIM")
1233 .oms_type(OmsType::Netting)
1234 .account_type(AccountType::Margin)
1235 .book_type(BookType::L1_MBP)
1236 };
1237 }
1238
1239 #[rstest]
1240 #[case(NautilusDataType::OrderBookDelta)]
1241 #[case(NautilusDataType::OrderBookDepth)]
1242 #[case(NautilusDataType::QuoteTick)]
1243 #[case(NautilusDataType::TradeTick)]
1244 #[case(NautilusDataType::Bar)]
1245 #[case(NautilusDataType::MarkPriceUpdate)]
1246 #[case(NautilusDataType::IndexPriceUpdate)]
1247 #[case(NautilusDataType::FundingRateUpdate)]
1248 #[case(NautilusDataType::OptionGreeks)]
1249 #[case(NautilusDataType::InstrumentStatus)]
1250 #[case(NautilusDataType::InstrumentClose)]
1251 fn test_data_config_accepts_supported_family(#[case] data_type: NautilusDataType) {
1252 let config = BacktestDataConfig::builder()
1253 .data_type(data_type.clone())
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(), &data_type);
1260 }
1261
1262 #[rstest]
1263 fn test_data_config_accepts_the_instrument_family() {
1264 let config = BacktestDataConfig::builder()
1265 .data_type(NautilusDataType::Instrument)
1266 .catalog_path("/tmp/catalog".to_string())
1267 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1268 .build()
1269 .unwrap();
1270
1271 assert_eq!(config.data_type(), &NautilusDataType::Instrument);
1272 }
1273
1274 #[rstest]
1275 #[case(NautilusDataType::Custom { type_name: "Signal".to_string() })]
1276 fn test_data_config_rejects_unsupported_family(#[case] data_type: NautilusDataType) {
1277 let error = BacktestDataConfig::builder()
1278 .data_type(data_type.clone())
1279 .catalog_path("/tmp/catalog".to_string())
1280 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1281 .build()
1282 .unwrap_err();
1283
1284 assert_eq!(
1285 error,
1286 ConfigError::unsupported_value(
1287 "data_type",
1288 format!("{data_type} is not supported by BacktestDataConfig"),
1289 ),
1290 );
1291 }
1292
1293 macro_rules! minimal_simulated_builder {
1294 () => {
1295 SimulatedVenueConfig::builder()
1296 .venue(Venue::from("SIM"))
1297 .oms_type(OmsType::Netting)
1298 .account_type(AccountType::Margin)
1299 .book_type(BookType::L1_MBP)
1300 .starting_balances(vec![Money::from("1_000_000 USD")])
1301 .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1302 };
1303 }
1304
1305 #[rstest]
1306 fn test_minimal_config_is_valid() {
1307 assert!(minimal_builder!().build().is_ok());
1308 }
1309
1310 #[rstest]
1311 fn test_default_leverage_is_optional() {
1312 let config = minimal_builder!().build().unwrap();
1313
1314 assert_eq!(config.default_leverage(), None);
1315 }
1316
1317 #[rstest]
1318 fn test_empty_name_rejected() {
1319 let result = BacktestVenueConfig::builder()
1320 .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::EmptyField { field }) if field == "name"));
1326 }
1327
1328 #[rstest]
1329 #[case(" ")]
1330 #[case("vénue")]
1331 fn test_invalid_venue_name_rejected(#[case] name: &str) {
1332 let result = BacktestVenueConfig::builder()
1333 .name(name)
1334 .oms_type(OmsType::Netting)
1335 .account_type(AccountType::Margin)
1336 .book_type(BookType::L1_MBP)
1337 .build();
1338 assert!(matches!(result, Err(ConfigError::InvalidValue { field, .. }) if field == "name"));
1339 }
1340
1341 #[rstest]
1342 #[case(Decimal::ZERO)]
1343 #[case(Decimal::from(-1))]
1344 fn test_non_positive_default_leverage_rejected(#[case] leverage: Decimal) {
1345 let result = minimal_builder!().default_leverage(leverage).build();
1346 assert!(
1347 matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1348 );
1349 }
1350
1351 #[rstest]
1352 fn test_non_positive_instrument_leverage_rejected() {
1353 let mut leverages = AHashMap::new();
1354 leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
1355 let result = minimal_builder!().leverages(leverages).build();
1356 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1357 }
1358
1359 #[rstest]
1360 #[case(Decimal::ZERO)]
1361 #[case(Decimal::from(-1))]
1362 fn test_simulated_non_positive_instrument_leverage_rejected(#[case] leverage: Decimal) {
1363 let mut leverages = AHashMap::new();
1364 leverages.insert(InstrumentId::from("ESZ21.GLBX"), leverage);
1365 let result = minimal_simulated_builder!().leverages(leverages).build();
1366 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1367 }
1368
1369 #[rstest]
1370 fn test_simulated_positive_instrument_leverage_accepted() {
1371 let mut leverages = AHashMap::new();
1372 leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::from(10));
1373 let result = minimal_simulated_builder!().leverages(leverages).build();
1374 assert!(result.is_ok());
1375 }
1376
1377 #[rstest]
1378 #[case(0.0)]
1379 #[case(-1.0)]
1380 #[case(f64::INFINITY)]
1381 #[case(f64::NAN)]
1382 fn test_invalid_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1383 let result = minimal_builder!().liquidation_trigger_ratio(ratio).build();
1384 assert!(
1385 matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1386 );
1387 }
1388
1389 #[rstest]
1390 fn test_unparsable_starting_balance_rejected() {
1391 let result = minimal_builder!()
1392 .starting_balances(vec!["not a balance".to_string()])
1393 .build();
1394 assert!(
1395 matches!(result, Err(ConfigError::InvalidFormat { field, .. }) if field == "starting_balances")
1396 );
1397 }
1398
1399 #[rstest]
1400 fn test_valid_starting_balance_accepted() {
1401 let result = minimal_builder!()
1402 .starting_balances(vec!["1_000_000 USD".to_string()])
1403 .build();
1404 assert!(result.is_ok());
1405 }
1406
1407 #[rstest]
1408 fn test_multiple_violations_collected() {
1409 let result = BacktestVenueConfig::builder()
1410 .name("")
1411 .oms_type(OmsType::Netting)
1412 .account_type(AccountType::Margin)
1413 .book_type(BookType::L1_MBP)
1414 .default_leverage(Decimal::ZERO)
1415 .starting_balances(vec!["bad".to_string()])
1416 .build();
1417 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1418 panic!("expected ConfigError::Multiple");
1419 };
1420 assert_eq!(errors.len(), 3);
1421 assert!(
1422 errors
1423 .iter()
1424 .any(|e| matches!(e, ConfigError::EmptyField { field } if field == "name"))
1425 );
1426 assert!(
1427 errors.iter().any(
1428 |e| matches!(e, ConfigError::Range { field, .. } if field == "default_leverage")
1429 )
1430 );
1431 assert!(errors.iter().any(
1432 |e| matches!(e, ConfigError::InvalidFormat { field, .. } if field == "starting_balances")
1433 ));
1434 }
1435
1436 #[rstest]
1437 fn test_minimal_data_config_is_valid() {
1438 let result = BacktestDataConfig::builder()
1439 .data_type(NautilusDataType::QuoteTick)
1440 .catalog_path("/tmp/catalog".to_string())
1441 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1442 .build();
1443 assert!(result.is_ok());
1444 }
1445
1446 #[rstest]
1447 #[case("")]
1448 #[case(" ")]
1449 fn test_empty_catalog_path_rejected(#[case] catalog_path: &str) {
1450 let result = BacktestDataConfig::builder()
1451 .data_type(NautilusDataType::QuoteTick)
1452 .catalog_path(catalog_path.to_string())
1453 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1454 .build();
1455 assert!(
1456 matches!(result, Err(ConfigError::EmptyField { field }) if field == "catalog_path")
1457 );
1458 }
1459
1460 #[rstest]
1461 fn test_inverted_time_range_rejected() {
1462 let result = BacktestDataConfig::builder()
1463 .data_type(NautilusDataType::QuoteTick)
1464 .catalog_path("/tmp/catalog".to_string())
1465 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1466 .start_time(UnixNanos::from(5_000_000_000u64))
1467 .end_time(UnixNanos::from(1_000_000_000u64))
1468 .build();
1469 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start_time"));
1470 }
1471
1472 #[rstest]
1473 fn test_equal_time_range_accepted() {
1474 let result = BacktestDataConfig::builder()
1475 .data_type(NautilusDataType::QuoteTick)
1476 .catalog_path("/tmp/catalog".to_string())
1477 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1478 .start_time(UnixNanos::from(1_000_000_000u64))
1479 .end_time(UnixNanos::from(1_000_000_000u64))
1480 .build();
1481 assert!(result.is_ok());
1482 }
1483
1484 #[rstest]
1485 fn test_missing_identifier_rejected() {
1486 let result = BacktestDataConfig::builder()
1487 .data_type(NautilusDataType::QuoteTick)
1488 .catalog_path("/tmp/catalog".to_string())
1489 .build();
1490 assert!(matches!(result, Err(ConfigError::RequiredOneOf { fields }) if fields.len() == 3));
1491 }
1492
1493 #[rstest]
1494 fn test_empty_instrument_ids_rejected() {
1495 let result = BacktestDataConfig::builder()
1496 .data_type(NautilusDataType::QuoteTick)
1497 .catalog_path("/tmp/catalog".to_string())
1498 .instrument_ids(vec![])
1499 .build();
1500 assert!(matches!(result, Err(ConfigError::RequiredOneOf { .. })));
1501 }
1502
1503 #[rstest]
1504 fn test_bar_types_satisfies_identifier_requirement() {
1505 let result = BacktestDataConfig::builder()
1506 .data_type(NautilusDataType::Bar)
1507 .catalog_path("/tmp/catalog".to_string())
1508 .bar_types(vec!["ETH/USDT.BINANCE-1-MINUTE-LAST-EXTERNAL".to_string()])
1509 .build();
1510 assert!(result.is_ok());
1511 }
1512
1513 #[rstest]
1514 fn test_data_config_multiple_violations_collected() {
1515 let result = BacktestDataConfig::builder()
1516 .data_type(NautilusDataType::QuoteTick)
1517 .catalog_path(String::new())
1518 .start_time(UnixNanos::from(5_000_000_000u64))
1519 .end_time(UnixNanos::from(1_000_000_000u64))
1520 .build();
1521 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1522 panic!("expected ConfigError::Multiple");
1523 };
1524 assert_eq!(errors.len(), 3);
1525 }
1526
1527 #[rstest]
1528 fn test_data_config_debug_redacts_storage_option_values() {
1529 let config = BacktestDataConfig::builder()
1530 .data_type(NautilusDataType::QuoteTick)
1531 .catalog_path("/tmp/catalog".to_string())
1532 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1533 .catalog_fs_storage_options(AHashMap::from([(
1534 "key".to_string(),
1535 SecretString::from("fs-key-sentinel"),
1536 )]))
1537 .catalog_fs_rust_storage_options(AHashMap::from([(
1538 "aws_secret_access_key".to_string(),
1539 SecretString::from("rust-secret-sentinel"),
1540 )]))
1541 .build()
1542 .unwrap();
1543
1544 let debug_output = format!("{config:?}");
1545
1546 assert!(!debug_output.contains("fs-key-sentinel"));
1547 assert!(!debug_output.contains("rust-secret-sentinel"));
1548 assert!(debug_output.contains(&format!(
1549 "catalog_fs_storage_options: Some({{\"key\": {REDACTED}}})"
1550 )));
1551 assert!(debug_output.contains(&format!(
1552 "catalog_fs_rust_storage_options: Some({{\"aws_secret_access_key\": {REDACTED}}})"
1553 )));
1554 }
1555
1556 macro_rules! minimal_sim_builder {
1557 () => {
1558 SimulatedVenueConfig::builder()
1559 .venue(Venue::from("SIM"))
1560 .oms_type(OmsType::Netting)
1561 .account_type(AccountType::Margin)
1562 .book_type(BookType::L1_MBP)
1563 .starting_balances(vec![Money::from("1_000_000 USD")])
1564 .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1565 };
1566 }
1567
1568 #[rstest]
1569 fn test_minimal_sim_config_is_valid() {
1570 let config = minimal_sim_builder!().build().unwrap();
1571 assert!(config.defer_option_settlement);
1572 }
1573
1574 #[rstest]
1575 fn test_empty_starting_balances_rejected() {
1576 let result = SimulatedVenueConfig::builder()
1577 .venue(Venue::from("SIM"))
1578 .oms_type(OmsType::Netting)
1579 .account_type(AccountType::Margin)
1580 .book_type(BookType::L1_MBP)
1581 .starting_balances(vec![])
1582 .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1583 .build();
1584 assert!(
1585 matches!(result, Err(ConfigError::EmptyField { field }) if field == "starting_balances")
1586 );
1587 }
1588
1589 #[rstest]
1590 #[case(Decimal::ZERO)]
1591 #[case(Decimal::from(-1))]
1592 fn test_non_positive_sim_default_leverage_rejected(#[case] leverage: Decimal) {
1593 let result = minimal_sim_builder!().default_leverage(leverage).build();
1594 assert!(
1595 matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1596 );
1597 }
1598
1599 #[rstest]
1600 fn test_positive_sim_default_leverage_accepted() {
1601 assert!(
1602 minimal_sim_builder!()
1603 .default_leverage(Decimal::from(5))
1604 .build()
1605 .is_ok()
1606 );
1607 }
1608
1609 #[rstest]
1610 #[case(0.0)]
1611 #[case(-1.0)]
1612 #[case(f64::INFINITY)]
1613 #[case(f64::NAN)]
1614 fn test_invalid_sim_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1615 let result = minimal_sim_builder!()
1616 .liquidation_trigger_ratio(ratio)
1617 .build();
1618 assert!(
1619 matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1620 );
1621 }
1622
1623 fn minimal_venue() -> BacktestVenueConfig {
1624 minimal_builder!().build().unwrap()
1625 }
1626
1627 #[rstest]
1628 fn test_minimal_run_config_is_valid() {
1629 let result = BacktestRunConfig::builder()
1630 .venues(vec![minimal_venue()])
1631 .data(vec![])
1632 .build();
1633 assert!(result.is_ok());
1634 }
1635
1636 #[rstest]
1637 fn test_run_config_requires_venues() {
1638 let result = BacktestRunConfig::builder()
1639 .venues(vec![])
1640 .data(vec![])
1641 .build();
1642 assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "venues"));
1643 }
1644
1645 #[rstest]
1646 fn test_run_config_inverted_time_range_rejected() {
1647 let result = BacktestRunConfig::builder()
1648 .venues(vec![minimal_venue()])
1649 .data(vec![])
1650 .start(UnixNanos::from(5_000_000_000u64))
1651 .end(UnixNanos::from(1_000_000_000u64))
1652 .build();
1653 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start"));
1654 }
1655
1656 #[rstest]
1657 fn test_run_config_equal_time_range_accepted() {
1658 let result = BacktestRunConfig::builder()
1659 .venues(vec![minimal_venue()])
1660 .data(vec![])
1661 .start(UnixNanos::from(1_000_000_000u64))
1662 .end(UnixNanos::from(1_000_000_000u64))
1663 .build();
1664 assert!(result.is_ok());
1665 }
1666
1667 #[rstest]
1668 fn test_run_config_accepts_chunk_size() {
1669 let config = BacktestRunConfig::builder()
1670 .venues(vec![minimal_venue()])
1671 .data(vec![])
1672 .chunk_size(10)
1673 .build()
1674 .unwrap();
1675 assert_eq!(config.chunk_size(), Some(10));
1676 }
1677
1678 #[rstest]
1679 fn test_run_config_accepts_maximum_chunk_size() {
1680 let config = BacktestRunConfig::builder()
1681 .venues(vec![minimal_venue()])
1682 .data(vec![])
1683 .chunk_size(MAX_BACKTEST_CHUNK_SIZE)
1684 .build()
1685 .unwrap();
1686
1687 assert_eq!(config.chunk_size(), Some(MAX_BACKTEST_CHUNK_SIZE));
1688 }
1689
1690 #[rstest]
1691 fn test_run_config_zero_chunk_size_rejected() {
1692 let result = BacktestRunConfig::builder()
1693 .venues(vec![minimal_venue()])
1694 .data(vec![])
1695 .chunk_size(0)
1696 .build();
1697 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1698 }
1699
1700 #[rstest]
1701 #[case(MAX_BACKTEST_CHUNK_SIZE + 1)]
1702 #[case(usize::MAX)]
1703 fn test_run_config_rejects_oversized_chunk_size(#[case] chunk_size: usize) {
1704 let result = BacktestRunConfig::builder()
1705 .venues(vec![minimal_venue()])
1706 .data(vec![])
1707 .chunk_size(chunk_size)
1708 .build();
1709
1710 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1711 }
1712
1713 #[rstest]
1714 fn test_run_config_multiple_violations_collected() {
1715 let result = BacktestRunConfig::builder()
1716 .venues(vec![])
1717 .data(vec![])
1718 .start(UnixNanos::from(5_000_000_000u64))
1719 .end(UnixNanos::from(1_000_000_000u64))
1720 .build();
1721 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1722 panic!("expected ConfigError::Multiple");
1723 };
1724 assert_eq!(errors.len(), 2);
1725 }
1726}