Skip to content

Commit d5e7555

Browse files
committed
implemented IV plotting into API
1 parent 3db6fd2 commit d5e7555

5 files changed

Lines changed: 215 additions & 3 deletions

File tree

examples/appl_example.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,28 @@
149149
# Select an arbitrary maturity
150150
# rebuilt_slice_df = surface_rebuild.slice(days_to_expiry=100).data
151151
# rebuilt_slice_df.head()
152+
153+
# Test Plotting
154+
print("\nTesting Plotting...")
155+
try:
156+
import matplotlib.pyplot as plt
157+
158+
# 1. VolCurve Plot
159+
# Pick the first available expiry
160+
expiry_to_plot = appl_surface.expiries[0]
161+
vol_curve = appl_surface.slice(expiry=expiry_to_plot)
162+
fig_iv = vol_curve.plot(title=f"Fitted IV Smile ({expiry_to_plot.date()})")
163+
# plt.show() # Uncomment to see plot
164+
print("VolCurve.plot() successful")
165+
166+
# 2. Distribution Plot
167+
dist = vol_curve.implied_distribution()
168+
fig_pdf = dist.plot(kind="pdf", title="Implied PDF (100 days)")
169+
# plt.show() # Uncomment to see plot
170+
print("Distribution.plot() successful")
171+
172+
except ImportError:
173+
print("Matplotlib not installed, skipping plotting tests")
174+
except Exception as e:
175+
print(f"Plotting failed: {e}")
176+
raise

oipd/interface/probability.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,31 @@ def metadata(self) -> dict[str, Any] | None:
135135

136136
return self._metadata
137137

138+
def plot(self, **kwargs) -> Any:
139+
"""Plot the risk-neutral probability distribution.
140+
141+
Args:
142+
**kwargs: Arguments forwarded to ``oipd.presentation.plot_rnd.plot_rnd``.
143+
144+
Returns:
145+
matplotlib.figure.Figure: The plot figure.
146+
"""
147+
from oipd.presentation.plot_rnd import plot_rnd
148+
149+
underlying_price = self._resolved_market.underlying_price
150+
valuation_date = self._resolved_market.valuation_date.strftime("%b %d, %Y")
151+
expiry_date = self._resolved_market.expiry_date.strftime("%b %d, %Y")
152+
153+
return plot_rnd(
154+
prices=self.prices,
155+
pdf=self.pdf,
156+
cdf=self.cdf,
157+
current_price=underlying_price,
158+
valuation_date=valuation_date,
159+
expiry_date=expiry_date,
160+
**kwargs,
161+
)
162+
138163

139164
class DistributionSurface:
140165
"""Multi-expiry risk-neutral distribution surface wrapper."""

oipd/interface/volatility.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
VendorSnapshot,
1717
resolve_market,
1818
)
19-
from oipd.pipelines.vol_curve import fit_vol_curve_internal
19+
from oipd.pipelines.vol_curve import fit_vol_curve_internal, compute_fitted_smile
2020
from oipd.pipelines.distribution import derive_distribution_from_curve
2121
from oipd.pipelines.vol_surface import fit_surface
2222
from oipd.pipelines.vol_surface.models import FittedSurface
2323

24+
from oipd.presentation.iv_plotting import plot_iv_smile, ReferenceAnnotation
25+
2426

