Skip to content

Commit 7bf4288

Browse files
committed
Added initial implementation of calibrator working with yfinance
1 parent 016c2c1 commit 7bf4288

4 files changed

Lines changed: 649 additions & 0 deletions

File tree

environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies:
2727
- pytest
2828
- pytest-cov
2929
- pre-commit
30+
- yfinance
3031
- -e . # install quantlab package in editable mode
3132

3233
# - tqdm

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"torch",
1717
"matplotlib",
1818
"py_vollib",
19+
"yfinance",
1920
]
2021

2122
[project.optional-dependencies]
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
"""
2+
Market-calibrated data generator for deep hedging evaluation.
3+
4+
This module calibrates stochastic models to publicly available market data
5+
and generates synthetic paths for model evaluation.
6+
"""
7+
import warnings
8+
from datetime import datetime
9+
10+
import numpy as np
11+
import pandas as pd
12+
import yfinance as yf
13+
14+
from quantlab.calibration.inverse import recover_heston_params_from_implied_vols
15+
from quantlab.calibration.utils import make_heston_object_wrapper
16+
from quantlab.market_data.market_state import MarketState
17+
from quantlab.models.heston.model import HestonParameters, HestonProcess
18+
from quantlab.pricing.heston.cos import price as cos_price
19+
from quantlab.sim.heston.paths import simulate_heston_paths_torch
20+
21+
warnings.filterwarnings("ignore")
22+
23+
24+
class MarketCalibrator:
25+
"""Calibrates Heston model to market data for realistic synthetic data generation.""" # noqa: E501
26+
27+
def __init__(self, risk_free_rate=None):
28+
"""
29+
Initialize with optional risk-free rate.
30+
31+
Args:
32+
risk_free_rate: Risk free rate.
33+
If None, will fetch from Treasury data.
34+
"""
35+
if risk_free_rate is None:
36+
self.risk_free_rate = self._fetch_risk_free_rate()
37+
else:
38+
self.risk_free_rate = risk_free_rate
39+
40+
def _fetch_risk_free_rate(self, maturity_years=1.0):
41+
"""
42+
Fetch appropriate risk-free rate for the given maturity.
43+
44+
Args:
45+
maturity_years: Maturity of the derivatives being hedged
46+
"""
47+
try:
48+
# Map maturity to appropriate Treasury security
49+
if maturity_years <= 1.0:
50+
ticker = "^IRX" # 3-month Treasury
51+
elif maturity_years <= 5.0:
52+
ticker = "^FVX" # 5-year Treasury
53+
elif maturity_years <= 10.0:
54+
ticker = "^TNX" # 10-year Treasury
55+
else: # Longer maturity
56+
ticker = "^TYX" # 30-year Treasury
57+
58+
treasury = yf.Ticker(ticker)
59+
hist = treasury.history(period="5d")
60+
rate_percent = hist["Close"].iloc[-1] # Annual percentage rate
61+
return rate_percent / 100 # Convert to decimal
62+
63+
except Exception as e:
64+
print(f"Warning: Could not fetch Treasury data: {e}")
65+
# Fallback: reasonable estimate based on maturity
66+
if maturity_years <= 1.0:
67+
return 0.045 # Short-term rate
68+
else:
69+
return 0.050 # Long-term rate
70+
71+
def _fetch_option_chain(self, ticker="SPY"):
72+
"""Fetch option chain data for calibration."""
73+
try:
74+
stock = yf.Ticker(ticker)
75+
76+
# Get current stock price from historical data
77+
hist = stock.history(period="5d")
78+
S0 = hist["Close"].iloc[-1]
79+
80+
# Get available expiration dates (using near-term options for calibration)
81+
exp_dates = stock.options[:3] # Use first 3 expiration dates
82+
83+
if not exp_dates:
84+
return None, None, None, S0
85+
86+
strikes = []
87+
maturities = []
88+
implied_vols = []
89+
90+
today = datetime.today()
91+
92+
for exp_date in exp_dates:
93+
try:
94+
# Check if expiration date is in the future
95+
expiry = datetime.strptime(exp_date, "%Y-%m-%d")
96+
if expiry <= today:
97+
print(f"Skipping expired option date: {exp_date}")
98+
continue
99+
100+
# Get options for this expiration
101+
opt = stock.option_chain(exp_date)
102+
103+
# Filter to reasonable strikes around current price
104+
atm_strike = round(S0, -1) # Round to nearest 10
105+
strike_range = [
106+
atm_strike - 40,
107+
atm_strike + 40,
108+
] # 80-strike range around ATM
109+
110+
# Use calls with valid implied volatility and reasonable volume
111+
calls = opt.calls[
112+
(opt.calls["strike"] >= strike_range[0])
113+
& (opt.calls["strike"] <= strike_range[1])
114+
& (opt.calls["impliedVolatility"] > 0)
115+
& (opt.calls["impliedVolatility"] < 1.0) # valid IVs
116+
& (opt.calls["volume"] > 10) # Exclude extremely high IVs
117+
& (pd.notna(opt.calls["lastPrice"])) # At least some volume
118+
& (opt.calls["lastPrice"] > 0)
119+
].copy()
120+
121+
if len(calls) == 0:
122+
continue
123+
124+
# Calculate time to maturity in years
125+
T = (expiry - today).days / 365.25
126+
if T <= 0:
127+
continue
128+
129+
for _, row in calls.iterrows():
130+
if row["impliedVolatility"] > 0 and row["lastPrice"] > 0:
131+
strikes.append(row["strike"])
132+
maturities.append(T)
133+
implied_vols.append(row["impliedVolatility"])
134+
135+
except Exception as e:
136+
print(f"Warning: Could not get options for {exp_date}: {e}")
137+
continue
138+
139+
if len(strikes) > 5: # Only return if we have enough data points
140+
return (
141+
np.array(strikes),
142+
np.array(maturities),
143+
np.array(implied_vols),
144+
S0,
145+
)
146+
else:
147+
return None, None, None, S0 # Not enough options data
148+
149+
except Exception as e:
150+
print(f"Warning: Could not fetch option chain for {ticker}: {e}")
151+
print("Falling back to equity-based calibration...")
152+
153+
# Still try to get S0 from equity data
154+
try:
155+
stock = yf.Ticker(ticker)
156+
hist = stock.history(period="5d")
157+
S0 = hist["Close"].iloc[-1]
158+
return None, None, None, S0
159+
except Exception as e:
160+
print(f"Could not extract S0: {e}")
161+
return None, None, None, None
162+
163+
def _calibrate_from_options(self, strikes, maturities, ivs, S0):
164+
"""Calibrate using option market data."""
165+
# Create market state
166+
market_state = MarketState(
167+
stock_price=S0, interest_rate=self.risk_free_rate, time=0.0
168+
)
169+
170+
# Initial guess based on market conditions
171+
initial_guess = {
172+
"kappa": 2.0,
173+
"theta": np.mean(ivs) ** 2, # Rough estimate from average IV
174+
"eta": 0.3,
175+
"rho": -0.7,
176+
"v0": np.mean(ivs) ** 2, # Start with average IV squared
177+
}
178+
179+
# Calibration bounds
180+
bounds = {
181+
"kappa": (0.1, 10.0),
182+
"theta": (0.001, 0.5),
183+
"eta": (0.01, 2.0),
184+
"rho": (-0.99, 0.99),
185+
"v0": (0.001, 0.5),
186+
}
187+
188+
# Create wrapper
189+
cos_wrapper = make_heston_object_wrapper(
190+
pricer_func=cos_price,
191+
market_state_for_calibration=market_state,
192+
pricer_kwargs={"n_points": 2048},
193+
)
194+
195+
# Perform calibration
196+
calibrated_params = recover_heston_params_from_implied_vols(
197+
strikes=strikes,
198+
maturities=maturities,
199+
target_implied_vols=ivs,
200+
market_state=market_state,
201+
initial_guess=initial_guess,
202+
pricing_func=cos_wrapper,
203+
pricing_kwargs={},
204+
bounds=bounds,
205+
weights=None, # Equal weighting
206+
method="differential_evolution",
207+
optimizer_options={
208+
"maxiter": 100,
209+
"seed": 42,
210+
"polish": True,
211+
"disp": True,
212+
},
213+
verbose=False,
214+
)
215+
216+
calibrated_heston_params = HestonParameters(
217+
v0=calibrated_params["v0"],
218+
kappa=calibrated_params["kappa"],
219+
theta=calibrated_params["theta"],
220+
eta=calibrated_params["eta"],
221+
rho=calibrated_params["rho"],
222+
)
223+
224+
print("Option-based calibration successful!")
225+
self._print_calibration_results(calibrated_heston_params)
226+
227+
return HestonProcess(calibrated_heston_params, market_state)
228+
229+
def _calibrate_from_equity_prices(self, ticker, period):
230+
"""Calibrate using equity price returns."""
231+
# Fetch historical data
232+
data = yf.download(ticker, period=period)
233+
prices = data["Close"].values
234+
returns = np.diff(np.log(prices))
235+
236+
# Only proceed if we have enough data points for meaningful statistics
237+
if len(returns) < 2:
238+
print(f"Warning: Insufficient data for {ticker}, using default parameters")
239+
S0 = prices[-1] if len(prices) > 0 else 100.0
240+
else:
241+
# Calculate target statistics
242+
target_vol = np.std(returns) * np.sqrt(252)
243+
target_drift = np.mean(returns) * 252
244+
245+
print(f"Target volatility: {target_vol:.4f}")
246+
print(f"Target drift: {target_drift:.4f}")
247+
248+
S0 = prices[-1]
249+
250+
# Create market state and parameters
251+
market_state = MarketState(
252+
stock_price=S0,
253+
interest_rate=self.risk_free_rate,
254+
time=0.0,
255+
)
256+
257+
initial_params = HestonParameters(
258+
v0=target_vol**2, # Square of target volatility
259+
kappa=2.0, # Mean reversion speed (typical value)
260+
theta=target_vol**2, # Long-term variance (matches target)
261+
eta=0.3, # Vol of vol (typical value)
262+
rho=-0.7, # Leverage effect (typical for equities)
263+
)
264+
265+
print("Equity-based calibration successful!")
266+
self._print_calibration_results(initial_params)
267+
268+
return HestonProcess(initial_params, market_state)
269+
270+
def _print_calibration_results(self, params: HestonParameters):
271+
"""Print calibration results for debugging."""
272+
print("Calibrated parameters:")
273+
print(f" v0 (initial variance): {params.v0:.6f}")
274+
print(f" kappa (mean reversion): {params.kappa:.6f}")
275+
print(f" theta (long-term var): {params.theta:.6f}")
276+
print(f" eta (vol of vol): {params.eta:.6f}")
277+
print(f" rho (correlation): {params.rho:.6f}")
278+
279+
def calibrate_to_market_data(
280+
self, ticker="SPY", period="2y", use_options_if_available=True
281+
):
282+
"""
283+
Calibrate Heston model to market data.
284+
285+
Args:
286+
ticker: Stock/ETF symbol (e.g., 'SPY', 'QQQ')
287+
period: Historical period ('1y', '2y', '5y')
288+
use_options_if_available: Whether to try option data first
289+
290+
Returns:
291+
Calibrated HestonProcess object
292+
"""
293+
print(f"Calibrating Heston model to {ticker} market data...")
294+
295+
if use_options_if_available:
296+
strikes, maturities, ivs, S0 = self._fetch_option_chain(ticker)
297+
298+
if strikes is not None and len(strikes) > 5: # Have enough option data
299+
print(f"Found {len(strikes)} option quotes, using for calibration...")
300+
return self._calibrate_from_options(strikes, maturities, ivs, S0)
301+
302+
print("Using equity price data for calibration...")
303+
return self._calibrate_from_equity_prices(ticker, period)
304+
305+
306+
def generate_market_calibrated_paths(
307+
ticker="SPY", n_paths=10000, maturity=1.0, n_steps=252
308+
):
309+
"""
310+
Generate market-calibrated synthetic paths for evaluation.
311+
312+
Args:
313+
ticker: Equity symbol to calibrate to
314+
n_paths: Number of paths to generate
315+
maturity: Time to maturity in years
316+
n_steps: Number of time steps per path
317+
318+
Returns:
319+
torch.Tensor of shape (n_paths, n_steps + 1) containing asset paths
320+
"""
321+
calibrator = MarketCalibrator()
322+
calibrated_process = calibrator.calibrate_to_market_data(ticker, period="2y")
323+
324+
paths, _ = simulate_heston_paths_torch(
325+
calibrated_process, T=maturity, N=n_paths, M=n_steps, device="cpu"
326+
)
327+
328+
return paths.float()
329+
330+
331+
if __name__ == "__main__":
332+
try:
333+
paths = generate_market_calibrated_paths(
334+
"SPY", n_paths=100, maturity=1.0, n_steps=252
335+
)
336+
print(f"Generated paths shape: {paths.shape}")
337+
print(f"Sample path: {paths[0, :10]}") # First 10 points of first path
338+
except Exception as e:
339+
print(f"Error in generation: {e}")

0 commit comments

Comments
 (0)