Skip to main content

fin_primitives/options/
mod.rs

1//! Black-Scholes options pricing engine with Greeks and implied volatility solver.
2//!
3//! ## Responsibility
4//! Black-Scholes European option pricing, Greeks (delta, gamma, theta, vega, rho,
5//! vanna, volga), implied volatility solving, and volatility surface interpolation.
6//!
7//! ## Sub-modules
8//! - [`greeks`] — BSM Greeks suite with Brent's-method IV solver (pure f64 API).
9//! - [`surface`] — Volatility surface with bilinear interpolation.
10//!
11//! ## Design
12//! - All public API uses `rust_decimal::Decimal` for inputs/outputs in the legacy engine.
13//! - The new `greeks` and `surface` modules use `f64` for ergonomics.
14//! - Transcendental math (exp, ln, sqrt, erf) is done in `f64` internally.
15//! - Every fallible operation returns `Result<_, FinError>`; no panics on edge inputs.
16//!
17//! ## NOT Responsible For
18//! - American-style options (European only)
19//! - Discrete dividend adjustments
20
21/// BSM closed-form Greeks suite and implied-volatility solver.
22pub mod greeks;
23
24/// Volatility surface with bilinear interpolation over (strike, expiry).
25pub mod surface;
26
27pub use greeks::{
28    GreekError, Greeks, OptionParams, OptionType, bsm_greeks, bsm_price, implied_volatility,
29};
30pub use surface::{VolPoint, VolSmile, VolSurface};
31
32use crate::error::FinError;
33use rust_decimal::prelude::ToPrimitive;
34use rust_decimal::Decimal;
35
36// ─── internal f64 helpers ────────────────────────────────────────────────────
37
38/// Standard normal PDF.
39#[inline]
40fn phi(x: f64) -> f64 {
41    crate::normal::pdf(x)
42}
43
44/// Standard normal CDF ([`crate::normal::cdf`]).
45#[inline]
46fn big_phi(x: f64) -> f64 {
47    crate::normal::cdf(x)
48}
49
50fn to_f64(d: Decimal) -> Result<f64, FinError> {
51    d.to_f64().ok_or(FinError::ArithmeticOverflow)
52}
53
54fn from_f64(f: f64) -> Result<Decimal, FinError> {
55    if !f.is_finite() {
56        return Err(FinError::ArithmeticOverflow);
57    }
58    Decimal::try_from(f).map_err(|_| FinError::ArithmeticOverflow)
59}
60
61// ─── public types ─────────────────────────────────────────────────────────────
62
63/// Whether the option grants the right to buy (Call) or sell (Put).
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub enum OptionKind {
67    /// Right to buy the underlying at the strike.
68    Call,
69    /// Right to sell the underlying at the strike.
70    Put,
71}
72
73/// All inputs required to price a European option under Black-Scholes.
74#[derive(Debug, Clone, Copy)]
75#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
76pub struct OptionSpec {
77    /// Option type: call or put.
78    pub kind: OptionKind,
79    /// Current underlying price (S). Must be positive.
80    pub spot: Decimal,
81    /// Strike price (K). Must be positive.
82    pub strike: Decimal,
83    /// Time to expiry in years (T). Must be positive.
84    pub time_to_expiry: Decimal,
85    /// Annualised risk-free rate (r). May be negative.
86    pub risk_free_rate: Decimal,
87    /// Annualised implied/historical volatility (σ). Must be positive.
88    pub volatility: Decimal,
89}
90
91/// Fair value and all first/second-order Greeks for a European option.
92#[derive(Debug, Clone, Copy)]
93#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
94pub struct OptionGreeks {
95    /// Theoretical fair value (premium).
96    pub price: Decimal,
97    /// Delta: ∂V/∂S — sensitivity of price to spot moves.
98    pub delta: Decimal,
99    /// Gamma: ∂²V/∂S² — rate of change of delta per unit spot move.
100    pub gamma: Decimal,
101    /// Theta: ∂V/∂t (per calendar day) — time decay.
102    pub theta: Decimal,
103    /// Vega: ∂V/∂σ (per 1% move in vol) — volatility sensitivity.
104    pub vega: Decimal,
105    /// Rho: ∂V/∂r (per 1% move in rate) — rate sensitivity.
106    pub rho: Decimal,
107}
108
109// ─── core engine ──────────────────────────────────────────────────────────────
110
111/// Black-Scholes European option pricing and Greeks engine.
112pub struct BlackScholes;
113
114impl BlackScholes {
115    /// Compute fair value and all Greeks for `spec`.
116    ///
117    /// # Errors
118    /// - `FinError::InvalidPrice` if spot or strike is non-positive.
119    /// - `FinError::InvalidInput` if time-to-expiry or volatility is non-positive.
120    /// - `FinError::ArithmeticOverflow` on internal numeric failure.
121    pub fn price(spec: &OptionSpec) -> Result<OptionGreeks, FinError> {
122        Self::validate(spec)?;
123
124        let s = to_f64(spec.spot)?;
125        let k = to_f64(spec.strike)?;
126        let t = to_f64(spec.time_to_expiry)?;
127        let r = to_f64(spec.risk_free_rate)?;
128        let v = to_f64(spec.volatility)?;
129
130        let sqrt_t = t.sqrt();
131        let d1 = ((s / k).ln() + (r + 0.5 * v * v) * t) / (v * sqrt_t);
132        let d2 = d1 - v * sqrt_t;
133
134        let (price_f, delta_f, rho_f) = match spec.kind {
135            OptionKind::Call => {
136                let price = s * big_phi(d1) - k * (-r * t).exp() * big_phi(d2);
137                let delta = big_phi(d1);
138                let rho = k * t * (-r * t).exp() * big_phi(d2) * 0.01;
139                (price, delta, rho)
140            }
141            OptionKind::Put => {
142                let price = k * (-r * t).exp() * big_phi(-d2) - s * big_phi(-d1);
143                let delta = big_phi(d1) - 1.0;
144                let rho = -k * t * (-r * t).exp() * big_phi(-d2) * 0.01;
145                (price, delta, rho)
146            }
147        };
148
149        let gamma_f = phi(d1) / (s * v * sqrt_t);
150        // Theta per calendar day (divide by 365)
151        let theta_f = match spec.kind {
152            OptionKind::Call => {
153                (-s * phi(d1) * v / (2.0 * sqrt_t)
154                    - r * k * (-r * t).exp() * big_phi(d2))
155                    / 365.0
156            }
157            OptionKind::Put => {
158                (-s * phi(d1) * v / (2.0 * sqrt_t)
159                    + r * k * (-r * t).exp() * big_phi(-d2))
160                    / 365.0
161            }
162        };
163        // Vega per 1% move in vol
164        let vega_f = s * sqrt_t * phi(d1) * 0.01;
165
166        Ok(OptionGreeks {
167            price: from_f64(price_f)?,
168            delta: from_f64(delta_f)?,
169            gamma: from_f64(gamma_f)?,
170            theta: from_f64(theta_f)?,
171            vega: from_f64(vega_f)?,
172            rho: from_f64(rho_f)?,
173        })
174    }
175
176    /// Solve for implied volatility given a market price using Newton-Raphson.
177    ///
178    /// Iterates up to `max_iter` times (default 100 is recommended).
179    /// Returns `FinError::InvalidInput` if the solver fails to converge within tolerance.
180    ///
181    /// # Errors
182    /// - `FinError::InvalidPrice` if spot or strike is non-positive.
183    /// - `FinError::InvalidInput` if the price is non-positive, time-to-expiry is non-positive,
184    ///   or the solver does not converge.
185    /// - `FinError::ArithmeticOverflow` on internal numeric failure.
186    pub fn implied_volatility(
187        market_price: Decimal,
188        spot: Decimal,
189        strike: Decimal,
190        time_to_expiry: Decimal,
191        risk_free_rate: Decimal,
192        kind: OptionKind,
193        max_iter: usize,
194        tolerance: Decimal,
195    ) -> Result<Decimal, FinError> {
196        if market_price <= Decimal::ZERO {
197            return Err(FinError::InvalidInput(
198                "Market price must be positive for IV solve".to_owned(),
199            ));
200        }
201        let tol_f = to_f64(tolerance)?;
202        let target = to_f64(market_price)?;
203
204        // Initial vol guess: Brenner-Subrahmanyam approximation
205        let s_f = to_f64(spot)?;
206        let k_f = to_f64(strike)?;
207        let t_f = to_f64(time_to_expiry)?;
208        let mut sigma = (2.0 * std::f64::consts::PI / t_f).sqrt() * (target / s_f);
209        sigma = sigma.clamp(1e-6, 10.0);
210
211        for _ in 0..max_iter {
212            let vol_dec = from_f64(sigma)?;
213            let spec = OptionSpec {
214                kind,
215                spot,
216                strike,
217                time_to_expiry,
218                risk_free_rate,
219                volatility: vol_dec,
220            };
221            let greeks = Self::price(&spec)?;
222            let price_f = to_f64(greeks.price)?;
223            let vega_f = to_f64(greeks.vega)? * 100.0; // vega stored as per-1%, restore to per-unit
224
225            let diff = price_f - target;
226            if diff.abs() < tol_f {
227                return from_f64(sigma);
228            }
229            if vega_f.abs() < 1e-12 {
230                return Err(FinError::InvalidInput(
231                    "Implied volatility solver: vega near zero, cannot converge".to_owned(),
232                ));
233            }
234            sigma -= diff / vega_f;
235            sigma = sigma.clamp(1e-6, 10.0);
236
237            // ATM moneyness check — use Corrado-Miller approximation as fallback seed
238            let _ = (s_f, k_f); // used for Brenner-Subrahmanyam above
239        }
240
241        Err(FinError::InvalidInput(format!(
242            "Implied volatility solver did not converge in {max_iter} iterations"
243        )))
244    }
245
246    fn validate(spec: &OptionSpec) -> Result<(), FinError> {
247        if spec.spot <= Decimal::ZERO {
248            return Err(FinError::InvalidPrice(spec.spot));
249        }
250        if spec.strike <= Decimal::ZERO {
251            return Err(FinError::InvalidPrice(spec.strike));
252        }
253        if spec.time_to_expiry <= Decimal::ZERO {
254            return Err(FinError::InvalidInput(
255                "time_to_expiry must be positive".to_owned(),
256            ));
257        }
258        if spec.volatility <= Decimal::ZERO {
259            return Err(FinError::InvalidInput(
260                "volatility must be positive".to_owned(),
261            ));
262        }
263        Ok(())
264    }
265}
266
267// ─── tests ────────────────────────────────────────────────────────────────────
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use rust_decimal_macros::dec;
273
274    fn atm_call() -> OptionSpec {
275        OptionSpec {
276            kind: OptionKind::Call,
277            spot: dec!(100),
278            strike: dec!(100),
279            time_to_expiry: dec!(1),
280            risk_free_rate: dec!(0.05),
281            volatility: dec!(0.2),
282        }
283    }
284
285    #[test]
286    fn test_call_price_positive() {
287        let g = BlackScholes::price(&atm_call()).unwrap();
288        assert!(g.price > Decimal::ZERO);
289    }
290
291    #[test]
292    fn test_put_call_parity() {
293        // C - P = S - K * exp(-rT)
294        let spec = atm_call();
295        let call = BlackScholes::price(&spec).unwrap();
296        let put_spec = OptionSpec { kind: OptionKind::Put, ..spec };
297        let put = BlackScholes::price(&put_spec).unwrap();
298        // C - P ≈ S - K*e^{-rT}; with ATM S=K this ≈ K*(1 - e^{-rT})
299        let diff = (call.price - put.price).abs();
300        // Should be roughly K * (1 - e^{-0.05}) ≈ 4.877 for S=K=100, r=0.05, T=1
301        assert!(diff > dec!(4) && diff < dec!(6), "put-call parity failed: {diff}");
302    }
303
304    #[test]
305    fn test_delta_call_between_zero_and_one() {
306        let g = BlackScholes::price(&atm_call()).unwrap();
307        assert!(g.delta > Decimal::ZERO && g.delta < dec!(1));
308    }
309
310    #[test]
311    fn test_gamma_positive() {
312        let g = BlackScholes::price(&atm_call()).unwrap();
313        assert!(g.gamma > Decimal::ZERO);
314    }
315
316    #[test]
317    fn test_vega_positive() {
318        let g = BlackScholes::price(&atm_call()).unwrap();
319        assert!(g.vega > Decimal::ZERO);
320    }
321
322    #[test]
323    fn test_invalid_spot_errors() {
324        let mut spec = atm_call();
325        spec.spot = dec!(0);
326        assert!(matches!(BlackScholes::price(&spec), Err(FinError::InvalidPrice(_))));
327    }
328
329    #[test]
330    fn test_implied_volatility_roundtrip() {
331        let spec = atm_call();
332        let g = BlackScholes::price(&spec).unwrap();
333        let iv = BlackScholes::implied_volatility(
334            g.price,
335            spec.spot,
336            spec.strike,
337            spec.time_to_expiry,
338            spec.risk_free_rate,
339            spec.kind,
340            200,
341            dec!(0.0001),
342        )
343        .unwrap();
344        let diff = (iv - spec.volatility).abs();
345        assert!(diff < dec!(0.001), "IV roundtrip error too large: {diff}");
346    }
347}