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(×tamp).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}