2527
class VolCurve:
2628
"""Single-expiry implied-volatility estimator with sklearn-style API.
@@ -155,6 +157,83 @@ def at_money_vol(self) -> float:
155157
raise ValueError("Call fit before accessing ATM volatility")
156158
return getattr(self._vol_curve, "at_money_vol", np.nan)
157159

160+
def iv_smile(
161+
self,
162+
domain: Optional[tuple[float, float]] = None,
163+
points: int = 200,
164+
include_observed: bool = True,
165+
) -> pd.DataFrame:
166+
"""Return the implied-volatility smile with fitted and market-observed values.
167+
168+
Args:
169+
domain: Optional (min, max) strike range. If None, inferred from observed data.
170+
points: Number of points in the grid.
171+
include_observed: Whether to include market-observed IVs. If False, only
172+
``strike`` and ``fitted_iv`` columns are returned.
173+
174+
Returns:
175+
DataFrame containing:
176+
177+
- ``strike``: Strike levels.
178+
- ``fitted_iv``: Fitted implied volatility from the calibrated model.
179+
- ``market_iv``: *(if include_observed=True)* Mid-price implied volatility
180+
computed by inverting the mid option price using the same pricing model
181+
(Black-76 or Black-Scholes), risk-free rate, dividend assumptions, and
182+
time-to-expiry as specified during ``.fit()``.
183+
- ``market_bid_iv``: *(if include_observed=True)* Bid-price implied volatility
184+
computed using the same methodology.
185+
- ``market_ask_iv``: *(if include_observed=True)* Ask-price implied volatility
186+
computed using the same methodology.
187+
- ``market_last_iv``: *(if include_observed=True)* Last-price implied volatility
188+
computed using the same methodology (only included if bid/ask are unavailable).
189+
190+
Note:
191+
The market IVs are **not** raw quotes from exchanges—they are computed by
192+
the library by solving the inverse problem: "What σ makes the model price
193+
match the observed option price?" This ensures consistency with the fitted
194+
curve, which uses the same pricing assumptions.
195+
"""
196+
return compute_fitted_smile(
197+
vol_curve=self,
198+
metadata=self._metadata,
199+
domain=domain,
200+
points=points,
201+
include_observed=include_observed,
202+
)
203+
204+
def plot(self, **kwargs) -> Any:
205+
"""Plot the fitted implied volatility smile.
206+
207+
Args:
208+
**kwargs: Arguments forwarded to ``oipd.presentation.iv_plotting.plot_iv_smile``.
209+
210+
Returns:
211+
matplotlib.figure.Figure: The plot figure.
212+
"""
213+
smile_df = self.iv_smile()
214+
215+
# Map our column names to what plot_iv_smile expects
216+
plot_df = smile_df.rename(columns={
217+
"market_bid_iv": "bid_iv",
218+
"market_ask_iv": "ask_iv",
219+
"market_last_iv": "last_iv"
220+
})
221+
222+
# Extract reference price (forward) for log-moneyness plotting
223+
reference = None
224+
forward_price = self._metadata.get("forward_price")
225+
if forward_price is not None:
226+
reference = ReferenceAnnotation(
227+
value=float(forward_price),
228+
label=f"Forward: {float(forward_price):.2f}"
229+
)
230+
231+
# If no reference price is available, default to strike axis to avoid errors
232+
if reference is None and "axis_mode" not in kwargs:
233+
kwargs["axis_mode"] = "strike"
234+
235+
return plot_iv_smile(plot_df, reference=reference, **kwargs)
236+
158237
@property
159238
def params(self) -> dict[str, Any]:
160239
"""Return fitted parameter dictionary from the underlying curve.
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Volatility curve fitting pipeline."""
22

3-
from oipd.pipelines.vol_curve.vol_curve_pipeline import fit_vol_curve_internal
3+
from oipd.pipelines.vol_curve.vol_curve_pipeline import (
4+
fit_vol_curve_internal,
5+
compute_fitted_smile,
6+
)
47

5-
__all__ = ["fit_vol_curve_internal"]
8+
__all__ = ["fit_vol_curve_internal", "compute_fitted_smile"]

oipd/pipelines/vol_curve/vol_curve_pipeline.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,83 @@ def fit_vol_curve_internal(
8787
}
8888

8989
return vol_curve, metadata
90+
91+
92+
def compute_fitted_smile(
93+
vol_curve: Any,
94+
metadata: Dict[str, Any],
95+
domain: Optional[Tuple[float, float]] = None,
96+
points: int = 200,
97+
include_observed: bool = True,
98+
) -> pd.DataFrame:
99+
"""
100+
Generate a DataFrame representing the fitted smile and observed data.
101+
102+
Args:
103+
vol_curve: The callable volatility curve.
104+
metadata: Metadata dictionary containing observed IVs.
105+
domain: Optional (min, max) strike range.
106+
points: Number of points in the grid.
107+
include_observed: Whether to include market observed IVs (mid, bid, ask).
108+
109+
Returns:
110+
DataFrame with columns: strike, fitted_iv, [market_iv, market_bid_iv, ...]
111+
"""
112+
observed_iv = metadata.get("observed_iv")
113+
114+
# Determine grid
115+
if domain is None:
116+
if observed_iv is None or observed_iv.empty:
117+
raise ValueError(
118+
"No observed data found to infer grid domain. "
119+
"Please provide an explicit `domain=(min, max)` argument."
120+
)
121+
else:
122+
min_strike = observed_iv["strike"].min()
123+
max_strike = observed_iv["strike"].max()
124+
if np.isclose(min_strike, max_strike):
125+
strike_grid = np.array([min_strike])
126+
else:
127+
# Add 20% padding
128+
padding = 0.2 * (max_strike - min_strike)
129+
strike_grid = np.linspace(
130+
max(0.01, min_strike - padding),
131+
max_strike + padding,
132+
points,
133+
)
134+
else:
135+
strike_grid = np.linspace(domain[0], domain[1], points)
136+
137+
# Evaluate curve
138+
fitted_values = vol_curve(strike_grid)
139+
140+
smile_df = pd.DataFrame(
141+
{
142+
"strike": strike_grid,
143+
"fitted_iv": fitted_values,
144+
}
145+
)
146+
147+
if not include_observed:
148+
return smile_df
149+
150+
# Merge observed data if available
151+
if observed_iv is not None and not observed_iv.empty:
152+
# Mid IV
153+
mid_subset = observed_iv[["strike", "iv"]].rename(columns={"iv": "market_iv"})
154+
smile_df = pd.merge(smile_df, mid_subset, on="strike", how="left")
155+
156+
# Bid/Ask/Last IVs from metadata
157+
for key, col_name in [
158+
("observed_iv_bid", "market_bid_iv"),
159+
("observed_iv_ask", "market_ask_iv"),
160+
("observed_iv_last", "market_last_iv"),
161+
]:
162+
obs_df = metadata.get(key)
163+
if isinstance(obs_df, pd.DataFrame) and not obs_df.empty:
164+
subset = obs_df[["strike", "iv"]].rename(columns={"iv": col_name})
165+
# Ensure strike is float for merging
166+
subset["strike"] = subset["strike"].astype(float)
167+
smile_df = pd.merge(smile_df, subset, on="strike", how="left")
168+
169+
return smile_df

0 commit comments

Comments
 (0)