深度学习在期权估值上的尝试

GhatGPT技术的推出,使得普通人可以用机器学习方法来解决问题:不需要精通编程技巧,甚至不需要深刻了解模型,这在以前几乎是不可以想象的(很多人开始焦虑了)。我有一些在衍生品估值方面的经验,最近开始接触深度学习,我觉得两个领域有些东西是相通的。比如,看涨期权的支付结构就是机器学习中的线性整流函(ReLU),用多个看涨期权的组合来逼近任意的欧式支付结构就是机器学习中的通用近似定理,实现期权参数敏感度的快速计算的AAD(Adjoint Algorithmic Differentiation)算法无非就是深度学习中普遍用到的反向传播算法。

将机器学习应用在期权估值上,我想是很靠谱的,期权的估值与希腊字母在参数空间里是若干 (确定性的) 超曲面,根据通用近似定理,我们就可以用神经网络来实现映射?一旦实现,我们就可以摆脱对蒙特卡洛计算或有限差分算法(特别是高维)的漫长等待,取而代之的是经过AI芯片加速的矩阵乘法。

在下文中,我将把简单神经网络套用在欧式期权的估值上,分别针对Black-Scholes模型和局部波动率模型。这种做法在实际应用中可能没什么意义。这里一个原因是受限于计算资源,另一个原因,如标题所述,本文主要是一种尝试,希望能起到抛砖引玉的作用,我相信一些比较标准化的奇异期权(如障碍期权,亚式,经典雪球期权等可以按类似的思路去做,区别体现在输入参数的数目和神经网络的复杂度上)。

本文利用了现成的深度学习框架PyTorch,原始代码结构由大模型Kimi完成(要是闲得蛋疼,我可以用鲁迅或余华的文风给这篇文章润色),训练目标值的准备是用QuantLib完成。比较偷懒,没什么图片,折腾出来的代码全部提供。废话不多说了,开始吧。

1. Black-Scholes模型

在Black-Scholes模型中,标的波动率σ\sigmaσ为常数。

1.1 模型

对于欧式期权来说解析解就几乎可以解决所有问题,但在这里我们用它为例主要是为了展示模型(主要还是生成训练样本数据快)。一个欧式看涨期权,假设标的价格为S{\textit S}S,行权价为K{\textit K}K,无风险利率为r{\textit r}r,年化到期期限为T{\textit T}T,波动率为σ\sigmaσ,则它的估值和几个常用的希腊字母计算如下

V=S⋅N(d1)−K⋅e−rT⋅N(d2),V = S \cdot N(d_1) - K \cdot e^{-rT} \cdot N(d_2),V=SN(d1)KerTN(d2),

Δ=N(d1),\Delta = N(d_1),Δ=N(d1),

