rustrade_backtest/
config.rs1use 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#[derive(Debug, Clone)]
30pub struct BacktestConfig {
31 pub symbols: Vec<Symbol>,
36 pub initial_cash: f64,
39 pub sizing: SizingConfig,
42 pub slippage: SlippageModel,
44 pub fees: FeeModel,
46 pub contract_value: f64,
51 pub risk_free_rate: f64,
56 pub periods_per_year: u32,
60 pub session_pnl: Option<SessionPnlConfig>,
70 pub circuit_breaker: Option<CircuitBreakerConfig>,
75}
76
77impl BacktestConfig {
78 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 pub fn builder() -> BacktestConfigBuilder {
98 BacktestConfigBuilder::default()
99 }
100}
101
102#[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 pub fn symbol(mut self, sym: impl Into<Symbol>) -> Self {
122 self.symbols = vec![sym.into()];
123 self
124 }
125 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 pub fn initial_cash(mut self, cash: f64) -> Self {
138 self.initial_cash = Some(cash);
139 self
140 }
141 pub fn sizing(mut self, sizing: SizingConfig) -> Self {
143 self.sizing = Some(sizing);
144 self
145 }
146 pub fn slippage(mut self, m: SlippageModel) -> Self {
148 self.slippage = Some(m);
149 self
150 }
151 pub fn fees(mut self, m: FeeModel) -> Self {
153 self.fees = Some(m);
154 self
155 }
156 pub fn contract_value(mut self, cv: f64) -> Self {
158 self.contract_value = Some(cv);
159 self
160 }
161 pub fn risk_free_rate(mut self, r: f64) -> Self {
164 self.risk_free_rate = Some(r);
165 self
166 }
167 pub fn periods_per_year(mut self, n: u32) -> Self {
170 self.periods_per_year = Some(n);
171 self
172 }
173 pub fn session_pnl(mut self, cfg: SessionPnlConfig) -> Self {
177 self.session_pnl = Some(cfg);
178 self
179 }
180 pub fn circuit_breaker(mut self, cfg: CircuitBreakerConfig) -> Self {
184 self.circuit_breaker = Some(cfg);
185 self
186 }
187
188 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 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}