qs_backtest/strategy/
geometry.rs1use 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#[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 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
55#[error("{0}")]
56pub struct SeriesGeometryError(String);
57
58impl ConfiguredHistoricalBindings {
59 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 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}