Skip to main content

finance_query/backtesting/engine/
mod.rs

1//! Backtest execution engine.
2
3mod benchmark;
4mod exits;
5#[cfg(test)]
6mod fixtures;
7mod indicators;
8mod margin;
9mod positions;
10mod simulate;
11mod sizing;
12
13pub(crate) use exits::{check_sl_tp, update_position_extremes, update_trailing_hwm};
14pub(crate) use indicators::compute_for_candles;
15pub(crate) use sizing::SizingSeries;
16
17use crate::models::chart::{Candle, Dividend};
18
19use self::benchmark::compute_benchmark_metrics;
20use super::config::BacktestConfig;
21use super::error::{BacktestError, Result};
22use super::result::BacktestResult;
23use super::strategy::Strategy;
24
25/// Reject candle or dividend series that are not ascending by timestamp.
26///
27/// Conditions binary-search the candle slice to locate the position entry, so an
28/// out-of-order series would silently yield a wrong index rather than an error.
29/// Dividend crediting walks a forward-only index for the same reason.
30///
31/// Optimisers and walk-forward call this once for the whole series and then use
32/// [`BacktestEngine::simulate`] per candidate, so a sweep pays the O(n) scan once
33/// instead of once per evaluation.
34pub(crate) fn validate_series_order(candles: &[Candle], dividends: &[Dividend]) -> Result<()> {
35    if !candles.windows(2).all(|w| w[0].timestamp <= w[1].timestamp) {
36        return Err(BacktestError::invalid_param(
37            "candles",
38            "must be sorted by timestamp (ascending)",
39        ));
40    }
41    if !dividends
42        .windows(2)
43        .all(|w| w[0].timestamp <= w[1].timestamp)
44    {
45        return Err(BacktestError::invalid_param(
46            "dividends",
47            "must be sorted by timestamp (ascending)",
48        ));
49    }
50    Ok(())
51}
52
53/// Backtest execution engine.
54///
55/// Handles indicator pre-computation, position management, and trade execution.
56pub struct BacktestEngine {
57    config: BacktestConfig,
58}
59
60impl BacktestEngine {
61    /// Create a new backtest engine with the given configuration
62    pub fn new(config: BacktestConfig) -> Self {
63        Self { config }
64    }
65
66    /// Run a backtest with the given strategy on historical candle data.
67    ///
68    /// Dividend income is not included. Use [`run_with_dividends`] to account
69    /// for dividend payments during holding periods.
70    ///
71    /// [`run_with_dividends`]: Self::run_with_dividends
72    pub fn run<S: Strategy>(
73        &self,
74        symbol: &str,
75        candles: &[Candle],
76        strategy: S,
77    ) -> Result<BacktestResult> {
78        validate_series_order(candles, &[])?;
79        self.simulate(symbol, candles, strategy, &[])
80    }
81
82    /// Run a backtest and credit dividend income for any dividends paid while a
83    /// position is open.
84    ///
85    /// `dividends` should be sorted by timestamp (ascending). The engine credits
86    /// each dividend whose ex-date falls on or before the current candle bar.
87    /// When [`BacktestConfig::reinvest_dividends`] is `true`, the income is also
88    /// used to notionally purchase additional shares at the ex-date close price.
89    pub fn run_with_dividends<S: Strategy>(
90        &self,
91        symbol: &str,
92        candles: &[Candle],
93        strategy: S,
94        dividends: &[Dividend],
95    ) -> Result<BacktestResult> {
96        validate_series_order(candles, dividends)?;
97        self.simulate(symbol, candles, strategy, dividends)
98    }
99
100    /// Run a backtest and compare against a benchmark, optionally crediting dividends.
101    ///
102    /// The result's `benchmark` field is populated with buy-and-hold comparison
103    /// metrics including alpha, beta, and information ratio. The benchmark candle
104    /// slice should cover the same time period as `candles` but need not be the
105    /// same length.
106    ///
107    /// `dividends` must be sorted ascending by timestamp. Pass `&[]` to omit
108    /// dividend processing.
109    pub fn run_with_benchmark<S: Strategy>(
110        &self,
111        symbol: &str,
112        candles: &[Candle],
113        strategy: S,
114        dividends: &[Dividend],
115        benchmark_symbol: &str,
116        benchmark_candles: &[Candle],
117    ) -> Result<BacktestResult> {
118        validate_series_order(candles, dividends)?;
119        let mut result = self.simulate(symbol, candles, strategy, dividends)?;
120        result.benchmark = Some(compute_benchmark_metrics(
121            benchmark_symbol,
122            candles,
123            benchmark_candles,
124            &result.equity_curve,
125            self.config.risk_free_rate,
126            self.config.bars_per_year,
127        ));
128        Ok(result)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::fixtures::make_candles;
135    use super::*;
136    use crate::backtesting::strategy::SmaCrossover;
137
138    #[test]
139    fn test_engine_basic() {
140        // Price trends up then down - should trigger crossover signals
141        let mut prices = vec![100.0; 30];
142        // Make fast SMA cross above slow SMA around bar 15
143        for (i, price) in prices.iter_mut().enumerate().take(25).skip(15) {
144            *price = 100.0 + (i - 15) as f64 * 2.0;
145        }
146        // Then cross back down
147        for (i, price) in prices.iter_mut().enumerate().take(30).skip(25) {
148            *price = 118.0 - (i - 25) as f64 * 3.0;
149        }
150
151        let candles = make_candles(&prices);
152        let config = BacktestConfig::builder()
153            .initial_capital(10_000.0)
154            .commission_pct(0.0)
155            .slippage_pct(0.0)
156            .build()
157            .unwrap();
158
159        let engine = BacktestEngine::new(config);
160        let strategy = SmaCrossover::new(5, 10);
161        let result = engine.run("TEST", &candles, strategy).unwrap();
162
163        assert_eq!(result.symbol, "TEST");
164        assert_eq!(result.strategy_name, "SMA Crossover");
165        assert!(!result.equity_curve.is_empty());
166    }
167
168    #[test]
169    fn test_stop_loss() {
170        // Price drops significantly after entry
171        let mut prices = vec![100.0; 20];
172        // Trend up to trigger long entry
173        for (i, price) in prices.iter_mut().enumerate().take(15).skip(10) {
174            *price = 100.0 + (i - 10) as f64 * 2.0;
175        }
176        // Then crash
177        for (i, price) in prices.iter_mut().enumerate().take(20).skip(15) {
178            *price = 108.0 - (i - 15) as f64 * 10.0;
179        }
180
181        let candles = make_candles(&prices);
182        let config = BacktestConfig::builder()
183            .initial_capital(10_000.0)
184            .stop_loss_pct(0.05) // 5% stop loss
185            .commission_pct(0.0)
186            .slippage_pct(0.0)
187            .build()
188            .unwrap();
189
190        let engine = BacktestEngine::new(config);
191        let strategy = SmaCrossover::new(3, 6);
192        let result = engine.run("TEST", &candles, strategy).unwrap();
193
194        // Should have triggered stop-loss
195        let _sl_signals: Vec<_> = result
196            .signals
197            .iter()
198            .filter(|s| {
199                s.reason
200                    .as_ref()
201                    .map(|r| r.contains("Stop-loss"))
202                    .unwrap_or(false)
203            })
204            .collect();
205
206        // May or may not trigger depending on exact timing
207        // The important thing is the engine doesn't crash
208        assert!(!result.equity_curve.is_empty());
209    }
210
211    #[test]
212    fn test_trailing_stop() {
213        // Price rises to 120, then drops 10%+ → trailing stop should fire
214        let mut prices: Vec<f64> = (0..20).map(|i| 100.0 + i as f64).collect();
215        // Peak is 119; now drop past 10% from peak (< 107.1)
216        prices.extend_from_slice(&[105.0, 103.0, 101.0]);
217
218        let candles = make_candles(&prices);
219        let config = BacktestConfig::builder()
220            .initial_capital(10_000.0)
221            .trailing_stop_pct(0.10)
222            .commission_pct(0.0)
223            .slippage_pct(0.0)
224            .build()
225            .unwrap();
226
227        let engine = BacktestEngine::new(config);
228        let strategy = SmaCrossover::new(3, 6);
229        let result = engine.run("TEST", &candles, strategy).unwrap();
230
231        let trail_exits: Vec<_> = result
232            .signals
233            .iter()
234            .filter(|s| {
235                s.reason
236                    .as_ref()
237                    .map(|r| r.contains("Trailing stop"))
238                    .unwrap_or(false)
239            })
240            .collect();
241
242        // Not guaranteed to fire given the specific crossover timing, but engine must not crash
243        let _ = trail_exits;
244        assert!(!result.equity_curve.is_empty());
245    }
246}