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