Γ=N′(d1)S⋅σT,\Gamma = \frac{N'(d_1)}{S \cdot \sigma \sqrt{T}},Γ=SσTN(d1),

υ=S⋅T⋅N′(d1),\upsilon = S \cdot \sqrt{T} \cdot N'(d_1),υ=STN(d1),

Θ=−S⋅N′(d1)⋅σ2T−K⋅e−rT⋅N(d2),\Theta = -\frac{S \cdot N'(d_1) \cdot \sigma}{2 \sqrt{T}} - K \cdot e^{-rT} \cdot N(d_2),Θ=2TSN(d1)σKerTN(d2),

ρ=T⋅K⋅e−rT⋅N(d2).\rho = T \cdot K \cdot e^{-rT} \cdot N(d_2).ρ=TKerTN(d2).

d1d_1d1d2d_2d2自行脑补。我们采用一个简单神经网络模型。为了减少输入参数,我们固定S=1{\textit S = 1}S=1,则行权价为相对值,最终的输入向量为[K,r,T,σ][ K, r, T, \sigma ][K,r,T,σ],输出向量为 [V,Δ,Γ,υ,Θ,ρ][V, \Delta, \Gamma, \upsilon , \Theta, \rho][V,Δ,Γ,υ,Θ,ρ]。结合常用的参数范围,我们生成均匀的空间网格linspace(0.5,1.5,101)×linspace(0.0,0.1,11)×linspace(0.01,2.01,201)×linspace(0.01,1.01,101)linspace(0.5, 1.5, 101) \times linspace(0.0, 0.1, 11) \times linspace(0.01, 2.01, 201) \times linspace(0.01, 1.01, 101)linspace(0.5,1.5,101)×linspace(0.0,0.1,11)×linspace(0.01,2.01,201)×linspace(0.01,1.01,101),这里linspacelinspacelinspace表示Numpy中的同名函数。以网格点为样本输入,筛选掉行权价在3个扩散标准差之外的样本,即样本参数满足限制

(r−0.5σ2)T−3σT<ln(K)<(r−0.5σ2)T+3σT(r-0.5\sigma^2)T - 3\sigma\sqrt{T} < ln(K) < (r-0.5\sigma^2)T+3\sigma\sqrt{T}(r0.5σ2)T3σT<ln(K)<(r0.5σ2)T+3σT

剩下样本数约2005万,通过Black-Scholes公式计算出对应的输出。随机选择其中的90%为训练集,余下的为测试集。由于这是一个回归问题,我们选用带权重的MSE(mean square error)作为损失函数,即

L=1N∑i=1N[wV(Vi−V^i)2+wΔ(Δi−Δ^i)2+wΓ(Γi−Γ^i)2+wυ(υi−υ^i)2+wΘ(Θi−Θ^i)2+wρ(ρi−ρ^i)2]L = \frac{1}{N} \sum_{i=1}^{N} [w_V(V_i - \hat{V}_i)^2+ w_\Delta(\Delta_i - \hat{\Delta}_i)^2+w_\Gamma(\Gamma_i - \hat{\Gamma}_i)^2+w_\upsilon (\upsilon _i - \hat{\upsilon }_i)^2+w_\Theta(\Theta_i - \hat{\Theta}_i)^2+w_\rho(\rho_i - \hat{\rho}_i)^2]L=N1i=1N[wV(ViV^i)2+wΔ(ΔiΔ^i)2+wΓ(ΓiΓ^i)2+wυ(υiυ^i)2+wΘ(ΘiΘ^i)2+wρ(ρiρ^i)2]

我们设置[wV,wΔ,wΓ,wυ,wΘ,wρ]=[1.0,1.0,0.01,1.0,0.01,1.0][w_V, w_\Delta, w_\Gamma, w_\upsilon, w_\Theta, w_\rho]=[1.0, 1.0, 0.01, 1.0, 0.01, 1.0][wV,wΔ,wΓ,wυ,wΘ,wρ]=[1.0,1.0,0.01,1.0,0.01,1.0](归一化前的值),这样做的原因一方面是根据实际应用中关注度,另一方面是因为Γ\GammaΓΘ\ThetaΘ出现极端值的概率较高,我们希望减小其对训练效率的影响。

1.2 结果

选择的神经网络大小为4×50×50×64 \times 50 \times 50 \times 64×50×50×6,这表示有两个50个神经元的隐藏层,学习率设置为0.001,在训练集上前后传播10次。最终,我们在测试集上的误差大约为7.92×10−47.92\times10^{-4}7.92×104,这是一个加权的结果,拆分到每一项的结果如下

-WeightedWeightedWeightedVVVΔ\DeltaΔΓ\GammaΓυ\upsilonυΘ\ThetaΘρ\rhoρ
MSEMSEMSE7.92×10−47.92\times10^{-4}7.92×1045.07×10−55.07\times10^{-5}5.07×1051.58×10−41.58\times10^{-4}1.58×1042.67×10−12.67\times10^{-1}2.67×1011.23×10−41.23\times10^{-4}1.23×1041.24×10−31.24\times10^{-3}1.24×1031.74×10−41.74\times10^{-4}1.74×104

不出意外,我们在权重较大的值上的训练效果较好。对于一般的期权,5.07×10−55.07\times10^{-5}5.07×105 的估值方差并不是一个很精确的结果(对于前台报价来说)。如果进一步提高网络的复杂度,误差可能会更小,但是要注意避免过拟合。

1.3 改进

不需要那么痛苦。我们可以利用反向传播算法直接计算出VVV对于SSSrrrTTTσ\sigmaσ的导数,在这种情况下,模型的训练目标只有一个,就是VVV。直觉上,对于相同的网络深度和宽度,这样导致的训练效率会更高。
由于输入向量中不含有SSS,我们可能将输入向量扩充为[1.0,K,r,T,σ][ 1.0, K, r, T, \sigma ][1.0,K,r,T,σ],但这是不可能的!考虑SSS从1变化到1+ϵ1+\epsilon1+ϵ,如果重新归一化,输入向量中唯一会变化的就是KKK,变成K/(1+ϵ)K/(1+\epsilon)K/(1+ϵ)Δ\DeltaΔ的计算就变成

Δ=lim⁡ϵ→0(1+ϵ)V^(K/(1+ϵ))−V^(K)ϵ=−K⋅∂V^(K)∂K+V^(K)\Delta = \lim_{\epsilon \to 0} \frac{(1+\epsilon)\hat{V}(K/(1+\epsilon)) - \hat{V}(K)}{\epsilon} =-K \cdot \frac{\partial \hat{V}(K)}{\partial K} + \hat{V}(K)Δ=limϵ0ϵ(1+ϵ)V^(K/(1+ϵ))V^(K)=KKV^(K)+V^(K)

Γ\GammaΓ的计算需要涉及二阶导数,我们暂时不考虑。

PyTorch的神经网络计算框架中存储了模型参数,以及损失函数对于模型参数的导数,而我们需要的是输出值对于输入参数的导数,可以进行简单的运算转换得到。假设输入向量为X\mathbf{X}X,输出值为yyy,从输入层向第一个隐层有仿射变换

Z=WTX+b\mathbf{Z} = \mathbf{W}^{T}\mathbf{X} + bZ=WTX+b

考虑单个样本的损失函数

L(y^,y)=y^−yL(\hat{y}, y) = \hat{y} - yL(y^,y)=y^y

两边对X\mathbf{X}X进行求导并参考链式法

∂L∂X=∂y^∂X,\frac{\partial L}{\partial \mathbf{X}} = \frac{\partial \hat {y}}{\partial \mathbf{X}},XL=Xy^,

∂L∂ZW=∂y^∂X.\frac{\partial L}{\partial \mathbf{Z}}\mathbf{W} = \frac{\partial \hat {y}}{\partial \mathbf{X}}.ZLW=Xy^.

左边需要的导数可以通过一次反向传播得到(不要更新模型参数),和转移矩阵相乘就得到了需要的导数∂y^∂X\frac{\partial \hat {y}}{\partial \mathbf{X}}Xy^

Γ\GammaΓ怎么办?难搞。回到一个基础的问题,我们是否要将希腊字母放入训练目标中?可能根本就不需要。第一,训练希腊字母会增加计算量,无论是参数数目上,还是收敛难度上(许多希腊字母呈现出各种陡峭的变化趋势);第二,借助反向传播算法,我们可以快捷地计算出一些希腊字母(可以拿来吹牛逼的AAD),如前所示;第三,希腊字母的定义多变,比如说隐含波动率曲面存在下的υ\upsilonυ,不同Sticky Rule下的Δ\DeltaΔ,一天的Θ\ThetaΘ,高阶值,等等。但是,只要我们有不同情景下的VVV(若干次神经网络的前向传播),就可以通过“万金油”的差分办法算出它们,如果VVV算得足够精确,希腊字母值也可以算到不错的精度。因此,只关注对VVV的训练是一个不错的选择,在下文中,我们的目标值都只有VVV

我们看一下数值结果,如下

-VVVΔ\DeltaΔΓ\GammaΓυ\upsilonυΘ\ThetaΘρ\rhoρ
MSEfullMSE_{full}MSEfull5.07×10−55.07\times10^{-5}5.07×1051.58×10−41.58\times10^{-4}1.58×1042.67×10−12.67\times10^{-1}2.67×1011.23×10−41.23\times10^{-4}1.23×1041.24×10−31.24\times10^{-3}1.24×1031.74×10−41.74\times10^{-4}1.74×104
MSEaadMSE_{aad}MSEaad7.66×10−77.66\times10^{-7}7.66×1074.44×10−44.44\times10^{-4}4.44×104-3.98×10−43.98\times10^{-4}3.98×1044.02×10−44.02\times10^{-4}4.02×1049.33×10−49.33\times10^{-4}9.33×104

不出意外,在相同的训练集和测试集下,只训练VVV得到的MSEaadMSE_{aad}MSEaad小了两个量级,通过AAD计算得到的希腊字母也能和直接训练保持相当的精度。抱歉这里没有用有限差分计算Γ\GammaΓ,有兴趣的可以自行计算。

1.4 代码

代码如下,C++计算导出的CSV文件比较大就不提供了,很容易自己生成。我是Mac下运行的,自行切换CUDA。比较耗时间的训练,更耗时间的是逐个样本的通过AAD计算测试集,但也能控制在5个小时内,自行把握吧。

european_call_full.py

import math
import time

import numpy as np
from sklearn.model_selection import train_test_split
from torch import nn, optim
import torch
from torch.utils.data import TensorDataset, DataLoader
import scipy.stats as stats
import pandas as pd

# 设置设备
device = torch.device('mps' if torch.backends.mps.is_available() else 'cpu')


# Black-Scholes 欧式看涨期权模型
def black_scholes_call(K, r, T, sigma):
    S = 1  # 标的价格固定为1
    d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * math.sqrt(T))
    d2 = d1 - sigma * math.sqrt(T)

    call_price = S * stats.norm.cdf(d1) - K * math.exp(-r * T) * stats.norm.cdf(d2)
    delta = stats.norm.cdf(d1)
    gamma = stats.norm.pdf(d1) / (S * sigma * math.sqrt(T))
    vega = S * stats.norm.pdf(d1) * math.sqrt(T) / math.sqrt(2 * math.pi)
    theta = -S * gamma * sigma / (2 * math.sqrt(T)) - r * K * math.exp(-r * T) * stats.norm.cdf(d2)
    rho = K * math.exp(-r * T) * T * stats.norm.cdf(d2)

    return call_price, delta, gamma, vega, theta, rho


class WeightedMSELoss(nn.Module):
    def __init__(self, weight=None, reduction='mean'):
        super(WeightedMSELoss, self).__init__()
        self.weight = weight
        self.reduction = reduction

    def forward(self, input, target):
        # 计算MSE损失
        loss = (input - target) ** 2

        # 如果提供了权重,则应用权重
        if self.weight is not None:
            assert len(self.weight) == loss.size(-1), "Weight dimension must match the last dimension of the input"
            # loss = loss * self.weight
            loss = loss * self.weight

        # 根据reduction参数处理损失
        if self.reduction == 'none':
            return loss
        elif self.reduction == 'mean':
            return loss.mean()
        elif self.reduction == 'sum':
            return loss.sum()
        else:
            raise ValueError("Invalid reduction mode: {}".format(self.reduction))


# 构建神经网络模型
class OptionPricingNN(nn.Module):
    def __init__(self):
        super(OptionPricingNN, self).__init__()
        self.layers = nn.Sequential(
            nn.Linear(4, 50),
            nn.ReLU(),
            nn.Linear(50, 50),
            nn.ReLU(),
            nn.Linear(50, 6)
        )

    def forward(self, x):
        return self.layers(x)


if __name__ == "__main__":
    # K_range = np.linspace(0.5, 1.5, 101)
    # r_range = np.linspace(0.0, 0.1, 11)
    # T_range = np.linspace(0.01, 2.01, 201)
    # sigma_range = np.linspace(0.01, 1.01, 101)
    # 生成训练样本
    # K_grid, r_grid, T_grid, sigma_grid = np.meshgrid(K_range, r_range, T_range, sigma_range)
    # params = np.vstack((K_grid.ravel(), r_grid.ravel(), sigma_grid.ravel(), T_grid.ravel())).T
    # results = np.array([black_scholes_call(*param) for param in params])

    params = pd.read_csv('european/strike_rate_term_sigma.csv', index_col=0).values
    results = pd.read_csv('european/npv_delta_gamma_vega_theta_rho.csv', index_col=0).values

    filtered_rows = ((np.log(params[:, 0]) < (params[:, 1] - 0.5 * params[:, 3]**2) * params[:, 2]
                      + 3 * params[:, 3] * np.sqrt(params[:, 2]))
                   & (np.log(params[:, 0]) > (params[:, 1] - 0.5 * params[:, 3]**2) * params[:, 2]
                      - 3 * params[:, 3] * np.sqrt(params[:, 2])))
    params = params[filtered_rows]
    results = results[filtered_rows]
    print(results.shape)

    # 划分训练集和测试集
    X_train, X_test, y_train, y_test = train_test_split(params, results, test_size=0.1, random_state=2024)

    # 转换数据为张量
    X_train = torch.tensor(X_train.astype(np.float32)).to(device)
    y_train = torch.tensor(y_train.astype(np.float32)).to(device)
    X_test = torch.tensor(X_test.astype(np.float32)).to(device)
    y_test = torch.tensor(y_test.astype(np.float32)).to(device)

    # 构建TensorDataset和DataLoader
    train_dataset = TensorDataset(X_train, y_train)
    test_dataset = TensorDataset(X_test, y_test)

    batch_size = 64
    train_loader = DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True)
    test_loader = DataLoader(dataset=test_dataset, batch_size=batch_size, shuffle=False)

    # 初始化模型、损失函数和优化器
    model = OptionPricingNN().to(device)
    weight = torch.tensor([1.0, 1.0, 0.01, 1.0, 0.01, 1.0]).to(device)
    weight = weight / weight.sum()
    criterion = WeightedMSELoss(weight=weight)
    optimizer = optim.Adam(model.parameters(), lr=0.001)

    print("Training begins: ")

    # 训练模型
    epochs = 10
    for epoch in range(epochs):
        model.train()  # 设置模型为训练模式
        running_loss = 0.0
        for batch_idx, (data, target) in enumerate(train_loader):
            optimizer.zero_grad()
            output = model(data)
            loss = criterion(output, target)
            loss.backward()
            optimizer.step()
            running_loss += loss.item()

        print(f'Epoch {epoch + 1}/{epochs}, Loss: {running_loss / len(train_loader)}')

    torch.save(model.state_dict(), 'european/model_weights_4_50_50_6.pth')

    #model.load_state_dict(torch.load('european/model_weights_4_50_50_6.pth', map_location=torch.device(device)))

    # 在测试集上评估模型
    print("Evaluation begins: ")
    start_time = time.time()  # 记录开始时间

    model.eval()  # 设置模型为评估模式
    criterion2 = WeightedMSELoss(weight=weight, reduction='sum')
    criterion3 = nn.MSELoss(size_average=None, reduce=None, reduction='sum')

    test_loss, npv_loss, delta_loss, gamma_loss, vega_loss, theta_loss, rho_loss = 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0

    with torch.no_grad():
        for data, target in test_loader:
            output = model(data)
            test_loss += criterion2(output, target).item()

            npv_loss += criterion3(output[:, [0]], target[:, [0]]).item()
            delta_loss += criterion3(output[:, [1]], target[:, [1]]).item()
            gamma_loss += criterion3(output[:, [2]], target[:, [2]]).item()
            vega_loss += criterion3(output[:, [3]], target[:, [3]]).item()
            theta_loss += criterion3(output[:, [4]], target[:, [4]]).item()
            rho_loss += criterion3(output[:, [5]], target[:, [5]]).item()

        print(f'Test  Loss: {test_loss / len(test_dataset)}')
        print(f'NPV   Loss: {npv_loss / len(test_dataset)}')
        print(f'Delta Loss: {delta_loss / len(test_dataset)}')
        print(f'Gamma Loss: {gamma_loss / len(test_dataset)}')
        print(f'Vega  Loss: {vega_loss / len(test_dataset)}')
        print(f'Theta Loss: {theta_loss / len(test_dataset)}')
        print(f'Rho   Loss: {rho_loss / len(test_dataset)}')

    end_time = time.time()  # 记录结束时间

    print("执行时间:", end_time - start_time)

