paft_domain/
instrument.rs

1//! Instrument identifier and asset classification domain types.
2
3use super::Exchange;
4use crate::{
5    DomainError,
6    identifiers::{Figi, Isin, Symbol},
7};
8use serde::{Deserialize, Serialize};
9use std::{borrow::Cow, str::FromStr};
10
11#[cfg(feature = "dataframe")]
12use df_derive::ToDataFrame;
13
14/// Kinds of financial instruments
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
16#[non_exhaustive]
17pub enum AssetKind {
18    /// Common stock or equity-like instruments.
19    #[default]
20    Equity,
21    /// Cryptocurrency assets.
22    Crypto,
23    /// Funds and ETFs.
24    Fund,
25    /// Market indexes.
26    Index,
27    /// Foreign exchange currency pairs.
28    Forex,
29    /// Bonds and fixed income.
30    Bond,
31    /// Commodities.
32    Commodity,
33    /// Option contracts.
34    Option,
35    /// Commodity futures.
36    Future,
37    /// Real Estate Investment Trusts.
38    REIT,
39    /// Warrants.
40    Warrant,
41    /// Convertible bonds/securities.
42    Convertible,
43    /// Non-fungible tokens.
44    NFT,
45    /// Perpetual futures contracts (no expiration date).
46    PerpetualFuture,
47    /// Leveraged tokens (e.g., 3x leveraged Bitcoin tokens).
48    LeveragedToken,
49    /// Liquidity provider tokens (`DeFi` protocol tokens).
50    LPToken,
51    /// Liquid staking tokens (e.g., stETH, rETH).
52    LST,
53    /// Real-world assets (tokenized physical assets).
54    RWA,
55}
56
57crate::string_enum_closed_with_code!(
58    AssetKind,
59    "AssetKind",
60    {
61        "EQUITY" => AssetKind::Equity,
62        "CRYPTO" => AssetKind::Crypto,
63        "FUND" => AssetKind::Fund,
64        "INDEX" => AssetKind::Index,
65        "FOREX" => AssetKind::Forex,
66        "BOND" => AssetKind::Bond,
67        "COMMODITY" => AssetKind::Commodity,
68        "OPTION" => AssetKind::Option,
69        "FUTURE" => AssetKind::Future,
70        "REIT" => AssetKind::REIT,
71        "WARRANT" => AssetKind::Warrant,
72        "CONVERTIBLE" => AssetKind::Convertible,
73        "NFT" => AssetKind::NFT,
74        "PERPETUAL_FUTURE" => AssetKind::PerpetualFuture,
75        "LEVERAGED_TOKEN" => AssetKind::LeveragedToken,
76        "LP_TOKEN" => AssetKind::LPToken,
77        "LST" => AssetKind::LST,
78        "RWA" => AssetKind::RWA
79    },
80    {
81        "STOCK" => AssetKind::Equity,
82        "FX" => AssetKind::Forex,
83    }
84);
85
86crate::impl_display_via_code!(AssetKind);
87
88impl AssetKind {
89    /// Human-readable label for displaying this asset kind.
90    #[must_use]
91    pub const fn full_name(&self) -> &'static str {
92        match self {
93            Self::Equity => "Equity",
94            Self::Crypto => "Crypto",
95            Self::Fund => "Fund",
96            Self::Index => "Index",
97            Self::Forex => "Forex",
98            Self::Bond => "Bond",
99            Self::Commodity => "Commodity",
100            Self::Option => "Option",
101            Self::Future => "Future",
102            Self::REIT => "REIT",
103            Self::Warrant => "Warrant",
104            Self::Convertible => "Convertible",
105            Self::NFT => "NFT",
106            Self::PerpetualFuture => "Perpetual Future",
107            Self::LeveragedToken => "Leveraged Token",
108            Self::LPToken => "LP Token",
109            Self::LST => "Liquid Staking Token",
110            Self::RWA => "Real-World Asset",
111        }
112    }
113}
114
115/// Logical instrument identifier with hierarchical identifier support and asset classification.
116///
117/// This struct serves as a container for multiple types of identifiers, prioritizing
118/// stable, universal identifiers like FIGI while maintaining backward compatibility
119/// with symbol-based identification. The hierarchical approach allows providers to
120/// populate the identifiers they have access to, while encouraging the use of
121/// better identifiers when available.
122///
123/// Symbol values are canonicalized into the [`Symbol`] newtype, preserving casing
124/// and punctuation semantics required by upstream data sources.
125#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
126#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
127pub struct Instrument {
128    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
129    figi: Option<Figi>,
130    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
131    isin: Option<Isin>,
132    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
133    symbol: Symbol,
134    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
135    exchange: Option<Exchange>,
136    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
137    kind: AssetKind,
138}
139
140impl Instrument {
141    /// # Errors
142    /// Returns `DomainError::InvalidIsin` when `isin` is empty, malformed,
143    /// or fails normalization/validation.
144    #[cfg_attr(
145        feature = "tracing",
146        tracing::instrument(level = "debug", skip(self), err)
147    )]
148    pub fn try_set_isin(&mut self, isin: &str) -> Result<(), DomainError> {
149        self.isin = Some(Isin::new(isin)?);
150        Ok(())
151    }
152
153    /// Try to set the FIGI while ensuring validation.
154    ///
155    /// # Errors
156    /// Returns `DomainError::InvalidFigi` when `figi` is empty, not exactly
157    /// 12 ASCII alphanumeric characters, or fails the checksum.
158    #[cfg_attr(
159        feature = "tracing",
160        tracing::instrument(level = "debug", skip(self), err)
161    )]
162    pub fn try_set_figi(&mut self, figi: &str) -> Result<(), DomainError> {
163        self.figi = Some(Figi::new(figi)?);
164        Ok(())
165    }
166
167    /// Try to set the ISIN while consuming and returning the instrument.
168    ///
169    /// # Errors
170    /// Propagates `DomainError::InvalidIsin` from [`Isin::new`].
171    #[cfg_attr(
172        feature = "tracing",
173        tracing::instrument(level = "debug", skip(self), err)
174    )]
175    pub fn try_with_isin(mut self, isin: &str) -> Result<Self, DomainError> {
176        self.try_set_isin(isin)?;
177        Ok(self)
178    }
179
180    /// Try to set the FIGI while consuming and returning the instrument.
181    ///
182    /// # Errors
183    /// Propagates `DomainError::InvalidFigi` from [`Figi::new`].
184    #[cfg_attr(
185        feature = "tracing",
186        tracing::instrument(level = "debug", skip(self), err)
187    )]
188    pub fn try_with_figi(mut self, figi: &str) -> Result<Self, DomainError> {
189        self.try_set_figi(figi)?;
190        Ok(self)
191    }
192
193    /// # Errors
194    /// Returns `DomainError::InvalidSymbol`, `DomainError::InvalidFigi`, or
195    /// `DomainError::InvalidIsin` if the provided identifiers fail
196    /// validation/normalization.
197    #[cfg_attr(
198        feature = "tracing",
199        tracing::instrument(level = "debug", skip(symbol), err)
200    )]
201    pub fn try_new(
202        symbol: impl AsRef<str>,
203        kind: AssetKind,
204        figi: Option<&str>,
205        isin: Option<&str>,
206        exchange: Option<Exchange>,
207    ) -> Result<Self, DomainError> {
208        let symbol = Symbol::new(symbol.as_ref())?;
209        let mut instrument = Self {
210            figi: None,
211            isin: None,
212            symbol,
213            exchange,
214            kind,
215        };
216
217        if let Some(figi_value) = figi {
218            instrument.try_set_figi(figi_value)?;
219        }
220        if let Some(isin_value) = isin {
221            instrument.try_set_isin(isin_value)?;
222        }
223
224        Ok(instrument)
225    }
226
227    /// Construct a new `Instrument` with just a symbol and kind (backward compatibility).
228    /// This is useful for providers that only have basic symbol information.
229    ///
230    /// # Errors
231    /// Returns `DomainError::InvalidSymbol` if the provided symbol violates canonical invariants.
232    #[cfg_attr(
233        feature = "tracing",
234        tracing::instrument(level = "debug", skip(symbol), err)
235    )]
236    pub fn from_symbol(symbol: impl AsRef<str>, kind: AssetKind) -> Result<Self, DomainError> {
237        Ok(Self {
238            figi: None,
239            isin: None,
240            symbol: Symbol::new(symbol.as_ref())?,
241            exchange: None,
242            kind,
243        })
244    }
245
246    /// Construct a new `Instrument` with symbol, exchange, and kind.
247    /// This is useful for providers that have exchange information but no global identifiers.
248    ///
249    /// # Errors
250    /// Returns `DomainError::InvalidSymbol` if the provided symbol violates canonical invariants.
251    #[cfg_attr(
252        feature = "tracing",
253        tracing::instrument(level = "debug", skip(symbol), err)
254    )]
255    pub fn from_symbol_and_exchange(
256        symbol: impl AsRef<str>,
257        exchange: Exchange,
258        kind: AssetKind,
259    ) -> Result<Self, DomainError> {
260        Ok(Self {
261            figi: None,
262            isin: None,
263            symbol: Symbol::new(symbol.as_ref())?,
264            exchange: Some(exchange),
265            kind,
266        })
267    }
268
269    /// Returns the best available unique identifier for this instrument.
270    ///
271    /// Priority order:
272    /// 1. FIGI (if available)
273    /// 2. ISIN (if available)
274    /// 3. `SYMBOL@EXCHANGE` (if the exchange is available)
275    /// 4. Symbol only (fallback; ambiguous across venues/data vendors)
276    ///
277    /// This method returns a `Cow<str>` to avoid unnecessary allocations:
278    /// - Returns `Cow::Borrowed` for FIGI, ISIN, and symbol-only cases
279    /// - Returns `Cow::Owned` only for the symbol@exchange case that requires formatting
280    ///
281    /// Bare symbols are not globally unique; callers should prefer FIGI/ISIN
282    /// when present and treat the symbol fallback as legacy.
283    ///
284    /// # Future compatibility
285    /// The `symbol@exchange` fallback currently uses the exchange display code (e.g. `NASDAQ`).
286    /// These values are not MICs and should be treated as a legacy format until
287    /// a canonical mapping is introduced in a future release.
288    #[must_use]
289    pub fn unique_key(&self) -> Cow<'_, str> {
290        if let Some(figi) = &self.figi {
291            return Cow::Borrowed(figi.as_ref());
292        }
293        if let Some(isin) = &self.isin {
294            return Cow::Borrowed(isin.as_ref());
295        }
296        if let Some(exchange) = &self.exchange {
297            return Cow::Owned(format!("{}@{}", self.symbol, exchange.code()));
298        }
299        Cow::Borrowed(self.symbol.as_str())
300    }
301
302    /// Returns true if this instrument has a globally unique identifier (FIGI or ISIN).
303    #[must_use]
304    pub const fn is_globally_identified(&self) -> bool {
305        self.figi.is_some() || self.isin.is_some()
306    }
307
308    /// Returns the FIGI identifier if available.
309    #[must_use]
310    pub const fn figi(&self) -> Option<&Figi> {
311        self.figi.as_ref()
312    }
313
314    /// Returns the FIGI as a string slice if available.
315    #[must_use]
316    pub fn figi_str(&self) -> Option<&str> {
317        self.figi.as_ref().map(AsRef::as_ref)
318    }
319
320    /// Returns the ISIN identifier if available.
321    #[must_use]
322    pub const fn isin(&self) -> Option<&Isin> {
323        self.isin.as_ref()
324    }
325
326    /// Returns the ISIN as a string slice if available.
327    #[must_use]
328    pub fn isin_str(&self) -> Option<&str> {
329        self.isin.as_ref().map(AsRef::as_ref)
330    }
331
332    /// Returns the canonical instrument symbol.
333    #[must_use]
334    pub const fn symbol(&self) -> &Symbol {
335        &self.symbol
336    }
337
338    /// Returns the ticker symbol as a string slice.
339    #[must_use]
340    pub fn symbol_str(&self) -> &str {
341        self.symbol.as_str()
342    }
343
344    /// Returns the exchange if available.
345    #[must_use]
346    pub const fn exchange(&self) -> Option<&Exchange> {
347        self.exchange.as_ref()
348    }
349
350    /// Returns the asset kind.
351    #[must_use]
352    pub const fn kind(&self) -> &AssetKind {
353        &self.kind
354    }
355
356    /// Returns true if this instrument has a FIGI identifier.
357    #[must_use]
358    pub const fn has_figi(&self) -> bool {
359        self.figi.is_some()
360    }
361
362    /// Returns true if this instrument has an ISIN identifier.
363    #[must_use]
364    pub const fn has_isin(&self) -> bool {
365        self.isin.is_some()
366    }
367
368    /// Returns true if this instrument has exchange information.
369    #[must_use]
370    pub const fn has_exchange(&self) -> bool {
371        self.exchange.is_some()
372    }
373}