第12章 曲面外推与内插:边界处理、极端行权价与期限的预测
做波动率曲面的人,迟早要面对一个问题:边界怎么处理?
我刚开始做期权定价系统时,觉得曲面构建嘛,把市场上的期权价格倒推成隐含波动率,再插值一下就行了。结果第一次遇到深度虚值期权——行权价离当前价格老远,市场上根本没有报价——系统直接崩了。嗯,从那以后我才意识到,曲面外推和内插才是真正考验功力的地方。
为什么需要外推与内插?
说白了,市场数据永远是不完整的。你打开交易终端,能看到几十个行权价、几个期限的报价。但实际定价时,你需要的是任意行权价、任意期限的波动率。比如一个奇异期权,行权价是标的价格的150%,期限是18个月——市场上大概率没有这个点。
这时候怎么办?
- 内插:在已知数据点之间填充。比如已知1个月和3个月的波动率,要算2个月的。
- 外推:在已知数据范围之外预测。比如行权价超出市场报价范围,或者期限超过最长期限。
内插相对简单,外推才是真正的挑战。我在项目中遇到过好几次,外推方法选错了,整个曲面在边界处出现奇怪的扭曲,导致定价结果完全不可用。
内插方法:从简单到实用
1. 线性内插
最直接的方法。给定两个点,中间的点按比例取值。
def linear_interp(x, x1, x2, y1, y2):
return y1 + (y2 - y1) * (x - x1) / (x2 - x1)
但说实话,线性内插在波动率曲面上效果一般。为什么?因为波动率曲面通常不是线性的,尤其是期限结构——短期波动率变化快,长期波动率变化慢。线性内插会忽略这种曲率。
2. 样条内插
我个人习惯用三次样条。它能在保证平滑的同时,捕捉曲面的局部特征。
from scipy.interpolate import CubicSpline
import numpy as np
# 假设已知期限和对应的波动率
tenors = np.array([0.25, 0.5, 1.0, 2.0, 3.0])
vols = np.array([0.25, 0.22, 0.20, 0.19, 0.185])
# 构建三次样条
cs = CubicSpline(tenors, vols, bc_type='natural')
# 内插任意期限
target_tenor = 1.5
interp_vol = cs(target_tenor)
print(f"1.5年期限的波动率: {interp_vol:.4f}")
这里要注意边界条件。我一般用natural(自然边界),也就是二阶导数为零。这样在边界处不会出现过度振荡。
3. 双线性/双三次内插(二维)
当你要同时处理行权价和期限两个维度时,就需要二维内插了。双线性内插是最简单的二维方法:
def bilinear_interp(x, y, x_grid, y_grid, z_grid):
# x: 行权价, y: 期限
# x_grid, y_grid: 网格坐标, z_grid: 波动率矩阵
from scipy.interpolate import RectBivariateSpline
interp_func = RectBivariateSpline(x_grid, y_grid, z_grid, kx=1, ky=1)
return interp_func(x, y)[0][0]
但说实话,双线性内插在曲面上的表现不够平滑。如果你追求更光滑的结果,试试双三次样条(kx=3, ky=3)。
外推方法:边界处的艺术
外推比内插难得多。你想想看,数据点之外的地方,没有任何信息约束,模型可以随便跑偏。我曾经见过一个团队用多项式外推,结果在深度虚值区域波动率变成了负数——这显然不合理。
1. 常数外推
最简单也最保守的方法:边界之外的波动率等于边界值。
def constant_extrap(vol_surface, strike, tenor):
# 如果行权价超出范围,取最近边界的值
if strike < min_strike:
return vol_surface[min_strike, tenor]
elif strike > max_strike:
return vol_surface[max_strike, tenor]
# 期限同理
这个方法的好处是稳定,不会出现离谱的值。但缺点也很明显——它假设波动率在边界外不再变化,这通常不符合市场行为。
2. 线性外推
用边界处的斜率继续延伸。比如在期限维度上,用最后两个已知点的斜率外推:
def linear_extrap(tenor, tenors, vols):
# 用最后两个点计算斜率
slope = (vols[-1] - vols[-2]) / (tenors[-1] - tenors[-2])
return vols[-1] + slope * (tenor - tenors[-1])
但这里有个坑:如果斜率是负的(长期波动率下降),外推太久波动率可能变成负值。我一般会加一个下限保护,比如波动率不低于5%。
3. SVI参数化外推
这是我最推荐的方法。SVI(Stochastic Volatility Inspired)模型用几个参数来描述波动率微笑的形状,然后外推时保持这些参数不变。
def svi_extrap(strike, forward, params):
# params: [a, b, rho, m, sigma]
a, b, rho, m, sigma = params
k = np.log(strike / forward)
# SVI公式
w = a + b * (rho * (k - m) + np.sqrt((k - m)**2 + sigma**2))
return np.sqrt(w / max(tenor, 0.01)) # 转换为波动率
SVI的好处是:它天然保证了波动率在极端行权价处是线性的(在方差空间上),而且不会出现负值。我在做外汇期权系统时,深度虚值期权的定价全靠SVI外推撑着。
极端行权价的预测
极端行权价,说白了就是深度实值和深度虚值。这些地方市场报价很少,但奇异期权经常需要。
我个人习惯用尾部行为假设来预测:
- 深度虚值看涨:波动率应该趋于一个常数(或者缓慢上升)。因为极端虚值期权反映的是尾部风险,市场通常要求更高的波动率补偿。
- 深度虚值看跌:同理,但要注意负偏斜(skew)的影响。在股票市场,深度虚值看跌的波动率通常高于深度虚值看涨。
- 深度实值:波动率应该趋于平价波动率。因为深度实值期权几乎等于标的资产本身,波动率的影响很小。
一个实用的方法是:用线性外推+上限/下限。比如:
def extreme_strike_vol(strike, forward, atm_vol, skew, max_vol=1.0, min_vol=0.05):
moneyness = strike / forward
# 基础波动率 = ATM波动率 + skew * (moneyness - 1)
base_vol = atm_vol + skew * (moneyness - 1)
# 限制在合理范围内
return max(min_vol, min(max_vol, base_vol))
嗯,这个方法虽然简单,但在大多数情况下够用了。如果你需要更精确的预测,可以考虑加入随机波动率模型(比如Heston模型)来生成理论上的极端行权价波动率。
极端期限的预测
期限外推比行权价外推更棘手。为什么?因为期限结构有明确的物理意义——短期波动率反映的是即时不确定性,长期波动率反映的是均值回归水平。
我一般遵循以下原则:
- 短期外推(小于1个月):波动率应该上升。因为越临近到期,不确定性越大(事件风险)。但要注意,不要外推到0期限——那会变成无穷大。
- 长期外推(大于5年):波动率应该趋于一个常数。这是均值回归的结果。我通常用5-10年的数据来估计这个长期水平。
一个实用的模型是指数衰减:
def tenor_extrap(tenor, short_vol, long_vol, decay_rate):
# 短期波动率 -> 长期波动率,指数衰减
return long_vol + (short_vol - long_vol) * np.exp(-decay_rate * tenor)
这个模型的好处是:参数少(就三个),而且保证了波动率在长期趋于稳定。我在做利率期权时,经常用这个模型来外推10年以上的期限。
实战:构建完整的波动率曲面
好了,理论说完了,咱们来点实际的。下面是一个完整的曲面构建流程:
import numpy as np
from scipy.interpolate import RectBivariateSpline
class VolSurface:
def __init__(self, strikes, tenors, vols, forward):
self.strikes = np.array(strikes)
self.tenors = np.array(tenors)
self.vols = np.array(vols) # 二维矩阵
self.forward = forward
# 在log-moneyness空间构建样条
log_moneyness = np.log(self.strikes / self.forward)
self.interp = RectBivariateSpline(
log_moneyness, self.tenors, self.vols,
kx=3, ky=3, s=0
)
def get_vol(self, strike, tenor):
# 内插
log_m = np.log(strike / self.forward)
if (self.strikes.min() <= strike <= self.strikes.max() and
self.tenors.min() <= tenor <= self.tenors.max()):
return self.interp(log_m, tenor)[0][0]
# 外推:用SVI风格
# 这里简化处理,实际项目中需要更复杂的逻辑
return self._extrapolate(strike, tenor)
def _extrapolate(self, strike, tenor):
# 边界处理
if tenor < self.tenors.min():
# 短期外推:波动率上升
short_vol = self.interp(log_m, self.tenors.min())[0][0]
return short_vol * 1.1 # 简单加10%
elif tenor > self.tenors.max():
# 长期外推:趋于常数
long_vol = self.interp(log_m, self.tenors.max())[0][0]
return long_vol * 0.95 # 简单减5%
# 行权价外推类似...
return self.interp(log_m, tenor)[0][0]
这个类虽然简单,但已经包含了内插和外推的核心逻辑。实际生产环境中,你还需要加入:
- 波动率上下限保护
- 不同资产类别的特殊处理(比如外汇的波动率微笑更对称)
- 日历套利和蝶式套利的检查
避坑指南
最后,分享几个我踩过的坑:
- 不要用多项式外推:高次多项式在边界外会剧烈振荡,我曾经用5次多项式外推,结果波动率在深度虚值区域变成了负数。
- 注意套利条件:外推后的曲面必须满足无套利条件。比如,不能出现日历套利(短期波动率高于长期太多)或蝶式套利(波动率微笑出现凹陷)。
- 定期验证:我每个月都会用市场数据回测外推效果。如果发现系统性的偏差,及时调整参数。
好了,关于曲面外推与内插,就聊这么多。记住一句话:内插是技术,外推是艺术。技术可以学,艺术需要练。多动手、多回测,你也能做出漂亮的波动率曲面。