Skip to main content

qs_backtest/strategy/
geometry.rs

1//! Historical series geometry for configured logical sources and the warmup it implies.
2//!
3//! A caller declares only which symbol, timeframe, price basis, and alignment back each logical source. Warmup and retention are derived from the compiled strategy's own completed-bar requirements, so an in-process search and a service request bind the same strategy identically.
4
5use chrono::NaiveDateTime;
6use qs_strategy::{ConfiguredStrategyRequirements, SourceId};
7
8use super::{
9    BarSeriesSpec, ConfiguredHistoricalBindings, ConfiguredSourceBinding,
10    HistoricalVolumeProjection, MissingIntervalPolicy, PriceBasis, SeriesId, SeriesRequirement,
11    Timeframe, WarmupRequirement,
12};
13
14/// Historical series geometry for one configured logical source.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct SeriesGeometry {
17    pub source: SourceId,
18    pub symbol: String,
19    pub timeframe: Timeframe,
20    pub price_basis: PriceBasis,
21    pub alignment_offset_seconds: i32,
22}
23
24impl SeriesGeometry {
25    pub fn new(
26        source: SourceId,
27        symbol: impl Into<String>,
28        timeframe: Timeframe,
29        price_basis: PriceBasis,
30        alignment_offset_seconds: i32,
31    ) -> Self {
32        Self {
33            source,
34            symbol: symbol.into(),
35            timeframe,
36            price_basis,
37            alignment_offset_seconds,
38        }
39    }
40
41    /// Whether a timestamp is exactly on this fixed-duration series' aligned bucket boundary.
42    pub fn is_aligned(&self, timestamp: NaiveDateTime) -> bool {
43        if timestamp.and_utc().timestamp_subsec_nanos() != 0 {
44            return false;
45        }
46        let duration = i64::try_from(self.timeframe.duration_seconds())
47            .expect("fixed timeframe duration always fits i64");
48        let offset = i64::from(self.alignment_offset_seconds).rem_euclid(duration);
49        (timestamp.and_utc().timestamp() - offset).rem_euclid(duration) == 0
50    }
51}
52
53/// Geometry that cannot be bound or whose warmup cannot be represented.
54#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
55#[error("{0}")]
56pub struct SeriesGeometryError(String);
57
58impl ConfiguredHistoricalBindings {
59    /// Bind each declared geometry to a series whose warmup and retention come from the compiled requirements, with exact tick-count volume and no named inputs.
60    pub fn from_geometry(
61        geometry: Vec<SeriesGeometry>,
62        requirements: &ConfiguredStrategyRequirements,
63    ) -> Result<Self, SeriesGeometryError> {
64        let invalid = |error: &dyn std::fmt::Display| SeriesGeometryError(error.to_string());
65        let mut sources = Vec::with_capacity(geometry.len());
66        for item in geometry {
67            let required = requirements
68                .completed_bars
69                .iter()
70                .find(|requirement| requirement.source == item.source)
71                .map_or(0, |requirement| requirement.required_lookback);
72            let series_id = SeriesId::new(item.source.as_str()).map_err(|error| invalid(&error))?;
73            let requirement = SeriesRequirement::new(
74                series_id,
75                item.symbol,
76                item.timeframe,
77                item.price_basis,
78                WarmupRequirement::bars(required).map_err(|error| invalid(&error))?,
79            )
80            .map_err(|error| invalid(&error))?;
81            let series = BarSeriesSpec::new(
82                requirement,
83                required.max(1),
84                item.alignment_offset_seconds,
85                MissingIntervalPolicy::Skip,
86            )
87            .map_err(|error| invalid(&error))?;
88            sources.push(ConfiguredSourceBinding::new(item.source, series));
89        }
90        Ok(Self::new(
91            sources,
92            Vec::new(),
93            HistoricalVolumeProjection::TickCountExact,
94        ))
95    }
96
97    /// Earliest time from which every bound series completes its warmup by `from`, starting on each source's aligned bucket boundary so dense data cannot make a strategy ready before `from`.
98    pub fn warmup_start(&self, from: NaiveDateTime) -> Result<NaiveDateTime, SeriesGeometryError> {
99        let from_seconds = from.and_utc().timestamp();
100        let mut start = from;
101        for binding in self.sources() {
102            let series = binding.series();
103            let requirement = series.requirement();
104            let bars = i64::try_from(requirement.warmup().required_bars())
105                .map_err(|_| SeriesGeometryError("warmup bars do not fit i64".into()))?;
106            if bars == 0 {
107                continue;
108            }
109            let duration = i64::try_from(requirement.timeframe().duration_seconds())
110                .map_err(|_| SeriesGeometryError("timeframe duration does not fit i64".into()))?;
111            let offset = series.alignment_offset_seconds();
112            let bucket_open = (from_seconds - offset).div_euclid(duration) * duration + offset;
113            let preceding_intervals = if bucket_open == from_seconds {
114                bars
115            } else {
116                bars - 1
117            };
118            let source_start = bucket_open
119                .checked_sub(
120                    duration
121                        .checked_mul(preceding_intervals)
122                        .ok_or_else(|| SeriesGeometryError("warmup span overflowed".into()))?,
123                )
124                .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0))
125                .map(|value| value.naive_utc())
126                .ok_or_else(|| {
127                    SeriesGeometryError("warmup start is outside timestamp bounds".into())
128                })?;
129            start = start.min(source_start);
130        }
131        Ok(start)
132    }
133}