Skip to main content

rustrade_backtest/
config.rs

1//! Backtest configuration + builder.
2
3use rustrade_core::Symbol;
4use rustrade_risk::{CircuitBreakerConfig, SessionPnlConfig, SizingConfig};
5
6use crate::error::{Error, Result};
7use crate::fees::FeeModel;
8use crate::slippage::SlippageModel;
9
10/// Configuration for a [`crate::Backtest`].
11///
12/// # Example
13///
14/// ```
15/// use rustrade_backtest::{BacktestConfig, FeeModel, SlippageModel};
16///
17/// let config = BacktestConfig::builder()
18///     .symbol("BTCUSDT")
19///     .initial_cash(10_000.0)
20///     .slippage(SlippageModel::FixedBps(5.0))
21///     .fees(FeeModel::Flat(0.001))
22///     .periods_per_year(252 * 24 * 60) // per-minute Sharpe
23///     .build()
24///     .unwrap();
25///
26/// assert_eq!(config.initial_cash, 10_000.0);
27/// assert_eq!(config.periods_per_year, 252 * 24 * 60);
28/// ```
29#[derive(Debug, Clone)]
30pub struct BacktestConfig {
31    /// Symbols the brain trades. For single-symbol backtests this is a
32    /// one-element vector; events whose symbol is not in the list are
33    /// silently ignored. The engine routes each `MarketDataEvent` to the
34    /// brain with the *current* position for that symbol.
35    pub symbols: Vec<Symbol>,
36    /// Starting cash balance in quote currency. Shared across all
37    /// symbols — there's a single equity curve.
38    pub initial_cash: f64,
39    /// Sizing config — how the brain's `Decision` becomes a contract
40    /// count. Same struct used by the live `ExecutionService`.
41    pub sizing: SizingConfig,
42    /// Slippage policy applied to every fill.
43    pub slippage: SlippageModel,
44    /// Fee schedule applied to every fill.
45    pub fees: FeeModel,
46    /// Base-asset units per contract. For spot adapters this is `1.0`;
47    /// futures adapters override per symbol. Multi-symbol backtests
48    /// share a single multiplier — for mixed spot/futures portfolios
49    /// run each symbol in its own `Backtest` instance.
50    pub contract_value: f64,
51    /// Per-period risk-free rate used by [`crate::BacktestResult::sharpe_ratio`]
52    /// and [`crate::BacktestResult::sortino_ratio`]. Expressed in the same
53    /// cadence as the candles — e.g. for daily candles with a 2 % annual
54    /// rate set this to `0.02 / 252 ≈ 7.94e-5`. Defaults to `0.0`.
55    pub risk_free_rate: f64,
56    /// Annualisation factor for the Sharpe and Sortino ratios. For daily
57    /// candles use `252` (trading days), for hourly `24 * 252`, for
58    /// minute `60 * 24 * 365`, etc. Defaults to `252`.
59    pub periods_per_year: u32,
60    /// Per-symbol session-PnL halt applied during replay — the same gate
61    /// the live `ExecutionService` checks first. When set, each symbol
62    /// gets its own `SessionPnl` driven by **candle time** (so the daily
63    /// halt rolls over at 00:00 UTC in replay time, not wall time), fed
64    /// from every emitted `TradeOutcome`. Once the net session PnL hits
65    /// `loss_limit`, further non-`Hold` decisions for that symbol are
66    /// blocked (counted in `BacktestResult::orders_blocked`) until the
67    /// next UTC day. `None` (the default) disables the gate — existing
68    /// backtests are unaffected.
69    pub session_pnl: Option<SessionPnlConfig>,
70    /// Per-symbol circuit breaker applied during replay — the live
71    /// execution path's second gate. Sliding-window loss counting and the
72    /// cooldown both run on **candle time**. `None` (the default)
73    /// disables the gate.
74    pub circuit_breaker: Option<CircuitBreakerConfig>,
75}
76
77impl BacktestConfig {
78    /// Convenience accessor for single-symbol configs.
79    ///
80    /// Returns the first (and only) symbol when [`Self::symbols`] is a
81    /// one-element vector. Panics on empty or multi-symbol configs —
82    /// callers that mix scopes should use [`Self::symbols`] directly.
83    pub fn symbol(&self) -> &Symbol {
84        assert_eq!(
85            self.symbols.len(),
86            1,
87            "BacktestConfig::symbol() is only valid for single-symbol backtests; \
88             this config has {} symbols. Use BacktestConfig::symbols instead.",
89            self.symbols.len()
90        );
91        &self.symbols[0]
92    }
93}
94
95impl BacktestConfig {
96    /// Start a [`BacktestConfigBuilder`].
97    pub fn builder() -> BacktestConfigBuilder {
98        BacktestConfigBuilder::default()
99    }
100}
101
102/// Builder for [`BacktestConfig`]. Validates on [`Self::build`].
103#[derive(Debug, Clone, Default)]
104pub struct BacktestConfigBuilder {
105    symbols: Vec<Symbol>,
106    initial_cash: Option<f64>,
107    sizing: Option<SizingConfig>,
108    slippage: Option<SlippageModel>,
109    fees: Option<FeeModel>,
110    contract_value: Option<f64>,
111    risk_free_rate: Option<f64>,
112    periods_per_year: Option<u32>,
113    session_pnl: Option<SessionPnlConfig>,
114    circuit_breaker: Option<CircuitBreakerConfig>,
115}
116
117impl BacktestConfigBuilder {
118    /// Single symbol to backtest. Convenience wrapper — equivalent to
119    /// calling [`Self::symbols`] with a one-element vector. Repeated
120    /// calls replace any previously set symbols.
121    pub fn symbol(mut self, sym: impl Into<Symbol>) -> Self {
122        self.symbols = vec![sym.into()];
123        self
124    }
125    /// Set the full symbol list. The brain will see events for all
126    /// listed symbols and is responsible for filtering. At least one
127    /// symbol is required.
128    pub fn symbols<I, S>(mut self, syms: I) -> Self
129    where
130        I: IntoIterator<Item = S>,
131        S: Into<Symbol>,
132    {
133        self.symbols = syms.into_iter().map(Into::into).collect();
134        self
135    }
136    /// Override the starting cash balance (default 10_000.0).
137    pub fn initial_cash(mut self, cash: f64) -> Self {
138        self.initial_cash = Some(cash);
139        self
140    }
141    /// Override the position-sizing config.
142    pub fn sizing(mut self, sizing: SizingConfig) -> Self {
143        self.sizing = Some(sizing);
144        self
145    }
146    /// Override the slippage model (default `Zero`).
147    pub fn slippage(mut self, m: SlippageModel) -> Self {
148        self.slippage = Some(m);
149        self
150    }
151    /// Override the fee model (default `Flat(0.0005)`).
152    pub fn fees(mut self, m: FeeModel) -> Self {
153        self.fees = Some(m);
154        self
155    }
156    /// Override the contract multiplier (default 1.0 — spot).
157    pub fn contract_value(mut self, cv: f64) -> Self {
158        self.contract_value = Some(cv);
159        self
160    }
161    /// Per-period risk-free rate for Sharpe / Sortino (default `0.0`).
162    /// See [`BacktestConfig::risk_free_rate`] for the expected scaling.
163    pub fn risk_free_rate(mut self, r: f64) -> Self {
164        self.risk_free_rate = Some(r);
165        self
166    }
167    /// Annualisation factor for Sharpe / Sortino (default `252`).
168    /// See [`BacktestConfig::periods_per_year`] for the typical cadences.
169    pub fn periods_per_year(mut self, n: u32) -> Self {
170        self.periods_per_year = Some(n);
171        self
172    }
173    /// Enable the per-symbol session-PnL halt during replay (off by
174    /// default). Use the same [`SessionPnlConfig`] the live bot runs with
175    /// so the backtest reproduces live gating.
176    pub fn session_pnl(mut self, cfg: SessionPnlConfig) -> Self {
177        self.session_pnl = Some(cfg);
178        self
179    }
180    /// Enable the per-symbol circuit breaker during replay (off by
181    /// default). Use the same [`CircuitBreakerConfig`] the live bot runs
182    /// with so the backtest reproduces live gating.
183    pub fn circuit_breaker(mut self, cfg: CircuitBreakerConfig) -> Self {
184        self.circuit_breaker = Some(cfg);
185        self
186    }
187
188    /// Validate and build. Returns `Error::Config` on any constraint
189    /// violation.
190    pub fn build(self) -> Result<BacktestConfig> {
191        if self.symbols.is_empty() {
192            return Err(Error::Config(
193                "BacktestConfig requires at least one symbol".into(),
194            ));
195        }
196        let initial_cash = self.initial_cash.unwrap_or(10_000.0);
197        if !initial_cash.is_finite() || initial_cash <= 0.0 {
198            return Err(Error::Config(
199                "BacktestConfig.initial_cash must be a finite positive number".into(),
200            ));
201        }
202        let contract_value = self.contract_value.unwrap_or(1.0);
203        if !contract_value.is_finite() || contract_value <= 0.0 {
204            return Err(Error::Config(
205                "BacktestConfig.contract_value must be a finite positive number".into(),
206            ));
207        }
208        let risk_free_rate = self.risk_free_rate.unwrap_or(0.0);
209        if !risk_free_rate.is_finite() {
210            return Err(Error::Config(
211                "BacktestConfig.risk_free_rate must be finite".into(),
212            ));
213        }
214        let periods_per_year = self.periods_per_year.unwrap_or(252);
215        if periods_per_year == 0 {
216            return Err(Error::Config(
217                "BacktestConfig.periods_per_year must be > 0".into(),
218            ));
219        }
220        // Mirror BotConfig's validation: a NaN loss limit would make
221        // every halt comparison silently false (a disabled gate that
222        // looks enabled).
223        if let Some(sp) = &self.session_pnl
224            && sp.loss_limit.is_nan()
225        {
226            return Err(Error::Config(
227                "BacktestConfig.session_pnl.loss_limit must not be NaN".into(),
228            ));
229        }
230        Ok(BacktestConfig {
231            symbols: self.symbols,
232            initial_cash,
233            sizing: self.sizing.unwrap_or_default(),
234            slippage: self.slippage.unwrap_or_default(),
235            fees: self.fees.unwrap_or_default(),
236            contract_value,
237            risk_free_rate,
238            periods_per_year,
239            session_pnl: self.session_pnl,
240            circuit_breaker: self.circuit_breaker,
241        })
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn requires_symbol() {
251        assert!(matches!(
252            BacktestConfig::builder().build(),
253            Err(Error::Config(_))
254        ));
255    }
256
257    #[test]
258    fn rejects_non_positive_cash() {
259        let r = BacktestConfig::builder()
260            .symbol("BTCUSDT")
261            .initial_cash(-100.0)
262            .build();
263        assert!(matches!(r, Err(Error::Config(_))));
264    }
265
266    #[test]
267    fn rejects_non_positive_contract_value() {
268        let r = BacktestConfig::builder()
269            .symbol("X")
270            .contract_value(0.0)
271            .build();
272        assert!(matches!(r, Err(Error::Config(_))));
273    }
274
275    #[test]
276    fn rejects_zero_periods_per_year() {
277        let r = BacktestConfig::builder()
278            .symbol("X")
279            .periods_per_year(0)
280            .build();
281        assert!(matches!(r, Err(Error::Config(_))));
282    }
283
284    #[test]
285    fn rejects_nan_risk_free_rate() {
286        let r = BacktestConfig::builder()
287            .symbol("X")
288            .risk_free_rate(f64::NAN)
289            .build();
290        assert!(matches!(r, Err(Error::Config(_))));
291    }
292
293    #[test]
294    fn defaults_for_optional_fields() {
295        let c = BacktestConfig::builder().symbol("X").build().unwrap();
296        assert_eq!(c.initial_cash, 10_000.0);
297        assert_eq!(c.contract_value, 1.0);
298        assert_eq!(c.slippage, SlippageModel::Zero);
299        assert_eq!(c.risk_free_rate, 0.0);
300        assert_eq!(c.periods_per_year, 252);
301    }
302
303    #[test]
304    fn multi_symbol_config_round_trips() {
305        let c = BacktestConfig::builder()
306            .symbols(["BTCUSDT", "ETHUSDT", "SOLUSDT"])
307            .build()
308            .unwrap();
309        assert_eq!(c.symbols.len(), 3);
310        assert_eq!(c.symbols[0].as_str(), "BTCUSDT");
311        assert_eq!(c.symbols[2].as_str(), "SOLUSDT");
312    }
313
314    #[test]
315    fn symbol_accessor_panics_on_multi_symbol() {
316        let c = BacktestConfig::builder()
317            .symbols(["A", "B"])
318            .build()
319            .unwrap();
320        let r = std::panic::catch_unwind(|| {
321            let _ = c.symbol();
322        });
323        assert!(r.is_err());
324    }
325
326    #[test]
327    fn symbol_accessor_works_on_single_symbol() {
328        let c = BacktestConfig::builder().symbol("X").build().unwrap();
329        assert_eq!(c.symbol().as_str(), "X");
330    }
331}