architect_api/symbology/
product.rs

1//! A product is a thing you can have a position in.
2
3use super::*;
4use anyhow::{anyhow, bail, Result};
5use chrono::{DateTime, Datelike, NaiveDate, Utc};
6use derive_more::{AsRef, Deref, Display, From};
7use rust_decimal::Decimal;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use serde_with::{DeserializeFromStr, SerializeDisplay};
11use std::str::FromStr;
12use strum_macros::{EnumString, IntoStaticStr};
13
14#[derive(
15    Debug,
16    Display,
17    From,
18    AsRef,
19    Deref,
20    Clone,
21    PartialEq,
22    Eq,
23    PartialOrd,
24    Ord,
25    Hash,
26    Deserialize,
27    Serialize,
28    JsonSchema,
29)]
30#[as_ref(forward)]
31#[serde(transparent)]
32#[cfg_attr(feature = "graphql", derive(juniper::GraphQLScalar))]
33#[cfg_attr(feature = "graphql", graphql(transparent))]
34#[cfg_attr(feature = "postgres", derive(postgres_types::ToSql))]
35#[cfg_attr(feature = "postgres", postgres(transparent))]
36pub struct Product(pub(crate) String);
37
38impl Product {
39    fn new(
40        symbol: &str,
41        venue_discriminant: Option<&str>,
42        product_kind: &str,
43    ) -> Result<Self> {
44        if symbol.contains('/')
45            || venue_discriminant.is_some_and(|v| v.contains('/'))
46            || product_kind.contains('/')
47        {
48            bail!("product symbol cannot contain the forward slash character '/'");
49        }
50        let inner = match venue_discriminant {
51            Some(venue_discriminant) => {
52                if venue_discriminant.is_empty() {
53                    bail!("venue discriminant cannot be empty if provided");
54                }
55                format!(
56                    "{} {} {}",
57                    symbol,
58                    venue_discriminant.to_uppercase(),
59                    product_kind
60                )
61            }
62            None => format!("{} {}", symbol, product_kind),
63        };
64        Ok(Self(inner))
65    }
66
67    pub fn fiat(symbol: &str) -> Result<Self> {
68        if symbol.contains(char::is_whitespace) {
69            bail!("fiat product symbol cannot contain whitespace");
70        }
71        if symbol.contains('/') {
72            bail!("fiat product symbol cannot contain the forward slash character '/'");
73        }
74        Ok(Self(symbol.to_string()))
75    }
76
77    pub fn commodity(symbol: &str) -> Result<Self> {
78        Self::new(symbol, None, "Commodity")
79    }
80
81    pub fn crypto(symbol: &str) -> Result<Self> {
82        Self::new(symbol, None, "Crypto")
83    }
84
85    pub fn index(symbol: &str, venue_discriminant: Option<&str>) -> Result<Self> {
86        Self::new(symbol, venue_discriminant, "Index")
87    }
88
89    pub fn equity(symbol: &str, country: &str) -> Result<Self> {
90        let symbol = format!("{symbol} {country}");
91        Self::new(&symbol, None, "Equity")
92    }
93
94    pub fn future(
95        symbol: &str,
96        expiration: NaiveDate,
97        venue_discriminant: Option<&str>,
98    ) -> Result<Self> {
99        let symbol = format!("{symbol} {}", expiration.format("%Y%m%d"));
100        Self::new(&symbol, venue_discriminant, "Future")
101    }
102
103    pub fn futures_spread<'a>(
104        legs: impl IntoIterator<Item = &'a str>,
105        ratios: Option<impl IntoIterator<Item = Decimal>>,
106        expiration: NaiveDate,
107        venue_discriminant: Option<&str>,
108    ) -> Result<Self> {
109        let legs_str = legs.into_iter().collect::<Vec<_>>().join("-");
110        let expiration_str = expiration.format("%Y%m%d");
111        let symbol = if let Some(ratios) = ratios {
112            format!(
113                "{legs_str} {} {}",
114                ratios.into_iter().map(|k| k.to_string()).collect::<Vec<_>>().join(":"),
115                expiration_str
116            )
117        } else {
118            format!("{legs_str} {expiration_str}")
119        };
120        Self::new(&symbol, venue_discriminant, "Futures Spread")
121    }
122
123    pub fn perpetual(symbol: &str, venue_discriminant: Option<&str>) -> Result<Self> {
124        Self::new(symbol, venue_discriminant, "Perpetual")
125    }
126
127    /// E.g. "AAPL  241227C00300000 Option" (OSI format)
128    pub fn option(
129        stem: &str,
130        expiration: NaiveDate,
131        strike: Decimal,
132        put_or_call: PutOrCall,
133        venue_discriminant: Option<&str>,
134    ) -> Result<Self> {
135        let strike_str = strike.to_string();
136        let (dollar_part, decimal_part) =
137            strike_str.split_once('.').unwrap_or((&strike_str, "000"));
138        let put_or_call_char = match put_or_call {
139            PutOrCall::Put => "P",
140            PutOrCall::Call => "C",
141        };
142        let osi_symbol = format!(
143            "{:<6}{:02}{:02}{:02}{}{:0>5}{:0<3}",
144            stem,
145            expiration.year() % 100,
146            expiration.month(),
147            expiration.day(),
148            put_or_call_char,
149            dollar_part,
150            &decimal_part[..decimal_part.len().min(3)]
151        );
152        Self::new(&osi_symbol, venue_discriminant, "Option")
153    }
154
155    // pub fn is_series(&self) -> bool {
156    //     self.0.ends_with("Option") || self.0.ends_with("Event Contract")
157    // }
158
159    /// For futures products, return the expiration date indicated by the
160    /// symbol itself, e.g. by parsing rather than looking up the product
161    /// information from a catalog.
162    pub fn nominative_expiration(&self) -> Option<NaiveDate> {
163        if self.ends_with(" Future") {
164            let date_part = self.split(' ').nth(1)?;
165            NaiveDate::parse_from_str(date_part, "%Y%m%d").ok()
166        } else {
167            None
168        }
169    }
170}
171
172impl std::borrow::Borrow<str> for Product {
173    fn borrow(&self) -> &str {
174        &self.0
175    }
176}
177
178impl FromStr for Product {
179    type Err = anyhow::Error;
180
181    fn from_str(s: &str) -> Result<Self> {
182        if s.contains('/') {
183            bail!("product symbol cannot contain the forward slash character '/'");
184        }
185        Ok(Self(s.to_string()))
186    }
187}
188
189#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
190pub struct ProductInfo {
191    pub product_type: ProductType,
192    pub primary_venue: Option<String>,
193    pub price_display_format: Option<PriceDisplayFormat>,
194}
195
196impl ProductInfo {
197    pub fn series(&self) -> Option<&str> {
198        match &self.product_type {
199            ProductType::Future { series, .. } => series.as_deref(),
200            _ => None,
201        }
202    }
203
204    pub fn multiplier(&self) -> Option<Decimal> {
205        match &self.product_type {
206            ProductType::Crypto
207            | ProductType::Fiat
208            | ProductType::Equity { .. }
209            | ProductType::Index
210            | ProductType::Commodity
211            | ProductType::Unknown
212            | ProductType::Option { .. }
213            | ProductType::EventContract { .. }
214            | ProductType::FutureSpread { .. } => None,
215            ProductType::Perpetual { multiplier, .. }
216            | ProductType::Future { multiplier, .. } => Some(*multiplier),
217        }
218    }
219
220    pub fn underlying(&self) -> Option<&Product> {
221        match &self.product_type {
222            ProductType::Crypto
223            | ProductType::Fiat
224            | ProductType::Equity { .. }
225            | ProductType::Index
226            | ProductType::Commodity
227            | ProductType::Unknown
228            | ProductType::Option { .. }
229            | ProductType::EventContract { .. }
230            | ProductType::FutureSpread { .. } => None,
231            ProductType::Perpetual { underlying, .. }
232            | ProductType::Future { underlying, .. } => underlying.as_ref(),
233        }
234    }
235
236    pub fn expiration(&self) -> Option<DateTime<Utc>> {
237        match &self.product_type {
238            ProductType::Crypto
239            | ProductType::Fiat
240            | ProductType::Equity { .. }
241            | ProductType::Index
242            | ProductType::Commodity
243            | ProductType::Unknown
244            | ProductType::Perpetual { .. }
245            | ProductType::FutureSpread { .. } => None,
246            ProductType::Option {
247                instance: OptionsSeriesInstance { expiration, .. },
248                ..
249            } => Some(*expiration),
250            ProductType::EventContract { instance, .. } => instance.expiration(),
251            ProductType::Future { expiration, .. } => Some(*expiration),
252        }
253    }
254
255    pub fn is_expired(&self, cutoff: DateTime<Utc>) -> bool {
256        if let Some(expiration) = self.expiration() {
257            expiration <= cutoff
258        } else {
259            false
260        }
261    }
262
263    pub fn derivative_kind(&self) -> Option<DerivativeKind> {
264        match &self.product_type {
265            ProductType::Future { derivative_kind, .. }
266            | ProductType::Perpetual { derivative_kind, .. } => Some(*derivative_kind),
267            _ => None,
268        }
269    }
270
271    pub fn first_notice_date(&self) -> Option<NaiveDate> {
272        match &self.product_type {
273            ProductType::Future { first_notice_date, .. } => *first_notice_date,
274            _ => None,
275        }
276    }
277
278    pub fn easy_to_borrow(&self) -> Option<bool> {
279        match &self.product_type {
280            ProductType::Equity { easy_to_borrow, .. } => *easy_to_borrow,
281            _ => None,
282        }
283    }
284}
285
286#[derive(Debug, Clone, IntoStaticStr, Deserialize, Serialize, JsonSchema)]
287#[serde(tag = "product_type")]
288pub enum ProductType {
289    #[schemars(title = "Fiat")]
290    Fiat,
291    #[schemars(title = "Commodity")]
292    Commodity,
293    #[schemars(title = "Crypto")]
294    Crypto,
295    #[schemars(title = "Equity")]
296    Equity { easy_to_borrow: Option<bool> },
297    #[schemars(title = "Index")]
298    Index,
299    #[schemars(title = "Future")]
300    Future {
301        series: Option<String>,
302        underlying: Option<Product>,
303        multiplier: Decimal,
304        expiration: DateTime<Utc>,
305        derivative_kind: DerivativeKind,
306        #[serde(default)]
307        first_notice_date: Option<NaiveDate>,
308    },
309    #[schemars(title = "FutureSpread")]
310    FutureSpread { legs: Vec<SpreadLeg> },
311    #[schemars(title = "Perpetual")]
312    Perpetual {
313        underlying: Option<Product>,
314        multiplier: Decimal,
315        derivative_kind: DerivativeKind,
316    },
317    #[schemars(title = "Option")]
318    Option { series: OptionsSeries, instance: OptionsSeriesInstance },
319    #[schemars(title = "EventContract")]
320    EventContract { series: EventContractSeries, instance: EventContractSeriesInstance },
321    #[serde(other)]
322    #[schemars(title = "Unknown")]
323    Unknown,
324}
325
326#[derive(
327    Debug, Copy, Clone, EnumString, IntoStaticStr, Serialize, Deserialize, JsonSchema,
328)]
329#[strum(ascii_case_insensitive)]
330pub enum DerivativeKind {
331    /// Normal futures
332    Linear,
333    /// Futures settled in the base currency
334    Inverse,
335    /// Quote currency different from settle currency
336    Quanto,
337}
338
339#[cfg(feature = "postgres")]
340crate::to_sql_str!(DerivativeKind);
341
342#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
343#[cfg_attr(feature = "graphql", derive(juniper::GraphQLObject))]
344pub struct SpreadLeg {
345    pub product: Product,
346    /// Some spreads have different ratios for their legs, like buy 1 A, sell 2 B, buy 1 C;
347    /// We would represent that with quantities in the legs: 1, -2, 1
348    pub quantity: Decimal,
349}
350
351impl std::fmt::Display for SpreadLeg {
352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353        if self.quantity > Decimal::ZERO {
354            write!(f, "+{} {}", self.quantity, self.product)
355        } else {
356            write!(f, "{} {}", self.quantity, self.product)
357        }
358    }
359}
360
361impl std::str::FromStr for SpreadLeg {
362    type Err = anyhow::Error;
363
364    fn from_str(s: &str) -> Result<Self> {
365        let (quantity, product) =
366            s.split_once(' ').ok_or_else(|| anyhow!("invalid leg format"))?;
367        Ok(Self { product: product.parse()?, quantity: quantity.parse()? })
368    }
369}
370
371#[derive(
372    Debug,
373    Clone,
374    Copy,
375    PartialEq,
376    Eq,
377    PartialOrd,
378    Ord,
379    Hash,
380    EnumString,
381    IntoStaticStr,
382    Deserialize,
383    Serialize,
384    JsonSchema,
385)]
386#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
387#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
388pub enum AliasKind {
389    CmeGlobex,
390    Cfe,
391}
392
393#[cfg(feature = "postgres")]
394crate::to_sql_str!(AliasKind);
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
397pub enum PriceDisplayFormat {
398    CmeFractional {
399        main_fraction: i32,
400        sub_fraction: i32,  // 0 if no subfraction
401        tick_decimals: i32, // number of decimals to the right of the tick mark
402    },
403}
404
405impl PriceDisplayFormat {
406    pub fn main_fraction(&self) -> Option<i32> {
407        match self {
408            Self::CmeFractional { main_fraction, .. } => Some(*main_fraction),
409        }
410    }
411
412    pub fn tick_decimals(&self) -> Option<i32> {
413        match self {
414            Self::CmeFractional { tick_decimals, .. } => Some(*tick_decimals),
415        }
416    }
417}
418
419crate::json_schema_is_string!(PriceDisplayFormat);
420
421impl std::fmt::Display for PriceDisplayFormat {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        match self {
424            Self::CmeFractional { main_fraction, sub_fraction, tick_decimals } => {
425                write!(
426                    f,
427                    "CME_FRACTIONAL({main_fraction},{sub_fraction},{tick_decimals})"
428                )
429            }
430        }
431    }
432}
433
434impl std::str::FromStr for PriceDisplayFormat {
435    type Err = anyhow::Error;
436
437    fn from_str(s: &str) -> Result<Self> {
438        if s.starts_with("CME_FRACTIONAL(") {
439            let s = s.trim_end_matches(')');
440            let parts: Vec<&str> = s["CME_FRACTIONAL(".len()..].split(',').collect();
441            if parts.len() != 3 {
442                return Err(anyhow!("invalid CME_FRACTIONAL format"));
443            }
444            let main_fraction = parts[0].parse()?;
445            let sub_fraction = parts[1].parse()?;
446            let tick_decimals = parts[2].parse()?;
447            Ok(Self::CmeFractional { main_fraction, sub_fraction, tick_decimals })
448        } else {
449            Err(anyhow!("invalid price display format"))
450        }
451    }
452}
453
454#[cfg(feature = "postgres")]
455crate::to_sql_display!(PriceDisplayFormat);