Nội suy bề mặt FX Vol từ điểm tấn công không đồng nhất so với lưới tenor
TL; DR
Tôi đang cố gắng phù hợp với bề mặt vol để tiếp thị báo giá quyền chọn FX nhằm xây dựng một mô hình vol cục bộ để định giá. Không giống như các tùy chọn được liệt kê thường có lưới hình chữ nhật đẹp mắt với các giao dịch và kỳ hạn, tùy chọn FX có xu hướng giao dịch OTC và các báo giá có sẵn không cung cấp một lưới đồng nhất.
Cách tiếp cận hợp lý để thực hiện nội suy 2D trên các lưới không đồng nhất là gì? Ý tưởng tôi có là:
- Tạo một lưới hình vuông mịn hơn gồm các điểm và nội suy giá trị cho các điểm đó (ví dụ: sử dụng
scipy.interpolate.griddata
hình bên dưới) và xây dựng bề mặt vol cho điểm đó (mặc dù điều này có vẻ lãng phí) - Áp dụng một số biến đổi cho các lệnh tùy chọn để trải chúng ra đồng nhất (kéo dài các kỳ hạn sớm hơn các kỳ hạn muộn hơn) sau đó sử dụng bộ nội suy lưới 2D tiêu chuẩn
Cuối cùng, tôi muốn xây dựng một mô hình đang QuantLib
sử dụng ql.BlackVarianceSurface
, mô hình này hiện yêu cầu một lưới hình chữ nhật gồm các vôn.
Tôi muốn biết mọi người đã thực hiện những phương pháp tiếp cận nào, bao gồm bất kỳ mối nguy hiểm nào về nội suy 2D và các vấn đề ngoại suy.
Chi tiết thêm về vấn đề
Dưới đây là một ví dụ về bề mặt FX vol được thị trường báo giá:
Khi điều này được chuyển đổi thành (đình công, giọng nam cao, âm lượng) tăng gấp ba lần, các cuộc đình công trông giống như sau:
Điều này cung cấp cho chúng tôi một lưới vols không đồng nhất, được vẽ trên bề mặt 2D, chúng trông như thế này (trong tte và trong tte gốc):
Truyền sang lưới vuông bằng cách sử dụng scipy.interpolate.griddatavà nội suy hai chiều:
Trả lời
Tôi đã thử một cái gì đó dọc theo những dòng này trong Quantlib python vài tuần trước. Tôi nghĩ đơn giản hơn một chút so với cách tiếp cận của bạn:
- bắt đầu với quy ước báo giá delta tiêu chuẩn cho FX vols (đặt 10D, đặt 25D, ATM, lệnh gọi 25D, lệnh gọi 10D)
- tính toán số tiền của các quyền chọn để có được bộ tiền phạt (đây sẽ là một bộ số tiền lớn vì mỗi kỳ hạn quyền chọn sẽ có số tiền đình công duy nhất tương ứng với báo giá tiền của nguồn gốc)
- nội suy các vol bị thiếu cho toàn bộ số lần đánh cho mỗi lần trưởng thành - tôi đã làm điều này bằng cách sử dụng chức năng BlackVarianceSurface trong Quantlib. Vì vậy, tôi đã có một lưới đầy đủ các kỳ hạn / đình công
- Cuối cùng tôi đã lấy dữ liệu này và thử hiệu chuẩn Heston và cắm đầu ra vào chức năng HestonBlackVolSurface
Kết quả không tuyệt vời vì vols ngụ ý của Heston không thực sự tái tạo vols nguồn đầu vào của tôi với độ chính xác nhưng điều đó có lẽ liên quan nhiều hơn đến hiệu chuẩn kém của tôi và các giá trị nguồn đầu vào giả mà tôi đã sử dụng. Tuy nhiên, đó là một bài tập đáng giá.
Trong trường hợp có thể hữu ích, mã Quantlib của tôi ở bên dưới:
def deltavolquotes(ccypair,fxcurve):
from market import curveinfo
sheetname = ccypair + '_fx_volcurve'
df = pd.read_excel('~/iCloud/python_stuff/finance/marketdata.xlsx', sheet_name=sheetname)
curveinfo = curveinfo(ccypair, 'fxvols')
calendar = curveinfo.loc['calendar', 'fxvols']
daycount = curveinfo.loc['curve_daycount', 'fxvols']
settlement = curveinfo.loc['curve_sett', 'fxvols']
flat_vol = ql.SimpleQuote(curveinfo.loc['flat_vol', 'fxvols'])
flat_vol_shift = ql.SimpleQuote(0)
used_flat_vol = ql.CompositeQuote(ql.QuoteHandle(flat_vol_shift), ql.QuoteHandle(flat_vol), f)
vol_shift = ql.SimpleQuote(0)
calculation_date = fxcurve.referenceDate()
settdate = calendar.advance(calculation_date, settlement, ql.Days)
date_periods = df[ccypair].tolist()
atm = [ql.CompositeQuote(ql.QuoteHandle(vol_shift), ql.QuoteHandle(ql.SimpleQuote(i)), f) for i in
df['ATM'].tolist()]
C25 = [ql.CompositeQuote(ql.QuoteHandle(vol_shift), ql.QuoteHandle(ql.SimpleQuote(i)), f) for i in
df['25C'].tolist()]
P25 = [ql.CompositeQuote(ql.QuoteHandle(vol_shift), ql.QuoteHandle(ql.SimpleQuote(i)), f) for i in
df['25P'].tolist()]
C10 = [ql.CompositeQuote(ql.QuoteHandle(vol_shift), ql.QuoteHandle(ql.SimpleQuote(i)), f) for i in
df['10C'].tolist()]
P10 = [ql.CompositeQuote(ql.QuoteHandle(vol_shift), ql.QuoteHandle(ql.SimpleQuote(i)), f) for i in
df['10P'].tolist()]
dates = [calendar.advance(settdate, ql.Period(i)) for i in date_periods]
yearfracs = [daycount.yearFraction(settdate, i) for i in dates]
dvq_C25 = [ql.DeltaVolQuote(0.25, ql.QuoteHandle(i), j, 0) for i, j in zip(C25, yearfracs)]
dvq_P25 = [ql.DeltaVolQuote(-0.25, ql.QuoteHandle(i), j, 0) for i, j in zip(P25, yearfracs)]
dvq_C10 = [ql.DeltaVolQuote(0.10, ql.QuoteHandle(i), j, 0) for i, j in zip(C10, yearfracs)]
dvq_P10 = [ql.DeltaVolQuote(-0.10, ql.QuoteHandle(i), j, 0) for i, j in zip(P10, yearfracs)]
info=[settdate,calendar,daycount,df,used_flat_vol,vol_shift,flat_vol_shift,date_periods]
return atm,dvq_C25,dvq_P25,dvq_C10,dvq_P10,dates,yearfracs,info
def fxvolsurface(ccypair,FX,fxcurve,curve):
atm,dvq_C25,dvq_P25,dvq_C10,dvq_P10,dates,yearfracs,info = deltavolquotes(ccypair,fxcurve)
settdate = info[0]
calendar=info[1]
daycount=info[2]
df=info[3]
used_flat_vol=info[4]
vol_shift=info[5]
flat_vol_shift=info[6]
date_periods=info[7]
blackdc_C25=[ql.BlackDeltaCalculator(ql.Option.Call,j.Spot,FX.value(),
fxcurve.discount(i)/fxcurve.discount(settdate),
curve.discount(i)/curve.discount(settdate),
j.value()*(k**0.5))
for i,j,k in zip(dates,dvq_C25,yearfracs)]
blackdc_C10=[ql.BlackDeltaCalculator(ql.Option.Call,j.Spot,FX.value(),
fxcurve.discount(i)/fxcurve.discount(settdate),
curve.discount(i)/curve.discount(settdate),
j.value()*(k**0.5))
for i,j,k in zip(dates,dvq_C10,yearfracs)]
blackdc_P25=[ql.BlackDeltaCalculator(ql.Option.Put,j.Spot,FX.value(),
fxcurve.discount(i)/fxcurve.discount(settdate),
curve.discount(i)/curve.discount(settdate),
j.value()*(k**0.5))
for i,j,k in zip(dates,dvq_P25,yearfracs)]
blackdc_P10=[ql.BlackDeltaCalculator(ql.Option.Put,j.Spot,FX.value(),
fxcurve.discount(i)/fxcurve.discount(settdate),
curve.discount(i)/curve.discount(settdate),
j.value()*(k**0.5))
for i,j,k in zip(dates,dvq_P10,yearfracs)]
C25_strikes=[i.strikeFromDelta(0.25) for i in blackdc_C25]
C10_strikes=[i.strikeFromDelta(0.10) for i in blackdc_C10]
P25_strikes=[i.strikeFromDelta(-0.25) for i in blackdc_P25]
P10_strikes=[i.strikeFromDelta(-0.10) for i in blackdc_P10]
ATM_strikes=[i.atmStrike(j.AtmFwd) for i,j in zip(blackdc_C25,dvq_C25)]
strikeset=ATM_strikes+C25_strikes+C10_strikes+P25_strikes+P10_strikes
strikeset.sort()
hestonstrikes=[P10_strikes,P25_strikes,ATM_strikes,C25_strikes,C10_strikes]
hestonvoldata=[df['10P'].tolist(),df['25P'].tolist(),df['ATM'].tolist(),df['25C'].tolist(),df['10C'].tolist()]
volmatrix=[]
for i in range(0,len(atm)):
volsurface=ql.BlackVolTermStructureHandle(ql.BlackVarianceSurface(settdate,calendar,[dates[i]],
[P10_strikes[i],P25_strikes[i],ATM_strikes[i],C25_strikes[i],C10_strikes[i]],
[[dvq_P10[i].value()],[dvq_P25[i].value()],[atm[i].value()],[dvq_C25[i].value()],
[dvq_C10[i].value()]],
daycount))
volmatrix.append([volsurface.blackVol(dates[i],j,True) for j in strikeset])
volarray=np.array(volmatrix).transpose()
matrix = []
for i in range(0, volarray.shape[0]):
matrix.append(volarray[i].tolist())
fxvolsurface=ql.BlackVolTermStructureHandle(
ql.BlackVarianceSurface(settdate,calendar,dates,strikeset,matrix,daycount))
'''
process = ql.HestonProcess(fxcurve, curve, ql.QuoteHandle(FX), 0.01, 0.5, 0.01, 0.1, 0)
model = ql.HestonModel(process)
engine = ql.AnalyticHestonEngine(model)
print(model.params())
hmh = []
for i in range(0,len(date_periods)):
for j in range(0,len(hestonstrikes)):
helper=ql.HestonModelHelper(ql.Period(date_periods[i]), calendar, FX.value(),hestonstrikes[j][i],
ql.QuoteHandle(ql.SimpleQuote(hestonvoldata[j][i])),fxcurve,curve)
helper.setPricingEngine(engine)
hmh.append(helper)
lm = ql.LevenbergMarquardt()
model.calibrate(hmh, lm,ql.EndCriteria(500, 10, 1.0e-8, 1.0e-8, 1.0e-8))
vs = ql.BlackVolTermStructureHandle(ql.HestonBlackVolSurface(ql.HestonModelHandle(model)))
vs.enableExtrapolation()'''
flatfxvolsurface = ql.BlackVolTermStructureHandle(
ql.BlackConstantVol(settdate, calendar, ql.QuoteHandle(used_flat_vol), daycount))
fxvoldata=pd.DataFrame({'10P strike':P10_strikes,'25P strike':P25_strikes,'ATM strike':ATM_strikes,
'25C strike':C25_strikes,'10C strike':C10_strikes,'10P vol':df['10P'].tolist(),
'25P vol':df['25P'].tolist(),'ATM vol':df['ATM'].tolist(),
'25C vol':df['25C'].tolist(),'10C vol':df['10C'].tolist()})
fxvoldata.index=date_periods
fxvolsdf=pd.DataFrame({'fxvolsurface':[fxvolsurface,flatfxvolsurface],'fxvoldata':[fxvoldata,None]})
fxvolsdf.index=['surface','flat']
fxvolshiftsdf=pd.DataFrame({'fxvolshifts':[vol_shift,flat_vol_shift]})
fxvolshiftsdf.index=['surface','flat']
return fxvolshiftsdf,fxvolsdf
Cuối cùng, tôi thấy rằng việc lắp một nụ cười SABR với mỗi giọng nam cao (mượn kết quả từ câu trả lời này ) là đủ để tạo ra bề mặt vol cục bộ đủ mịn và hoạt động tốt để tạo ra một bề mặt phương sai hoạt động tốt. Tôi cũng đã lắp một mô hình Heston cho nó, và hai bề mặt trông khá giống nhau. Đây là mã cuối cùng và các khớp được tạo (đoạn mã dài ở dưới cùng là bắt buộc để tạo các ô này và cũng chứa dữ liệu thô được yêu cầu)
Đầu tiên, lặp lại từng giọng nam cao và lắp một nụ cười SABR:
# This is the 'SABR-solution'... fit a SABR smile to each tenor, and let the vol surface interpolate
# between them. Below, we're using the python minimizer to do a fit to the provided smiles
calibrated_params = {}
# params are sigma_0, beta, vol_vol, rho
params = [0.4, 0.6, 0.1, 0.2]
fig, i = plt.figure(figsize=(6, 42)), 1
for tte, group in full_df.groupby('tte'):
fwd = group.iloc[0]['fwd']
expiry = group.iloc[0]['expiry']
strikes = group.sort_values('strike')['strike'].values
vols = group.sort_values('strike')['vol'].values
def f(params):
params[0] = max(params[0], 1e-8) # Avoid alpha going negative
params[1] = max(params[1], 1e-8) # Avoid beta going negative
params[2] = max(params[2], 1e-8) # Avoid nu going negative
params[3] = max(params[3], -0.999) # Avoid nu going negative
params[3] = min(params[3], 0.999) # Avoid nu going negative
calc_vols = np.array([
ql.sabrVolatility(strike, fwd, tte, *params)
for strike in strikes
])
error = ((calc_vols - np.array(vols))**2 ).mean() **.5
return error
cons = (
{'type': 'ineq', 'fun': lambda x: x[0]},
{'type': 'ineq', 'fun': lambda x: 0.99 - x[1]},
{'type': 'ineq', 'fun': lambda x: x[1]},
{'type': 'ineq', 'fun': lambda x: x[2]},
{'type': 'ineq', 'fun': lambda x: 1. - x[3]**2}
)
result = optimize.minimize(f, params, constraints=cons, options={'eps': 1e-5})
new_params = result['x']
calibrated_params[tte] = {'v0': new_params[0], 'beta': new_params[1], 'alpha': new_params[2], 'rho': new_params[3], 'fwd': fwd}
newVols = [ql.sabrVolatility(strike, fwd, tte, *new_params) for strike in strikes]
# Start next round of optimisation with this round's parameters, they're probably quite close!
params = new_params
plt.subplot(len(tenors), 1, i)
i = i+1
plt.plot(strikes, vols, marker='o', linestyle='none', label='market {}'.format(expiry))
plt.plot(strikes, newVols, label='SABR {0:1.2f}'.format(tte))
plt.title("Smile {0:1.3f}".format(tte))
plt.grid()
plt.legend()
plt.show()
tạo ra một chuỗi các âm mưu như thế này, tất cả hầu hết đều phù hợp với nhau:
tạo ra các tham số SABR ở mỗi kỳ hạn trông như thế này (ví dụ này, tôi đã đặt đường cong chiết khấu nước ngoài và trong nước bằng phẳng):
Sau đó, tôi đã hiệu chỉnh mô hình vol cục bộ và mô hình vol Heston, thực sự cả hai đều trông khá gần nhau:
# Fit a local vol surface to a strike-tenor grid extrapolated according to SABR
strikes = np.linspace(1.0, 1.5, 21)
expiration_dates = [calc_date + ql.Period(int(365 * x), ql.Days) for x in params.index]
implied_vols = []
for tte, row in params.iterrows():
fwd, v0, beta, alpha, rho = row['fwd'], row['v0'], row['beta'], row['alpha'], row['rho']
vols = [ql.sabrVolatility(strike, fwd, tte, v0, beta, alpha, rho) for strike in strikes]
implied_vols.append(vols)
implied_vols = ql.Matrix(np.matrix(implied_vols).transpose().tolist())
local_vol_surface = ql.BlackVarianceSurface(calc_date, calendar, expiration_dates, strikes, implied_vols, day_count)
# Fit a Heston model to the data as well
v0 = 0.005; kappa = 0.01; theta = 0.0064; rho = 0.0; sigma = 0.01
heston_process = ql.HestonProcess(dom_dcf_curve, for_dcf_curve, ql.QuoteHandle(ql.SimpleQuote(spot)), v0, kappa, theta, sigma, rho)
heston_model = ql.HestonModel(heston_process)
heston_engine = ql.AnalyticHestonEngine(heston_model)
# Set up Heston 'helpers' to calibrate to
heston_helpers = []
for idx, row in full_df.iterrows():
vol = row['vol']
strike = row['strike']
tenor = ql.Period(row['expiry'])
helper = ql.HestonModelHelper(tenor, calendar, spot, strike, ql.QuoteHandle(ql.SimpleQuote(vol)), dom_dcf_curve, for_dcf_curve)
helper.setPricingEngine(heston_engine)
heston_helpers.append(helper)
lm = ql.LevenbergMarquardt(1e-8, 1e-8, 1e-8)
heston_model.calibrate(heston_helpers, lm, ql.EndCriteria(5000, 100, 1.0e-8, 1.0e-8, 1.0e-8))
theta, kappa, sigma, rho, v0 = heston_model.params()
feller = 2 * kappa * theta - sigma ** 2
print(f"theta = {theta:.4f}, kappa = {kappa:.4f}, sigma = {sigma:.4f}, rho = {rho:.4f}, v0 = {v0:.4f}, spot = {spot:.4f}, feller = {feller:.4f}")
heston_handle = ql.HestonModelHandle(heston_model)
heston_vol_surface = ql.HestonBlackVolSurface(heston_handle)
# Plot the two vol surfaces ...
plot_vol_surface([local_vol_surface, heston_vol_surface], plot_years=np.arange(0.1, 1.0, 0.1), plot_strikes=np.linspace(1.05, 1.45, 20))
Chúng tôi kỳ vọng mô hình khối lượng cục bộ sẽ định giá biến mất một cách chính xác nhưng cung cấp động lực khối lượng không thực tế, trong khi chúng tôi hy vọng Heston sẽ cung cấp động lực khối lượng tốt hơn nhưng không biến giá quá tốt, nhưng bằng cách hiệu chỉnh hàm đòn bẩy và sử dụng mô hình khối lượng địa phương ngẫu nhiên Heston, chúng tôi có thể có được tốt nhất của cả hai thế giới - và đây cũng là một thử nghiệm tốt cho thấy bề mặt vol cục bộ mà chúng tôi đã tạo hoạt động tốt
# Calculate the Dupire instantaneous vol surface
local_vol_surface.setInterpolation('bicubic')
local_vol_handle = ql.BlackVolTermStructureHandle(local_vol_surface)
local_vol = ql.LocalVolSurface(local_vol_handle, dom_dcf_curve, for_dcf_curve, ql.QuoteHandle(ql.SimpleQuote(spot)))
# Calibrating a leverage function
end_date = ql.Date(21, 9, 2021)
generator_factory = ql.MTBrownianGeneratorFactory(43)
timeStepsPerYear = 182
nBins = 101
calibrationPaths = 2**19
stoch_local_mc_model = ql.HestonSLVMCModel(local_vol, heston_model, generator_factory, end_date, timeStepsPerYear, nBins, calibrationPaths)
leverage_functon = stoch_local_mc_model.leverageFunction()
plot_vol_surface(leverage_functon, funct='localVol', plot_years=np.arange(0.5, 0.98, 0.1), plot_strikes=np.linspace(1.05, 1.35, 20))
tạo ra một hàm đòn bẩy đẹp mắt, gần bằng 1 ở mọi nơi (cho thấy rằng phù hợp với Heston thô đã khá tốt)
Mã soạn sẵn để tạo các hình ảnh trên (bao gồm cả chuyển đổi từ delta sang FX):
import warnings
warnings.filterwarnings('ignore')
import pandas as pd
import numpy as np
from matplotlib import pyplot as plt
import matplotlib.cm as cm
from mpl_toolkits.mplot3d import Axes3D
from scipy.stats import norm
from scipy import optimize, stats
import QuantLib as ql
calc_date = ql.Date(1, 9, 2020)
def plot_vol_surface(vol_surface, plot_years=np.arange(0.1, 3, 0.1), plot_strikes=np.arange(70, 130, 1), funct='blackVol'):
if type(vol_surface) != list:
surfaces = [vol_surface]
else:
surfaces = vol_surface
fig = plt.figure(figsize=(10, 6))
ax = fig.gca(projection='3d')
X, Y = np.meshgrid(plot_strikes, plot_years)
Z_array, Z_min, Z_max = [], 100, 0
for surface in surfaces:
method_to_call = getattr(surface, funct)
Z = np.array([method_to_call(float(y), float(x))
for xr, yr in zip(X, Y)
for x, y in zip(xr, yr)]
).reshape(len(X), len(X[0]))
Z_array.append(Z)
Z_min, Z_max = min(Z_min, Z.min()), max(Z_max, Z.max())
# In case of multiple surfaces, need to find universal max and min first for colourmap
for Z in Z_array:
N = (Z - Z_min) / (Z_max - Z_min) # normalize 0 -> 1 for the colormap
surf = ax.plot_surface(X, Y, Z, rstride=1, cstride=1, linewidth=0.1, facecolors=cm.coolwarm(N))
m = cm.ScalarMappable(cmap=cm.coolwarm)
m.set_array(Z)
plt.colorbar(m, shrink=0.8, aspect=20)
ax.view_init(30, 300)
def generate_multi_paths_df(process, num_paths=1000, timestep=24, length=2):
"""Generates multiple paths from an n-factor process, each factor is returned in a seperate df"""
times = ql.TimeGrid(length, timestep)
dimension = process.factors()
rng = ql.GaussianRandomSequenceGenerator(ql.UniformRandomSequenceGenerator(dimension * timestep, ql.UniformRandomGenerator()))
seq = ql.GaussianMultiPathGenerator(process, list(times), rng, False)
paths = [[] for i in range(dimension)]
for i in range(num_paths):
sample_path = seq.next()
values = sample_path.value()
spot = values[0]
for j in range(dimension):
paths[j].append([x for x in values[j]])
df_paths = [pd.DataFrame(path, columns=[spot.time(x) for x in range(len(spot))]) for path in paths]
return df_paths
# Define functions to map from delta to strike
def strike_from_spot_delta(tte, fwd, vol, delta, dcf_for, put_call):
sigma_root_t = vol * np.sqrt(tte)
inv_norm = norm.ppf(delta * put_call * dcf_for)
return fwd * np.exp(-sigma_root_t * put_call * inv_norm + 0.5 * sigma_root_t * sigma_root_t)
def strike_from_fwd_delta(tte, fwd, vol, delta, put_call):
sigma_root_t = vol * np.sqrt(tte)
inv_norm = norm.ppf(delta * put_call)
return fwd * np.exp(-sigma_root_t * put_call * inv_norm + 0.5 * sigma_root_t * sigma_root_t)
# World State for Vanilla Pricing
spot = 1.17858
rateDom = 0.0
rateFor = 0.0
calendar = ql.NullCalendar()
day_count = ql.Actual365Fixed()
# Set up the flat risk-free curves
riskFreeCurveDom = ql.FlatForward(calc_date, rateDom, ql.Actual365Fixed())
riskFreeCurveFor = ql.FlatForward(calc_date, rateFor, ql.Actual365Fixed())
dom_dcf_curve = ql.YieldTermStructureHandle(riskFreeCurveDom)
for_dcf_curve = ql.YieldTermStructureHandle(riskFreeCurveFor)
tenors = ['1W', '2W', '1M', '2M', '3M', '6M', '9M', '1Y', '18M', '2Y']
deltas = ['ATM', '35D Call EUR', '35D Put EUR', '25D Call EUR', '25D Put EUR', '15D Call EUR', '15D Put EUR', '10D Call EUR', '10D Put EUR', '5D Call EUR', '5D Put EUR']
vols = [[7.255, 7.428, 7.193, 7.61, 7.205, 7.864, 7.261, 8.033, 7.318, 8.299, 7.426],
[7.14, 7.335, 7.07, 7.54, 7.08, 7.836, 7.149, 8.032, 7.217, 8.34, 7.344],
[7.195, 7.4, 7.13, 7.637, 7.167, 7.984, 7.286, 8.226, 7.394, 8.597, 7.58],
[7.17, 7.39, 7.11, 7.645, 7.155, 8.031, 7.304, 8.303, 7.438, 8.715, 7.661],
[7.6, 7.827, 7.547, 8.105, 7.615, 8.539, 7.796, 8.847, 7.952, 9.308, 8.222],
[7.285, 7.54, 7.26, 7.878, 7.383, 8.434, 7.671, 8.845, 7.925, 9.439, 8.344],
[7.27, 7.537, 7.262, 7.915, 7.425, 8.576, 7.819, 9.078, 8.162, 9.77, 8.713],
[7.275, 7.54, 7.275, 7.935, 7.455, 8.644, 7.891, 9.188, 8.283, 9.922, 8.898],
[7.487, 7.724, 7.521, 8.089, 7.731, 8.742, 8.197, 9.242, 8.592, 9.943, 9.232],
[7.59, 7.81, 7.645, 8.166, 7.874, 8.837, 8.382, 9.354, 8.816, 10.065, 9.51]]
# Convert vol surface to strike surface (we need both)
full_option_surface = []
for i, name in enumerate(deltas):
delta = 0.5 if name == "ATM" else int(name.split(" ")[0].replace("D", "")) / 100.
put_call = 1 if name == "ATM" else -1 if name.split(" ")[1] == "Put" else 1
for j, tenor in enumerate(tenors):
expiry = calc_date + ql.Period(tenor)
tte = day_count.yearFraction(calc_date, expiry)
fwd = spot * for_dcf_curve.discount(expiry) / dom_dcf_curve.discount(expiry)
for_dcf = for_dcf_curve.discount(expiry)
vol = vols[j][i] / 100.
# Assume that spot delta used out to 1Y (used to be this way...)
if tte < 1.:
strike = strike_from_spot_delta(tte, fwd, vol, put_call*delta, for_dcf, put_call)
else:
strike = strike_from_fwd_delta(tte, fwd, vol, put_call*delta, put_call)
full_option_surface.append({"vol": vol, "fwd": fwd, "expiry": tenor, "tte": tte, "delta": put_call*delta, "strike": strike, "put_call": put_call, "for_dcf": for_dcf, "name": name})
full_df = pd.DataFrame(full_option_surface)
display_df = full_df.copy()
display_df['call_delta'] = 1 - (display_df['put_call'].clip(0) - display_df['delta'])
df = display_df.set_index(['tte', 'call_delta']).sort_index()[['strike']].unstack()
df = df.reindex(sorted(df.columns, reverse=True), axis=1)
fig = plt.figure(figsize=(12,9))
plt.subplot(2,1,1)
plt.plot(full_df['tte'], full_df['strike'], marker='o', linestyle='none', label='strike grid')
plt.title("Option Strike Grid, tte vs. K")
plt.grid()
plt.xlim(0, 2.1)
df