Skip to main content

qs_instruments/
spec.rs

1use std::collections::BTreeSet;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    AssetId, DecimalGrid, EconomicsModelId, InstrumentAlias, InstrumentId, PositiveDecimal,
8    SpecRevision,
9};
10
11/// Broad asset classification separate from an asset identifier.
12#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum AssetKind {
15    Fiat,
16    Stablecoin,
17    Crypto,
18    Commodity,
19    Equity,
20    Index,
21    Synthetic,
22    Other,
23}
24
25/// Metadata for one economic asset.
26#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct AssetSpec {
29    pub asset: AssetId,
30    pub kind: AssetKind,
31    pub display_code: String,
32    pub storage_scale: Option<u8>,
33}
34
35/// Assets participating in an instrument's economic contract.
36#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct InstrumentAssets {
39    pub base: Option<AssetId>,
40    pub quote: Option<AssetId>,
41    pub settlement: AssetId,
42    #[serde(default)]
43    pub fee_assets: BTreeSet<AssetId>,
44}
45
46/// Listing availability at an effective time.
47#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum ListingStatus {
50    PreTrading,
51    Trading,
52    ReduceOnly,
53    Halted,
54    Delisted,
55}
56
57/// Exact price-grid metadata.
58#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct PriceRules {
61    pub grid: DecimalGrid,
62    pub display_scale: u8,
63}
64
65/// Exact quantity-grid metadata.
66#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct QuantityRules {
69    pub grid: DecimalGrid,
70    pub minimum: PositiveDecimal,
71    pub maximum: Option<PositiveDecimal>,
72    pub storage_scale: u8,
73}
74
75/// Optional bounds for a model-calculated notional value.
76#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct NotionalRules {
79    pub asset: AssetId,
80    pub minimum: Option<PositiveDecimal>,
81    pub maximum: Option<PositiveDecimal>,
82}
83
84/// Unit represented by an instrument quantity.
85#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum QuantityUnit {
88    StandardLot,
89    BaseAsset,
90    Contract,
91    QuoteAsset,
92}
93
94/// Declarative economics requirements for one instrument revision.
95#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct InstrumentEconomics {
98    pub pnl_model: EconomicsModelId,
99    pub quantity_unit: QuantityUnit,
100    pub contract_multiplier: PositiveDecimal,
101    pub settlement_asset: AssetId,
102    pub fee_model: Option<EconomicsModelId>,
103    pub funding_model: Option<EconomicsModelId>,
104    pub margin_model: Option<EconomicsModelId>,
105}
106
107/// Half-open UTC interval `[valid_from, valid_until)`.
108#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct EffectiveInterval {
111    pub valid_from: DateTime<Utc>,
112    pub valid_until: Option<DateTime<Utc>>,
113}
114
115impl EffectiveInterval {
116    pub fn new(
117        valid_from: DateTime<Utc>,
118        valid_until: Option<DateTime<Utc>>,
119    ) -> Result<Self, EffectiveIntervalError> {
120        if valid_until.is_some_and(|until| until <= valid_from) {
121            return Err(EffectiveIntervalError::InvalidBounds);
122        }
123        Ok(Self {
124            valid_from,
125            valid_until,
126        })
127    }
128
129    pub fn contains(&self, at: DateTime<Utc>) -> bool {
130        at >= self.valid_from && self.valid_until.is_none_or(|until| at < until)
131    }
132
133    pub fn overlaps(&self, other: &Self) -> bool {
134        self.valid_until
135            .is_none_or(|until| other.valid_from < until)
136            && other
137                .valid_until
138                .is_none_or(|until| self.valid_from < until)
139    }
140}
141
142/// One effective-dated instrument specification.
143#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct InstrumentSpec {
146    pub revision: SpecRevision,
147    pub instrument: InstrumentId,
148    pub effective: EffectiveInterval,
149    pub status: ListingStatus,
150    pub assets: InstrumentAssets,
151    pub price: PriceRules,
152    pub quantity: QuantityRules,
153    pub notional: Option<NotionalRules>,
154    pub economics: InstrumentEconomics,
155    #[serde(default)]
156    pub aliases: BTreeSet<InstrumentAlias>,
157}
158
159impl AssetSpec {
160    pub fn validate(&self) -> Result<(), SpecValidationError> {
161        if self.display_code.is_empty() || !self.display_code.is_ascii() {
162            return Err(SpecValidationError::InvalidAssetDisplayCode {
163                asset: self.asset.clone(),
164            });
165        }
166        if self
167            .storage_scale
168            .is_some_and(|scale| scale > crate::MAX_DECIMAL_SCALE)
169        {
170            return Err(SpecValidationError::ScaleTooLarge);
171        }
172        Ok(())
173    }
174}
175
176impl InstrumentSpec {
177    pub fn validate(&self) -> Result<(), SpecValidationError> {
178        if self
179            .effective
180            .valid_until
181            .is_some_and(|until| until <= self.effective.valid_from)
182        {
183            return Err(SpecValidationError::InvalidEffectiveInterval);
184        }
185        if self.price.display_scale > crate::MAX_DECIMAL_SCALE
186            || self.quantity.storage_scale > crate::MAX_DECIMAL_SCALE
187        {
188            return Err(SpecValidationError::ScaleTooLarge);
189        }
190        if !self.quantity.grid.contains(self.quantity.minimum.get())? {
191            return Err(SpecValidationError::QuantityMinimumOffGrid);
192        }
193        if let Some(maximum) = self.quantity.maximum {
194            if maximum < self.quantity.minimum {
195                return Err(SpecValidationError::InvalidQuantityBounds);
196            }
197            if !self.quantity.grid.contains(maximum.get())? {
198                return Err(SpecValidationError::QuantityMaximumOffGrid);
199            }
200        }
201        if let Some(notional) = &self.notional {
202            validate_optional_bounds(notional.minimum, notional.maximum)?;
203        }
204        if self.assets.settlement != self.economics.settlement_asset {
205            return Err(SpecValidationError::SettlementAssetMismatch);
206        }
207        Ok(())
208    }
209}
210
211fn validate_optional_bounds(
212    minimum: Option<PositiveDecimal>,
213    maximum: Option<PositiveDecimal>,
214) -> Result<(), SpecValidationError> {
215    if let (Some(minimum), Some(maximum)) = (minimum, maximum)
216        && maximum < minimum
217    {
218        return Err(SpecValidationError::InvalidNotionalBounds);
219    }
220    Ok(())
221}
222
223/// Effective-interval construction failures.
224#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
225pub enum EffectiveIntervalError {
226    #[error("effective interval valid_until must be after valid_from")]
227    InvalidBounds,
228}
229
230/// Instrument specification invariant failures.
231#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
232pub enum SpecValidationError {
233    #[error("asset {asset} has an invalid display code")]
234    InvalidAssetDisplayCode { asset: AssetId },
235    #[error("effective interval valid_until must be after valid_from")]
236    InvalidEffectiveInterval,
237    #[error("declared scale exceeds the supported maximum")]
238    ScaleTooLarge,
239    #[error("quantity minimum is outside the declared grid")]
240    QuantityMinimumOffGrid,
241    #[error("quantity maximum is outside the declared grid")]
242    QuantityMaximumOffGrid,
243    #[error("quantity maximum is below quantity minimum")]
244    InvalidQuantityBounds,
245    #[error("notional maximum is below notional minimum")]
246    InvalidNotionalBounds,
247    #[error("instrument and economics settlement assets differ")]
248    SettlementAssetMismatch,
249    #[error(transparent)]
250    Grid(#[from] crate::GridError),
251    #[error(transparent)]
252    Decimal(#[from] crate::DecimalError),
253}