Skip to main content

qs_instruments/
identity.rs

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/// A broker-, exchange-, or internally-owned listing identity.
268#[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/// Validation failures for normalized identifiers.
316#[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/// Parsing failures for the display form of an instrument identity.
334#[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}