Skip to main content

af_backtest/
lib.rs

1//! `af-backtest` — deterministic historical replay for product adapters.
2//!
3//! This crate owns the reusable mechanics only: ordered historical ingress,
4//! state transitions, decision traces, value curves, metrics, and bounded
5//! report projection. Domain event types, accounting, execution semantics,
6//! data providers, and concrete strategies stay in product/extension crates.
7
8use serde::{Deserialize, Serialize};
9
10/// Product-supplied deterministic replay behavior.
11pub 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    /// Produce decisions from the current event and pre-transition state.
21    fn decide(
22        &mut self,
23        event: &Self::Event,
24        state: &Self::State,
25    ) -> Result<Vec<Self::Action>, String>;
26
27    /// Apply one decision. The model owns domain-specific transition rules.
28    fn apply(
29        &mut self,
30        event: &Self::Event,
31        action: &Self::Action,
32        state: &mut Self::State,
33    ) -> Result<(), String>;
34
35    /// Mark the state after all actions for the event have been applied.
36    fn value(&self, event: &Self::Event, state: &Self::State) -> f64;
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct BacktestConfig {
41    /// Hard resource bound; inputs above it fail before executing any model code.
42    pub max_events: usize,
43    /// Capture one detailed decision step every N events. Curve points remain full.
44    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/// A transport/chat-safe projection of a potentially large full report.
104#[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
170/// Execute a deterministic replay. Events with the same timestamp are allowed
171/// and preserve input order; decreasing timestamps fail closed.
172pub 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}