european_call_aad.py

import time

import numpy as np
from sklearn.model_selection import train_test_split
from torch import nn, optim
import torch
from torch.utils.data import TensorDataset, DataLoader
import pandas as pd

# 设置设备
device = torch.device('mps' if torch.backends.mps.is_available() else 'cpu')


# 构建神经网络模型
class OptionPricingNN(nn.Module):
    def __init__(self, width=50):
        super(OptionPricingNN, self).__init__()
        self.width = width
        self.fc1 = nn.Linear(4, width)
        self.layers = nn.Sequential(
            nn.ReLU(),
            nn.Linear(width, width),
            nn.ReLU(),
            nn.Linear(width, 1)
        )

    def forward(self, x):
        x = self.fc1(x)
        x = self.layers(x)

        return x


class DiffLoss(nn.Module):
    def __init__(self):
        super(DiffLoss, self).__init__()

    def forward(self, input, target):
        # 计算MSE损失
        loss = input - target

        return loss


if __name__ == "__main__":
    params = pd.read_csv('european/strike_rate_term_sigma.csv', index_col=0).values
    results = pd.read_csv('european/npv_delta_gamma_vega_theta_rho.csv', index_col=0).values

    filtered_rows = ((np.log(params[:, 0]) < (params[:, 1] - 0.5 * params[:, 3]**2) * params[:, 2]
                      + 3 * params[:, 3] * np.sqrt(params[:, 2]))
                   & (np.log(params[:, 0]) > (params[:, 1] - 0.5 * params[:, 3]**2) * params[:, 2]
                      - 3 * params[:, 3] * np.sqrt(params[:, 2])))
    params = params[filtered_rows]
    results = results[filtered_rows]

    print(results.shape)

    # 划分训练集和测试集
    X_train, X_test, y_train, y_test = train_test_split(params, results, test_size=0.1, random_state=2024)

    # 转换数据为张量
    X_train = torch.tensor(X_train.astype(np.float32)).to(device)
    y_train = torch.tensor(y_train.astype(np.float32)[:, [0]]).to(device)

    X_test = torch.tensor(X_test.astype(np.float32), requires_grad=True).to(device)
    y_test = torch.tensor(y_test.astype(np.float32)).to(device)

    # 构建TensorDataset和DataLoader
    train_dataset = TensorDataset(X_train, y_train)
    test_dataset = TensorDataset(X_test, y_test)

    batch_size = 64
    train_loader = DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True)
    test_loader = DataLoader(dataset=test_dataset, batch_size=1, shuffle=False)

    # 初始化模型、损失函数和优化器
    model = OptionPricingNN().to(device)
    criterion = nn.MSELoss()
    optimizer = optim.Adam(model.parameters(), lr=0.001)

    # print("Training begins: ")
    #
    # # 训练模型
    # epochs = 10
    # for epoch in range(epochs):
    #     model.train()  # 设置模型为训练模式
    #     running_loss = 0.0
    #     for batch_idx, (data, target) in enumerate(train_loader):
    #         optimizer.zero_grad()
    #         output = model(data)
    #         loss = criterion(output, target)
    #         loss.backward()
    #         optimizer.step()
    #         running_loss += loss.item()
    #
    #     print(f'Epoch {epoch + 1}/{epochs}, Loss: {running_loss / len(train_loader)}')

    # torch.save(model.state_dict(), 'european/model_weights_4_50_50_1.pth')

    model.load_state_dict(torch.load('european/model_weights_4_50_50_1.pth', map_location=torch.device(device), weights_only=True))

    # 在测试集上评估模型
    print("Evaluation begins: ")
    start_time = time.time()  # 记录开始时间

    model.eval()
    criterion2 = DiffLoss()

    npv_loss, delta_loss, vega_loss, theta_loss, rho_loss = 0.0, 0.0, 0.0, 0.0, 0.0
    counter = 0

    for data, target in test_loader:
        if counter % 10000 == 0:
            print(counter)
        counter = counter + 1

        data, target = data.to(device), target.to(device)

        x1 = model.fc1(data).to(device)
        x1.retain_grad()
        output = model.layers(x1)

        loss = criterion2(output, target[:, [0]])
        npv_loss += loss.item()**2

        model.zero_grad()
        loss.backward()

        first_derivatives = torch.mm(x1.grad, model.fc1.weight)

        # 计算希腊字母
        delta = -data[0, 0] * first_derivatives[0, 0] + output[0, 0]
        vega = first_derivatives[0, 3]
        theta = -first_derivatives[0, 2]
        rho = first_derivatives[0, 1]

        target_delta = target[0, 1]
        target_vega = target[0, 3]
        target_theta = target[0, 4]
        target_rho = target[0, 5]

        delta_loss = delta_loss + (delta - target_delta) ** 2
        vega_loss = vega_loss + (vega - target_vega) ** 2
        theta_loss = theta_loss + (theta - target_theta) ** 2
        rho_loss = rho_loss + (rho - target_rho) ** 2

    print(f'NPV   Loss: {npv_loss / len(test_dataset)}')
    print(f'Delta Loss: {delta_loss / len(test_dataset)}')
    print(f'Vega  Loss: {vega_loss / len(test_dataset)}')
    print(f'Theta Loss: {theta_loss / len(test_dataset)}')
    print(f'Rho   Loss: {rho_loss / len(test_dataset)}')

    end_time = time.time()  # 记录结束时间
    print("执行时间:", end_time - start_time)

