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