1use serde::{Deserialize, Serialize};
9
10pub trait BacktestModel {
12 type Event;
13 type State: Clone;
14 type Action: Clone;
15
16 fn initial_state(&self) -> Self::State;
17 fn initial_value(&self, state: &Self::State) -> f64;
18 fn timestamp(&self, event: &Self::Event) -> i64;
19
20 fn decide(
22 &mut self,
23 event: &Self::Event,
24 state: &Self::State,
25 ) -> Result<Vec<Self::Action>, String>;
26
27 fn apply(
29 &mut self,
30 event: &Self::Event,
31 action: &Self::Action,
32 state: &mut Self::State,
33 ) -> Result<(), String>;
34
35 fn value(&self, event: &Self::Event, state: &Self::State) -> f64;
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct BacktestConfig {
41 pub max_events: usize,
43 pub capture_every: usize,
45}
46
47impl Default for BacktestConfig {
48 fn default() -> Self {
49 Self {
50 max_events: 1_000_000,
51 capture_every: 1,
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct ValuePoint {
58 pub timestamp: i64,
59 pub value: f64,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct ReplayStep<A> {
64 pub event_index: usize,
65 pub timestamp: i64,
66 pub actions: Vec<A>,
67 pub value: f64,
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct BacktestSummary {
72 pub events_processed: usize,
73 pub actions_applied: usize,
74 pub initial_value: f64,
75 pub final_value: f64,
76 pub total_return: f64,
77 pub max_drawdown: f64,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct BacktestReport<S, A> {
82 pub summary: BacktestSummary,
83 pub final_state: S,
84 pub value_curve: Vec<ValuePoint>,
85 pub steps: Vec<ReplayStep<A>>,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct ProjectionPolicy {
90 pub max_curve_points: usize,
91 pub max_steps: usize,
92}
93
94impl Default for ProjectionPolicy {
95 fn default() -> Self {
96 Self {
97 max_curve_points: 80,
98 max_steps: 24,
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct BacktestProjection<A> {
106 pub summary: BacktestSummary,
107 pub value_curve: Vec<ValuePoint>,
108 pub steps: Vec<ReplayStep<A>>,
109 pub total_curve_points: usize,
110 pub total_steps: usize,
111}
112
113impl<S, A: Clone> BacktestReport<S, A> {
114 pub fn project(
115 &self,
116 policy: ProjectionPolicy,
117 ) -> Result<BacktestProjection<A>, BacktestError> {
118 if policy.max_curve_points < 2 {
119 return Err(BacktestError::InvalidProjection(
120 "max_curve_points must be at least 2".into(),
121 ));
122 }
123 if policy.max_steps == 0 {
124 return Err(BacktestError::InvalidProjection(
125 "max_steps must be greater than 0".into(),
126 ));
127 }
128 Ok(BacktestProjection {
129 summary: self.summary.clone(),
130 value_curve: sample_evenly(&self.value_curve, policy.max_curve_points),
131 steps: sample_evenly(&self.steps, policy.max_steps),
132 total_curve_points: self.value_curve.len(),
133 total_steps: self.steps.len(),
134 })
135 }
136}
137
138#[derive(Debug, thiserror::Error, PartialEq)]
139pub enum BacktestError {
140 #[error("backtest requires at least one historical event")]
141 EmptyEvents,
142 #[error("max_events must be greater than 0")]
143 InvalidEventLimit,
144 #[error("capture_every must be greater than 0")]
145 InvalidCaptureRate,
146 #[error("event count {actual} exceeds configured maximum {maximum}")]
147 EventLimitExceeded { actual: usize, maximum: usize },
148 #[error("initial value must be finite and greater than zero, got {0}")]
149 InvalidInitialValue(f64),
150 #[error("event timestamps are out of order at index {index}: {previous} > {current}")]
151 NonMonotonicTimestamp {
152 index: usize,
153 previous: i64,
154 current: i64,
155 },
156 #[error("decision failed at event {index}: {message}")]
157 Decision { index: usize, message: String },
158 #[error("state transition failed at event {index}, action {action_index}: {message}")]
159 Transition {
160 index: usize,
161 action_index: usize,
162 message: String,
163 },
164 #[error("model returned a non-finite value at event {index}: {value}")]
165 NonFiniteValue { index: usize, value: f64 },
166 #[error("invalid projection policy: {0}")]
167 InvalidProjection(String),
168}
169
170pub fn run_backtest<M: BacktestModel>(
173 model: &mut M,
174 events: &[M::Event],
175 config: BacktestConfig,
176) -> Result<BacktestReport<M::State, M::Action>, BacktestError> {
177 if events.is_empty() {
178 return Err(BacktestError::EmptyEvents);
179 }
180 if config.max_events == 0 {
181 return Err(BacktestError::InvalidEventLimit);
182 }
183 if config.capture_every == 0 {
184 return Err(BacktestError::InvalidCaptureRate);
185 }
186 if events.len() > config.max_events {
187 return Err(BacktestError::EventLimitExceeded {
188 actual: events.len(),
189 maximum: config.max_events,
190 });
191 }
192
193 let timestamps = events
194 .iter()
195 .map(|event| model.timestamp(event))
196 .collect::<Vec<_>>();
197 for (index, pair) in timestamps.windows(2).enumerate() {
198 if pair[1] < pair[0] {
199 return Err(BacktestError::NonMonotonicTimestamp {
200 index: index + 1,
201 previous: pair[0],
202 current: pair[1],
203 });
204 }
205 }
206
207 let mut state = model.initial_state();
208 let initial_value = model.initial_value(&state);
209 if !initial_value.is_finite() || initial_value <= 0.0 {
210 return Err(BacktestError::InvalidInitialValue(initial_value));
211 }
212
213 let mut value_curve = Vec::with_capacity(events.len());
214 let mut steps = Vec::with_capacity(events.len().div_ceil(config.capture_every));
215 let mut actions_applied = 0;
216
217 for (index, event) in events.iter().enumerate() {
218 let timestamp = timestamps[index];
219
220 let actions = model
221 .decide(event, &state)
222 .map_err(|message| BacktestError::Decision { index, message })?;
223 for (action_index, action) in actions.iter().enumerate() {
224 model.apply(event, action, &mut state).map_err(|message| {
225 BacktestError::Transition {
226 index,
227 action_index,
228 message,
229 }
230 })?;
231 actions_applied += 1;
232 }
233
234 let value = model.value(event, &state);
235 if !value.is_finite() {
236 return Err(BacktestError::NonFiniteValue { index, value });
237 }
238 value_curve.push(ValuePoint { timestamp, value });
239 if index % config.capture_every == 0 || index + 1 == events.len() {
240 steps.push(ReplayStep {
241 event_index: index,
242 timestamp,
243 actions,
244 value,
245 });
246 }
247 }
248
249 let final_value = value_curve.last().expect("events is non-empty").value;
250 let summary = BacktestSummary {
251 events_processed: events.len(),
252 actions_applied,
253 initial_value,
254 final_value,
255 total_return: (final_value / initial_value) - 1.0,
256 max_drawdown: max_drawdown(initial_value, &value_curve),
257 };
258 Ok(BacktestReport {
259 summary,
260 final_state: state,
261 value_curve,
262 steps,
263 })
264}
265
266fn max_drawdown(initial_value: f64, curve: &[ValuePoint]) -> f64 {
267 let mut peak = initial_value;
268 let mut maximum = 0.0_f64;
269 for point in curve {
270 peak = peak.max(point.value);
271 if peak > 0.0 {
272 maximum = maximum.max((peak - point.value) / peak);
273 }
274 }
275 maximum
276}
277
278fn sample_evenly<T: Clone>(items: &[T], maximum: usize) -> Vec<T> {
279 if items.len() <= maximum {
280 return items.to_vec();
281 }
282 if maximum == 1 {
283 return vec![items[items.len() - 1].clone()];
284 }
285 (0..maximum)
286 .map(|slot| {
287 let index = slot * (items.len() - 1) / (maximum - 1);
288 items[index].clone()
289 })
290 .collect()
291}