Skip to main content

nautilus_backtest/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Configuration types for the backtest engine, venues, data, and run parameters.
17
18use std::time::Duration;
19
20use ahash::AHashMap;
21use nautilus_common::{
22    cache::CacheConfig,
23    config::{ConfigError, ConfigErrorCollector, ConfigResult},
24    enums::Environment,
25    logging::logger::LoggerConfig,
26    msgbus::MessageBusConfig,
27};
28use nautilus_core::{UUID4, UnixNanos};
29use nautilus_data::engine::config::DataEngineConfig;
30use nautilus_execution::{
31    engine::config::ExecutionEngineConfig,
32    models::{
33        fee::{FeeModelAny, FeeModelHandle},
34        fill::{FillModelAny, FillModelHandle},
35        latency::{LatencyModelAny, LatencyModelHandle},
36    },
37};
38use nautilus_model::{
39    accounts::margin_model::{MarginModelAny, MarginModelHandle},
40    data::{BarSpecification, BarType, NautilusDataType},
41    enums::{AccountType, BookType, OmsType, OtoTriggerMode},
42    identifiers::{ClientId, InstrumentId, TraderId, Venue},
43    types::{Currency, Money},
44};
45#[cfg(feature = "streaming")]
46use nautilus_persistence::config::CatalogBackendType;
47#[cfg(feature = "streaming")]
48use nautilus_persistence::config::DataCatalogConfig;
49use nautilus_portfolio::config::PortfolioConfig;
50use nautilus_risk::engine::config::RiskEngineConfig;
51use nautilus_system::config::NautilusKernelConfig;
52#[cfg(feature = "streaming")]
53use nautilus_system::config::StreamingConfig;
54use nautilus_trading::ImportableControllerConfig;
55use rust_decimal::Decimal;
56use ustr::Ustr;
57
58use crate::modules::{SimulationModuleAny, SimulationModuleHandle};
59
60pub(crate) const MAX_BACKTEST_CHUNK_SIZE: usize = 1_000_000;
61
62/// Configuration for ``BacktestEngine`` instances.
63#[cfg_attr(
64    feature = "python",
65    pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
66)]
67#[cfg_attr(
68    feature = "python",
69    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
70)]
71#[expect(
72    clippy::struct_excessive_bools,
73    reason = "config fields mirror the existing Rust and Python backtest engine surfaces"
74)]
75#[derive(Debug, Clone, bon::Builder)]
76pub struct BacktestEngineConfig {
77    /// The kernel environment context.
78    #[builder(default = Environment::Backtest)]
79    pub environment: Environment,
80    /// The trader ID for the node.
81    #[builder(default)]
82    pub trader_id: TraderId,
83    /// If actor and strategy state should be loaded from the database on start.
84    #[builder(default)]
85    pub load_state: bool,
86    /// If actor and strategy state should be saved to the database on stop.
87    #[builder(default)]
88    pub save_state: bool,
89    /// If the system should request shutdown when an error log is emitted.
90    ///
91    /// Filtered or bypassed error logs still request shutdown.
92    #[builder(default)]
93    pub shutdown_on_error: bool,
94    /// The logging configuration for the kernel.
95    #[builder(default)]
96    pub logging: LoggerConfig,
97    /// The unique instance identifier for the kernel.
98    pub instance_id: Option<UUID4>,
99    /// The timeout for all clients to connect and initialize.
100    #[builder(default = Duration::from_mins(1))]
101    pub timeout_connection: Duration,
102    /// The timeout for execution state to reconcile.
103    #[builder(default = Duration::from_secs(30))]
104    pub timeout_reconciliation: Duration,
105    /// The timeout for portfolio to initialize margins and unrealized pnls.
106    #[builder(default = Duration::from_secs(10))]
107    pub timeout_portfolio: Duration,
108    /// The timeout for all engine clients to disconnect.
109    #[builder(default = Duration::from_secs(10))]
110    pub timeout_disconnection: Duration,
111    /// The delay after stopping the node to await residual events before final shutdown.
112    #[builder(default = Duration::from_secs(10))]
113    pub delay_post_stop: Duration,
114    /// The timeout to await pending tasks cancellation during shutdown.
115    #[builder(default = Duration::from_secs(5))]
116    pub timeout_shutdown: Duration,
117    /// The cache configuration.
118    ///
119    /// [`crate::engine::BacktestEngine`] always overrides
120    /// `drop_instruments_on_reset` to `false` on this config so that
121    /// successive runs can reuse the same dataset.
122    pub cache: Option<CacheConfig>,
123    /// The message bus configuration.
124    pub msgbus: Option<MessageBusConfig>,
125    /// The data engine configuration.
126    pub data_engine: Option<DataEngineConfig>,
127    /// The risk engine configuration.
128    pub risk_engine: Option<RiskEngineConfig>,
129    /// The execution engine configuration.
130    pub exec_engine: Option<ExecutionEngineConfig>,
131    /// The portfolio configuration.
132    pub portfolio: Option<PortfolioConfig>,
133    /// The importable controller configuration.
134    pub controller: Option<ImportableControllerConfig>,
135    /// The configuration for streaming to feather files.
136    #[cfg(feature = "streaming")]
137    pub streaming: Option<StreamingConfig>,
138    /// Configurations for existing data catalogs.
139    #[cfg(feature = "streaming")]
140    #[builder(default)]
141    pub catalogs: Vec<DataCatalogConfig>,
142    /// If logging should be bypassed.
143    #[builder(default)]
144    pub bypass_logging: bool,
145    /// If post backtest performance analysis should be run.
146    #[builder(default = true)]
147    pub run_analysis: bool,
148}
149
150impl NautilusKernelConfig for BacktestEngineConfig {
151    fn environment(&self) -> Environment {
152        self.environment
153    }
154
155    fn trader_id(&self) -> TraderId {
156        self.trader_id
157    }
158
159    fn load_state(&self) -> bool {
160        self.load_state
161    }
162
163    fn save_state(&self) -> bool {
164        self.save_state
165    }
166
167    fn shutdown_on_error(&self) -> bool {
168        self.shutdown_on_error
169    }
170
171    fn logging(&self) -> LoggerConfig {
172        self.logging.clone()
173    }
174
175    fn instance_id(&self) -> Option<UUID4> {
176        self.instance_id
177    }
178
179    fn timeout_connection(&self) -> Duration {
180        self.timeout_connection
181    }
182
183    fn timeout_reconciliation(&self) -> Duration {
184        self.timeout_reconciliation
185    }
186
187    fn timeout_portfolio(&self) -> Duration {
188        self.timeout_portfolio
189    }
190
191    fn timeout_disconnection(&self) -> Duration {
192        self.timeout_disconnection
193    }
194
195    fn delay_post_stop(&self) -> Duration {
196        self.delay_post_stop
197    }
198
199    fn timeout_shutdown(&self) -> Duration {
200        self.timeout_shutdown
201    }
202
203    fn cache(&self) -> Option<CacheConfig> {
204        self.cache.clone()
205    }
206
207    fn msgbus(&self) -> Option<MessageBusConfig> {
208        self.msgbus.clone()
209    }
210
211    fn data_engine(&self) -> Option<DataEngineConfig> {
212        self.data_engine.clone()
213    }
214
215    fn risk_engine(&self) -> Option<RiskEngineConfig> {
216        self.risk_engine.clone()
217    }
218
219    fn exec_engine(&self) -> Option<ExecutionEngineConfig> {
220        self.exec_engine.clone()
221    }
222
223    fn portfolio(&self) -> Option<PortfolioConfig> {
224        self.portfolio
225    }
226
227    #[cfg(feature = "streaming")]
228    fn streaming(&self) -> Option<StreamingConfig> {
229        self.streaming.clone()
230    }
231
232    #[cfg(feature = "streaming")]
233    fn catalogs(&self) -> Vec<DataCatalogConfig> {
234        self.catalogs.clone()
235    }
236}
237
238impl Default for BacktestEngineConfig {
239    fn default() -> Self {
240        Self::builder().build()
241    }
242}
243
244/// Imperative-API configuration for registering a simulated venue on
245/// [`crate::engine::BacktestEngine`].
246///
247/// Constructed via [`bon::Builder`] so callers only specify what differs from
248/// the documented defaults. Field types mirror the internal
249/// `SimulatedExchange` shapes (runtime handles for modules and models,
250/// and typed `Money` balances), which is why this is distinct from the
251/// YAML-friendly [`BacktestVenueConfig`] used by `BacktestNode`.
252///
253/// # Option Settlement Deferral
254///
255/// With `defer_option_settlement`, the caller schedules expiration processing after
256/// all market data at the expiry timestamp. This defaults to `true`; `BacktestEngine`
257/// schedules the required expiry timers.
258///
259/// Cancellation and market closure remain immediate; explicit contract-close events
260/// bypass deferral, and automatic checks after expiry can also settle.
261#[allow(missing_debug_implementations)]
262#[expect(
263    clippy::struct_excessive_bools,
264    reason = "venue config fields mirror the existing imperative backtest API"
265)]
266#[derive(bon::Builder)]
267#[builder(finish_fn(name = build_inner, vis = ""))]
268pub struct SimulatedVenueConfig {
269    /// The simulated venue identifier.
270    pub venue: Venue,
271    /// The order management mode for position tracking.
272    pub oms_type: OmsType,
273    /// The account type used for balance and margin calculations.
274    pub account_type: AccountType,
275    /// The order book type used for matching.
276    pub book_type: BookType,
277    /// The initial account balances.
278    pub starting_balances: Vec<Money>,
279    /// The account base currency, or `None` for a multi-currency account.
280    pub base_currency: Option<Currency>,
281    /// The default leverage, falling back to 10x for margin accounts and 1x otherwise.
282    pub default_leverage: Option<Decimal>,
283    /// The leverage overrides for individual instruments.
284    #[builder(default)]
285    pub leverages: AHashMap<InstrumentId, Decimal>,
286    /// The model used to calculate margin requirements.
287    pub margin_model: Option<MarginModelHandle>,
288    /// The simulation modules run by the exchange.
289    #[builder(default)]
290    pub modules: Vec<SimulationModuleHandle>,
291    /// The model used to simulate order fills.
292    #[builder(default)]
293    pub fill_model: FillModelHandle,
294    /// The model used to calculate trading fees.
295    #[builder(default)]
296    pub fee_model: FeeModelHandle,
297    /// The optional model used to simulate command latency.
298    pub latency_model: Option<LatencyModelHandle>,
299    /// If the execution client supports routing orders to other venues.
300    #[builder(default = false)]
301    pub routing: bool,
302    /// If stop orders already in the market are rejected on submission.
303    #[builder(default = true)]
304    pub reject_stop_orders: bool,
305    /// If good-till-date order expiry is supported.
306    #[builder(default = true)]
307    pub support_gtd_orders: bool,
308    /// If contingent order relationships are supported.
309    #[builder(default = true)]
310    pub support_contingent_orders: bool,
311    /// If venue position IDs are generated.
312    #[builder(default = true)]
313    pub use_position_ids: bool,
314    /// If generated identifiers use random values instead of sequential counters.
315    #[builder(default = false)]
316    pub use_random_ids: bool,
317    /// If reduce-only order restrictions are enforced.
318    #[builder(default = true)]
319    pub use_reduce_only: bool,
320    /// If trading commands are queued instead of processed immediately.
321    #[builder(default = true)]
322    pub use_message_queue: bool,
323    /// If market orders emit acceptance events before filling.
324    #[builder(default = false)]
325    pub use_market_order_acks: bool,
326    /// If bars drive order execution.
327    #[builder(default = true)]
328    pub bar_execution: bool,
329    /// If bar execution visits the high or low closest to the open first.
330    #[builder(default = false)]
331    pub bar_adaptive_high_low_ordering: bool,
332    /// If trade ticks drive order execution.
333    #[builder(default = true)]
334    pub trade_execution: bool,
335    /// If fills consume available liquidity.
336    #[builder(default = false)]
337    pub liquidity_consumption: bool,
338    /// If cash accounts may borrow funds.
339    #[builder(default = false)]
340    pub allow_cash_borrowing: bool,
341    /// If account balances remain unchanged by simulated trading.
342    #[builder(default = false)]
343    pub frozen_account: bool,
344    /// If passive fills account for queue position.
345    #[builder(default = false)]
346    pub queue_position: bool,
347    /// If one-triggers-other orders wait for the parent to fill completely.
348    #[builder(default = false)]
349    pub oto_full_trigger: bool,
350    /// If option settlement waits for expiry processing after same-timestamp market data.
351    #[builder(default = true)]
352    pub defer_option_settlement: bool,
353    /// The market order price protection distance in ticks, or zero to disable protection.
354    #[builder(default = 0)]
355    pub price_protection_points: u32,
356    /// If positions are liquidated when maintenance margin is breached.
357    #[builder(default = false)]
358    pub liquidation_enabled: bool,
359    /// The equity-to-maintenance-margin ratio at or below which liquidation triggers.
360    #[builder(default = 1.0)]
361    pub liquidation_trigger_ratio: f64,
362    /// If open orders are canceled before liquidating positions.
363    #[builder(default = true)]
364    pub liquidation_cancel_open_orders: bool,
365}
366
367impl<S: simulated_venue_config_builder::IsComplete> SimulatedVenueConfigBuilder<S> {
368    /// Validates and builds the [`SimulatedVenueConfig`].
369    ///
370    /// # Errors
371    ///
372    /// Returns a [`ConfigError`] if any field fails validation
373    /// (see [`SimulatedVenueConfig::validate`]).
374    pub fn build(self) -> ConfigResult<SimulatedVenueConfig> {
375        let config = self.build_inner();
376        config.validate()?;
377        Ok(config)
378    }
379}
380
381impl SimulatedVenueConfig {
382    /// Validates the venue configuration, collecting every field violation.
383    ///
384    /// # Errors
385    ///
386    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
387    /// invalid) if any field fails validation.
388    pub fn validate(&self) -> ConfigResult<()> {
389        let mut errors = ConfigErrorCollector::new();
390
391        if self.starting_balances.is_empty() {
392            errors.push(ConfigError::empty_field("starting_balances"));
393        }
394
395        if let Some(default_leverage) = self.default_leverage {
396            errors.check(
397                default_leverage > Decimal::ZERO,
398                ConfigError::range(
399                    "default_leverage",
400                    format!("must be positive, was {default_leverage}"),
401                ),
402            );
403        }
404
405        for (instrument_id, leverage) in &self.leverages {
406            errors.check(
407                *leverage > Decimal::ZERO,
408                ConfigError::range(
409                    "leverages",
410                    format!("leverage for {instrument_id} must be positive, was {leverage}"),
411                ),
412            );
413        }
414
415        errors.check(
416            self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
417            ConfigError::range(
418                "liquidation_trigger_ratio",
419                format!(
420                    "must be a positive finite value, was {}",
421                    self.liquidation_trigger_ratio
422                ),
423            ),
424        );
425
426        errors.into_result()
427    }
428}
429
430/// Represents a venue configuration for one specific backtest engine.
431#[cfg_attr(
432    feature = "python",
433    pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
434)]
435#[cfg_attr(
436    feature = "python",
437    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
438)]
439#[expect(
440    clippy::struct_excessive_bools,
441    reason = "venue config fields mirror the existing Rust and Python backtest surfaces"
442)]
443#[derive(Debug, Clone, bon::Builder)]
444#[builder(finish_fn(name = build_inner, vis = ""))]
445pub struct BacktestVenueConfig {
446    /// The name of the venue.
447    #[builder(into)]
448    name: Ustr,
449    /// The order management system type for the exchange. If ``HEDGING`` will generate new position IDs.
450    oms_type: OmsType,
451    /// The account type for the exchange.
452    account_type: AccountType,
453    /// The default order book type.
454    book_type: BookType,
455    /// The starting account balances (specify one for a single asset account).
456    #[builder(default)]
457    starting_balances: Vec<String>,
458    /// If multi-venue routing should be enabled for the execution client.
459    #[builder(default)]
460    routing: bool,
461    /// If the account for this exchange is frozen (balances will not change).
462    #[builder(default)]
463    frozen_account: bool,
464    /// If stop orders are rejected on submission if trigger price is in the market.
465    #[builder(default = true)]
466    reject_stop_orders: bool,
467    /// If orders with GTD time in force will be supported by the venue.
468    #[builder(default = true)]
469    support_gtd_orders: bool,
470    /// If contingent orders will be supported/respected by the venue.
471    /// If False, then it's expected the strategy will be managing any contingent orders.
472    #[builder(default = true)]
473    support_contingent_orders: bool,
474    /// If venue position IDs will be generated on order fills.
475    #[builder(default = true)]
476    use_position_ids: bool,
477    /// If venue order IDs and position IDs will be random UUID4's.
478    /// Trade IDs are always deterministic and not affected by this flag.
479    #[builder(default)]
480    use_random_ids: bool,
481    /// If the `reduce_only` execution instruction on orders will be enforced.
482    /// If false, reduce-only orders are rejected.
483    #[builder(default = true)]
484    use_reduce_only: bool,
485    /// If bars should be processed by the matching engine(s) (and move the market).
486    #[builder(default = true)]
487    bar_execution: bool,
488    /// Determines whether the processing order of bar prices is adaptive based on a heuristic.
489    /// This setting is only relevant when `bar_execution` is True.
490    /// If False, bar prices are always processed in the fixed order: Open, High, Low, Close.
491    /// If True, the processing order adapts with the heuristic:
492    /// - If High is closer to Open than Low then the processing order is Open, High, Low, Close.
493    /// - If Low is closer to Open than High then the processing order is Open, Low, High, Close.
494    #[builder(default)]
495    bar_adaptive_high_low_ordering: bool,
496    /// If trades should be processed by the matching engine(s) (and move the market).
497    #[builder(default = true)]
498    trade_execution: bool,
499    /// If `OrderAccepted` events should be generated for market orders.
500    #[builder(default)]
501    use_market_order_acks: bool,
502    /// If order book liquidity consumption should be tracked per level.
503    #[builder(default)]
504    liquidity_consumption: bool,
505    /// If negative cash balances are allowed (borrowing).
506    #[builder(default)]
507    allow_cash_borrowing: bool,
508    /// If limit order queue position tracking is enabled during trade execution.
509    #[builder(default)]
510    queue_position: bool,
511    /// When OTO child orders are released relative to parent fills.
512    #[builder(default)]
513    oto_trigger_mode: OtoTriggerMode,
514    /// The account base currency for the exchange. Use `None` for multi-currency accounts.
515    base_currency: Option<Currency>,
516    /// The account default leverage, or `None` to use the account-type default.
517    default_leverage: Option<Decimal>,
518    /// The instrument specific leverage configuration (for margin accounts).
519    leverages: Option<AHashMap<InstrumentId, Decimal>>,
520    /// The margin model for the venue.
521    margin_model: Option<MarginModelAny>,
522    /// The simulation modules for the venue.
523    #[builder(default)]
524    modules: Vec<SimulationModuleAny>,
525    /// The fill model for the venue.
526    fill_model: Option<FillModelAny>,
527    /// The latency model for the venue.
528    latency_model: Option<LatencyModelAny>,
529    /// The fee model for the venue.
530    fee_model: Option<FeeModelAny>,
531    /// Defines an exchange-calculated price boundary to prevent a market order from being
532    /// filled at an extremely aggressive price.
533    #[builder(default)]
534    price_protection_points: u32,
535    /// If liquidation of positions should be triggered when maintenance margin is breached.
536    #[builder(default)]
537    liquidation_enabled: bool,
538    /// The ratio of equity to maintenance margin at which liquidation is triggered.
539    /// A value of 1.0 means liquidation triggers when equity <= `maintenance_margin`.
540    #[builder(default = 1.0)]
541    liquidation_trigger_ratio: f64,
542    /// If open orders should be canceled before closing positions during liquidation.
543    #[builder(default = true)]
544    liquidation_cancel_open_orders: bool,
545}
546
547impl<S: backtest_venue_config_builder::IsComplete> BacktestVenueConfigBuilder<S> {
548    /// Validates and builds the [`BacktestVenueConfig`].
549    ///
550    /// # Errors
551    ///
552    /// Returns a [`ConfigError`] if any field fails validation
553    /// (see [`BacktestVenueConfig::validate`]).
554    pub fn build(self) -> ConfigResult<BacktestVenueConfig> {
555        let config = self.build_inner();
556        config.validate()?;
557        Ok(config)
558    }
559}
560
561impl BacktestVenueConfig {
562    /// Validates the venue configuration, collecting every field violation.
563    ///
564    /// # Errors
565    ///
566    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
567    /// invalid) if any field fails validation.
568    pub fn validate(&self) -> ConfigResult<()> {
569        let mut errors = ConfigErrorCollector::new();
570
571        if self.name.is_empty() {
572            errors.push(ConfigError::empty_field("name"));
573        } else if let Err(e) = Venue::new_checked(self.name.as_str()) {
574            errors.push(ConfigError::invalid_value(
575                "name",
576                format!("must be a valid venue identifier ({e})"),
577            ));
578        }
579
580        if let Some(default_leverage) = self.default_leverage {
581            errors.check(
582                default_leverage > Decimal::ZERO,
583                ConfigError::range(
584                    "default_leverage",
585                    format!("must be positive, was {default_leverage}"),
586                ),
587            );
588        }
589
590        if let Some(leverages) = &self.leverages {
591            for (instrument_id, leverage) in leverages {
592                errors.check(
593                    *leverage > Decimal::ZERO,
594                    ConfigError::range(
595                        "leverages",
596                        format!("leverage for {instrument_id} must be positive, was {leverage}"),
597                    ),
598                );
599            }
600        }
601        errors.check(
602            self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
603            ConfigError::range(
604                "liquidation_trigger_ratio",
605                format!(
606                    "must be a positive finite value, was {}",
607                    self.liquidation_trigger_ratio
608                ),
609            ),
610        );
611
612        for balance in &self.starting_balances {
613            if let Err(reason) = balance.parse::<Money>() {
614                errors.push(ConfigError::invalid_format(
615                    "starting_balances",
616                    format!("a valid money string, was '{balance}' ({reason})"),
617                ));
618            }
619        }
620
621        errors.into_result()
622    }
623
624    #[must_use]
625    pub fn name(&self) -> Ustr {
626        self.name
627    }
628
629    #[must_use]
630    pub fn oms_type(&self) -> OmsType {
631        self.oms_type
632    }
633
634    #[must_use]
635    pub fn account_type(&self) -> AccountType {
636        self.account_type
637    }
638
639    #[must_use]
640    pub fn book_type(&self) -> BookType {
641        self.book_type
642    }
643
644    #[must_use]
645    pub fn starting_balances(&self) -> &[String] {
646        &self.starting_balances
647    }
648
649    #[must_use]
650    pub fn routing(&self) -> bool {
651        self.routing
652    }
653
654    #[must_use]
655    pub fn frozen_account(&self) -> bool {
656        self.frozen_account
657    }
658
659    #[must_use]
660    pub fn reject_stop_orders(&self) -> bool {
661        self.reject_stop_orders
662    }
663
664    #[must_use]
665    pub fn support_gtd_orders(&self) -> bool {
666        self.support_gtd_orders
667    }
668
669    #[must_use]
670    pub fn support_contingent_orders(&self) -> bool {
671        self.support_contingent_orders
672    }
673
674    #[must_use]
675    pub fn use_position_ids(&self) -> bool {
676        self.use_position_ids
677    }
678
679    #[must_use]
680    pub fn use_random_ids(&self) -> bool {
681        self.use_random_ids
682    }
683
684    #[must_use]
685    pub fn use_reduce_only(&self) -> bool {
686        self.use_reduce_only
687    }
688
689    #[must_use]
690    pub fn bar_execution(&self) -> bool {
691        self.bar_execution
692    }
693
694    #[must_use]
695    pub fn bar_adaptive_high_low_ordering(&self) -> bool {
696        self.bar_adaptive_high_low_ordering
697    }
698
699    #[must_use]
700    pub fn trade_execution(&self) -> bool {
701        self.trade_execution
702    }
703
704    #[must_use]
705    pub fn use_market_order_acks(&self) -> bool {
706        self.use_market_order_acks
707    }
708
709    #[must_use]
710    pub fn liquidity_consumption(&self) -> bool {
711        self.liquidity_consumption
712    }
713
714    #[must_use]
715    pub fn allow_cash_borrowing(&self) -> bool {
716        self.allow_cash_borrowing
717    }
718
719    #[must_use]
720    pub fn queue_position(&self) -> bool {
721        self.queue_position
722    }
723
724    #[must_use]
725    pub fn oto_trigger_mode(&self) -> OtoTriggerMode {
726        self.oto_trigger_mode
727    }
728
729    #[must_use]
730    pub fn base_currency(&self) -> Option<Currency> {
731        self.base_currency
732    }
733
734    #[must_use]
735    pub fn default_leverage(&self) -> Option<Decimal> {
736        self.default_leverage
737    }
738
739    #[must_use]
740    pub fn leverages(&self) -> Option<&AHashMap<InstrumentId, Decimal>> {
741        self.leverages.as_ref()
742    }
743
744    #[must_use]
745    pub fn margin_model(&self) -> Option<&MarginModelAny> {
746        self.margin_model.as_ref()
747    }
748
749    #[must_use]
750    pub fn modules(&self) -> &[SimulationModuleAny] {
751        &self.modules
752    }
753
754    #[must_use]
755    pub fn fill_model(&self) -> Option<&FillModelAny> {
756        self.fill_model.as_ref()
757    }
758
759    #[must_use]
760    pub fn latency_model(&self) -> Option<&LatencyModelAny> {
761        self.latency_model.as_ref()
762    }
763
764    #[must_use]
765    pub fn fee_model(&self) -> Option<&FeeModelAny> {
766        self.fee_model.as_ref()
767    }
768
769    #[must_use]
770    pub fn price_protection_points(&self) -> u32 {
771        self.price_protection_points
772    }
773
774    #[must_use]
775    pub fn liquidation_enabled(&self) -> bool {
776        self.liquidation_enabled
777    }
778
779    #[must_use]
780    pub fn liquidation_trigger_ratio(&self) -> f64 {
781        self.liquidation_trigger_ratio
782    }
783
784    #[must_use]
785    pub fn liquidation_cancel_open_orders(&self) -> bool {
786        self.liquidation_cancel_open_orders
787    }
788}
789
790/// Represents the data configuration for one specific backtest run.
791#[derive(Debug, Clone, bon::Builder)]
792#[builder(finish_fn(name = build_inner, vis = ""))]
793#[cfg_attr(
794    feature = "python",
795    pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
796)]
797#[cfg_attr(
798    feature = "python",
799    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
800)]
801pub struct BacktestDataConfig {
802    /// The type of data to query from the catalog.
803    data_type: NautilusDataType,
804    /// The path to the data catalog.
805    catalog_path: String,
806    /// Catalog backend used for data loading.
807    #[builder(default)]
808    #[cfg(feature = "streaming")]
809    catalog_backend: CatalogBackendType,
810    /// The `fsspec` filesystem protocol for the catalog.
811    catalog_fs_protocol: Option<String>,
812    /// The filesystem storage options for the catalog (e.g. cloud auth credentials).
813    catalog_fs_storage_options: Option<AHashMap<String, String>>,
814    /// Rust-specific storage options for the catalog backend.
815    catalog_fs_rust_storage_options: Option<AHashMap<String, String>>,
816    /// The instrument ID for the data configuration (single).
817    instrument_id: Option<InstrumentId>,
818    /// Multiple instrument IDs for the data configuration.
819    instrument_ids: Option<Vec<InstrumentId>>,
820    /// The start time for the data configuration.
821    start_time: Option<UnixNanos>,
822    /// The end time for the data configuration.
823    end_time: Option<UnixNanos>,
824    /// The additional filter expressions for the data catalog query.
825    filter_expr: Option<String>,
826    /// The client ID for the data configuration.
827    client_id: Option<ClientId>,
828    /// The metadata for the data catalog query.
829    metadata: Option<AHashMap<String, String>>,
830    /// The bar specification for the data catalog query.
831    bar_spec: Option<BarSpecification>,
832    /// Explicit bar type strings for the data catalog query (e.g. "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
833    bar_types: Option<Vec<String>>,
834    /// If directory-based file registration should be used for more efficient loading.
835    #[builder(default)]
836    optimize_file_loading: bool,
837}
838
839impl<S: backtest_data_config_builder::IsComplete> BacktestDataConfigBuilder<S> {
840    /// Validates and builds the [`BacktestDataConfig`].
841    ///
842    /// # Errors
843    ///
844    /// Returns a [`ConfigError`] if any field fails validation
845    /// (see [`BacktestDataConfig::validate`]).
846    pub fn build(self) -> ConfigResult<BacktestDataConfig> {
847        let config = self.build_inner();
848        config.validate()?;
849        Ok(config)
850    }
851}
852
853impl BacktestDataConfig {
854    /// Returns the configured catalog backend.
855    #[must_use]
856    #[cfg(feature = "streaming")]
857    pub fn catalog_backend(&self) -> CatalogBackendType {
858        self.catalog_backend.clone()
859    }
860
861    /// Validates the data configuration, collecting every field violation.
862    ///
863    /// # Errors
864    ///
865    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
866    /// invalid) if any field fails validation.
867    pub fn validate(&self) -> ConfigResult<()> {
868        let mut errors = ConfigErrorCollector::new();
869
870        errors.check(
871            matches!(
872                self.data_type,
873                NautilusDataType::OrderBookDelta
874                    | NautilusDataType::OrderBookDepth
875                    | NautilusDataType::QuoteTick
876                    | NautilusDataType::TradeTick
877                    | NautilusDataType::Bar
878                    | NautilusDataType::MarkPriceUpdate
879                    | NautilusDataType::IndexPriceUpdate
880                    | NautilusDataType::FundingRateUpdate
881                    | NautilusDataType::OptionGreeks
882                    | NautilusDataType::InstrumentStatus
883                    | NautilusDataType::InstrumentClose
884                    | NautilusDataType::Instrument
885            ),
886            ConfigError::unsupported_value(
887                "data_type",
888                format!("{} is not supported by BacktestDataConfig", self.data_type),
889            ),
890        );
891
892        if self.catalog_path.trim().is_empty() {
893            errors.push(ConfigError::empty_field("catalog_path"));
894        }
895
896        if let (Some(start), Some(end)) = (self.start_time, self.end_time) {
897            errors.check(
898                start <= end,
899                ConfigError::range(
900                    "start_time",
901                    format!("must be <= end_time, was {start} > {end}"),
902                ),
903            );
904        }
905
906        let has_identifier = self.instrument_id.is_some()
907            || self
908                .instrument_ids
909                .as_ref()
910                .is_some_and(|ids| !ids.is_empty())
911            || self.bar_types.as_ref().is_some_and(|bars| !bars.is_empty());
912        errors.check(
913            has_identifier,
914            ConfigError::required_one_of(["instrument_id", "instrument_ids", "bar_types"]),
915        );
916
917        errors.into_result()
918    }
919
920    #[must_use]
921    pub const fn data_type(&self) -> &NautilusDataType {
922        &self.data_type
923    }
924
925    #[must_use]
926    pub fn catalog_path(&self) -> &str {
927        &self.catalog_path
928    }
929
930    #[must_use]
931    pub fn catalog_fs_protocol(&self) -> Option<&str> {
932        self.catalog_fs_protocol.as_deref()
933    }
934
935    #[must_use]
936    pub fn catalog_fs_storage_options(&self) -> Option<&AHashMap<String, String>> {
937        self.catalog_fs_storage_options.as_ref()
938    }
939
940    #[must_use]
941    pub fn catalog_fs_rust_storage_options(&self) -> Option<&AHashMap<String, String>> {
942        self.catalog_fs_rust_storage_options.as_ref()
943    }
944
945    #[must_use]
946    pub fn instrument_id(&self) -> Option<InstrumentId> {
947        self.instrument_id
948    }
949
950    #[must_use]
951    pub fn instrument_ids(&self) -> Option<&[InstrumentId]> {
952        self.instrument_ids.as_deref()
953    }
954
955    #[must_use]
956    pub fn start_time(&self) -> Option<UnixNanos> {
957        self.start_time
958    }
959
960    #[must_use]
961    pub fn end_time(&self) -> Option<UnixNanos> {
962        self.end_time
963    }
964
965    #[must_use]
966    pub fn filter_expr(&self) -> Option<&str> {
967        self.filter_expr.as_deref()
968    }
969
970    #[must_use]
971    pub fn client_id(&self) -> Option<ClientId> {
972        self.client_id
973    }
974
975    #[must_use]
976    pub fn metadata(&self) -> Option<&AHashMap<String, String>> {
977        self.metadata.as_ref()
978    }
979
980    #[must_use]
981    pub fn bar_spec(&self) -> Option<BarSpecification> {
982        self.bar_spec
983    }
984
985    #[must_use]
986    pub fn bar_types(&self) -> Option<&[String]> {
987        self.bar_types.as_deref()
988    }
989
990    #[must_use]
991    pub fn optimize_file_loading(&self) -> bool {
992        self.optimize_file_loading
993    }
994
995    /// Constructs identifier strings for catalog queries.
996    ///
997    /// Follows the same logic as Python's `BacktestDataConfig.query`:
998    /// - For bars: prefer `bar_types`, else construct from instrument(s) + `bar_spec` + "-EXTERNAL"
999    /// - For other types: use `instrument_id` or `instrument_ids`
1000    #[must_use]
1001    pub fn query_identifiers(&self) -> Option<Vec<String>> {
1002        if self.data_type == NautilusDataType::Bar {
1003            if let Some(bar_types) = &self.bar_types
1004                && !bar_types.is_empty()
1005            {
1006                return Some(bar_types.clone());
1007            }
1008
1009            // Construct from instrument_id + bar_spec
1010            if let Some(bar_spec) = &self.bar_spec {
1011                if let Some(id) = self.instrument_id {
1012                    return Some(vec![format!("{id}-{bar_spec}-EXTERNAL")]);
1013                }
1014
1015                if let Some(ids) = &self.instrument_ids {
1016                    let bar_types: Vec<String> = ids
1017                        .iter()
1018                        .map(|id| format!("{id}-{bar_spec}-EXTERNAL"))
1019                        .collect();
1020
1021                    if !bar_types.is_empty() {
1022                        return Some(bar_types);
1023                    }
1024                }
1025            }
1026        }
1027
1028        // Fallback: instrument_id or instrument_ids
1029        if let Some(id) = self.instrument_id {
1030            return Some(vec![id.to_string()]);
1031        }
1032
1033        if let Some(ids) = &self.instrument_ids {
1034            let strs: Vec<String> = ids.iter().map(ToString::to_string).collect();
1035            if !strs.is_empty() {
1036                return Some(strs);
1037            }
1038        }
1039
1040        None
1041    }
1042
1043    /// Returns all instrument IDs referenced by this config.
1044    ///
1045    /// For `bar_types`, extracts the instrument ID from each bar type string.
1046    ///
1047    /// # Errors
1048    ///
1049    /// Returns an error if any bar type string cannot be parsed.
1050    pub fn get_instrument_ids(&self) -> anyhow::Result<Vec<InstrumentId>> {
1051        if let Some(id) = self.instrument_id {
1052            return Ok(vec![id]);
1053        }
1054
1055        if let Some(ids) = &self.instrument_ids {
1056            return Ok(ids.clone());
1057        }
1058
1059        if let Some(bar_types) = &self.bar_types {
1060            let ids = bar_types
1061                .iter()
1062                .map(|bt| {
1063                    bt.parse::<BarType>()
1064                        .map(|b| b.instrument_id())
1065                        .map_err(|_| anyhow::anyhow!("Invalid bar type string: '{bt}'"))
1066                })
1067                .collect::<anyhow::Result<Vec<_>>>()?;
1068            return Ok(ids);
1069        }
1070        Ok(Vec::new())
1071    }
1072}
1073
1074/// Represents the configuration for one specific backtest run.
1075/// This includes a backtest engine with its actors and strategies, with the external inputs of venues and data.
1076#[derive(Debug, Clone, bon::Builder)]
1077#[builder(finish_fn(name = build_inner, vis = ""))]
1078#[cfg_attr(
1079    feature = "python",
1080    pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
1081)]
1082#[cfg_attr(
1083    feature = "python",
1084    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
1085)]
1086pub struct BacktestRunConfig {
1087    /// The unique identifier for this run configuration.
1088    #[builder(default = UUID4::new().to_string())]
1089    id: String,
1090    /// The venue configurations for the backtest run.
1091    venues: Vec<BacktestVenueConfig>,
1092    /// The data configurations for the backtest run.
1093    data: Vec<BacktestDataConfig>,
1094    /// The backtest engine configuration (the core system kernel).
1095    #[builder(default)]
1096    engine: BacktestEngineConfig,
1097    /// The number of data points to process in each chunk during streaming mode
1098    /// (range `[1, 1_000_000]`).
1099    /// If `None`, the backtest will run without streaming, loading all data at once.
1100    chunk_size: Option<usize>,
1101    /// If exceptions during build or run should interrupt processing.
1102    #[builder(default)]
1103    raise_exception: bool,
1104    /// If the backtest engine should be disposed on completion of the run.
1105    /// If `True`, then will drop data and all state.
1106    /// If `False`, then will *only* drop data.
1107    #[builder(default = true)]
1108    dispose_on_completion: bool,
1109    /// The start datetime (UTC) for the backtest run.
1110    /// If `None` engine runs from the start of the data.
1111    start: Option<UnixNanos>,
1112    /// The end datetime (UTC) for the backtest run.
1113    /// If `None` engine runs to the end of the data.
1114    end: Option<UnixNanos>,
1115}
1116
1117impl<S: backtest_run_config_builder::IsComplete> BacktestRunConfigBuilder<S> {
1118    /// Validates and builds the [`BacktestRunConfig`].
1119    ///
1120    /// # Errors
1121    ///
1122    /// Returns a [`ConfigError`] if any field fails validation
1123    /// (see [`BacktestRunConfig::validate`]).
1124    pub fn build(self) -> ConfigResult<BacktestRunConfig> {
1125        let config = self.build_inner();
1126        config.validate()?;
1127        Ok(config)
1128    }
1129}
1130
1131impl BacktestRunConfig {
1132    /// Validates the run configuration, collecting every field violation.
1133    ///
1134    /// # Errors
1135    ///
1136    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
1137    /// invalid) if any field fails validation.
1138    pub fn validate(&self) -> ConfigResult<()> {
1139        let mut errors = ConfigErrorCollector::new();
1140
1141        if self.venues.is_empty() {
1142            errors.push(ConfigError::empty_field("venues"));
1143        }
1144
1145        if let (Some(start), Some(end)) = (self.start, self.end) {
1146            errors.check(
1147                start <= end,
1148                ConfigError::range("start", format!("must be <= end, was {start} > {end}")),
1149            );
1150        }
1151
1152        if let Some(chunk_size) = self.chunk_size {
1153            errors.check(
1154                (1..=MAX_BACKTEST_CHUNK_SIZE).contains(&chunk_size),
1155                ConfigError::range(
1156                    "chunk_size",
1157                    format!("must be in range [1, {MAX_BACKTEST_CHUNK_SIZE}], was {chunk_size}"),
1158                ),
1159            );
1160        }
1161
1162        errors.into_result()
1163    }
1164
1165    #[must_use]
1166    pub fn id(&self) -> &str {
1167        &self.id
1168    }
1169
1170    #[must_use]
1171    pub fn venues(&self) -> &[BacktestVenueConfig] {
1172        &self.venues
1173    }
1174
1175    #[must_use]
1176    pub fn data(&self) -> &[BacktestDataConfig] {
1177        &self.data
1178    }
1179
1180    #[must_use]
1181    pub fn engine(&self) -> &BacktestEngineConfig {
1182        &self.engine
1183    }
1184
1185    #[must_use]
1186    pub fn chunk_size(&self) -> Option<usize> {
1187        self.chunk_size
1188    }
1189
1190    #[must_use]
1191    pub fn raise_exception(&self) -> bool {
1192        self.raise_exception
1193    }
1194
1195    #[must_use]
1196    pub fn dispose_on_completion(&self) -> bool {
1197        self.dispose_on_completion
1198    }
1199
1200    #[must_use]
1201    pub fn start(&self) -> Option<UnixNanos> {
1202        self.start
1203    }
1204
1205    #[must_use]
1206    pub fn end(&self) -> Option<UnixNanos> {
1207        self.end
1208    }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use rstest::rstest;
1214
1215    use super::*;
1216
1217    macro_rules! minimal_builder {
1218        () => {
1219            BacktestVenueConfig::builder()
1220                .name("SIM")
1221                .oms_type(OmsType::Netting)
1222                .account_type(AccountType::Margin)
1223                .book_type(BookType::L1_MBP)
1224        };
1225    }
1226
1227    #[rstest]
1228    #[case(NautilusDataType::OrderBookDelta)]
1229    #[case(NautilusDataType::OrderBookDepth)]
1230    #[case(NautilusDataType::QuoteTick)]
1231    #[case(NautilusDataType::TradeTick)]
1232    #[case(NautilusDataType::Bar)]
1233    #[case(NautilusDataType::MarkPriceUpdate)]
1234    #[case(NautilusDataType::IndexPriceUpdate)]
1235    #[case(NautilusDataType::FundingRateUpdate)]
1236    #[case(NautilusDataType::OptionGreeks)]
1237    #[case(NautilusDataType::InstrumentStatus)]
1238    #[case(NautilusDataType::InstrumentClose)]
1239    fn test_data_config_accepts_supported_family(#[case] data_type: NautilusDataType) {
1240        let config = BacktestDataConfig::builder()
1241            .data_type(data_type.clone())
1242            .catalog_path("/tmp/catalog".to_string())
1243            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1244            .build()
1245            .unwrap();
1246
1247        assert_eq!(config.data_type(), &data_type);
1248    }
1249
1250    #[rstest]
1251    fn test_data_config_accepts_the_instrument_family() {
1252        let config = BacktestDataConfig::builder()
1253            .data_type(NautilusDataType::Instrument)
1254            .catalog_path("/tmp/catalog".to_string())
1255            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1256            .build()
1257            .unwrap();
1258
1259        assert_eq!(config.data_type(), &NautilusDataType::Instrument);
1260    }
1261
1262    #[rstest]
1263    #[case(NautilusDataType::Custom { type_name: "Signal".to_string() })]
1264    fn test_data_config_rejects_unsupported_family(#[case] data_type: NautilusDataType) {
1265        let error = BacktestDataConfig::builder()
1266            .data_type(data_type.clone())
1267            .catalog_path("/tmp/catalog".to_string())
1268            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1269            .build()
1270            .unwrap_err();
1271
1272        assert_eq!(
1273            error,
1274            ConfigError::unsupported_value(
1275                "data_type",
1276                format!("{data_type} is not supported by BacktestDataConfig"),
1277            ),
1278        );
1279    }
1280
1281    macro_rules! minimal_simulated_builder {
1282        () => {
1283            SimulatedVenueConfig::builder()
1284                .venue(Venue::from("SIM"))
1285                .oms_type(OmsType::Netting)
1286                .account_type(AccountType::Margin)
1287                .book_type(BookType::L1_MBP)
1288                .starting_balances(vec![Money::from("1_000_000 USD")])
1289        };
1290    }
1291
1292    #[rstest]
1293    fn test_minimal_config_is_valid() {
1294        assert!(minimal_builder!().build().is_ok());
1295    }
1296
1297    #[rstest]
1298    fn test_default_leverage_is_optional() {
1299        let config = minimal_builder!().build().unwrap();
1300
1301        assert_eq!(config.default_leverage(), None);
1302    }
1303
1304    #[rstest]
1305    fn test_empty_name_rejected() {
1306        let result = BacktestVenueConfig::builder()
1307            .name("")
1308            .oms_type(OmsType::Netting)
1309            .account_type(AccountType::Margin)
1310            .book_type(BookType::L1_MBP)
1311            .build();
1312        assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "name"));
1313    }
1314
1315    #[rstest]
1316    #[case("   ")]
1317    #[case("vénue")]
1318    fn test_invalid_venue_name_rejected(#[case] name: &str) {
1319        let result = BacktestVenueConfig::builder()
1320            .name(name)
1321            .oms_type(OmsType::Netting)
1322            .account_type(AccountType::Margin)
1323            .book_type(BookType::L1_MBP)
1324            .build();
1325        assert!(matches!(result, Err(ConfigError::InvalidValue { field, .. }) if field == "name"));
1326    }
1327
1328    #[rstest]
1329    #[case(Decimal::ZERO)]
1330    #[case(Decimal::from(-1))]
1331    fn test_non_positive_default_leverage_rejected(#[case] leverage: Decimal) {
1332        let result = minimal_builder!().default_leverage(leverage).build();
1333        assert!(
1334            matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1335        );
1336    }
1337
1338    #[rstest]
1339    fn test_non_positive_instrument_leverage_rejected() {
1340        let mut leverages = AHashMap::new();
1341        leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
1342        let result = minimal_builder!().leverages(leverages).build();
1343        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1344    }
1345
1346    #[rstest]
1347    #[case(Decimal::ZERO)]
1348    #[case(Decimal::from(-1))]
1349    fn test_simulated_non_positive_instrument_leverage_rejected(#[case] leverage: Decimal) {
1350        let mut leverages = AHashMap::new();
1351        leverages.insert(InstrumentId::from("ESZ21.GLBX"), leverage);
1352        let result = minimal_simulated_builder!().leverages(leverages).build();
1353        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1354    }
1355
1356    #[rstest]
1357    fn test_simulated_positive_instrument_leverage_accepted() {
1358        let mut leverages = AHashMap::new();
1359        leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::from(10));
1360        let result = minimal_simulated_builder!().leverages(leverages).build();
1361        assert!(result.is_ok());
1362    }
1363
1364    #[rstest]
1365    #[case(0.0)]
1366    #[case(-1.0)]
1367    #[case(f64::INFINITY)]
1368    #[case(f64::NAN)]
1369    fn test_invalid_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1370        let result = minimal_builder!().liquidation_trigger_ratio(ratio).build();
1371        assert!(
1372            matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1373        );
1374    }
1375
1376    #[rstest]
1377    fn test_unparsable_starting_balance_rejected() {
1378        let result = minimal_builder!()
1379            .starting_balances(vec!["not a balance".to_string()])
1380            .build();
1381        assert!(
1382            matches!(result, Err(ConfigError::InvalidFormat { field, .. }) if field == "starting_balances")
1383        );
1384    }
1385
1386    #[rstest]
1387    fn test_valid_starting_balance_accepted() {
1388        let result = minimal_builder!()
1389            .starting_balances(vec!["1_000_000 USD".to_string()])
1390            .build();
1391        assert!(result.is_ok());
1392    }
1393
1394    #[rstest]
1395    fn test_multiple_violations_collected() {
1396        let result = BacktestVenueConfig::builder()
1397            .name("")
1398            .oms_type(OmsType::Netting)
1399            .account_type(AccountType::Margin)
1400            .book_type(BookType::L1_MBP)
1401            .default_leverage(Decimal::ZERO)
1402            .starting_balances(vec!["bad".to_string()])
1403            .build();
1404        let ConfigError::Multiple { errors } = result.unwrap_err() else {
1405            panic!("expected ConfigError::Multiple");
1406        };
1407        assert_eq!(errors.len(), 3);
1408        assert!(
1409            errors
1410                .iter()
1411                .any(|e| matches!(e, ConfigError::EmptyField { field } if field == "name"))
1412        );
1413        assert!(
1414            errors.iter().any(
1415                |e| matches!(e, ConfigError::Range { field, .. } if field == "default_leverage")
1416            )
1417        );
1418        assert!(errors.iter().any(
1419            |e| matches!(e, ConfigError::InvalidFormat { field, .. } if field == "starting_balances")
1420        ));
1421    }
1422
1423    #[rstest]
1424    fn test_minimal_data_config_is_valid() {
1425        let result = BacktestDataConfig::builder()
1426            .data_type(NautilusDataType::QuoteTick)
1427            .catalog_path("/tmp/catalog".to_string())
1428            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1429            .build();
1430        assert!(result.is_ok());
1431    }
1432
1433    #[rstest]
1434    #[case("")]
1435    #[case("   ")]
1436    fn test_empty_catalog_path_rejected(#[case] catalog_path: &str) {
1437        let result = BacktestDataConfig::builder()
1438            .data_type(NautilusDataType::QuoteTick)
1439            .catalog_path(catalog_path.to_string())
1440            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1441            .build();
1442        assert!(
1443            matches!(result, Err(ConfigError::EmptyField { field }) if field == "catalog_path")
1444        );
1445    }
1446
1447    #[rstest]
1448    fn test_inverted_time_range_rejected() {
1449        let result = BacktestDataConfig::builder()
1450            .data_type(NautilusDataType::QuoteTick)
1451            .catalog_path("/tmp/catalog".to_string())
1452            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1453            .start_time(UnixNanos::from(5_000_000_000u64))
1454            .end_time(UnixNanos::from(1_000_000_000u64))
1455            .build();
1456        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start_time"));
1457    }
1458
1459    #[rstest]
1460    fn test_equal_time_range_accepted() {
1461        let result = BacktestDataConfig::builder()
1462            .data_type(NautilusDataType::QuoteTick)
1463            .catalog_path("/tmp/catalog".to_string())
1464            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1465            .start_time(UnixNanos::from(1_000_000_000u64))
1466            .end_time(UnixNanos::from(1_000_000_000u64))
1467            .build();
1468        assert!(result.is_ok());
1469    }
1470
1471    #[rstest]
1472    fn test_missing_identifier_rejected() {
1473        let result = BacktestDataConfig::builder()
1474            .data_type(NautilusDataType::QuoteTick)
1475            .catalog_path("/tmp/catalog".to_string())
1476            .build();
1477        assert!(matches!(result, Err(ConfigError::RequiredOneOf { fields }) if fields.len() == 3));
1478    }
1479
1480    #[rstest]
1481    fn test_empty_instrument_ids_rejected() {
1482        let result = BacktestDataConfig::builder()
1483            .data_type(NautilusDataType::QuoteTick)
1484            .catalog_path("/tmp/catalog".to_string())
1485            .instrument_ids(vec![])
1486            .build();
1487        assert!(matches!(result, Err(ConfigError::RequiredOneOf { .. })));
1488    }
1489
1490    #[rstest]
1491    fn test_bar_types_satisfies_identifier_requirement() {
1492        let result = BacktestDataConfig::builder()
1493            .data_type(NautilusDataType::Bar)
1494            .catalog_path("/tmp/catalog".to_string())
1495            .bar_types(vec!["ETH/USDT.BINANCE-1-MINUTE-LAST-EXTERNAL".to_string()])
1496            .build();
1497        assert!(result.is_ok());
1498    }
1499
1500    #[rstest]
1501    fn test_data_config_multiple_violations_collected() {
1502        let result = BacktestDataConfig::builder()
1503            .data_type(NautilusDataType::QuoteTick)
1504            .catalog_path(String::new())
1505            .start_time(UnixNanos::from(5_000_000_000u64))
1506            .end_time(UnixNanos::from(1_000_000_000u64))
1507            .build();
1508        let ConfigError::Multiple { errors } = result.unwrap_err() else {
1509            panic!("expected ConfigError::Multiple");
1510        };
1511        assert_eq!(errors.len(), 3);
1512    }
1513
1514    macro_rules! minimal_sim_builder {
1515        () => {
1516            SimulatedVenueConfig::builder()
1517                .venue(Venue::from("SIM"))
1518                .oms_type(OmsType::Netting)
1519                .account_type(AccountType::Margin)
1520                .book_type(BookType::L1_MBP)
1521                .starting_balances(vec![Money::from("1_000_000 USD")])
1522        };
1523    }
1524
1525    #[rstest]
1526    fn test_minimal_sim_config_is_valid() {
1527        let config = minimal_sim_builder!().build().unwrap();
1528        assert!(config.defer_option_settlement);
1529    }
1530
1531    #[rstest]
1532    fn test_empty_starting_balances_rejected() {
1533        let result = SimulatedVenueConfig::builder()
1534            .venue(Venue::from("SIM"))
1535            .oms_type(OmsType::Netting)
1536            .account_type(AccountType::Margin)
1537            .book_type(BookType::L1_MBP)
1538            .starting_balances(vec![])
1539            .build();
1540        assert!(
1541            matches!(result, Err(ConfigError::EmptyField { field }) if field == "starting_balances")
1542        );
1543    }
1544
1545    #[rstest]
1546    #[case(Decimal::ZERO)]
1547    #[case(Decimal::from(-1))]
1548    fn test_non_positive_sim_default_leverage_rejected(#[case] leverage: Decimal) {
1549        let result = minimal_sim_builder!().default_leverage(leverage).build();
1550        assert!(
1551            matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1552        );
1553    }
1554
1555    #[rstest]
1556    fn test_positive_sim_default_leverage_accepted() {
1557        assert!(
1558            minimal_sim_builder!()
1559                .default_leverage(Decimal::from(5))
1560                .build()
1561                .is_ok()
1562        );
1563    }
1564
1565    #[rstest]
1566    #[case(0.0)]
1567    #[case(-1.0)]
1568    #[case(f64::INFINITY)]
1569    #[case(f64::NAN)]
1570    fn test_invalid_sim_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1571        let result = minimal_sim_builder!()
1572            .liquidation_trigger_ratio(ratio)
1573            .build();
1574        assert!(
1575            matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1576        );
1577    }
1578
1579    fn minimal_venue() -> BacktestVenueConfig {
1580        minimal_builder!().build().unwrap()
1581    }
1582
1583    #[rstest]
1584    fn test_minimal_run_config_is_valid() {
1585        let result = BacktestRunConfig::builder()
1586            .venues(vec![minimal_venue()])
1587            .data(vec![])
1588            .build();
1589        assert!(result.is_ok());
1590    }
1591
1592    #[rstest]
1593    fn test_run_config_requires_venues() {
1594        let result = BacktestRunConfig::builder()
1595            .venues(vec![])
1596            .data(vec![])
1597            .build();
1598        assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "venues"));
1599    }
1600
1601    #[rstest]
1602    fn test_run_config_inverted_time_range_rejected() {
1603        let result = BacktestRunConfig::builder()
1604            .venues(vec![minimal_venue()])
1605            .data(vec![])
1606            .start(UnixNanos::from(5_000_000_000u64))
1607            .end(UnixNanos::from(1_000_000_000u64))
1608            .build();
1609        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start"));
1610    }
1611
1612    #[rstest]
1613    fn test_run_config_equal_time_range_accepted() {
1614        let result = BacktestRunConfig::builder()
1615            .venues(vec![minimal_venue()])
1616            .data(vec![])
1617            .start(UnixNanos::from(1_000_000_000u64))
1618            .end(UnixNanos::from(1_000_000_000u64))
1619            .build();
1620        assert!(result.is_ok());
1621    }
1622
1623    #[rstest]
1624    fn test_run_config_accepts_chunk_size() {
1625        let config = BacktestRunConfig::builder()
1626            .venues(vec![minimal_venue()])
1627            .data(vec![])
1628            .chunk_size(10)
1629            .build()
1630            .unwrap();
1631        assert_eq!(config.chunk_size(), Some(10));
1632    }
1633
1634    #[rstest]
1635    fn test_run_config_accepts_maximum_chunk_size() {
1636        let config = BacktestRunConfig::builder()
1637            .venues(vec![minimal_venue()])
1638            .data(vec![])
1639            .chunk_size(MAX_BACKTEST_CHUNK_SIZE)
1640            .build()
1641            .unwrap();
1642
1643        assert_eq!(config.chunk_size(), Some(MAX_BACKTEST_CHUNK_SIZE));
1644    }
1645
1646    #[rstest]
1647    fn test_run_config_zero_chunk_size_rejected() {
1648        let result = BacktestRunConfig::builder()
1649            .venues(vec![minimal_venue()])
1650            .data(vec![])
1651            .chunk_size(0)
1652            .build();
1653        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1654    }
1655
1656    #[rstest]
1657    #[case(MAX_BACKTEST_CHUNK_SIZE + 1)]
1658    #[case(usize::MAX)]
1659    fn test_run_config_rejects_oversized_chunk_size(#[case] chunk_size: usize) {
1660        let result = BacktestRunConfig::builder()
1661            .venues(vec![minimal_venue()])
1662            .data(vec![])
1663            .chunk_size(chunk_size)
1664            .build();
1665
1666        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1667    }
1668
1669    #[rstest]
1670    fn test_run_config_multiple_violations_collected() {
1671        let result = BacktestRunConfig::builder()
1672            .venues(vec![])
1673            .data(vec![])
1674            .start(UnixNanos::from(5_000_000_000u64))
1675            .end(UnixNanos::from(1_000_000_000u64))
1676            .build();
1677        let ConfigError::Multiple { errors } = result.unwrap_err() else {
1678            panic!("expected ConfigError::Multiple");
1679        };
1680        assert_eq!(errors.len(), 2);
1681    }
1682}