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/// Durable persistence port for definitions, snapshots, runs and bounded reports.
14#[async_trait::async_trait]
15pub trait BacktestStore: Send + Sync {
16    /// Save or replace a tenant-owned definition.
17    async fn save_definition(
18        &self,
19        context: &af_context::RequestContext,
20        definition: BacktestDefinition,
21    ) -> Result<(), BacktestStoreError>;
22    /// Persist an immutable input snapshot and start a run.
23    async fn start_run(
24        &self,
25        context: &af_context::RequestContext,
26        definition_id: &af_context::BacktestDefinitionId,
27        input_snapshot: serde_json::Value,
28    ) -> Result<af_context::BacktestRunId, BacktestStoreError>;
29    /// Complete a run with a report below `max_bytes`.
30    async fn complete_run(
31        &self,
32        context: &af_context::RequestContext,
33        run_id: &af_context::BacktestRunId,
34        report: serde_json::Value,
35        max_bytes: usize,
36    ) -> Result<(), BacktestStoreError>;
37    /// Preserve a terminal failure for recovery diagnostics.
38    async fn fail_run(
39        &self,
40        context: &af_context::RequestContext,
41        run_id: &af_context::BacktestRunId,
42        message: &str,
43    ) -> Result<(), BacktestStoreError>;
44}
45
46/// Tenant-owned deterministic replay definition.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct BacktestDefinition {
49    /// Stable definition identity.
50    pub id: af_context::BacktestDefinitionId,
51    /// Product-neutral display name.
52    pub name: String,
53    /// Immutable model implementation version.
54    pub model_version: String,
55    /// Bounded model configuration.
56    pub config: serde_json::Value,
57}
58
59/// Failure owned by the durable replay store.
60#[derive(Debug, thiserror::Error, PartialEq, Eq)]
61pub enum BacktestStoreError {
62    /// Store dependency is unavailable.
63    #[error("backtest store unavailable: {0}")]
64    Unavailable(String),
65    /// Requested record does not exist in the tenant.
66    #[error("backtest record not found")]
67    NotFound,
68    /// Input is invalid.
69    #[error("invalid backtest state: {0}")]
70    Invalid(String),
71    /// Report exceeds its configured serialized size cap.
72    #[error("report size {actual} exceeds maximum {maximum}")]
73    ReportTooLarge {
74        /// Serialized report bytes.
75        actual: usize,
76        /// Configured maximum bytes.
77        maximum: usize,
78    },
79}
80
81/// Product-supplied deterministic replay behavior.
82pub trait BacktestModel {
83    /// Typed product error returned by decisions and transitions.
84    type Error: std::error::Error + Send + Sync + 'static;
85    /// Historical input event type.
86    type Event;
87    /// Replay state carried between events.
88    type State: Clone;
89    /// Decision produced by the model.
90    type Action: Clone;
91
92    /// State before the first event.
93    fn initial_state(&self) -> Self::State;
94    /// Starting value used for return and drawdown.
95    fn initial_value(&self, state: &Self::State) -> f64;
96    /// Monotonic timestamp of an event.
97    fn timestamp(&self, event: &Self::Event) -> i64;
98
99    /// Produce decisions from the current event and pre-transition state.
100    fn decide(
101        &mut self,
102        event: &Self::Event,
103        state: &Self::State,
104    ) -> Result<Vec<Self::Action>, Self::Error>;
105
106    /// Apply one decision. The model owns domain-specific transition rules.
107    fn apply(
108        &mut self,
109        event: &Self::Event,
110        action: &Self::Action,
111        state: &mut Self::State,
112    ) -> Result<(), Self::Error>;
113
114    /// Mark the state after all actions for the event have been applied.
115    fn value(&self, event: &Self::Event, state: &Self::State) -> f64;
116}
117
118/// Replay limits.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct BacktestConfig {
121    /// Hard resource bound; inputs above it fail before executing any model code.
122    pub max_events: usize,
123    /// Capture one detailed decision step every N events. Curve points remain full.
124    pub capture_every: usize,
125}
126
127impl Default for BacktestConfig {
128    fn default() -> Self {
129        Self {
130            max_events: 1_000_000,
131            capture_every: 1,
132        }
133    }
134}
135
136/// Value after one event.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct ValuePoint {
139    /// Event timestamp.
140    pub timestamp: i64,
141    /// Value at this point.
142    pub value: f64,
143}
144
145/// Captured decision trace for one event.
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct ReplayStep<A> {
148    /// Index of the event in the input.
149    pub event_index: usize,
150    /// Event timestamp.
151    pub timestamp: i64,
152    /// Decisions applied for this event.
153    pub actions: Vec<A>,
154    /// Value at this point.
155    pub value: f64,
156}
157
158/// Aggregate metrics for a replay.
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct BacktestSummary {
161    /// Events replayed.
162    pub events_processed: usize,
163    /// Decisions applied.
164    pub actions_applied: usize,
165    /// Value before the first event.
166    pub initial_value: f64,
167    /// Value after the last event.
168    pub final_value: f64,
169    /// `final_value / initial_value - 1`.
170    pub total_return: f64,
171    /// Largest peak-to-trough decline as a fraction of the peak.
172    pub max_drawdown: f64,
173}
174
175/// Full replay output.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct BacktestReport<S, A> {
178    /// Short human-readable summary.
179    pub summary: BacktestSummary,
180    /// State after the last event.
181    pub final_state: S,
182    /// Value after every event.
183    pub value_curve: Vec<ValuePoint>,
184    /// Captured decision traces.
185    pub steps: Vec<ReplayStep<A>>,
186}
187
188/// Caps for projecting a report to chat or API consumers.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub struct ProjectionPolicy {
191    /// Maximum curve points kept; evenly down-sampled beyond this.
192    pub max_curve_points: usize,
193    /// Upper bound on model steps per Run.
194    pub max_steps: usize,
195}
196
197impl Default for ProjectionPolicy {
198    fn default() -> Self {
199        Self {
200            max_curve_points: 80,
201            max_steps: 24,
202        }
203    }
204}
205
206/// A transport/chat-safe projection of a potentially large full report.
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct BacktestProjection<A> {
209    /// Short human-readable summary.
210    pub summary: BacktestSummary,
211    /// Down-sampled value curve.
212    pub value_curve: Vec<ValuePoint>,
213    /// Capped decision traces.
214    pub steps: Vec<ReplayStep<A>>,
215    /// Curve points before capping.
216    pub total_curve_points: usize,
217    /// Steps before capping.
218    pub total_steps: usize,
219}
220
221impl<S, A: Clone> BacktestReport<S, A> {
222    /// Project a report under `policy` without losing the summary.
223    pub fn project(
224        &self,
225        policy: ProjectionPolicy,
226    ) -> Result<BacktestProjection<A>, BacktestError> {
227        if policy.max_curve_points < 2 {
228            return Err(BacktestError::InvalidProjection(
229                "max_curve_points must be at least 2".into(),
230            ));
231        }
232        if policy.max_steps == 0 {
233            return Err(BacktestError::InvalidProjection(
234                "max_steps must be greater than 0".into(),
235            ));
236        }
237        Ok(BacktestProjection {
238            summary: self.summary.clone(),
239            value_curve: sample_evenly(&self.value_curve, policy.max_curve_points),
240            steps: sample_evenly(&self.steps, policy.max_steps),
241            total_curve_points: self.value_curve.len(),
242            total_steps: self.steps.len(),
243        })
244    }
245}
246
247/// Failure while validating input or replaying.
248#[derive(Debug, thiserror::Error, PartialEq)]
249pub enum BacktestError {
250    /// Backtest requires at least one historical event.
251    #[error("backtest requires at least one historical event")]
252    EmptyEvents,
253    /// Max_events must be greater than 0.
254    #[error("max_events must be greater than 0")]
255    InvalidEventLimit,
256    /// Capture_every must be greater than 0.
257    #[error("capture_every must be greater than 0")]
258    InvalidCaptureRate,
259    /// More events than `max_events`.
260    #[error("event count {actual} exceeds configured maximum {maximum}")]
261    EventLimitExceeded {
262        /// Events supplied.
263        actual: usize,
264        /// Configured `max_events`.
265        maximum: usize,
266    },
267    /// Initial value must be finite and greater than zero, got.
268    #[error("initial value must be finite and greater than zero, got {0}")]
269    InvalidInitialValue(f64),
270    /// Event timestamps are out of order at index `index`: `previous` > `current`.
271    #[error("event timestamps are out of order at index {index}: {previous} > {current}")]
272    NonMonotonicTimestamp {
273        /// Index of the event being applied.
274        index: usize,
275        /// Timestamp of the previous event.
276        previous: i64,
277        /// Timestamp of the offending event.
278        current: i64,
279    },
280    /// The model failed to decide on an event.
281    #[error("decision failed at event {index}: {message}")]
282    Decision {
283        /// Index of the event being decided.
284        index: usize,
285        /// Model-reported failure.
286        message: String,
287    },
288    /// State transition failed at event `index`, action `action_index`: `message`.
289    #[error("state transition failed at event {index}, action {action_index}: {message}")]
290    Transition {
291        /// Index of the event being applied.
292        index: usize,
293        /// Index of the action within that event.
294        action_index: usize,
295        /// Model-reported failure.
296        message: String,
297    },
298    /// The model produced NaN or infinity.
299    #[error("model returned a non-finite value at event {index}: {value}")]
300    NonFiniteValue {
301        /// Index of the offending event.
302        index: usize,
303        /// The non-finite value.
304        value: f64,
305    },
306    /// Invalid projection policy.
307    #[error("invalid projection policy: {0}")]
308    InvalidProjection(String),
309    /// Replay observed cancellation between events.
310    #[error("backtest cancelled")]
311    Cancelled,
312    /// Replay exceeded its caller-provided deadline.
313    #[error("backtest deadline exceeded")]
314    DeadlineExceeded,
315}
316
317/// Execute a deterministic replay. Events with the same timestamp are allowed
318/// and preserve input order; decreasing timestamps fail closed.
319pub fn run_backtest<M: BacktestModel>(
320    model: &mut M,
321    events: &[M::Event],
322    config: BacktestConfig,
323) -> Result<BacktestReport<M::State, M::Action>, BacktestError> {
324    if events.is_empty() {
325        return Err(BacktestError::EmptyEvents);
326    }
327    if config.max_events == 0 {
328        return Err(BacktestError::InvalidEventLimit);
329    }
330    if config.capture_every == 0 {
331        return Err(BacktestError::InvalidCaptureRate);
332    }
333    if events.len() > config.max_events {
334        return Err(BacktestError::EventLimitExceeded {
335            actual: events.len(),
336            maximum: config.max_events,
337        });
338    }
339
340    let timestamps = events
341        .iter()
342        .map(|event| model.timestamp(event))
343        .collect::<Vec<_>>();
344    for (index, pair) in timestamps.windows(2).enumerate() {
345        if pair[1] < pair[0] {
346            return Err(BacktestError::NonMonotonicTimestamp {
347                index: index + 1,
348                previous: pair[0],
349                current: pair[1],
350            });
351        }
352    }
353
354    let mut state = model.initial_state();
355    let initial_value = model.initial_value(&state);
356    if !initial_value.is_finite() || initial_value <= 0.0 {
357        return Err(BacktestError::InvalidInitialValue(initial_value));
358    }
359
360    let mut value_curve = Vec::with_capacity(events.len());
361    let mut steps = Vec::with_capacity(events.len().div_ceil(config.capture_every));
362    let mut actions_applied = 0;
363
364    for (index, event) in events.iter().enumerate() {
365        let timestamp = timestamps[index];
366
367        let actions = model
368            .decide(event, &state)
369            .map_err(|error| BacktestError::Decision {
370                index,
371                message: error.to_string(),
372            })?;
373        for (action_index, action) in actions.iter().enumerate() {
374            model
375                .apply(event, action, &mut state)
376                .map_err(|error| BacktestError::Transition {
377                    index,
378                    action_index,
379                    message: error.to_string(),
380                })?;
381            actions_applied += 1;
382        }
383
384        let value = model.value(event, &state);
385        if !value.is_finite() {
386            return Err(BacktestError::NonFiniteValue { index, value });
387        }
388        value_curve.push(ValuePoint { timestamp, value });
389        if index % config.capture_every == 0 || index + 1 == events.len() {
390            steps.push(ReplayStep {
391                event_index: index,
392                timestamp,
393                actions,
394                value,
395            });
396        }
397    }
398
399    let final_value = value_curve
400        .last()
401        .map_or(initial_value, |point| point.value);
402    let summary = BacktestSummary {
403        events_processed: events.len(),
404        actions_applied,
405        initial_value,
406        final_value,
407        total_return: (final_value / initial_value) - 1.0,
408        max_drawdown: max_drawdown(initial_value, &value_curve),
409    };
410    Ok(BacktestReport {
411        summary,
412        final_state: state,
413        value_curve,
414        steps,
415    })
416}
417
418/// Execute a deterministic replay while observing cancellation and deadline
419/// between input events.
420pub fn run_backtest_with_control<M: BacktestModel>(
421    model: &mut M,
422    events: &[M::Event],
423    config: BacktestConfig,
424    cancellation: &tokio_util::sync::CancellationToken,
425    deadline: std::time::Instant,
426) -> Result<BacktestReport<M::State, M::Action>, BacktestError> {
427    if cancellation.is_cancelled() {
428        return Err(BacktestError::Cancelled);
429    }
430    if std::time::Instant::now() >= deadline {
431        return Err(BacktestError::DeadlineExceeded);
432    }
433    let mut checked = CheckedModel {
434        inner: model,
435        cancellation,
436        deadline,
437    };
438    run_backtest(&mut checked, events, config).map_err(|error| match &error {
439        BacktestError::Decision { message, .. } | BacktestError::Transition { message, .. }
440            if message == "backtest cancelled" =>
441        {
442            BacktestError::Cancelled
443        }
444        BacktestError::Decision { message, .. } | BacktestError::Transition { message, .. }
445            if message == "backtest deadline exceeded" =>
446        {
447            BacktestError::DeadlineExceeded
448        }
449        _ => error,
450    })
451}
452
453struct CheckedModel<'a, M> {
454    inner: &'a mut M,
455    cancellation: &'a tokio_util::sync::CancellationToken,
456    deadline: std::time::Instant,
457}
458
459impl<M: BacktestModel> BacktestModel for CheckedModel<'_, M> {
460    type Error = ControlledModelError<M::Error>;
461    type Event = M::Event;
462    type State = M::State;
463    type Action = M::Action;
464
465    fn initial_state(&self) -> Self::State {
466        self.inner.initial_state()
467    }
468    fn initial_value(&self, state: &Self::State) -> f64 {
469        self.inner.initial_value(state)
470    }
471    fn timestamp(&self, event: &Self::Event) -> i64 {
472        self.inner.timestamp(event)
473    }
474    fn decide(
475        &mut self,
476        event: &Self::Event,
477        state: &Self::State,
478    ) -> Result<Vec<Self::Action>, Self::Error> {
479        if self.cancellation.is_cancelled() {
480            return Err(ControlledModelError::Cancelled);
481        }
482        if std::time::Instant::now() >= self.deadline {
483            return Err(ControlledModelError::DeadlineExceeded);
484        }
485        self.inner
486            .decide(event, state)
487            .map_err(ControlledModelError::Model)
488    }
489    fn apply(
490        &mut self,
491        event: &Self::Event,
492        action: &Self::Action,
493        state: &mut Self::State,
494    ) -> Result<(), Self::Error> {
495        self.inner
496            .apply(event, action, state)
497            .map_err(ControlledModelError::Model)
498    }
499    fn value(&self, event: &Self::Event, state: &Self::State) -> f64 {
500        self.inner.value(event, state)
501    }
502}
503
504#[derive(Debug, thiserror::Error)]
505enum ControlledModelError<E: std::error::Error> {
506    #[error("backtest cancelled")]
507    Cancelled,
508    #[error("backtest deadline exceeded")]
509    DeadlineExceeded,
510    #[error(transparent)]
511    Model(E),
512}
513
514fn max_drawdown(initial_value: f64, curve: &[ValuePoint]) -> f64 {
515    let mut peak = initial_value;
516    let mut maximum = 0.0_f64;
517    for point in curve {
518        peak = peak.max(point.value);
519        if peak > 0.0 {
520            maximum = maximum.max((peak - point.value) / peak);
521        }
522    }
523    maximum
524}
525
526fn sample_evenly<T: Clone>(items: &[T], maximum: usize) -> Vec<T> {
527    if items.len() <= maximum {
528        return items.to_vec();
529    }
530    if maximum == 1 {
531        return vec![items[items.len() - 1].clone()];
532    }
533    (0..maximum)
534        .map(|slot| {
535            let index = slot * (items.len() - 1) / (maximum - 1);
536            items[index].clone()
537        })
538        .collect()
539}