Skip to main content

nautilus_backtest/
config.rs

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