Skip to main content

nautilus_backtest/
config.rs

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