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