1#![deny(missing_docs)]
9#![deny(rustdoc::broken_intra_doc_links)]
10
11use serde::{Deserialize, Serialize};
12
13pub trait BacktestModel {
15 type Event;
17 type State: Clone;
19 type Action: Clone;
21
22 fn initial_state(&self) -> Self::State;
24 fn initial_value(&self, state: &Self::State) -> f64;
26 fn timestamp(&self, event: &Self::Event) -> i64;
28
29 fn decide(
31 &mut self,
32 event: &Self::Event,
33 state: &Self::State,
34 ) -> Result<Vec<Self::Action>, String>;
35
36 fn apply(
38 &mut self,
39 event: &Self::Event,
40 action: &Self::Action,
41 state: &mut Self::State,
42 ) -> Result<(), String>;
43
44 fn value(&self, event: &Self::Event, state: &Self::State) -> f64;
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct BacktestConfig {
51 pub max_events: usize,
53 pub capture_every: usize,
55}
56
57impl Default for BacktestConfig {
58 fn default() -> Self {
59 Self {
60 max_events: 1_000_000,
61 capture_every: 1,
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct ValuePoint {
69 pub timestamp: i64,
71 pub value: f64,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct ReplayStep<A> {
78 pub event_index: usize,
80 pub timestamp: i64,
82 pub actions: Vec<A>,
84 pub value: f64,
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct BacktestSummary {
91 pub events_processed: usize,
93 pub actions_applied: usize,
95 pub initial_value: f64,
97 pub final_value: f64,
99 pub total_return: f64,
101 pub max_drawdown: f64,
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct BacktestReport<S, A> {
108 pub summary: BacktestSummary,
110 pub final_state: S,
112 pub value_curve: Vec<ValuePoint>,
114 pub steps: Vec<ReplayStep<A>>,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct ProjectionPolicy {
121 pub max_curve_points: usize,
123 pub max_steps: usize,
125}
126
127impl Default for ProjectionPolicy {
128 fn default() -> Self {
129 Self {
130 max_curve_points: 80,
131 max_steps: 24,
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct BacktestProjection<A> {
139 pub summary: BacktestSummary,
141 pub value_curve: Vec<ValuePoint>,
143 pub steps: Vec<ReplayStep<A>>,
145 pub total_curve_points: usize,
147 pub total_steps: usize,
149}
150
151impl<S, A: Clone> BacktestReport<S, A> {
152 pub fn project(
154 &self,
155 policy: ProjectionPolicy,
156 ) -> Result<BacktestProjection<A>, BacktestError> {
157 if policy.max_curve_points < 2 {
158 return Err(BacktestError::InvalidProjection(
159 "max_curve_points must be at least 2".into(),
160 ));
161 }
162 if policy.max_steps == 0 {
163 return Err(BacktestError::InvalidProjection(
164 "max_steps must be greater than 0".into(),
165 ));
166 }
167 Ok(BacktestProjection {
168 summary: self.summary.clone(),
169 value_curve: sample_evenly(&self.value_curve, policy.max_curve_points),
170 steps: sample_evenly(&self.steps, policy.max_steps),
171 total_curve_points: self.value_curve.len(),
172 total_steps: self.steps.len(),
173 })
174 }
175}
176
177#[derive(Debug, thiserror::Error, PartialEq)]
179pub enum BacktestError {
180 #[error("backtest requires at least one historical event")]
182 EmptyEvents,
183 #[error("max_events must be greater than 0")]
185 InvalidEventLimit,
186 #[error("capture_every must be greater than 0")]
188 InvalidCaptureRate,
189 #[error("event count {actual} exceeds configured maximum {maximum}")]
191 EventLimitExceeded {
192 actual: usize,
194 maximum: usize,
196 },
197 #[error("initial value must be finite and greater than zero, got {0}")]
199 InvalidInitialValue(f64),
200 #[error("event timestamps are out of order at index {index}: {previous} > {current}")]
202 NonMonotonicTimestamp {
203 index: usize,
205 previous: i64,
207 current: i64,
209 },
210 #[error("decision failed at event {index}: {message}")]
212 Decision {
213 index: usize,
215 message: String,
217 },
218 #[error("state transition failed at event {index}, action {action_index}: {message}")]
220 Transition {
221 index: usize,
223 action_index: usize,
225 message: String,
227 },
228 #[error("model returned a non-finite value at event {index}: {value}")]
230 NonFiniteValue {
231 index: usize,
233 value: f64,
235 },
236 #[error("invalid projection policy: {0}")]
238 InvalidProjection(String),
239}
240
241pub fn run_backtest<M: BacktestModel>(
244 model: &mut M,
245 events: &[M::Event],
246 config: BacktestConfig,
247) -> Result<BacktestReport<M::State, M::Action>, BacktestError> {
248 if events.is_empty() {
249 return Err(BacktestError::EmptyEvents);
250 }
251 if config.max_events == 0 {
252 return Err(BacktestError::InvalidEventLimit);
253 }
254 if config.capture_every == 0 {
255 return Err(BacktestError::InvalidCaptureRate);
256 }
257 if events.len() > config.max_events {
258 return Err(BacktestError::EventLimitExceeded {
259 actual: events.len(),
260 maximum: config.max_events,
261 });
262 }
263
264 let timestamps = events
265 .iter()
266 .map(|event| model.timestamp(event))
267 .collect::<Vec<_>>();
268 for (index, pair) in timestamps.windows(2).enumerate() {
269 if pair[1] < pair[0] {
270 return Err(BacktestError::NonMonotonicTimestamp {
271 index: index + 1,
272 previous: pair[0],
273 current: pair[1],
274 });
275 }
276 }
277
278 let mut state = model.initial_state();
279 let initial_value = model.initial_value(&state);
280 if !initial_value.is_finite() || initial_value <= 0.0 {
281 return Err(BacktestError::InvalidInitialValue(initial_value));
282 }
283
284 let mut value_curve = Vec::with_capacity(events.len());
285 let mut steps = Vec::with_capacity(events.len().div_ceil(config.capture_every));
286 let mut actions_applied = 0;
287
288 for (index, event) in events.iter().enumerate() {
289 let timestamp = timestamps[index];
290
291 let actions = model
292 .decide(event, &state)
293 .map_err(|message| BacktestError::Decision { index, message })?;
294 for (action_index, action) in actions.iter().enumerate() {
295 model.apply(event, action, &mut state).map_err(|message| {
296 BacktestError::Transition {
297 index,
298 action_index,
299 message,
300 }
301 })?;
302 actions_applied += 1;
303 }
304
305 let value = model.value(event, &state);
306 if !value.is_finite() {
307 return Err(BacktestError::NonFiniteValue { index, value });
308 }
309 value_curve.push(ValuePoint { timestamp, value });
310 if index % config.capture_every == 0 || index + 1 == events.len() {
311 steps.push(ReplayStep {
312 event_index: index,
313 timestamp,
314 actions,
315 value,
316 });
317 }
318 }
319
320 let final_value = value_curve
321 .last()
322 .map_or(initial_value, |point| point.value);
323 let summary = BacktestSummary {
324 events_processed: events.len(),
325 actions_applied,
326 initial_value,
327 final_value,
328 total_return: (final_value / initial_value) - 1.0,
329 max_drawdown: max_drawdown(initial_value, &value_curve),
330 };
331 Ok(BacktestReport {
332 summary,
333 final_state: state,
334 value_curve,
335 steps,
336 })
337}
338
339fn max_drawdown(initial_value: f64, curve: &[ValuePoint]) -> f64 {
340 let mut peak = initial_value;
341 let mut maximum = 0.0_f64;
342 for point in curve {
343 peak = peak.max(point.value);
344 if peak > 0.0 {
345 maximum = maximum.max((peak - point.value) / peak);
346 }
347 }
348 maximum
349}
350
351fn sample_evenly<T: Clone>(items: &[T], maximum: usize) -> Vec<T> {
352 if items.len() <= maximum {
353 return items.to_vec();
354 }
355 if maximum == 1 {
356 return vec![items[items.len() - 1].clone()];
357 }
358 (0..maximum)
359 .map(|slot| {
360 let index = slot * (items.len() - 1) / (maximum - 1);
361 items[index].clone()
362 })
363 .collect()
364}