rustrade_backtest/config.rs
1//! Backtest configuration + builder.
2
3use rustrade_core::Symbol;
4use rustrade_risk::{CircuitBreakerConfig, SessionPnlConfig, SizingConfig};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{Error, Result};
8use crate::fees::FeeModel;
9use crate::funding::FundingModel;
10use crate::slippage::SlippageModel;
11
12/// How the replay engine turns orders into fills.
13///
14/// Selected via [`BacktestConfig::fill_model`] (builder:
15/// `.fill_model(FillModel::…)`). Defaults to [`FillModel::TakerAtClose`],
16/// the engine's historical behaviour — existing backtests are
17/// bit-identical with the default.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum FillModel {
21 /// Legacy single-candle fills (the default).
22 ///
23 /// Market / IOC / FOK entries and closes fill as takers at the
24 /// decision candle's **close**. Limit / post-only entries fill only
25 /// if the *decision candle itself* crosses the level (marketability
26 /// judged against the candle **open**; a marketable limit fills at
27 /// the open as taker, a marketable post-only is rejected) and are
28 /// dropped otherwise — nothing rests across candles. Protective
29 /// brackets require **both** SL and TP legs and fill at their fixed
30 /// level even when a candle gaps through it.
31 #[default]
32 TakerAtClose,
33 /// Honest resting-order semantics.
34 ///
35 /// Orders reach the synthetic book at the decision candle's
36 /// **close** (the market price at decision time), so the decision
37 /// candle can never fill a resting order — its range printed before
38 /// the order existed (no lookahead).
39 ///
40 /// - **Market entries and closes** fill at the decision close as
41 /// takers, same as legacy.
42 /// - **Limit / post-only entries** marketable at the close fill
43 /// immediately at the close as takers (a marketable post-only is
44 /// rejected). Non-marketable ones **rest** (GTC) and fill on the
45 /// first later candle that crosses the level: at the **limit
46 /// price** on a cross, or at that candle's **open** when the
47 /// candle gaps through the level — never at a better price than
48 /// the market offered. Resting fills are makers: maker fee rate,
49 /// no slippage.
50 /// - **IOC / FOK entries** that are not marketable at the close are
51 /// cancelled (the engine has no book depth, so both fill in full
52 /// or not at all).
53 /// - **Protective stops** trigger on a cross and fill at the stop
54 /// level *or worse* (gap through → that candle's open), as takers
55 /// with slippage. **Take-profits** are resting limits: level on a
56 /// cross, open on a gap (price improvement), maker fee, no
57 /// slippage. Standalone (stop-only or TP-only) brackets are
58 /// honoured — legacy mode requires both legs.
59 /// - **Same-candle ambiguity** (multiple levels inside one candle's
60 /// range, OHLC path unknown) resolves to the *worse* outcome for
61 /// the strategy: the stop leg fires before the take-profit leg;
62 /// on the candle a resting entry fills, its attached stop may
63 /// fire on that same candle, but its take-profit only becomes
64 /// eligible from the next candle.
65 /// - At most **one resting entry per symbol**: any later non-`Hold`
66 /// decision that passes the risk gates cancels it
67 /// (cancel-and-replace, where the replacement may be nothing).
68 Resting,
69}
70
71/// Configuration for a [`crate::Backtest`].
72///
73/// # Example
74///
75/// ```
76/// use rustrade_backtest::{BacktestConfig, FeeModel, SlippageModel};
77///
78/// let config = BacktestConfig::builder()
79/// .symbol("BTCUSDT")
80/// .initial_cash(10_000.0)
81/// .slippage(SlippageModel::FixedBps(5.0))
82/// .fees(FeeModel::Flat(0.001))
83/// .periods_per_year(252 * 24 * 60) // per-minute Sharpe
84/// .build()
85/// .unwrap();
86///
87/// assert_eq!(config.initial_cash, 10_000.0);
88/// assert_eq!(config.periods_per_year, 252 * 24 * 60);
89/// ```
90#[derive(Debug, Clone)]
91pub struct BacktestConfig {
92 /// Symbols the brain trades. For single-symbol backtests this is a
93 /// one-element vector; events whose symbol is not in the list are
94 /// silently ignored. The engine routes each `MarketDataEvent` to the
95 /// brain with the *current* position for that symbol.
96 pub symbols: Vec<Symbol>,
97 /// Starting cash balance in quote currency. Shared across all
98 /// symbols — there's a single equity curve.
99 pub initial_cash: f64,
100 /// Sizing config — how the brain's `Decision` becomes a contract
101 /// count. Same struct used by the live `ExecutionService`.
102 pub sizing: SizingConfig,
103 /// Slippage policy applied to every fill.
104 pub slippage: SlippageModel,
105 /// Fee schedule applied to every fill.
106 pub fees: FeeModel,
107 /// How orders turn into fills — see [`FillModel`]. Defaults to
108 /// [`FillModel::TakerAtClose`] (the engine's historical behaviour);
109 /// opt in to honest resting limit/stop semantics with
110 /// [`FillModel::Resting`].
111 pub fill_model: FillModel,
112 /// Perp funding-rate schedule applied to open positions at funding
113 /// settlement timestamps (see [`FundingModel`] for the sign
114 /// conventions and window semantics). Multi-symbol backtests share a
115 /// single schedule — for per-symbol funding run each symbol in its
116 /// own `Backtest`, like [`Self::contract_value`]. Defaults to
117 /// [`FundingModel::None`] — existing backtests are unchanged.
118 pub funding: FundingModel,
119 /// Base-asset units per contract. For spot adapters this is `1.0`;
120 /// futures adapters override per symbol. Multi-symbol backtests
121 /// share a single multiplier — for mixed spot/futures portfolios
122 /// run each symbol in its own `Backtest` instance.
123 pub contract_value: f64,
124 /// Per-period risk-free rate used by [`crate::BacktestResult::sharpe_ratio`]
125 /// and [`crate::BacktestResult::sortino_ratio`]. Expressed in the same
126 /// cadence as the candles — e.g. for daily candles with a 2 % annual
127 /// rate set this to `0.02 / 252 ≈ 7.94e-5`. Defaults to `0.0`.
128 pub risk_free_rate: f64,
129 /// Annualisation factor for the Sharpe and Sortino ratios. For daily
130 /// candles use `252` (trading days), for hourly `24 * 252`, for
131 /// minute `60 * 24 * 365`, etc. Defaults to `252`.
132 pub periods_per_year: u32,
133 /// Per-symbol session-PnL halt applied during replay — the same gate
134 /// the live `ExecutionService` checks first. When set, each symbol
135 /// gets its own `SessionPnl` driven by **candle time** (so the daily
136 /// halt rolls over at 00:00 UTC in replay time, not wall time), fed
137 /// from every emitted `TradeOutcome`. Once the net session PnL hits
138 /// `loss_limit`, further non-`Hold` decisions for that symbol are
139 /// blocked (counted in `BacktestResult::orders_blocked`) until the
140 /// next UTC day. `None` (the default) disables the gate — existing
141 /// backtests are unaffected.
142 pub session_pnl: Option<SessionPnlConfig>,
143 /// Per-symbol circuit breaker applied during replay — the live
144 /// execution path's second gate. Sliding-window loss counting and the
145 /// cooldown both run on **candle time**. `None` (the default)
146 /// disables the gate.
147 pub circuit_breaker: Option<CircuitBreakerConfig>,
148}
149
150impl BacktestConfig {
151 /// Convenience accessor for single-symbol configs.
152 ///
153 /// Returns the first (and only) symbol when [`Self::symbols`] is a
154 /// one-element vector. Panics on empty or multi-symbol configs —
155 /// callers that mix scopes should use [`Self::symbols`] directly.
156 pub fn symbol(&self) -> &Symbol {
157 assert_eq!(
158 self.symbols.len(),
159 1,
160 "BacktestConfig::symbol() is only valid for single-symbol backtests; \
161 this config has {} symbols. Use BacktestConfig::symbols instead.",
162 self.symbols.len()
163 );
164 &self.symbols[0]
165 }
166}
167
168impl BacktestConfig {
169 /// Start a [`BacktestConfigBuilder`].
170 pub fn builder() -> BacktestConfigBuilder {
171 BacktestConfigBuilder::default()
172 }
173}
174
175/// Builder for [`BacktestConfig`]. Validates on [`Self::build`].
176#[derive(Debug, Clone, Default)]
177pub struct BacktestConfigBuilder {
178 symbols: Vec<Symbol>,
179 initial_cash: Option<f64>,
180 sizing: Option<SizingConfig>,
181 slippage: Option<SlippageModel>,
182 fees: Option<FeeModel>,
183 fill_model: Option<FillModel>,
184 funding: Option<FundingModel>,
185 contract_value: Option<f64>,
186 risk_free_rate: Option<f64>,
187 periods_per_year: Option<u32>,
188 session_pnl: Option<SessionPnlConfig>,
189 circuit_breaker: Option<CircuitBreakerConfig>,
190}
191
192impl BacktestConfigBuilder {
193 /// Single symbol to backtest. Convenience wrapper — equivalent to
194 /// calling [`Self::symbols`] with a one-element vector. Repeated
195 /// calls replace any previously set symbols.
196 pub fn symbol(mut self, sym: impl Into<Symbol>) -> Self {
197 self.symbols = vec![sym.into()];
198 self
199 }
200 /// Set the full symbol list. The brain will see events for all
201 /// listed symbols and is responsible for filtering. At least one
202 /// symbol is required.
203 pub fn symbols<I, S>(mut self, syms: I) -> Self
204 where
205 I: IntoIterator<Item = S>,
206 S: Into<Symbol>,
207 {
208 self.symbols = syms.into_iter().map(Into::into).collect();
209 self
210 }
211 /// Override the starting cash balance (default 10_000.0).
212 pub fn initial_cash(mut self, cash: f64) -> Self {
213 self.initial_cash = Some(cash);
214 self
215 }
216 /// Override the position-sizing config.
217 pub fn sizing(mut self, sizing: SizingConfig) -> Self {
218 self.sizing = Some(sizing);
219 self
220 }
221 /// Override the slippage model (default `Zero`).
222 pub fn slippage(mut self, m: SlippageModel) -> Self {
223 self.slippage = Some(m);
224 self
225 }
226 /// Override the fee model (default `Flat(0.0005)`).
227 pub fn fees(mut self, m: FeeModel) -> Self {
228 self.fees = Some(m);
229 self
230 }
231 /// Override the fill model (default [`FillModel::TakerAtClose`] —
232 /// the engine's historical single-candle fills). Pass
233 /// [`FillModel::Resting`] for honest resting limit/stop semantics.
234 pub fn fill_model(mut self, m: FillModel) -> Self {
235 self.fill_model = Some(m);
236 self
237 }
238 /// Enable perp funding cashflows (default [`FundingModel::None`] —
239 /// off). Pass a historical [`FundingModel::Series`] when one exists,
240 /// or a [`FundingModel::Constant`] rate + interval as the fallback.
241 pub fn funding(mut self, m: FundingModel) -> Self {
242 self.funding = Some(m);
243 self
244 }
245 /// Override the contract multiplier (default 1.0 — spot).
246 pub fn contract_value(mut self, cv: f64) -> Self {
247 self.contract_value = Some(cv);
248 self
249 }
250 /// Per-period risk-free rate for Sharpe / Sortino (default `0.0`).
251 /// See [`BacktestConfig::risk_free_rate`] for the expected scaling.
252 pub fn risk_free_rate(mut self, r: f64) -> Self {
253 self.risk_free_rate = Some(r);
254 self
255 }
256 /// Annualisation factor for Sharpe / Sortino (default `252`).
257 /// See [`BacktestConfig::periods_per_year`] for the typical cadences.
258 pub fn periods_per_year(mut self, n: u32) -> Self {
259 self.periods_per_year = Some(n);
260 self
261 }
262 /// Enable the per-symbol session-PnL halt during replay (off by
263 /// default). Use the same [`SessionPnlConfig`] the live bot runs with
264 /// so the backtest reproduces live gating.
265 pub fn session_pnl(mut self, cfg: SessionPnlConfig) -> Self {
266 self.session_pnl = Some(cfg);
267 self
268 }
269 /// Enable the per-symbol circuit breaker during replay (off by
270 /// default). Use the same [`CircuitBreakerConfig`] the live bot runs
271 /// with so the backtest reproduces live gating.
272 pub fn circuit_breaker(mut self, cfg: CircuitBreakerConfig) -> Self {
273 self.circuit_breaker = Some(cfg);
274 self
275 }
276
277 /// Validate and build. Returns `Error::Config` on any constraint
278 /// violation.
279 pub fn build(self) -> Result<BacktestConfig> {
280 if self.symbols.is_empty() {
281 return Err(Error::Config(
282 "BacktestConfig requires at least one symbol".into(),
283 ));
284 }
285 let initial_cash = self.initial_cash.unwrap_or(10_000.0);
286 if !initial_cash.is_finite() || initial_cash <= 0.0 {
287 return Err(Error::Config(
288 "BacktestConfig.initial_cash must be a finite positive number".into(),
289 ));
290 }
291 let contract_value = self.contract_value.unwrap_or(1.0);
292 if !contract_value.is_finite() || contract_value <= 0.0 {
293 return Err(Error::Config(
294 "BacktestConfig.contract_value must be a finite positive number".into(),
295 ));
296 }
297 let risk_free_rate = self.risk_free_rate.unwrap_or(0.0);
298 if !risk_free_rate.is_finite() {
299 return Err(Error::Config(
300 "BacktestConfig.risk_free_rate must be finite".into(),
301 ));
302 }
303 let periods_per_year = self.periods_per_year.unwrap_or(252);
304 if periods_per_year == 0 {
305 return Err(Error::Config(
306 "BacktestConfig.periods_per_year must be > 0".into(),
307 ));
308 }
309 // Mirror BotConfig's validation: a NaN loss limit would make
310 // every halt comparison silently false (a disabled gate that
311 // looks enabled).
312 if let Some(sp) = &self.session_pnl
313 && sp.loss_limit.is_nan()
314 {
315 return Err(Error::Config(
316 "BacktestConfig.session_pnl.loss_limit must not be NaN".into(),
317 ));
318 }
319 // Validate + normalise the funding schedule (sorts a Series,
320 // rejects NaN rates / non-positive intervals / duplicate
321 // settlement timestamps).
322 let funding = self
323 .funding
324 .unwrap_or_default()
325 .validated()
326 .map_err(|why| Error::Config(format!("BacktestConfig.funding: {why}")))?;
327 Ok(BacktestConfig {
328 symbols: self.symbols,
329 initial_cash,
330 sizing: self.sizing.unwrap_or_default(),
331 slippage: self.slippage.unwrap_or_default(),
332 fees: self.fees.unwrap_or_default(),
333 fill_model: self.fill_model.unwrap_or_default(),
334 funding,
335 contract_value,
336 risk_free_rate,
337 periods_per_year,
338 session_pnl: self.session_pnl,
339 circuit_breaker: self.circuit_breaker,
340 })
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn requires_symbol() {
350 assert!(matches!(
351 BacktestConfig::builder().build(),
352 Err(Error::Config(_))
353 ));
354 }
355
356 #[test]
357 fn rejects_non_positive_cash() {
358 let r = BacktestConfig::builder()
359 .symbol("BTCUSDT")
360 .initial_cash(-100.0)
361 .build();
362 assert!(matches!(r, Err(Error::Config(_))));
363 }
364
365 #[test]
366 fn rejects_non_positive_contract_value() {
367 let r = BacktestConfig::builder()
368 .symbol("X")
369 .contract_value(0.0)
370 .build();
371 assert!(matches!(r, Err(Error::Config(_))));
372 }
373
374 #[test]
375 fn rejects_zero_periods_per_year() {
376 let r = BacktestConfig::builder()
377 .symbol("X")
378 .periods_per_year(0)
379 .build();
380 assert!(matches!(r, Err(Error::Config(_))));
381 }
382
383 #[test]
384 fn rejects_nan_risk_free_rate() {
385 let r = BacktestConfig::builder()
386 .symbol("X")
387 .risk_free_rate(f64::NAN)
388 .build();
389 assert!(matches!(r, Err(Error::Config(_))));
390 }
391
392 #[test]
393 fn defaults_for_optional_fields() {
394 let c = BacktestConfig::builder().symbol("X").build().unwrap();
395 assert_eq!(c.initial_cash, 10_000.0);
396 assert_eq!(c.contract_value, 1.0);
397 assert_eq!(c.slippage, SlippageModel::Zero);
398 assert_eq!(c.fill_model, FillModel::TakerAtClose);
399 assert_eq!(c.funding, FundingModel::None);
400 assert_eq!(c.risk_free_rate, 0.0);
401 assert_eq!(c.periods_per_year, 252);
402 }
403
404 #[test]
405 fn rejects_invalid_funding_models() {
406 for bad in [
407 FundingModel::Constant {
408 rate: f64::NAN,
409 interval_ms: 1_000,
410 },
411 FundingModel::Constant {
412 rate: 0.0001,
413 interval_ms: 0,
414 },
415 FundingModel::Series(vec![(100, 0.1), (100, 0.2)]),
416 FundingModel::Series(vec![(100, f64::INFINITY)]),
417 ] {
418 let r = BacktestConfig::builder().symbol("X").funding(bad).build();
419 assert!(matches!(r, Err(Error::Config(_))));
420 }
421 }
422
423 #[test]
424 fn build_sorts_funding_series() {
425 let c = BacktestConfig::builder()
426 .symbol("X")
427 .funding(FundingModel::Series(vec![(300, 0.3), (100, 0.1)]))
428 .build()
429 .unwrap();
430 assert_eq!(
431 c.funding,
432 FundingModel::Series(vec![(100, 0.1), (300, 0.3)])
433 );
434 }
435
436 #[test]
437 fn multi_symbol_config_round_trips() {
438 let c = BacktestConfig::builder()
439 .symbols(["BTCUSDT", "ETHUSDT", "SOLUSDT"])
440 .build()
441 .unwrap();
442 assert_eq!(c.symbols.len(), 3);
443 assert_eq!(c.symbols[0].as_str(), "BTCUSDT");
444 assert_eq!(c.symbols[2].as_str(), "SOLUSDT");
445 }
446
447 #[test]
448 fn symbol_accessor_panics_on_multi_symbol() {
449 let c = BacktestConfig::builder()
450 .symbols(["A", "B"])
451 .build()
452 .unwrap();
453 let r = std::panic::catch_unwind(|| {
454 let _ = c.symbol();
455 });
456 assert!(r.is_err());
457 }
458
459 #[test]
460 fn symbol_accessor_works_on_single_symbol() {
461 let c = BacktestConfig::builder().symbol("X").build().unwrap();
462 assert_eq!(c.symbol().as_str(), "X");
463 }
464}