- Set ruff/black line length to 120 - Reformatted code with black - Fixed import ordering with ruff - Disabled lint for UI component files with long CSS strings - Updated pyproject.toml with proper tool configuration
191 lines
6.2 KiB
Python
191 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
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: float = 0.16
|
|
DEFAULT_DIVIDEND_YIELD: float = 0.0
|
|
DEFAULT_GLD_PRICE: float = 460.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AmericanOptionInputs:
|
|
"""Inputs for American option pricing via a binomial tree.
|
|
|
|
This module is intended primarily for GLD protective puts, where early
|
|
exercise can matter in stressed scenarios.
|
|
|
|
Example:
|
|
>>> params = AmericanOptionInputs(
|
|
... spot=460.0,
|
|
... strike=420.0,
|
|
... time_to_expiry=0.5,
|
|
... option_type="put",
|
|
... )
|
|
>>> params.steps
|
|
500
|
|
"""
|
|
|
|
spot: float = DEFAULT_GLD_PRICE
|
|
strike: float = DEFAULT_GLD_PRICE
|
|
time_to_expiry: float = 0.5
|
|
risk_free_rate: float = DEFAULT_RISK_FREE_RATE
|
|
volatility: float = DEFAULT_VOLATILITY
|
|
option_type: OptionType = "put"
|
|
dividend_yield: float = DEFAULT_DIVIDEND_YIELD
|
|
steps: int = 500
|
|
valuation_date: date | None = None
|
|
tree: str = "crr"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AmericanPricingResult:
|
|
"""American option price and finite-difference Greeks."""
|
|
|
|
price: float
|
|
delta: float
|
|
gamma: float
|
|
theta: float
|
|
vega: float
|
|
rho: float
|
|
|
|
|
|
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 _build_dates(time_to_expiry: float, valuation_date: date | None) -> tuple[ql.Date, ql.Date]:
|
|
if time_to_expiry <= 0.0:
|
|
raise ValueError("time_to_expiry must be positive")
|
|
|
|
valuation = valuation_date or date.today()
|
|
maturity = valuation + timedelta(days=max(1, round(time_to_expiry * 365)))
|
|
return (
|
|
ql.Date(valuation.day, valuation.month, valuation.year),
|
|
ql.Date(maturity.day, maturity.month, maturity.year),
|
|
)
|
|
|
|
|
|
def _american_price(
|
|
params: AmericanOptionInputs,
|
|
*,
|
|
spot: float | None = None,
|
|
risk_free_rate: float | None = None,
|
|
volatility: float | None = None,
|
|
time_to_expiry: float | None = None,
|
|
) -> float:
|
|
option_type = _validate_option_type(params.option_type)
|
|
used_spot = params.spot if spot is None else spot
|
|
used_rate = params.risk_free_rate if risk_free_rate is None else risk_free_rate
|
|
used_vol = params.volatility if volatility is None else volatility
|
|
used_time = params.time_to_expiry if time_to_expiry is None else time_to_expiry
|
|
|
|
if used_spot <= 0 or used_vol <= 0 or used_time <= 0:
|
|
raise ValueError("spot, volatility, and time_to_expiry must be positive")
|
|
if params.steps < 10:
|
|
raise ValueError("steps must be at least 10 for binomial pricing")
|
|
|
|
valuation_ql, maturity_ql = _build_dates(used_time, params.valuation_date)
|
|
ql.Settings.instance().evaluationDate = valuation_ql
|
|
|
|
day_count = ql.Actual365Fixed()
|
|
calendar = ql.NullCalendar()
|
|
|
|
spot_handle = ql.QuoteHandle(ql.SimpleQuote(used_spot))
|
|
dividend_curve = ql.YieldTermStructureHandle(ql.FlatForward(valuation_ql, params.dividend_yield, day_count))
|
|
risk_free_curve = ql.YieldTermStructureHandle(ql.FlatForward(valuation_ql, used_rate, day_count))
|
|
volatility_curve = ql.BlackVolTermStructureHandle(ql.BlackConstantVol(valuation_ql, calendar, used_vol, day_count))
|
|
|
|
process = ql.BlackScholesMertonProcess(
|
|
spot_handle,
|
|
dividend_curve,
|
|
risk_free_curve,
|
|
volatility_curve,
|
|
)
|
|
|
|
payoff = ql.PlainVanillaPayoff(_to_quantlib_option_type(option_type), params.strike)
|
|
exercise = ql.AmericanExercise(valuation_ql, maturity_ql)
|
|
option = ql.VanillaOption(payoff, exercise)
|
|
option.setPricingEngine(ql.BinomialVanillaEngine(process, params.tree, params.steps))
|
|
return float(option.NPV())
|
|
|
|
|
|
def american_option_price_and_greeks(
|
|
params: AmericanOptionInputs,
|
|
) -> AmericanPricingResult:
|
|
"""Price an American option and estimate Greeks with finite differences.
|
|
|
|
Notes:
|
|
- The price uses a QuantLib binomial tree engine.
|
|
- Greeks are finite-difference approximations because closed-form
|
|
American Greeks are not available in general.
|
|
- Theta is annualized and approximated by rolling one calendar day forward.
|
|
|
|
Args:
|
|
params: American option inputs.
|
|
|
|
Returns:
|
|
A price and finite-difference Greeks.
|
|
|
|
Example:
|
|
>>> params = AmericanOptionInputs(
|
|
... spot=460.0,
|
|
... strike=400.0,
|
|
... time_to_expiry=0.5,
|
|
... risk_free_rate=0.045,
|
|
... volatility=0.16,
|
|
... option_type="put",
|
|
... )
|
|
>>> result = american_option_price_and_greeks(params)
|
|
>>> result.price > 0
|
|
True
|
|
"""
|
|
|
|
base_price = _american_price(params)
|
|
|
|
spot_bump = max(0.01, params.spot * 0.01)
|
|
vol_bump = 0.01
|
|
rate_bump = 0.0001
|
|
dt = 1.0 / 365.0
|
|
|
|
price_up = _american_price(params, spot=params.spot + spot_bump)
|
|
price_down = _american_price(params, spot=max(1e-8, params.spot - spot_bump))
|
|
delta = (price_up - price_down) / (2.0 * spot_bump)
|
|
gamma = (price_up - 2.0 * base_price + price_down) / (spot_bump**2)
|
|
|
|
vega_up = _american_price(params, volatility=params.volatility + vol_bump)
|
|
vega_down = _american_price(params, volatility=max(1e-6, params.volatility - vol_bump))
|
|
vega = (vega_up - vega_down) / (2.0 * vol_bump)
|
|
|
|
rho_up = _american_price(params, risk_free_rate=params.risk_free_rate + rate_bump)
|
|
rho_down = _american_price(params, risk_free_rate=params.risk_free_rate - rate_bump)
|
|
rho = (rho_up - rho_down) / (2.0 * rate_bump)
|
|
|
|
if params.time_to_expiry <= dt:
|
|
theta = 0.0
|
|
else:
|
|
shorter_price = _american_price(params, time_to_expiry=params.time_to_expiry - dt)
|
|
theta = (shorter_price - base_price) / dt
|
|
|
|
return AmericanPricingResult(
|
|
price=base_price,
|
|
delta=delta,
|
|
gamma=gamma,
|
|
theta=theta,
|
|
vega=vega,
|
|
rho=rho,
|
|
)
|