2. 局部波动率模型

实际当中,波动率是一个TTT和行权价KKK的函数,即所谓的隐含波动率曲面σ(T,K)\sigma(T,K)σ(T,K),由此推出局部波动率曲面σLV(T,K)\sigma_{LV}(T,K)σLV(T,K),假设标的价格满足随机微分方程

dS=rSdt+σLV(t,S)SdWtdS = rSdt + \sigma_{LV}(t,S)SdW_tdS=rSdt+σLV(t,S)SdWt

这就是局部波动率模型,这玩意儿的特点就是模拟到任何一个期限产生的价格分布与隐含波动率曲面所蕴含的风险中性下的概率分布(转化为欧式期权价格,用Butter Fly组合逼近)是一致的。

2.1 模型

对于欧式期权来说,我们可以直接将隐含波动率σ(T,K)\sigma(T,K)σ(T,K)代入Black-Scholes公式进行计算,在这里我们仍然将其归为局部波动率模型,并不是说我要给欧式期权上MC或PDE,最后跑出一个跟解析解一致的玩意儿沾沾自喜(但那是一个很好的练习)。对于路径依赖强的奇异期权,我们就不得不那么做了,但是输入还是σ(T,K)\sigma(T,K)σ(T,K)σLV(t,S)\sigma_{LV}(t,S)σLV(t,S)则是on-the-fly式的生成的,我假设你已经知道如何从隐含波动率曲面推出局部波动率曲面。

