Skip to main content

qs_strategy/
value.rs

1use chrono::{Duration, NaiveDateTime};
2use qs_core::Side;
3use serde::{Deserialize, Serialize};
4
5pub const MAX_ID_BYTES: usize = 64;
6pub const MAX_TEXT_BYTES: usize = 256;
7pub const MAX_GENERATED_ID_BYTES: usize = 192;
8
9/// A scalar type understood by configured expressions.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ScalarType {
13    Bool,
14    Integer,
15    Number,
16    Price,
17    Ratio,
18    Percent,
19    PricePerObservation,
20    PricePerObservationSquared,
21    RatioPerObservation,
22    RatioPerObservationSquared,
23    LogReturn,
24    LogReturnVariance,
25    Timestamp,
26    Duration,
27    Text,
28    Side,
29}
30
31/// A scalar expression type with explicit optionality.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct ValueType {
35    pub scalar: ScalarType,
36    #[serde(default)]
37    pub optional: bool,
38}
39
40impl ValueType {
41    pub const fn required(scalar: ScalarType) -> Self {
42        Self {
43            scalar,
44            optional: false,
45        }
46    }
47
48    pub const fn optional(scalar: ScalarType) -> Self {
49        Self {
50            scalar,
51            optional: true,
52        }
53    }
54}
55
56/// A typed runtime value. Missing always retains its scalar type.
57#[derive(Debug, Clone, PartialEq)]
58pub enum Value {
59    Missing(ScalarType),
60    Bool(bool),
61    Integer(i64),
62    Number(f64),
63    Price(f64),
64    Ratio(f64),
65    Percent(f64),
66    PricePerObservation(f64),
67    PricePerObservationSquared(f64),
68    RatioPerObservation(f64),
69    RatioPerObservationSquared(f64),
70    LogReturn(f64),
71    LogReturnVariance(f64),
72    Timestamp(NaiveDateTime),
73    Duration(Duration),
74    Text(String),
75    Side(Side),
76}
77
78impl Value {
79    pub fn scalar_type(&self) -> ScalarType {
80        match self {
81            Self::Missing(value_type) => *value_type,
82            Self::Bool(_) => ScalarType::Bool,
83            Self::Integer(_) => ScalarType::Integer,
84            Self::Number(_) => ScalarType::Number,
85            Self::Price(_) => ScalarType::Price,
86            Self::Ratio(_) => ScalarType::Ratio,
87            Self::Percent(_) => ScalarType::Percent,
88            Self::PricePerObservation(_) => ScalarType::PricePerObservation,
89            Self::PricePerObservationSquared(_) => ScalarType::PricePerObservationSquared,
90            Self::RatioPerObservation(_) => ScalarType::RatioPerObservation,
91            Self::RatioPerObservationSquared(_) => ScalarType::RatioPerObservationSquared,
92            Self::LogReturn(_) => ScalarType::LogReturn,
93            Self::LogReturnVariance(_) => ScalarType::LogReturnVariance,
94            Self::Timestamp(_) => ScalarType::Timestamp,
95            Self::Duration(_) => ScalarType::Duration,
96            Self::Text(_) => ScalarType::Text,
97            Self::Side(_) => ScalarType::Side,
98        }
99    }
100
101    pub fn is_missing(&self) -> bool {
102        matches!(self, Self::Missing(_))
103    }
104
105    pub(crate) fn finite(self, path: &str) -> Result<Self, crate::EvaluationError> {
106        match self {
107            Self::Number(value)
108            | Self::Price(value)
109            | Self::Ratio(value)
110            | Self::Percent(value)
111            | Self::PricePerObservation(value)
112            | Self::PricePerObservationSquared(value)
113            | Self::RatioPerObservation(value)
114            | Self::RatioPerObservationSquared(value)
115            | Self::LogReturn(value)
116            | Self::LogReturnVariance(value)
117                if !value.is_finite() =>
118            {
119                Err(crate::EvaluationError::NonFinite { path: path.into() })
120            }
121            value => Ok(value),
122        }
123    }
124}
125
126/// A strict configured literal, including an explicitly typed missing value.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128#[serde(
129    tag = "type",
130    content = "value",
131    rename_all = "snake_case",
132    deny_unknown_fields
133)]
134pub enum Literal {
135    Missing(ScalarType),
136    Bool(bool),
137    Integer(i64),
138    Number(f64),
139    Price(f64),
140    Ratio(f64),
141    Percent(f64),
142    PricePerObservation(f64),
143    PricePerObservationSquared(f64),
144    RatioPerObservation(f64),
145    RatioPerObservationSquared(f64),
146    LogReturn(f64),
147    LogReturnVariance(f64),
148    Timestamp(NaiveDateTime),
149    DurationMillis(i64),
150    Text(String),
151    Side(Side),
152}
153
154impl Literal {
155    pub fn value_type(&self) -> ValueType {
156        match self {
157            Self::Missing(value_type) => ValueType::optional(*value_type),
158            Self::Bool(_) => ValueType::required(ScalarType::Bool),
159            Self::Integer(_) => ValueType::required(ScalarType::Integer),
160            Self::Number(_) => ValueType::required(ScalarType::Number),
161            Self::Price(_) => ValueType::required(ScalarType::Price),
162            Self::Ratio(_) => ValueType::required(ScalarType::Ratio),
163            Self::Percent(_) => ValueType::required(ScalarType::Percent),
164            Self::PricePerObservation(_) => ValueType::required(ScalarType::PricePerObservation),
165            Self::PricePerObservationSquared(_) => {
166                ValueType::required(ScalarType::PricePerObservationSquared)
167            }
168            Self::RatioPerObservation(_) => ValueType::required(ScalarType::RatioPerObservation),
169            Self::RatioPerObservationSquared(_) => {
170                ValueType::required(ScalarType::RatioPerObservationSquared)
171            }
172            Self::LogReturn(_) => ValueType::required(ScalarType::LogReturn),
173            Self::LogReturnVariance(_) => ValueType::required(ScalarType::LogReturnVariance),
174            Self::Timestamp(_) => ValueType::required(ScalarType::Timestamp),
175            Self::DurationMillis(_) => ValueType::required(ScalarType::Duration),
176            Self::Text(_) => ValueType::required(ScalarType::Text),
177            Self::Side(_) => ValueType::required(ScalarType::Side),
178        }
179    }
180
181    pub fn to_value(&self) -> Result<Value, crate::CompileError> {
182        let value = match self {
183            Self::Missing(value_type) => Value::Missing(*value_type),
184            Self::Bool(value) => Value::Bool(*value),
185            Self::Integer(value) => Value::Integer(*value),
186            Self::Number(value) => Value::Number(*value),
187            Self::Price(value) => Value::Price(*value),
188            Self::Ratio(value) => Value::Ratio(*value),
189            Self::Percent(value) => Value::Percent(*value),
190            Self::PricePerObservation(value) => Value::PricePerObservation(*value),
191            Self::PricePerObservationSquared(value) => Value::PricePerObservationSquared(*value),
192            Self::RatioPerObservation(value) => Value::RatioPerObservation(*value),
193            Self::RatioPerObservationSquared(value) => Value::RatioPerObservationSquared(*value),
194            Self::LogReturn(value) => Value::LogReturn(*value),
195            Self::LogReturnVariance(value) => Value::LogReturnVariance(*value),
196            Self::Timestamp(value) => Value::Timestamp(*value),
197            Self::DurationMillis(value) => Value::Duration(Duration::milliseconds(*value)),
198            Self::Text(value) => {
199                validate_text(value, MAX_TEXT_BYTES).map_err(|reason| {
200                    crate::CompileError::InvalidConfig {
201                        path: "literal.text".into(),
202                        reason,
203                    }
204                })?;
205                Value::Text(value.clone())
206            }
207            Self::Side(value) => Value::Side(*value),
208        };
209        match value {
210            Value::Number(number)
211            | Value::Price(number)
212            | Value::Ratio(number)
213            | Value::Percent(number)
214            | Value::PricePerObservation(number)
215            | Value::PricePerObservationSquared(number)
216            | Value::RatioPerObservation(number)
217            | Value::RatioPerObservationSquared(number)
218            | Value::LogReturn(number)
219            | Value::LogReturnVariance(number)
220                if !number.is_finite() =>
221            {
222                Err(crate::CompileError::InvalidConfig {
223                    path: "literal".into(),
224                    reason: "number must be finite".into(),
225                })
226            }
227            _ => Ok(value),
228        }
229    }
230}
231
232pub(crate) fn validate_id(value: &str) -> Result<(), String> {
233    if value.is_empty() {
234        return Err("must not be empty".into());
235    }
236    if value.len() > MAX_ID_BYTES {
237        return Err(format!("must not exceed {MAX_ID_BYTES} bytes"));
238    }
239    if !value
240        .bytes()
241        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
242    {
243        return Err("must contain only ASCII letters, digits, '_', '-', or '.'".into());
244    }
245    Ok(())
246}
247
248pub(crate) fn validate_text(value: &str, limit: usize) -> Result<(), String> {
249    if value.is_empty() {
250        return Err("must not be empty".into());
251    }
252    if value != value.trim() {
253        return Err("must not have leading or trailing whitespace".into());
254    }
255    if value.chars().any(char::is_control) {
256        return Err("must not contain control characters".into());
257    }
258    if value.len() > limit {
259        return Err(format!("must not exceed {limit} bytes"));
260    }
261    Ok(())
262}