rustrade_backtest/
config.rs1use rustrade_core::Symbol;
4use rustrade_risk::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}
61
62impl BacktestConfig {
63 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 pub fn builder() -> BacktestConfigBuilder {
83 BacktestConfigBuilder::default()
84 }
85}
86
87#[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 pub fn symbol(mut self, sym: impl Into<Symbol>) -> Self {
105 self.symbols = vec![sym.into()];
106 self
107 }
108 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 pub fn initial_cash(mut self, cash: f64) -> Self {
121 self.initial_cash = Some(cash);
122 self
123 }
124 pub fn sizing(mut self, sizing: SizingConfig) -> Self {
126 self.sizing = Some(sizing);
127 self
128 }
129 pub fn slippage(mut self, m: SlippageModel) -> Self {
131 self.slippage = Some(m);
132 self
133 }
134 pub fn fees(mut self, m: FeeModel) -> Self {
136 self.fees = Some(m);
137 self
138 }
139 pub fn contract_value(mut self, cv: f64) -> Self {
141 self.contract_value = Some(cv);
142 self
143 }
144 pub fn risk_free_rate(mut self, r: f64) -> Self {
147 self.risk_free_rate = Some(r);
148 self
149 }
150 pub fn periods_per_year(mut self, n: u32) -> Self {
153 self.periods_per_year = Some(n);
154 self
155 }
156
157 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}