finance_query/backtesting/engine/
mod.rs1mod 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
25pub(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
53pub struct BacktestEngine {
57 config: BacktestConfig,
58}
59
60impl BacktestEngine {
61 pub fn new(config: BacktestConfig) -> Self {
63 Self { config }
64 }
65
66 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 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 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 let mut prices = vec![100.0; 30];
142 for (i, price) in prices.iter_mut().enumerate().take(25).skip(15) {
144 *price = 100.0 + (i - 15) as f64 * 2.0;
145 }
146 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 let mut prices = vec![100.0; 20];
172 for (i, price) in prices.iter_mut().enumerate().take(15).skip(10) {
174 *price = 100.0 + (i - 10) as f64 * 2.0;
175 }
176 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) .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 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 assert!(!result.equity_curve.is_empty());
209 }
210
211 #[test]
212 fn test_trailing_stop() {
213 let mut prices: Vec<f64> = (0..20).map(|i| 100.0 + i as f64).collect();
215 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 let _ = trail_exits;
244 assert!(!result.equity_curve.is_empty());
245 }
246}