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, string::secret::SecretString};
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    ///
296    /// Must be configured explicitly, including an explicit zero-fee model.
297    /// Missing configuration must not silently turn a fee-paying replay into
298    /// a zero-fee replay.
299    pub fee_model: FeeModelHandle,
300    /// The optional model used to simulate command latency.
301    pub latency_model: Option<LatencyModelHandle>,
302    /// If the execution client supports routing orders to other venues.
303    #[builder(default = false)]
304    pub routing: bool,
305    /// If stop orders already in the market are rejected on submission.
306    #[builder(default = true)]
307    pub reject_stop_orders: bool,
308    /// If good-till-date order expiry is supported.
309    #[builder(default = true)]
310    pub support_gtd_orders: bool,
311    /// If contingent order relationships are supported.
312    #[builder(default = true)]
313    pub support_contingent_orders: bool,
314    /// If venue position IDs are generated.
315    #[builder(default = true)]
316    pub use_position_ids: bool,
317    /// If generated identifiers use random values instead of sequential counters.
318    #[builder(default = false)]
319    pub use_random_ids: bool,
320    /// If reduce-only order restrictions are enforced.
321    #[builder(default = true)]
322    pub use_reduce_only: bool,
323    /// If trading commands are queued instead of processed immediately.
324    #[builder(default = true)]
325    pub use_message_queue: bool,
326    /// If market orders emit acceptance events before filling.
327    #[builder(default = false)]
328    pub use_market_order_acks: bool,
329    /// If bars drive order execution.
330    #[builder(default = true)]
331    pub bar_execution: bool,
332    /// If bar execution visits the high or low closest to the open first.
333    #[builder(default = false)]
334    pub bar_adaptive_high_low_ordering: bool,
335    /// If trade ticks drive order execution.
336    #[builder(default = true)]
337    pub trade_execution: bool,
338    /// If fills consume available liquidity.
339    #[builder(default = false)]
340    pub liquidity_consumption: bool,
341    /// If cash accounts may borrow funds.
342    #[builder(default = false)]
343    pub allow_cash_borrowing: bool,
344    /// If account balances remain unchanged by simulated trading.
345    #[builder(default = false)]
346    pub frozen_account: bool,
347    /// If passive fills account for queue position.
348    #[builder(default = false)]
349    pub queue_position: bool,
350    /// If one-triggers-other orders wait for the parent to fill completely.
351    #[builder(default = false)]
352    pub oto_full_trigger: bool,
353    /// If option settlement waits for expiry processing after same-timestamp market data.
354    #[builder(default = true)]
355    pub defer_option_settlement: bool,
356    /// The market order price protection distance in ticks, or zero to disable protection.
357    #[builder(default = 0)]
358    pub price_protection_points: u32,
359    /// If positions are liquidated when maintenance margin is breached.
360    #[builder(default = false)]
361    pub liquidation_enabled: bool,
362    /// The equity-to-maintenance-margin ratio at or below which liquidation triggers.
363    #[builder(default = 1.0)]
364    pub liquidation_trigger_ratio: f64,
365    /// If open orders are canceled before liquidating positions.
366    #[builder(default = true)]
367    pub liquidation_cancel_open_orders: bool,
368}
369
370impl<S: simulated_venue_config_builder::IsComplete> SimulatedVenueConfigBuilder<S> {
371    /// Validates and builds the [`SimulatedVenueConfig`].
372    ///
373    /// # Errors
374    ///
375    /// Returns an error if any field fails validation (see [`SimulatedVenueConfig::validate`]).
376    pub fn build(self) -> ConfigResult<SimulatedVenueConfig> {
377        let config = self.build_inner();
378        config.validate()?;
379        Ok(config)
380    }
381}
382
383impl SimulatedVenueConfig {
384    /// Validates the venue configuration, collecting every field violation.
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if any field fails validation. Multiple validation failures are collected
389    /// into one error.
390    pub fn validate(&self) -> ConfigResult<()> {
391        let mut errors = ConfigErrorCollector::new();
392
393        if self.starting_balances.is_empty() {
394            errors.push(ConfigError::empty_field("starting_balances"));
395        }
396
397        if let Some(default_leverage) = self.default_leverage {
398            errors.check(
399                default_leverage > Decimal::ZERO,
400                ConfigError::range(
401                    "default_leverage",
402                    format!("must be positive, was {default_leverage}"),
403                ),
404            );
405        }
406
407        for (instrument_id, leverage) in &self.leverages {
408            errors.check(
409                *leverage > Decimal::ZERO,
410                ConfigError::range(
411                    "leverages",
412                    format!("leverage for {instrument_id} must be positive, was {leverage}"),
413                ),
414            );
415        }
416
417        errors.check(
418            self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
419            ConfigError::range(
420                "liquidation_trigger_ratio",
421                format!(
422                    "must be a positive finite value, was {}",
423                    self.liquidation_trigger_ratio
424                ),
425            ),
426        );
427
428        errors.into_result()
429    }
430}
431
432/// Represents a venue configuration for one specific backtest engine.
433#[cfg_attr(
434    feature = "python",
435    pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
436)]
437#[cfg_attr(
438    feature = "python",
439    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
440)]
441#[expect(
442    clippy::struct_excessive_bools,
443    reason = "venue config fields mirror the existing Rust and Python backtest surfaces"
444)]
445#[derive(Debug, Clone, bon::Builder)]
446#[builder(finish_fn(name = build_inner, vis = ""))]
447pub struct BacktestVenueConfig {
448    /// The name of the venue.
449    #[builder(into)]
450    name: Ustr,
451    /// The order management system type for the exchange. If ``HEDGING`` will generate new position IDs.
452    oms_type: OmsType,
453    /// The account type for the exchange.
454    account_type: AccountType,
455    /// The default order book type.
456    book_type: BookType,
457    /// The starting account balances (specify one for a single asset account).
458    #[builder(default)]
459    starting_balances: Vec<String>,
460    /// If multi-venue routing should be enabled for the execution client.
461    #[builder(default)]
462    routing: bool,
463    /// If the account for this exchange is frozen (balances will not change).
464    #[builder(default)]
465    frozen_account: bool,
466    /// If stop orders are rejected on submission if trigger price is in the market.
467    #[builder(default = true)]
468    reject_stop_orders: bool,
469    /// If orders with GTD time in force will be supported by the venue.
470    #[builder(default = true)]
471    support_gtd_orders: bool,
472    /// If contingent orders will be supported/respected by the venue.
473    /// If False, then it's expected the strategy will be managing any contingent orders.
474    #[builder(default = true)]
475    support_contingent_orders: bool,
476    /// If venue position IDs will be generated on order fills.
477    #[builder(default = true)]
478    use_position_ids: bool,
479    /// If venue order IDs and position IDs will be random UUID4's.
480    /// Trade IDs are always deterministic and not affected by this flag.
481    #[builder(default)]
482    use_random_ids: bool,
483    /// If the `reduce_only` execution instruction on orders will be enforced.
484    /// If false, reduce-only orders are rejected.
485    #[builder(default = true)]
486    use_reduce_only: bool,
487    /// If bars should be processed by the matching engine(s) (and move the market).
488    #[builder(default = true)]
489    bar_execution: bool,
490    /// Determines whether the processing order of bar prices is adaptive based on a heuristic.
491    /// This setting is only relevant when `bar_execution` is True.
492    /// If False, bar prices are always processed in the fixed order: Open, High, Low, Close.
493    /// If True, the processing order adapts with the heuristic:
494    /// - If High is closer to Open than Low then the processing order is Open, High, Low, Close.
495    /// - If Low is closer to Open than High then the processing order is Open, Low, High, Close.
496    #[builder(default)]
497    bar_adaptive_high_low_ordering: bool,
498    /// If trades should be processed by the matching engine(s) (and move the market).
499    #[builder(default = true)]
500    trade_execution: bool,
501    /// If `OrderAccepted` events should be generated for market orders.
502    #[builder(default)]
503    use_market_order_acks: bool,
504    /// If order book liquidity consumption should be tracked per level.
505    #[builder(default)]
506    liquidity_consumption: bool,
507    /// If negative cash balances are allowed (borrowing).
508    #[builder(default)]
509    allow_cash_borrowing: bool,
510    /// If limit order queue position tracking is enabled during trade execution.
511    #[builder(default)]
512    queue_position: bool,
513    /// When OTO child orders are released relative to parent fills.
514    #[builder(default)]
515    oto_trigger_mode: OtoTriggerMode,
516    /// The account base currency for the exchange. Use `None` for multi-currency accounts.
517    base_currency: Option<Currency>,
518    /// The account default leverage, or `None` to use the account-type default.
519    default_leverage: Option<Decimal>,
520    /// The instrument specific leverage configuration (for margin accounts).
521    leverages: Option<AHashMap<InstrumentId, Decimal>>,
522    /// The margin model for the venue.
523    margin_model: Option<MarginModelAny>,
524    /// The simulation modules for the venue.
525    #[builder(default)]
526    modules: Vec<SimulationModuleAny>,
527    /// The fill model for the venue.
528    fill_model: Option<FillModelAny>,
529    /// The latency model for the venue.
530    latency_model: Option<LatencyModelAny>,
531    /// The fee model for the venue.
532    ///
533    /// Required when building engines from this config, including an explicit
534    /// zero-fee model; node build fails without one.
535    fee_model: Option<FeeModelAny>,
536    /// Defines an exchange-calculated price boundary to prevent a market order from being
537    /// filled at an extremely aggressive price.
538    #[builder(default)]
539    price_protection_points: u32,
540    /// If liquidation of positions should be triggered when maintenance margin is breached.
541    #[builder(default)]
542    liquidation_enabled: bool,
543    /// The ratio of equity to maintenance margin at which liquidation is triggered.
544    /// A value of 1.0 means liquidation triggers when equity <= `maintenance_margin`.
545    #[builder(default = 1.0)]
546    liquidation_trigger_ratio: f64,
547    /// If open orders should be canceled before closing positions during liquidation.
548    #[builder(default = true)]
549    liquidation_cancel_open_orders: bool,
550}
551
552impl<S: backtest_venue_config_builder::IsComplete> BacktestVenueConfigBuilder<S> {
553    /// Validates and builds the [`BacktestVenueConfig`].
554    ///
555    /// # Errors
556    ///
557    /// Returns an error if any field fails validation (see [`BacktestVenueConfig::validate`]).
558    pub fn build(self) -> ConfigResult<BacktestVenueConfig> {
559        let config = self.build_inner();
560        config.validate()?;
561        Ok(config)
562    }
563}
564
565impl BacktestVenueConfig {
566    /// Validates the venue configuration, collecting every field violation.
567    ///
568    /// # Errors
569    ///
570    /// Returns an error if any field fails validation. Multiple validation failures are collected
571    /// into one error.
572    pub fn validate(&self) -> ConfigResult<()> {
573        let mut errors = ConfigErrorCollector::new();
574
575        if self.name.is_empty() {
576            errors.push(ConfigError::empty_field("name"));
577        } else if let Err(e) = Venue::new_checked(self.name.as_str()) {
578            errors.push(ConfigError::invalid_value(
579                "name",
580                format!("must be a valid venue identifier ({e})"),
581            ));
582        }
583
584        if let Some(default_leverage) = self.default_leverage {
585            errors.check(
586                default_leverage > Decimal::ZERO,
587                ConfigError::range(
588                    "default_leverage",
589                    format!("must be positive, was {default_leverage}"),
590                ),
591            );
592        }
593
594        if let Some(leverages) = &self.leverages {
595            for (instrument_id, leverage) in leverages {
596                errors.check(
597                    *leverage > Decimal::ZERO,
598                    ConfigError::range(
599                        "leverages",
600                        format!("leverage for {instrument_id} must be positive, was {leverage}"),
601                    ),
602                );
603            }
604        }
605        errors.check(
606            self.liquidation_trigger_ratio.is_finite() && self.liquidation_trigger_ratio > 0.0,
607            ConfigError::range(
608                "liquidation_trigger_ratio",
609                format!(
610                    "must be a positive finite value, was {}",
611                    self.liquidation_trigger_ratio
612                ),
613            ),
614        );
615
616        for balance in &self.starting_balances {
617            if let Err(reason) = balance.parse::<Money>() {
618                errors.push(ConfigError::invalid_format(
619                    "starting_balances",
620                    format!("a valid money string, was '{balance}' ({reason})"),
621                ));
622            }
623        }
624
625        errors.into_result()
626    }
627
628    #[must_use]
629    pub fn name(&self) -> Ustr {
630        self.name
631    }
632
633    #[must_use]
634    pub fn oms_type(&self) -> OmsType {
635        self.oms_type
636    }
637
638    #[must_use]
639    pub fn account_type(&self) -> AccountType {
640        self.account_type
641    }
642
643    #[must_use]
644    pub fn book_type(&self) -> BookType {
645        self.book_type
646    }
647
648    #[must_use]
649    pub fn starting_balances(&self) -> &[String] {
650        &self.starting_balances
651    }
652
653    #[must_use]
654    pub fn routing(&self) -> bool {
655        self.routing
656    }
657
658    #[must_use]
659    pub fn frozen_account(&self) -> bool {
660        self.frozen_account
661    }
662
663    #[must_use]
664    pub fn reject_stop_orders(&self) -> bool {
665        self.reject_stop_orders
666    }
667
668    #[must_use]
669    pub fn support_gtd_orders(&self) -> bool {
670        self.support_gtd_orders
671    }
672
673    #[must_use]
674    pub fn support_contingent_orders(&self) -> bool {
675        self.support_contingent_orders
676    }
677
678    #[must_use]
679    pub fn use_position_ids(&self) -> bool {
680        self.use_position_ids
681    }
682
683    #[must_use]
684    pub fn use_random_ids(&self) -> bool {
685        self.use_random_ids
686    }
687
688    #[must_use]
689    pub fn use_reduce_only(&self) -> bool {
690        self.use_reduce_only
691    }
692
693    #[must_use]
694    pub fn bar_execution(&self) -> bool {
695        self.bar_execution
696    }
697
698    #[must_use]
699    pub fn bar_adaptive_high_low_ordering(&self) -> bool {
700        self.bar_adaptive_high_low_ordering
701    }
702
703    #[must_use]
704    pub fn trade_execution(&self) -> bool {
705        self.trade_execution
706    }
707
708    #[must_use]
709    pub fn use_market_order_acks(&self) -> bool {
710        self.use_market_order_acks
711    }
712
713    #[must_use]
714    pub fn liquidity_consumption(&self) -> bool {
715        self.liquidity_consumption
716    }
717
718    #[must_use]
719    pub fn allow_cash_borrowing(&self) -> bool {
720        self.allow_cash_borrowing
721    }
722
723    #[must_use]
724    pub fn queue_position(&self) -> bool {
725        self.queue_position
726    }
727
728    #[must_use]
729    pub fn oto_trigger_mode(&self) -> OtoTriggerMode {
730        self.oto_trigger_mode
731    }
732
733    #[must_use]
734    pub fn base_currency(&self) -> Option<Currency> {
735        self.base_currency
736    }
737
738    #[must_use]
739    pub fn default_leverage(&self) -> Option<Decimal> {
740        self.default_leverage
741    }
742
743    #[must_use]
744    pub fn leverages(&self) -> Option<&AHashMap<InstrumentId, Decimal>> {
745        self.leverages.as_ref()
746    }
747
748    #[must_use]
749    pub fn margin_model(&self) -> Option<&MarginModelAny> {
750        self.margin_model.as_ref()
751    }
752
753    #[must_use]
754    pub fn modules(&self) -> &[SimulationModuleAny] {
755        &self.modules
756    }
757
758    #[must_use]
759    pub fn fill_model(&self) -> Option<&FillModelAny> {
760        self.fill_model.as_ref()
761    }
762
763    #[must_use]
764    pub fn latency_model(&self) -> Option<&LatencyModelAny> {
765        self.latency_model.as_ref()
766    }
767
768    #[must_use]
769    pub fn fee_model(&self) -> Option<&FeeModelAny> {
770        self.fee_model.as_ref()
771    }
772
773    #[must_use]
774    pub fn price_protection_points(&self) -> u32 {
775        self.price_protection_points
776    }
777
778    #[must_use]
779    pub fn liquidation_enabled(&self) -> bool {
780        self.liquidation_enabled
781    }
782
783    #[must_use]
784    pub fn liquidation_trigger_ratio(&self) -> f64 {
785        self.liquidation_trigger_ratio
786    }
787
788    #[must_use]
789    pub fn liquidation_cancel_open_orders(&self) -> bool {
790        self.liquidation_cancel_open_orders
791    }
792}
793
794/// Represents the data configuration for one specific backtest run.
795#[derive(Debug, Clone, bon::Builder)]
796#[builder(finish_fn(name = build_inner, vis = ""))]
797#[cfg_attr(
798    feature = "python",
799    pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
800)]
801#[cfg_attr(
802    feature = "python",
803    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
804)]
805pub struct BacktestDataConfig {
806    /// The type of data to query from the catalog.
807    data_type: NautilusDataType,
808    /// The path to the data catalog.
809    catalog_path: String,
810    /// Catalog backend used for data loading.
811    #[builder(default)]
812    #[cfg(feature = "streaming")]
813    catalog_backend: CatalogBackendType,
814    /// The `fsspec` filesystem protocol for the catalog.
815    catalog_fs_protocol: Option<String>,
816    /// The filesystem storage options for the catalog (e.g. cloud auth credentials).
817    catalog_fs_storage_options: Option<AHashMap<String, SecretString>>,
818    /// Rust-specific storage options for the catalog backend.
819    catalog_fs_rust_storage_options: Option<AHashMap<String, SecretString>>,
820    /// The instrument ID for the data configuration (single).
821    instrument_id: Option<InstrumentId>,
822    /// Multiple instrument IDs for the data configuration.
823    instrument_ids: Option<Vec<InstrumentId>>,
824    /// The start time for the data configuration.
825    start_time: Option<UnixNanos>,
826    /// The end time for the data configuration.
827    end_time: Option<UnixNanos>,
828    /// The additional filter expressions for the data catalog query.
829    filter_expr: Option<String>,
830    /// The client ID for the data configuration.
831    client_id: Option<ClientId>,
832    /// The metadata for the data catalog query.
833    metadata: Option<AHashMap<String, String>>,
834    /// The bar specification for the data catalog query.
835    bar_spec: Option<BarSpecification>,
836    /// Explicit bar type strings for the data catalog query (e.g. "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
837    bar_types: Option<Vec<String>>,
838    /// If directory-based file registration should be used for more efficient loading.
839    #[builder(default)]
840    optimize_file_loading: bool,
841    /// If order book deltas should replay as `OrderBookDeltas` batches closed by the `F_LAST` flag.
842    #[builder(default = true)]
843    batch_deltas: bool,
844}
845
846impl<S: backtest_data_config_builder::IsComplete> BacktestDataConfigBuilder<S> {
847    /// Validates and builds the [`BacktestDataConfig`].
848    ///
849    /// # Errors
850    ///
851    /// Returns an error if any field fails validation (see [`BacktestDataConfig::validate`]).
852    pub fn build(self) -> ConfigResult<BacktestDataConfig> {
853        let config = self.build_inner();
854        config.validate()?;
855        Ok(config)
856    }
857}
858
859impl BacktestDataConfig {
860    /// Returns the configured catalog backend.
861    #[must_use]
862    #[cfg(feature = "streaming")]
863    pub fn catalog_backend(&self) -> CatalogBackendType {
864        self.catalog_backend.clone()
865    }
866
867    /// Validates the data configuration, collecting every field violation.
868    ///
869    /// # Errors
870    ///
871    /// Returns an error if any field fails validation. Multiple validation failures are collected
872    /// into one error.
873    pub fn validate(&self) -> ConfigResult<()> {
874        let mut errors = ConfigErrorCollector::new();
875
876        errors.check(
877            matches!(
878                self.data_type,
879                NautilusDataType::OrderBookDelta
880                    | NautilusDataType::OrderBookDepth
881                    | NautilusDataType::QuoteTick
882                    | NautilusDataType::TradeTick
883                    | NautilusDataType::Bar
884                    | NautilusDataType::MarkPriceUpdate
885                    | NautilusDataType::IndexPriceUpdate
886                    | NautilusDataType::FundingRateUpdate
887                    | NautilusDataType::OptionGreeks
888                    | NautilusDataType::InstrumentStatus
889                    | NautilusDataType::InstrumentClose
890                    | NautilusDataType::Instrument
891            ),
892            ConfigError::unsupported_value(
893                "data_type",
894                format!("{} is not supported by BacktestDataConfig", self.data_type),
895            ),
896        );
897
898        if self.catalog_path.trim().is_empty() {
899            errors.push(ConfigError::empty_field("catalog_path"));
900        }
901
902        if let (Some(start), Some(end)) = (self.start_time, self.end_time) {
903            errors.check(
904                start <= end,
905                ConfigError::range(
906                    "start_time",
907                    format!("must be <= end_time, was {start} > {end}"),
908                ),
909            );
910        }
911
912        let has_identifier = self.instrument_id.is_some()
913            || self
914                .instrument_ids
915                .as_ref()
916                .is_some_and(|ids| !ids.is_empty())
917            || self.bar_types.as_ref().is_some_and(|bars| !bars.is_empty());
918        errors.check(
919            has_identifier,
920            ConfigError::required_one_of(["instrument_id", "instrument_ids", "bar_types"]),
921        );
922
923        errors.into_result()
924    }
925
926    #[must_use]
927    pub const fn data_type(&self) -> &NautilusDataType {
928        &self.data_type
929    }
930
931    #[must_use]
932    pub fn catalog_path(&self) -> &str {
933        &self.catalog_path
934    }
935
936    #[must_use]
937    pub fn catalog_fs_protocol(&self) -> Option<&str> {
938        self.catalog_fs_protocol.as_deref()
939    }
940
941    #[must_use]
942    pub fn catalog_fs_storage_options(&self) -> Option<&AHashMap<String, SecretString>> {
943        self.catalog_fs_storage_options.as_ref()
944    }
945
946    #[must_use]
947    pub fn catalog_fs_rust_storage_options(&self) -> Option<&AHashMap<String, SecretString>> {
948        self.catalog_fs_rust_storage_options.as_ref()
949    }
950
951    #[must_use]
952    pub fn instrument_id(&self) -> Option<InstrumentId> {
953        self.instrument_id
954    }
955
956    #[must_use]
957    pub fn instrument_ids(&self) -> Option<&[InstrumentId]> {
958        self.instrument_ids.as_deref()
959    }
960
961    #[must_use]
962    pub fn start_time(&self) -> Option<UnixNanos> {
963        self.start_time
964    }
965
966    #[must_use]
967    pub fn end_time(&self) -> Option<UnixNanos> {
968        self.end_time
969    }
970
971    #[must_use]
972    pub fn filter_expr(&self) -> Option<&str> {
973        self.filter_expr.as_deref()
974    }
975
976    #[must_use]
977    pub fn client_id(&self) -> Option<ClientId> {
978        self.client_id
979    }
980
981    #[must_use]
982    pub fn metadata(&self) -> Option<&AHashMap<String, String>> {
983        self.metadata.as_ref()
984    }
985
986    #[must_use]
987    pub fn bar_spec(&self) -> Option<BarSpecification> {
988        self.bar_spec
989    }
990
991    #[must_use]
992    pub fn bar_types(&self) -> Option<&[String]> {
993        self.bar_types.as_deref()
994    }
995
996    #[must_use]
997    pub fn optimize_file_loading(&self) -> bool {
998        self.optimize_file_loading
999    }
1000
1001    #[must_use]
1002    pub fn batch_deltas(&self) -> bool {
1003        self.batch_deltas
1004    }
1005
1006    /// Constructs identifier strings for catalog queries.
1007    ///
1008    /// Follows the same logic as Python's `BacktestDataConfig.query`:
1009    /// - For bars: prefer `bar_types`, else construct from instrument(s) + `bar_spec` + "-EXTERNAL"
1010    /// - For other types: use `instrument_id` or `instrument_ids`
1011    #[must_use]
1012    pub fn query_identifiers(&self) -> Option<Vec<String>> {
1013        if self.data_type == NautilusDataType::Bar {
1014            if let Some(bar_types) = &self.bar_types
1015                && !bar_types.is_empty()
1016            {
1017                return Some(bar_types.clone());
1018            }
1019
1020            // Construct from instrument_id + bar_spec
1021            if let Some(bar_spec) = &self.bar_spec {
1022                if let Some(id) = self.instrument_id {
1023                    return Some(vec![format!("{id}-{bar_spec}-EXTERNAL")]);
1024                }
1025
1026                if let Some(ids) = &self.instrument_ids {
1027                    let bar_types: Vec<String> = ids
1028                        .iter()
1029                        .map(|id| format!("{id}-{bar_spec}-EXTERNAL"))
1030                        .collect();
1031
1032                    if !bar_types.is_empty() {
1033                        return Some(bar_types);
1034                    }
1035                }
1036            }
1037        }
1038
1039        // Fallback: instrument_id or instrument_ids
1040        if let Some(id) = self.instrument_id {
1041            return Some(vec![id.to_string()]);
1042        }
1043
1044        if let Some(ids) = &self.instrument_ids {
1045            let strs: Vec<String> = ids.iter().map(ToString::to_string).collect();
1046            if !strs.is_empty() {
1047                return Some(strs);
1048            }
1049        }
1050
1051        None
1052    }
1053
1054    /// Returns all instrument IDs referenced by this config.
1055    ///
1056    /// For `bar_types`, extracts the instrument ID from each bar type string.
1057    ///
1058    /// # Errors
1059    ///
1060    /// Returns an error if any bar type string cannot be parsed.
1061    pub fn get_instrument_ids(&self) -> anyhow::Result<Vec<InstrumentId>> {
1062        if let Some(id) = self.instrument_id {
1063            return Ok(vec![id]);
1064        }
1065
1066        if let Some(ids) = &self.instrument_ids {
1067            return Ok(ids.clone());
1068        }
1069
1070        if let Some(bar_types) = &self.bar_types {
1071            let ids = bar_types
1072                .iter()
1073                .map(|bt| {
1074                    bt.parse::<BarType>()
1075                        .map(|b| b.instrument_id())
1076                        .map_err(|_| anyhow::anyhow!("Invalid bar type string: '{bt}'"))
1077                })
1078                .collect::<anyhow::Result<Vec<_>>>()?;
1079            return Ok(ids);
1080        }
1081        Ok(Vec::new())
1082    }
1083}
1084
1085/// Represents the configuration for one specific backtest run.
1086/// This includes a backtest engine with its actors and strategies, with the external inputs of venues and data.
1087#[derive(Debug, Clone, bon::Builder)]
1088#[builder(finish_fn(name = build_inner, vis = ""))]
1089#[cfg_attr(
1090    feature = "python",
1091    pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object, unsendable)
1092)]
1093#[cfg_attr(
1094    feature = "python",
1095    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
1096)]
1097pub struct BacktestRunConfig {
1098    /// The unique identifier for this run configuration.
1099    #[builder(default = UUID4::new().to_string())]
1100    id: String,
1101    /// The venue configurations for the backtest run.
1102    venues: Vec<BacktestVenueConfig>,
1103    /// The data configurations for the backtest run.
1104    data: Vec<BacktestDataConfig>,
1105    /// The backtest engine configuration (the core system kernel).
1106    #[builder(default)]
1107    engine: BacktestEngineConfig,
1108    /// The number of data points to process in each chunk during streaming mode
1109    /// (range `[1, 1_000_000]`).
1110    /// If `None`, the backtest will run without streaming, loading all data at once.
1111    chunk_size: Option<usize>,
1112    /// If exceptions during build or run should interrupt processing.
1113    #[builder(default)]
1114    raise_exception: bool,
1115    /// If the backtest engine should be disposed on completion of the run.
1116    /// If `True`, then will drop data and all state.
1117    /// If `False`, then will *only* drop data.
1118    #[builder(default = true)]
1119    dispose_on_completion: bool,
1120    /// The start datetime (UTC) for the backtest run.
1121    /// If `None` engine runs from the start of the data.
1122    start: Option<UnixNanos>,
1123    /// The end datetime (UTC) for the backtest run.
1124    /// If `None` engine runs to the end of the data.
1125    end: Option<UnixNanos>,
1126}
1127
1128impl<S: backtest_run_config_builder::IsComplete> BacktestRunConfigBuilder<S> {
1129    /// Validates and builds the [`BacktestRunConfig`].
1130    ///
1131    /// # Errors
1132    ///
1133    /// Returns an error if any field fails validation (see [`BacktestRunConfig::validate`]).
1134    pub fn build(self) -> ConfigResult<BacktestRunConfig> {
1135        let config = self.build_inner();
1136        config.validate()?;
1137        Ok(config)
1138    }
1139}
1140
1141impl BacktestRunConfig {
1142    /// Validates the run configuration, collecting every field violation.
1143    ///
1144    /// # Errors
1145    ///
1146    /// Returns an error if any field fails validation. Multiple validation failures are collected
1147    /// into one error.
1148    pub fn validate(&self) -> ConfigResult<()> {
1149        let mut errors = ConfigErrorCollector::new();
1150
1151        if self.venues.is_empty() {
1152            errors.push(ConfigError::empty_field("venues"));
1153        }
1154
1155        if let (Some(start), Some(end)) = (self.start, self.end) {
1156            errors.check(
1157                start <= end,
1158                ConfigError::range("start", format!("must be <= end, was {start} > {end}")),
1159            );
1160        }
1161
1162        if let Some(chunk_size) = self.chunk_size {
1163            errors.check(
1164                (1..=MAX_BACKTEST_CHUNK_SIZE).contains(&chunk_size),
1165                ConfigError::range(
1166                    "chunk_size",
1167                    format!("must be in range [1, {MAX_BACKTEST_CHUNK_SIZE}], was {chunk_size}"),
1168                ),
1169            );
1170        }
1171
1172        errors.into_result()
1173    }
1174
1175    #[must_use]
1176    pub fn id(&self) -> &str {
1177        &self.id
1178    }
1179
1180    #[must_use]
1181    pub fn venues(&self) -> &[BacktestVenueConfig] {
1182        &self.venues
1183    }
1184
1185    #[must_use]
1186    pub fn data(&self) -> &[BacktestDataConfig] {
1187        &self.data
1188    }
1189
1190    #[must_use]
1191    pub fn engine(&self) -> &BacktestEngineConfig {
1192        &self.engine
1193    }
1194
1195    #[must_use]
1196    pub fn chunk_size(&self) -> Option<usize> {
1197        self.chunk_size
1198    }
1199
1200    #[must_use]
1201    pub fn raise_exception(&self) -> bool {
1202        self.raise_exception
1203    }
1204
1205    #[must_use]
1206    pub fn dispose_on_completion(&self) -> bool {
1207        self.dispose_on_completion
1208    }
1209
1210    #[must_use]
1211    pub fn start(&self) -> Option<UnixNanos> {
1212        self.start
1213    }
1214
1215    #[must_use]
1216    pub fn end(&self) -> Option<UnixNanos> {
1217        self.end
1218    }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use nautilus_core::string::secret::REDACTED;
1224    use nautilus_execution::models::fee::MakerTakerFeeModel;
1225    use rstest::rstest;
1226
1227    use super::*;
1228
1229    macro_rules! minimal_builder {
1230        () => {
1231            BacktestVenueConfig::builder()
1232                .name("SIM")
1233                .oms_type(OmsType::Netting)
1234                .account_type(AccountType::Margin)
1235                .book_type(BookType::L1_MBP)
1236        };
1237    }
1238
1239    #[rstest]
1240    #[case(NautilusDataType::OrderBookDelta)]
1241    #[case(NautilusDataType::OrderBookDepth)]
1242    #[case(NautilusDataType::QuoteTick)]
1243    #[case(NautilusDataType::TradeTick)]
1244    #[case(NautilusDataType::Bar)]
1245    #[case(NautilusDataType::MarkPriceUpdate)]
1246    #[case(NautilusDataType::IndexPriceUpdate)]
1247    #[case(NautilusDataType::FundingRateUpdate)]
1248    #[case(NautilusDataType::OptionGreeks)]
1249    #[case(NautilusDataType::InstrumentStatus)]
1250    #[case(NautilusDataType::InstrumentClose)]
1251    fn test_data_config_accepts_supported_family(#[case] data_type: NautilusDataType) {
1252        let config = BacktestDataConfig::builder()
1253            .data_type(data_type.clone())
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(), &data_type);
1260    }
1261
1262    #[rstest]
1263    fn test_data_config_accepts_the_instrument_family() {
1264        let config = BacktestDataConfig::builder()
1265            .data_type(NautilusDataType::Instrument)
1266            .catalog_path("/tmp/catalog".to_string())
1267            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1268            .build()
1269            .unwrap();
1270
1271        assert_eq!(config.data_type(), &NautilusDataType::Instrument);
1272    }
1273
1274    #[rstest]
1275    #[case(NautilusDataType::Custom { type_name: "Signal".to_string() })]
1276    fn test_data_config_rejects_unsupported_family(#[case] data_type: NautilusDataType) {
1277        let error = BacktestDataConfig::builder()
1278            .data_type(data_type.clone())
1279            .catalog_path("/tmp/catalog".to_string())
1280            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1281            .build()
1282            .unwrap_err();
1283
1284        assert_eq!(
1285            error,
1286            ConfigError::unsupported_value(
1287                "data_type",
1288                format!("{data_type} is not supported by BacktestDataConfig"),
1289            ),
1290        );
1291    }
1292
1293    macro_rules! minimal_simulated_builder {
1294        () => {
1295            SimulatedVenueConfig::builder()
1296                .venue(Venue::from("SIM"))
1297                .oms_type(OmsType::Netting)
1298                .account_type(AccountType::Margin)
1299                .book_type(BookType::L1_MBP)
1300                .starting_balances(vec![Money::from("1_000_000 USD")])
1301                .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1302        };
1303    }
1304
1305    #[rstest]
1306    fn test_minimal_config_is_valid() {
1307        assert!(minimal_builder!().build().is_ok());
1308    }
1309
1310    #[rstest]
1311    fn test_default_leverage_is_optional() {
1312        let config = minimal_builder!().build().unwrap();
1313
1314        assert_eq!(config.default_leverage(), None);
1315    }
1316
1317    #[rstest]
1318    fn test_empty_name_rejected() {
1319        let result = BacktestVenueConfig::builder()
1320            .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::EmptyField { field }) if field == "name"));
1326    }
1327
1328    #[rstest]
1329    #[case("   ")]
1330    #[case("vénue")]
1331    fn test_invalid_venue_name_rejected(#[case] name: &str) {
1332        let result = BacktestVenueConfig::builder()
1333            .name(name)
1334            .oms_type(OmsType::Netting)
1335            .account_type(AccountType::Margin)
1336            .book_type(BookType::L1_MBP)
1337            .build();
1338        assert!(matches!(result, Err(ConfigError::InvalidValue { field, .. }) if field == "name"));
1339    }
1340
1341    #[rstest]
1342    #[case(Decimal::ZERO)]
1343    #[case(Decimal::from(-1))]
1344    fn test_non_positive_default_leverage_rejected(#[case] leverage: Decimal) {
1345        let result = minimal_builder!().default_leverage(leverage).build();
1346        assert!(
1347            matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1348        );
1349    }
1350
1351    #[rstest]
1352    fn test_non_positive_instrument_leverage_rejected() {
1353        let mut leverages = AHashMap::new();
1354        leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
1355        let result = minimal_builder!().leverages(leverages).build();
1356        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1357    }
1358
1359    #[rstest]
1360    #[case(Decimal::ZERO)]
1361    #[case(Decimal::from(-1))]
1362    fn test_simulated_non_positive_instrument_leverage_rejected(#[case] leverage: Decimal) {
1363        let mut leverages = AHashMap::new();
1364        leverages.insert(InstrumentId::from("ESZ21.GLBX"), leverage);
1365        let result = minimal_simulated_builder!().leverages(leverages).build();
1366        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "leverages"));
1367    }
1368
1369    #[rstest]
1370    fn test_simulated_positive_instrument_leverage_accepted() {
1371        let mut leverages = AHashMap::new();
1372        leverages.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::from(10));
1373        let result = minimal_simulated_builder!().leverages(leverages).build();
1374        assert!(result.is_ok());
1375    }
1376
1377    #[rstest]
1378    #[case(0.0)]
1379    #[case(-1.0)]
1380    #[case(f64::INFINITY)]
1381    #[case(f64::NAN)]
1382    fn test_invalid_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1383        let result = minimal_builder!().liquidation_trigger_ratio(ratio).build();
1384        assert!(
1385            matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1386        );
1387    }
1388
1389    #[rstest]
1390    fn test_unparsable_starting_balance_rejected() {
1391        let result = minimal_builder!()
1392            .starting_balances(vec!["not a balance".to_string()])
1393            .build();
1394        assert!(
1395            matches!(result, Err(ConfigError::InvalidFormat { field, .. }) if field == "starting_balances")
1396        );
1397    }
1398
1399    #[rstest]
1400    fn test_valid_starting_balance_accepted() {
1401        let result = minimal_builder!()
1402            .starting_balances(vec!["1_000_000 USD".to_string()])
1403            .build();
1404        assert!(result.is_ok());
1405    }
1406
1407    #[rstest]
1408    fn test_multiple_violations_collected() {
1409        let result = BacktestVenueConfig::builder()
1410            .name("")
1411            .oms_type(OmsType::Netting)
1412            .account_type(AccountType::Margin)
1413            .book_type(BookType::L1_MBP)
1414            .default_leverage(Decimal::ZERO)
1415            .starting_balances(vec!["bad".to_string()])
1416            .build();
1417        let ConfigError::Multiple { errors } = result.unwrap_err() else {
1418            panic!("expected ConfigError::Multiple");
1419        };
1420        assert_eq!(errors.len(), 3);
1421        assert!(
1422            errors
1423                .iter()
1424                .any(|e| matches!(e, ConfigError::EmptyField { field } if field == "name"))
1425        );
1426        assert!(
1427            errors.iter().any(
1428                |e| matches!(e, ConfigError::Range { field, .. } if field == "default_leverage")
1429            )
1430        );
1431        assert!(errors.iter().any(
1432            |e| matches!(e, ConfigError::InvalidFormat { field, .. } if field == "starting_balances")
1433        ));
1434    }
1435
1436    #[rstest]
1437    fn test_minimal_data_config_is_valid() {
1438        let result = BacktestDataConfig::builder()
1439            .data_type(NautilusDataType::QuoteTick)
1440            .catalog_path("/tmp/catalog".to_string())
1441            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1442            .build();
1443        assert!(result.is_ok());
1444    }
1445
1446    #[rstest]
1447    #[case("")]
1448    #[case("   ")]
1449    fn test_empty_catalog_path_rejected(#[case] catalog_path: &str) {
1450        let result = BacktestDataConfig::builder()
1451            .data_type(NautilusDataType::QuoteTick)
1452            .catalog_path(catalog_path.to_string())
1453            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1454            .build();
1455        assert!(
1456            matches!(result, Err(ConfigError::EmptyField { field }) if field == "catalog_path")
1457        );
1458    }
1459
1460    #[rstest]
1461    fn test_inverted_time_range_rejected() {
1462        let result = BacktestDataConfig::builder()
1463            .data_type(NautilusDataType::QuoteTick)
1464            .catalog_path("/tmp/catalog".to_string())
1465            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1466            .start_time(UnixNanos::from(5_000_000_000u64))
1467            .end_time(UnixNanos::from(1_000_000_000u64))
1468            .build();
1469        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start_time"));
1470    }
1471
1472    #[rstest]
1473    fn test_equal_time_range_accepted() {
1474        let result = BacktestDataConfig::builder()
1475            .data_type(NautilusDataType::QuoteTick)
1476            .catalog_path("/tmp/catalog".to_string())
1477            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1478            .start_time(UnixNanos::from(1_000_000_000u64))
1479            .end_time(UnixNanos::from(1_000_000_000u64))
1480            .build();
1481        assert!(result.is_ok());
1482    }
1483
1484    #[rstest]
1485    fn test_missing_identifier_rejected() {
1486        let result = BacktestDataConfig::builder()
1487            .data_type(NautilusDataType::QuoteTick)
1488            .catalog_path("/tmp/catalog".to_string())
1489            .build();
1490        assert!(matches!(result, Err(ConfigError::RequiredOneOf { fields }) if fields.len() == 3));
1491    }
1492
1493    #[rstest]
1494    fn test_empty_instrument_ids_rejected() {
1495        let result = BacktestDataConfig::builder()
1496            .data_type(NautilusDataType::QuoteTick)
1497            .catalog_path("/tmp/catalog".to_string())
1498            .instrument_ids(vec![])
1499            .build();
1500        assert!(matches!(result, Err(ConfigError::RequiredOneOf { .. })));
1501    }
1502
1503    #[rstest]
1504    fn test_bar_types_satisfies_identifier_requirement() {
1505        let result = BacktestDataConfig::builder()
1506            .data_type(NautilusDataType::Bar)
1507            .catalog_path("/tmp/catalog".to_string())
1508            .bar_types(vec!["ETH/USDT.BINANCE-1-MINUTE-LAST-EXTERNAL".to_string()])
1509            .build();
1510        assert!(result.is_ok());
1511    }
1512
1513    #[rstest]
1514    fn test_data_config_multiple_violations_collected() {
1515        let result = BacktestDataConfig::builder()
1516            .data_type(NautilusDataType::QuoteTick)
1517            .catalog_path(String::new())
1518            .start_time(UnixNanos::from(5_000_000_000u64))
1519            .end_time(UnixNanos::from(1_000_000_000u64))
1520            .build();
1521        let ConfigError::Multiple { errors } = result.unwrap_err() else {
1522            panic!("expected ConfigError::Multiple");
1523        };
1524        assert_eq!(errors.len(), 3);
1525    }
1526
1527    #[rstest]
1528    fn test_data_config_debug_redacts_storage_option_values() {
1529        let config = BacktestDataConfig::builder()
1530            .data_type(NautilusDataType::QuoteTick)
1531            .catalog_path("/tmp/catalog".to_string())
1532            .instrument_id(InstrumentId::from("ETH/USDT.BINANCE"))
1533            .catalog_fs_storage_options(AHashMap::from([(
1534                "key".to_string(),
1535                SecretString::from("fs-key-sentinel"),
1536            )]))
1537            .catalog_fs_rust_storage_options(AHashMap::from([(
1538                "aws_secret_access_key".to_string(),
1539                SecretString::from("rust-secret-sentinel"),
1540            )]))
1541            .build()
1542            .unwrap();
1543
1544        let debug_output = format!("{config:?}");
1545
1546        assert!(!debug_output.contains("fs-key-sentinel"));
1547        assert!(!debug_output.contains("rust-secret-sentinel"));
1548        assert!(debug_output.contains(&format!(
1549            "catalog_fs_storage_options: Some({{\"key\": {REDACTED}}})"
1550        )));
1551        assert!(debug_output.contains(&format!(
1552            "catalog_fs_rust_storage_options: Some({{\"aws_secret_access_key\": {REDACTED}}})"
1553        )));
1554    }
1555
1556    macro_rules! minimal_sim_builder {
1557        () => {
1558            SimulatedVenueConfig::builder()
1559                .venue(Venue::from("SIM"))
1560                .oms_type(OmsType::Netting)
1561                .account_type(AccountType::Margin)
1562                .book_type(BookType::L1_MBP)
1563                .starting_balances(vec![Money::from("1_000_000 USD")])
1564                .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1565        };
1566    }
1567
1568    #[rstest]
1569    fn test_minimal_sim_config_is_valid() {
1570        let config = minimal_sim_builder!().build().unwrap();
1571        assert!(config.defer_option_settlement);
1572    }
1573
1574    #[rstest]
1575    fn test_empty_starting_balances_rejected() {
1576        let result = SimulatedVenueConfig::builder()
1577            .venue(Venue::from("SIM"))
1578            .oms_type(OmsType::Netting)
1579            .account_type(AccountType::Margin)
1580            .book_type(BookType::L1_MBP)
1581            .starting_balances(vec![])
1582            .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1583            .build();
1584        assert!(
1585            matches!(result, Err(ConfigError::EmptyField { field }) if field == "starting_balances")
1586        );
1587    }
1588
1589    #[rstest]
1590    #[case(Decimal::ZERO)]
1591    #[case(Decimal::from(-1))]
1592    fn test_non_positive_sim_default_leverage_rejected(#[case] leverage: Decimal) {
1593        let result = minimal_sim_builder!().default_leverage(leverage).build();
1594        assert!(
1595            matches!(result, Err(ConfigError::Range { field, .. }) if field == "default_leverage")
1596        );
1597    }
1598
1599    #[rstest]
1600    fn test_positive_sim_default_leverage_accepted() {
1601        assert!(
1602            minimal_sim_builder!()
1603                .default_leverage(Decimal::from(5))
1604                .build()
1605                .is_ok()
1606        );
1607    }
1608
1609    #[rstest]
1610    #[case(0.0)]
1611    #[case(-1.0)]
1612    #[case(f64::INFINITY)]
1613    #[case(f64::NAN)]
1614    fn test_invalid_sim_liquidation_trigger_ratio_rejected(#[case] ratio: f64) {
1615        let result = minimal_sim_builder!()
1616            .liquidation_trigger_ratio(ratio)
1617            .build();
1618        assert!(
1619            matches!(result, Err(ConfigError::Range { field, .. }) if field == "liquidation_trigger_ratio")
1620        );
1621    }
1622
1623    fn minimal_venue() -> BacktestVenueConfig {
1624        minimal_builder!().build().unwrap()
1625    }
1626
1627    #[rstest]
1628    fn test_minimal_run_config_is_valid() {
1629        let result = BacktestRunConfig::builder()
1630            .venues(vec![minimal_venue()])
1631            .data(vec![])
1632            .build();
1633        assert!(result.is_ok());
1634    }
1635
1636    #[rstest]
1637    fn test_run_config_requires_venues() {
1638        let result = BacktestRunConfig::builder()
1639            .venues(vec![])
1640            .data(vec![])
1641            .build();
1642        assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "venues"));
1643    }
1644
1645    #[rstest]
1646    fn test_run_config_inverted_time_range_rejected() {
1647        let result = BacktestRunConfig::builder()
1648            .venues(vec![minimal_venue()])
1649            .data(vec![])
1650            .start(UnixNanos::from(5_000_000_000u64))
1651            .end(UnixNanos::from(1_000_000_000u64))
1652            .build();
1653        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "start"));
1654    }
1655
1656    #[rstest]
1657    fn test_run_config_equal_time_range_accepted() {
1658        let result = BacktestRunConfig::builder()
1659            .venues(vec![minimal_venue()])
1660            .data(vec![])
1661            .start(UnixNanos::from(1_000_000_000u64))
1662            .end(UnixNanos::from(1_000_000_000u64))
1663            .build();
1664        assert!(result.is_ok());
1665    }
1666
1667    #[rstest]
1668    fn test_run_config_accepts_chunk_size() {
1669        let config = BacktestRunConfig::builder()
1670            .venues(vec![minimal_venue()])
1671            .data(vec![])
1672            .chunk_size(10)
1673            .build()
1674            .unwrap();
1675        assert_eq!(config.chunk_size(), Some(10));
1676    }
1677
1678    #[rstest]
1679    fn test_run_config_accepts_maximum_chunk_size() {
1680        let config = BacktestRunConfig::builder()
1681            .venues(vec![minimal_venue()])
1682            .data(vec![])
1683            .chunk_size(MAX_BACKTEST_CHUNK_SIZE)
1684            .build()
1685            .unwrap();
1686
1687        assert_eq!(config.chunk_size(), Some(MAX_BACKTEST_CHUNK_SIZE));
1688    }
1689
1690    #[rstest]
1691    fn test_run_config_zero_chunk_size_rejected() {
1692        let result = BacktestRunConfig::builder()
1693            .venues(vec![minimal_venue()])
1694            .data(vec![])
1695            .chunk_size(0)
1696            .build();
1697        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1698    }
1699
1700    #[rstest]
1701    #[case(MAX_BACKTEST_CHUNK_SIZE + 1)]
1702    #[case(usize::MAX)]
1703    fn test_run_config_rejects_oversized_chunk_size(#[case] chunk_size: usize) {
1704        let result = BacktestRunConfig::builder()
1705            .venues(vec![minimal_venue()])
1706            .data(vec![])
1707            .chunk_size(chunk_size)
1708            .build();
1709
1710        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "chunk_size"));
1711    }
1712
1713    #[rstest]
1714    fn test_run_config_multiple_violations_collected() {
1715        let result = BacktestRunConfig::builder()
1716            .venues(vec![])
1717            .data(vec![])
1718            .start(UnixNanos::from(5_000_000_000u64))
1719            .end(UnixNanos::from(1_000_000_000u64))
1720            .build();
1721        let ConfigError::Multiple { errors } = result.unwrap_err() else {
1722            panic!("expected ConfigError::Multiple");
1723        };
1724        assert_eq!(errors.len(), 2);
1725    }
1726}