#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
use serde::{Deserialize, Serialize};
pub trait BacktestModel {
type Event;
type State: Clone;
type Action: Clone;
fn initial_state(&self) -> Self::State;
fn initial_value(&self, state: &Self::State) -> f64;
fn timestamp(&self, event: &Self::Event) -> i64;
fn decide(
&mut self,
event: &Self::Event,
state: &Self::State,
) -> Result<Vec<Self::Action>, String>;
fn apply(
&mut self,
event: &Self::Event,
action: &Self::Action,
state: &mut Self::State,
) -> Result<(), String>;
fn value(&self, event: &Self::Event, state: &Self::State) -> f64;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BacktestConfig {
pub max_events: usize,
pub capture_every: usize,
}
impl Default for BacktestConfig {
fn default() -> Self {
Self {
max_events: 1_000_000,
capture_every: 1,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ValuePoint {
pub timestamp: i64,
pub value: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReplayStep<A> {
pub event_index: usize,
pub timestamp: i64,
pub actions: Vec<A>,
pub value: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BacktestSummary {
pub events_processed: usize,
pub actions_applied: usize,
pub initial_value: f64,
pub final_value: f64,
pub total_return: f64,
pub max_drawdown: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BacktestReport<S, A> {
pub summary: BacktestSummary,
pub final_state: S,
pub value_curve: Vec<ValuePoint>,
pub steps: Vec<ReplayStep<A>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProjectionPolicy {
pub max_curve_points: usize,
pub max_steps: usize,
}
impl Default for ProjectionPolicy {
fn default() -> Self {
Self {
max_curve_points: 80,
max_steps: 24,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BacktestProjection<A> {
pub summary: BacktestSummary,
pub value_curve: Vec<ValuePoint>,
pub steps: Vec<ReplayStep<A>>,
pub total_curve_points: usize,
pub total_steps: usize,
}
impl<S, A: Clone> BacktestReport<S, A> {
pub fn project(
&self,
policy: ProjectionPolicy,
) -> Result<BacktestProjection<A>, BacktestError> {
if policy.max_curve_points < 2 {
return Err(BacktestError::InvalidProjection(
"max_curve_points must be at least 2".into(),
));
}
if policy.max_steps == 0 {
return Err(BacktestError::InvalidProjection(
"max_steps must be greater than 0".into(),
));
}
Ok(BacktestProjection {
summary: self.summary.clone(),
value_curve: sample_evenly(&self.value_curve, policy.max_curve_points),
steps: sample_evenly(&self.steps, policy.max_steps),
total_curve_points: self.value_curve.len(),
total_steps: self.steps.len(),
})
}
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum BacktestError {
#[error("backtest requires at least one historical event")]
EmptyEvents,
#[error("max_events must be greater than 0")]
InvalidEventLimit,
#[error("capture_every must be greater than 0")]
InvalidCaptureRate,
#[error("event count {actual} exceeds configured maximum {maximum}")]
EventLimitExceeded {
actual: usize,
maximum: usize,
},
#[error("initial value must be finite and greater than zero, got {0}")]
InvalidInitialValue(f64),
#[error("event timestamps are out of order at index {index}: {previous} > {current}")]
NonMonotonicTimestamp {
index: usize,
previous: i64,
current: i64,
},
#[error("decision failed at event {index}: {message}")]
Decision {
index: usize,
message: String,
},
#[error("state transition failed at event {index}, action {action_index}: {message}")]
Transition {
index: usize,
action_index: usize,
message: String,
},
#[error("model returned a non-finite value at event {index}: {value}")]
NonFiniteValue {
index: usize,
value: f64,
},
#[error("invalid projection policy: {0}")]
InvalidProjection(String),
}
pub fn run_backtest<M: BacktestModel>(
model: &mut M,
events: &[M::Event],
config: BacktestConfig,
) -> Result<BacktestReport<M::State, M::Action>, BacktestError> {
if events.is_empty() {
return Err(BacktestError::EmptyEvents);
}
if config.max_events == 0 {
return Err(BacktestError::InvalidEventLimit);
}
if config.capture_every == 0 {
return Err(BacktestError::InvalidCaptureRate);
}
if events.len() > config.max_events {
return Err(BacktestError::EventLimitExceeded {
actual: events.len(),
maximum: config.max_events,
});
}
let timestamps = events
.iter()
.map(|event| model.timestamp(event))
.collect::<Vec<_>>();
for (index, pair) in timestamps.windows(2).enumerate() {
if pair[1] < pair[0] {
return Err(BacktestError::NonMonotonicTimestamp {
index: index + 1,
previous: pair[0],
current: pair[1],
});
}
}
let mut state = model.initial_state();
let initial_value = model.initial_value(&state);
if !initial_value.is_finite() || initial_value <= 0.0 {
return Err(BacktestError::InvalidInitialValue(initial_value));
}
let mut value_curve = Vec::with_capacity(events.len());
let mut steps = Vec::with_capacity(events.len().div_ceil(config.capture_every));
let mut actions_applied = 0;
for (index, event) in events.iter().enumerate() {
let timestamp = timestamps[index];
let actions = model
.decide(event, &state)
.map_err(|message| BacktestError::Decision { index, message })?;
for (action_index, action) in actions.iter().enumerate() {
model.apply(event, action, &mut state).map_err(|message| {
BacktestError::Transition {
index,
action_index,
message,
}
})?;
actions_applied += 1;
}
let value = model.value(event, &state);
if !value.is_finite() {
return Err(BacktestError::NonFiniteValue { index, value });
}
value_curve.push(ValuePoint { timestamp, value });
if index % config.capture_every == 0 || index + 1 == events.len() {
steps.push(ReplayStep {
event_index: index,
timestamp,
actions,
value,
});
}
}
let final_value = value_curve
.last()
.map_or(initial_value, |point| point.value);
let summary = BacktestSummary {
events_processed: events.len(),
actions_applied,
initial_value,
final_value,
total_return: (final_value / initial_value) - 1.0,
max_drawdown: max_drawdown(initial_value, &value_curve),
};
Ok(BacktestReport {
summary,
final_state: state,
value_curve,
steps,
})
}
fn max_drawdown(initial_value: f64, curve: &[ValuePoint]) -> f64 {
let mut peak = initial_value;
let mut maximum = 0.0_f64;
for point in curve {
peak = peak.max(point.value);
if peak > 0.0 {
maximum = maximum.max((peak - point.value) / peak);
}
}
maximum
}
fn sample_evenly<T: Clone>(items: &[T], maximum: usize) -> Vec<T> {
if items.len() <= maximum {
return items.to_vec();
}
if maximum == 1 {
return vec![items[items.len() - 1].clone()];
}
(0..maximum)
.map(|slot| {
let index = slot * (items.len() - 1) / (maximum - 1);
items[index].clone()
})
.collect()
}