Skip to main content

rustrade_backtest/
config.rs

1//! Backtest configuration + builder.
2
3use rustrade_core::Symbol;
4use rustrade_risk::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}
61
62impl BacktestConfig {
63    /// Convenience accessor for single-symbol configs.
64    ///
65    /// Returns the first (and only) symbol when [`Self::symbols`] is a
66    /// one-element vector. Panics on empty or multi-symbol configs —
67    /// callers that mix scopes should use [`Self::symbols`] directly.
68    pub fn symbol(&self) -> &Symbol {
69        assert_eq!(
70            self.symbols.len(),
71            1,
72            "BacktestConfig::symbol() is only valid for single-symbol backtests; \
73             this config has {} symbols. Use BacktestConfig::symbols instead.",
74            self.symbols.len()
75        );
76        &self.symbols[0]
77    }
78}
79
80impl BacktestConfig {
81    /// Start a [`BacktestConfigBuilder`].
82    pub fn builder() -> BacktestConfigBuilder {
83        BacktestConfigBuilder::default()
84    }
85}
86
87/// Builder for [`BacktestConfig`]. Validates on [`Self::build`].
88#[derive(Debug, Clone, Default)]
89pub struct BacktestConfigBuilder {
90    symbols: Vec<Symbol>,
91    initial_cash: Option<f64>,
92    sizing: Option<SizingConfig>,
93    slippage: Option<SlippageModel>,
94    fees: Option<FeeModel>,
95    contract_value: Option<f64>,
96    risk_free_rate: Option<f64>,
97    periods_per_year: Option<u32>,
98}
99
100impl BacktestConfigBuilder {
101    /// Single symbol to backtest. Convenience wrapper — equivalent to
102    /// calling [`Self::symbols`] with a one-element vector. Repeated
103    /// calls replace any previously set symbols.
104    pub fn symbol(mut self, sym: impl Into<Symbol>) -> Self {
105        self.symbols = vec![sym.into()];
106        self
107    }
108    /// Set the full symbol list. The brain will see events for all
109    /// listed symbols and is responsible for filtering. At least one
110    /// symbol is required.
111    pub fn symbols<I, S>(mut self, syms: I) -> Self
112    where
113        I: IntoIterator<Item = S>,
114        S: Into<Symbol>,
115    {
116        self.symbols = syms.into_iter().map(Into::into).collect();
117        self
118    }
119    /// Override the starting cash balance (default 10_000.0).
120    pub fn initial_cash(mut self, cash: f64) -> Self {
121        self.initial_cash = Some(cash);
122        self
123    }
124    /// Override the position-sizing config.
125    pub fn sizing(mut self, sizing: SizingConfig) -> Self {
126        self.sizing = Some(sizing);
127        self
128    }
129    /// Override the slippage model (default `Zero`).
130    pub fn slippage(mut self, m: SlippageModel) -> Self {
131        self.slippage = Some(m);
132        self
133    }
134    /// Override the fee model (default `Flat(0.0005)`).
135    pub fn fees(mut self, m: FeeModel) -> Self {
136        self.fees = Some(m);
137        self
138    }
139    /// Override the contract multiplier (default 1.0 — spot).
140    pub fn contract_value(mut self, cv: f64) -> Self {
141        self.contract_value = Some(cv);
142        self
143    }
144    /// Per-period risk-free rate for Sharpe / Sortino (default `0.0`).
145    /// See [`BacktestConfig::risk_free_rate`] for the expected scaling.
146    pub fn risk_free_rate(mut self, r: f64) -> Self {
147        self.risk_free_rate = Some(r);
148        self
149    }
150    /// Annualisation factor for Sharpe / Sortino (default `252`).
151    /// See [`BacktestConfig::periods_per_year`] for the typical cadences.
152    pub fn periods_per_year(mut self, n: u32) -> Self {
153        self.periods_per_year = Some(n);
154        self
155    }
156
157    /// Validate and build. Returns `Error::Config` on any constraint
158    /// violation.
159    pub fn build(self) -> Result<BacktestConfig> {
160        if self.symbols.is_empty() {
161            return Err(Error::Config(
162                "BacktestConfig requires at least one symbol".into(),
163            ));
164        }
165        let initial_cash = self.initial_cash.unwrap_or(10_000.0);
166        if !initial_cash.is_finite() || initial_cash <= 0.0 {
167            return Err(Error::Config(
168                "BacktestConfig.initial_cash must be a finite positive number".into(),
169            ));
170        }
171        let contract_value = self.contract_value.unwrap_or(1.0);
172        if !contract_value.is_finite() || contract_value <= 0.0 {
173            return Err(Error::Config(
174                "BacktestConfig.contract_value must be a finite positive number".into(),
175            ));
176        }
177        let risk_free_rate = self.risk_free_rate.unwrap_or(0.0);
178        if !risk_free_rate.is_finite() {
179            return Err(Error::Config(
180                "BacktestConfig.risk_free_rate must be finite".into(),
181            ));
182        }
183        let periods_per_year = self.periods_per_year.unwrap_or(252);
184        if periods_per_year == 0 {
185            return Err(Error::Config(
186                "BacktestConfig.periods_per_year must be > 0".into(),
187            ));
188        }
189        Ok(BacktestConfig {
190            symbols: self.symbols,
191            initial_cash,
192            sizing: self.sizing.unwrap_or_default(),
193            slippage: self.slippage.unwrap_or_default(),
194            fees: self.fees.unwrap_or_default(),
195            contract_value,
196            risk_free_rate,
197            periods_per_year,
198        })
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn requires_symbol() {
208        assert!(matches!(
209            BacktestConfig::builder().build(),
210            Err(Error::Config(_))
211        ));
212    }
213
214    #[test]
215    fn rejects_non_positive_cash() {
216        let r = BacktestConfig::builder()
217            .symbol("BTCUSDT")
218            .initial_cash(-100.0)
219            .build();
220        assert!(matches!(r, Err(Error::Config(_))));
221    }
222
223    #[test]
224    fn rejects_non_positive_contract_value() {
225        let r = BacktestConfig::builder()
226            .symbol("X")
227            .contract_value(0.0)
228            .build();
229        assert!(matches!(r, Err(Error::Config(_))));
230    }
231
232    #[test]
233    fn rejects_zero_periods_per_year() {
234        let r = BacktestConfig::builder()
235            .symbol("X")
236            .periods_per_year(0)
237            .build();
238        assert!(matches!(r, Err(Error::Config(_))));
239    }
240
241    #[test]
242    fn rejects_nan_risk_free_rate() {
243        let r = BacktestConfig::builder()
244            .symbol("X")
245            .risk_free_rate(f64::NAN)
246            .build();
247        assert!(matches!(r, Err(Error::Config(_))));
248    }
249
250    #[test]
251    fn defaults_for_optional_fields() {
252        let c = BacktestConfig::builder().symbol("X").build().unwrap();
253        assert_eq!(c.initial_cash, 10_000.0);
254        assert_eq!(c.contract_value, 1.0);
255        assert_eq!(c.slippage, SlippageModel::Zero);
256        assert_eq!(c.risk_free_rate, 0.0);
257        assert_eq!(c.periods_per_year, 252);
258    }
259
260    #[test]
261    fn multi_symbol_config_round_trips() {
262        let c = BacktestConfig::builder()
263            .symbols(["BTCUSDT", "ETHUSDT", "SOLUSDT"])
264            .build()
265            .unwrap();
266        assert_eq!(c.symbols.len(), 3);
267        assert_eq!(c.symbols[0].as_str(), "BTCUSDT");
268        assert_eq!(c.symbols[2].as_str(), "SOLUSDT");
269    }
270
271    #[test]
272    fn symbol_accessor_panics_on_multi_symbol() {
273        let c = BacktestConfig::builder()
274            .symbols(["A", "B"])
275            .build()
276            .unwrap();
277        let r = std::panic::catch_unwind(|| {
278            let _ = c.symbol();
279        });
280        assert!(r.is_err());
281    }
282
283    #[test]
284    fn symbol_accessor_works_on_single_symbol() {
285        let c = BacktestConfig::builder().symbol("X").build().unwrap();
286        assert_eq!(c.symbol().as_str(), "X");
287    }
288}