#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
use serde::{Deserialize, Serialize};
#[async_trait::async_trait]
pub trait BacktestStore: Send + Sync {
async fn save_definition(
&self,
context: &af_context::RequestContext,
definition: BacktestDefinition,
) -> Result<(), BacktestStoreError>;
async fn start_run(
&self,
context: &af_context::RequestContext,
definition_id: &af_context::BacktestDefinitionId,
input_snapshot: serde_json::Value,
idempotency_key: &str,
) -> Result<af_context::BacktestRunId, BacktestStoreError>;
async fn get_run(
&self,
context: &af_context::RequestContext,
run_id: &af_context::BacktestRunId,
) -> Result<BacktestRun, BacktestStoreError>;
async fn cancel_run(
&self,
context: &af_context::RequestContext,
run_id: &af_context::BacktestRunId,
) -> Result<(), BacktestStoreError>;
async fn complete_run(
&self,
context: &af_context::RequestContext,
run_id: &af_context::BacktestRunId,
report: serde_json::Value,
max_bytes: usize,
) -> Result<(), BacktestStoreError>;
async fn fail_run(
&self,
context: &af_context::RequestContext,
run_id: &af_context::BacktestRunId,
message: &str,
) -> Result<(), BacktestStoreError>;
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BacktestDefinition {
pub id: af_context::BacktestDefinitionId,
pub name: String,
pub model_version: String,
pub config: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BacktestRun {
pub id: af_context::BacktestRunId,
pub definition: Option<BacktestDefinition>,
pub input_snapshot: serde_json::Value,
pub status: BacktestRunStatus,
pub report: Option<serde_json::Value>,
pub error_message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BacktestRunStatus {
Running,
Completed,
Failed,
Cancelled,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum BacktestStoreError {
#[error("backtest store unavailable: {0}")]
Unavailable(String),
#[error("backtest record not found")]
NotFound,
#[error("backtest run state conflicts with requested mutation")]
Conflict,
#[error("invalid backtest state: {0}")]
Invalid(String),
#[error("report size {actual} exceeds maximum {maximum}")]
ReportTooLarge {
actual: usize,
maximum: usize,
},
}
pub trait BacktestModel {
type Error: std::error::Error + Send + Sync + 'static;
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>, Self::Error>;
fn apply(
&mut self,
event: &Self::Event,
action: &Self::Action,
state: &mut Self::State,
) -> Result<(), Self::Error>;
fn value(&self, event: &Self::Event, state: &Self::State) -> f64;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
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),
#[error("backtest cancelled")]
Cancelled,
#[error("backtest deadline exceeded")]
DeadlineExceeded,
}
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(|error| BacktestError::Decision {
index,
message: error.to_string(),
})?;
for (action_index, action) in actions.iter().enumerate() {
model
.apply(event, action, &mut state)
.map_err(|error| BacktestError::Transition {
index,
action_index,
message: error.to_string(),
})?;
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,
})
}
pub fn run_backtest_with_control<M: BacktestModel>(
model: &mut M,
events: &[M::Event],
config: BacktestConfig,
cancellation: &tokio_util::sync::CancellationToken,
deadline: std::time::Instant,
) -> Result<BacktestReport<M::State, M::Action>, BacktestError> {
if cancellation.is_cancelled() {
return Err(BacktestError::Cancelled);
}
if std::time::Instant::now() >= deadline {
return Err(BacktestError::DeadlineExceeded);
}
let mut checked = CheckedModel {
inner: model,
cancellation,
deadline,
};
run_backtest(&mut checked, events, config).map_err(|error| match &error {
BacktestError::Decision { message, .. } | BacktestError::Transition { message, .. }
if message == "backtest cancelled" =>
{
BacktestError::Cancelled
}
BacktestError::Decision { message, .. } | BacktestError::Transition { message, .. }
if message == "backtest deadline exceeded" =>
{
BacktestError::DeadlineExceeded
}
_ => error,
})
}
struct CheckedModel<'a, M> {
inner: &'a mut M,
cancellation: &'a tokio_util::sync::CancellationToken,
deadline: std::time::Instant,
}
impl<M: BacktestModel> BacktestModel for CheckedModel<'_, M> {
type Error = ControlledModelError<M::Error>;
type Event = M::Event;
type State = M::State;
type Action = M::Action;
fn initial_state(&self) -> Self::State {
self.inner.initial_state()
}
fn initial_value(&self, state: &Self::State) -> f64 {
self.inner.initial_value(state)
}
fn timestamp(&self, event: &Self::Event) -> i64 {
self.inner.timestamp(event)
}
fn decide(
&mut self,
event: &Self::Event,
state: &Self::State,
) -> Result<Vec<Self::Action>, Self::Error> {
if self.cancellation.is_cancelled() {
return Err(ControlledModelError::Cancelled);
}
if std::time::Instant::now() >= self.deadline {
return Err(ControlledModelError::DeadlineExceeded);
}
self.inner
.decide(event, state)
.map_err(ControlledModelError::Model)
}
fn apply(
&mut self,
event: &Self::Event,
action: &Self::Action,
state: &mut Self::State,
) -> Result<(), Self::Error> {
self.inner
.apply(event, action, state)
.map_err(ControlledModelError::Model)
}
fn value(&self, event: &Self::Event, state: &Self::State) -> f64 {
self.inner.value(event, state)
}
}
#[derive(Debug, thiserror::Error)]
enum ControlledModelError<E: std::error::Error> {
#[error("backtest cancelled")]
Cancelled,
#[error("backtest deadline exceeded")]
DeadlineExceeded,
#[error(transparent)]
Model(E),
}
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()
}