Skip to main content

qs_core/
canonical.rs

1use std::fmt;
2use std::str::FromStr;
3
4use chrono::{DateTime, NaiveDateTime, SecondsFormat, Utc};
5use qs_instruments::{Decimal, PositiveDecimal};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8pub const MAX_DOMAIN_ID_BYTES: usize = 160;
9pub const MAX_TRADE_INTENT_ID_BYTES: usize = "intent:".len() + MAX_DOMAIN_ID_BYTES + 1 + 10;
10pub const MAX_EXECUTION_COMMAND_ID_BYTES: usize =
11    "command:".len() + MAX_TRADE_INTENT_ID_BYTES + 1 + 20;
12pub const MAX_OPAQUE_REF_BYTES: usize = 512;
13
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15pub enum CanonicalDomainError {
16    #[error("{kind} length must be between 1 and {maximum} bytes, got {actual}")]
17    InvalidIdentifierLength {
18        kind: &'static str,
19        maximum: usize,
20        actual: usize,
21    },
22    #[error("{kind} contains an unsupported character")]
23    InvalidIdentifierCharacter { kind: &'static str },
24    #[error("invalid UTC timestamp: {0}")]
25    InvalidTimestamp(String),
26    #[error("fraction must be greater than zero and at most one")]
27    InvalidFraction,
28    #[error("duration must be greater than zero milliseconds")]
29    InvalidDuration,
30}
31
32fn validate_identifier(
33    kind: &'static str,
34    value: &str,
35    maximum: usize,
36) -> Result<(), CanonicalDomainError> {
37    if value.is_empty() || value.len() > maximum {
38        return Err(CanonicalDomainError::InvalidIdentifierLength {
39            kind,
40            maximum,
41            actual: value.len(),
42        });
43    }
44    if !value.is_ascii()
45        || value
46            .bytes()
47            .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
48    {
49        return Err(CanonicalDomainError::InvalidIdentifierCharacter { kind });
50    }
51    Ok(())
52}
53
54macro_rules! domain_id {
55    ($name:ident, $kind:literal, $maximum:expr) => {
56        #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
57        pub struct $name(String);
58
59        impl $name {
60            pub fn new(value: impl Into<String>) -> Result<Self, CanonicalDomainError> {
61                let value = value.into();
62                validate_identifier($kind, &value, $maximum)?;
63                Ok(Self(value))
64            }
65
66            pub fn as_str(&self) -> &str {
67                &self.0
68            }
69        }
70
71        impl fmt::Display for $name {
72            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73                formatter.write_str(&self.0)
74            }
75        }
76
77        impl FromStr for $name {
78            type Err = CanonicalDomainError;
79
80            fn from_str(value: &str) -> Result<Self, Self::Err> {
81                Self::new(value)
82            }
83        }
84
85        impl Serialize for $name {
86            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
87            where
88                S: Serializer,
89            {
90                serializer.serialize_str(&self.0)
91            }
92        }
93
94        impl<'de> Deserialize<'de> for $name {
95            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
96            where
97                D: Deserializer<'de>,
98            {
99                Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
100            }
101        }
102    };
103}
104
105domain_id!(TradeIntentId, "trade intent ID", MAX_TRADE_INTENT_ID_BYTES);
106domain_id!(IntentProducerId, "intent producer ID", MAX_DOMAIN_ID_BYTES);
107domain_id!(
108    IntentCorrelationId,
109    "intent correlation ID",
110    MAX_DOMAIN_ID_BYTES
111);
112domain_id!(
113    IntentIdentityNamespace,
114    "intent identity namespace",
115    MAX_DOMAIN_ID_BYTES
116);
117domain_id!(
118    IntentPositionRef,
119    "intent position reference",
120    MAX_DOMAIN_ID_BYTES
121);
122domain_id!(
123    IntentCampaignRef,
124    "intent campaign reference",
125    MAX_DOMAIN_ID_BYTES
126);
127domain_id!(
128    IntentStateRef,
129    "intent state reference",
130    MAX_DOMAIN_ID_BYTES
131);
132domain_id!(
133    ExecutionCommandId,
134    "execution command ID",
135    MAX_EXECUTION_COMMAND_ID_BYTES
136);
137domain_id!(VenueOrderRef, "venue order reference", MAX_DOMAIN_ID_BYTES);
138domain_id!(
139    VenuePositionRef,
140    "venue position reference",
141    MAX_DOMAIN_ID_BYTES
142);
143domain_id!(FillId, "fill ID", MAX_DOMAIN_ID_BYTES);
144domain_id!(
145    OpaqueProvenanceRef,
146    "opaque provenance reference",
147    MAX_OPAQUE_REF_BYTES
148);
149domain_id!(
150    OpaquePayloadRef,
151    "opaque payload reference",
152    MAX_OPAQUE_REF_BYTES
153);
154
155#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
156pub struct DateTimeUtc(DateTime<Utc>);
157
158impl DateTimeUtc {
159    pub fn new(value: DateTime<Utc>) -> Self {
160        Self(value)
161    }
162
163    pub fn from_naive_utc(value: NaiveDateTime) -> Self {
164        Self(DateTime::from_naive_utc_and_offset(value, Utc))
165    }
166
167    pub fn parse(value: &str) -> Result<Self, CanonicalDomainError> {
168        DateTime::parse_from_rfc3339(value)
169            .map(|timestamp| Self(timestamp.with_timezone(&Utc)))
170            .map_err(|error| CanonicalDomainError::InvalidTimestamp(error.to_string()))
171    }
172
173    pub fn into_inner(self) -> DateTime<Utc> {
174        self.0
175    }
176
177    pub fn as_inner(&self) -> &DateTime<Utc> {
178        &self.0
179    }
180}
181
182impl fmt::Display for DateTimeUtc {
183    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184        formatter.write_str(&canonical_timestamp(self.0))
185    }
186}
187
188impl Serialize for DateTimeUtc {
189    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
190    where
191        S: Serializer,
192    {
193        serializer.serialize_str(&canonical_timestamp(self.0))
194    }
195}
196
197impl<'de> Deserialize<'de> for DateTimeUtc {
198    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
199    where
200        D: Deserializer<'de>,
201    {
202        Self::parse(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
203    }
204}
205
206fn canonical_timestamp(value: DateTime<Utc>) -> String {
207    let mut value = value.to_rfc3339_opts(SecondsFormat::Nanos, true);
208    let suffix = value.pop();
209    debug_assert_eq!(suffix, Some('Z'));
210    while value.ends_with('0') {
211        value.pop();
212    }
213    if value.ends_with('.') {
214        value.pop();
215    }
216    value.push('Z');
217    value
218}
219
220#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
221pub struct PositiveFraction(PositiveDecimal);
222
223impl PositiveFraction {
224    pub fn new(value: PositiveDecimal) -> Result<Self, CanonicalDomainError> {
225        let one = Decimal::new(1, 0).expect("one is a valid decimal");
226        if value.get() > one {
227            return Err(CanonicalDomainError::InvalidFraction);
228        }
229        Ok(Self(value))
230    }
231
232    pub const fn get(self) -> PositiveDecimal {
233        self.0
234    }
235}
236
237impl Serialize for PositiveFraction {
238    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
239    where
240        S: Serializer,
241    {
242        self.0.serialize(serializer)
243    }
244}
245
246impl<'de> Deserialize<'de> for PositiveFraction {
247    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
248    where
249        D: Deserializer<'de>,
250    {
251        Self::new(PositiveDecimal::deserialize(deserializer)?).map_err(serde::de::Error::custom)
252    }
253}
254
255#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
256#[serde(transparent)]
257pub struct PriceDistance(PositiveDecimal);
258
259impl PriceDistance {
260    pub const fn new(value: PositiveDecimal) -> Self {
261        Self(value)
262    }
263
264    pub const fn get(self) -> PositiveDecimal {
265        self.0
266    }
267}
268
269#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
270#[serde(transparent)]
271pub struct DurationMillis(u64);
272
273impl DurationMillis {
274    pub fn new(value: u64) -> Result<Self, CanonicalDomainError> {
275        if value == 0 {
276            return Err(CanonicalDomainError::InvalidDuration);
277        }
278        Ok(Self(value))
279    }
280
281    pub const fn get(self) -> u64 {
282        self.0
283    }
284}
285
286#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
287#[serde(rename_all = "snake_case")]
288pub enum OperatingMode {
289    Research,
290    Replay,
291    Shadow,
292    Paper,
293    Live,
294}
295
296#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
297#[serde(rename_all = "snake_case")]
298pub enum ExecutionCapability {
299    MarketOrder,
300    LimitOrder,
301    StopOrder,
302    StopLimitOrder,
303    PartialReduction,
304    ReplaceProtection,
305    ReplaceTargets,
306    AddTranche,
307    CancelEntry,
308    ScopedFlatten,
309    ReduceOnly,
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn identifiers_are_strict() {
318        assert!(TradeIntentId::new("").is_err());
319        assert!(TradeIntentId::new("intent with spaces").is_err());
320        assert!(TradeIntentId::new("x".repeat(MAX_TRADE_INTENT_ID_BYTES)).is_ok());
321        assert!(TradeIntentId::new("x".repeat(MAX_TRADE_INTENT_ID_BYTES + 1)).is_err());
322        assert!(ExecutionCommandId::new("x".repeat(MAX_EXECUTION_COMMAND_ID_BYTES)).is_ok());
323        assert!(ExecutionCommandId::new("x".repeat(MAX_EXECUTION_COMMAND_ID_BYTES + 1)).is_err());
324    }
325
326    #[test]
327    fn timestamp_serialization_is_canonical_utc() {
328        let timestamp = DateTimeUtc::parse("2026-08-14T12:00:00.120000+02:00").unwrap();
329        assert_eq!(
330            serde_json::to_string(&timestamp).unwrap(),
331            "\"2026-08-14T10:00:00.12Z\""
332        );
333    }
334
335    #[test]
336    fn positive_fraction_rejects_values_above_one() {
337        let half = PositiveDecimal::new("0.5".parse().unwrap()).unwrap();
338        assert_eq!(PositiveFraction::new(half).unwrap().get(), half);
339        let too_large = PositiveDecimal::new("1.1".parse().unwrap()).unwrap();
340        assert!(PositiveFraction::new(too_large).is_err());
341    }
342}