Skip to main content

finance_solution/derivatives/
types.rs

1//! Shared option types and BSM parameter packs.
2//!
3//! ## Trading vs engineering
4//!
5//! - **Trading:** [`OptionType`] is the side of the contract; [`BsmParams`] is the
6//!   *market + contract* snapshot you reprice (spot from the tape, strike/expiry fixed,
7//!   vol from your surface or from mid via IV).
8//! - **Engineering:** keep [`BsmParams`] as a plain `Copy` struct so configs and message
9//!   handlers stay allocation-free; validate once with [`ValidatedBsm::new`] or store a
10//!   [`crate::derivatives::BsmState`] per contract key.
11
12use crate::util::error::{require_finite, FinanceError, FinanceResult};
13use std::fmt;
14
15/// Call or put (European exercise in this module).
16///
17/// **Trading:** long call = bullish / long convexity; long put = bearish / hedge inventory.  
18/// **Engineering:** store next to strike/expiry in your contract id; pass into every price/greeks call.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20pub enum OptionType {
21    Call,
22    Put,
23}
24
25impl OptionType {
26    pub fn is_call(self) -> bool {
27        matches!(self, OptionType::Call)
28    }
29
30    pub fn is_put(self) -> bool {
31        matches!(self, OptionType::Put)
32    }
33}
34
35impl fmt::Display for OptionType {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            OptionType::Call => write!(f, "Call"),
39            OptionType::Put => write!(f, "Put"),
40        }
41    }
42}
43
44/// Black–Scholes–Merton inputs (European, continuous dividend yield `q`).
45///
46/// # Field meanings (trading)
47///
48/// | Field | Trading meaning |
49/// |-------|-----------------|
50/// | `spot` | Underlier mid / last (or your mark) |
51/// | `strike` | Option strike |
52/// | `time_years` | Fraction of year to expiry (day-count is **your** policy) |
53/// | `rate` | Continuous funding / risk-free input to the model |
54/// | `dividend_yield` | Continuous yield `q` (dividends, or borrow approximation) |
55/// | `vol` | Annualized σ — model input or **implied** from market |
56///
57/// # Examples
58/// ```
59/// use finance_solution::derivatives::BsmParams;
60/// let p = BsmParams::atm_one_year(100.0, 0.05, 0.20);
61/// assert_eq!(p.spot, p.strike);
62/// assert_eq!(p.time_years, 1.0);
63/// ```
64///
65/// Convert calendar days with an explicit day-count (engine responsibility):
66/// ```
67/// use finance_solution::derivatives::BsmParams;
68/// let days = 21.0;
69/// let p = BsmParams {
70///     spot: 50.0,
71///     strike: 55.0,
72///     time_years: days / 365.25,
73///     rate: 0.04,
74///     dividend_yield: 0.01,
75///     vol: 0.30,
76/// };
77/// assert!(p.time_years < 0.1);
78/// ```
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub struct BsmParams {
81    pub spot: f64,
82    pub strike: f64,
83    /// Time to expiry in **years**.
84    pub time_years: f64,
85    /// Continuous risk-free rate.
86    pub rate: f64,
87    /// Continuous dividend yield (equity) or foreign-rate analog in FX-style setups.
88    pub dividend_yield: f64,
89    /// Annualized volatility (absolute).
90    pub vol: f64,
91}
92
93impl BsmParams {
94    /// ATM, one year, zero dividend yield — convenient textbook / smoke fixture.
95    pub const fn atm_one_year(spot: f64, rate: f64, vol: f64) -> Self {
96        Self {
97            spot,
98            strike: spot,
99            time_years: 1.0,
100            rate,
101            dividend_yield: 0.0,
102            vol,
103        }
104    }
105
106    /// Build from calendar days using **Actual/365.25** style (`days / 365.25`).
107    ///
108    /// Desks differ (business days, 365, 252). Prefer this helper only when that
109    /// convention is intentional; otherwise set `time_years` yourself.
110    pub fn with_days_365_25(
111        spot: f64,
112        strike: f64,
113        days: f64,
114        rate: f64,
115        dividend_yield: f64,
116        vol: f64,
117    ) -> Self {
118        Self {
119            spot,
120            strike,
121            time_years: days / 365.25,
122            rate,
123            dividend_yield,
124            vol,
125        }
126    }
127}
128
129/// Validated BSM pack (strictly positive S,K; non-negative T,σ; finite rates).
130///
131/// **Engineering:** construct once per “clean” snapshot; use on hot paths so validation
132/// is not mixed into every formula line. For mutable live fields prefer
133/// [`crate::derivatives::BsmState`].
134#[derive(Clone, Copy, Debug, PartialEq)]
135pub struct ValidatedBsm {
136    params: BsmParams,
137}
138
139impl ValidatedBsm {
140    /// Fallible constructor — Result-only style (`new`, not `try_new`).
141    ///
142    /// # Errors
143    /// Non-finite inputs; non-positive spot/strike; negative time or vol.
144    pub fn new(params: BsmParams) -> FinanceResult<Self> {
145        validate_bsm_params(params)?;
146        Ok(Self { params })
147    }
148
149    pub fn params(self) -> BsmParams {
150        self.params
151    }
152
153    /// Model price for this validated snapshot.
154    pub fn price(self, option_type: OptionType) -> FinanceResult<f64> {
155        crate::derivatives::black_scholes::bsm_price(self.params, option_type)
156    }
157
158    /// Model Greeks for this validated snapshot.
159    pub fn greeks(
160        self,
161        option_type: OptionType,
162    ) -> FinanceResult<crate::derivatives::black_scholes::BsmGreeks> {
163        crate::derivatives::black_scholes::bsm_greeks(self.params, option_type)
164    }
165
166    /// Cross Greeks (vanna, volga, charm).
167    pub fn cross_greeks(
168        self,
169        option_type: OptionType,
170    ) -> FinanceResult<crate::derivatives::black_scholes::BsmCrossGreeks> {
171        crate::derivatives::black_scholes::bsm_cross_greeks(self.params, option_type)
172    }
173}
174
175pub(crate) fn validate_bsm_params(p: BsmParams) -> FinanceResult<()> {
176    require_finite("spot", p.spot)?;
177    require_finite("strike", p.strike)?;
178    require_finite("time_years", p.time_years)?;
179    require_finite("rate", p.rate)?;
180    require_finite("dividend_yield", p.dividend_yield)?;
181    require_finite("vol", p.vol)?;
182    if p.spot <= 0.0 {
183        return Err(FinanceError::InvalidCashflow {
184            message: "spot must be strictly positive",
185        });
186    }
187    if p.strike <= 0.0 {
188        return Err(FinanceError::InvalidCashflow {
189            message: "strike must be strictly positive",
190        });
191    }
192    if p.time_years < 0.0 {
193        return Err(FinanceError::Unsolvable {
194            message: "time_years must be non-negative",
195        });
196    }
197    if p.vol < 0.0 {
198        return Err(FinanceError::Unsolvable {
199            message: "vol must be non-negative",
200        });
201    }
202    Ok(())
203}
204
205/// Intrinsic value (European exercise value at this spot).
206///
207/// **Trading:** the “dead” part of the premium if exercised now (European still
208/// cannot early-exercise, but intrinsic is the mental floor).  
209/// **Engineering:** useful for UI columns and for rejecting IV solves below floor at T=0.
210pub fn intrinsic(spot: f64, strike: f64, option_type: OptionType) -> FinanceResult<f64> {
211    require_finite("spot", spot)?;
212    require_finite("strike", strike)?;
213    if spot <= 0.0 || strike <= 0.0 {
214        return Err(FinanceError::InvalidCashflow {
215            message: "spot and strike must be strictly positive",
216        });
217    }
218    Ok(match option_type {
219        OptionType::Call => (spot - strike).max(0.0),
220        OptionType::Put => (strike - spot).max(0.0),
221    })
222}
223
224/// Time value = premium − intrinsic (floored at 0 for numerical noise).
225///
226/// **Trading:** what you pay for optionality / vol.  
227/// **Engineering:** `premium` may be model or market mid — you choose.
228pub fn time_value(
229    premium: f64,
230    spot: f64,
231    strike: f64,
232    option_type: OptionType,
233) -> FinanceResult<f64> {
234    require_finite("premium", premium)?;
235    let i = intrinsic(spot, strike, option_type)?;
236    Ok((premium - i).max(0.0))
237}
238
239/// Forward moneyness `S e^{(r-q)T} / K`.
240///
241/// **Trading:** >1 call is ITM on a forward basis; skew is often quoted vs this.  
242/// **Engineering:** pure function of [`BsmParams`]; no vol dependence.
243pub fn forward_moneyness(p: BsmParams) -> FinanceResult<f64> {
244    validate_bsm_params(p)?;
245    let f = p.spot * ((p.rate - p.dividend_yield) * p.time_years).exp();
246    Ok(f / p.strike)
247}
248
249/// Spot moneyness `S / K` (not forward-adjusted).
250pub fn spot_moneyness(spot: f64, strike: f64) -> FinanceResult<f64> {
251    require_finite("spot", spot)?;
252    require_finite("strike", strike)?;
253    if spot <= 0.0 || strike <= 0.0 {
254        return Err(FinanceError::InvalidCashflow {
255            message: "spot and strike must be strictly positive",
256        });
257    }
258    Ok(spot / strike)
259}