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