Skip to main content

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;
10
11paft_core::other_string_code_type!(
12    /// Provider-specific asset kind that is not modeled by [`AssetKind`].
13    pub struct OtherAssetKind for AssetKind;
14    type Error = DomainError;
15    parse(input) => input.parse::<AssetKind>();
16    invalid(input) => DomainError::InvalidAssetKindValue {
17        value: input.to_string(),
18    };
19);
20
21/// Kinds of financial instruments.
22///
23/// Canonical/serde rules:
24/// - Emission uses a single canonical form per variant (UPPERCASE ASCII, no spaces)
25/// - Parser accepts a superset of tokens (aliases, case-insensitive)
26/// - `Other(s)` serializes to its canonical `code()` string (no escape prefix)
27/// - `Display` output matches the canonical code for known variants and the raw `s` for `Other(s)`
28/// - Serde round-trips preserve identity for canonical variants; unknown tokens normalize to `Other(UPPERCASE)`
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub enum AssetKind {
32    /// Common stock or equity-like instruments.
33    Equity,
34    /// Cryptocurrency assets.
35    Crypto,
36    /// Funds and ETFs.
37    Fund,
38    /// Market indexes.
39    Index,
40    /// Foreign exchange currency pairs.
41    Forex,
42    /// Bonds and fixed income.
43    Bond,
44    /// Commodities.
45    Commodity,
46    /// Option contracts.
47    Option,
48    /// Commodity futures.
49    Future,
50    /// Real Estate Investment Trusts.
51    REIT,
52    /// Warrants.
53    Warrant,
54    /// Convertible bonds/securities.
55    Convertible,
56    /// Non-fungible tokens.
57    NFT,
58    /// Perpetual futures contracts (no expiration date).
59    PerpetualFuture,
60    /// Leveraged tokens (e.g., 3x leveraged Bitcoin tokens).
61    LeveragedToken,
62    /// Liquidity provider tokens (`DeFi` protocol tokens).
63    LPToken,
64    /// Liquid staking tokens (e.g., stETH, rETH).
65    LST,
66    /// Real-world assets (tokenized physical assets).
67    RWA,
68    /// Provider-specific asset kind not modeled as a canonical variant.
69    Other(OtherAssetKind),
70}
71
72crate::string_enum_with_code!(
73    AssetKind, Other(OtherAssetKind),
74    "AssetKind",
75    type Error = DomainError;
76    invalid(input) => DomainError::InvalidAssetKindValue {
77        value: input.to_string(),
78    };
79    {
80        "EQUITY" => AssetKind::Equity,
81        "CRYPTO" => AssetKind::Crypto,
82        "FUND" => AssetKind::Fund,
83        "INDEX" => AssetKind::Index,
84        "FOREX" => AssetKind::Forex,
85        "BOND" => AssetKind::Bond,
86        "COMMODITY" => AssetKind::Commodity,
87        "OPTION" => AssetKind::Option,
88        "FUTURE" => AssetKind::Future,
89        "REIT" => AssetKind::REIT,
90        "WARRANT" => AssetKind::Warrant,
91        "CONVERTIBLE" => AssetKind::Convertible,
92        "NFT" => AssetKind::NFT,
93        "PERPETUAL_FUTURE" => AssetKind::PerpetualFuture,
94        "LEVERAGED_TOKEN" => AssetKind::LeveragedToken,
95        "LP_TOKEN" => AssetKind::LPToken,
96        "LST" => AssetKind::LST,
97        "RWA" => AssetKind::RWA,
98    },
99    {
100        "STOCK" => AssetKind::Equity,
101        "FX" => AssetKind::Forex,
102    }
103);
104
105crate::impl_display_via_code!(AssetKind);
106
107impl AssetKind {
108    /// Builds an unknown asset kind, rejecting tokens modeled by [`AssetKind`].
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if `input` is empty, cannot be canonicalized, or parses
113    /// to a modeled [`AssetKind`] variant.
114    pub fn other(input: &str) -> Result<Self, DomainError> {
115        OtherAssetKind::new(input).map(Self::Other)
116    }
117
118    /// Human-readable label for displaying this asset kind.
119    #[must_use]
120    pub fn full_name(&self) -> Cow<'static, str> {
121        match self {
122            Self::Equity => Cow::Borrowed("Equity"),
123            Self::Crypto => Cow::Borrowed("Crypto"),
124            Self::Fund => Cow::Borrowed("Fund"),
125            Self::Index => Cow::Borrowed("Index"),
126            Self::Forex => Cow::Borrowed("Forex"),
127            Self::Bond => Cow::Borrowed("Bond"),
128            Self::Commodity => Cow::Borrowed("Commodity"),
129            Self::Option => Cow::Borrowed("Option"),
130            Self::Future => Cow::Borrowed("Future"),
131            Self::REIT => Cow::Borrowed("REIT"),
132            Self::Warrant => Cow::Borrowed("Warrant"),
133            Self::Convertible => Cow::Borrowed("Convertible"),
134            Self::NFT => Cow::Borrowed("NFT"),
135            Self::PerpetualFuture => Cow::Borrowed("Perpetual Future"),
136            Self::LeveragedToken => Cow::Borrowed("Leveraged Token"),
137            Self::LPToken => Cow::Borrowed("LP Token"),
138            Self::LST => Cow::Borrowed("Liquid Staking Token"),
139            Self::RWA => Cow::Borrowed("Real-World Asset"),
140            Self::Other(code) => Cow::Owned(code.as_ref().to_string()),
141        }
142    }
143}
144
145/// Logical instrument identity for a security.
146#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[cfg_attr(feature = "dataframe", derive(df_derive_macros::ToDataFrame))]
148pub struct Instrument {
149    /// Canonical ticker symbol.
150    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
151    pub symbol: Symbol,
152    /// Optional trading venue context for disambiguation.
153    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
154    pub exchange: Option<Exchange>,
155    /// Optional global identifier (preferred).
156    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
157    pub figi: Option<Figi>,
158    /// Optional global identifier (fallback).
159    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
160    pub isin: Option<Isin>,
161    /// Asset class and behavior.
162    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
163    pub kind: AssetKind,
164}
165
166impl Instrument {
167    /// Construct an instrument from a validated symbol and asset kind.
168    #[must_use]
169    pub const fn new(symbol: Symbol, kind: AssetKind) -> Self {
170        Self {
171            symbol,
172            exchange: None,
173            figi: None,
174            isin: None,
175            kind,
176        }
177    }
178
179    /// Construct a new `Instrument` with just a symbol and kind.
180    ///
181    /// # Errors
182    /// Returns `DomainError::InvalidSymbol` if the provided symbol violates canonical invariants.
183    #[cfg_attr(
184        feature = "tracing",
185        tracing::instrument(level = "debug", skip(symbol), err)
186    )]
187    pub fn from_symbol(symbol: impl AsRef<str>, kind: AssetKind) -> Result<Self, DomainError> {
188        Ok(Self {
189            symbol: Symbol::new(symbol.as_ref())?,
190            exchange: None,
191            figi: None,
192            isin: None,
193            kind,
194        })
195    }
196
197    /// Construct a new `Instrument` with symbol, exchange, and kind.
198    ///
199    /// # Errors
200    /// Returns `DomainError::InvalidSymbol` if the provided symbol violates canonical invariants.
201    #[cfg_attr(
202        feature = "tracing",
203        tracing::instrument(level = "debug", skip(symbol), err)
204    )]
205    pub fn from_symbol_and_exchange(
206        symbol: impl AsRef<str>,
207        exchange: Exchange,
208        kind: AssetKind,
209    ) -> Result<Self, DomainError> {
210        Ok(Self {
211            symbol: Symbol::new(symbol.as_ref())?,
212            exchange: Some(exchange),
213            figi: None,
214            isin: None,
215            kind,
216        })
217    }
218
219    /// Construct a new `Instrument` for a security identified by a FIGI and symbol.
220    ///
221    /// # Errors
222    /// Returns `DomainError::InvalidFigi` if FIGI validation fails.
223    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))]
224    pub fn from_figi(figi: &str, symbol: Symbol, kind: AssetKind) -> Result<Self, DomainError> {
225        Ok(Self {
226            symbol,
227            exchange: None,
228            figi: Some(Figi::new(figi)?),
229            isin: None,
230            kind,
231        })
232    }
233
234    /// Returns a stable, namespaced identity key for this instrument.
235    ///
236    /// The key includes the asset kind and identifier source so instruments that
237    /// share a raw symbol (for example, an equity and a crypto asset both named
238    /// `BTC`) do not collapse to the same key. Symbol payloads include their
239    /// byte length to avoid delimiter collisions with symbols that contain
240    /// characters such as `@`.
241    ///
242    /// This is a synthetic composite key and is always returned as an owned
243    /// [`String`]. Use [`Self::display_key`] when a compact display identifier
244    /// is needed.
245    #[must_use]
246    pub fn unique_key(&self) -> String {
247        let kind = self.kind.code();
248
249        if let Some(figi) = &self.figi {
250            return format!("{kind}|FIGI|{}", figi.as_ref());
251        }
252        if let Some(isin) = &self.isin {
253            return format!("{kind}|ISIN|{}", isin.as_ref());
254        }
255
256        let symbol = self.symbol.as_str();
257        let symbol_len = symbol.len();
258
259        if let Some(exchange) = &self.exchange {
260            return format!(
261                "{kind}|SYMBOL|{symbol_len}:{symbol}|EXCHANGE|{}",
262                exchange.code()
263            );
264        }
265
266        format!("{kind}|SYMBOL|{symbol_len}:{symbol}")
267    }
268
269    /// Returns the best available compact identifier for display
270    /// (FIGI > ISIN > SYMBOL@EXCHANGE > SYMBOL).
271    #[must_use]
272    pub fn display_key(&self) -> Cow<'_, str> {
273        if let Some(figi) = &self.figi {
274            return Cow::Borrowed(figi.as_ref());
275        }
276        if let Some(isin) = &self.isin {
277            return Cow::Borrowed(isin.as_ref());
278        }
279        if let Some(exchange) = &self.exchange {
280            return Cow::Owned(format!("{}@{}", self.symbol, exchange.code()));
281        }
282        Cow::Borrowed(self.symbol.as_str())
283    }
284}
285
286impl std::fmt::Display for Instrument {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        write!(f, "{}", self.display_key())
289    }
290}