1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6const MAX_SCOPED_ID_LEN: usize = 64;
7const MAX_ASSET_ID_LEN: usize = 24;
8
9fn validate_ascii_component(
10 kind: &'static str,
11 value: &str,
12 minimum: usize,
13 maximum: usize,
14 separator: impl Fn(u8) -> bool,
15) -> Result<(), IdentifierError> {
16 let length = value.len();
17 if !(minimum..=maximum).contains(&length) {
18 return Err(IdentifierError::InvalidLength {
19 kind,
20 minimum,
21 maximum,
22 actual: length,
23 });
24 }
25 if !value
26 .bytes()
27 .all(|byte| byte.is_ascii_alphanumeric() || separator(byte))
28 {
29 return Err(IdentifierError::InvalidCharacter { kind });
30 }
31 Ok(())
32}
33
34macro_rules! normalized_id {
35 ($name:ident, $kind:literal, $normalize:expr, $validate:expr) => {
36 #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
37 pub struct $name(String);
38
39 impl $name {
40 pub fn new(value: impl AsRef<str>) -> Result<Self, IdentifierError> {
41 let normalized: String = ($normalize)(value.as_ref().trim());
42 ($validate)(&normalized)?;
43 Ok(Self(normalized))
44 }
45
46 pub fn as_str(&self) -> &str {
47 &self.0
48 }
49 }
50
51 impl AsRef<str> for $name {
52 fn as_ref(&self) -> &str {
53 self.as_str()
54 }
55 }
56
57 impl fmt::Display for $name {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(self.as_str())
60 }
61 }
62
63 impl FromStr for $name {
64 type Err = IdentifierError;
65
66 fn from_str(value: &str) -> Result<Self, Self::Err> {
67 Self::new(value)
68 }
69 }
70
71 impl Serialize for $name {
72 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
73 where
74 S: Serializer,
75 {
76 serializer.serialize_str(self.as_str())
77 }
78 }
79
80 impl<'de> Deserialize<'de> for $name {
81 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
82 where
83 D: Deserializer<'de>,
84 {
85 let value = String::deserialize(deserializer)?;
86 Self::new(value).map_err(serde::de::Error::custom)
87 }
88 }
89 };
90}
91
92fn lowercase(value: &str) -> String {
93 value.to_ascii_lowercase()
94}
95
96fn uppercase(value: &str) -> String {
97 value.to_ascii_uppercase()
98}
99
100fn unchanged(value: &str) -> String {
101 value.to_owned()
102}
103
104fn validate_scoped(kind: &'static str, value: &str) -> Result<(), IdentifierError> {
105 validate_ascii_component(kind, value, 1, MAX_SCOPED_ID_LEN, |byte| {
106 matches!(byte, b'_' | b'-' | b'.')
107 })?;
108 if value.bytes().any(|byte| byte.is_ascii_uppercase()) {
109 return Err(IdentifierError::NonCanonicalCase { kind });
110 }
111 Ok(())
112}
113
114fn validate_listing(value: &str) -> Result<(), IdentifierError> {
115 validate_ascii_component("listing ID", value, 1, MAX_SCOPED_ID_LEN, |byte| {
116 matches!(byte, b'_' | b'-' | b'.')
117 })?;
118 if value.bytes().any(|byte| byte.is_ascii_lowercase()) {
119 return Err(IdentifierError::NonCanonicalCase { kind: "listing ID" });
120 }
121 Ok(())
122}
123
124normalized_id!(AssetId, "asset ID", uppercase, |value: &str| {
125 validate_ascii_component("asset ID", value, 2, MAX_ASSET_ID_LEN, |byte| {
126 matches!(byte, b'_' | b'-' | b'.')
127 })?;
128 if value.bytes().any(|byte| byte.is_ascii_lowercase()) {
129 return Err(IdentifierError::NonCanonicalCase { kind: "asset ID" });
130 }
131 Ok(())
132});
133normalized_id!(
134 ListingVenueId,
135 "listing venue ID",
136 lowercase,
137 |value: &str| { validate_scoped("listing venue ID", value) }
138);
139normalized_id!(ListingId, "listing ID", uppercase, validate_listing);
140normalized_id!(MarketKind, "market kind", lowercase, |value: &str| {
141 validate_scoped("market kind", value)
142});
143normalized_id!(
144 TradingPlatformId,
145 "trading platform ID",
146 lowercase,
147 |value: &str| { validate_scoped("trading platform ID", value) }
148);
149normalized_id!(
150 ExecutionVenueId,
151 "execution venue ID",
152 lowercase,
153 |value: &str| { validate_scoped("execution venue ID", value) }
154);
155normalized_id!(
156 MarketDataSourceId,
157 "market data source ID",
158 lowercase,
159 |value: &str| { validate_scoped("market data source ID", value) }
160);
161normalized_id!(
162 EconomicsModelId,
163 "economics model ID",
164 lowercase,
165 |value: &str| { validate_scoped("economics model ID", value) }
166);
167normalized_id!(
168 EconomicsImplementationId,
169 "economics implementation ID",
170 lowercase,
171 |value: &str| { validate_scoped("economics implementation ID", value) }
172);
173normalized_id!(SpecRevision, "spec revision", unchanged, |value: &str| {
174 validate_spec_revision(value)
175});
176normalized_id!(
177 InstrumentAlias,
178 "instrument alias",
179 uppercase,
180 |value: &str| {
181 validate_ascii_component("instrument alias", value, 1, MAX_SCOPED_ID_LEN, |byte| {
182 matches!(byte, b'_' | b'-' | b'.' | b'/')
183 })?;
184 if value.bytes().any(|byte| byte.is_ascii_lowercase()) {
185 return Err(IdentifierError::NonCanonicalCase {
186 kind: "instrument alias",
187 });
188 }
189 Ok(())
190 }
191);
192
193impl MarketKind {
194 pub const FX_CFD: &'static str = "fx_cfd";
195 pub const METAL_CFD: &'static str = "metal_cfd";
196 pub const COMMODITY_CFD: &'static str = "commodity_cfd";
197 pub const INDEX_CFD: &'static str = "index_cfd";
198 pub const LINEAR_EXPOSURE: &'static str = "linear_exposure";
199 pub const CASH_SPOT: &'static str = "cash_spot";
200 pub const LINEAR_FUTURE: &'static str = "linear_future";
201 pub const LINEAR_PERPETUAL: &'static str = "linear_perpetual";
202 pub const INVERSE_FUTURE: &'static str = "inverse_future";
203 pub const INVERSE_PERPETUAL: &'static str = "inverse_perpetual";
204}
205
206impl EconomicsModelId {
207 pub const FX_QUOTE_LINEAR_V1: &'static str = "fx_quote_linear_v1";
208 pub const CFD_QUOTE_LINEAR_V1: &'static str = "cfd_quote_linear_v1";
209 pub const LINEAR_BASE_QUANTITY_V1: &'static str = "linear_base_quantity_v1";
210 pub const CASH_SPOT_INVENTORY_V1: &'static str = "cash_spot_inventory_v1";
211 pub const LINEAR_CONTRACT_V1: &'static str = "linear_contract_v1";
212 pub const INVERSE_CONTRACT_V1: &'static str = "inverse_contract_v1";
213}
214
215fn validate_spec_revision(value: &str) -> Result<(), IdentifierError> {
216 if value.is_empty() || value.len() > MAX_SCOPED_ID_LEN || !value.is_ascii() {
217 return Err(IdentifierError::InvalidLength {
218 kind: "spec revision",
219 minimum: 1,
220 maximum: MAX_SCOPED_ID_LEN,
221 actual: value.len(),
222 });
223 }
224
225 let (without_build, build) = value
226 .split_once('+')
227 .map_or((value, None), |(core, build)| (core, Some(build)));
228 if build.is_some_and(|build| !valid_semantic_identifiers(build, false)) {
229 return Err(IdentifierError::InvalidSemanticRevision);
230 }
231 let (core, prerelease) = without_build
232 .split_once('-')
233 .map_or((without_build, None), |(core, prerelease)| {
234 (core, Some(prerelease))
235 });
236 if prerelease.is_some_and(|prerelease| !valid_semantic_identifiers(prerelease, true)) {
237 return Err(IdentifierError::InvalidSemanticRevision);
238 }
239
240 let components = core.split('.').collect::<Vec<_>>();
241 if components.len() != 3
242 || components.iter().any(|component| {
243 component.is_empty()
244 || !component.bytes().all(|byte| byte.is_ascii_digit())
245 || (component.len() > 1 && component.starts_with('0'))
246 })
247 {
248 return Err(IdentifierError::InvalidSemanticRevision);
249 }
250 Ok(())
251}
252
253fn valid_semantic_identifiers(value: &str, reject_numeric_leading_zero: bool) -> bool {
254 !value.is_empty()
255 && value.split('.').all(|identifier| {
256 !identifier.is_empty()
257 && identifier
258 .bytes()
259 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
260 && !(reject_numeric_leading_zero
261 && identifier.len() > 1
262 && identifier.bytes().all(|byte| byte.is_ascii_digit())
263 && identifier.starts_with('0'))
264 })
265}
266
267#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
269#[serde(deny_unknown_fields)]
270pub struct InstrumentId {
271 pub listing_venue: ListingVenueId,
272 pub market_kind: MarketKind,
273 pub listing: ListingId,
274}
275
276impl InstrumentId {
277 pub fn new(listing_venue: ListingVenueId, market_kind: MarketKind, listing: ListingId) -> Self {
278 Self {
279 listing_venue,
280 market_kind,
281 listing,
282 }
283 }
284}
285
286impl fmt::Display for InstrumentId {
287 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 write!(
289 f,
290 "{}/{}/{}",
291 self.listing_venue, self.market_kind, self.listing
292 )
293 }
294}
295
296impl FromStr for InstrumentId {
297 type Err = InstrumentIdError;
298
299 fn from_str(value: &str) -> Result<Self, Self::Err> {
300 let mut components = value.split('/');
301 let listing_venue = components.next().ok_or(InstrumentIdError::InvalidFormat)?;
302 let market_kind = components.next().ok_or(InstrumentIdError::InvalidFormat)?;
303 let listing = components.next().ok_or(InstrumentIdError::InvalidFormat)?;
304 if components.next().is_some() {
305 return Err(InstrumentIdError::InvalidFormat);
306 }
307 Ok(Self::new(
308 listing_venue.parse()?,
309 market_kind.parse()?,
310 listing.parse()?,
311 ))
312 }
313}
314
315#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
317pub enum IdentifierError {
318 #[error("{kind} length must be between {minimum} and {maximum} bytes, got {actual}")]
319 InvalidLength {
320 kind: &'static str,
321 minimum: usize,
322 maximum: usize,
323 actual: usize,
324 },
325 #[error("{kind} contains an unsupported character")]
326 InvalidCharacter { kind: &'static str },
327 #[error("{kind} uses noncanonical letter case")]
328 NonCanonicalCase { kind: &'static str },
329 #[error("spec revision must be a semantic version such as 1.0.0")]
330 InvalidSemanticRevision,
331}
332
333#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
335pub enum InstrumentIdError {
336 #[error("instrument ID must have listing-venue/market-kind/listing form")]
337 InvalidFormat,
338 #[error(transparent)]
339 InvalidComponent(#[from] IdentifierError),
340}