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::DataCatalogConfig;
47use nautilus_portfolio::config::PortfolioConfig;
48use nautilus_risk::engine::config::RiskEngineConfig;
49use nautilus_system::config::NautilusKernelConfig;
50#[cfg(feature = "streaming")]
51use nautilus_system::config::StreamingConfig;
52use nautilus_trading::ImportableControllerConfig;
53use rust_decimal::Decimal;
54use ustr::Ustr;
55
56use crate::modules::{SimulationModuleAny, SimulationModuleHandle};
57
58pub(crate) const MAX_BACKTEST_CHUNK_SIZE: usize = 1_000_000;
59
60#[cfg_attr(
62 feature = "python",
63 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
64)]
65#[cfg_attr(
66 feature = "python",
67 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
68)]
69#[expect(
70 clippy::struct_excessive_bools,
71 reason = "config fields mirror the existing Rust and Python backtest engine surfaces"
72)]
73#[derive(Debug, Clone, bon::Builder)]
74pub struct BacktestEngineConfig {
75 #[builder(default = Environment::Backtest)]
77 pub environment: Environment,
78 #[builder(default)]
80 pub trader_id: TraderId,
81 #[builder(default)]
83 pub load_state: bool,
84 #[builder(default)]
86 pub save_state: bool,
87 #[builder(default)]
91 pub shutdown_on_error: bool,
92 #[builder(default)]
94 pub logging: LoggerConfig,
95 pub instance_id: Option<UUID4>,
97 #[builder(default = Duration::from_mins(1))]
99 pub timeout_connection: Duration,
100 #[builder(default = Duration::from_secs(30))]
102 pub timeout_reconciliation: Duration,
103 #[builder(default = Duration::from_secs(10))]
105 pub timeout_portfolio: Duration,
106 #[builder(default = Duration::from_secs(10))]
108 pub timeout_disconnection: Duration,
109 #[builder(default = Duration::from_secs(10))]
111 pub delay_post_stop: Duration,
112 #[builder(default = Duration::from_secs(5))]
114 pub timeout_shutdown: Duration,
115 pub cache: Option<CacheConfig>,
121 pub msgbus: Option<MessageBusConfig>,
123 pub data_engine: Option<DataEngineConfig>,
125 pub risk_engine: Option<RiskEngineConfig>,
127 pub exec_engine: Option<ExecutionEngineConfig>,
129 pub portfolio: Option<PortfolioConfig>,
131 pub controller: Option<ImportableControllerConfig>,
133 #[cfg(feature = "streaming")]
135 pub streaming: Option<StreamingConfig>,
136 #[cfg(feature = "streaming")]
138 #[builder(default)]
139 pub catalogs: Vec<DataCatalogConfig>,
140 #[builder(default)]
142 pub bypass_logging: bool,
143 #[builder(default = true)]
145 pub run_analysis: bool,
146}
147
148impl NautilusKernelConfig for BacktestEngineConfig {
149 fn environment(&self) -> Environment {
150 self.environment
151 }
152
153 fn trader_id(&self) -> TraderId {
154 self.trader_id
155 }
156
157 fn load_state(&self) -> bool {
158 self.load_state
159 }
160
161 fn save_state(&self) -> bool {
162 self.save_state
163 }
164
165 fn shutdown_on_error(&self) -> bool {
166 self.shutdown_on_error
167 }
168
169 fn logging(&self) -> LoggerConfig {
170 self.logging.clone()
171 }
172
173 fn instance_id(&self) -> Option<UUID4> {
174 self.instance_id
175 }
176
177 fn timeout_connection(&self) -> Duration {
178 self.timeout_connection
179 }
180
181 fn timeout_reconciliation(&self) -> Duration {
182 self.timeout_reconciliation
183 }
184
185 fn timeout_portfolio(&self) -> Duration {
186 self.timeout_portfolio
187 }
188
189 fn timeout_disconnection(&self) -> Duration {
190 self.timeout_disconnection
191 }
192
193 fn delay_post_stop(&self) -> Duration {
194 self.delay_post_stop
195 }
196
197 fn timeout_shutdown(&self) -> Duration {
198 self.timeout_shutdown
199 }
200
201 fn cache(&self) -> Option<CacheConfig> {
202 self.cache.clone()
203 }
204
205 fn msgbus(&self) -> Option<MessageBusConfig> {
206 self.msgbus.clone()
207 }
208
209 fn data_engine(&self) -> Option<DataEngineConfig> {
210 self.data_engine.clone()
211 }
212
213 fn risk_engine(&self) -> Option<RiskEngineConfig> {
214 self.risk_engine.clone()
215 }
216
217 fn exec_engine(&self) -> Option<ExecutionEngineConfig> {
218 self.exec_engine.clone()
219 }
220
221 fn portfolio(&self) -> Option<PortfolioConfig> {
222 self.portfolio
223 }
224
225 #[cfg(feature = "streaming")]
226 fn streaming(&self) -> Option<StreamingConfig> {
227 self.streaming.clone()
228 }
229
230 #[cfg(feature = "streaming")]
231 fn catalogs(&self) -> Vec<DataCatalogConfig> {
232 self.catalogs.clone()
233 }
234}
235
236impl Default for BacktestEngineConfig {
237 fn default() -> Self {
238 Self::builder().build()
239 }
240}
241
242#[allow(missing_debug_implementations)]
260#[expect(
261 clippy::struct_excessive_bools,
262 reason = "venue config fields mirror the existing imperative backtest API"
263)]
264#[derive(bon::Builder)]
265#[builder(finish_fn(name = build_inner, vis = ""))]
266pub struct SimulatedVenueConfig {
267 pub venue: Venue,
269 pub oms_type: OmsType,
271 pub account_type: AccountType,
273 pub book_type: BookType,
275 pub starting_balances: Vec<Money>,
277 pub base_currency: Option<Currency>,
279 pub default_leverage: Option<Decimal>,
281 #[builder(default)]
283 pub leverages: AHashMap<InstrumentId, Decimal>,
284 #[builder(default)]
286 pub fill_model: FillModelHandle,
287 pub fee_model: FeeModelHandle,
293 pub margin_model: Option<MarginModelHandle>,
295 pub latency_model: Option<LatencyModelHandle>,
297 #[builder(default)]
299 pub modules: Vec<SimulationModuleHandle>,
300 #[builder(default = false)]
302 pub routing: bool,
303 #[builder(default = true)]
305 pub reject_stop_orders: bool,
306 #[builder(default = true)]
308 pub support_gtd_orders: bool,
309 #[builder(default = true)]
311 pub support_contingent_orders: bool,
312 #[builder(default = true)]
314 pub use_position_ids: bool,
315 #[builder(default = false)]
317 pub use_random_ids: bool,
318 #[builder(default = true)]
320 pub use_reduce_only: bool,
321 #[builder(default = true)]
323 pub use_message_queue: bool,
324 #[builder(default = false)]
326 pub use_market_order_acks: bool,
327 #[builder(default = true)]
329 pub bar_execution: bool,
330 #[builder(default = false)]
332 pub bar_adaptive_high_low_ordering: bool,
333 #[builder(default = true)]
335 pub trade_execution: bool,
336 #[builder(default = false)]
338 pub liquidity_consumption: bool,
339 #[builder(default = false)]
341 pub allow_cash_borrowing: bool,
342 #[builder(default = false)]
344 pub frozen_account: bool,
345 #[builder(default = false)]
347 pub queue_position: bool,
348 #[builder(default = false)]
350 pub oto_full_trigger: bool,
351 #[builder(default = true)]
353 pub defer_option_settlement: bool,
354 #[builder(default = 0)]
356 pub price_protection_points: u32,
357 #[builder(default = false)]
359 pub liquidation_enabled: bool,
360 #[builder(default = 1.0)]
362 pub liquidation_trigger_ratio: f64,
363 #[builder(default = true)]
365 pub liquidation_cancel_open_orders: bool,
366}
367
368impl<S: simulated_venue_config_builder::IsComplete> SimulatedVenueConfigBuilder<S> {
369 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 fill_model: Option<FillModelAny>,
522 fee_model: Option<FeeModelAny>,
527 margin_model: Option<MarginModelAny>,
529 latency_model: Option<LatencyModelAny>,
531 #[builder(default)]
533 modules: Vec<SimulationModuleAny>,
534 #[builder(default)]
537 price_protection_points: u32,
538 #[builder(default)]
540 liquidation_enabled: bool,
541 #[builder(default = 1.0)]
544 liquidation_trigger_ratio: f64,
545 #[builder(default = true)]
547 liquidation_cancel_open_orders: bool,
548}
549
550impl<S: backtest_venue_config_builder::IsComplete> BacktestVenueConfigBuilder<S> {
551 pub fn build(self) -> ConfigResult<BacktestVenueConfig> {
557 let config = self.build_inner();
558 config.validate()?;
559 Ok(config)
560 }
561}
562
563impl BacktestVenueConfig {
564 pub fn validate(&self) -> ConfigResult<()> {
571 let mut errors = ConfigErrorCollector::new();
572
573 if self.name.is_empty() {
574 errors.push(ConfigError::empty_field("name"));
575 } else if let Err(e) = Venue::new_checked(self.name.as_str()) {
576 errors.push(ConfigError::invalid_value(
577 "name",
578 format!("must be a valid venue identifier ({e})"),
579 ));
580 }
581
582 if let Some(default_leverage) = self.default_leverage {
583 errors.check(
584 default_leverage > Decimal::ZERO,
585 ConfigError::range(
586 "default_leverage",
587 format!("must be positive, was {default_leverage}"),
588 ),
589 );
590 }
591
592 if let Some(leverages) = &self.leverages {
593 for (instrument_id, leverage) in leverages {
594 errors.check(
595 *leverage > Decimal::ZERO,
596 ConfigError::range(
597 "leverages",
598 format!("leverage for {instrument_id} must be positive, was {leverage}"),
599 ),
600 );
601 }
602 }
603 errors.check(
604 self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
605 ConfigError::range(
606 "liquidation_trigger_ratio",
607 format!(
608 "must be a positive finite value, was {}",
609 self.liquidation_trigger_ratio
610 ),
611 ),
612 );
613
614 for balance in &self.starting_balances {
615 if let Err(reason) = balance.parse::<Money>() {
616 errors.push(ConfigError::invalid_format(
617 "starting_balances",
618 format!("a valid money string, was '{balance}' ({reason})"),
619 ));
620 }
621 }
622
623 errors.into_result()
624 }
625
626 #[must_use]
627 pub fn name(&self) -> Ustr {
628 self.name
629 }
630
631 #[must_use]
632 pub fn oms_type(&self) -> OmsType {
633 self.oms_type
634 }
635
636 #[must_use]
637 pub fn account_type(&self) -> AccountType {
638 self.account_type
639 }
640
641 #[must_use]
642 pub fn book_type(&self) -> BookType {
643 self.book_type
644 }
645
646 #[must_use]
647 pub fn starting_balances(&self) -> &[String] {
648 &self.starting_balances
649 }
650
651 #[must_use]
652 pub fn routing(&self) -> bool {
653 self.routing
654 }
655
656 #[must_use]
657 pub fn frozen_account(&self) -> bool {
658 self.frozen_account
659 }
660
661 #[must_use]
662 pub fn reject_stop_orders(&self) -> bool {
663 self.reject_stop_orders
664 }
665
666 #[must_use]
667 pub fn support_gtd_orders(&self) -> bool {
668 self.support_gtd_orders
669 }
670
671 #[must_use]
672 pub fn support_contingent_orders(&self) -> bool {
673 self.support_contingent_orders
674 }
675
676 #[must_use]
677 pub fn use_position_ids(&self) -> bool {
678 self.use_position_ids
679 }
680
681 #[must_use]
682 pub fn use_random_ids(&self) -> bool {
683 self.use_random_ids
684 }
685
686 #[must_use]
687 pub fn use_reduce_only(&self) -> bool {
688 self.use_reduce_only
689 }
690
691 #[must_use]
692 pub fn bar_execution(&self) -> bool {
693 self.bar_execution
694 }
695
696 #[must_use]
697 pub fn bar_adaptive_high_low_ordering(&self) -> bool {
698 self.bar_adaptive_high_low_ordering
699 }
700
701 #[must_use]
702 pub fn trade_execution(&self) -> bool {
703 self.trade_execution
704 }
705
706 #[must_use]
707 pub fn use_market_order_acks(&self) -> bool {
708 self.use_market_order_acks
709 }
710
711 #[must_use]
712 pub fn liquidity_consumption(&self) -> bool {
713 self.liquidity_consumption
714 }
715
716 #[must_use]
717 pub fn allow_cash_borrowing(&self) -> bool {
718 self.allow_cash_borrowing
719 }
720
721 #[must_use]
722 pub fn queue_position(&self) -> bool {
723 self.queue_position
724 }
725
726 #[must_use]
727 pub fn oto_trigger_mode(&self) -> OtoTriggerMode {
728 self.oto_trigger_mode
729 }
730
731 #[must_use]
732 pub fn base_currency(&self) -> Option<Currency> {
733 self.base_currency
734 }
735
736 #[must_use]
737 pub fn default_leverage(&self) -> Option<Decimal> {
738 self.default_leverage
739 }
740
741 #[must_use]
742 pub fn leverages(&self) -> Option<&AHashMap<InstrumentId, Decimal>> {
743 self.leverages.as_ref()
744 }
745
746 #[must_use]
747 pub fn fill_model(&self) -> Option<&FillModelAny> {
748 self.fill_model.as_ref()
749 }
750
751 #[must_use]
752 pub fn fee_model(&self) -> Option<&FeeModelAny> {
753 self.fee_model.as_ref()
754 }
755
756 #[must_use]
757 pub fn margin_model(&self) -> Option<&MarginModelAny> {
758 self.margin_model.as_ref()
759 }
760
761 #[must_use]
762 pub fn latency_model(&self) -> Option<&LatencyModelAny> {
763 self.latency_model.as_ref()
764 }
765
766 #[must_use]
767 pub fn modules(&self) -> &[SimulationModuleAny] {
768 &self.modules
769 }
770
771 #[must_use]
772 pub fn price_protection_points(&self) -> u32 {
773 self.price_protection_points
774 }
775
776 #[must_use]
777 pub fn liquidation_enabled(&self) -> bool {
778 self.liquidation_enabled
779 }
780
781 #[must_use]
782 pub fn liquidation_trigger_ratio(&self) -> f64 {
783 self.liquidation_trigger_ratio
784 }
785
786 #[must_use]
787 pub fn liquidation_cancel_open_orders(&self) -> bool {
788 self.liquidation_cancel_open_orders
789 }
790}
791
792#[derive(Debug, Clone, bon::Builder)]
794#[builder(finish_fn(name = build_inner, vis = ""))]
795#[cfg_attr(
796 feature = "python",
797 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
798)]
799#[cfg_attr(
800 feature = "python",
801 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
802)]
803pub struct BacktestDataConfig {
804 data_type: NautilusDataType,
806 #[cfg(feature = "streaming")]
808 catalog: DataCatalogConfig,
809 instrument_id: Option<InstrumentId>,
811 instrument_ids: Option<Vec<InstrumentId>>,
813 start_time: Option<UnixNanos>,
815 end_time: Option<UnixNanos>,
817 filter_expr: Option<String>,
819 client_id: Option<ClientId>,
821 metadata: Option<AHashMap<String, String>>,
823 bar_spec: Option<BarSpecification>,
825 bar_types: Option<Vec<String>>,
827 #[builder(default)]
829 optimize_file_loading: bool,
830 #[builder(default = true)]
832 batch_deltas: bool,
833}
834
835impl<S: backtest_data_config_builder::IsComplete> BacktestDataConfigBuilder<S> {
836 pub fn build(self) -> ConfigResult<BacktestDataConfig> {
842 let config = self.build_inner();
843 config.validate()?;
844 Ok(config)
845 }
846}
847
848impl BacktestDataConfig {
849 pub fn validate(&self) -> ConfigResult<()> {
856 let mut errors = ConfigErrorCollector::new();
857
858 errors.check(
859 matches!(
860 self.data_type,
861 NautilusDataType::OrderBookDelta
862 | NautilusDataType::OrderBookDepth
863 | NautilusDataType::QuoteTick
864 | NautilusDataType::TradeTick
865 | NautilusDataType::Bar
866 | NautilusDataType::MarkPriceUpdate
867 | NautilusDataType::IndexPriceUpdate
868 | NautilusDataType::FundingRateUpdate
869 | NautilusDataType::OptionGreeks
870 | NautilusDataType::InstrumentStatus
871 | NautilusDataType::InstrumentClose
872 | NautilusDataType::Instrument
873 ),
874 ConfigError::unsupported_value(
875 "data_type",
876 format!("{} is not supported by BacktestDataConfig", self.data_type),
877 ),
878 );
879
880 #[cfg(feature = "streaming")]
881 {
882 if self.catalog.path().trim().is_empty() {
883 errors.push(ConfigError::empty_field("catalog.path"));
884 }
885
886 errors.collect(self.catalog.validate());
887 }
888
889 if let (Some(start), Some(end)) = (self.start_time, self.end_time) {
890 errors.check(
891 start <= end,
892 ConfigError::range(
893 "start_time",
894 format!("must be <= end_time, was {start} > {end}"),
895 ),
896 );
897 }
898
899 let has_identifier = self.instrument_id.is_some()
900 || self
901 .instrument_ids
902 .as_ref()
903 .is_some_and(|ids| !ids.is_empty())
904 || self.bar_types.as_ref().is_some_and(|bars| !bars.is_empty());
905 errors.check(
906 has_identifier,
907 ConfigError::required_one_of(["instrument_id", "instrument_ids", "bar_types"]),
908 );
909
910 errors.into_result()
911 }
912
913 #[must_use]
914 pub const fn data_type(&self) -> &NautilusDataType {
915 &self.data_type
916 }
917
918 #[must_use]
920 #[cfg(feature = "streaming")]
921 pub const fn catalog(&self) -> &DataCatalogConfig {
922 &self.catalog
923 }
924
925 #[must_use]
926 pub fn instrument_id(&self) -> Option<InstrumentId> {
927 self.instrument_id
928 }
929
930 #[must_use]
931 pub fn instrument_ids(&self) -> Option<&[InstrumentId]> {
932 self.instrument_ids.as_deref()
933 }
934
935 #[must_use]
936 pub fn start_time(&self) -> Option<UnixNanos> {
937 self.start_time
938 }
939
940 #[must_use]
941 pub fn end_time(&self) -> Option<UnixNanos> {
942 self.end_time
943 }
944
945 #[must_use]
946 pub fn filter_expr(&self) -> Option<&str> {
947 self.filter_expr.as_deref()
948 }
949
950 #[must_use]
951 pub fn client_id(&self) -> Option<ClientId> {
952 self.client_id
953 }
954
955 #[must_use]
956 pub fn metadata(&self) -> Option<&AHashMap<String, String>> {
957 self.metadata.as_ref()
958 }
959
960 #[must_use]
961 pub fn bar_spec(&self) -> Option<BarSpecification> {
962 self.bar_spec
963 }
964
965 #[must_use]
966 pub fn bar_types(&self) -> Option<&[String]> {
967 self.bar_types.as_deref()
968 }
969
970 #[must_use]
971 pub fn optimize_file_loading(&self) -> bool {
972 self.optimize_file_loading
973 }
974
975 #[must_use]
976 pub fn batch_deltas(&self) -> bool {
977 self.batch_deltas
978 }
979
980 #[must_use]
986 pub fn query_identifiers(&self) -> Option<Vec<String>> {
987 if self.data_type == NautilusDataType::Bar {
988 if let Some(bar_types) = &self.bar_types
989 && !bar_types.is_empty()
990 {
991 return Some(bar_types.clone());
992 }
993
994 if let Some(bar_spec) = &self.bar_spec {
996 if let Some(id) = self.instrument_id {
997 return Some(vec![format!("{id}-{bar_spec}-EXTERNAL")]);
998 }
999
1000 if let Some(ids) = &self.instrument_ids {
1001 let bar_types: Vec<String> = ids
1002 .iter()
1003 .map(|id| format!("{id}-{bar_spec}-EXTERNAL"))
1004 .collect();
1005
1006 if !bar_types.is_empty() {
1007 return Some(bar_types);
1008 }
1009 }
1010 }
1011 }
1012
1013 if let Some(id) = self.instrument_id {
1015 return Some(vec![id.to_string()]);
1016 }
1017
1018 if let Some(ids) = &self.instrument_ids {
1019 let strs: Vec<String> = ids.iter().map(ToString::to_string).collect();
1020 if !strs.is_empty() {
1021 return Some(strs);
1022 }
1023 }
1024
1025 None
1026 }
1027
1028 pub fn get_instrument_ids(&self) -> anyhow::Result<Vec<InstrumentId>> {
1036 if let Some(id) = self.instrument_id {
1037 return Ok(vec![id]);
1038 }
1039
1040 if let Some(ids) = &self.instrument_ids {
1041 return Ok(ids.clone());
1042 }
1043
1044 if let Some(bar_types) = &self.bar_types {
1045 let ids = bar_types
1046 .iter()
1047 .map(|bt| {
1048 bt.parse::<BarType>()
1049 .map(|b| b.instrument_id())
1050 .map_err(|_| anyhow::anyhow!("Invalid bar type string: '{bt}'"))
1051 })
1052 .collect::<anyhow::Result<Vec<_>>>()?;
1053 return Ok(ids);
1054 }
1055 Ok(Vec::new())
1056 }
1057}
1058
1059#[derive(Debug, Clone, bon::Builder)]
1062#[builder(finish_fn(name = build_inner, vis = ""))]
1063#[cfg_attr(
1064 feature = "python",
1065 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
1066)]
1067#[cfg_attr(
1068 feature = "python",
1069 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
1070)]
1071pub struct BacktestRunConfig {
1072 #[builder(default = UUID4::new().to_string())]
1074 id: String,
1075 venues: Vec<BacktestVenueConfig>,
1077 data: Vec<BacktestDataConfig>,
1079 #[builder(default)]
1081 engine: BacktestEngineConfig,
1082 chunk_size: Option<usize>,
1086 #[builder(default)]
1088 raise_exception: bool,
1089 #[builder(default = true)]
1093 dispose_on_completion: bool,
1094 start: Option<UnixNanos>,
1097 end: Option<UnixNanos>,
1100}
1101
1102impl<S: backtest_run_config_builder::IsComplete> BacktestRunConfigBuilder<S> {
1103 pub fn build(self) -> ConfigResult<BacktestRunConfig> {
1109 let config = self.build_inner();
1110 config.validate()?;
1111 Ok(config)
1112 }
1113}
1114
1115impl BacktestRunConfig {
1116 pub fn validate(&self) -> ConfigResult<()> {
1123 let mut errors = ConfigErrorCollector::new();
1124
1125 if self.venues.is_empty() {
1126 errors.push(ConfigError::empty_field("venues"));
1127 }
1128
1129 if let (Some(start), Some(end)) = (self.start, self.end) {
1130 errors.check(
1131 start <= end,
1132 ConfigError::range("start", format!("must be <= end, was {start} > {end}")),
1133 );
1134 }
1135
1136 if let Some(chunk_size) = self.chunk_size {
1137 errors.check(
1138 (1..=MAX_BACKTEST_CHUNK_SIZE).contains(&chunk_size),
1139 ConfigError::range(
1140 "chunk_size",
1141 format!("must be in range [1, {MAX_BACKTEST_CHUNK_SIZE}], was {chunk_size}"),
1142 ),
1143 );
1144 }
1145
1146 errors.into_result()
1147 }
1148
1149 #[must_use]
1150 pub fn id(&self) -> &str {
1151 &self.id
1152 }
1153
1154 #[must_use]
1155 pub fn venues(&self) -> &[BacktestVenueConfig] {
1156 &self.venues
1157 }
1158
1159 #[must_use]
1160 pub fn data(&self) -> &[BacktestDataConfig] {
1161 &self.data
1162 }
1163
1164 #[must_use]
1165 pub fn engine(&self) -> &BacktestEngineConfig {
1166 &self.engine
1167 }
1168
1169 #[must_use]
1170 pub fn chunk_size(&self) -> Option<usize> {
1171 self.chunk_size
1172 }
1173
1174 #[must_use]
1175 pub fn raise_exception(&self) -> bool {
1176 self.raise_exception
1177 }
1178
1179 #[must_use]
1180 pub fn dispose_on_completion(&self) -> bool {
1181 self.dispose_on_completion
1182 }
1183
1184 #[must_use]
1185 pub fn start(&self) -> Option<UnixNanos> {
1186 self.start
1187 }
1188
1189 #[must_use]
1190 pub fn end(&self) -> Option<UnixNanos> {
1191 self.end
1192 }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 #[cfg(feature = "streaming")]
1198 use nautilus_core::{
1199 Params,
1200 string::secret::{REDACTED, SecretString},
1201 };
1202 use nautilus_execution::models::fee::MakerTakerFeeModel;
1203 #[cfg(feature = "streaming")]
1204 use nautilus_persistence::config::{CatalogBackendType, CatalogCompression};
1205 use rstest::rstest;
1206
1207 use super::*;
1208
1209 macro_rules! minimal_builder {
1210 () => {
1211 BacktestVenueConfig::builder()
1212 .name("SIM")
1213 .oms_type(OmsType::Netting)
1214 .account_type(AccountType::Margin)
1215 .book_type(BookType::L1_MBP)
1216 };
1217 }
1218
1219 macro_rules! data_builder {
1220 ($data_type:expr, $catalog_path:expr) => {{
1221 let builder = BacktestDataConfig::builder().data_type($data_type);
1222 #[cfg(feature = "streaming")]
1223 let builder = builder.catalog(DataCatalogConfig::new($catalog_path, None, None));
1224 builder
1225 }};
1226 }
1227
1228 #[rstest]
1229 #[case(NautilusDataType::OrderBookDelta)]
1230 #[case(NautilusDataType::OrderBookDepth)]
1231 #[case(NautilusDataType::QuoteTick)]
1232 #[case(NautilusDataType::TradeTick)]
1233 #[case(NautilusDataType::Bar)]
1234 #[case(NautilusDataType::MarkPriceUpdate)]
1235 #[case(NautilusDataType::IndexPriceUpdate)]
1236 #[case(NautilusDataType::FundingRateUpdate)]
1237 #[case(NautilusDataType::OptionGreeks)]
1238 #[case(NautilusDataType::InstrumentStatus)]
1239 #[case(NautilusDataType::InstrumentClose)]
1240 fn test_data_config_accepts_supported_family(#[case] data_type: NautilusDataType) {
1241 let config = data_builder!(data_type.clone(), "/tmp/catalog".to_string())
1242 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1243 .build()
1244 .unwrap();
1245
1246 assert_eq!(config.data_type(), &data_type);
1247 }
1248
1249 #[rstest]
1250 fn test_data_config_accepts_the_instrument_family() {
1251 let config = data_builder!(NautilusDataType::Instrument, "/tmp/catalog".to_string())
1252 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1253 .build()
1254 .unwrap();
1255
1256 assert_eq!(config.data_type(), &NautilusDataType::Instrument);
1257 }
1258
1259 #[rstest]
1260 #[case(NautilusDataType::Custom { type_name: "Signal".to_string() })]
1261 fn test_data_config_rejects_unsupported_family(#[case] data_type: NautilusDataType) {
1262 let error = data_builder!(data_type.clone(), "/tmp/catalog".to_string())
1263 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1264 .build()
1265 .unwrap_err();
1266
1267 assert_eq!(
1268 error,
1269 ConfigError::unsupported_value(
1270 "data_type",
1271 format!("{data_type} is not supported by BacktestDataConfig"),
1272 ),
1273 );
1274 }
1275
1276 macro_rules! minimal_simulated_builder {
1277 () => {
1278 SimulatedVenueConfig::builder()
1279 .venue(Venue::from("SIM"))
1280 .oms_type(OmsType::Netting)
1281 .account_type(AccountType::Margin)
1282 .book_type(BookType::L1_MBP)
1283 .starting_balances(vec![Money::from("1_000_000 USD")])
1284 .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1285 };
1286 }
1287
1288 #[rstest]
1289 fn test_minimal_config_is_valid() {
1290 assert!(minimal_builder!().build().is_ok());
1291 }
1292
1293 #[rstest]
1294 fn test_default_leverage_is_optional() {
1295 let config = minimal_builder!().build().unwrap();
1296
1297 assert_eq!(config.default_leverage(), None);
1298 }
1299
1300 #[rstest]
1301 fn test_empty_name_rejected() {
1302 let result = BacktestVenueConfig::builder()
1303 .name("")
1304 .oms_type(OmsType::Netting)
1305 .account_type(AccountType::Margin)
1306 .book_type(BookType::L1_MBP)
1307 .build();
1308 assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "name"));
1309 }
1310
1311 #[rstest]
1312 #[case(" ")]
1313 #[case("vénue")]
1314 fn test_invalid_venue_name_rejected(#[case] name: &str) {
1315 let result = BacktestVenueConfig::builder()
1316 .name(name)
1317 .oms_type(OmsType::Netting)
1318 .account_type(AccountType::Margin)
1319 .book_type(BookType::L1_MBP)
1320 .build();
1321 assert!(matches!(result, Err(ConfigError::InvalidValue { field, .. }) if field == "name"));
1322 }
1323
1324 #[rstest]
1325 #[case(Decimal::ZERO)]
1326 #[case(Decimal::from(-1))]
1327 fn test_non_positive_default_leverage_rejected(#[case] leverage: Decimal) {
1328 let result = minimal_builder!().default_leverage(leverage).build();
1329 assert!(
1330 matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1331 );
1332 }
1333
1334 #[rstest]
1335 fn test_non_positive_instrument_leverage_rejected() {
1336 let mut leverages = AHashMap::new();
1337 leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
1338 let result = minimal_builder!().leverages(leverages).build();
1339 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1340 }
1341
1342 #[rstest]
1343 #[case(Decimal::ZERO)]
1344 #[case(Decimal::from(-1))]
1345 fn test_simulated_non_positive_instrument_leverage_rejected(#[case] leverage: Decimal) {
1346 let mut leverages = AHashMap::new();
1347 leverages.insert(InstrumentId::from("ESZ21.GLBX"), leverage);
1348 let result = minimal_simulated_builder!().leverages(leverages).build();
1349 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1350 }
1351
1352 #[rstest]
1353 fn test_simulated_positive_instrument_leverage_accepted() {
1354 let mut leverages = AHashMap::new();
1355 leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::from(10));
1356 let result = minimal_simulated_builder!().leverages(leverages).build();
1357 assert!(result.is_ok());
1358 }
1359
1360 #[rstest]
1361 #[case(0.0)]
1362 #[case(-1.0)]
1363 #[case(f64::INFINITY)]
1364 #[case(f64::NAN)]
1365 fn test_invalid_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1366 let result = minimal_builder!().liquidation_trigger_ratio(ratio).build();
1367 assert!(
1368 matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1369 );
1370 }
1371
1372 #[rstest]
1373 fn test_unparsable_starting_balance_rejected() {
1374 let result = minimal_builder!()
1375 .starting_balances(vec!["not a balance".to_string()])
1376 .build();
1377 assert!(
1378 matches!(result, Err(ConfigError::InvalidFormat { field, .. }) if field == "starting_balances")
1379 );
1380 }
1381
1382 #[rstest]
1383 fn test_valid_starting_balance_accepted() {
1384 let result = minimal_builder!()
1385 .starting_balances(vec!["1_000_000 USD".to_string()])
1386 .build();
1387 assert!(result.is_ok());
1388 }
1389
1390 #[rstest]
1391 fn test_multiple_violations_collected() {
1392 let result = BacktestVenueConfig::builder()
1393 .name("")
1394 .oms_type(OmsType::Netting)
1395 .account_type(AccountType::Margin)
1396 .book_type(BookType::L1_MBP)
1397 .default_leverage(Decimal::ZERO)
1398 .starting_balances(vec!["bad".to_string()])
1399 .build();
1400 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1401 panic!("expected ConfigError::Multiple");
1402 };
1403 assert_eq!(errors.len(), 3);
1404 assert!(
1405 errors
1406 .iter()
1407 .any(|e| matches!(e, ConfigError::EmptyField { field } if field == "name"))
1408 );
1409 assert!(
1410 errors.iter().any(
1411 |e| matches!(e, ConfigError::Range { field, .. } if field == "default_leverage")
1412 )
1413 );
1414 assert!(errors.iter().any(
1415 |e| matches!(e, ConfigError::InvalidFormat { field, .. } if field == "starting_balances")
1416 ));
1417 }
1418
1419 #[rstest]
1420 fn test_minimal_data_config_is_valid() {
1421 let result = data_builder!(NautilusDataType::QuoteTick, "/tmp/catalog".to_string())
1422 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1423 .build();
1424 assert!(result.is_ok());
1425 }
1426
1427 #[cfg(feature = "streaming")]
1428 #[rstest]
1429 #[case("")]
1430 #[case(" ")]
1431 fn test_empty_catalog_path_rejected(#[case] catalog_path: &str) {
1432 let result = data_builder!(NautilusDataType::QuoteTick, catalog_path.to_string())
1433 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1434 .build();
1435 assert!(
1436 matches!(result, Err(ConfigError::EmptyField { field }) if field == "catalog.path")
1437 );
1438 }
1439
1440 #[rstest]
1441 fn test_inverted_time_range_rejected() {
1442 let result = data_builder!(NautilusDataType::QuoteTick, "/tmp/catalog".to_string())
1443 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1444 .start_time(UnixNanos::from(5_000_000_000u64))
1445 .end_time(UnixNanos::from(1_000_000_000u64))
1446 .build();
1447 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start_time"));
1448 }
1449
1450 #[rstest]
1451 fn test_equal_time_range_accepted() {
1452 let result = data_builder!(NautilusDataType::QuoteTick, "/tmp/catalog".to_string())
1453 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1454 .start_time(UnixNanos::from(1_000_000_000u64))
1455 .end_time(UnixNanos::from(1_000_000_000u64))
1456 .build();
1457 assert!(result.is_ok());
1458 }
1459
1460 #[rstest]
1461 fn test_missing_identifier_rejected() {
1462 let result = data_builder!(NautilusDataType::QuoteTick, "/tmp/catalog".to_string()).build();
1463 assert!(matches!(result, Err(ConfigError::RequiredOneOf { fields }) if fields.len() == 3));
1464 }
1465
1466 #[rstest]
1467 fn test_empty_instrument_ids_rejected() {
1468 let result = data_builder!(NautilusDataType::QuoteTick, "/tmp/catalog".to_string())
1469 .instrument_ids(vec![])
1470 .build();
1471 assert!(matches!(result, Err(ConfigError::RequiredOneOf { .. })));
1472 }
1473
1474 #[rstest]
1475 fn test_bar_types_satisfies_identifier_requirement() {
1476 let result = data_builder!(NautilusDataType::Bar, "/tmp/catalog".to_string())
1477 .bar_types(vec!["ETH/USDT.BINANCE-1-MINUTE-LAST-EXTERNAL".to_string()])
1478 .build();
1479 assert!(result.is_ok());
1480 }
1481
1482 #[rstest]
1483 fn test_data_config_multiple_violations_collected() {
1484 let result = data_builder!(NautilusDataType::QuoteTick, String::new())
1485 .start_time(UnixNanos::from(5_000_000_000u64))
1486 .end_time(UnixNanos::from(1_000_000_000u64))
1487 .build();
1488 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1489 panic!("expected ConfigError::Multiple");
1490 };
1491
1492 assert_eq!(errors.len(), 2 + usize::from(cfg!(feature = "streaming")));
1493 }
1494
1495 #[cfg(feature = "streaming")]
1496 #[rstest]
1497 #[case::local("/data/catalog", "file", "file:///data/catalog")]
1498 #[case::object_store("bucket/history", "s3", "s3://bucket/history")]
1499 fn test_data_config_preserves_catalog_connection_settings(
1500 #[case] path: &str,
1501 #[case] protocol: &str,
1502 #[case] uri: &str,
1503 ) {
1504 let options =
1505 AHashMap::from([("region".to_string(), SecretString::from("ap-southeast-2"))]);
1506
1507 let params = Params::new(indexmap::IndexMap::from([(
1508 "snapshot_id".to_string(),
1509 serde_json::json!(7),
1510 )]));
1511 let catalog = DataCatalogConfig::builder()
1512 .path(path.to_string())
1513 .name("history".to_string())
1514 .fs_protocol(protocol.to_string())
1515 .catalog_backend(CatalogBackendType::External("Research".to_string()))
1516 .batch_size(512)
1517 .compression(CatalogCompression::Snappy)
1518 .max_row_group_size(2048)
1519 .params(params.clone())
1520 .fs_rust_storage_options(options.clone())
1521 .read_only(true)
1522 .build()
1523 .unwrap();
1524 let config = BacktestDataConfig::builder()
1525 .data_type(NautilusDataType::QuoteTick)
1526 .catalog(catalog.clone())
1527 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1528 .build()
1529 .unwrap();
1530 let connect = config.catalog().connect_config();
1531
1532 assert_eq!(config.catalog(), &catalog);
1533 assert_eq!(config.catalog().fs_rust_storage_options(), Some(&options));
1534 assert_eq!(connect.uri, uri);
1535 assert_eq!(
1536 connect.storage_options,
1537 Some(AHashMap::from([(
1538 "region".to_string(),
1539 "ap-southeast-2".to_string()
1540 )]))
1541 );
1542 assert_eq!(connect.batch_size, Some(512));
1543 assert_eq!(connect.compression, Some(CatalogCompression::Snappy));
1544 assert_eq!(connect.max_row_group_size, Some(2048));
1545 assert_eq!(connect.params, Some(params));
1546 }
1547
1548 #[cfg(feature = "streaming")]
1549 #[rstest]
1550 #[case::batch_size("batch_size")]
1551 #[case::row_group("max_row_group_size")]
1552 fn test_data_config_validates_deserialized_catalog(#[case] field: &str) {
1553 let catalog: DataCatalogConfig = serde_json::from_value(serde_json::json!({
1554 "path": "/data/catalog",
1555 (field): 0,
1556 }))
1557 .unwrap();
1558 let error = BacktestDataConfig::builder()
1559 .data_type(NautilusDataType::QuoteTick)
1560 .catalog(catalog)
1561 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1562 .build()
1563 .unwrap_err();
1564
1565 assert_eq!(
1566 error,
1567 ConfigError::range(
1568 field,
1569 "must be a positive number of rows; omit the field for the backend default",
1570 )
1571 );
1572 }
1573
1574 #[cfg(feature = "streaming")]
1575 #[rstest]
1576 fn test_data_config_debug_redacts_storage_option_values() {
1577 let catalog = DataCatalogConfig::builder()
1578 .path("/tmp/catalog".to_string())
1579 .fs_rust_storage_options(AHashMap::from([(
1580 "aws_secret_access_key".to_string(),
1581 SecretString::from("rust-secret-sentinel"),
1582 )]))
1583 .build()
1584 .unwrap();
1585 let config = BacktestDataConfig::builder()
1586 .data_type(NautilusDataType::QuoteTick)
1587 .catalog(catalog)
1588 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1589 .build()
1590 .unwrap();
1591
1592 let debug_output = format!("{config:?}");
1593
1594 assert!(!debug_output.contains("rust-secret-sentinel"));
1595 assert!(debug_output.contains(&format!(
1596 "fs_rust_storage_options: Some({{\"aws_secret_access_key\": {REDACTED}}})"
1597 )));
1598 }
1599
1600 macro_rules! minimal_sim_builder {
1601 () => {
1602 SimulatedVenueConfig::builder()
1603 .venue(Venue::from("SIM"))
1604 .oms_type(OmsType::Netting)
1605 .account_type(AccountType::Margin)
1606 .book_type(BookType::L1_MBP)
1607 .starting_balances(vec![Money::from("1_000_000 USD")])
1608 .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1609 };
1610 }
1611
1612 #[rstest]
1613 fn test_minimal_sim_config_is_valid() {
1614 let config = minimal_sim_builder!().build().unwrap();
1615 assert!(config.defer_option_settlement);
1616 }
1617
1618 #[rstest]
1619 fn test_empty_starting_balances_rejected() {
1620 let result = SimulatedVenueConfig::builder()
1621 .venue(Venue::from("SIM"))
1622 .oms_type(OmsType::Netting)
1623 .account_type(AccountType::Margin)
1624 .book_type(BookType::L1_MBP)
1625 .starting_balances(vec![])
1626 .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1627 .build();
1628 assert!(
1629 matches!(result, Err(ConfigError::EmptyField { field }) if field == "starting_balances")
1630 );
1631 }
1632
1633 #[rstest]
1634 #[case(Decimal::ZERO)]
1635 #[case(Decimal::from(-1))]
1636 fn test_non_positive_sim_default_leverage_rejected(#[case] leverage: Decimal) {
1637 let result = minimal_sim_builder!().default_leverage(leverage).build();
1638 assert!(
1639 matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1640 );
1641 }
1642
1643 #[rstest]
1644 fn test_positive_sim_default_leverage_accepted() {
1645 assert!(
1646 minimal_sim_builder!()
1647 .default_leverage(Decimal::from(5))
1648 .build()
1649 .is_ok()
1650 );
1651 }
1652
1653 #[rstest]
1654 #[case(0.0)]
1655 #[case(-1.0)]
1656 #[case(f64::INFINITY)]
1657 #[case(f64::NAN)]
1658 fn test_invalid_sim_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1659 let result = minimal_sim_builder!()
1660 .liquidation_trigger_ratio(ratio)
1661 .build();
1662 assert!(
1663 matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1664 );
1665 }
1666
1667 fn minimal_venue() -> BacktestVenueConfig {
1668 minimal_builder!().build().unwrap()
1669 }
1670
1671 #[rstest]
1672 fn test_minimal_run_config_is_valid() {
1673 let result = BacktestRunConfig::builder()
1674 .venues(vec![minimal_venue()])
1675 .data(vec![])
1676 .build();
1677 assert!(result.is_ok());
1678 }
1679
1680 #[rstest]
1681 fn test_run_config_requires_venues() {
1682 let result = BacktestRunConfig::builder()
1683 .venues(vec![])
1684 .data(vec![])
1685 .build();
1686 assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "venues"));
1687 }
1688
1689 #[rstest]
1690 fn test_run_config_inverted_time_range_rejected() {
1691 let result = BacktestRunConfig::builder()
1692 .venues(vec![minimal_venue()])
1693 .data(vec![])
1694 .start(UnixNanos::from(5_000_000_000u64))
1695 .end(UnixNanos::from(1_000_000_000u64))
1696 .build();
1697 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start"));
1698 }
1699
1700 #[rstest]
1701 fn test_run_config_equal_time_range_accepted() {
1702 let result = BacktestRunConfig::builder()
1703 .venues(vec![minimal_venue()])
1704 .data(vec![])
1705 .start(UnixNanos::from(1_000_000_000u64))
1706 .end(UnixNanos::from(1_000_000_000u64))
1707 .build();
1708 assert!(result.is_ok());
1709 }
1710
1711 #[rstest]
1712 fn test_run_config_accepts_chunk_size() {
1713 let config = BacktestRunConfig::builder()
1714 .venues(vec![minimal_venue()])
1715 .data(vec![])
1716 .chunk_size(10)
1717 .build()
1718 .unwrap();
1719 assert_eq!(config.chunk_size(), Some(10));
1720 }
1721
1722 #[rstest]
1723 fn test_run_config_accepts_maximum_chunk_size() {
1724 let config = BacktestRunConfig::builder()
1725 .venues(vec![minimal_venue()])
1726 .data(vec![])
1727 .chunk_size(MAX_BACKTEST_CHUNK_SIZE)
1728 .build()
1729 .unwrap();
1730
1731 assert_eq!(config.chunk_size(), Some(MAX_BACKTEST_CHUNK_SIZE));
1732 }
1733
1734 #[rstest]
1735 fn test_run_config_zero_chunk_size_rejected() {
1736 let result = BacktestRunConfig::builder()
1737 .venues(vec![minimal_venue()])
1738 .data(vec![])
1739 .chunk_size(0)
1740 .build();
1741 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1742 }
1743
1744 #[rstest]
1745 #[case(MAX_BACKTEST_CHUNK_SIZE + 1)]
1746 #[case(usize::MAX)]
1747 fn test_run_config_rejects_oversized_chunk_size(#[case] chunk_size: usize) {
1748 let result = BacktestRunConfig::builder()
1749 .venues(vec![minimal_venue()])
1750 .data(vec![])
1751 .chunk_size(chunk_size)
1752 .build();
1753
1754 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1755 }
1756
1757 #[rstest]
1758 fn test_run_config_multiple_violations_collected() {
1759 let result = BacktestRunConfig::builder()
1760 .venues(vec![])
1761 .data(vec![])
1762 .start(UnixNanos::from(5_000_000_000u64))
1763 .end(UnixNanos::from(1_000_000_000u64))
1764 .build();
1765 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1766 panic!("expected ConfigError::Multiple");
1767 };
1768 assert_eq!(errors.len(), 2);
1769 }
1770}