Skip to main content

qs_backtest/strategy/
runtime.rs

1//! Stateful callback contract for causal historical strategies.
2
3use chrono::NaiveDateTime;
4
5use crate::data_feed::FeedEvent;
6use crate::profile::RawSignal;
7
8use super::domain::validate_decision_fields;
9use super::{
10    ClosedBar, StrategyContext, StrategyDecisionKind, StrategyDecisionRecord, StrategyDescriptor,
11    StrategyDomainError, StrategyFeedback, StrategyJournalDraft, StrategyJournalError,
12    StrategyObservation, StrategyRequirements, StrategyRetentionLimits,
13};
14
15/// One complete replay timestamp presented in already committed order.
16#[derive(Debug, Clone, Copy)]
17pub struct StrategyEvent<'a> {
18    primary_events: &'a [FeedEvent],
19    closed_bars: &'a [ClosedBar],
20    observations: &'a [StrategyObservation],
21    feedback: StrategyFeedback<'a>,
22}
23
24impl<'a> StrategyEvent<'a> {
25    pub const fn new(
26        primary_events: &'a [FeedEvent],
27        closed_bars: &'a [ClosedBar],
28        observations: &'a [StrategyObservation],
29        feedback: StrategyFeedback<'a>,
30    ) -> Self {
31        Self {
32            primary_events,
33            closed_bars,
34            observations,
35            feedback,
36        }
37    }
38
39    pub const fn primary_events(self) -> &'a [FeedEvent] {
40        self.primary_events
41    }
42
43    pub const fn closed_bars(self) -> &'a [ClosedBar] {
44        self.closed_bars
45    }
46
47    pub const fn observations(self) -> &'a [StrategyObservation] {
48        self.observations
49    }
50
51    pub const fn feedback(self) -> StrategyFeedback<'a> {
52        self.feedback
53    }
54}
55
56/// A validated decision that may emit several ordered strict signals.
57#[derive(Debug, Clone)]
58pub struct StrategyDecisionDraft {
59    kind: StrategyDecisionKind,
60    reason: String,
61    related_trade_id: Option<String>,
62    signals: Vec<RawSignal>,
63}
64
65impl StrategyDecisionDraft {
66    pub fn new(
67        kind: StrategyDecisionKind,
68        reason: impl Into<String>,
69        related_trade_id: Option<String>,
70        signals: Vec<RawSignal>,
71        limits: StrategyRetentionLimits,
72    ) -> Result<Self, StrategyDomainError> {
73        let reason = reason.into();
74        validate_decision_fields(&reason, related_trade_id.as_deref(), signals.len(), limits)?;
75        Ok(Self {
76            kind,
77            reason,
78            related_trade_id,
79            signals,
80        })
81    }
82
83    pub fn kind(&self) -> StrategyDecisionKind {
84        self.kind
85    }
86
87    pub fn reason(&self) -> &str {
88        &self.reason
89    }
90
91    pub fn related_trade_id(&self) -> Option<&str> {
92        self.related_trade_id.as_deref()
93    }
94
95    pub fn signals(&self) -> &[RawSignal] {
96        &self.signals
97    }
98
99    pub fn into_record(
100        self,
101        sequence: u64,
102        observed_through: NaiveDateTime,
103        limits: StrategyRetentionLimits,
104    ) -> Result<StrategyDecisionRecord, StrategyRuntimeError> {
105        if let Some((signal_index, signal)) = self
106            .signals
107            .iter()
108            .enumerate()
109            .find(|(_, signal)| signal.ts() != observed_through)
110        {
111            return Err(StrategyRuntimeError::SignalTimestampMismatch {
112                signal_index,
113                signal_ts: signal.ts(),
114                observed_through,
115            });
116        }
117        Ok(StrategyDecisionRecord::new(
118            sequence,
119            observed_through,
120            self.kind,
121            self.reason,
122            self.related_trade_id,
123            self.signals,
124            limits,
125        )?)
126    }
127}
128
129/// Optional economic decision and ordered non-economic journal drafts from one callback.
130#[derive(Debug, Clone, Default)]
131pub struct StrategyOutput {
132    decision: Option<StrategyDecisionDraft>,
133    journal: Vec<StrategyJournalDraft>,
134}
135
136impl StrategyOutput {
137    pub const fn new(decision: Option<StrategyDecisionDraft>) -> Self {
138        Self {
139            decision,
140            journal: Vec::new(),
141        }
142    }
143
144    pub const fn none() -> Self {
145        Self::new(None)
146    }
147
148    pub fn from_decision(decision: StrategyDecisionDraft) -> Self {
149        Self::new(Some(decision))
150    }
151
152    pub fn from_journal(journal: Vec<StrategyJournalDraft>) -> Self {
153        Self {
154            decision: None,
155            journal,
156        }
157    }
158
159    pub fn with_journal(mut self, journal: Vec<StrategyJournalDraft>) -> Self {
160        self.journal = journal;
161        self
162    }
163
164    pub fn decision(&self) -> Option<&StrategyDecisionDraft> {
165        self.decision.as_ref()
166    }
167
168    pub fn journal(&self) -> &[StrategyJournalDraft] {
169        &self.journal
170    }
171
172    pub fn into_decision(self) -> Option<StrategyDecisionDraft> {
173        self.decision
174    }
175
176    pub fn into_parts(self) -> (Option<StrategyDecisionDraft>, Vec<StrategyJournalDraft>) {
177        (self.decision, self.journal)
178    }
179}
180
181/// Errors produced while finalizing one callback decision.
182#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
183pub enum StrategyRuntimeError {
184    #[error(transparent)]
185    Domain(#[from] StrategyDomainError),
186    #[error(transparent)]
187    Journal(#[from] StrategyJournalError),
188    #[error(
189        "signal {signal_index} timestamp {signal_ts} does not match strategy boundary {observed_through}"
190    )]
191    SignalTimestampMismatch {
192        signal_index: usize,
193        signal_ts: NaiveDateTime,
194        observed_through: NaiveDateTime,
195    },
196}
197
198/// Stateful synchronous strategy evaluated once per complete replay timestamp.
199pub trait HistoricalStrategy {
200    type Error;
201
202    fn descriptor(&self) -> &StrategyDescriptor;
203    fn requirements(&self) -> &StrategyRequirements;
204
205    fn on_event(
206        &mut self,
207        event: StrategyEvent<'_>,
208        context: StrategyContext<'_>,
209    ) -> Result<StrategyOutput, Self::Error>;
210}