对于隐含波动率曲面的生成,经典的做法是将场内期权的报价转化成隐含波动率,对离散的点进行拟合或插值生成完整的波动率曲面。如果采用神经网络,我们恐怕得将上节中的σ\sigmaσ扩充为一组刻画曲面的数字。我们分权益类和外汇类期权两种分别考虑。

外汇类期权市场数据,每一个点都是某个特定期限TTTΔ\DeltaΔ(这个遵循惯例的,跟你想要的那个可能不一样)上的隐含波动率值σ(T,Δ)\sigma(T,\Delta)σ(T,Δ),任意时刻的报价期限和Δ\DeltaΔ都是固定的,因此可以将二维的矩阵展成一维,与其他输入参数一起连成一个输入向量参与模型训练,对任何一个单点σ(T,Δ)\sigma(T,\Delta)σ(T,Δ)测算敏感度,我们就得到了Bucket Vega,天选之子。

权益类期权市场数据,每一个点都是某个特定期限TTT和行权价KKK上的看涨期权和看跌期权报价构成,在处理当中会结合远期价和虚值期权价格反推出σ(T,K)\sigma(T,K)σ(T,K),但是,(T,K)(T,K)(T,K)的数目和分布并不规则。我们可以借鉴外汇期权的情形进行预处理,基于σ(T,K)\sigma(T,K)σ(T,K)先构造出完整的隐含波动率曲面(通常是先在KKK维度上进行样条插值,SVI或SABR拟合,然后在TTT维度上对σ2T\sigma^2 Tσ2T进行线性插值),然后获取一个固定网格上(由一组固定期限和一组固定行权价构成)的隐含波动率矩阵再进行训练。这里会有几个值得考虑的问题,

  • 计算时(比如我们为了准备训练目标时做的),在规范网格上二次插值产生的隐含波动率大概率无法复现市场数据值(选取一个较密的规范网格可以减小这种误差,但是会需要更大的计算量),这是一个值得评估的模型误差。
  • 如果我们在KKK维度上选择的是SVI或SABR拟合,那么每一条波动率微笑曲线的决定参数只有5个或更少,因此完全可以考虑将曲线拟合放在数据预处理环节,不同期限上的曲线参数放入模型输入向量。
  • 前面的困扰其实都来自于我们对于训练数据的准备上,但是如果我们有了海量的历史数据,我们是不是连波动面插值都不需要了,期权参数也不需要自己预设了,直接拿来训练。外汇可能可以(当然,别在欧式期权上浪费时间了),但权益类怎么办?我目前没想到。

