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