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
8#![deny(missing_docs)]
9#![deny(rustdoc::broken_intra_doc_links)]
10
11use serde::{Deserialize, Serialize};
12
13/// Product-supplied deterministic replay behavior.
14pub trait BacktestModel {
15    /// Historical input event type.
16    type Event;
17    /// Replay state carried between events.
18    type State: Clone;
19    /// Decision produced by the model.
20    type Action: Clone;
21
22    /// State before the first event.
23    fn initial_state(&self) -> Self::State;
24    /// Starting value used for return and drawdown.
25    fn initial_value(&self, state: &Self::State) -> f64;
26    /// Monotonic timestamp of an event.
27    fn timestamp(&self, event: &Self::Event) -> i64;
28
29    /// Produce decisions from the current event and pre-transition state.
30    fn decide(
31        &mut self,
32        event: &Self::Event,
33        state: &Self::State,
34    ) -> Result<Vec<Self::Action>, String>;
35
36    /// Apply one decision. The model owns domain-specific transition rules.
37    fn apply(
38        &mut self,
39        event: &Self::Event,
40        action: &Self::Action,
41        state: &mut Self::State,
42    ) -> Result<(), String>;
43
44    /// Mark the state after all actions for the event have been applied.
45    fn value(&self, event: &Self::Event, state: &Self::State) -> f64;
46}
47
48/// Replay limits.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct BacktestConfig {
51    /// Hard resource bound; inputs above it fail before executing any model code.
52    pub max_events: usize,
53    /// Capture one detailed decision step every N events. Curve points remain full.
54    pub capture_every: usize,
55}
56
57impl Default for BacktestConfig {
58    fn default() -> Self {
59        Self {
60            max_events: 1_000_000,
61            capture_every: 1,
62        }
63    }
64}
65
66/// Value after one event.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct ValuePoint {
69    /// Event timestamp.
70    pub timestamp: i64,
71    /// Value at this point.
72    pub value: f64,
73}
74
75/// Captured decision trace for one event.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct ReplayStep<A> {
78    /// Index of the event in the input.
79    pub event_index: usize,
80    /// Event timestamp.
81    pub timestamp: i64,
82    /// Decisions applied for this event.
83    pub actions: Vec<A>,
84    /// Value at this point.
85    pub value: f64,
86}
87
88/// Aggregate metrics for a replay.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct BacktestSummary {
91    /// Events replayed.
92    pub events_processed: usize,
93    /// Decisions applied.
94    pub actions_applied: usize,
95    /// Value before the first event.
96    pub initial_value: f64,
97    /// Value after the last event.
98    pub final_value: f64,
99    /// `final_value / initial_value - 1`.
100    pub total_return: f64,
101    /// Largest peak-to-trough decline as a fraction of the peak.
102    pub max_drawdown: f64,
103}
104
105/// Full replay output.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct BacktestReport<S, A> {
108    /// Short human-readable summary.
109    pub summary: BacktestSummary,
110    /// State after the last event.
111    pub final_state: S,
112    /// Value after every event.
113    pub value_curve: Vec<ValuePoint>,
114    /// Captured decision traces.
115    pub steps: Vec<ReplayStep<A>>,
116}
117
118/// Caps for projecting a report to chat or API consumers.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct ProjectionPolicy {
121    /// Maximum curve points kept; evenly down-sampled beyond this.
122    pub max_curve_points: usize,
123    /// Upper bound on model steps per Run.
124    pub max_steps: usize,
125}
126
127impl Default for ProjectionPolicy {
128    fn default() -> Self {
129        Self {
130            max_curve_points: 80,
131            max_steps: 24,
132        }
133    }
134}
135
136/// A transport/chat-safe projection of a potentially large full report.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct BacktestProjection<A> {
139    /// Short human-readable summary.
140    pub summary: BacktestSummary,
141    /// Down-sampled value curve.
142    pub value_curve: Vec<ValuePoint>,
143    /// Capped decision traces.
144    pub steps: Vec<ReplayStep<A>>,
145    /// Curve points before capping.
146    pub total_curve_points: usize,
147    /// Steps before capping.
148    pub total_steps: usize,
149}
150
151impl<S, A: Clone> BacktestReport<S, A> {
152    /// Project a report under `policy` without losing the summary.
153    pub fn project(
154        &self,
155        policy: ProjectionPolicy,
156    ) -> Result<BacktestProjection<A>, BacktestError> {
157        if policy.max_curve_points < 2 {
158            return Err(BacktestError::InvalidProjection(
159                "max_curve_points must be at least 2".into(),
160            ));
161        }
162        if policy.max_steps == 0 {
163            return Err(BacktestError::InvalidProjection(
164                "max_steps must be greater than 0".into(),
165            ));
166        }
167        Ok(BacktestProjection {
168            summary: self.summary.clone(),
169            value_curve: sample_evenly(&self.value_curve, policy.max_curve_points),
170            steps: sample_evenly(&self.steps, policy.max_steps),
171            total_curve_points: self.value_curve.len(),
172            total_steps: self.steps.len(),
173        })
174    }
175}
176
177/// Failure while validating input or replaying.
178#[derive(Debug, thiserror::Error, PartialEq)]
179pub enum BacktestError {
180    /// Backtest requires at least one historical event.
181    #[error("backtest requires at least one historical event")]
182    EmptyEvents,
183    /// Max_events must be greater than 0.
184    #[error("max_events must be greater than 0")]
185    InvalidEventLimit,
186    /// Capture_every must be greater than 0.
187    #[error("capture_every must be greater than 0")]
188    InvalidCaptureRate,
189    /// More events than `max_events`.
190    #[error("event count {actual} exceeds configured maximum {maximum}")]
191    EventLimitExceeded {
192        /// Events supplied.
193        actual: usize,
194        /// Configured `max_events`.
195        maximum: usize,
196    },
197    /// Initial value must be finite and greater than zero, got.
198    #[error("initial value must be finite and greater than zero, got {0}")]
199    InvalidInitialValue(f64),
200    /// Event timestamps are out of order at index `index`: `previous` > `current`.
201    #[error("event timestamps are out of order at index {index}: {previous} > {current}")]
202    NonMonotonicTimestamp {
203        /// Index of the event being applied.
204        index: usize,
205        /// Timestamp of the previous event.
206        previous: i64,
207        /// Timestamp of the offending event.
208        current: i64,
209    },
210    /// The model failed to decide on an event.
211    #[error("decision failed at event {index}: {message}")]
212    Decision {
213        /// Index of the event being decided.
214        index: usize,
215        /// Model-reported failure.
216        message: String,
217    },
218    /// State transition failed at event `index`, action `action_index`: `message`.
219    #[error("state transition failed at event {index}, action {action_index}: {message}")]
220    Transition {
221        /// Index of the event being applied.
222        index: usize,
223        /// Index of the action within that event.
224        action_index: usize,
225        /// Model-reported failure.
226        message: String,
227    },
228    /// The model produced NaN or infinity.
229    #[error("model returned a non-finite value at event {index}: {value}")]
230    NonFiniteValue {
231        /// Index of the offending event.
232        index: usize,
233        /// The non-finite value.
234        value: f64,
235    },
236    /// Invalid projection policy.
237    #[error("invalid projection policy: {0}")]
238    InvalidProjection(String),
239}
240
241/// Execute a deterministic replay. Events with the same timestamp are allowed
242/// and preserve input order; decreasing timestamps fail closed.
243pub fn run_backtest<M: BacktestModel>(
244    model: &mut M,
245    events: &[M::Event],
246    config: BacktestConfig,
247) -> Result<BacktestReport<M::State, M::Action>, BacktestError> {
248    if events.is_empty() {
249        return Err(BacktestError::EmptyEvents);
250    }
251    if config.max_events == 0 {
252        return Err(BacktestError::InvalidEventLimit);
253    }
254    if config.capture_every == 0 {
255        return Err(BacktestError::InvalidCaptureRate);
256    }
257    if events.len() > config.max_events {
258        return Err(BacktestError::EventLimitExceeded {
259            actual: events.len(),
260            maximum: config.max_events,
261        });
262    }
263
264    let timestamps = events
265        .iter()
266        .map(|event| model.timestamp(event))
267        .collect::<Vec<_>>();
268    for (index, pair) in timestamps.windows(2).enumerate() {
269        if pair[1] < pair[0] {
270            return Err(BacktestError::NonMonotonicTimestamp {
271                index: index + 1,
272                previous: pair[0],
273                current: pair[1],
274            });
275        }
276    }
277
278    let mut state = model.initial_state();
279    let initial_value = model.initial_value(&state);
280    if !initial_value.is_finite() || initial_value <= 0.0 {
281        return Err(BacktestError::InvalidInitialValue(initial_value));
282    }
283
284    let mut value_curve = Vec::with_capacity(events.len());
285    let mut steps = Vec::with_capacity(events.len().div_ceil(config.capture_every));
286    let mut actions_applied = 0;
287
288    for (index, event) in events.iter().enumerate() {
289        let timestamp = timestamps[index];
290
291        let actions = model
292            .decide(event, &state)
293            .map_err(|message| BacktestError::Decision { index, message })?;
294        for (action_index, action) in actions.iter().enumerate() {
295            model.apply(event, action, &mut state).map_err(|message| {
296                BacktestError::Transition {
297                    index,
298                    action_index,
299                    message,
300                }
301            })?;
302            actions_applied += 1;
303        }
304
305        let value = model.value(event, &state);
306        if !value.is_finite() {
307            return Err(BacktestError::NonFiniteValue { index, value });
308        }
309        value_curve.push(ValuePoint { timestamp, value });
310        if index % config.capture_every == 0 || index + 1 == events.len() {
311            steps.push(ReplayStep {
312                event_index: index,
313                timestamp,
314                actions,
315                value,
316            });
317        }
318    }
319
320    let final_value = value_curve
321        .last()
322        .map_or(initial_value, |point| point.value);
323    let summary = BacktestSummary {
324        events_processed: events.len(),
325        actions_applied,
326        initial_value,
327        final_value,
328        total_return: (final_value / initial_value) - 1.0,
329        max_drawdown: max_drawdown(initial_value, &value_curve),
330    };
331    Ok(BacktestReport {
332        summary,
333        final_state: state,
334        value_curve,
335        steps,
336    })
337}
338
339fn max_drawdown(initial_value: f64, curve: &[ValuePoint]) -> f64 {
340    let mut peak = initial_value;
341    let mut maximum = 0.0_f64;
342    for point in curve {
343        peak = peak.max(point.value);
344        if peak > 0.0 {
345            maximum = maximum.max((peak - point.value) / peak);
346        }
347    }
348    maximum
349}
350
351fn sample_evenly<T: Clone>(items: &[T], maximum: usize) -> Vec<T> {
352    if items.len() <= maximum {
353        return items.to_vec();
354    }
355    if maximum == 1 {
356        return vec![items[items.len() - 1].clone()];
357    }
358    (0..maximum)
359        .map(|slot| {
360            let index = slot * (items.len() - 1) / (maximum - 1);
361            items[index].clone()
362        })
363        .collect()
364}