Skip to main content

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}