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}