1#![deny(missing_docs)]
9#![deny(rustdoc::broken_intra_doc_links)]
10
11use serde::{Deserialize, Serialize};
12
13#[async_trait::async_trait]
15pub trait BacktestStore: Send + Sync {
16 async fn save_definition(
18 &self,
19 context: &af_context::RequestContext,
20 definition: BacktestDefinition,
21 ) -> Result<(), BacktestStoreError>;
22 async fn start_run(
24 &self,
25 context: &af_context::RequestContext,
26 definition_id: &af_context::BacktestDefinitionId,
27 input_snapshot: serde_json::Value,
28 idempotency_key: &str,
29 ) -> Result<af_context::BacktestRunId, BacktestStoreError>;
30 async fn get_run(
32 &self,
33 context: &af_context::RequestContext,
34 run_id: &af_context::BacktestRunId,
35 ) -> Result<BacktestRun, BacktestStoreError>;
36 async fn cancel_run(
38 &self,
39 context: &af_context::RequestContext,
40 run_id: &af_context::BacktestRunId,
41 ) -> Result<(), BacktestStoreError>;
42 async fn complete_run(
44 &self,
45 context: &af_context::RequestContext,
46 run_id: &af_context::BacktestRunId,
47 report: serde_json::Value,
48 max_bytes: usize,
49 ) -> Result<(), BacktestStoreError>;
50 async fn fail_run(
52 &self,
53 context: &af_context::RequestContext,
54 run_id: &af_context::BacktestRunId,
55 message: &str,
56 ) -> Result<(), BacktestStoreError>;
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct BacktestDefinition {
62 pub id: af_context::BacktestDefinitionId,
64 pub name: String,
66 pub model_version: String,
68 pub config: serde_json::Value,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct BacktestRun {
75 pub id: af_context::BacktestRunId,
77 pub definition: Option<BacktestDefinition>,
79 pub input_snapshot: serde_json::Value,
81 pub status: BacktestRunStatus,
83 pub report: Option<serde_json::Value>,
85 pub error_message: Option<String>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum BacktestRunStatus {
93 Running,
95 Completed,
97 Failed,
99 Cancelled,
101}
102
103#[derive(Debug, thiserror::Error, PartialEq, Eq)]
105pub enum BacktestStoreError {
106 #[error("backtest store unavailable: {0}")]
108 Unavailable(String),
109 #[error("backtest record not found")]
111 NotFound,
112 #[error("backtest run state conflicts with requested mutation")]
114 Conflict,
115 #[error("invalid backtest state: {0}")]
117 Invalid(String),
118 #[error("report size {actual} exceeds maximum {maximum}")]
120 ReportTooLarge {
121 actual: usize,
123 maximum: usize,
125 },
126}
127
128pub trait BacktestModel {
130 type Error: std::error::Error + Send + Sync + 'static;
132 type Event;
134 type State: Clone;
136 type Action: Clone;
138
139 fn initial_state(&self) -> Self::State;
141 fn initial_value(&self, state: &Self::State) -> f64;
143 fn timestamp(&self, event: &Self::Event) -> i64;
145
146 fn decide(
148 &mut self,
149 event: &Self::Event,
150 state: &Self::State,
151 ) -> Result<Vec<Self::Action>, Self::Error>;
152
153 fn apply(
155 &mut self,
156 event: &Self::Event,
157 action: &Self::Action,
158 state: &mut Self::State,
159 ) -> Result<(), Self::Error>;
160
161 fn value(&self, event: &Self::Event, state: &Self::State) -> f64;
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(default, deny_unknown_fields)]
168pub struct BacktestConfig {
169 pub max_events: usize,
171 pub capture_every: usize,
173}
174
175impl Default for BacktestConfig {
176 fn default() -> Self {
177 Self {
178 max_events: 1_000_000,
179 capture_every: 1,
180 }
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186pub struct ValuePoint {
187 pub timestamp: i64,
189 pub value: f64,
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct ReplayStep<A> {
196 pub event_index: usize,
198 pub timestamp: i64,
200 pub actions: Vec<A>,
202 pub value: f64,
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct BacktestSummary {
209 pub events_processed: usize,
211 pub actions_applied: usize,
213 pub initial_value: f64,
215 pub final_value: f64,
217 pub total_return: f64,
219 pub max_drawdown: f64,
221}
222
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225pub struct BacktestReport<S, A> {
226 pub summary: BacktestSummary,
228 pub final_state: S,
230 pub value_curve: Vec<ValuePoint>,
232 pub steps: Vec<ReplayStep<A>>,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub struct ProjectionPolicy {
239 pub max_curve_points: usize,
241 pub max_steps: usize,
243}
244
245impl Default for ProjectionPolicy {
246 fn default() -> Self {
247 Self {
248 max_curve_points: 80,
249 max_steps: 24,
250 }
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256pub struct BacktestProjection<A> {
257 pub summary: BacktestSummary,
259 pub value_curve: Vec<ValuePoint>,
261 pub steps: Vec<ReplayStep<A>>,
263 pub total_curve_points: usize,
265 pub total_steps: usize,
267}
268
269impl<S, A: Clone> BacktestReport<S, A> {
270 pub fn project(
272 &self,
273 policy: ProjectionPolicy,
274 ) -> Result<BacktestProjection<A>, BacktestError> {
275 if policy.max_curve_points < 2 {
276 return Err(BacktestError::InvalidProjection(
277 "max_curve_points must be at least 2".into(),
278 ));
279 }
280 if policy.max_steps == 0 {
281 return Err(BacktestError::InvalidProjection(
282 "max_steps must be greater than 0".into(),
283 ));
284 }
285 Ok(BacktestProjection {
286 summary: self.summary.clone(),
287 value_curve: sample_evenly(&self.value_curve, policy.max_curve_points),
288 steps: sample_evenly(&self.steps, policy.max_steps),
289 total_curve_points: self.value_curve.len(),
290 total_steps: self.steps.len(),
291 })
292 }
293}
294
295#[derive(Debug, thiserror::Error, PartialEq)]
297pub enum BacktestError {
298 #[error("backtest requires at least one historical event")]
300 EmptyEvents,
301 #[error("max_events must be greater than 0")]
303 InvalidEventLimit,
304 #[error("capture_every must be greater than 0")]
306 InvalidCaptureRate,
307 #[error("event count {actual} exceeds configured maximum {maximum}")]
309 EventLimitExceeded {
310 actual: usize,
312 maximum: usize,
314 },
315 #[error("initial value must be finite and greater than zero, got {0}")]
317 InvalidInitialValue(f64),
318 #[error("event timestamps are out of order at index {index}: {previous} > {current}")]
320 NonMonotonicTimestamp {
321 index: usize,
323 previous: i64,
325 current: i64,
327 },
328 #[error("decision failed at event {index}: {message}")]
330 Decision {
331 index: usize,
333 message: String,
335 },
336 #[error("state transition failed at event {index}, action {action_index}: {message}")]
338 Transition {
339 index: usize,
341 action_index: usize,
343 message: String,
345 },
346 #[error("model returned a non-finite value at event {index}: {value}")]
348 NonFiniteValue {
349 index: usize,
351 value: f64,
353 },
354 #[error("invalid projection policy: {0}")]
356 InvalidProjection(String),
357 #[error("backtest cancelled")]
359 Cancelled,
360 #[error("backtest deadline exceeded")]
362 DeadlineExceeded,
363}
364
365pub fn run_backtest<M: BacktestModel>(
368 model: &mut M,
369 events: &[M::Event],
370 config: BacktestConfig,
371) -> Result<BacktestReport<M::State, M::Action>, BacktestError> {
372 if events.is_empty() {
373 return Err(BacktestError::EmptyEvents);
374 }
375 if config.max_events == 0 {
376 return Err(BacktestError::InvalidEventLimit);
377 }
378 if config.capture_every == 0 {
379 return Err(BacktestError::InvalidCaptureRate);
380 }
381 if events.len() > config.max_events {
382 return Err(BacktestError::EventLimitExceeded {
383 actual: events.len(),
384 maximum: config.max_events,
385 });
386 }
387
388 let timestamps = events
389 .iter()
390 .map(|event| model.timestamp(event))
391 .collect::<Vec<_>>();
392 for (index, pair) in timestamps.windows(2).enumerate() {
393 if pair[1] < pair[0] {
394 return Err(BacktestError::NonMonotonicTimestamp {
395 index: index + 1,
396 previous: pair[0],
397 current: pair[1],
398 });
399 }
400 }
401
402 let mut state = model.initial_state();
403 let initial_value = model.initial_value(&state);
404 if !initial_value.is_finite() || initial_value <= 0.0 {
405 return Err(BacktestError::InvalidInitialValue(initial_value));
406 }
407
408 let mut value_curve = Vec::with_capacity(events.len());
409 let mut steps = Vec::with_capacity(events.len().div_ceil(config.capture_every));
410 let mut actions_applied = 0;
411
412 for (index, event) in events.iter().enumerate() {
413 let timestamp = timestamps[index];
414
415 let actions = model
416 .decide(event, &state)
417 .map_err(|error| BacktestError::Decision {
418 index,
419 message: error.to_string(),
420 })?;
421 for (action_index, action) in actions.iter().enumerate() {
422 model
423 .apply(event, action, &mut state)
424 .map_err(|error| BacktestError::Transition {
425 index,
426 action_index,
427 message: error.to_string(),
428 })?;
429 actions_applied += 1;
430 }
431
432 let value = model.value(event, &state);
433 if !value.is_finite() {
434 return Err(BacktestError::NonFiniteValue { index, value });
435 }
436 value_curve.push(ValuePoint { timestamp, value });
437 if index % config.capture_every == 0 || index + 1 == events.len() {
438 steps.push(ReplayStep {
439 event_index: index,
440 timestamp,
441 actions,
442 value,
443 });
444 }
445 }
446
447 let final_value = value_curve
448 .last()
449 .map_or(initial_value, |point| point.value);
450 let summary = BacktestSummary {
451 events_processed: events.len(),
452 actions_applied,
453 initial_value,
454 final_value,
455 total_return: (final_value / initial_value) - 1.0,
456 max_drawdown: max_drawdown(initial_value, &value_curve),
457 };
458 Ok(BacktestReport {
459 summary,
460 final_state: state,
461 value_curve,
462 steps,
463 })
464}
465
466pub fn run_backtest_with_control<M: BacktestModel>(
469 model: &mut M,
470 events: &[M::Event],
471 config: BacktestConfig,
472 cancellation: &tokio_util::sync::CancellationToken,
473 deadline: std::time::Instant,
474) -> Result<BacktestReport<M::State, M::Action>, BacktestError> {
475 if cancellation.is_cancelled() {
476 return Err(BacktestError::Cancelled);
477 }
478 if std::time::Instant::now() >= deadline {
479 return Err(BacktestError::DeadlineExceeded);
480 }
481 let mut checked = CheckedModel {
482 inner: model,
483 cancellation,
484 deadline,
485 };
486 run_backtest(&mut checked, events, config).map_err(|error| match &error {
487 BacktestError::Decision { message, .. } | BacktestError::Transition { message, .. }
488 if message == "backtest cancelled" =>
489 {
490 BacktestError::Cancelled
491 }
492 BacktestError::Decision { message, .. } | BacktestError::Transition { message, .. }
493 if message == "backtest deadline exceeded" =>
494 {
495 BacktestError::DeadlineExceeded
496 }
497 _ => error,
498 })
499}
500
501struct CheckedModel<'a, M> {
502 inner: &'a mut M,
503 cancellation: &'a tokio_util::sync::CancellationToken,
504 deadline: std::time::Instant,
505}
506
507impl<M: BacktestModel> BacktestModel for CheckedModel<'_, M> {
508 type Error = ControlledModelError<M::Error>;
509 type Event = M::Event;
510 type State = M::State;
511 type Action = M::Action;
512
513 fn initial_state(&self) -> Self::State {
514 self.inner.initial_state()
515 }
516 fn initial_value(&self, state: &Self::State) -> f64 {
517 self.inner.initial_value(state)
518 }
519 fn timestamp(&self, event: &Self::Event) -> i64 {
520 self.inner.timestamp(event)
521 }
522 fn decide(
523 &mut self,
524 event: &Self::Event,
525 state: &Self::State,
526 ) -> Result<Vec<Self::Action>, Self::Error> {
527 if self.cancellation.is_cancelled() {
528 return Err(ControlledModelError::Cancelled);
529 }
530 if std::time::Instant::now() >= self.deadline {
531 return Err(ControlledModelError::DeadlineExceeded);
532 }
533 self.inner
534 .decide(event, state)
535 .map_err(ControlledModelError::Model)
536 }
537 fn apply(
538 &mut self,
539 event: &Self::Event,
540 action: &Self::Action,
541 state: &mut Self::State,
542 ) -> Result<(), Self::Error> {
543 self.inner
544 .apply(event, action, state)
545 .map_err(ControlledModelError::Model)
546 }
547 fn value(&self, event: &Self::Event, state: &Self::State) -> f64 {
548 self.inner.value(event, state)
549 }
550}
551
552#[derive(Debug, thiserror::Error)]
553enum ControlledModelError<E: std::error::Error> {
554 #[error("backtest cancelled")]
555 Cancelled,
556 #[error("backtest deadline exceeded")]
557 DeadlineExceeded,
558 #[error(transparent)]
559 Model(E),
560}
561
562fn max_drawdown(initial_value: f64, curve: &[ValuePoint]) -> f64 {
563 let mut peak = initial_value;
564 let mut maximum = 0.0_f64;
565 for point in curve {
566 peak = peak.max(point.value);
567 if peak > 0.0 {
568 maximum = maximum.max((peak - point.value) / peak);
569 }
570 }
571 maximum
572}
573
574fn sample_evenly<T: Clone>(items: &[T], maximum: usize) -> Vec<T> {
575 if items.len() <= maximum {
576 return items.to_vec();
577 }
578 if maximum == 1 {
579 return vec![items[items.len() - 1].clone()];
580 }
581 (0..maximum)
582 .map(|slot| {
583 let index = slot * (items.len() - 1) / (maximum - 1);
584 items[index].clone()
585 })
586 .collect()
587}