我们下面遵循上面的第二个思路处理权益类期权。

由于输入向量的大幅延长,在目标值的准备上,需要的计算量都要大的多,但是这种大规模的计算储备对于日后的应用来说是划得来的。做个比方,这好比我们尽可能多的算出了各种市场参数搭配期权参数下的估值,我们把这些数据存了下来并进行了一种拟合(注意:这绝不是插值),这种拟合可能要比我们直接进行高维空间插值更平滑和接近模型内在,最后,这种拟合要比常规的数值算法(MC或PDE)算起来更快。

2.2 SVI拟合

SVI(Stochastic Volatility Inspired)是由Gatheral提出的一种波动率微笑拟合方式,在权益期权上用的比较多。在有市场报价的期限TTT上,令k=ln(K/F)k=ln(K/F)k=ln(K/F)(FFF表示远期价),我们称之为log−foward−moneynesslog-foward-moneynesslogfowardmoneyness,拟合出波动率微笑曲线

σ2(T,k)=a+b[ρ(k−m)+(k−m)2+σ2]\sigma^2(T,k) = a + b\Big[\rho(k-m) + \sqrt{(k-m)^2+\sigma^2}\Big]σ2(T,k)=a+b[ρ(km)+(km)2+σ2]

也有些人会把Tσ2(T,k)T\sigma^2(T,k)Tσ2(T,k)作为拟合对象,可以看出,它一共有5个参数[a,b,ρ,m,σ][a, b, \rho, m, \sigma][a,b,ρ,m,σ],这5个参数可以转换成更有直觉意义的平值波动率,平值SkewSkewSkew,平值CurvatureCurvatureCurvature,左极限SkewSkewSkew,右极限SkewSkewSkew(有兴趣自己推导去)。当市场上有多个报价期限的时候,我们拟合出多组参数。为了避免KKK方向上的无风险套利,参数之间满足下列关系,

b>0,b > 0,b>0,

σ≥0,\sigma \geq 0,σ0,

∣ρ∣≤1,|\rho| \leq 1,ρ1,

a≥−bσ1−ρ2,a \geq -b\sigma\sqrt{1-\rho^{2}},a1ρ2,

b≤4(1+∣ρ∣)T.b \leq \frac{4}{(1+|\rho|)T}.b(1+ρ)T4.

我们现在假设市场上只有一个期限(这时候已经无所谓它是哪个期限上的了),这样做的目的主要还是出于训练效率,并且,在点抽样的时候我们也不会受到期限无风险套利带来的参数限制。再简化,我们令a=0.01,b=1.0,σ=0.05a = 0.01, b = 1.0, \sigma = 0.05a=0.01,b=1.0,σ=0.05,令无风险利率r=0r=0r=0。现在,我们的模型输入向量变成了[K,T,ρ,m][K, T, \rho, m][K,T,ρ,m]。我们生成均匀的空间网格linspace(0.5,1.5,101)×linspace(0.01,1.01,101)×linspace(−0.7,0.3,51)×linspace(−0.3,0.3,61)linspace(0.5, 1.5, 101) \times linspace(0.01, 1.01, 101) \times linspace(-0.7, 0.3, 51) \times linspace(-0.3, 0.3, 61)linspace(0.5,1.5,101)×linspace(0.01,1.01,101)×linspace(0.7,0.3,51)×linspace(0.3,0.3,61),这样,总样本点数大概3000万,用C++解析地算出VVVΔSticky−Moneyess\Delta_{Sticky-Moneyess}ΔStickyMoneyessΔSticky−Strike\Delta_{Sticky-Strike}ΔStickyStrike(定义见下文),和Θ\ThetaΘ,并存下来。随机选择其中的90%为训练集,余下的为测试集。

考虑一个欧式期权,当市场数据SSS发生变化,它的moneynessmoneynessmoneyness(K/SK/SK/S)一定是变化的,kkk也是变化的,用变化的kkk从上面的式子中去索取隐含波动率,就是Sticky-Moneyness的观点:我只认moneyneesmoneyneesmoneynees。我们有时也习惯于将隐含波动率写成σ(T,K)\sigma(T,K)σ(T,K),期权的KKK并没有变,那隐含波动率就不变,该吃吃,该喝喝,这就是Sticky-Strike的观点,这种观点下的Δ\DeltaΔΓ\GammaΓ就是我们简单的将隐含波动率代入Black-Scholes公式下得到的。

事实上,当我们采用Sticky-Strike的时候,SVI的参数似乎要重新拟合了,因为市场上报价的每个kkk都变了,但如果“货也要,钱又不想给”怎么办?好办,所有的kkk都发生了相同方向和大小的偏移,那么波动率微笑曲线无非就是发生一点左右平移,这可以通过调整SVI的参数mmm来实现。让我们来计算一下两种观点之下的Δ\DeltaΔ,仍然假设SSS111变化到1+ϵ1+\epsilon1+ϵ

