Skip to main content

finance_solution/derivatives/
implied_vol.rs

1//! # Implied volatility
2//!
3//! Invert a European BSM **market premium** → annualized σ.
4//!
5//! ---
6//!
7//! ## Trading perspective
8//!
9//! The market does not quote “model vol”; it quotes **prices**. IV is the σ that makes
10//! BSM match that price — the language of surfaces, skew, and “IV crush”.
11//!
12//! | Use | How |
13//! |-----|-----|
14//! | Mark a book | Mid → IV → store on the contract |
15//! | Relative value | Compare IV across strikes/expiries (skew, term structure) |
16//! | Events | IV vs realized (realized from underlier returns / TA path) |
17//!
18//! **Gotchas desks already know:** American early exercise, discrete dividends, and
19//! wide markets break naive European IV — treat results as model-dependent.
20//!
21//! ---
22//!
23//! ## Engineering perspective
24//!
25//! - Solver: Newton–Raphson on vega, bisection fallback.  
26//! - `params.vol` is **ignored** as an input seed for the root (only S,K,T,r,q, type matter).  
27//! - Prefer [`crate::derivatives::BsmState::set_vol_from_price`] in live loops so IV is stored once.  
28//! - Hot path cost: a handful of BSM evaluations — fine per quote; batch chains may parallelize **across strikes** in your engine.
29//!
30//! ---
31//!
32//! ## Word problem
33//!
34//! > Market call mid is ~10.45 with S=K=100, T=1, r=5%, q=0. What is IV?
35//!
36//! Expect: about **20%**.
37//!
38//! ```
39//! use finance_solution::derivatives::{
40//!     bsm_implied_vol, BsmParams, OptionType,
41//! };
42//! let p = BsmParams::atm_one_year(100.0, 0.05, 0.20);
43//! let iv = bsm_implied_vol(p, OptionType::Call, 10.4506).unwrap();
44//! assert!((iv - 0.20).abs() < 1e-3);
45//! ```
46
47use crate::derivatives::black_scholes::{price_raw, vega_raw};
48use crate::derivatives::types::{validate_bsm_params, BsmParams, OptionType};
49use crate::util::error::{require_finite, FinanceError, FinanceResult};
50
51/// Solve for annualized vol given a target premium.
52///
53/// Uses Newton–Raphson on vega with bisection fallback. `params.vol` is not used as a seed.
54///
55/// # Errors
56/// Invalid BSM domain, non-finite premium, T=0, premium outside achievable range, or solver failure.
57pub fn bsm_implied_vol(
58    params: BsmParams,
59    option_type: OptionType,
60    market_price: f64,
61) -> FinanceResult<f64> {
62    validate_bsm_params(params)?;
63    require_finite("market_price", market_price)?;
64    if market_price < 0.0 {
65        return Err(FinanceError::Unsolvable {
66            message: "market_price must be non-negative",
67        });
68    }
69
70    let mut p = params;
71    let intrinsic = match option_type {
72        OptionType::Call => (p.spot - p.strike).max(0.0),
73        OptionType::Put => (p.strike - p.spot).max(0.0),
74    };
75    if market_price + 1e-12 < intrinsic && p.time_years == 0.0 {
76        return Err(FinanceError::Unsolvable {
77            message: "market_price below intrinsic",
78        });
79    }
80
81    if p.time_years == 0.0 {
82        return Err(FinanceError::Unsolvable {
83            message: "implied vol undefined at expiry (T=0)",
84        });
85    }
86
87    let mut lo = 1e-8;
88    let mut hi = 5.0;
89    p.vol = hi;
90    let mut price_hi = price_raw(p, option_type)?;
91    let mut expand = 0;
92    while price_hi < market_price && hi < 50.0 && expand < 10 {
93        hi *= 2.0;
94        p.vol = hi;
95        price_hi = price_raw(p, option_type)?;
96        expand += 1;
97    }
98    p.vol = lo;
99    let price_lo = price_raw(p, option_type)?;
100    if market_price < price_lo - 1e-10 {
101        return Err(FinanceError::Unsolvable {
102            message: "market_price below zero-vol price",
103        });
104    }
105    if market_price > price_hi + 1e-8 {
106        return Err(FinanceError::Unsolvable {
107            message: "market_price above max-vol price in search range",
108        });
109    }
110
111    let mut sigma = 0.25_f64.max(lo).min(hi);
112    for _ in 0..30 {
113        p.vol = sigma;
114        let price = price_raw(p, option_type)?;
115        let diff = price - market_price;
116        if diff.abs() < 1e-10 {
117            return Ok(sigma);
118        }
119        let vega = vega_raw(p, option_type)?;
120        if vega < 1e-14 {
121            break;
122        }
123        let next = sigma - diff / vega;
124        sigma = next.clamp(lo, hi);
125    }
126
127    for _ in 0..80 {
128        let mid = 0.5 * (lo + hi);
129        p.vol = mid;
130        let price = price_raw(p, option_type)?;
131        if (price - market_price).abs() < 1e-10 {
132            return Ok(mid);
133        }
134        if price > market_price {
135            hi = mid;
136        } else {
137            lo = mid;
138        }
139    }
140    Ok(0.5 * (lo + hi))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::derivatives::black_scholes::bsm_price;
147
148    #[test]
149    fn round_trip_atm() {
150        let p = BsmParams::atm_one_year(100.0, 0.05, 0.22);
151        let mkt = bsm_price(p, OptionType::Call).unwrap();
152        let iv = bsm_implied_vol(p, OptionType::Call, mkt).unwrap();
153        assert!((iv - 0.22).abs() < 1e-6);
154    }
155
156    #[test]
157    fn put_round_trip() {
158        let p = BsmParams {
159            spot: 90.0,
160            strike: 100.0,
161            time_years: 0.75,
162            rate: 0.01,
163            dividend_yield: 0.02,
164            vol: 0.35,
165        };
166        let mkt = bsm_price(p, OptionType::Put).unwrap();
167        let iv = bsm_implied_vol(p, OptionType::Put, mkt).unwrap();
168        assert!((iv - 0.35).abs() < 1e-6);
169    }
170}