Skip to main content

nautilus_backtest/
config.rs

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