ΔSticky−Moneyness=lim⁡ϵ→0(1+ϵ)V^(K/(1+ϵ),m)−V^(K,m)ϵ=−K⋅∂V^(K,m)∂K+V^(K,m),\Delta_{Sticky-Moneyness} = \lim_{\epsilon \to 0} \frac{(1+\epsilon)\hat{V}(K/(1+\epsilon), m) - \hat{V}(K, m)}{\epsilon} = -K \cdot \frac{\partial \hat{V}(K, m)}{\partial K} + \hat{V}(K, m),ΔStickyMoneyness=limϵ0ϵ(1+ϵ)V^(K/(1+ϵ),m)V^(K,m)=KKV^(K,m)+V^(K,m),

ΔSticky−Strike=lim⁡ϵ→0(1+ϵ)V^(K/(1+ϵ),m−ln(1+ϵ))−V^(K,m)ϵ=−K⋅∂V^(K,m)∂K+V^(K,m)−∂V^(K,m)∂m,\Delta_{Sticky-Strike} = \lim_{\epsilon \to 0} \frac{(1+\epsilon)\hat{V}(K/(1+\epsilon), m-ln(1+\epsilon)) - \hat{V}(K, m)}{\epsilon} = -K \cdot \frac{\partial \hat{V}(K, m)}{\partial K} + \hat{V}(K, m)- \frac{\partial \hat{V}(K, m)}{\partial m} ,ΔStickyStrike=limϵ0ϵ(1+ϵ)V^(K/(1+ϵ),mln(1+ϵ))V^(K,m)=KKV^(K,m)+V^(K,m)mV^(K,m),

kkk变化了−ln(1+ϵ)-ln(1+\epsilon)ln(1+ϵ),为了取到不变的隐含波动率满足Sticky-Strike要求,mmm就得发生相应的调整。可见,两种Δ\DeltaΔ都可以用AAD的方法来计算了。

2.3 数值

选择的神经网络大小为4×50×50×14 \times 50 \times 50 \times 14×50×50×1,学习率设置为0.001,在训练集上前后传播10次。结果如下

VVVΔSticky−Strike\Delta_{Sticky-Strike}ΔStickyStrikeΔSticky−Moneyness\Delta_{Sticky-Moneyness}ΔStickyMoneynessΘ\ThetaΘ
MSEMSEMSE6.25×10−76.25\times10^{-7}6.25×1075.51×10−45.51\times10^{-4}5.51×1048.92×10−48.92\times10^{-4}8.92×1043.75×10−43.75\times10^{-4}3.75×104

算的稍微有点慢(我用的Air),连训练带测试大约耗时24小时。欣慰的是,结果算是挺完美了。

2.4 代码

老规矩,csv文件自己用C++去准备。

european_call_svi.py

import math
import time
import scipy.stats as stats

import numpy as np
from sklearn.model_selection import train_test_split
from torch import nn, optim
import torch
from torch.utils.data import TensorDataset, DataLoader
import pandas as pd

# 设置设备
device = torch.device('mps' if torch.backends.mps.is_available() else 'cpu')


def black_scholes_call(K, T, rho, m):
    S = 1
    r = 0
    a = 0.01
    b = 1.0
    sigma = 0.05

    F = S * math.exp(r*T)
    k = math.log(K / F)
    x = k - m

    var = a + b * (rho * x + math.sqrt(x * x + sigma * sigma))
    vol = math.sqrt(var)

    d1 = (math.log(S / K) + (r + 0.5 * var) * T) / (vol * math.sqrt(T))
    d2 = d1 - vol * math.sqrt(T)

    call_price = S * stats.norm.cdf(d1) - K * math.exp(-r * T) * stats.norm.cdf(d2)
    delta_ss = stats.norm.cdf(d1)
    vega = S * stats.norm.pdf(d1) * math.sqrt(T) / math.sqrt(2 * math.pi)
    theta = -S * vol * vol / (2 * math.sqrt(T)) - r * K * math.exp(-r * T) * stats.norm.cdf(d2)

    dvar_dk = b * (rho + x / math.sqrt(x * x + sigma * sigma))
    dvol_dk = dvar_dk / (2.0 * vol)
    delta_sm = delta_ss - vega * dvol_dk / S

    return call_price, delta_ss, delta_sm, theta


# 构建神经网络模型
class OptionPricingNN(nn.Module):
    def __init__(self, width=50):
        super(OptionPricingNN, self).__init__()
        self.width = width
        self.fc1 = nn.Linear(4, width)
        self.layers = nn.Sequential(
            nn.ReLU(),
            nn.Linear(width, width),
            nn.ReLU(),
            nn.Linear(width, 1)
        )

    def forward(self, x):
        x = self.fc1(x)
        x = self.layers(x)

        return x


class DiffLoss(nn.Module):
    def __init__(self):
        super(DiffLoss, self).__init__()

    def forward(self, input, target):
        # 计算MSE损失
        loss = input - target

        return loss


