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