mathidot's picture
build option trading agent modules
8f1601b
Raw
History Blame Contribute Delete
6.72 kB
from __future__ import annotations
import math
from statistics import NormalDist
import pandas as pd
from .schemas import OptionChain
NORMAL = NormalDist()
def realized_volatility(
prices: pd.Series,
windows: tuple[int, ...] = (5, 10, 20, 30, 60),
trading_days: int = 252,
) -> dict[str, float | None]:
close = prices.dropna().astype(float)
returns = close.pct_change().dropna()
output: dict[str, float | None] = {}
for window in windows:
key = f"{window}d"
if len(returns) < window:
output[key] = None
continue
output[key] = float(returns.tail(window).std(ddof=1) * math.sqrt(trading_days))
return output
def _norm_pdf(value: float) -> float:
return math.exp(-0.5 * value * value) / math.sqrt(2 * math.pi)
def black_scholes_greeks(
spot: float,
strike: float,
time_to_expiry: float,
volatility: float,
risk_free_rate: float = 0.0,
dividend_yield: float = 0.0,
option_type: str = "call",
) -> dict[str, float | None]:
if spot <= 0 or strike <= 0 or time_to_expiry <= 0 or volatility <= 0:
return {
"delta": None,
"gamma": None,
"vega": None,
"theta": None,
"rho": None,
}
sqrt_t = math.sqrt(time_to_expiry)
d1 = (
math.log(spot / strike)
+ (risk_free_rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry
) / (volatility * sqrt_t)
d2 = d1 - volatility * sqrt_t
discount_dividend = math.exp(-dividend_yield * time_to_expiry)
discount_rate = math.exp(-risk_free_rate * time_to_expiry)
option_type = option_type.lower()
if option_type == "put":
delta = discount_dividend * (NORMAL.cdf(d1) - 1)
theta = (
-spot * discount_dividend * _norm_pdf(d1) * volatility / (2 * sqrt_t)
+ dividend_yield * spot * discount_dividend * NORMAL.cdf(-d1)
- risk_free_rate * strike * discount_rate * NORMAL.cdf(-d2)
) / 365
rho = -strike * time_to_expiry * discount_rate * NORMAL.cdf(-d2) / 100
else:
delta = discount_dividend * NORMAL.cdf(d1)
theta = (
-spot * discount_dividend * _norm_pdf(d1) * volatility / (2 * sqrt_t)
- dividend_yield * spot * discount_dividend * NORMAL.cdf(d1)
+ risk_free_rate * strike * discount_rate * NORMAL.cdf(d2)
) / 365
rho = strike * time_to_expiry * discount_rate * NORMAL.cdf(d2) / 100
gamma = discount_dividend * _norm_pdf(d1) / (spot * volatility * sqrt_t)
vega = spot * discount_dividend * _norm_pdf(d1) * sqrt_t / 100
return {
"delta": float(delta),
"gamma": float(gamma),
"vega": float(vega),
"theta": float(theta),
"rho": float(rho),
}
def nearest_atm_iv(chain: OptionChain) -> float | None:
if chain.underlying_price is None:
return None
contracts = chain.calls + chain.puts
valid = [
contract
for contract in contracts
if contract.implied_volatility is not None and contract.implied_volatility > 0
]
if not valid:
return None
nearest = min(valid, key=lambda contract: abs(contract.strike - chain.underlying_price))
return nearest.implied_volatility
def simple_skew(chain: OptionChain) -> float | None:
if chain.underlying_price is None:
return None
otm_puts = [
contract
for contract in chain.puts
if contract.strike < chain.underlying_price and contract.implied_volatility
]
otm_calls = [
contract
for contract in chain.calls
if contract.strike > chain.underlying_price and contract.implied_volatility
]
if not otm_puts or not otm_calls:
return None
put = max(otm_puts, key=lambda contract: contract.strike)
call = min(otm_calls, key=lambda contract: contract.strike)
return float((put.implied_volatility or 0) - (call.implied_volatility or 0))
def summarize_option_chain(chain: OptionChain, realized_vol_20d: float | None = None) -> dict:
atm_iv = nearest_atm_iv(chain)
return {
"symbol": chain.symbol,
"expiration": chain.expiration,
"underlying_price": chain.underlying_price,
"atm_iv": atm_iv,
"iv_rv_spread_20d": (
float(atm_iv - realized_vol_20d)
if atm_iv is not None and realized_vol_20d is not None
else None
),
"skew_put_minus_call": simple_skew(chain),
"call_count": len(chain.calls),
"put_count": len(chain.puts),
}
def rank_current_iv_against_rv(
current_iv: float | None,
realized_vols: dict[str, float | None],
) -> float | None:
if current_iv is None:
return None
rv_values = [value for value in realized_vols.values() if value is not None]
if len(rv_values) < 2:
return None
low = min(rv_values)
high = max(rv_values)
if high <= low:
return None
return max(0.0, min(1.0, (current_iv - low) / (high - low)))
def classify_volatility_regime(
current_iv: float | None,
realized_vol_20d: float | None,
term_structure_slope: float | None,
skew: float | None,
) -> dict:
if current_iv is None or realized_vol_20d is None:
return {
"regime": "unknown",
"vol_signal": "insufficient_iv_or_rv",
"confidence": "low",
"notes": ["Need both option implied volatility and realized volatility."],
}
iv_rv_spread = current_iv - realized_vol_20d
notes = []
if iv_rv_spread > 0.08:
regime = "high_implied_vol_premium"
vol_signal = "short_vol_candidate"
notes.append("Current ATM IV is materially above 20D realized volatility.")
elif iv_rv_spread < -0.04:
regime = "low_implied_vol_discount"
vol_signal = "long_vol_candidate"
notes.append("Current ATM IV is below 20D realized volatility.")
else:
regime = "balanced_iv_vs_rv"
vol_signal = "neutral_vol"
notes.append("Current ATM IV is close to 20D realized volatility.")
if term_structure_slope is not None:
if term_structure_slope > 0.04:
notes.append("Term structure is upward sloping.")
elif term_structure_slope < -0.04:
notes.append("Term structure is inverted or front-loaded.")
if skew is not None and abs(skew) > 0.05:
notes.append("Put-call skew is elevated in the sampled expiration.")
confidence = "medium" if len(notes) >= 2 else "low"
return {
"regime": regime,
"vol_signal": vol_signal,
"confidence": confidence,
"notes": notes,
}