Skip to main content

finance_solution/derivatives/
implied_vol.rs

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