if __name__ == "__main__":
    params = pd.read_csv('european/strike_term_rho_m_svi.csv', index_col=0).values
    results = pd.read_csv('european/npv_ss_sm_theta_svi.csv', index_col=0).values

    print(results.shape)

    # 划分训练集和测试集
    X_train, X_test, y_train, y_test = train_test_split(params, results, test_size=0.1, random_state=2024)

    # 转换数据为张量
    X_train = torch.tensor(X_train.astype(np.float32)).to(device)
    y_train = torch.tensor(y_train.astype(np.float32)[:, [0]]).to(device)

    X_test = torch.tensor(X_test.astype(np.float32), requires_grad=True).to(device)
    y_test = torch.tensor(y_test.astype(np.float32)).to(device)

    # 构建TensorDataset和DataLoader
    train_dataset = TensorDataset(X_train, y_train)
    test_dataset = TensorDataset(X_test, y_test)

    batch_size = 64
    train_loader = DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True)
    test_loader = DataLoader(dataset=test_dataset, batch_size=1, shuffle=False)

    # 初始化模型、损失函数和优化器
    model = OptionPricingNN().to(device)
    criterion = nn.MSELoss()
    optimizer = optim.Adam(model.parameters(), lr=0.001)

    print("Training begins: ")

    epochs = 10
    for epoch in range(epochs):
        model.train()  # 设置模型为训练模式
        running_loss = 0.0
        for batch_idx, (data, target) in enumerate(train_loader):
            optimizer.zero_grad()
            output = model(data)
            loss = criterion(output, target)
            loss.backward()
            optimizer.step()
            running_loss += loss.item()

        print(f'Epoch {epoch + 1}/{epochs}, Loss: {running_loss / len(train_loader)}')

    torch.save(model.state_dict(), 'european/model_weights_4_50_50_1_svi.pth')

    # model.load_state_dict(torch.load('european/model_weights_4_50_50_1_svi.pth', map_location=torch.device(device), weights_only=True))

    # 在测试集上评估模型
    print("Evaluation begins: ")
    start_time = time.time()  # 记录开始时间

    model.eval()
    criterion2 = DiffLoss()

    npv_loss, delta_ss_loss, delta_sm_loss, theta_loss = 0.0, 0.0, 0.0, 0.0
    counter = 0

    for data, target in test_loader:
        if counter % 10000 == 0:
            print(counter)
        counter = counter + 1

        data, target = data.to(device), target.to(device)

        x1 = model.fc1(data).to(device)
        x1.retain_grad()
        output = model.layers(x1)

        loss = criterion2(output, target[:, [0]])
        npv_loss += loss.item()**2

        model.zero_grad()
        loss.backward()

        first_derivatives = torch.mm(x1.grad, model.fc1.weight)

        # 计算希腊字母
        delta_sm = -data[0, 0] * first_derivatives[0, 0] + output[0, 0]
        delta_ss = delta_sm - first_derivatives[0, 3]
        theta = -first_derivatives[0, 1]

        target_delta_ss = target[0, 1]
        target_delta_sm = target[0, 2]
        target_theta = target[0, 3]

        delta_ss_loss = delta_ss_loss + (delta_ss - target_delta_ss) ** 2
        delta_sm_loss = delta_sm_loss + (delta_sm - target_delta_sm) ** 2
        theta_loss = theta_loss + (theta - target_theta) ** 2

    print(f'NPV     Loss: {npv_loss / len(test_dataset)}')
    print(f'DeltaSS Loss: {delta_ss_loss / len(test_dataset)}')
    print(f'DeltaSM Loss: {delta_sm_loss / len(test_dataset)}')
    print(f'Theta   Loss: {theta_loss / len(test_dataset)}')

    end_time = time.time()  # 记录结束时间
    print("执行时间:", end_time - start_time)

3. 总结

我们将简单神经网络应用在了欧式期权的估值上,分别基于Black-Scholes模型和局部波动率模型,采用AAD的思想来计算一些希腊字母,这是个很有意思的尝试,也让我们似乎看到了一线在复杂结构上应用的曙光。

慢着,还是得想清楚。首先,我们该如何规范市场行情和合约参数来作为输入。关于市场行情,前面提到了权益类的隐含波动率曲面就是个麻烦。至于合约参数,那会更让人挠头,了解一点具体的场外期权合约就知道了。其次,训练数据的准备,市场报价噪声太多,我们恐怕还是得调基础库来算,这个计算量不会小,有些产品没什么交易量,不值得去为之训练。用GPU来跑蒙特卡洛可能是一个不错的选择,可以先让ChatGPT写一个简单的例子再慢慢攒。总之,别为了神经网络发神经。要是前面的问题都解决了,那就像调鸡尾酒一样调调模型参数吧。我目前能想到的最适合深度学习来训练的对象应该是外汇的障碍和触碰期权,那玩意儿结构相对固定,交易量大,讲究一点的都会用局部随机波动率(Local Stochastic Volatility Model)模型来做,而那是一个恶心的双因子模型,计算相当耗时。

关于ChatGPT,确实是一个很有用的工具,有一些想法可以立马去尝试,以前我们很多人是面向百度编程,现在得益于技术的发展升级了。但最后的结果还是靠人为干涉,这世界怎么能没有可爱的程序员呢?

期权定价模型与其捕捉标的现货价格过程动态的能力有关。 它的错误指定将导致定价和对冲错误。 参数定价公式取决于标的资产动态的特定形式。 出于易处理性的原因,做出了一些与市场回报的多重分形性质不一致的假设。 另一方面,神经网络等非参数模型使用市场数据来估计驱动现货价格的隐式随机过程及其与或有债权的关系。 在为多维或有债权,甚至是具有复杂模型的普通期权定价时,必须依赖于偏微分方程等数值方法、傅里叶方法等数值积分方法或蒙特卡罗模拟。 此外,在根据市场价格校准金融模型时,必须生成大量模型价格以拟合模型参数。 因此,人们需要快速且准确的高效计算方法。 具有多个隐藏层的神经网络是具有表示任何平滑多维函数能力的通用插值器。 因此,监督学习关注的是解决函数估计问题。 网络被分解为两个独立的阶段,一个是离线优化模型的训练阶段,另一个是模型在线逼近解决方案的测试阶段。 因此,这些方法可以以快速而稳健的方式用于金融领域,用于为奇异期权定价以及根据内插/外推波动率表面来校准期权价格。 鉴于执行某些信用风险分析,它们还可用于风险管理以在投资组合级别拟合期权价格。 我们回顾了一些使用神经网络为市场和模型价格定价的现有方法,提出了校准,并介绍了奇异的期权定价。 我们讨论这些方法的可行性,突出问题,并提出替代解决方案。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值