Skip to main content

qs_backtest/strategy/
config.rs

1//! Shared configuration values for historical strategy requirements.
2
3use std::fmt;
4use std::num::NonZeroU32;
5
6use serde::{Deserialize, Deserializer, Serialize};
7
8pub const MAX_SERIES_ID_BYTES: usize = 64;
9pub const MAX_WARMUP_BARS: usize = 1_000_000;
10pub const MAX_DECISION_RECORDS: usize = 1_000_000;
11pub const MAX_SIGNALS_PER_CALLBACK: usize = 4096;
12pub const MAX_REASON_BYTES: usize = 4096;
13
14/// Errors returned while constructing historical strategy configuration values.
15#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
16pub enum StrategyConfigError {
17    #[error("{field} must be greater than zero")]
18    ZeroValue { field: &'static str },
19    #[error("series ID must contain 1 to {MAX_SERIES_ID_BYTES} ASCII identifier bytes")]
20    InvalidSeriesId,
21    #[error("warmup bars {value} exceed the maximum {MAX_WARMUP_BARS}")]
22    WarmupTooLarge { value: usize },
23    #[error("{field} {value} exceeds the maximum {maximum}")]
24    LimitTooLarge {
25        field: &'static str,
26        value: usize,
27        maximum: usize,
28    },
29}
30
31/// Stable caller-supplied identity for one required historical series.
32#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
33#[serde(transparent)]
34pub struct SeriesId(String);
35
36impl SeriesId {
37    pub fn new(value: impl Into<String>) -> Result<Self, StrategyConfigError> {
38        let value = value.into();
39        if valid_identifier(&value, MAX_SERIES_ID_BYTES) {
40            Ok(Self(value))
41        } else {
42            Err(StrategyConfigError::InvalidSeriesId)
43        }
44    }
45
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49}
50
51impl fmt::Display for SeriesId {
52    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53        formatter.write_str(&self.0)
54    }
55}
56
57impl<'de> Deserialize<'de> for SeriesId {
58    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
59    where
60        D: Deserializer<'de>,
61    {
62        let value = String::deserialize(deserializer)?;
63        Self::new(value).map_err(serde::de::Error::custom)
64    }
65}
66
67/// Fixed-duration historical analysis interval.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum Timeframe {
71    Seconds(NonZeroU32),
72    Minutes(NonZeroU32),
73    Hours(NonZeroU32),
74    Days(NonZeroU32),
75}
76
77impl Timeframe {
78    pub fn seconds(value: u32) -> Result<Self, StrategyConfigError> {
79        nonzero(value, "timeframe seconds").map(Self::Seconds)
80    }
81
82    pub fn minutes(value: u32) -> Result<Self, StrategyConfigError> {
83        nonzero(value, "timeframe minutes").map(Self::Minutes)
84    }
85
86    pub fn hours(value: u32) -> Result<Self, StrategyConfigError> {
87        nonzero(value, "timeframe hours").map(Self::Hours)
88    }
89
90    pub fn days(value: u32) -> Result<Self, StrategyConfigError> {
91        nonzero(value, "timeframe days").map(Self::Days)
92    }
93
94    pub fn duration_seconds(self) -> u64 {
95        match self {
96            Self::Seconds(value) => u64::from(value.get()),
97            Self::Minutes(value) => u64::from(value.get()) * 60,
98            Self::Hours(value) => u64::from(value.get()) * 60 * 60,
99            Self::Days(value) => u64::from(value.get()) * 24 * 60 * 60,
100        }
101    }
102}
103
104/// Quote side used to aggregate one historical analysis series.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum PriceBasis {
108    Bid,
109    Ask,
110    Mid,
111}
112
113/// Number of completed bars required before one series is ready.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
115#[serde(transparent)]
116pub struct WarmupRequirement(usize);
117
118impl WarmupRequirement {
119    pub fn bars(value: usize) -> Result<Self, StrategyConfigError> {
120        if value <= MAX_WARMUP_BARS {
121            Ok(Self(value))
122        } else {
123            Err(StrategyConfigError::WarmupTooLarge { value })
124        }
125    }
126
127    pub fn required_bars(self) -> usize {
128        self.0
129    }
130}
131
132impl<'de> Deserialize<'de> for WarmupRequirement {
133    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
134    where
135        D: Deserializer<'de>,
136    {
137        let value = usize::deserialize(deserializer)?;
138        Self::bars(value).map_err(serde::de::Error::custom)
139    }
140}
141
142/// Caller-visible bounds for retained decisions and callback output.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
144pub struct StrategyRetentionLimits {
145    max_decisions: usize,
146    max_signals_per_callback: usize,
147    max_reason_bytes: usize,
148}
149
150impl StrategyRetentionLimits {
151    pub fn new(
152        max_decisions: usize,
153        max_signals_per_callback: usize,
154        max_reason_bytes: usize,
155    ) -> Result<Self, StrategyConfigError> {
156        validate_limit("max_decisions", max_decisions, MAX_DECISION_RECORDS, true)?;
157        validate_limit(
158            "max_signals_per_callback",
159            max_signals_per_callback,
160            MAX_SIGNALS_PER_CALLBACK,
161            false,
162        )?;
163        validate_limit(
164            "max_reason_bytes",
165            max_reason_bytes,
166            MAX_REASON_BYTES,
167            false,
168        )?;
169        Ok(Self {
170            max_decisions,
171            max_signals_per_callback,
172            max_reason_bytes,
173        })
174    }
175
176    pub fn max_decisions(self) -> usize {
177        self.max_decisions
178    }
179
180    pub fn max_signals_per_callback(self) -> usize {
181        self.max_signals_per_callback
182    }
183
184    pub fn max_reason_bytes(self) -> usize {
185        self.max_reason_bytes
186    }
187}
188
189impl Default for StrategyRetentionLimits {
190    fn default() -> Self {
191        Self {
192            max_decisions: 10_000,
193            max_signals_per_callback: 256,
194            max_reason_bytes: 1024,
195        }
196    }
197}
198
199#[derive(Deserialize)]
200#[serde(deny_unknown_fields)]
201struct StrategyRetentionLimitsDef {
202    max_decisions: usize,
203    max_signals_per_callback: usize,
204    max_reason_bytes: usize,
205}
206
207impl<'de> Deserialize<'de> for StrategyRetentionLimits {
208    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
209    where
210        D: Deserializer<'de>,
211    {
212        let value = StrategyRetentionLimitsDef::deserialize(deserializer)?;
213        Self::new(
214            value.max_decisions,
215            value.max_signals_per_callback,
216            value.max_reason_bytes,
217        )
218        .map_err(serde::de::Error::custom)
219    }
220}
221
222fn nonzero(value: u32, field: &'static str) -> Result<NonZeroU32, StrategyConfigError> {
223    NonZeroU32::new(value).ok_or(StrategyConfigError::ZeroValue { field })
224}
225
226fn validate_limit(
227    field: &'static str,
228    value: usize,
229    maximum: usize,
230    allow_zero: bool,
231) -> Result<(), StrategyConfigError> {
232    if value == 0 && !allow_zero {
233        return Err(StrategyConfigError::ZeroValue { field });
234    }
235    if value > maximum {
236        return Err(StrategyConfigError::LimitTooLarge {
237            field,
238            value,
239            maximum,
240        });
241    }
242    Ok(())
243}
244
245fn valid_identifier(value: &str, maximum: usize) -> bool {
246    !value.is_empty()
247        && value.len() <= maximum
248        && value
249            .bytes()
250            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
251}