Skip to main content

qs_backtest/strategy/
domain.rs

1//! Validated domain values for historical strategy descriptions and decisions.
2
3use std::collections::HashSet;
4use std::fmt;
5
6use chrono::{Duration, NaiveDateTime};
7use serde::{Deserialize, Deserializer, Serialize};
8
9use crate::profile::RawSignal;
10use crate::report::BacktestResult;
11
12use super::annotation::deserialize_research_annotations;
13use super::config::{PriceBasis, SeriesId, StrategyRetentionLimits, Timeframe, WarmupRequirement};
14use super::{StrategyAnnotation, StrategyJournalOutput};
15
16pub const MAX_STRATEGY_ID_BYTES: usize = 64;
17pub const MAX_STRATEGY_REVISION_BYTES: usize = 64;
18pub const MAX_STRATEGY_TITLE_BYTES: usize = 256;
19pub const MAX_INSTRUMENT_BYTES: usize = 64;
20pub const MAX_TRADE_ID_BYTES: usize = qs_strategy::MAX_GENERATED_ID_BYTES;
21pub const MAX_DECISION_LATENCY_MS: u64 = 86_400_000;
22
23/// Errors returned while validating historical strategy domain values.
24#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
25pub enum StrategyDomainError {
26    #[error("strategy ID must contain 1 to {MAX_STRATEGY_ID_BYTES} ASCII identifier bytes")]
27    InvalidStrategyId,
28    #[error(
29        "strategy revision must contain 1 to {MAX_STRATEGY_REVISION_BYTES} ASCII identifier bytes"
30    )]
31    InvalidRevision,
32    #[error(
33        "strategy title must contain 1 to {MAX_STRATEGY_TITLE_BYTES} trimmed non-control bytes"
34    )]
35    InvalidTitle,
36    #[error("strategy requirements must declare at least one instrument")]
37    MissingInstruments,
38    #[error("strategy requirements must declare at least one series")]
39    MissingSeries,
40    #[error("invalid instrument symbol '{symbol}'")]
41    InvalidInstrument { symbol: String },
42    #[error("instrument symbol '{symbol}' is declared more than once")]
43    DuplicateInstrument { symbol: String },
44    #[error("series ID '{series_id}' is declared more than once")]
45    DuplicateSeriesId { series_id: SeriesId },
46    #[error("series '{series_id}' references undeclared instrument '{symbol}'")]
47    UndeclaredSeriesInstrument { series_id: SeriesId, symbol: String },
48    #[error(
49        "series '{series_id}' duplicates another symbol, timeframe, and price-basis requirement"
50    )]
51    DuplicateSeriesDefinition { series_id: SeriesId },
52    #[error("decision latency {value} ms exceeds the maximum {MAX_DECISION_LATENCY_MS} ms")]
53    DecisionLatencyTooLarge { value: u64 },
54    #[error(
55        "decision timestamp {timestamp} plus {latency_ms} ms is outside the supported time range"
56    )]
57    DecisionTimestampOverflow {
58        timestamp: NaiveDateTime,
59        latency_ms: u64,
60    },
61    #[error("decision reason must contain 1 to {maximum} trimmed non-control bytes")]
62    InvalidDecisionReason { maximum: usize },
63    #[error("related trade ID must contain 1 to {MAX_TRADE_ID_BYTES} trimmed non-control bytes")]
64    InvalidTradeId,
65    #[error("decision returned {actual} signals, exceeding the callback limit {maximum}")]
66    TooManySignals { actual: usize, maximum: usize },
67    #[error("decision sequence {current} must be greater than the prior sequence {previous}")]
68    NonMonotonicDecisionSequence { previous: u64, current: u64 },
69    #[error("decision omitted counter overflowed")]
70    OmittedCounterOverflow,
71}
72
73/// Stable caller-supplied strategy identity.
74#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
75#[serde(transparent)]
76pub struct StrategyId(String);
77
78impl StrategyId {
79    pub fn new(value: impl Into<String>) -> Result<Self, StrategyDomainError> {
80        let value = value.into();
81        if valid_identifier(&value, MAX_STRATEGY_ID_BYTES) {
82            Ok(Self(value))
83        } else {
84            Err(StrategyDomainError::InvalidStrategyId)
85        }
86    }
87
88    pub fn as_str(&self) -> &str {
89        &self.0
90    }
91}
92
93impl fmt::Display for StrategyId {
94    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95        formatter.write_str(&self.0)
96    }
97}
98
99impl<'de> Deserialize<'de> for StrategyId {
100    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
101    where
102        D: Deserializer<'de>,
103    {
104        let value = String::deserialize(deserializer)?;
105        Self::new(value).map_err(serde::de::Error::custom)
106    }
107}
108
109/// Human-readable identity and explicit revision for one strategy variant.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111pub struct StrategyDescriptor {
112    id: StrategyId,
113    revision: String,
114    title: String,
115}
116
117impl StrategyDescriptor {
118    pub fn new(
119        id: StrategyId,
120        revision: impl Into<String>,
121        title: impl Into<String>,
122    ) -> Result<Self, StrategyDomainError> {
123        let revision = revision.into();
124        let title = title.into();
125        if !valid_identifier(&revision, MAX_STRATEGY_REVISION_BYTES) {
126            return Err(StrategyDomainError::InvalidRevision);
127        }
128        if !valid_text(&title, MAX_STRATEGY_TITLE_BYTES) {
129            return Err(StrategyDomainError::InvalidTitle);
130        }
131        Ok(Self {
132            id,
133            revision,
134            title,
135        })
136    }
137
138    pub fn id(&self) -> &StrategyId {
139        &self.id
140    }
141
142    pub fn revision(&self) -> &str {
143        &self.revision
144    }
145
146    pub fn title(&self) -> &str {
147        &self.title
148    }
149}
150
151#[derive(Deserialize)]
152#[serde(deny_unknown_fields)]
153struct StrategyDescriptorDef {
154    id: StrategyId,
155    revision: String,
156    title: String,
157}
158
159impl<'de> Deserialize<'de> for StrategyDescriptor {
160    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
161    where
162        D: Deserializer<'de>,
163    {
164        let value = StrategyDescriptorDef::deserialize(deserializer)?;
165        Self::new(value.id, value.revision, value.title).map_err(serde::de::Error::custom)
166    }
167}
168
169/// One required closed-bar series and its authoritative warmup count.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
171pub struct SeriesRequirement {
172    id: SeriesId,
173    symbol: String,
174    timeframe: Timeframe,
175    price_basis: PriceBasis,
176    warmup: WarmupRequirement,
177}
178
179impl SeriesRequirement {
180    pub fn new(
181        id: SeriesId,
182        symbol: impl Into<String>,
183        timeframe: Timeframe,
184        price_basis: PriceBasis,
185        warmup: WarmupRequirement,
186    ) -> Result<Self, StrategyDomainError> {
187        let symbol = symbol.into();
188        validate_instrument(&symbol)?;
189        Ok(Self {
190            id,
191            symbol,
192            timeframe,
193            price_basis,
194            warmup,
195        })
196    }
197
198    pub fn id(&self) -> &SeriesId {
199        &self.id
200    }
201
202    pub fn symbol(&self) -> &str {
203        &self.symbol
204    }
205
206    pub fn timeframe(&self) -> Timeframe {
207        self.timeframe
208    }
209
210    pub fn price_basis(&self) -> PriceBasis {
211        self.price_basis
212    }
213
214    pub fn warmup(&self) -> WarmupRequirement {
215        self.warmup
216    }
217}
218
219#[derive(Deserialize)]
220#[serde(deny_unknown_fields)]
221struct SeriesRequirementDef {
222    id: SeriesId,
223    symbol: String,
224    timeframe: Timeframe,
225    price_basis: PriceBasis,
226    warmup: WarmupRequirement,
227}
228
229impl<'de> Deserialize<'de> for SeriesRequirement {
230    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
231    where
232        D: Deserializer<'de>,
233    {
234        let value = SeriesRequirementDef::deserialize(deserializer)?;
235        Self::new(
236            value.id,
237            value.symbol,
238            value.timeframe,
239            value.price_basis,
240            value.warmup,
241        )
242        .map_err(serde::de::Error::custom)
243    }
244}
245
246/// Explicit historical inputs and runtime capabilities required by a strategy.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
248pub struct StrategyRequirements {
249    instruments: Vec<String>,
250    series: Vec<SeriesRequirement>,
251    decision_latency_ms: u64,
252    needs_tick_execution: bool,
253    needs_execution_feedback: bool,
254}
255
256impl StrategyRequirements {
257    pub fn new(
258        instruments: Vec<String>,
259        series: Vec<SeriesRequirement>,
260        decision_latency_ms: u64,
261        needs_tick_execution: bool,
262        needs_execution_feedback: bool,
263    ) -> Result<Self, StrategyDomainError> {
264        validate_requirements(&instruments, &series, decision_latency_ms)?;
265        Ok(Self {
266            instruments,
267            series,
268            decision_latency_ms,
269            needs_tick_execution,
270            needs_execution_feedback,
271        })
272    }
273
274    pub fn instruments(&self) -> &[String] {
275        &self.instruments
276    }
277
278    pub fn series(&self) -> &[SeriesRequirement] {
279        &self.series
280    }
281
282    pub fn decision_latency_ms(&self) -> u64 {
283        self.decision_latency_ms
284    }
285
286    pub fn needs_tick_execution(&self) -> bool {
287        self.needs_tick_execution
288    }
289
290    pub fn needs_execution_feedback(&self) -> bool {
291        self.needs_execution_feedback
292    }
293
294    /// Overall warmup is complete only when every declared series is ready.
295    pub fn warmup_complete<F>(&self, mut available_bars: F) -> bool
296    where
297        F: FnMut(&SeriesId) -> usize,
298    {
299        self.series.iter().all(|requirement| {
300            available_bars(requirement.id()) >= requirement.warmup().required_bars()
301        })
302    }
303
304    pub fn effective_timestamp(
305        &self,
306        decision_timestamp: NaiveDateTime,
307    ) -> Result<NaiveDateTime, StrategyDomainError> {
308        let latency = i64::try_from(self.decision_latency_ms)
309            .expect("validated strategy latency always fits i64");
310        decision_timestamp
311            .checked_add_signed(Duration::milliseconds(latency))
312            .ok_or(StrategyDomainError::DecisionTimestampOverflow {
313                timestamp: decision_timestamp,
314                latency_ms: self.decision_latency_ms,
315            })
316    }
317}
318
319#[derive(Deserialize)]
320#[serde(deny_unknown_fields)]
321struct StrategyRequirementsDef {
322    instruments: Vec<String>,
323    series: Vec<SeriesRequirement>,
324    decision_latency_ms: u64,
325    needs_tick_execution: bool,
326    needs_execution_feedback: bool,
327}
328
329impl<'de> Deserialize<'de> for StrategyRequirements {
330    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
331    where
332        D: Deserializer<'de>,
333    {
334        let value = StrategyRequirementsDef::deserialize(deserializer)?;
335        Self::new(
336            value.instruments,
337            value.series,
338            value.decision_latency_ms,
339            value.needs_tick_execution,
340            value.needs_execution_feedback,
341        )
342        .map_err(serde::de::Error::custom)
343    }
344}
345
346/// Classification for one strategy decision.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "snake_case")]
349pub enum StrategyDecisionKind {
350    NoAction,
351    Entry,
352    Management,
353    Exit,
354    Annotation,
355    Rejected,
356}
357
358/// Bounded explanation and exact strict signals emitted at one decision boundary.
359#[derive(Debug, Clone, Serialize)]
360pub struct StrategyDecisionRecord {
361    sequence: u64,
362    observed_through: NaiveDateTime,
363    kind: StrategyDecisionKind,
364    reason: String,
365    related_trade_id: Option<String>,
366    emitted_signals: Vec<RawSignal>,
367}
368
369impl StrategyDecisionRecord {
370    pub fn new(
371        sequence: u64,
372        observed_through: NaiveDateTime,
373        kind: StrategyDecisionKind,
374        reason: impl Into<String>,
375        related_trade_id: Option<String>,
376        emitted_signals: Vec<RawSignal>,
377        limits: StrategyRetentionLimits,
378    ) -> Result<Self, StrategyDomainError> {
379        let reason = reason.into();
380        validate_decision_fields(
381            &reason,
382            related_trade_id.as_deref(),
383            emitted_signals.len(),
384            limits,
385        )?;
386        Ok(Self {
387            sequence,
388            observed_through,
389            kind,
390            reason,
391            related_trade_id,
392            emitted_signals,
393        })
394    }
395
396    pub fn sequence(&self) -> u64 {
397        self.sequence
398    }
399
400    pub fn observed_through(&self) -> NaiveDateTime {
401        self.observed_through
402    }
403
404    pub fn kind(&self) -> StrategyDecisionKind {
405        self.kind
406    }
407
408    pub fn reason(&self) -> &str {
409        &self.reason
410    }
411
412    pub fn related_trade_id(&self) -> Option<&str> {
413        self.related_trade_id.as_deref()
414    }
415
416    pub fn emitted_signals(&self) -> &[RawSignal] {
417        &self.emitted_signals
418    }
419}
420
421/// Exact output-retention accounting for strategy decisions.
422#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
423pub struct StrategyDecisionRetention {
424    pub retained: usize,
425    pub omitted: usize,
426}
427
428/// Retained decisions plus exact omitted-count metadata.
429#[derive(Debug, Clone, Serialize)]
430pub struct StrategyDecisionOutput {
431    pub records: Vec<StrategyDecisionRecord>,
432    pub retention: StrategyDecisionRetention,
433}
434
435/// Bounded recorder that returns executable signals even when a decision record is omitted.
436pub struct StrategyDecisionRecorder {
437    limits: StrategyRetentionLimits,
438    records: Vec<StrategyDecisionRecord>,
439    omitted: usize,
440    last_sequence: Option<u64>,
441}
442
443impl StrategyDecisionRecorder {
444    pub fn new(limits: StrategyRetentionLimits) -> Self {
445        Self {
446            limits,
447            records: Vec::new(),
448            omitted: 0,
449            last_sequence: None,
450        }
451    }
452
453    pub fn push(
454        &mut self,
455        record: StrategyDecisionRecord,
456    ) -> Result<Vec<RawSignal>, StrategyDomainError> {
457        if let Some(previous) = self.last_sequence
458            && record.sequence() <= previous
459        {
460            return Err(StrategyDomainError::NonMonotonicDecisionSequence {
461                previous,
462                current: record.sequence(),
463            });
464        }
465        let executable_signals = record.emitted_signals.clone();
466        self.last_sequence = Some(record.sequence());
467        if self.records.len() < self.limits.max_decisions() {
468            self.records.push(record);
469        } else {
470            self.omitted = self
471                .omitted
472                .checked_add(1)
473                .ok_or(StrategyDomainError::OmittedCounterOverflow)?;
474        }
475        Ok(executable_signals)
476    }
477
478    pub fn finish(self) -> StrategyDecisionOutput {
479        StrategyDecisionOutput {
480            retention: StrategyDecisionRetention {
481                retained: self.records.len(),
482                omitted: self.omitted,
483            },
484            records: self.records,
485        }
486    }
487}
488
489/// Non-economic journal and research-only annotations returned beside replay.
490#[derive(Debug, Clone, Default, PartialEq, Serialize)]
491pub struct StrategyResearchOutput {
492    pub journal: StrategyJournalOutput,
493    pub research_annotations: Vec<StrategyAnnotation>,
494}
495
496#[derive(Deserialize)]
497#[serde(deny_unknown_fields)]
498struct StrategyResearchOutputDef {
499    journal: StrategyJournalOutput,
500    #[serde(deserialize_with = "deserialize_research_annotations")]
501    research_annotations: Vec<StrategyAnnotation>,
502}
503
504impl<'de> Deserialize<'de> for StrategyResearchOutput {
505    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
506    where
507        D: Deserializer<'de>,
508    {
509        let value = StrategyResearchOutputDef::deserialize(deserializer)?;
510        Ok(Self {
511            journal: value.journal,
512            research_annotations: value.research_annotations,
513        })
514    }
515}
516
517/// Strategy-specific wrapper around an existing economic replay result.
518#[derive(Debug, Clone, Serialize)]
519pub struct StrategyBacktestResult {
520    pub replay: BacktestResult,
521    pub descriptor: StrategyDescriptor,
522    pub decisions: StrategyDecisionOutput,
523    pub research: StrategyResearchOutput,
524}
525
526pub(crate) fn validate_decision_fields(
527    reason: &str,
528    related_trade_id: Option<&str>,
529    signal_count: usize,
530    limits: StrategyRetentionLimits,
531) -> Result<(), StrategyDomainError> {
532    if !valid_text(reason, limits.max_reason_bytes()) {
533        return Err(StrategyDomainError::InvalidDecisionReason {
534            maximum: limits.max_reason_bytes(),
535        });
536    }
537    if let Some(trade_id) = related_trade_id {
538        validate_trade_id(trade_id)?;
539    }
540    if signal_count > limits.max_signals_per_callback() {
541        return Err(StrategyDomainError::TooManySignals {
542            actual: signal_count,
543            maximum: limits.max_signals_per_callback(),
544        });
545    }
546    Ok(())
547}
548
549pub(crate) fn validate_trade_id(trade_id: &str) -> Result<(), StrategyDomainError> {
550    if valid_text(trade_id, MAX_TRADE_ID_BYTES) {
551        Ok(())
552    } else {
553        Err(StrategyDomainError::InvalidTradeId)
554    }
555}
556
557fn validate_requirements(
558    instruments: &[String],
559    series: &[SeriesRequirement],
560    decision_latency_ms: u64,
561) -> Result<(), StrategyDomainError> {
562    if instruments.is_empty() {
563        return Err(StrategyDomainError::MissingInstruments);
564    }
565    if series.is_empty() {
566        return Err(StrategyDomainError::MissingSeries);
567    }
568    if decision_latency_ms > MAX_DECISION_LATENCY_MS {
569        return Err(StrategyDomainError::DecisionLatencyTooLarge {
570            value: decision_latency_ms,
571        });
572    }
573
574    let mut instrument_set = HashSet::with_capacity(instruments.len());
575    for symbol in instruments {
576        validate_instrument(symbol)?;
577        if !instrument_set.insert(symbol.as_str()) {
578            return Err(StrategyDomainError::DuplicateInstrument {
579                symbol: symbol.clone(),
580            });
581        }
582    }
583
584    let mut series_ids = HashSet::with_capacity(series.len());
585    let mut definitions = HashSet::with_capacity(series.len());
586    for requirement in series {
587        if !series_ids.insert(requirement.id()) {
588            return Err(StrategyDomainError::DuplicateSeriesId {
589                series_id: requirement.id().clone(),
590            });
591        }
592        if !instrument_set.contains(requirement.symbol()) {
593            return Err(StrategyDomainError::UndeclaredSeriesInstrument {
594                series_id: requirement.id().clone(),
595                symbol: requirement.symbol().to_string(),
596            });
597        }
598        let definition = (
599            requirement.symbol(),
600            requirement.timeframe(),
601            requirement.price_basis(),
602        );
603        if !definitions.insert(definition) {
604            return Err(StrategyDomainError::DuplicateSeriesDefinition {
605                series_id: requirement.id().clone(),
606            });
607        }
608    }
609    Ok(())
610}
611
612fn validate_instrument(symbol: &str) -> Result<(), StrategyDomainError> {
613    if symbol.is_empty()
614        || symbol.len() > MAX_INSTRUMENT_BYTES
615        || !symbol.bytes().all(|byte| {
616            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/' | b':')
617        })
618    {
619        return Err(StrategyDomainError::InvalidInstrument {
620            symbol: symbol.to_string(),
621        });
622    }
623    Ok(())
624}
625
626fn valid_identifier(value: &str, maximum: usize) -> bool {
627    !value.is_empty()
628        && value.len() <= maximum
629        && value
630            .bytes()
631            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
632}
633
634fn valid_text(value: &str, maximum: usize) -> bool {
635    !value.is_empty()
636        && value.len() <= maximum
637        && value.trim() == value
638        && !value.chars().any(char::is_control)
639}