上一篇文章“How to Accelerate Quantitative Finance with ISO C++ Standard Parallelism”(如何使用 ISO C++标准并行机制加速量化金融) 演示了如何使用 ISO C++标准并行机制和NVIDIA accelerated-quant-finance GitHub 库中找到的代码编写 Black-Scholes 模拟。这种方法使您能够高效地编写简洁且可移植的代码。
仅使用标准 C++,就可以编写可在现代多核 CPU 或 GPU 上并行运行的应用程序,而无需进行修改。本文从之前开发的 Black-Scholes 并行代码开始,构建了一个更复杂的模型,并对其进行了优化,以利用 GPU 的优势,同时保留标准 C++。
利润和损失建模说明
交易已实现波动性的热门策略是对期权持仓进行增量套期保值。根据 Black-Scholes 的假设,如果投资者成功套期保值了基础风险,则此策略的主要盈利和损失因素(P&L)与已实现波动性的平方与用于定价和套期保值的波动性之间的差值成比例。
市盈率取决于底层资产的路径。估算大型期权组合在给定水平线上的完整市盈率分布可能需要大量计算,因此需要扩展并行 Black-Scholes 代码。
考虑在同一底层资产上由各种执行力和到期日组成的多头欧洲看涨期权网格 。假设选项保持在给定的时间范围内 ( 时间步长),并在每个时间步长对其进行增量对冲 。
随着时间的推移,底层 移动时,每个期权的货币价值也会相应变化,到期时间也会越来越近。
对于给定的期权合约,权利金是多个参数的函数,其中包括 以及期权的剩余到期时间: 理论上,越短 移动的机会越少 .
假设所有参数随着时间推移保持不变,则选项会随着时钟的每个刻度而失去值。随着时间推移,选项值的这种负变化称为 theta 或时间衰减。
作为底层 随着时间的推移,选项的值也会发生变化。
首先,期权值的变化由增量选项的值。例如,如果增量为 0.55 并且 上涨 1 倍,然后期权价格也上涨约 0.55 倍。
其次, 移动,选项的增量也是如此,其数量与二阶希腊字母成比例.由于多头看涨期权是底层商品的凹凸函数,因此 gamma 为正数,而 gamma 带来的价格收益也为正数,无论底层商品的移动方向如何。
在增量套期保值选项的情况下,总增量 delta P&L 为零,而 gamma 增益有可能抵消、超过或承受由于 theta 而造成的损失 (图 1)。
在本示例中,目标是通过模拟底层资产的路径并沿这些路径累加 P&L,描述网格中每个选项在给定水平下的 gamma-theta P&L 分布。
在这个简单的 Black-Scholes 世界中,底层资产在风险中性测量下遵循对数正态动力学,并实现了波动性 :
每日 (或一次性步骤) 盈利和损失可通过以下方式获得:
在这个方程中, 和 是 gamma 和 theta Greeks, 使用套期保值波动率计算的时间步长 (在实践中,隐性波动率通常用作套期保值波动率)。
底层资产的单个路径上的损失,包括 时间步长是每日盈利和损失的累积:
并行 P&L 模拟
图 2 显示了选项网格和四个模拟路径。每个网格单元代表一个期权合约,其相应的货币性和成熟时间分别标记在水平轴和垂直轴上。热图中的颜色与这些路径中的平均 P&L 成正比。平均值只是一个统计量,可以从模拟的 P&L 中计算,该 P&L 可通过模拟获得完整分布。
上一篇文章中的并行代码用作基准。每个路径都循环遍历,然后行走,正如之前的示例中所做的那样,将选项的P&L计算并行化。
这是一种合理的方法,因为有可能有大量选项进行并行化。代码本身很简单,唯一的主要区别是在最后添加了一个transform,以将总和转换为均值。
但是,仍有机会进一步优化代码并提高性能。
void calculate_pnl_paths_sequential(stdex::mdspan<const double, stdex::dextents<size_t,2>> paths,
std::span<const double>Strikes,
std::span<const double>Maturities,
std::span<const double>Volatilities,
const double RiskFreeRate,
std::span<double>pnl,
const double dt)
{
int num_paths = paths.extent(0);
int horizon = paths.extent(1);
auto steps = std::views::iota(1,horizon);
// Iterate from 0 to num_paths - 1
auto path_itr = std::views::iota(0,num_paths);
// Note - In this version path remains in CPU memory
// Note - Also that when built for the GPU this will result in
// num_paths * (horizon - 1) kernel launches
std::for_each(path_itr.begin(), path_itr.end(),
[=](int path) // Called for each path from 0 to num_paths - 1
{
// Iterate from 1 to horizon - 1
std::for_each(steps.begin(), steps.end(),
[=](int step) // Called for each step along the chosen path
{
// Query the number of options from the pnl array
int optN = pnl.size();
// Enumerate from 0 to (optN - 1)
auto opts = std::views::iota(0,optN);
double s = paths(path,step);
double s_prev = paths(path,step-1);
double ds2 = s - s_prev;
ds2 *= ds2;
// Calculate pnl for each option
std::transform(std::execution::par_unseq, opts.begin(), opts.end(),
pnl.begin(), [=](int opt)
{
double gamma = 0.0, theta = 0.0;
BlackScholesBody(gamma,
s_prev,
Strikes[opt],
Maturities[opt] - std::max(dt*(step-1),0.0),
RiskFreeRate,
Volatilities[opt],
CALL,
GAMMA);
BlackScholesBody(theta,
s_prev,
Strikes[opt],
Maturities[opt] - std::max(dt*(step-1),0.0),
RiskFreeRate,
Volatilities[opt],
CALL,
THETA);
// P&L = 0.5 * Gamma * (dS)^2 + Theta * dt
return pnl[opt] + 0.5 * gamma * ds2 + (theta*dt);
});
});
});
}
提高并行性以提高性能
每当将并行算法卸载到 GPU 时,都会产生两种用度:
- 启动延迟:启动 GPU 内核的成本。
- 同步:并行算法相对于 CPU 是同步的,这意味着程序必须等待内核完成,然后再继续并启动下一个内核。
这两种开销都不是特别大,每次都只有一小部分秒,但当重复执行时,开销会增加。更糟糕的是,NVIDIA Nsight Systems 分析器显示,每个内核都需要比内核本身更长的设备同步步骤。
路径是独立的随机行走,除了底层计算的相同初始值之外,没有任何关系 。因此,您也可以跨路径并行化,前提是没有两个路径试图同时更新内存中的同一位置,这将是比赛条件.
要解决这种潜在的竞争状况,请使用 C++atomic_ref
以确保如果两条路径尝试同时更新 P&L 数组中的同一位置,它们将以安全的方式执行此操作。
通过将路径的迭代转移到函数中,现在可以在每个路径的路径和选项上实现并行化。虽然这个示例更复杂,但它本质上与为初始示例所做的重构相同。
void calculate_pnl_paths_parallel(stdex::mdspan<const double,
stdex::dextents<size_t,2>> paths,
std::span<const double>Strikes,
std::span<const double>Maturities,
std::span<const double>Volatilities,
const double RiskFreeRate,
std::span<double>pnl,
const double dt)
{
int num_paths = paths.extent(0);
int horizon = paths.extent(1);
int optN = pnl.size();
// Create an iota to enumerate the flatted index space of
// options and paths
auto opts = std::views::iota(0,optN*num_paths);
std::for_each(std::execution::par_unseq, opts.begin(), opts.end(),
[=](int idx)
{
// Extract path and option number from flat index
// C++23 cartesian_product would remove the need for below
int path = idx/optN;
int opt = idx%optN;
// atomic_ref prevents race condition on elements of pnl array.
std::atomic_ref<double> elem(pnl[opt]);
// Walk the path from 1 to (horizon - 1) in steps of 1
auto path_itr = std::views::iota(1,horizon);
// Transform_Reduce will apply the lambda to every option and perform
// a plus reduction to sum the PNL value for each option.
double pnl_temp = std::transform_reduce(path_itr.begin(), path_itr.end(),
0.0, std::plus{},
[=](int step) {
double gamma = 0.0, theta = 0.0;
double s = paths(path,step);
double s_prev = paths(path,step-1);
double ds2 = s - s_prev;
ds2 *= ds2;
// Options in the grid age as the simulation progresses
// along the path
double time_to_maturity = Maturities[opt] –
std::max(dt*(step-1),0.0);
BlackScholesBody(gamma,
s_prev,
Strikes[opt],
time_to_maturity,
RiskFreeRate,
Volatilities[opt],
CALL,
GAMMA);
BlackScholesBody(theta,
s_prev,
Strikes[opt],
time_to_maturity,
RiskFreeRate,
Volatilities[opt],
CALL,
THETA);
// P&L = 0.5 * Gamma * (dS)^2 + Theta * dt
return 0.5 * gamma * ds2 + (theta*dt);
});
// accumulate on atomic_ref to pnl array
elem.fetch_add(pnl_temp, std::memory_order_relaxed);
});
}
std::for_each
算法用于在路径和选项之间进行迭代。在每次迭代中,std::transform_reduce
算法用于遍历每个选项的每个路径,将利润和损失相加并返回该结果。然后,每个中间结果都会自动添加到 P&L 数组中。
此方法的主要优点是,无需在 GPU 和 CPU 之间反复来回反弹,而是在 GPU 上针对完整数据集启动单个操作,且程序仅需等待一次结果(图 3)。
这种方法的性能比原始版本显著提升,而原始版本本身已在 GPU 上加速(图 4)。
从第二个示例中汲取的经验是,尽可能多地展示硬件的并行性。第一种方法改进了 CPU 和 GPU 版本,但 GPU 版本在通过更多并行性减少启动和同步开销后确实非常出色。
探索代码
使用 NVIDIA accelerated-quant-finance GitHub 库中的代码在此量化金融示例中实现的加速可轻松应用于 C++ 应用程序。使用串行循环编写的任何 C++ 代码都可以使用标准语言并行轻松修改,以实现显著的 GPU 加速。
要轻松生成自己的可移植并行优先代码,请下载 NVIDIA HPC SDK,其中包含利用 ISO C++ 标准并行性并对结果进行分析的所有工具。