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,
34 fill::FillModelAny,
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 rust_decimal::Decimal;
49use ustr::Ustr;
50
51use crate::modules::{SimulationModule, SimulationModuleAny};
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum NautilusDataType {
56 QuoteTick,
57 TradeTick,
58 Bar,
59 OrderBookDelta,
60 OrderBookDepth10,
61 MarkPriceUpdate,
62 IndexPriceUpdate,
63 FundingRateUpdate,
64 InstrumentStatus,
65 OptionGreeks,
66 InstrumentClose,
67}
68
69impl Display for NautilusDataType {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 std::fmt::Debug::fmt(self, f)
72 }
73}
74
75impl FromStr for NautilusDataType {
76 type Err = anyhow::Error;
77
78 fn from_str(s: &str) -> anyhow::Result<Self> {
79 match s {
80 stringify!(QuoteTick) => Ok(Self::QuoteTick),
81 stringify!(TradeTick) => Ok(Self::TradeTick),
82 stringify!(Bar) => Ok(Self::Bar),
83 stringify!(OrderBookDelta) => Ok(Self::OrderBookDelta),
84 stringify!(OrderBookDepth10) => Ok(Self::OrderBookDepth10),
85 stringify!(MarkPriceUpdate) => Ok(Self::MarkPriceUpdate),
86 stringify!(IndexPriceUpdate) => Ok(Self::IndexPriceUpdate),
87 stringify!(FundingRateUpdate) => Ok(Self::FundingRateUpdate),
88 stringify!(InstrumentStatus) => Ok(Self::InstrumentStatus),
89 stringify!(OptionGreeks) => Ok(Self::OptionGreeks),
90 stringify!(InstrumentClose) => Ok(Self::InstrumentClose),
91 _ => anyhow::bail!("Invalid `NautilusDataType`: '{s}'"),
92 }
93 }
94}
95
96#[cfg_attr(
98 feature = "python",
99 pyo3::pyclass(
100 module = "nautilus_trader.core.nautilus_pyo3.backtest",
101 from_py_object,
102 unsendable
103 )
104)]
105#[cfg_attr(
106 feature = "python",
107 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
108)]
109#[expect(
110 clippy::struct_excessive_bools,
111 reason = "config fields mirror the existing Rust and Python backtest engine surfaces"
112)]
113#[derive(Debug, Clone, bon::Builder)]
114pub struct BacktestEngineConfig {
115 #[builder(default = Environment::Backtest)]
117 pub environment: Environment,
118 #[builder(default)]
120 pub trader_id: TraderId,
121 #[builder(default)]
123 pub load_state: bool,
124 #[builder(default)]
126 pub save_state: bool,
127 #[builder(default)]
131 pub shutdown_on_error: bool,
132 #[builder(default)]
134 pub logging: LoggerConfig,
135 pub instance_id: Option<UUID4>,
137 #[builder(default = Duration::from_mins(1))]
139 pub timeout_connection: Duration,
140 #[builder(default = Duration::from_secs(30))]
142 pub timeout_reconciliation: Duration,
143 #[builder(default = Duration::from_secs(10))]
145 pub timeout_portfolio: Duration,
146 #[builder(default = Duration::from_secs(10))]
148 pub timeout_disconnection: Duration,
149 #[builder(default = Duration::from_secs(10))]
151 pub delay_post_stop: Duration,
152 #[builder(default = Duration::from_secs(5))]
154 pub timeout_shutdown: Duration,
155 pub cache: Option<CacheConfig>,
161 pub msgbus: Option<MessageBusConfig>,
163 pub data_engine: Option<DataEngineConfig>,
165 pub risk_engine: Option<RiskEngineConfig>,
167 pub exec_engine: Option<ExecutionEngineConfig>,
169 pub portfolio: Option<PortfolioConfig>,
171 pub streaming: Option<StreamingConfig>,
173 #[builder(default)]
175 pub bypass_logging: bool,
176 #[builder(default = true)]
178 pub run_analysis: bool,
179}
180
181impl NautilusKernelConfig for BacktestEngineConfig {
182 fn environment(&self) -> Environment {
183 self.environment
184 }
185
186 fn trader_id(&self) -> TraderId {
187 self.trader_id
188 }
189
190 fn load_state(&self) -> bool {
191 self.load_state
192 }
193
194 fn save_state(&self) -> bool {
195 self.save_state
196 }
197
198 fn shutdown_on_error(&self) -> bool {
199 self.shutdown_on_error
200 }
201
202 fn logging(&self) -> LoggerConfig {
203 self.logging.clone()
204 }
205
206 fn instance_id(&self) -> Option<UUID4> {
207 self.instance_id
208 }
209
210 fn timeout_connection(&self) -> Duration {
211 self.timeout_connection
212 }
213
214 fn timeout_reconciliation(&self) -> Duration {
215 self.timeout_reconciliation
216 }
217
218 fn timeout_portfolio(&self) -> Duration {
219 self.timeout_portfolio
220 }
221
222 fn timeout_disconnection(&self) -> Duration {
223 self.timeout_disconnection
224 }
225
226 fn delay_post_stop(&self) -> Duration {
227 self.delay_post_stop
228 }
229
230 fn timeout_shutdown(&self) -> Duration {
231 self.timeout_shutdown
232 }
233
234 fn cache(&self) -> Option<CacheConfig> {
235 self.cache.clone()
236 }
237
238 fn msgbus(&self) -> Option<MessageBusConfig> {
239 self.msgbus.clone()
240 }
241
242 fn data_engine(&self) -> Option<DataEngineConfig> {
243 self.data_engine.clone()
244 }
245
246 fn risk_engine(&self) -> Option<RiskEngineConfig> {
247 self.risk_engine.clone()
248 }
249
250 fn exec_engine(&self) -> Option<ExecutionEngineConfig> {
251 self.exec_engine.clone()
252 }
253
254 fn portfolio(&self) -> Option<PortfolioConfig> {
255 self.portfolio
256 }
257
258 fn streaming(&self) -> Option<StreamingConfig> {
259 self.streaming.clone()
260 }
261}
262
263impl Default for BacktestEngineConfig {
264 fn default() -> Self {
265 Self::builder().build()
266 }
267}
268
269#[allow(missing_debug_implementations)]
278#[expect(
279 clippy::struct_excessive_bools,
280 reason = "venue config fields mirror the existing imperative backtest API"
281)]
282#[derive(bon::Builder)]
283#[builder(finish_fn(name = build_inner, vis = ""))]
284pub struct SimulatedVenueConfig {
285 pub venue: Venue,
286 pub oms_type: OmsType,
287 pub account_type: AccountType,
288 pub book_type: BookType,
289 pub starting_balances: Vec<Money>,
290 pub base_currency: Option<Currency>,
291 pub default_leverage: Option<Decimal>,
294 #[builder(default)]
295 pub leverages: AHashMap<InstrumentId, Decimal>,
296 pub margin_model: Option<MarginModelAny>,
297 #[builder(default)]
298 pub modules: Vec<Box<dyn SimulationModule>>,
299 #[builder(default)]
300 pub fill_model: FillModelAny,
301 #[builder(default)]
302 pub fee_model: FeeModelAny,
303 pub latency_model: Option<Box<dyn LatencyModel>>,
304 #[builder(default = false)]
305 pub routing: bool,
306 #[builder(default = true)]
307 pub reject_stop_orders: bool,
308 #[builder(default = true)]
309 pub support_gtd_orders: bool,
310 #[builder(default = true)]
311 pub support_contingent_orders: bool,
312 #[builder(default = true)]
313 pub use_position_ids: bool,
314 #[builder(default = false)]
315 pub use_random_ids: bool,
316 #[builder(default = true)]
317 pub use_reduce_only: bool,
318 #[builder(default = true)]
319 pub use_message_queue: bool,
320 #[builder(default = false)]
321 pub use_market_order_acks: bool,
322 #[builder(default = true)]
323 pub bar_execution: bool,
324 #[builder(default = false)]
325 pub bar_adaptive_high_low_ordering: bool,
326 #[builder(default = true)]
327 pub trade_execution: bool,
328 #[builder(default = false)]
329 pub liquidity_consumption: bool,
330 #[builder(default = false)]
331 pub allow_cash_borrowing: bool,
332 #[builder(default = false)]
333 pub frozen_account: bool,
334 #[builder(default = false)]
335 pub queue_position: bool,
336 #[builder(default = false)]
337 pub oto_full_trigger: bool,
338 #[builder(default = 0)]
339 pub price_protection_points: u32,
340 #[builder(default)]
342 pub settlement_prices: AHashMap<InstrumentId, Price>,
343 #[builder(default = false)]
345 pub liquidation_enabled: bool,
346 #[builder(default = 1.0)]
349 pub liquidation_trigger_ratio: f64,
350 #[builder(default = true)]
352 pub liquidation_cancel_open_orders: bool,
353}
354
355impl<S: simulated_venue_config_builder::IsComplete> SimulatedVenueConfigBuilder<S> {
356 pub fn build(self) -> ConfigResult<SimulatedVenueConfig> {
363 let config = self.build_inner();
364 config.validate()?;
365 Ok(config)
366 }
367}
368
369impl SimulatedVenueConfig {
370 pub fn validate(&self) -> ConfigResult<()> {
377 let mut errors = ConfigErrorCollector::new();
378
379 if self.starting_balances.is_empty() {
380 errors.push(ConfigError::empty_field("starting_balances"));
381 }
382
383 if let Some(default_leverage) = self.default_leverage {
384 errors.check(
385 default_leverage > Decimal::ZERO,
386 ConfigError::range(
387 "default_leverage",
388 format!("must be positive, was {default_leverage}"),
389 ),
390 );
391 }
392
393 errors.check(
394 self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
395 ConfigError::range(
396 "liquidation_trigger_ratio",
397 format!(
398 "must be a positive finite value, was {}",
399 self.liquidation_trigger_ratio
400 ),
401 ),
402 );
403
404 errors.into_result()
405 }
406}
407
408#[cfg_attr(
410 feature = "python",
411 pyo3::pyclass(
412 module = "nautilus_trader.core.nautilus_pyo3.backtest",
413 from_py_object,
414 unsendable
415 )
416)]
417#[cfg_attr(
418 feature = "python",
419 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
420)]
421#[expect(
422 clippy::struct_excessive_bools,
423 reason = "venue config fields mirror the existing Rust and Python backtest surfaces"
424)]
425#[derive(Debug, Clone, bon::Builder)]
426#[builder(finish_fn(name = build_inner, vis = ""))]
427pub struct BacktestVenueConfig {
428 #[builder(into)]
430 name: Ustr,
431 oms_type: OmsType,
433 account_type: AccountType,
435 book_type: BookType,
437 #[builder(default)]
439 starting_balances: Vec<String>,
440 #[builder(default)]
442 routing: bool,
443 #[builder(default)]
445 frozen_account: bool,
446 #[builder(default = true)]
448 reject_stop_orders: bool,
449 #[builder(default = true)]
451 support_gtd_orders: bool,
452 #[builder(default = true)]
455 support_contingent_orders: bool,
456 #[builder(default = true)]
458 use_position_ids: bool,
459 #[builder(default)]
462 use_random_ids: bool,
463 #[builder(default = true)]
465 use_reduce_only: bool,
466 #[builder(default = true)]
468 bar_execution: bool,
469 #[builder(default)]
476 bar_adaptive_high_low_ordering: bool,
477 #[builder(default = true)]
479 trade_execution: bool,
480 #[builder(default)]
482 use_market_order_acks: bool,
483 #[builder(default)]
485 liquidity_consumption: bool,
486 #[builder(default)]
488 allow_cash_borrowing: bool,
489 #[builder(default)]
491 queue_position: bool,
492 #[builder(default)]
494 oto_trigger_mode: OtoTriggerMode,
495 base_currency: Option<Currency>,
497 #[builder(default = Decimal::ONE)]
499 default_leverage: Decimal,
500 leverages: Option<AHashMap<InstrumentId, Decimal>>,
502 margin_model: Option<MarginModelAny>,
504 #[builder(default)]
506 modules: Vec<SimulationModuleAny>,
507 fill_model: Option<FillModelAny>,
509 latency_model: Option<LatencyModelAny>,
511 fee_model: Option<FeeModelAny>,
513 #[builder(default)]
516 price_protection_points: u32,
517 settlement_prices: Option<AHashMap<InstrumentId, f64>>,
519 #[builder(default)]
521 liquidation_enabled: bool,
522 #[builder(default = 1.0)]
525 liquidation_trigger_ratio: f64,
526 #[builder(default = true)]
528 liquidation_cancel_open_orders: bool,
529}
530
531impl<S: backtest_venue_config_builder::IsComplete> BacktestVenueConfigBuilder<S> {
532 pub fn build(self) -> ConfigResult<BacktestVenueConfig> {
539 let config = self.build_inner();
540 config.validate()?;
541 Ok(config)
542 }
543}
544
545impl BacktestVenueConfig {
546 pub fn validate(&self) -> ConfigResult<()> {
553 let mut errors = ConfigErrorCollector::new();
554
555 if self.name.is_empty() {
556 errors.push(ConfigError::empty_field("name"));
557 } else if let Err(e) = Venue::new_checked(self.name.as_str()) {
558 errors.push(ConfigError::invalid_value(
559 "name",
560 format!("must be a valid venue identifier ({e})"),
561 ));
562 }
563 errors.check(
564 self.default_leverage > Decimal::ZERO,
565 ConfigError::range(
566 "default_leverage",
567 format!("must be positive, was {}", self.default_leverage),
568 ),
569 );
570
571 if let Some(leverages) = &self.leverages {
572 for (instrument_id, leverage) in leverages {
573 errors.check(
574 *leverage > Decimal::ZERO,
575 ConfigError::range(
576 "leverages",
577 format!("leverage for {instrument_id} must be positive, was {leverage}"),
578 ),
579 );
580 }
581 }
582 errors.check(
583 self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
584 ConfigError::range(
585 "liquidation_trigger_ratio",
586 format!(
587 "must be a positive finite value, was {}",
588 self.liquidation_trigger_ratio
589 ),
590 ),
591 );
592
593 for balance in &self.starting_balances {
594 if let Err(reason) = balance.parse::<Money>() {
595 errors.push(ConfigError::invalid_format(
596 "starting_balances",
597 format!("a valid money string, was '{balance}' ({reason})"),
598 ));
599 }
600 }
601
602 errors.into_result()
603 }
604
605 #[must_use]
606 pub fn name(&self) -> Ustr {
607 self.name
608 }
609
610 #[must_use]
611 pub fn oms_type(&self) -> OmsType {
612 self.oms_type
613 }
614
615 #[must_use]
616 pub fn account_type(&self) -> AccountType {
617 self.account_type
618 }
619
620 #[must_use]
621 pub fn book_type(&self) -> BookType {
622 self.book_type
623 }
624
625 #[must_use]
626 pub fn starting_balances(&self) -> &[String] {
627 &self.starting_balances
628 }
629
630 #[must_use]
631 pub fn routing(&self) -> bool {
632 self.routing
633 }
634
635 #[must_use]
636 pub fn frozen_account(&self) -> bool {
637 self.frozen_account
638 }
639
640 #[must_use]
641 pub fn reject_stop_orders(&self) -> bool {
642 self.reject_stop_orders
643 }
644
645 #[must_use]
646 pub fn support_gtd_orders(&self) -> bool {
647 self.support_gtd_orders
648 }
649
650 #[must_use]
651 pub fn support_contingent_orders(&self) -> bool {
652 self.support_contingent_orders
653 }
654
655 #[must_use]
656 pub fn use_position_ids(&self) -> bool {
657 self.use_position_ids
658 }
659
660 #[must_use]
661 pub fn use_random_ids(&self) -> bool {
662 self.use_random_ids
663 }
664
665 #[must_use]
666 pub fn use_reduce_only(&self) -> bool {
667 self.use_reduce_only
668 }
669
670 #[must_use]
671 pub fn bar_execution(&self) -> bool {
672 self.bar_execution
673 }
674
675 #[must_use]
676 pub fn bar_adaptive_high_low_ordering(&self) -> bool {
677 self.bar_adaptive_high_low_ordering
678 }
679
680 #[must_use]
681 pub fn trade_execution(&self) -> bool {
682 self.trade_execution
683 }
684
685 #[must_use]
686 pub fn use_market_order_acks(&self) -> bool {
687 self.use_market_order_acks
688 }
689
690 #[must_use]
691 pub fn liquidity_consumption(&self) -> bool {
692 self.liquidity_consumption
693 }
694
695 #[must_use]
696 pub fn allow_cash_borrowing(&self) -> bool {
697 self.allow_cash_borrowing
698 }
699
700 #[must_use]
701 pub fn queue_position(&self) -> bool {
702 self.queue_position
703 }
704
705 #[must_use]
706 pub fn oto_trigger_mode(&self) -> OtoTriggerMode {
707 self.oto_trigger_mode
708 }
709
710 #[must_use]
711 pub fn base_currency(&self) -> Option<Currency> {
712 self.base_currency
713 }
714
715 #[must_use]
716 pub fn default_leverage(&self) -> Decimal {
717 self.default_leverage
718 }
719
720 #[must_use]
721 pub fn leverages(&self) -> Option<&AHashMap<InstrumentId, Decimal>> {
722 self.leverages.as_ref()
723 }
724
725 #[must_use]
726 pub fn margin_model(&self) -> Option<&MarginModelAny> {
727 self.margin_model.as_ref()
728 }
729
730 #[must_use]
731 pub fn modules(&self) -> &[SimulationModuleAny] {
732 &self.modules
733 }
734
735 #[must_use]
736 pub fn fill_model(&self) -> Option<&FillModelAny> {
737 self.fill_model.as_ref()
738 }
739
740 #[must_use]
741 pub fn latency_model(&self) -> Option<&LatencyModelAny> {
742 self.latency_model.as_ref()
743 }
744
745 #[must_use]
746 pub fn fee_model(&self) -> Option<&FeeModelAny> {
747 self.fee_model.as_ref()
748 }
749
750 #[must_use]
751 pub fn price_protection_points(&self) -> u32 {
752 self.price_protection_points
753 }
754
755 #[must_use]
756 pub fn settlement_prices(&self) -> Option<&AHashMap<InstrumentId, f64>> {
757 self.settlement_prices.as_ref()
758 }
759
760 #[must_use]
761 pub fn liquidation_enabled(&self) -> bool {
762 self.liquidation_enabled
763 }
764
765 #[must_use]
766 pub fn liquidation_trigger_ratio(&self) -> f64 {
767 self.liquidation_trigger_ratio
768 }
769
770 #[must_use]
771 pub fn liquidation_cancel_open_orders(&self) -> bool {
772 self.liquidation_cancel_open_orders
773 }
774}
775
776#[derive(Debug, Clone, bon::Builder)]
778#[builder(finish_fn(name = build_inner, vis = ""))]
779#[cfg_attr(
780 feature = "python",
781 pyo3::pyclass(
782 module = "nautilus_trader.core.nautilus_pyo3.backtest",
783 from_py_object,
784 unsendable
785 )
786)]
787#[cfg_attr(
788 feature = "python",
789 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
790)]
791pub struct BacktestDataConfig {
792 data_type: NautilusDataType,
794 catalog_path: String,
796 catalog_fs_protocol: Option<String>,
798 catalog_fs_storage_options: Option<AHashMap<String, String>>,
800 catalog_fs_rust_storage_options: Option<AHashMap<String, String>>,
802 instrument_id: Option<InstrumentId>,
804 instrument_ids: Option<Vec<InstrumentId>>,
806 start_time: Option<UnixNanos>,
808 end_time: Option<UnixNanos>,
810 filter_expr: Option<String>,
812 client_id: Option<ClientId>,
814 #[allow(dead_code)]
816 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 bar_spec(&self) -> Option<BarSpecification> {
935 self.bar_spec
936 }
937
938 #[must_use]
939 pub fn bar_types(&self) -> Option<&[String]> {
940 self.bar_types.as_deref()
941 }
942
943 #[must_use]
944 pub fn optimize_file_loading(&self) -> bool {
945 self.optimize_file_loading
946 }
947
948 #[must_use]
954 pub fn query_identifiers(&self) -> Option<Vec<String>> {
955 if self.data_type == NautilusDataType::Bar {
956 if let Some(bar_types) = &self.bar_types
957 && !bar_types.is_empty()
958 {
959 return Some(bar_types.clone());
960 }
961
962 if let Some(bar_spec) = &self.bar_spec {
964 if let Some(id) = self.instrument_id {
965 return Some(vec![format!("{id}-{bar_spec}-EXTERNAL")]);
966 }
967
968 if let Some(ids) = &self.instrument_ids {
969 let bar_types: Vec<String> = ids
970 .iter()
971 .map(|id| format!("{id}-{bar_spec}-EXTERNAL"))
972 .collect();
973
974 if !bar_types.is_empty() {
975 return Some(bar_types);
976 }
977 }
978 }
979 }
980
981 if let Some(id) = self.instrument_id {
983 return Some(vec![id.to_string()]);
984 }
985
986 if let Some(ids) = &self.instrument_ids {
987 let strs: Vec<String> = ids.iter().map(ToString::to_string).collect();
988 if !strs.is_empty() {
989 return Some(strs);
990 }
991 }
992
993 None
994 }
995
996 pub fn get_instrument_ids(&self) -> anyhow::Result<Vec<InstrumentId>> {
1004 if let Some(id) = self.instrument_id {
1005 return Ok(vec![id]);
1006 }
1007
1008 if let Some(ids) = &self.instrument_ids {
1009 return Ok(ids.clone());
1010 }
1011
1012 if let Some(bar_types) = &self.bar_types {
1013 let ids = bar_types
1014 .iter()
1015 .map(|bt| {
1016 bt.parse::<BarType>()
1017 .map(|b| b.instrument_id())
1018 .map_err(|_| anyhow::anyhow!("Invalid bar type string: '{bt}'"))
1019 })
1020 .collect::<anyhow::Result<Vec<_>>>()?;
1021 return Ok(ids);
1022 }
1023 Ok(Vec::new())
1024 }
1025}
1026
1027#[derive(Debug, Clone, bon::Builder)]
1030#[builder(finish_fn(name = build_inner, vis = ""))]
1031#[cfg_attr(
1032 feature = "python",
1033 pyo3::pyclass(
1034 module = "nautilus_trader.core.nautilus_pyo3.backtest",
1035 from_py_object,
1036 unsendable
1037 )
1038)]
1039#[cfg_attr(
1040 feature = "python",
1041 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
1042)]
1043pub struct BacktestRunConfig {
1044 #[builder(default = UUID4::new().to_string())]
1046 id: String,
1047 venues: Vec<BacktestVenueConfig>,
1049 data: Vec<BacktestDataConfig>,
1051 #[builder(default)]
1053 engine: BacktestEngineConfig,
1054 chunk_size: Option<usize>,
1057 #[builder(default)]
1059 raise_exception: bool,
1060 #[builder(default = true)]
1064 dispose_on_completion: bool,
1065 start: Option<UnixNanos>,
1068 end: Option<UnixNanos>,
1071}
1072
1073impl<S: backtest_run_config_builder::IsComplete> BacktestRunConfigBuilder<S> {
1074 pub fn build(self) -> ConfigResult<BacktestRunConfig> {
1081 let config = self.build_inner();
1082 config.validate()?;
1083 Ok(config)
1084 }
1085}
1086
1087impl BacktestRunConfig {
1088 pub fn validate(&self) -> ConfigResult<()> {
1095 let mut errors = ConfigErrorCollector::new();
1096
1097 if self.venues.is_empty() {
1098 errors.push(ConfigError::empty_field("venues"));
1099 }
1100
1101 if let (Some(start), Some(end)) = (self.start, self.end) {
1102 errors.check(
1103 start <= end,
1104 ConfigError::range("start", format!("must be <= end, was {start} > {end}")),
1105 );
1106 }
1107
1108 if let Some(chunk_size) = self.chunk_size {
1109 errors.check(
1110 chunk_size > 0,
1111 ConfigError::range("chunk_size", format!("must be positive, was {chunk_size}")),
1112 );
1113 }
1114
1115 errors.into_result()
1116 }
1117
1118 #[must_use]
1119 pub fn id(&self) -> &str {
1120 &self.id
1121 }
1122
1123 #[must_use]
1124 pub fn venues(&self) -> &[BacktestVenueConfig] {
1125 &self.venues
1126 }
1127
1128 #[must_use]
1129 pub fn data(&self) -> &[BacktestDataConfig] {
1130 &self.data
1131 }
1132
1133 #[must_use]
1134 pub fn engine(&self) -> &BacktestEngineConfig {
1135 &self.engine
1136 }
1137
1138 #[must_use]
1139 pub fn chunk_size(&self) -> Option<usize> {
1140 self.chunk_size
1141 }
1142
1143 #[must_use]
1144 pub fn raise_exception(&self) -> bool {
1145 self.raise_exception
1146 }
1147
1148 #[must_use]
1149 pub fn dispose_on_completion(&self) -> bool {
1150 self.dispose_on_completion
1151 }
1152
1153 #[must_use]
1154 pub fn start(&self) -> Option<UnixNanos> {
1155 self.start
1156 }
1157
1158 #[must_use]
1159 pub fn end(&self) -> Option<UnixNanos> {
1160 self.end
1161 }
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use rstest::rstest;
1167
1168 use super::*;
1169
1170 macro_rules! minimal_builder {
1171 () => {
1172 BacktestVenueConfig::builder()
1173 .name("SIM")
1174 .oms_type(OmsType::Netting)
1175 .account_type(AccountType::Margin)
1176 .book_type(BookType::L1_MBP)
1177 };
1178 }
1179
1180 #[rstest]
1181 fn test_minimal_config_is_valid() {
1182 assert!(minimal_builder!().build().is_ok());
1183 }
1184
1185 #[rstest]
1186 fn test_empty_name_rejected() {
1187 let result = BacktestVenueConfig::builder()
1188 .name("")
1189 .oms_type(OmsType::Netting)
1190 .account_type(AccountType::Margin)
1191 .book_type(BookType::L1_MBP)
1192 .build();
1193 assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "name"));
1194 }
1195
1196 #[rstest]
1197 #[case(" ")]
1198 #[case("vénue")]
1199 fn test_invalid_venue_name_rejected(#[case] name: &str) {
1200 let result = BacktestVenueConfig::builder()
1201 .name(name)
1202 .oms_type(OmsType::Netting)
1203 .account_type(AccountType::Margin)
1204 .book_type(BookType::L1_MBP)
1205 .build();
1206 assert!(matches!(result, Err(ConfigError::InvalidValue { field, .. }) if field == "name"));
1207 }
1208
1209 #[rstest]
1210 #[case(Decimal::ZERO)]
1211 #[case(Decimal::from(-1))]
1212 fn test_non_positive_default_leverage_rejected(#[case] leverage: Decimal) {
1213 let result = minimal_builder!().default_leverage(leverage).build();
1214 assert!(
1215 matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1216 );
1217 }
1218
1219 #[rstest]
1220 fn test_non_positive_instrument_leverage_rejected() {
1221 let mut leverages = AHashMap::new();
1222 leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
1223 let result = minimal_builder!().leverages(leverages).build();
1224 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1225 }
1226
1227 #[rstest]
1228 #[case(0.0)]
1229 #[case(-1.0)]
1230 #[case(f64::INFINITY)]
1231 #[case(f64::NAN)]
1232 fn test_invalid_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1233 let result = minimal_builder!().liquidation_trigger_ratio(ratio).build();
1234 assert!(
1235 matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1236 );
1237 }
1238
1239 #[rstest]
1240 fn test_unparsable_starting_balance_rejected() {
1241 let result = minimal_builder!()
1242 .starting_balances(vec!["not a balance".to_string()])
1243 .build();
1244 assert!(
1245 matches!(result, Err(ConfigError::InvalidFormat { field, .. }) if field == "starting_balances")
1246 );
1247 }
1248
1249 #[rstest]
1250 fn test_valid_starting_balance_accepted() {
1251 let result = minimal_builder!()
1252 .starting_balances(vec!["1_000_000 USD".to_string()])
1253 .build();
1254 assert!(result.is_ok());
1255 }
1256
1257 #[rstest]
1258 fn test_multiple_violations_collected() {
1259 let result = BacktestVenueConfig::builder()
1260 .name("")
1261 .oms_type(OmsType::Netting)
1262 .account_type(AccountType::Margin)
1263 .book_type(BookType::L1_MBP)
1264 .default_leverage(Decimal::ZERO)
1265 .starting_balances(vec!["bad".to_string()])
1266 .build();
1267 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1268 panic!("expected ConfigError::Multiple");
1269 };
1270 assert_eq!(errors.len(), 3);
1271 assert!(
1272 errors
1273 .iter()
1274 .any(|e| matches!(e, ConfigError::EmptyField { field } if field == "name"))
1275 );
1276 assert!(
1277 errors.iter().any(
1278 |e| matches!(e, ConfigError::Range { field, .. } if field == "default_leverage")
1279 )
1280 );
1281 assert!(errors.iter().any(
1282 |e| matches!(e, ConfigError::InvalidFormat { field, .. } if field == "starting_balances")
1283 ));
1284 }
1285
1286 #[rstest]
1287 fn test_minimal_data_config_is_valid() {
1288 let result = BacktestDataConfig::builder()
1289 .data_type(NautilusDataType::QuoteTick)
1290 .catalog_path("/tmp/catalog".to_string())
1291 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1292 .build();
1293 assert!(result.is_ok());
1294 }
1295
1296 #[rstest]
1297 #[case("")]
1298 #[case(" ")]
1299 fn test_empty_catalog_path_rejected(#[case] catalog_path: &str) {
1300 let result = BacktestDataConfig::builder()
1301 .data_type(NautilusDataType::QuoteTick)
1302 .catalog_path(catalog_path.to_string())
1303 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1304 .build();
1305 assert!(
1306 matches!(result, Err(ConfigError::EmptyField { field }) if field == "catalog_path")
1307 );
1308 }
1309
1310 #[rstest]
1311 fn test_inverted_time_range_rejected() {
1312 let result = BacktestDataConfig::builder()
1313 .data_type(NautilusDataType::QuoteTick)
1314 .catalog_path("/tmp/catalog".to_string())
1315 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1316 .start_time(UnixNanos::from(5_000_000_000u64))
1317 .end_time(UnixNanos::from(1_000_000_000u64))
1318 .build();
1319 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start_time"));
1320 }
1321
1322 #[rstest]
1323 fn test_equal_time_range_accepted() {
1324 let result = BacktestDataConfig::builder()
1325 .data_type(NautilusDataType::QuoteTick)
1326 .catalog_path("/tmp/catalog".to_string())
1327 .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1328 .start_time(UnixNanos::from(1_000_000_000u64))
1329 .end_time(UnixNanos::from(1_000_000_000u64))
1330 .build();
1331 assert!(result.is_ok());
1332 }
1333
1334 #[rstest]
1335 fn test_missing_identifier_rejected() {
1336 let result = BacktestDataConfig::builder()
1337 .data_type(NautilusDataType::QuoteTick)
1338 .catalog_path("/tmp/catalog".to_string())
1339 .build();
1340 assert!(matches!(result, Err(ConfigError::RequiredOneOf { fields }) if fields.len() == 3));
1341 }
1342
1343 #[rstest]
1344 fn test_empty_instrument_ids_rejected() {
1345 let result = BacktestDataConfig::builder()
1346 .data_type(NautilusDataType::QuoteTick)
1347 .catalog_path("/tmp/catalog".to_string())
1348 .instrument_ids(vec![])
1349 .build();
1350 assert!(matches!(result, Err(ConfigError::RequiredOneOf { .. })));
1351 }
1352
1353 #[rstest]
1354 fn test_bar_types_satisfies_identifier_requirement() {
1355 let result = BacktestDataConfig::builder()
1356 .data_type(NautilusDataType::Bar)
1357 .catalog_path("/tmp/catalog".to_string())
1358 .bar_types(vec!["ETH/USDT.BINANCE-1-MINUTE-LAST-EXTERNAL".to_string()])
1359 .build();
1360 assert!(result.is_ok());
1361 }
1362
1363 #[rstest]
1364 fn test_data_config_multiple_violations_collected() {
1365 let result = BacktestDataConfig::builder()
1366 .data_type(NautilusDataType::QuoteTick)
1367 .catalog_path(String::new())
1368 .start_time(UnixNanos::from(5_000_000_000u64))
1369 .end_time(UnixNanos::from(1_000_000_000u64))
1370 .build();
1371 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1372 panic!("expected ConfigError::Multiple");
1373 };
1374 assert_eq!(errors.len(), 3);
1375 }
1376
1377 macro_rules! minimal_sim_builder {
1378 () => {
1379 SimulatedVenueConfig::builder()
1380 .venue(Venue::from("SIM"))
1381 .oms_type(OmsType::Netting)
1382 .account_type(AccountType::Margin)
1383 .book_type(BookType::L1_MBP)
1384 .starting_balances(vec![Money::from("1_000_000 USD")])
1385 };
1386 }
1387
1388 #[rstest]
1389 fn test_minimal_sim_config_is_valid() {
1390 assert!(minimal_sim_builder!().build().is_ok());
1391 }
1392
1393 #[rstest]
1394 fn test_empty_starting_balances_rejected() {
1395 let result = SimulatedVenueConfig::builder()
1396 .venue(Venue::from("SIM"))
1397 .oms_type(OmsType::Netting)
1398 .account_type(AccountType::Margin)
1399 .book_type(BookType::L1_MBP)
1400 .starting_balances(vec![])
1401 .build();
1402 assert!(
1403 matches!(result, Err(ConfigError::EmptyField { field }) if field == "starting_balances")
1404 );
1405 }
1406
1407 #[rstest]
1408 #[case(Decimal::ZERO)]
1409 #[case(Decimal::from(-1))]
1410 fn test_non_positive_sim_default_leverage_rejected(#[case] leverage: Decimal) {
1411 let result = minimal_sim_builder!().default_leverage(leverage).build();
1412 assert!(
1413 matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1414 );
1415 }
1416
1417 #[rstest]
1418 fn test_positive_sim_default_leverage_accepted() {
1419 assert!(
1420 minimal_sim_builder!()
1421 .default_leverage(Decimal::from(5))
1422 .build()
1423 .is_ok()
1424 );
1425 }
1426
1427 #[rstest]
1428 #[case(0.0)]
1429 #[case(-1.0)]
1430 #[case(f64::INFINITY)]
1431 #[case(f64::NAN)]
1432 fn test_invalid_sim_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1433 let result = minimal_sim_builder!()
1434 .liquidation_trigger_ratio(ratio)
1435 .build();
1436 assert!(
1437 matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1438 );
1439 }
1440
1441 fn minimal_venue() -> BacktestVenueConfig {
1442 minimal_builder!().build().unwrap()
1443 }
1444
1445 #[rstest]
1446 fn test_minimal_run_config_is_valid() {
1447 let result = BacktestRunConfig::builder()
1448 .venues(vec![minimal_venue()])
1449 .data(vec![])
1450 .build();
1451 assert!(result.is_ok());
1452 }
1453
1454 #[rstest]
1455 fn test_run_config_requires_venues() {
1456 let result = BacktestRunConfig::builder()
1457 .venues(vec![])
1458 .data(vec![])
1459 .build();
1460 assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "venues"));
1461 }
1462
1463 #[rstest]
1464 fn test_run_config_inverted_time_range_rejected() {
1465 let result = BacktestRunConfig::builder()
1466 .venues(vec![minimal_venue()])
1467 .data(vec![])
1468 .start(UnixNanos::from(5_000_000_000u64))
1469 .end(UnixNanos::from(1_000_000_000u64))
1470 .build();
1471 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start"));
1472 }
1473
1474 #[rstest]
1475 fn test_run_config_equal_time_range_accepted() {
1476 let result = BacktestRunConfig::builder()
1477 .venues(vec![minimal_venue()])
1478 .data(vec![])
1479 .start(UnixNanos::from(1_000_000_000u64))
1480 .end(UnixNanos::from(1_000_000_000u64))
1481 .build();
1482 assert!(result.is_ok());
1483 }
1484
1485 #[rstest]
1486 fn test_run_config_accepts_chunk_size() {
1487 let config = BacktestRunConfig::builder()
1488 .venues(vec![minimal_venue()])
1489 .data(vec![])
1490 .chunk_size(10)
1491 .build()
1492 .unwrap();
1493 assert_eq!(config.chunk_size(), Some(10));
1494 }
1495
1496 #[rstest]
1497 fn test_run_config_zero_chunk_size_rejected() {
1498 let result = BacktestRunConfig::builder()
1499 .venues(vec![minimal_venue()])
1500 .data(vec![])
1501 .chunk_size(0)
1502 .build();
1503 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1504 }
1505
1506 #[rstest]
1507 fn test_run_config_multiple_violations_collected() {
1508 let result = BacktestRunConfig::builder()
1509 .venues(vec![])
1510 .data(vec![])
1511 .start(UnixNanos::from(5_000_000_000u64))
1512 .end(UnixNanos::from(1_000_000_000u64))
1513 .build();
1514 let ConfigError::Multiple { errors } = result.unwrap_err() else {
1515 panic!("expected ConfigError::Multiple");
1516 };
1517 assert_eq!(errors.len(), 2);
1518 }
1519}