from __future__ import annotations from datetime import date, timedelta from typing import Literal import QuantLib as ql OptionType = Literal["call", "put"] DEFAULT_RISK_FREE_RATE: float = 0.045 DEFAULT_VOLATILITY_GUESS: float = 0.16 DEFAULT_DIVIDEND_YIELD: float = 0.0 def _validate_option_type(option_type: str) -> OptionType: option = option_type.lower() if option not in {"call", "put"}: raise ValueError("option_type must be either 'call' or 'put'") return option # type: ignore[return-value] def _to_quantlib_option_type(option_type: OptionType) -> ql.Option.Type: return ql.Option.Call if option_type == "call" else ql.Option.Put def implied_volatility( option_price: float, spot: float, strike: float, time_to_expiry: float, risk_free_rate: float = DEFAULT_RISK_FREE_RATE, option_type: OptionType = "put", dividend_yield: float = DEFAULT_DIVIDEND_YIELD, valuation_date: date | None = None, initial_guess: float = DEFAULT_VOLATILITY_GUESS, min_vol: float = 1e-4, max_vol: float = 4.0, accuracy: float = 1e-8, max_evaluations: int = 500, ) -> float: """Invert the Black-Scholes-Merton model to solve for implied volatility. Assumptions: - European option exercise - Flat rate, dividend, and volatility term structures - GLD dividend yield defaults to zero Args: option_price: Observed market premium. spot: Current underlying price. strike: Option strike price. time_to_expiry: Time to maturity in years. risk_free_rate: Annual risk-free rate. option_type: ``"call"`` or ``"put"``. dividend_yield: Continuous dividend yield. valuation_date: Pricing date, defaults to today. initial_guess: Starting volatility guess used in the pricing process. min_vol: Lower volatility search bound. max_vol: Upper volatility search bound. accuracy: Root-finding tolerance. max_evaluations: Maximum solver iterations. Returns: The annualized implied volatility as a decimal. Example: >>> vol = implied_volatility( ... option_price=12.0, ... spot=460.0, ... strike=430.0, ... time_to_expiry=0.5, ... risk_free_rate=0.045, ... option_type="put", ... ) >>> vol > 0 True """ if option_price <= 0 or spot <= 0 or strike <= 0 or time_to_expiry <= 0: raise ValueError("option_price, spot, strike, and time_to_expiry must be positive") if initial_guess <= 0 or min_vol <= 0 or max_vol <= min_vol: raise ValueError("invalid volatility bounds or initial_guess") option_type = _validate_option_type(option_type) valuation = valuation_date or date.today() maturity = valuation + timedelta(days=max(1, round(time_to_expiry * 365))) valuation_ql = ql.Date(valuation.day, valuation.month, valuation.year) maturity_ql = ql.Date(maturity.day, maturity.month, maturity.year) ql.Settings.instance().evaluationDate = valuation_ql day_count = ql.Actual365Fixed() calendar = ql.NullCalendar() spot_handle = ql.QuoteHandle(ql.SimpleQuote(spot)) dividend_curve = ql.YieldTermStructureHandle( ql.FlatForward(valuation_ql, dividend_yield, day_count) ) risk_free_curve = ql.YieldTermStructureHandle( ql.FlatForward(valuation_ql, risk_free_rate, day_count) ) volatility_curve = ql.BlackVolTermStructureHandle( ql.BlackConstantVol(valuation_ql, calendar, initial_guess, day_count) ) process = ql.BlackScholesMertonProcess( spot_handle, dividend_curve, risk_free_curve, volatility_curve, ) payoff = ql.PlainVanillaPayoff(_to_quantlib_option_type(option_type), strike) exercise = ql.EuropeanExercise(maturity_ql) option = ql.VanillaOption(payoff, exercise) option.setPricingEngine(ql.AnalyticEuropeanEngine(process)) return float( option.impliedVolatility( option_price, process, accuracy, max_evaluations, min_vol, max_vol, ) )