现代-C---金融学习指南-早期发布--全-

现代 C++ 金融学习指南(早期发布)(全)

原文:zh.annas-archive.org/md5/a51188c4d4cf55dbcc8576aaf0c72d75

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:C++ 概述

在深入学习 C++ 编程之前,介绍语言、C++ 标准库以及 C++ 在量化金融中继续占据主导地位的简要概述将非常有用。

你可能已经被声称 C++ 学习难度极大且充满陷阱的意见和传言所吓到。因此,在本章中,我们将试图首先揭穿关于 C++ 的一些常见神话,然后通过简单的示例帮助你快速上手。

大部分内容对大多数读者来说可能都很熟悉,但本节讨论尝试通过关于量化编程和通常不包括在入门书籍中的最佳实践来扩展一些基础知识。我们还将首次介绍 C++20,即已添加到 C++ 标准库中的数学常数。

通过本章的学习,你应该能够编写、编译和运行简单的 C++ 程序,理解基本的数值类型,并在标准库中应用数学函数,这些函数在包括金融在内的任何量化学科中都是基础。

C++ 和量化金融

C++ 在 1990 年代中期开始在金融领域迅速发展。在这个时候,许多从事该行业的人都是在 FORTRAN 上成长起来的,特别是用于编写数值例程和科学应用程序。虽然 FORTRAN 及其支持库在数学和线性代数支持方面非常成熟,但在面向对象编程方面却有所欠缺。

抽象中的金融建模自然由相互作用的不同组件组成。例如,即使是基于外汇和利率的简单衍生品合同的定价,通常也需要以下几点:

  • 每种货币的利率期限结构

  • 实时外汇汇率报价的市场利率信息源

  • 外汇汇率和利率的波动曲线或曲面

  • 一组定价方法,例如封闭形式、模拟或其他数值逼近方法

这些组件中的每一个都可以用一个对象来表示,而 C++ 则提供了创建这些对象和管理它们彼此关系的手段。

银行和其他金融机构还需要一种方法来计算区域和全球范围内的风险度量。对于在纽约、伦敦和东京等主要金融中心分布有交易业务的公司来说,这是一个特别的挑战,同时还要考虑本地和全球维护的投资组合在每个交易日开始时的风险报告。这可能是一个计算密集型的任务,但是 C++ 的性能使其成为可能,也是其在金融行业早期被广泛采用的另一个重要因素。

在世纪之交左右,新型面向对象语言如 Java 和 C#使软件开发变得相对简单快捷,同时更高效的处理器价格也变得更加合理。然而,这些语言中带来更快部署的特性,如内置管理内存和中间编译,也可能在运行时性能方面引入额外开销。管理决策往往需要在更快开发与运行时效率之间权衡选择。即使选择其中一种语言,计算密集型的定价模型和风险计算通常仍然会委托给现有的 C++库,并通过接口调用。还应注意,C++还提供了一些在其他编程语言中无法实现的编译时优化。

C++11:现代时代诞生

2011 年,标准 C++基金会发布了一项重大修订,解决了长期以来需要现代化的问题,特别是提供了一些非常受量化开发者欢迎的抽象。其中包括:

  • 从各种概率分布生成随机数

  • 封装数学函数的 Lambda 表达式,也可作为参数传递

  • 并行化计算的任务并发,无需手动线程管理

  • 智能指针可以防止与内存相关的程序崩溃,而不影响性能

这些主题及更多内容将在接下来的章节中讨论。还有一本出色的参考书籍,涵盖了 C++进入现代时代的历史和演变,来自 O’Reilly 出版社:《C++ Today: The Beast is Back》,作者为 Jon Kalb 和 Gasper Azman [1]。还应注意,通过 ISO C++委员会对最佳实践[1]和指南[2]的更多关注和推广,跨平台开发现在比过去容易得多。

随着 C++11 之后,每三年发布一次新版本,增加了越来越多面向金融和数据科学行业需求的现代特性,最新版本为 C++20。本书主要涵盖了截至 C++20 的发展,特别是对金融量化开发者感兴趣的内容。还提到了正在进行中的未来标准的建议。

私人和高频交易公司一直在积极采纳 C++11 及其后版本,因为在统计策略中对市场和交易簿信号做出反应的速度可能导致利润和损失上的深刻差异。现代 C++在投资银行和对冲基金的交易员和风险管理人员中,也急需用于衍生品定价模型。例如,标准库中最近增加的随机数生成和并发特性提供了内置支持,用于高效的蒙特卡洛模拟,这是评估交易策略和定价复杂异国期权的关键组成部分。过去,这些任务通常需要大量分布式随机数生成代码的开发工作和耗时的平台相关线程库的集成。

开源数学库

过去十年中另一个非常受欢迎的发展是,标准 C++编写的健壮的开源数学库大量涌现,因此不再需要过去那种耗时的 C 语言接口操作。主要包括 Boost 库、Eigen 和 Armadillo 矩阵代数库,以及 TensorFlow 和 PyTorch 等机器学习库。我们将在本书的后续部分详细介绍 Boost 和 Eigen。

拆解有关 C++的神话

关于 C++存在许多神话。以下是几个较为臭名昭著的信念,以及解释反驳它们。

  • 学习 C 是学习 C++的必要条件:尽管 C++标准保留了大部分 C 语言的特性,但完全可以在不了解 C 语言的情况下学习 C++,我们将看到这一点。固守于 C 风格实际上可能阻碍对 C++强大抽象和潜在优势的学习。

  • C++太难了:毫无疑问,C++是一门丰富的语言,提供了大量谚语式的绞索,可以让人自缢,但通过利用语言的现代特性,并在初期暂时搁置传统问题,完全可以在 C++中迅速成为一名非常高效的量化开发人员。

  • 内存泄漏在 C++中总是一个问题:自从 C++11 引入智能指针以来,在大多数金融模型实现中,这不再是一个问题,我们将会看到。

编译与解释代码

正如上文所暗示的,C++是一种编译语言,我们凡人在文件中键入的命令会被翻译成计算机处理器能理解的二进制指令,或者机器码。与 Python、R 和 Matlab 等非类型和解释性语言相比,这些语言中的每一行代码都必须在运行时单独翻译成机器码,因此会减慢大型应用程序的执行速度。

这绝不是对这些语言的贬低,因为它们的强大体现在它们在金融、数据科学和生物科学等量化领域中的流行度中,其内置的数学和统计函数通常是用 C、C++ 或 FORTRAN 编译的。然而,至少在金融界,有很多故事可以说明,一个模型在解释性语言中可能需要几天才能运行,而在 C++ 中重新实现后,运行时间可以缩短到几分钟。

一个有效的方法是以互补的方式将解释性数学语言与 C++ 结合使用。例如,当在 C++ 库中编写计算密集型模型代码,并且在 R 中的应用程序中以交互方式或调用方式调用时,C++ 可以有效地处理数值计算。然后,结果可以在 R 中强大的绘图和其他可视化工具中使用,这些工具在 C++ 中不可用。

另一个优点是,模型代码仅需编写一次,并在可以跨多个部门、部门甚至跨国界部署的 C++ 库中维护,并通过不同前端语言编写的应用程序接口调用,同时确保整个组织内数值结果的一致性。这对于符合监管合规要求尤为有利。

流行的开源 C++ 包分别适用于 R 和 Python,即 Rcpppybind11。Matlab 也提供了用于 C++ 接口的选项。

C++ 的组成部分

标准的 C++ 发行版,从高层面来看,由两个组成部分组成:语言特性和 C++ 标准库。软件库本质上是一组函数和类,它们不能单独执行,而是由应用程序或系统调用。与前几十年流行的独立应用程序相比,现代 C++ 开发中的库开发(包括开源和商业)现在占据主导地位,我们稍后将讨论一些对计算工作有用的库。最重要的 C++ 库是与现代编译器一起提供的标准库。

C++ 语言特性

C++ 语言特性大部分与其他编程语言中常见的基本运算符和结构重叠,比如:

  • 基本整数和浮点数数值类型

  • 条件分支:if/else if/else 语句和 switch/case 语句

  • 迭代结构:for 循环和 while 循环

  • 标准数学变量类型:整数、双精度浮点数等

  • 数值类型的标准数学和逻辑运算符:加法、减法、乘法、除法、模数和不等式

此外,C++并不仅限于面向对象编程;相反,语言还支持另外三种主要的编程范式,即过程式编程、泛型编程和函数式编程。这些将在随后的章节中讨论。

C++是一种强类型语言,这意味着在使用变量之前,我们必须声明它们的类型。语言提供了各种数值类型;然而,我们主要使用的是以下几种:

类型 描述 最小值 最大值
double 双精度 +/- 2.2e-308 +/- 1.8e308
int 整数 -2,147,483,648 2,147,483,647

其他类型,如无符号和扩展整数类型,将在需要时介绍。

C++标准库

正如 Nicolai Josuttis 在他不可或缺的著作《The C++ Standard Library - A Tutorial and Reference, 2nd Edition》[3]中描述的那样,C++标准库“使程序员能够使用通用组件和更高层次的抽象,而不会失去可移植性,而不必从头开始开发所有代码。”直到最新的 C++20 发布,用于量化模型实现的非常有用的库功能包括:

  • 数组样式的容器,特别是受尊敬的vector

  • 一系列标准算法,操作这些数组容器,如排序、搜索和高效地应用函数到容器中一系列元素

  • 标准实数值数学函数,如平方根、指数和三角函数

  • 复数和复数运算

  • 从一组标准概率分布中生成随机数

  • 基于任务的并发,可以内部和安全地管理线程

  • 智能指针,抽象了与内存管理相关的危险

  • 一个用于存储和管理字符数据的类

  • 流函数用于从控制台获取输入并显示结果

使用标准库组件需要程序员将它们显式地导入到代码中,因为它们存储在一个单独的库中,而不是在核心语言中。这个思想类似于将 NumPy 数组导入 Python 程序或在 R 脚本中加载外部函数包。在 C++中,这是一个两步过程,首先加载包含我们希望使用的标准库函数和类声明的文件,然后使用标准库命名空间名std来限定这些函数(C++开发人员通常称之为“stood”)。

编译器和集成开发环境(IDE)

学习 C++的第一步是获取一个编译器和开发环境。现代主要的免费可用编译器有三种,并且它们都包含了它们的 C++标准库的实现:

也有几种集成开发环境(IDE's)可用,即 Visual Studio、Apple 的 Xcode(附带 Clang 编译器)和 CLion,后者是一款通常需要从 JetBrains 购买的产品。对于本书来说,强烈推荐使用微软的 Visual Studio 编译器和 IDE。它们是友好的选项,能够快速上手 C++,并具有非常强大的调试工具。

此外,Visual Studio 选项还包括一个 Clang 选项,允许程序员在微软编译器和 Clang 之间切换,有助于确保跨平台兼容性。

不幸的是,C++的 Visual Studio 选项仅适用于 Windows,因为 Mac 版本不提供 C++选项。在这种情况下,可以选择下载附带 Clang 编译器的 Apple Xcode。Linux 用户通常会选择 gcc 或 Clang 编译器。

C++基础复习

以下将是对 C++的快速复习,使用一些简单的代码示例。我们还将首次看到 C++20 中的一个新功能,即数学常数。

经典的“Hello World!”

首先,这里有一个“Hello World!”的示例以便开始。以下代码将消息返回到屏幕,然后允许用户输入要打招呼的人的名字:

 #include <iostream>
#include <string>
int main()
{
	std::cout << "Hello World!" << '\n';
	std::string person;
	std::cout << "To whom do you wish to say hello? ";
	std::cin >> person;
	std::cout << "Hello "<< person << "!" << '\n';
	return 0;
}

如果您想向您的母亲打招呼,那么编译并运行代码后,屏幕将如下所示:

Hello World!
To whom do you wish to say hello? Mom
Hello Mom!

主要复习点如下:

  • coutcin以及 string 类,都依赖于包含 C++标准库声明文件 iostream 和 string。

    标准库的成员需要在其命名空间std下进行作用域限定。另一种方法是将带有命名空间作用域的using语句放在文件顶部,表明每当这些元素出现在代码中时,它们来自 std 命名空间。此外,您可能会发现输入endl(行末)比'\n'更容易:

     #include <iostream>
    using std::cout;
    using std::cin;
    using std::endl;
    #include <string>
    using std::string;
    int main()
    {
    	cout << "Hello World!" << endl;
    	string person;
    	cout << "To whom do you wish to say hello? ";
    	cin >> person;
    	cout << "Hello " << person << "!" << endl;
    	return 0;
    }
    
  • 使用以下方式将 std 命名空间导入全局命名空间中

    using namespace std;
    

    有时用于替代单独的 using 语句;然而,这不被认为是良好的做法,因为它可能导致编译时命名冲突。命名空间背后的动机将在第三章中介绍。

  • 几乎不会在生产级别的金融编程中使用控制台输入和输出。用户输入数据通常来自图形用户界面(GUI)或 Web 应用程序,而市场数据通常来自实时数据源。结果通常显示在用户界面中,然后存储在数据库中,例如在执行交易时。

  • 我们将使用coutcin来模拟这些输入,但在生产代码中应避免使用它们。

C++中的简单过程式编程

过程式程序的结构应该是熟悉的,即:

  • main()函数,在程序执行中首先调用的函数,

  • 一组包含组成程序的各个任务的用户定义函数。

在最简单的情况下,所有这些都可以写在一个包含main()的单个可执行文件中。

我们首先在main()函数的开始之前,在函数声明语句中声明每个用户定义函数。 函数声明说明其名称,返回类型和输入参数类型,后跟一个分号。

函数实现写在main()下面,每个函数包含一系列在开放和关闭大括号中的命令。 可以在main函数中或从其他用户定义函数中进行用户定义函数调用。

单行注释由两个连续的斜杠表示。 高级格式如下所示:

 // Function declarations ("//" indicates a comment line)
return_type function_01(input arguments);
return_type function_02(input arguments);
return_type function_03(input arguments);
.
.
.
int main()
{
  // Call each function 
  function_01(input arguments);
  function_02(input arguments);
  function_03(input arguments);
.
.
.
}
return_type function_01(input arguments)
{
  // Do stuff
  // Return something (or void return)
}
return_type function_02(input arguments)
{
  // Do stuff
  // Return something (or void return)
}
return_type function_03(input arguments)
{
  // Do stuff
  // Return something (or void return)
}
.
.
.
注意

对于更大更健壮的生产应用程序,我们很快将研究在单独的C++20模块中编写函数的方法,其中声明和实现函数的相同方法将延续。

函数声明的更多细节将在接下来的两个小节中详细说明。

函数声明

C++函数可能有可能没有返回值;此外,它们可能有可能没有输入参数。 没有返回值的函数由void返回类型指示。 例如,如果我们将我们的“Hello World”示例移到一个单独的函数中,当从main函数调用时,它只需向屏幕输出一条消息而不返回任何值,因此它将被声明为void函数。 另外,它不需要任何输入参数,因此其声明将采用以下形式

 void hello_world(); 

接下来,假设我们想编写一个真实值函数,该函数接受一个变量并返回其两倍值。 在这种情况下,我们的声明将具有双精度浮点返回类型,由double表示,并且具有相同类型的输入。 如果我们将此函数命名为twice_a_real,输入变量为x,则我们的声明将写为

double twice_a_real(double x);    

最后一个例子,与其他编程语言一样,函数可以接受多个变量。 假设我们希望在名为add_three_ints的函数中添加三个整数,并返回变量ijk的总和。 整数类型由int表示,因此我们的函数声明将是

int add_three_ints(int i, int j, int k);    

函数实现

函数实现,也称为函数定义,是我们在其中实现显示消息到屏幕,计算数学结果或执行其他任务的实际命令的地方。 函数的主体放在括号内,如此处所示的hello_world函数。 我们再次需要指定void返回类型。

 void hello_world()
{
  std::cout << "Hello World!\n";
}

接下来,我们可以编写我们两个简单数学函数的实现。 就像它们的声明一样,必须包括doubleint返回类型以及它们的输入变量类型:

double twice_a_real(double x)
{
	double y = 2.0 * x;
	return y;
}
int add_three_ints(int i, int j, int k)
{
	return i + j + k;
}

在第一种情况下,我们初始化了一个新的double变量y,并使用计算结果。因为 C++是一个强类型语言,我们在初始化变量时需要注明变量的类型。然后,这个变量将结果返回给main函数。在第二个函数中,我们只将求和操作放在返回语句本身;这也是完全合法的。

最后,我们将所有内容放在一个main函数中,当程序启动时调用,并调用我们的用户定义函数。它位于用户定义函数声明及其实现之间,如下所示:

#include <iostream>
// Maybe put in using statements here(?)
void hello_world();
double twice_a_real(double x);
int add_three_ints(int i, int j, int k);
int main()
{
	hello_world();
	double prod = twice_a_real(2.5);
	std::cout << "2 x 2.5 = " << prod << std::endl;
	std::cout << "1 + 2 + 3 = " << add_three_ints(1, 2, 3) << std::endl;
	double r;
	std::cout << "Enter a real number: ";
	std::cin >> r;
	std::cout << "2 x " << r << " = " << twice_a_real(r) << std::endl;
	return 0;
}
void hello_world()
{
	std::cout << "Hello World!\n";
}
double twice_a_real(double x)
{
	double y = 2.0 * x;
	return y;
}
int add_three_ints(int i, int j, int k)
{
	return i + j + k;
}

C++ 语法和风格指南

在本节中,提供了 C++语法的基本回顾,以及关于代码格式和变量命名的指南。尽管许多人可能不把这些讨论放在优先事项上,但在编写金融系统中的关键生产代码时,这个话题实际上非常重要,尤其是在像 C++这样的功能丰富的语言中。如果源代码写得清晰可维护,避免或解决 bug、运行时错误和程序崩溃将更加容易。

我们将回顾有关 C++语法的基本规则。即使你已经熟悉其中一些内容,仍将在一个地方总结,可能会对你有所帮助。

大括号中的代码块

函数实现,也称为函数定义,被放置在大括号内,如上文 6.2.2 中每个函数实现所示。当控制到达闭括号时,函数终止。对于其他代码块,如条件语句、循环、用户定义的函数和用户定义的类,当遇到闭括号时,块内定义的非静态局部变量和对象被称为 _ 超出作用域 _。也就是说,它们从内存中被清除,无法再访问。指针可能是此规则的一个例外,但我们将在第 XX 章中详细讨论。

语法复习

C++中的命令和声明以分号结束:

 double y = 2.0 * x;

同样,由于 C++是一个强类型语言,数值变量类型应该在初始化之前注明。

double x1 = 10.6;
int k;	// Defaults to zero
double y1 = twice_a_real(x1);
注意

C++11 引入了auto关键字,可以自动推断变量或对象类型,以及统一初始化(使用大括号)。对于它们的使用存在不同的意见,但许多程序员仍然倾向于显式声明普通数据类型(POD),如intdouble,以避免模糊不清的情况。本书将遵循这种风格。auto和统一初始化将在后续的上下文中讨论,这些上下文中它们更为有用。

单行注释用两个斜杠表示,例如,

// This is a comment

多行注释也可以在块中注释掉,如下所示:

/*
    Owl loved to rest quietly whilst no one was talking
    Sitting on a fence one day, he was surprised when
    suddenly a kangaroo ran close by.
*/

对于编译器来说,单个空格和多个空格没有区别;例如,尽管空白字符有变化,以下代码是合法的:

  int j = 1101;
  int k=   603;
  int sum = j +    k;
  std::cout <<    "j + k = " <<   sum << "\n";

然而,在编程中众所周知的一句箴言,特别是在 C++中,是因为你可以做某事,并不意味着你应该这么做。如果以清晰一致的间距编写,上述代码将更易读和易于维护:

  int j = 1101;
  int k = 603;
  int sum = j + k;
  std::cout << "j + k = " << sum << "\n";

同样,对于更加真实和复杂的代码,这一箴言应该牢记于心。在本书的后续章节中,这将是一个经常出现的主题。

代码也可以跨多行而不使用连续字符,并且垂直空间被忽略。回到我们之前的例子,编写

  int j = 1101;
  int k = 
          603;

  int sum = j + k;
  std::cout << "j + k = " 
            << sum 
            << "\n";

将得到相同的结果。与之前一样,具有统一间距且每个命令放在单独一行的前述示例更为可取。然而,需要注意的是,在涉及复杂和嵌套计算的量化编程中,通常非常建议将公式和算法拆分为多行以提高代码的清晰度和可维护性。我们将在后续章节中看到此类示例。

最后,C++ 语法是区分大小写的。例如,两个double变量xX会像kirkspock等其他变量一样不同。函数名也是如此。在上面的例子中,我们使用了标准库函数std::cout。尝试写成std::Cout将触发编译器错误。

命名约定

变量、函数和类名可以是任意连续的字母和数字组合,但需满足以下条件:

  • 名称必须以字母或下划线开头;不能以数字开头。

  • 除了下划线字符外,不允许使用特殊字符,如@=``$等。

  • 不允许使用空格。名称必须连续。

  • 不允许使用语言关键字命名,如doubleifwhile等。可以在 https://en.cppreference.com/w/cpp/keyword 找到完整列表。

最大名称长度取决于编译器,并且在至少一个情况下 - GNU gcc 编译器 - 没有限制;然而,请参考上述箴言

对于简单示例和简单的数学函数,单个字母的变量和函数名是可以接受的。然而,在量化模型中,通常最好使用更具描述性的名称来传递函数参数。函数和类名也应该提供一些关于其功能的指示。

多年来,几种命名风格已经很常见,即

  • 小驼峰命名法;例如,optionDeltariskFreeRateefficientFrontier:第一个单词以小写字母开头,后续单词首字母大写

  • 大驼峰命名法;例如,OptionDeltaRiskFreeRateEfficientFrontier:每个单词的首字母大写

  • 蛇形命名法;例如,option_deltarisk_free_rateefficient_frontier:每个单词以小写字母开头,用下划线分隔

小驼峰命名法和蛇形命名法是 C++函数和变量名中最常见的形式,类名通常采用大驼峰形式。近年来,可能受到Google 的 C++风格指南 [5]的推动,变量和函数名更倾向于使用蛇形命名法。因此,在本书中,我们将采用这种约定,并使用大驼峰命名类名。

在整数计数变量中使用单个字符时,仍然常见使用 FORTRAN 约定的字母 in,尽管这不是必须的。我们也将采纳这一做法。

C++中的数学运算符、函数和常量

在前面的讨论非常有趣,但本书的重点在于数学和金融。我们已经在上文中使用了内置的 C++数值类型的加法和乘法数学运算符。这些都是 C++语言的特性,接下来会对这些标准运算符进行全面的讨论。然而,常见的数学函数如余弦、指数等,是由 C++标准库提供而不是核心语言功能。

标准算术运算符

如上面的例子所示,C++提供了整数和浮点数类型的加法、减法、乘法和除法运算符 +-*/,这通常也是其他编程语言中的做法。此外,还包括取模运算符 %。示例如下:

 // integers:		
int i = 8;		    
int j = 5;		    
int k = i + 7;		
int v = j - 3;		
int u = i % j;		
  // double precision:
double x1 = 30.6;
double x2 = 8.74;
double y = x1 + x2;
double z = x1 - x2;
double twice_x2 = 2.0 * x2;

算术运算符的顺序和优先级与大多数其他编程语言相同,即:

  • 顺序从左到右:
i + j - v

使用上述整数值会得到 8 + 5 - 2 = 11

  • 乘法、除法和取模运算优先于加法和减法:
x1 + twice_x2/x2

使用上述双精度值会得到 30.6 + 2.0 = 32.6

  • 使用圆括号改变优先级:
(x1 + twice_x2)/x2

这将产生

使用相同的双精度值。

C++标准库中的数学函数

计算金融中常用的许多常见数学函数在 C++中具有相同或类似的语法,其中假定 xy 是双精度变量:

cos(x) x 的余弦
sin(x) x 的正弦
tan x 的正切
exp 指数函数 e^x
log 自然对数 ln(x)
sqrt x 的平方根
cbrt x 的立方根
pow xy 次幂
hypot 计算两个数值 x 和 y 的

由于这些函数位于标准库而不是语言功能中。应始终包括 cmath 头文件,并使用 std:: 前缀限定函数的作用域:

 #include <cmath>      // Put this at top of the file.
double trig_fcn(double theta, double phi)
{  
  return = std::sin(theta) + std::cos(phi);
} 

如果你不想每次都打出 std::,在 include 语句后面放置 using 语句也是可以的:

#include <cmath>      // Put this at top of the file.
using std::sin;
using std::cos;
double trig_fcn(double theta, double phi)
{  
  return = sin(theta) + cos(phi);
}

现在我们可以编写我们的第一个金融例子了。我们想要定价一张零息债券

Ae^(-rt)

其中

A = 债券的面值,

r 是利率,而

t 是到期时间,以年分数表示。

在 C++中,我们可以这样写

double zero_coupon_bond(double face_value, double int_rate, double year_fraction)
{  
    return face_value * std::exp(-int_rate * year_fraction);
}

要获取更全面的标准库数学函数列表,请参阅 Josuttis 的《C++标准库(第 2 版)》[4]第 17.3 节,或者访问CppReference 网站 [6]上的列表。这两者对于任何现代 C++开发者都是必不可少的参考资料,也是本书的高级补充资源强烈推荐。接下来的两个部分将进一步指导如何使用标准库数学函数。

C++中没有幂运算符

与其他语言不同,指数通常由^**操作符表示,但这在 C++语言中并不存在。相反,需要调用cmath中的标准库std::pow函数。然而,在计算多项式时,通过霍纳方法进行因式分解并减少乘法操作是更有效的。例如,如果我们希望实现一个函数

最好将其在 C++中写成

f(x) = 8x⁴ + 7x³ + 4x²- 10^x - 6

 double f(double x)
{  
  return x * (x * (x * (8.0 * x + 7.0) + 4.0 * x) - 10.0) - 6.0;
}

而不是

 double f(double x)
{  
  return 8.0 * std::pow(x, 4) + 7.0 * std::pow(x, 3) + 
    4.0 * std::pow(x, 2) + 10.0 * x - 6.0;
}

对于非整数指数的情况,比如

g(x,y) = x^(-1.368x) + 4.19y

则必须使用std::pow

double g(double x, double y)
{  
  return std::pow(x, -1.368 * x) + 4.19 * y; 
}

<cmath> 确保跨编译器的一致性

也许在某些情况下,可以在没有包含#include <cmath>的情况下使用这些数学函数,但是应坚持包含<cmath>并使用std::来限定函数的作用域。首先,因为 C++建立在 C 之上,一些编译器保留了来自 C 的旧数学函数,放置在所谓的全局命名空间中。然而,其他编译器可能将cmath放置在全局命名空间中。因此,实际上可能调用的是旧的 C 函数而不是 ISO C++标准版本,这可能导致不同编译器之间的意外或不一致的行为。

另一个可能出现的不一致性的例子涉及绝对值函数。在 C 语言和旧版 C++编译器中,abs函数仅适用于整数类型。要计算浮点数的绝对值,需要使用fabs函数。然而,std::abs函数对整数和浮点数(如double)参数都进行了重载,应优先选择使用。

不幸的是,这是由于 C++长期与 C 的关联而导致的一个怪异之处;然而,故事的教训非常简单:为了保持 C++代码的 ISO 兼容性,我们应始终包含#include <cmath>,并使用std::来限定数学函数的作用域。这将有助于确保在不同编译器和操作系统平台上的跨兼容性。

注意

注意:关于 C 头文件和命名空间 std 的问题,例如在 gcc 编译器的规范中已经明确说明:

标准规定,如果包括 C 样式头文件(在本例中为<math.h>),则符号将在全局命名空间中可用,可能也在 std:: 命名空间中可用(但这不再是一个确定的要求)。另一方面,包括 C++样式头文件(<cmath>)将保证实体将在命名空间 std 中找到,可能也在全局命名空间中找到。[8]

常量

在任何类型的数量编程中,通常需要在计算中使用常量值。在 C++中,可以通过简单地在赋值时附加关键字const来定义常量。此外,从 C++20 开始,现在还提供了一组常用的数学常量。

const关键字

如果一个变量不改变值,使用const关键字将其声明为常量类型更安全。例如,我们可以用它来存储地球的重力加速度常数的近似值:

const double grav_accel = 9.80665;

然后,如果在相同作用域内尝试将其重新分配为不同的值:

grav_accel = 1.625;	// Gravitational constant for the moon

将导致编译器错误,并显示尝试修改常量值的消息。在编译时捕获错误比在运行时追踪并找出原因要好,特别是在实时生产环境中。

const还有其他重要用途和有趣的属性,我们将在稍后介绍,特别是在面向对象编程的上下文中。

标准库数学常量

C++ 20 标准库的一个方便之处是一组常用的数学常量,例如e等。以下表格显示了对量化金融方便的一些常量。

C++常量 e pi inv_pi inv_sqrt_pi sqrt2
定义 e

要使用这些常量,必须首先在标准库中包含numbers头文件。在撰写本文时,每个常量必须使用std::numbers命名空间进行作用域限定。例如,要实现函数

我们可以编写

 #include <cmath>
#include <numbers>
. . . 
double some_fcn(double x, double y)
{
	double math_inv_sqrt_two_pi = 
		std::numbers::inv_sqrtpi / std::numbers::sqrt2;
	return math_inv_sqrt_two_pi*(std::sin(std::numbers::pi * x) + 
		std::cos(std::numbers::inv_pi*y));
}

这样,例如在计算中使用时,其值将在整个程序中保持一致,而不是交给项目中不同的程序员,他们可能会使用不同精度的近似值,从而导致数值结果可能不一致。

此外,数学计算中可能经常出现的的值无需重新计算

std::sqrt(2.0)

每次需要时都必须引用常量

std::numbers::sqrt2

本身保存了双精度的近似值。尽管在单次性能方面可能无关紧要,但在计算密集型代码中数百万次重复调用std::sqrt函数可能会产生一定影响。

注意

尽管在这一点上了解它并非必要,但值得至少提到这些常量是在 编译时 而不是运行时设置的,使用了 C++11 中称为 constexpr 的设计。这与更广泛和更高级的 模板元编程 主题相关联,在其中计算常量值以在运行时使用时在编译时执行。

作为结束语,有点奇怪的是,在 C++20 提供的数学常量集中包括值 ,但不包括 ,尽管后两者在统计计算中更为常见。[[查看 Boost 库的后续章节 - 在那里包括它们]]

结论

这就是我们对 C++ 的风潮性概述。我们强调了量化编程,以及现在包括在 C++20 中的数学常量。

我们在编码风格方面的最佳实践覆盖将是本书的一贯主题,因为 C++ 是一种功能极其丰富的语言,有足够的“绳索”让自己上吊。遵循最佳实践和一致的编码风格对于确保代码可维护性和可靠性至关重要。

还有一点要记住的是,虽然我们使用大量的屏幕输出和输入,但这并不是 C++ 在量化开发中典型的使用方式。std::coutstd::cin 应该被视为真实世界接口的占位符。我们将继续将它们用作检查结果的设备,但它们主要会被降低到从 main() 调用的测试函数内部使用,而不是在实际数学和模型代码中使用,实际上在这些代码中应该避免它们。

参考文献

[1] Kalb 和 Azman,C++ 今天:野兽回归,可在 resources.jetbrains.com/storage/products/cpp/books/Cplusplus_Today.pdf 上找到(链接)

[2] 指南支持库(ISO)(链接)

[3] ISO C++ 编程标准(链接)

[4] Nicolai Josuttis,《The C++ Standard Library (2E)》(http://www.cppstdlib.com)(链接)

[5] Google C++ 风格指南(https://google.github.io/styleguide/cppguide.html)

[6] cppreference.com

[7] Stepanov,《泛型编程的数学》(霍纳方法)

[8] GNU gcc 编译器文档

https://gcc.gnu.org/onlinedocs/libstdc++/manual/using_headers.html

第二章:C++ 的一些机制

几乎任何编程语言都会有一种数组结构来存储类似类型的集合。在 C++ 中,有一些选项可用于此目的,但是标准库 vector 容器是远远最常用的类型。在本章中,我们将看到一个 vector 如何方便地表示数学意义上的实数向量。我们还将介绍创建和使用 vector 以及其关键成员函数的基础知识,因为它与计数循环和 while 语句等迭代语句有很好的联系,这些也将在本章中介绍。

控制结构,包括迭代语句和条件语句。在 C++ 中,条件分支可以通过 if 语句实现,类似于其他语言,以及所谓的 switch 语句。与 switch 语句相关的一个很好的补充主题是枚举类型(enums),特别是在 C++11 中添加的更现代的 enum classes 。枚举类也非常适用于便利地输入和输出金融模型的数据。

最后,我们将总结可在 C++ 中使用的别名,并解释它们的重要性。这包括可以在代码中增加清晰度的类型别名,以代替更长并且有时更神秘的模板类型。引用和指针允许您访问和修改对象,而不需要对象复制的开销,尽管指针也有更广泛的用途。

vector 容器

C++ 标准库中的 vector 容器是存储和管理类似类型的索引数组的首选选择。它特别适用于管理量化工作中广泛存在的实数向量,double 类型表示数值。

注意

历史注释vector 更具体地说是所谓的标准模板库(STL)的一部分。STL 是在 1980 年代和 90 年代,由研究员亚历山大·斯蒂帕诺夫独立开发的,与 Bjarne Stroupstrup 早期努力设计和生产 C++ 的努力是无关的。关于 STL 的被接纳和它被纳入 C++ 标准的历史是一个非常有趣的故事[[参见 Kalb/Azman]],但总结是基于通用编程的 STL 在 1998 年被接纳为 C++ 的首个 ISO 标准版本。

作为一个通用容器,std::vector 可以保存来自于普通任意类型的元素,从普通数据(POD)类型如 doubleint,到用户定义和库类的对象。

 #include <vector>
using std::vector;
//. . .
vector <double> x;		// Vector of real numbers
vector <BondTrade> bond_trades;	// Vector of user-defined BondTrade objects

要保存的类型在尖括号内指示。

注意

尖括号表示 模板参数。模板是 C++ 实现通用编程的手段。这个主题将在第七章中进一步讨论。

设置和访问 vector 的元素

STL 的vector本质上封装和管理动态数组,这意味着在构造后可以向其中追加或从中移除元素。vector还支持随机访问,意味着可以访问和修改元素的索引。和 C++中的所有其他内容一样,vector是从零开始索引的,这意味着它的第一个位置的索引是 0,最后一个位置的索引是n - 1,如果它包含n个元素。

创建一个vector并使用它的索引

以下指令将创建一个持有三个实数的向量。

vector <double> v(3);	// Will hold three elements

vector可以像这里展示的那样逐个元素填充。注意索引从零开始而不是从一开始。

v[0] = 10.6;		// Set the first element (index 0) and assign to 10.6
v[1] = 58.63;		// Set the second element (index 1) and assign to 58.63
v[2] = 0.874;		// Set the first element (index 2) and assign to 0.874

方括号表示索引。我们还可以通过简单地将元素重新分配给一个新值来改变值;即,

v[1] = 13.68;

还可以使用 C++11 引入的统一初始化初始化向量。也称为花括号初始化,使用赋值操作符是可选的:

vector <double> w{9.8, 36.8, 91.3, 104.7}; // No assignment operator
vector <int> q = {4, 12, 15};	 // With assignment operator 
注意

在 C++11 中增加统一初始化对语言产生了重大影响,不仅仅是初始化向量。它具有一些有趣和方便的特性,将在第四章中讨论。

成员函数

由于vector是一个类,它包含许多公共成员函数,包括atsizepush_back三个函数。

at函数

at函数本质上扮演与方括号操作符相同的角色,即为给定索引访问元素,或修改元素。

double val = v.at(2);	// val = 0.874
w.at(1) = val;			// 36.8 in w[1] is replaced with 0.874

使用方括号操作符和at函数的区别在于后者执行边界检查。两个例子是

  • 尝试访问超过最大索引的元素;例如,

    double out_of_range = v.at(100);	// 2 is the max index for v
    
  • 尝试使用负索引值

    w.at(-3) = 19.28;
    

在每种情况下,都会抛出一个异常,可以用于错误处理。否则,你可以认为at[.]是相同的。

size 函数

此成员函数的名称使它的作用相当明显:它返回一个vector持有的元素数量:

auto num_elems_w = w.size();	// Returns 5
auto num_elems_q = q.size();	// Returns 3

你可能注意到这是我们第一次使用auto关键字。它自动推断从size函数返回的类型。我们将在以后的案例中看到auto有多么有用,但在这里,它帮助我们规避了std::vector容器的最大大小因编译器设置和使用平台而异的事实。该类型将是某种形式的无符号(非负)整数,有多种大小。

为了避免在这里陷入细节,我们不需要关注具体的无符号类型,因此我们可以大多数情况下只使用auto作为size成员函数的返回类型。

push_back 函数

此函数将元素追加到一个vector中;也就是说,新元素被“推到容器的后面”。

例如,我们可以在上述的vector v中追加一个新元素,比如说 47.44:

v.push_back(47.44);

现在,v包含四个值:10.6, 58.63, 0.84, 47.44,其中v[3](第四个元素,使用 0 索引)等于新值。

我们也可以向空向量追加值。一开始,我们定义了

vector <double> x;		// x.size() = 0

现在,如果我们追加一个值,

x.push_back(3.08);	// x.size() = 1

x现在在其索引 0 位置包含值 3.08,并包含一个元素。这可以任意重复多次:

x.push_back(5.12);	// x.size() = 2
x.push_back(7.32);	// x.size() = 3
//. . . etc

关于push_back的讨论到此结束,还有一个需要注意的潜在问题,

假设我们创建了一个整数vector,其中包含三个元素:

vector <int> ints(3);

现在,每个元素将保持int类型的默认值:0。

如果我们然后应用push_back函数来追加,比如说,5:

ints.push_back(5);

这个值将作为第三个零后的新元素追加到向量中;即,向量现在包含四个元素。

0 0 0 5

要将值放入前三个位置中的任何一个,您需要显式使用索引;例如

ints[0] = 2;
ints.at(2) = 4;

关于 STL vector的总结说明

在上面的例子中,我们只使用了普通的数值类型doubleint。当然,对于计算工作,实数向量是基础,但请记住 STL vector是通用的,可以容纳任何有效类型的元素,包括对象,而不仅仅是数值数据类型,这将在更高级的上下文中看到。

此外,正如前面提到的,在实际的生产级别编程中,输入来自于市场和产品数据以及用户输入的函数参数,而不是像前面示例中看到的硬编码的值。你可能会在测试函数中找到设置了固定数值的向量,但在生产代码中应避免使用它们。

标准库包含额外的 STL 容器,以及现代 C++编程的核心组成部分之一的大量 STL 算法。这些将在第七章更详细地讨论,并且现在熟悉vector容器的基础知识将使这些材料在我们讨论它时更易于理解。

最后,重申一下,优先使用 STL vector而不是使用newdelete创建动态 C 风格数组。使用后者没有性能优势,并且内存管理完全封装在vector类中,解放开发人员免受内存泄漏风险。

枚举常量和类

枚举常量,简称为枚举,将文本映射到整数。在 C++11 之前,枚举是一种很好的方法,使我们这些凡人更容易理解整数代码,通过用(连续的)单词表示它们。对于机器来说,处理整数要比处理占用更多内存的std::string对象更有效率。最后,通过在引号字符和杂乱的字符串中避免拼写错误,也可以避免错误。

C++11 标准进一步改进了这一点,引入了枚举类。这些移除了使用普通枚举常量时可能发生的重叠整数值的歧义,同时保留了优势。

我们将讨论更倾向于现代枚举类而不是基于整数的枚举的动机。在接下来的部分中,我们将看到它们如何在条件语句中对我们有利。后来,在使用金融模型进行数据输入和输出时,它们将证明是有用的。

枚举常量

枚举允许我们在文本表示中传递标识符、分类、指标等,而在幕后,编译器将其识别为整数。

例如,我们可以创建一个名为OptionType的枚举,该枚举将指示简单交易系统中允许的期权交易类型,例如欧式、美式、百慕大和亚洲。声明enum类型;然后,在大括号内定义允许的类型,用逗号分隔。默认情况下,每个类型将被赋予一个从零开始逐一增加的整数值(记住,C++中的索引是从零开始的)。闭括号后必须跟着一个分号。在代码中,我们会这样写:

enum OptionType
{
    European,     	// default integer value = 0
    American,     	// default integer value = 1
    Bermudan,     	// default integer value = 2
    Asian	      	// default integer value = 3
};  

我们可以验证每种选项类型的对应整数值:

cout << " European = " << European << endl;
cout << " American = " << American << endl;
cout << " Bermudan = " << Bermudan << endl;
cout << " Asian = " << Asian << endl;
cout << endl;

检查输出,我们得到:

European
American
Bermudan
Asian

因此,我们可以看到程序将文本表示视为整数。请注意,这些文本标签用引号括起来,因为它们最终代表的是整数类型,而不是字符串。

枚举可能的冲突

正如一开始讨论的那样,对于任何enum类型,默认的整数赋值从零开始,然后每个类型成员递增一。因此,两个来自两种不同类型的枚举常量可能在数字上相等。例如,假设我们定义了两种不同的enum类型,称为FootballBaseball,分别表示每种运动的防守位置。默认情况下,棒球位置从投手开始为 0,并逐一增加到列表中的每个位置。橄榄球位置也是如此,从防守后卫开始。在注释中提供了整数常量。

enum Baseball
{
	Pitcher,		// 0
	Catcher,		// 1
	First_Baseman,	// 2
	Second_Baseman,	// 3
	Third_Baseman,	// 4
	Shortstop,		// 5
	Left_Field,    // 6
	Center_Field,	// 7
	Right_Field	// 8
};
enum Football
{
	Defensive_Tackle,	// 0
	Edge_Rusher,		// 1
	Defensive_End,		// 2
	Linebacker,		// 3
	Cornerback,		// 4
	Strong_Safety,		// 5
	Weak_Safety		// 6
};

随后,我们可以比较Defensive_EndFirst_Baseman

	if (Defensive_End == First_Baseman)
	{
		cout << " Defensive_End == First_Baseman is true" << endl;
	}
	else
	{
		cout << " Defensive_End != First_Baseman is true" << endl;
	}

我们的结果将是无意义的:

Defensive_End == First_Baseman is true

这是因为这两个位置都映射到整数值 2。

一个快速的修复,以及在 C++11 之前经常使用的方法,将是重新索引每组枚举;例如,

enum Baseball
{
	Pitcher = 100,	
	Catcher,		// 101
	First_Baseman,	// 102
	. . .
};
enum Football
{
	Defensive_Tackle = 200,
	Edge_Rusher,		// 201
	Defensive_End,		// 202
	. . .
};

现在,如果我们比较Defensive_EndFirst_Baseman,它们将不再相等,因为 202 ≠ 102。尽管如此,在大型代码库中可能有数百个枚举定义,因此可能会出现重叠并导致错误。C++11 引入的枚举类消除了这种风险。

枚举类

在 C++11 中引入了一种新的更健壮的方法,可以避免enum重叠,这种方法完全消除了整数表示。枚举的其他好处,如避免晦涩的数字代码和更大的字符串对象,仍然存在,但通过使用所谓的enum class避免了冲突。例如,我们可以在枚举类中定义债券和期货合同的类别,如下所示:

enum class Bond
{
	Government,
	Corporate,		      
	Municipal,
	Convertible
};
enum class Futures_Contract
{
	Gold,
	Silver,
	Oil,
	Natural_Gas,
	Wheat,
	Corn
	};
enum class Options_Contract
{
    European,     	
    American,     	
    Bermudan,     
    Asian	      
};

请注意,我们现在不再需要手动设置整数值以避免冲突,就像我们在常规枚举中所做的那样。

尝试比较两个不同枚举类的成员,例如 BondFutures_Contract 位置,现在将导致编译器错误。例如,以下内容甚至不会编译:

if(Bond::Corporate == Futures_Contract::Gold)
{
	// . . .
}

这对我们有利,因为在编译时捕获错误要比在运行时更好。现代最佳实践现在认为,我们应该优先使用枚举类而不是枚举常量[[参考 ISO 指南]]。

控制结构

控制结构包括两个类别:

  • 条件分支,例如 if 语句

  • 迭代控制循环中重复一组命令

在 C++中,与给定条件或序列相关的代码包含在由大括号定义的块中。类似于函数,块内声明的变量在块终止时将超出范围。这些结构也可以嵌套在彼此内部。

在前一节关于枚举和枚举类中,我们假设你已经熟悉了 if 条件的基础知识,但在这里,你可以阅读更全面的条件和迭代结构的回顾,这些将从现在开始广泛使用。两者都依赖于逻辑运算符来确定真或假条件,因此在控制结构的探索之前,有必要快速复习一下逻辑运算符和布尔类型。

C++ 的布尔类型,由 bool 表示,可以存储 truefalse 的值。在幕后,bool 类型具有一个字节的大小,并且只能存储 1 表示 true,或 0 表示 false。请注意,truefalse 没有放在引号中,因为它们不是字符类型。它们代表固定的整数值。

C++ 的相等和不等运算符将根据结果是真还是假返回 bool 类型。它们如下所示:

  • <, > 严格不等

  • <=, >= 包含不等

  • == 相等

  • != 不等于

  • AndOr 操作分别用 &&|| 表示。

示例将在下一节关于条件分支中展示。

条件分支

C++ 支持大多数其他语言中常见的基于 if 的逻辑,以及 switch/case 语句,后者在特定情况下提供了对多个 else if 条件的更清晰替代方法。

if 和相关条件

常见的条件分支语句

  • if (condition) then (action)

  • if (condition) then (action), else (default action)

  • if (condition 1) then (action 1),

  • else if (condition 2) then (action 2)

  • ...

  • else if (condition n) then (action n)

  • else (default action)

由以下 C++语法表示。每个条件,无论是 ifelse if 还是 else,在执行为 true 的条件时,都包含在单独的主体内,用大括号表示。

// Simple if
if (condition)
{
  // action
}
// if/else
if (condition)
{
  // action
}
else
{
  // default action
}
// if/else if.../else
if (condition 1)
{
  // action 1
}
else if (condition 2)
{
  // action 2
}
// ...
else if (condition n)
{
  // action n
}
else
{
  // default action
}
提示

在包含else if的条件语句中,最佳实践是在最后包括一个默认的else块。如果没有,代码可能会编译无误并正常运行,但其执行可能会导致意外行为,在更大和更实际的代码库中可能会引起严重问题。

利用上述介绍的不等运算符,我们可以写一些关于if语句主题的简单示例的所有三种变体:

	int x = 1;
	int y = 2;
	int z = 3;

	// Simple if
	if (x > 0)
	{
		cout << x << " > 0" << endl;
	}
	// if/else
	if (x >= y)
	{
		cout << x << " >= " << y << endl;
	}
	else
	{
		cout << x << " is less than " << y << endl;
	}
	// if/else if.../else
	if (x == z)
	{
		cout << x << " == " << z << endl;
	}
	else if (x < z)
	{
		cout << x << " > " << z << endl;
	}
	else if (x > z)
	{
		cout << x << " < " << z << endl;
	}
	else
	{
		cout << "Default condition" << endl;
	}
警告

由于浮点数数值表示和算术的特性,永远不应该测试两个double类型之间的精确相等性,也不应该将浮点类型与零完全相同地比较。这些情况将在另一个上下文中进一步讨论。

逻辑 AND 和 OR 运算符也可以在条件参数中使用。例如:

	#include <cmath>
	using std::abs;
	using std::exp;

	// Simple if
	if (x > 0 || y < 1)
	{
		cout << x << " > 0 OR " << y << " < 1 " << endl;
	}
	// if/else if.../else
	if (x > 0 && y < 1)
	{
		cout << x << " > 0 AND " << y << " < 1 " << endl;
	}
	else if (x <= 0 || y >= 1)
	{
		cout << x << " <= 0 OR " << y << " >= 1 " << endl;
	}
	else if (z <= 0 || (abs(x) > z && exp(y) < z))
	{
		cout << z << " <= 0 OR " << endl;
		cout << abs(x) << " > " << z << " AND " 
			      << exp(y) << " < " << z << endl;
	}
	else
	{
		cout << "Default condition" << endl;
	}

注意,在最后的else if条件中,我们将 AND 条件放在圆括号内,因为 OR 比 AND 具有更高的优先级。[cppreference.com]

最后,我们可以将逻辑条件赋给bool变量,并在if条件中使用,如下所示:

	bool cond1 = (x > 0 && y < 1);
	bool cond2 = (z <= 0 || (abs(x) > z && abs(y) < z));
	if (cond1)
	{
		cout << x << " > 0 AND " << y << " < 1 " << endl;
	}
	else if (!cond1)
	{
		cout << x << " <= 0 OR " << y << " >= 1 " << endl;
	}
	else if (cond2)
	{
		cout << z << " <= 0 OR " << endl;
		cout << abs(x) << " > " << z << " AND "
			<< abs(y) << " < " << z << endl;
	}
	else
	{
		cout << "Default condition" << endl;
	}

注意,布尔变量可以通过在其前面加上!运算符来取反,如上面第一个else if条件所示。

警告

一个常见的陷阱是错误地使用=来测试相等性,而不是==。前者是赋值运算符,在这种情况下会导致意外行为。确保在测试相等性时使用==

三元if语句

还有一种方便的单行快捷方式来表示简洁的if-else组合。语法如下:

类型 var = 逻辑条件 ? var_val_true(如果true) : var_val_false(如果false);

在英语中,这意味着如果 逻辑条件true,则将var的值分配给var_val_true;否则,分配给var_val_false

代码示例应当更加清晰:

using std::sin;
using std::cos;
int j = 10;
int k = 20;
double theta = 3.14;
double result = j < k ? sin(theta) : cos(theta);

因此,在这个例子中,result将被赋予 sin(3.14)的值,即大约为零。

switch/case语句

也称为switch语句,这种控制序列允许我们消除多个else if子句带来的一些混乱,但对于单个整数类型的state的分支的特定情况,或者替代地,映射到整数的枚举,或枚举类成员。

对于每一个可能的case,执行与匹配状态相对应的命令。与上述else条件类似,最后应该提供一个default动作,以捕捉不属于任何给定类别或处理无法通过的错误情况。

作为第一个示例,考虑一个假设整数条件表示选项类型的情况,在每个cout的位置,操作将是调用相应的定价模型。这将使我们的代码比使用多个else if语句更具可读性和可维护性。

void switch_statement(int x)
{
	switch (x)
	{
	case 0:
		cout << "European Option: Use Black-Scholes" << endl;
		break;
	case 1:    
		cout << "American Option: Use a lattice model" << endl;
		break;
	case 2:
		cout << "Bermudan Option: Use Longstaff-Schwartz Monte Carlo" << endl;
		break;
	case 3:
		cout << "Asian Option: Calculate average of the spot time series" << endl;
		break;
	default:
		cout << "Option type unknown" << endl;
		break;
	}
}

每个情况结束后,break 语句指示程序在执行特定状态的相应代码后退出 switch 语句。因此,如果 x1,则会调用格点模型来定价美式期权,然后控制将传出 switch 语句的主体,而不是检查 x 是否为 2

也有一些情况下,如果对多个状态执行相同的操作,则可能希望转到下一步。例如,在(美式)橄榄球中,如果进攻停滞,进攻方在第四次进攻失败时会将球踢出,不会得分。然而,如果球队得分,可能会踢出三分球,或者得分进攻有三种可能结果:

  • 未中额外一分 -- 结果是六分

  • 踢额外一分 -- 结果是七分

  • 进行两分转换 -- 结果是八分

无论球队如何得分,都会将球踢给对手,因此对于情况 3、6、7 和 8,我们只需逐个遍历每种情况,直到踢球。这种准贝叶斯逻辑可以通过以下代码实现:

void switch_football(int x)
{
	switch (x)
	{
	case 0:		// Drive stalls
		cout << "Punt" << endl;
		break;
	case 3:		// Kick field goal
	case 6:		// Score touchdown; miss extra point(s)
	case 7:		// Kick extra point
	case 8:		// Score two-point conversion
		cout << "Kick off" << endl;
		break;
	default:
		cout << "Are you at a tennis match?" << endl;
		break;
	}
}

在 C++11 之前,用于期权定价 caseswitch 的明显替代方案是用相应的枚举替换整数代码,从而使逻辑对人类更易理解(cout 消息保持不变):

void switch_statement_enum(OptionType ot)
{
	switch (ot)
	{
	case European:		// = 0
		cout << "European Option: Use Black-Scholes" << endl;
		break;
	case American:		// = 1    
		. . .
	case Bermudan:		// = 2
		. . .
	case Asian:		// = 3
		. . .
	default:
		cout << "Option type unknown" << endl;
		break;
	}
}

然而,现代 ISO 指南现在更倾向于使用枚举类,原因如上所示,存在整数冲突。因此,我们只需将 Options_Contract 枚举类替换到前面的示例中即可得到:

void switch_enum_class_member(Options_Contract oc)
{
	switch (oc)
	{
	case Options_Contract::European:
		cout << "European Option: Use Black-Scholes" << endl;
		break;
	case Options_Contract::American:    
		. . .
	case Options_Contract::Bermudan:
		. . .
	case Options_Contract::Asian:
		. . .
	default:
		cout << "Option type unknown" << endl;
		break;
	}
}

迭代语句

在 C++ 中,有两个内置语言特性可以实现循环逻辑和迭代:

  • whiledo...while 循环

  • for 循环(包括基于范围的 for 循环)

这些迭代命令将根据固定计数、逻辑条件为真或在 vector 中保存的一系列元素的范围上执行一段重复的代码块。

whiledo...while 循环

while 循环背后的基本工作流程是在逻辑表达式为 true(或者在 false 的情况下)时重复执行一段代码块。以下简单示例演示了一个简单的 while 循环,其中递增的整数值在其值严格小于某个固定最大值时输出到屏幕:

int i = 0;
int max = 10;
while (i < max)
{
	cout << i << ", ";
	++i;
}

我们的逻辑条件是 i 严格小于值 max。只要这个条件成立,i 的值将被递增

do...while 循环类似,只是通过将 while 条件放在末尾,可以保证循环至少执行一次。例如:

int i = 0;
int max = 10;
do 
{
	cout << i << ", ";
	++i;
} while (i < max);

注意,即使 max 设置为零或更小,仍会有一次通过 do...while 循环的行程,因为最大条件直到最后才会被检查。这是将其与更简单的 while 循环区分开的特点。

最终,我们将看到涉及更有趣的数学和金融应用的循环示例。

for循环

这种结构是迭代计数范围的另一种形式。C++中使用的形式可以用以下伪代码示例概括:

for(initial expression executed only once;
exit condition executed at the beginning of every loop;
loop expression executed at the end of every loop)
{
DoSomeStuff;
}

这里的语法很重要,特别是在for参数中分隔三个表达式的分号。将其分解为 a、b 和 c 部分,我们会有:

for(a; b; c) 

每个部分通常依赖于某种形式的计数器,例如在while语句中看到的int i计数器;然而,现在我们将此索引移到for语句的参数中,这允许我们将增量从循环体中移除。(a)部分确定计数器的起始值,(b)指示停止的位置,(c)强制计数器如何增加或减少。

例如,我们可以使用for循环将上面的while示例重写如下:

int max = 10;
for(int i = 0; i < max; ++i)
{
	cout << i << ", ";		// we no longer need ++i in the body
}   

结果将与while循环示例中的结果完全相同。

  1. 从技术上讲,前增量运算符和后增量运算符之间存在影响其他用途的差异,但在for中使用++ii++将完全相同。通常更倾向于使用++i

  2. 同样,可以使用递减(--)减少索引值到某个最小值的for循环是合法的。

breakcontinue

在迭代循环中,有时需要在达到最大或最小索引值之前或在指定的逻辑条件终止迭代之前退出循环。在计算金融中的一个典型例子是使用蒙特卡罗模拟定价障碍期权。模拟路径通常具有相同数量的时间步长;然而,在例如敲出障碍期权的情况下,如果基础资产价格上涨到障碍水平以上,我们需要退出循环。

通过应用与switch语句中相同的break命令来实现。这里展示了一个简单的示例,还演示了在for块中嵌套if条件:

int max = 10;
for (int i = 0; i < 100; ++i)
{
	cout << i << ", ";
	if (i > max)
	{
	    cout << "Passed i = " << max << "; I'm tired, so let's go home." 
	        << endl;
	    break;
	}
}

一旦i增加到 11,if语句为真,因此调用break命令,导致程序控制退出for循环。

还有continue关键字,可以用来继续循环的过程,但由于这已经是循环的默认行为,它的用处有限。

嵌套循环

除了在循环内部嵌套if条件外,在其他块中嵌套迭代块(无论是for还是while循环)也是可能的。在量化编程中,当实现常见的数值例程和金融模型时,很容易发现自己编写双甚至三重嵌套循环。然而,这种编码方式可能会迅速变得复杂且容易出错,因此需要采取特殊预防措施,并考虑稍后我们将讨论的替代方案。

基于范围的for循环

在 C++11 之前,通过使用索引作为计数器来遍历 vector

vector<double> v;
// Populate the vector v and then use below:
for(unsigned i = 0; i < v.size(); ++i)
{
	// Do something with v[i] or v.at(i). . .
}

引入于 C++11 的基于范围的 for 循环使得代码更加函数化和优雅。而不是显式地使用 vector 的索引,基于范围的 for 循环只需说“对于 v 中的每个元素 elem,做某事”。

for(auto elem : v)
{
	// Use elem, rather than v[i] or v.at(i)
}

作为一个简单的例子,计算元素的总和:

double sum = 0.0;
for(auto elem : v)
{
	sum += elem;
}

我们完成了。不用担心索引错误,输入更少,代码更明显地表达了它正在做的事情。实际上,ISO 指南告诉我们要优先使用基于范围的 for 循环与 vector 对象,以及其他将在第七章讨论的 STL 容器。

别名

别名可以采用多种形式,第一种是便利性的形式,即“类型别名”,其中常用的参数化类型名称可以分配给更短且更具描述性的别名。

此外,“引用别名”有助于避免在将对象传递到函数时创建对象的副本,通常会在运行时显著加快速度。

指针也可以被视为别名,特别是用于类设计中表示活动对象(第四章中的 this 指针)。指针(现在还有智能指针)也可以用于分配持久性内存,但这是一个单独且更深入的讨论,将推迟到第六章。

引用和指针都可以帮助促进将在后续章节中介绍的“面向对象编程”的概念中的“继承”和“组合”。

类型别名

在数量代码中,std::vector<double> 对象因其显而易见的原因而无处不在。由于它使用如此频繁,通常会为其分配一个类型别名,例如 RealVector。这更好地表达了它在数学上的含义,而且我们不需要输入太多。

使用现代 C++,我们可以通过简单地定义别名 RealVector 来定义别名。

using RealVector = vector<double>;

然后,我们可以简单地写:

RealVector v = {3.19, 2.58, 1.06};
v.push_back(2.1);
v.push_back(1.7);
// etc...

只要在代码中使用之前定义了别名,那么就可以使用它了。

在 C++11 之前,不存在 using 命令的这种应用,因此类型别名是通过使用 typedef 命令来实现的;例如,

typedef vector<double> RealVector;

这也是有效的 C++,仍然存在于许多现代代码库中,但根据现代 ISO 指南,使用 using 形式更可取。详细原因超出本书范围,但要点是 using 可用于定义通用模板类型的别名(例如,不仅限于上述的 double 参数),而 typedef 不能。

引用

简单地说,引用为“变量”提供了别名,而不是类型。一旦定义了引用,访问或修改它与使用原始变量完全相同。通过在类型名称和引用名称之间放置一个和符号来创建引用,然后将其分配给原始变量。例如:

int original = 15;
int& ref = original;	// int& means "reference to an int"

在这一点上,如果在函数中访问或分配给另一个变量,originalref都将返回 15。然而,重新将original重新分配为 12 也意味着现在ref返回 12。同样,重新分配ref会改变由original保存的值:

original = 12;			// ref now = 12	
ref = 4;				// original also now = 4

需要注意的是,引用必须在声明时同时进行分配。例如,

int& ozone;

将是无意义的,因为没有任何东西与其引用,代码将无法编译。此外,一旦定义了引用,就不能再将其重新分配给另一个变量直至其生命周期结束。

对于普通的数值类型,使用引用是微不足道的,但当将大对象传递到函数中时,它们变得很重要,以避免可能会使程序运行时性能严重下降的对象复制。

假设我们有一个包含 2000 个期权合约对象的std::vector?通过将其作为引用传递到函数中,可以访问原始对象本身而无需复制它。

然而,有一个需要注意的地方。请记住,如果修改了引用,原始变量也会被修改。因此,可以将引用参数设为const。然后,编译器将阻止对引用的任何修改尝试。

例如,这里有两个函数,它们将std::vector<int>对象作为引用参数。第一个函数返回元素的总和,因此不会尝试修改元素。然而,第二个函数尝试将每个元素重置为其两倍值然后返回元素的总和。这将导致编译器错误 - 这比运行时错误好得多 - 并阻止操作被执行:

// This is OK
using IntVector = std::vector<int>;
int sum_ints(const IntVector& v)
{
	int sum = 0;
	for (auto elem : v)
	{
		sum += elem;
	}

	return sum;
}
int sum_of_twice_the_ints(const IntVector& v)
{
	// Will not compile!  const prevents modification
	// of the elements in the vector v.

	int sum = 0;
	for (auto elem : v)
	{
		elem = 2 * elem;
		sum += elem;
	}

	return sum;
}
注意

也可以将函数参数作为非const引用传递,以便在原地修改它。在这种情况下,一般会将返回类型设置为void,而不是返回修改后的变量。在现代 C++中这种做法已经很少有正当理由,因为返回值优化RVO)使得对象默认以“原地返回”的方式而不是作为函数的副本返回。这已经成为编译器根据 ISO 标准(从 C++11 开始)的要求。

关于引用的最后一点与 Java 和 C#等托管语言相关,其中默认行为是通过非常量引用传递对象。在 C++中,默认是按值传递;因此,如果在 C++和托管语言之间切换,必须明确指示编译器期望一个引用的函数参数。这是程序员在切换时需要进行的调整。

指针

在 C++中,指针与引用有一些相似之处,它也可以是对另一个变量的别名,但它不像引用那样在其生命周期内永久绑定到变量。指针指向包含变量内容的内存地址,并且可以重定向到包含另一个变量的另一个内存地址。

不幸的是,这可能会让人困惑,因为变量的内存地址也是通过&运算符来表示的,除了另一个用于声明指针的运算符*。 一个简单的例子说明了这一点。 首先,声明并分配一个整数变量:

int x = 42;

接下来,使用*运算符声明一个指向整数的指针:

int* xp;

这意味着创建一个变量,它将是int类型的指针,但是还没有指向任何具体的东西; 这将在下一步中进行:

xp = &x;

在这种情况下,&运算符意味着x的地址。 xp现在指向包含x内容的内存地址,即 42。 请注意,此处的&用法与声明引用的含义不同。

通过应用*运算符来对xpdereferencing,现在我们可以访问该内存地址的内容。 如果我们把

std::cout << *xp << std::endl;

输出将是 42,就像我们对变量x应用了std::cout一样。 注意这里的*运算符在这里使用的是不同的上下文,访问内存的内容而不是声明指针。

我们还可以意味着我们可以更改x的值。 例如,放置

*xp = 25;

然后*xpx都将返回值 25,而不是 42。

还可以重新分配指针xp到不同的内存地址; 这是无法使用引用进行的。 假设我们有一个不同的整数变量y,我们将xp重新分配到指向y的地址:

int y = 106;
xp = &y;

现在,*xp将返回 106 而不是 25,但x仍然等于 25。

注意

xp,而不是*xp,将返回一个十六进制值,表示内存中包含y内容的第一个字节的地址。

与引用类似,指针也可以与对象一起使用。 如果我们有一个类SomeClass,其中有一个成员函数,例如some_fcn,那么我们可以定义一个指向SomeClass对象的指针:

SomeClass sc;
auto SomeClass* ptr_sc = &sc;

由于明显ptr_sc将指向一个SomeClass对象,我们可以使用auto关键字而不会使其上下文含糊不清。

假设SomeClass也有一个成员函数some_fcn。 通过解引用ptr_sc然后以通常的方式调用它:

(*ptr_sc).some_fcn();

然而更常见的是使用箭头表示的间接运算符:

ptr_sc->some_fcn();

这就是我们现在所需了解关于指针的一切。 更具体地说,这些示例发生在堆栈内存中,并且在定义它们的函数或控制块终止时会自动删除。 更高级的用法将稍后介绍。

注意

指针还可以指向在堆内存中分配的内存,这使得值或对象可以在函数或控制块的范围之外的内存中保持持久。 这在涉及面向对象编程的某些情况下非常重要,并且需要额外注意。 此外,C++11 在标准库中引入了智能指针。 这些主题将在第五章中介绍。

函数和运算符重载

C++的一个关键特性,以及其他现代编程语言的特性,是实现同一函数名的不同版本,通过不同的输入参数集合进行区分。这被称为函数重载。作为量化程序员非常方便的相关特性是运算符重载,我们可以为特定类型定义操作,比如向量乘法。运算符重载并不像函数重载那样被多种语言支持;例如,在 Java 中就不支持。

函数重载

为了说明函数重载,让我们看一个sum函数的示例,它有两个版本,一个返回double类型,另一个返回vector<double>。第一个版本很简单,只是对两个实数求和。

#include <vector> 
// . . .
double sum(double x, double y)
{
	return x + y;
}

然而,第二个版本将接受两个std::vector<double>对象,并返回一个包含其元素和的向量。

std::vector<double> sum(const std::vector<double>& x, const std::vector<double>& y)
{
	// NOTE TO SELF: Can we do this with range-based for loops(?!)
	std::vector<double> vec_sum;
	if(x.size() == y.size())
	{
		for (int i = 0; i < x.size(); ++i)
		{
			vec_sum.push_back(x.at(i) + y.at(i));
		}	
	}
	return vec_sum;		// Empty if size of x and y do not match
}

正如我们所见,这两个函数执行两个不同的任务,并且根据参数类型具有不同的返回类型。

重载函数还可以根据相同类型的参数数量进行区分,并返回相同类型。例如(显然),我们可以定义一个接受三个实数的sum函数:

double sum(double x, double y, double z)
{
	return x + y + z;
}

现在,如果我们在main()函数中输入以下内容,

sum(5.31, 92.26);
sum(4.19, 41.9, 419.0);

将调用相应的重载函数。

运算符重载

C++为整数和浮点数提供了标准的数学运算符。标准库还为std::string类型提供了+运算符,用于连接它们。然而,例如对于std::vector,并未提供运算符。因此,如果我们想要计算两个向量的逐元素求和,或者计算点积,我们就得自己动手。

我们可以像上面显示的向量加法一样,使用sum重载,然后编写一个名为dot_product的新函数来进行向量乘法。然而,C++为我们提供了一个更自然的数学方法,即运算符重载

对于向量求和,加法运算符取代了如下所示的sum重载。函数体保持不变:

std::vector<double> operator + (const std::vector<double>& x, const std::vector<double>& y)
{
	std::vector<double> add_vec;
	if (x.size() == y.size())
	{
		for (unsigned i = 0; i < x.size(); ++i)
		{
			add_vec.push_back(x.at(i) + y.at(i));
		}
	}
	return add_vec;		// Empty vector if x & y sizes not identical
}

同样地,对于返回标量(double)的点积,重载*运算符:

double operator * (const std::vector<double>& x, const std::vector<double>& y)
{
	double dot_prod = 0.0;
	if (x.size() == y.size())
	{
		for (int i = 0; i < x.size(); ++i)
		{
			dot_prod += (x[i] * y[i]);
		}
	}
	return dot_prod;	// Return 0.0 if size of x and y do not match		
}

然后,对于两个向量xy,例如

std::vector<double> x = {1.1, 2.2, 3.3};
std::vector<double> y = {0.1, 0.2, 0.3};

重载的运算符将执行向量加法和乘法:

auto v_sum = x + y;		// ans: {1.2, 2.4, 3.6}
auto v_dot = x * y;		// ans: 1.54

对于double类型,编译器知道要应用语言提供的运算符

double s = 1.1 + 0.1;	// s = 1.2
double p = 2.2 * 0.2;	// p = 0.44
注意
  1. 对于同时迭代两个vector对象,在这个阶段我们需要回到索引的for循环。有更优雅的方法可以避免索引,但需要额外的背景,这将在第七章中介绍。

  2. 对于x.size() != y.size()的错误条件,目前我们只是返回空向量作为向量和的结果,以及返回 0 作为点积的结果。在生产代码中,异常更为合适。

至于其他示例,如果我们编写一个Matrix类,我们还希望重载运算符+-*。对于Date类,我们可以定义-来返回两个日期之间的天数。因此,运算符重载在数学和金融编程中非常方便。我们将在以后的各种上下文中使用它。

总结

本章涵盖了一系列相当广泛的主题,从标准模板库(STL)中的std::vector容器类开始。std::vector在量化编程中无处不在,其(良好的)原因将在第七章详细讨论,同时还包括 STL 迭代器和算法,这些可以使 C++代码更加优雅、可靠和高效。然而,目前的目标是熟悉std::vector作为动态数组的实现。

别名有三种不同的形式:类型别名(using)、引用和指针。using使我们无需键入长类型名称,例如常用的std::vector<double>中的RealVector。在 C++中,引用主要用于将const引用对象作为函数参数传递,避免了可能降低性能的对象复制,并防止在函数内修改对象。指针除了作为纯粹的别名外,还有几个重要的应用场景,这些将在适当的时候介绍,同时还包括从 C++11 开始添加到标准库的智能指针。

函数重载对数学编程非常适合,对于如矩阵和向量这样在量化编程中无处不在的对象,运算符重载更是如此。这是另一个将在第四章的面向对象编程中进一步扩展的主题。

引用

[1] CppReference: https://en.cppreference.com/w/cpp/language/operator_precedence

[2] Stroustrup 4E(未直接引用)

第三章:在模块中编写用户定义的函数和类

到目前为止,在我们的代码示例中,我们将所有代码放入一个文件中,采用自顶向下的设计,从main函数开始执行程序。当然,这对于实际的生产编程来说是不切实际的。在 C++20 之前,编写用户定义(非模板化)函数和类的通常方法首先涉及将它们的声明放在头文件中(通常是.h.hpp.hxx扩展名)。然后,一个实现文件(通常是.cpp.cxx.cc扩展名)会使用#include预处理器命令加载这些声明。然后,每个实现文件将被编译成一个翻译单元(事实上标准扩展名为.o用于 Clang 和 gcc,以及.obj用于 Visual Studio)。

C++20 引入了模块,在这里不详细展开,它们提供了编译和构建代码库所需时间减少、消除头文件和预处理器指令可能引发的问题、以及对导出到其他代码文件的内容更大的控制权等优势。一个方便的结果是不再需要将声明包装在包含保护宏中。此外,头文件泄漏的问题也可以避免,因为一个导入到另一个模块的模块不会携带它导入的所有内容,不像一个头文件中的#include宏会传播到另一个头文件中。[[更多细节,请参阅 Ranier Grimm 和 Niall Cooling (https://blog.feabhas.com/2021/08/c20-modules-with-gcc11/)]]

模块还允许将声明和实现放在一个文件中,尽管也可以将它们分开放在一个接口和多个实现文件中。对于本书,我们将把讨论限制在单文件情况下[[至少目前是这样]].

单文件模块可以用于实现类似于 C#和 Java 的函数和类,而无需单独的声明。但即使在一个文件中保持分离仍然具有某些优势,即接口和实现的清晰分离,以及减少或消除消耗模块的其他代码重新编译的潜力。

单文件模块的默认文件扩展名在这个阶段似乎正在收敛到.ixx用于 Visual Studio,以及.cppm用于 Clang,尽管使用.cpp也是一个选项。[[查看 Ranier Grimm 关于使用 cppm 的警告;他建议在 Clang 中使用.cpp,或在 MSVC 中使用 Clang-Cl 中使用.ixx]]. 在本书中,我们将使用.ixx扩展名来命名模块文件。

注意

在撰写本文时,仍有一些关于标准化的问题尚未解决。这在本书出版时可能会发生变化。

在本章中,我们首先展示了使用模块来定义用户自定义非成员函数和类的初步示例。这将为更深入讨论类设计铺平道路,包括编译器提供的默认特殊函数、用户定义的构造函数和析构函数以及运算符重载。移动语义是在 C++11 中添加的一种语言特性,也将被介绍。对于一些读者来说,这将是复习内容,但这些示例将在 C++20 模块的上下文中再次展示。

使用模块编写用户自定义函数

现在,我们将逐步构建编写模块中函数的过程,从一个非常简单的示例开始,然后逐步介绍细节。

非成员函数的第一个示例

假设我们想编写一个函数,用于计算整数向量中元素的总和:

int vector_sum_of_ints()
{
	vector<int> v = { 1,2,3 };
	int sum = 0;
	for (int elem : v) sum += elem;
	return sum;
}

这个模块被放置在一个名为 CppMod 的模块中,位于一个单独的 .ixx 文件中,比如 CppModule.ixx。通过在这个文件中直接编写整个代码,然后逐部分进行详细讨论,将更容易理解。

module;	// The global fragment
#include <vector>
export module CppMod;	// The global fragment ends at this point, and 
					// the functionality of the module starts here.
using std::vector;
// Declare the function first, using the
// export keyword to make it accessible outside the module.
export int vector_sum_of_ints();
// Implementation of the function.  The export keyword
// is not necessary here as it is already included 
// in the declaration.
int vector_sum_of_ints()
{
	vector<int> v = { 1,2,3 };
	int sum = 0;
	for (int elem : v) sum += elem;
	return sum;
}

第一行上的 module 语句确定了模块的 全局片段 开始的位置。这一部分专门用于预处理器命令,特别是用于标准库和其他地方的头文件的 #include 声明,这些声明在实现时是必需的。

接下来,export module 语句表示全局片段的结束并定义模块本身。这将使其能够被 import 到其他模块和源代码中。首先是 vector_sum_of_ints 函数的声明,前面是另一个 export 关键字的使用。这样做的作用是告诉编译器,该函数可以从模块外部的代码中调用。未标记为 export 的函数只能从模块内部调用。

在此之后,我们可以编写函数的实现。请注意,我们不需要在这里放置 export,因为在函数声明时放置它就足够了。

为了看看我们如何使用这个模块,我们将它导入到我们通常的 Main.cpp 文件中(此时还不是模块本身)。这是在 main 函数之前使用 import 关键字来实现的。然后,从 main 中,我们将调用 vector_sum_of_ints

#include <iostream>		// #include <iostream> as usual
using std::cout;
using std::endl;
import CppMod;		// Import the CppMod module
				// containing the vector_sum_of_ints function.
int main()
{
	cout << vector_sum_of_ints() << endl;
}

运行这段代码,你可以验证结果为 6。

接下来,我们可以看看如果向模块添加一个非导出函数 add_stuff 会发生什么。再次,这是一个非常简单的例子,它只会将整数值加倍。如果我们从导出函数内部调用它,那么向量结果的两倍将被返回:

module;
#include <vector>
export module CppMod;
using std::vector;
export int vector_sum_of_ints();
int add_stuff (int n);
int vector_sum_of_ints()
{
	vector<int> v = { 1,2,3 };
	int sum = 0;
	for (int elem : v) sum += elem;
	sum = add_one(sum);
	return sum;
}
int add_stuff (int n)
{
	return n + n;
}

再次编译和运行程序,结果毫不奇怪(请鼓掌),是 12。然而,试图从外部 main 函数调用 add_stuff 将导致编译器错误。

也可以定义一个局限于模块内部且外部不可访问的变量。例如,我们可以声明并初始化一个非导出的整数变量为 0,然后如果重新赋值,它将在模块内部保持新值。例如,我们可以在add_stuff函数内将其设置为n,然后在导出的函数中再次添加它,它将保持其重新分配的值为 6:

module;
#include <vector>
export module CppMod;
using std::vector;
export int vector_sum_of_ints();
int add_one(int n);
int k = 0;
int vector_sum_of_ints()
{
	vector<int> v = { 1,2,3 };
	int sum = 0;
	for (int elem : v) sum += elem;
	sum = add_k(sum);
	return sum + k;		// k still = 6; returns 18
}
int add_stuff(int n)
{
	k = n;			// k = 6
	return k + n;		// 12
}

现在结果为 18,但k局限于模块内部并且无法从外部访问这一点才更为重要。类似于非导出函数,尝试在main函数内使用k将无法编译通过。从概念上讲,非导出函数和变量都像类的私有成员一样,但是相对于模块和其非成员函数而言。

此外,可以通过将函数实现重新排列到所谓的私有片段中来更清晰地分离接口和实现。这必须是模块中的最后部分,并且用module: private将上面的声明与下面的实现分开:

module;
#include <vector>
export module CppMod;
using std::vector;
// Interface section is here:
export int vector_sum_of_ints();
int add_stuff(int n);
int k = 0;
// Implementations are placed in the private fragment:
module:private;
int vector_sum_of_ints()
{
	vector<int> v = { 1,2,3 };
	int sum = 0;
	for (int elem : v) sum += elem;
	sum = add_stuff(sum);
	return sum + k;		// k still = 6; returns 18
}
int add_stuff(int n)
{
	k = n;			// k = 6
	return k + n;		// 12
}

使用私有片段还据称可以防止使用模块的外部代码在模块内部发生更改时重新编译。对于这一点仍然存在一些模糊之处,希望在接下来的几个月内能够澄清。

我们很快将看到,在更实际的示例中,带有私有片段的模块如何有用。

标准库头单元

ISO C++委员会提交的重组标准库为“标准模块版本” [Stroustrup P2412r0] 的提案也已草拟并提交,以便包含在 C++20 中,但此努力已延期至 2023 年计划的下一个版本。在此期间,作为占位符,保证[“现有的标准库头文件的#include在 C++20 中会透明地变为模块导入” [ISO P1502R1] 已经可用。这基本上意味着预处理器语句,如

#include <vector>
#include <algorithm>

可以通过导入它们的头文件等价物进行替换:

import <vector>;		// Note these require a semicolon
import <algorithm>;

这适用于所有 C++标准库声明文件;然而,由于继承自 C 的头文件引起的复杂性,例如,分别基于遗留的 C 头文件<assert.h>和<math.h> – 这些不在范围内。

因此,在上述示例中,我们可以消除全局片段,并在export module语句下导入vector头文件;即,

export module CppMod;
import <vector>;
using std::vector;

如果需要#include标准库之外的其他头文件,作为预处理指令,必须放入模块的全局片段中。

除了导入标准库头文件单元的便利性外,它们还显示减少了构建时间和二进制文件的膨胀,尽管这并不总是保证的。

示例可以在 Stroustrup 的[P2412r0]中找到。

模块防止泄漏到其他模块

当导入到其他模型时,一个模块不会在其内部“泄漏”导入的模块。也就是说,如果模块A导入另一个模块B

// Define module A that imports module B:
export module A;
import B;

然后,如果将A导入到另一个模块C中,或者导入到Main.cpp文件中,B也不会隐式导入,除非它也显式地被导入:

// Define module C that imports module A:
export module C;
import A;
import B;		// Not implicitly imported with module A.
			// Must be explicitly imported if functions
			// in B are also to be used inside module C

这与#include的头文件不同,后者会泄漏。例如,头文件MyHeader.h包含用户定义的YourHeader.h和 STL 的<vector>头文件:

// MyHeader.h
#include “YourHeader.h”
#include <vector>

如果在Main.cpp#includeMyHeader.h,那么它将同时包含“YourHeader.h”中的函数和std::vector类:

// Main.cpp

#include “MyHeader.h”
int main()
{
	// This will compile:
	auto y = my_header_fcn(…);
	// But so will these lines:
	auto z = your_header_fcn(…);
	std::vector <double> v;
}

使用预处理器#include语句时,包含在YourHeader.hstd::vector中的内容会“泄漏”到main函数中。在实际情况下,可能涉及许多更多的头文件,迷失在哪些被包含和哪些没有被包含中可能会导致意外行为或运行时错误。此外,这也可能导致更长的构建时间。使用模块,程序员可以更好地控制导入的内容,并且构建时间可以大大缩短。

更多详细信息请参阅Ranier Grimm 的非常信息丰富的 ModernesCpp 博客网站

Black-Scholes 模块示例

对于更接近现实的金融示例,可以将 Black-Scholes 模型用于定价欧式股票期权,该模型可以写在一个模块内。但在编写该模型之前,我们需要一种可靠的方法来指示期权的支付类型,即认购或认沽。为此,一个名为Enums的模块将包含一个导出的枚举类PayoffType

对于 Black-Scholes 函数,将使用 James 的 Option Theory 中显示的数学公式。

包含枚举定义的模块

首先,为了防止ɸ的伪输入值,使用枚举类表示支付类型,并将其放置在自己的模块和独立文件中,例如Enums.ixx,以便可以在其他地方重复使用。

// File Enums.ixx
export module Enums;
export enum class PayoffType
{
	CALL,
	PUT
};

在实践中,可以附加表示债券类型、期货合约标识符、货币代码等的其他枚举类到这个模块中,然后根据需要将其导入其他定价和风险模块中。

Black-Scholes 公式模块

该模型需要自然对数函数和计算标准正态分布的累积分布函数的方法。在这里,我们很幸运,因为累积分布函数可以写成

其中 erf 是误差函数,并且它在 <cmath> 中可用,如自然对数函数 log。然而,由于此头文件在 C++20 标准中没有保证的头部单元,因此它需要在模块的全局片段中 #include

module;
#include <cmath>	// cstuff headers (derived from stuff.h) 
				// should be #include(d) in the global fragment

接下来是一个 export 语句,并定义了模块的名称,比如 BlackScholesFcns。为方便起见,所需的 <cmath> 函数 using 别名接下来会被引入。

export module BlackScholesFcns;
// <cmath> functions used below:
using std::log;
using std::erf;

公式还使用了 ,这在 C++20 中现在作为常量别名可用。它还需要确定期权到期时期权支付和零之间的最大值。为此,在 <numbers> 中的 sqrt2 常量和 <algorithms> 中的 std::max 函数都是可用的。由于 <numbers><algorithms> 作为头部单元可用,因此可以 import 而不是 #include

// Standard Library header units, and using aliases
import <numbers>;
using std::numbers::sqrt2;
import <algorithm>;
using std::max;

export import

BlackScholesFcns 模块需要 import Enums 模块,以指示期权是看涨还是看跌。但正如前面所述,使用模块的好处在于不会将已导入的模块泄漏到其他位置。这意味着如果将 BlackScholesFcns 导入到 Main.cpp 中,则程序员需要知道还需将 Enums 模块 import 进去。这不是一个理想的做法,因为这需要在可以在其他地方使用之前检查模块的源代码,看它导入了哪些模块。

幸运的是,export import 命令可用。它将首先将 Enums 模块 importBlackScholesFcns,然后在任何消耗后者的地方 export 它。

export import Enums;

这样,当 BlackScholesFcns 被导入到另一个模块或文件中时,就不需要在 BlackScholesFcns 的源代码中寻找已导入的 Enums,然后重新将其导入到新目标中。此外,一般来说,只有标记为 export import 的导入模块可以通过。这样可以避免由于意外泄漏的模块而导致的意外行为。

注意

由于 #include 语句,<cmath> 头文件将泄漏到导入 BlackScholesFcns 的目标中。这应该在预定用于 C++23 的标准模块版的标准库中得到纠正。

计算自然分为一个导出的 Black-Scholes 函数,以及计算 d1 和 d2 值,以及标准正态分布函数的两个私有函数。声明如下:

export double black_scholes_price(double strike, double spot, double rate,
	double sigma, double year_frac, PayoffType pot);
// Internal functions and variables
void dee_fcns(double strike, double rate, double spot,
	double sigma, double year_frac);
double norm_cdf(double x);

这里的关键点是模块用户只需关注 black_scholes_price 函数。调用其他两个函数的责任被委托给了导出函数。

因为 d1 和 d2 值是模块内部的,它们被声明和初始化,但也没有被导出。这使它们对模块中的函数可访问,但不对外部世界可访问,就像类的私有成员函数一样。

// Internal module variables
double d1 = 0.0, d2 = 0.0;		// d1 and d2 values in Black-Scholes

函数实现放在最后,首先是导出的black_scholes_price函数,然后是私有片段中的两个辅助函数。请注意,dee_fcns是一个void函数。与其有两个单独的函数来计算d1 和d2 值,不如通过在一个函数中设置d1d2变量的结果来完成,这两个变量对于模块是公共的,但对其用户是隐藏的。

double black_scholes_price(double strike, double spot, double rate,
	double sigma, double year_frac, PayoffType pot)
{
	double opt_price = 0.0;
	// phi, as in the James book: 
	double phi = (pot == PayoffType::CALL) ? 1.0 : -1.0;
	if (year_frac > 0.0)
	{		
		dee_fcns(strike, rate, spot, sigma, year_frac);
		double n_dee_one = norm_cdf(phi * d1);		// N(d1)
		double n_dee_two = norm_cdf(phi * d2); 	// N(d2)
		double disc_fctr = exp(-rate * year_frac);
		opt_price = phi * (spot * n_dee_one - disc_fctr * strike * n_dee_two);
	}
	else
	{
		opt_price = max(phi * (spot - strike), 0.0);
	}
	return opt_price;
}
module : private;
void dee_fcns(double strike, double rate, double spot,
	double sigma, double year_frac)
{
	double numer = log(spot / strike) + rate * year_frac
		+ 0.5 * year_frac * sigma * sigma;
	double sigma_sqrt = sigma * sqrt(year_frac);
	d1 = numer / sigma_sqrt;
	d2 = d1 - sigma_sqrt;
}
double norm_cdf(double x)
{
	return (1.0 + erf(x / sqrt2)) / 2.0;
}

Main.cpp文件中,放入

import BlackScholesFcns;

要导入模型,然后调用导出的函数;例如,一个在到期前约三个月的实值看跌期权。请注意,可以分配支付枚举类型,因为Enums模块是通过export import命令从BlackScholesFcns导出的。

	strike = 200.0;
	porc = PayoffType::PUT;
	spot = 185.0;
	rate = 0.05;
	sigma = 0.25;
	year_frac = 0.25;
	cout << "Put Option price = "
		<< black_scholes_price(strike, spot, rate, sigma, year_frac, porc )
		<< endl;

这给出了一个价格为 17.0649。

如(希望)所见,模块除了其他好处外,在实施需要大量内部计算但使用者不必关心的金融模型时也非常有用。这种方式,中间值可以封装成类的私有变量一样,而不是在函数之间传递并暴露于意外修改之下,而且不会增加对象的开销。对于比布莱克-斯科尔斯模型复杂的模型 - 当然存在许多模型 - 这意味着更少的外部可访问的运动部件,因此出错的可能性更少。

这并不是说使用类来实现金融模型是错误的,但是选择使用非成员函数的模块而不是类将取决于设计决策。也许有些情况下,将一组模型符合在抽象基类上设置的契约中可能更可取,正如我们将在下一章讨论的那样。因此,真正的问题在于在设计阶段考虑到的每个要求时权衡每种方法的利弊。

至于编写类,现代方法也涉及使用模块。这是下一节的主题。

模块中的用户定义类实现

在 C++中写(非模板)类的传统方法也是在头文件中编写声明,在单独的文件中编写实现。对于类来说,这在代码维护方面可能也有好处,因为仅仅是声明 - 假设成员函数和变量命名具有信息性 - 本质上就提前呈现了类的概要,而没有来自所有函数实现的“杂乱”。

转向模块,再次,现在可以将声明和实现放在单个文件中,同时保持将类声明与实现分开的最佳实践。这样一来,可以减少编译时间,而且在头文件中找到的类的概述也被保存在模块内部。

除了用来保存名为BlackScholes的类的新模块名字以外,

export module BlackScholesClass;

与非成员函数版本相同的初步 importexportexport import 语句保持不变。然后,将类声明而不是单独的函数声明写入,注意整个类声明都放在 export class 语句的作用域内。

export class BlackScholes
{
public:
	double black_scholes_price(double strike, double spot, double rate,
		double sigma, double year_frac, PayoffType pot);
private:
	void dee_fcns_(double strike, double rate, double spot,
		double sigma, double year_frac);
	double norm_cdf_(double x);
	double d1_ = 0.0, d2_ = 0.0;
};

成员函数与之前相同,但与类名一起作用域:

double BlackScholes::black_scholes_price(double strike, double spot, double rate,
	double sigma, double year_frac, PayoffType pot)
{
	double opt_price = 0.0;
	// phi, as in the James book: 
	double phi = (pot == PayoffType::CALL) ? 1.0 : -1.0;
	if (year_frac > 0.0) . . . 
	return opt_price;
}
etc. . .

由于类声明标记为 export,实现被隐式导出,所以不需要对公共的 black_scholes_price 函数进行 export

在模块中使用命名空间

命名空间经常被用来防止来自不同源文件或库的具有相同名称和签名的函数之间的名称冲突。它们也可以防止来自两个不同模块的类似编译器错误。

假设有两个模块,ThisModuleThatModule,每个模块都包含一个 maximum 函数,用于返回两个实数的最大值。

export module ThisModule;
export double maximum(double x, double y)
{
	double max_val = x > y ? x : y;
	return max_val;
}
export module ThatModule;
export double maximum(double x, double y)
{
	double max_val = 2*x > 2*y ? x : y;
	return max_val;
}

如果两个模块都被导入到另一个翻译单元(例如 Main.cpp)中,并且调用 maximum 函数,则编译器将无法确定所需函数的版本,导致编译器错误。

// In some other location:
import ThisModule;
import ThatModule;
//. . .
double compare_max(double x, double y)
{
	return maximum(x, y);	// Compiler error!
} 

通过将 maximum 函数包装在一个独立的命名空间中,并在模块外部调用时指定命名空间作用域,可以避免编译器错误。

export module ThisModule;
export namespace this_nsp
{
	export double maximum(double x, double y)
	{
		double max_val = x > y ? x : y;
		return max_val;
	}
}
export module ThatModule;
export namespace that_nsp
{
	export double maximum(double x, double y)
	{
		double max_val = 2*x > 2*y ? x : y;
		return max_val;
	}
}

现在,就像我们对标准库中的类函数一样,将函数调用限定在一个命名空间中,并且代码将能够编译:

import ThisModule;
import ThatModule;
//. . .
double compare_max(double x, double y)
{
	return this_nsp::maximum(x, y);	// Will now compile
}

另一种方法是为一个命名空间使用 using 别名:

import ThisModule;
import ThatModule;
using that_nsp::maximum
// …
double compare_max(double x, double y)
{
	return this_nsp::maximum(x, y);	// Will also compile
}

注意,如果在 Main.cpp 中,每个命名空间的全部内容都加载到全局命名空间中,使用 using namespace 语句不对 maximum 函数进行作用域限定,将导致同样的问题:

import ThisModule;
import ThatModule;
using namespace this_nsp;
using namespace that_nsp;
// . . .
double compare_max(double x, double y)
{
	return maximum(x, y);	// Compiler error!
}

作为推论,现在您可以看到为什么在全局使用

`using namespace::std;`

也不被视为良好的实践。由于标准库中有如此多的类和函数,完全有可能某人会在其他地方使用常见名称作为用户定义的类或函数,导致名称冲突从而导致编译器错误。

注意

许多 C++ 的新特性,从 C++11 开始,都源于 Boost 库。例如,智能指针 unique_ptrshared_ptr 在 Boost 和标准库中使用相同的名称。由于 Boost 库被广泛使用,全局打开 stdboost 命名空间可能会给代码维护和构建团队带来麻烦。

特别是智能指针和 Boost 库将在本书的后续章节中详细讨论。

我们在这里讨论命名空间更具体地与模块相关,但有关在 C++ 中使用命名空间的最佳实践的更多信息,请参阅 Sutter 和 Alexandrescu 的《编码风格》第 X 章仍然是一个很好的资源。

作为一个总体结论,属于命名空间的类和函数应明确作用域;例如,

import <vector>;
import ThisModule;
//. . .
std::vector<double> v;
return this_nsp::maximum(x, y);

或包含在使用别名中;

import <vector>;
using std::vector;
import ThisModule;
using this_nsp::maximum; 

而不是全局暴露整个命名空间。

摘要

待定…

一些参考资料:

www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1502r1.html

www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1453r0.html

www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0581r1.pdf

www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p2412r0.pdf

devblogs.microsoft.com/cppblog/a-tour-of-cpp-modules-in-visual-studio/

github.com/microsoft/STL/issues/60

www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0955r0.pdf

第四章:日期和固定收益证券

日期和日期计算可能看起来不是讨论的最引人注目的主题,但在量化金融中至关重要,特别是在固定收益交易和分析中。

与过去的分布式随机数生成类似,金融 C++程序员面临相似的选择:要么编写自己的日期类和函数,要么使用商业或开源的外部库。这在 C++20 中已经改变。它包括一个日期类,该类由整数年、月和日值确定。该类依赖于已经存在的(自 C++11 以来)std::chrono的持续时间、时间点和系统时钟 - 即,时间计算 - 以及基于相对纪元的天数的日历计算,并考虑每个月的非均匀天数。

虽然本章的目标是展示如何在金融应用中使用新的日期特性,但std::chrono的作者 Howard Hinnant 在他的 GitHub 网站上提供了更多关于其原始开发代码的细节,以及std::chrono日期的详细信息{1}。(此后将称为“GitHub 日期代码站点”)。

使用std::chrono中的日历选项可以影响年份和月份的添加,但添加天数需要转换为时间点。这些是需要在适当时候讨论的重要操作,但首先让我们来看看 C++20 中如何表示和实例化日期。从那里开始,我们将看看在金融中所需的常见日期计算,一个封装这些功能的类,日计数约定和收益曲线,最后是在估值付息债券中的应用。

日期的表示

C++11 将std::chrono库引入了标准库中,提供了以下抽象:

  • 时间段:在给定时间间隔内的测量方法,例如以分钟、天、毫秒等为单位

  • 时间点:相对于纪元的时间段,例如 UNIX 纪元 1970-1-1

  • 时钟:指定纪元并标准化持续时间测量的对象

std::chrono中的日期基于这些时间基础,但作为新 C++20 功能的一部分,现在也提供了到日历形式的转换。这些可用于涉及年份和月份的计算。

std::chrono中,标准日期由类std::chrono::year_month_day的对象表示。这个类有各种构造函数,其中几个在这里讨论。

首先,提供了一个接受年、月和日的构造函数。但是,每个构造函数参数不是整数值,而是必须定义为单独的std::chrono::yearstd::chrono::monthstd::chrono::day对象。例如,要创建一个保存日期为 2002 年 11 月 14 日的对象,我们将如下创建它。

import <chrono>;

std::chrono::year_month_day ymd{ std::chrono::year{2002},
	std::chrono::month{11}, std::chrono::day{14} };

或者,通过名称在std::chrono中定义单独的常量month对象,因此构造上述相同月份的等效方法是用先前示例中构造的月份对象替换为预定义的November实例:

std::chrono::year_month_day ymd_alt{ std::chrono::year{2002},
	std::chrono::November, std::chrono::day(14) };

对于赋值,/运算符也已被重载以定义一个year_month_day对象:

ymd = std::chrono::year{ 2002 } / std::chrono::month{11} / std::chrono::day{14};

可以使用不同的顺序,以及整数类型,只要第一个参数是明显的。对于 yyyy/mm/dd 格式,放置

ymd = std::chrono::year{ 2002 } / 11 / 14;

将产生相同的结果,编译器将 11 和 14 解释为unsigned类型。也可以使用 mm/dd/yyyy 格式:auto mdy = std::chrono::November / 14 / 2002;

在这种情况下,14 被识别为unsigned,年份被识别为int。在std::chrono中,monthday类型可以转换为unsigned,而year只能转换为int。上面的示例并非详尽无遗,更全面的列表可以在 GitHub 日期代码网站{1}上找到。

请注意,输出流运算符已被重载为year_month_day,因此可以使用cout将上述任何内容输出到控制台。例如,

cout << ymd << endl;

将在屏幕上显示日期为2002-11-14

1.1 串行表示和日期差异

一个year_month_day日期也可以根据自纪元以来的天数来衡量,system_clock的默认值是 UNIX 纪元 1970 年 1 月 1 日。类似于 Excel - 其纪元是 1900 年 1 月 1 日 - 这种表示法对于金融中某些类型的日期算术可能很方便,特别是确定两个日期之间的天数。然而,与 Excel 不同,UNIX 纪元由 0 而不是 1 表示,即串行日期是以自纪元以来的天数来衡量的。考虑以下示例,日期为 1970-1-1 和 1970-1-2。

std::chrono::year_month_day epoch{ date::year{1970}, date::month{1}, date::day{1} };
std::chrono::year_month_day epoch_plus_1{ date::year{1970}, date::month{1}, date::day{2} };

然后,可以按如下方式访问相应的串行日期:

int first_days_test =
	std::chrono::sys_days(epoch).time_since_epoch().count();		// 0

first_days_test =
	std::chrono::sys_days(epoch_plus_1).time_since_epoch().count();	// 1

这些分别返回int值 0 和 1。与 Excel 不同,std::chrono日期在纪元之前也是有效的,但带有负整数值。在接下来的语句中,返回的值为-1。

first_days_test =
	std::chrono::sys_days(epoch_minus_1).time_since_epoch().count();	// -1

对于典型的金融交易,通常不需要回溯到 1970 年之前,但在一些领域,如养老金责任的精算估值中,许多退休人员出生在此日期之前。市场的历史模拟也可能使用追溯数十年的数据。

回想一下year_month_day类是建立在最初列出的三个std::chrono抽象之上的,从技术上讲,在这里发生的是sys_days运算符将ymd日期返回为std::chrono::time_point对象,其中sys_daystime_point的别名。然后,它的time_since_epoch成员函数返回一个std::chrono::duration类型。然后,相应的整数值通过count函数访问。

在金融中一个重要的计算是两个日期之间的天数。再次使用 ymd 作为 2002-11-14,并将 ymd_later 初始化为六个月后的日期 - 2003-5-14 - 取得两个 sys_days 对象的差异,然后应用 count 函数来计算差异:

// ymd = 2002-11-14
// ymd_later = 2003-5-14

auto diff = (std::chrono::sys_days(ymd_later) –
	std::chrono::sys_days(ymd)).count();		// 181

结果为 181,再次返回为一个 int

接下来,在处理日期时,通常需要执行几个检查,即日期是否有效,是否为闰年,找到一个月中的天数,以及日期是否为周末。在 std::chrono 中方便地包含了一些函数来处理这些检查,但在其他情况下,可能需要更多的工作。

1.2 年、月和日的访问函数

为了获取年、月和日,提供了在 year_month_day 上的访问函数,但它们作为它们各自的 yearmonthday 对象返回。

year()		// returns std::chrono::year
month()		// returns std::chrono::month
day()		// returns std::chrono::day

假设我们有两个 year_month_day 对象 date1date2,方便地应用于差异(一个 duration)的 count 函数,结果是 int 类型。

(date2.year() - date1.year()).count()		// returns int
(date2.month() - date1.month()).count()		// returns int
(date2.day() - date1.day()).count()			// returns int

每个单独访问的年、月和日组件也可以转换为整数(在数学意义上),但需要注意的重要一点是 year 可以转换为 int,但对于 monthday,这些需要转换为 unsigned

auto the_year = static_cast<int>(date1.year());
auto the_month = static_cast<unsigned>(date1.month());
auto the_day = static_cast<unsigned>(date1.day());
注意

未来,为了方便起见,我们将使用命名空间别名 namespace date = std::chrono;

1.3 日期的有效性

year_month_day 对象可以设置为无效日期。例如,稍后将看到,将一个日期设置为 1 月 31 日再加一个月会导致 2 月 31 日。此外,构造函数还允许月份和日期值超出范围。不会抛出异常,而是由程序员来检查日期是否有效。幸运的是,可以通过布尔值 ok 成员函数轻松实现这一点。在下面的例子中,ymd 日期(与上述相同)是有效的,而其后的两个显然是无效的。

// date is now an alias for std::chrono

date::year_month_day ymd{ date::year{2002},
	date::month{11}, date::day{14} };

bool torf = ymd.ok();			// true

date::year_month_day negative_year{ date::year{-1000},
	date::October, date::day{10} };

torf = negative_year.ok();		// true – negative year is valid

date::year_month_day ymd_invalid{ date::year{2018},
	date::month{2}, date::day{31} };

torf = ymd_invalid.ok();			// false

date::year_month_day ymd_completely_bogus{ date::year{-2004},
	date::month{19}, date::day{58} };

torf = ymd_completely_bogus.ok();	// false

在后续的示例中,ok 成员函数将非常有用,特别是在日期操作导致正确的年和月,但在月末情况下设置错误的日期时。很快就会解决这个问题。总之,year_month_day 类的消费者需要检查有效性,因为它不会抛出异常或自动调整。

1.4 闰年和月末最后一天

您可以轻松地检查一个日期是否为闰年。一个布尔值成员函数,不出所料地称为 is_leap,帮助我们处理这个问题:

date::year_month_day ymd_leap{ date::year{2016},
	date::month{10}, date::day{26} };

torf = ymd_leap.year().is_leap()		// true

year_month_day 上没有一个成员函数可以返回月的最后一天。可以使用 std::chrono 中的一个单独类来解决这个问题,表示月末日期 year_month_day_last,从中也可以像以前一样访问其月的最后一天,然后转换为无符号整数。

date::year_month_day_last
	eom_apr{ date::year{ 2009 } / date:April / date::last };

auto last_day = static_cast<unsigned>(eom_apr.day());	// result = 30

这也可以用作检查日期是否落在月末的一种方法:

date::year_month_day ymd_eom{ date::year{2009},
	date::month{4}, date::day{30} };

bool torf = ymd_eom == eom_apr;		// Returns true (torf = "true or false")

对于任意日期,月底最后一天也可以确定:

date::year_month_day ymd = date::year{ 2024 } / 2 / 21;

year_month_day_last
	eom{ date::year{ ymd.year() } / date::month{ ymd.month() } / date::last };

last_day = static_cast<unsigned>(eom.day());	// result = 29

还应注意,year_month_day_last类型通过重新分配隐式转换为year_month_day类型:

ymd = eom_apr;			// ymd is now 2009-04-30

更多背景信息可以在{2}中找到。

尽管这有效,但每次调用时都带有创建year_month_day_last的开销,并在重新分配时有额外的对象复制,就像上面代码示例的最后一行所示。尽管结果可能有所不同,但在金融系统中管理大量交易和包含固定收益证券的大型投资组合时,这可能会对性能产生负面影响。

在 GitHub 日期代码站点{3}的其他地方提供了一组“chrono兼容的低级日期算法”。这些替代方法适用于独立于year_month_day类方法的方法,并且它们在文档中的描述中声明这些低级算法是“能够编写自己的日期类的关键算法”。这是我们最终要达到的方向。

要确定月末的最后一天,可以从本组算法提供的代码中推导出一个更有效的用户定义函数,如下所示:

// User-defined last_day_of_the_month
unsigned last_day_of_the_month(const std::chrono::year_month_day& ymd)
{
    constexpr std::array<unsigned, 12>
        normal_end_dates{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };

    if (!(ymd.month() == date::February && ymd.year().is_leap()))
    {
        unsigned m = static_cast<unsigned>(ymd.month());
        return normal_end_dates[m - 1];
    }
    else
    {
        return 29;
    }
}

这更像是一种通过硬编码非闰年每个月的天数来暴力解决的方法,但它确实避免了额外的对象创建和复制。

这里使用的另一个要点是constexpr,这是 C++11 中添加的另一个语言特性。由于array的长度和其内容是预先已知的,constexpr指示编译器在编译时初始化end_dates,因此消除了每次调用last_day_of_the_month函数时的重新初始化。接下来有两个相关的要点:

  1. 在这种特定情况下使用constexpr可能对性能产生重要影响,但在计算密集型代码中多次调用函数的情况下可能有效,例如在投资组合风险模拟和计算中。

  2. 大多数金融数据,如市场和交易数据,将不可避免地是动态的,因此在编译时是未知的,因此constexpr在金融应用中的使用可能有限。然而,上面的例子演示了何时和如何可以有效地使用它的示例。

1.5 周工作日和周末

与月底日期类似,没有成员函数可以检查日期是否落在周末。再次有一个解决方法,我们可以从中得到我们需要的结果。

std::chrono包含一个weekday类,表示一周的日子,从星期一到星期日,而不仅仅是工作日(这里的术语可能有些令人困惑)。它可以通过在构造函数参数中再次应用sys_days运算符来构造。

// Define a year_month_day date that falls on a business day (Wednesday)

date::year_month_day ymd_biz_day{ date::year{2022},
	date::month{10}, date::day{26} };	// Wednesday

// Its day of the week can be constructed as a weekday object:
date::weekday dw{ date::sys_days(ymd_biz_day) };

iso_encoding 成员函数返回的无符号整数值可以标识星期几,其中值 1 到 7 分别代表星期一到星期日。流操作符被重载,以便显示缩写的星期几。

unsigned iso_code = dw.iso_encoding();
cout << ymd_biz_day << ", " << dw << ", " << iso_code << endl;

输出结果是 2022-10-26, Wed, 3,这使我们可以定义自己的函数,比如这里的 lambda 函数,来判断一个日期是否为周末。

auto is_weekend = [](const date::year_month_day& ymd)->bool
{
	date::weekday dw{ date::sys_days(ymd) };
	return dw.iso_encoding() >= 6;
};

现在,还构造一个落在星期六的 year_month_day 日期:

date::year_month_day ymd_weekend{ date::year{2022},
	date::month{10}, date::day{29} };	// Saturday

然后,我们可以使用 lambda 函数来测试每一天是否是工作日。

torf = is_weekend(ymd_biz_day);		// false (Wed)
torf = is_weekend(ymd_weekend);		// true (Sat)

std::chrono 中可以找到关于周末的补充信息,详见 {4}。

1.6 添加年、月和日

另一个金融重要日期操作是向现有日期添加年、月和日。这些操作尤其适用于生成固定支付计划。添加年或月非常简单,使用 += 运算符,但添加天数则涉及不同的方法。

1.6.1 添加年份

添加年份非常简单。例如,将两年添加到 2002-11-14,然后再向结果添加另外 18 年。需要注意,要添加的年数必须表示为 std::chrono::years 对象,这是一个代表一年的 duration 的别名。

// Start with 2002-11-14
date::year_month_day ymd{ date::year{2002}, date::month{11}, date::day{14} };

ymd += date::years{ 2 };		// ymd is now 2004-11-14
ymd += date::years{ 18 };		// ymd is now 2022-11-14

然而,我们遇到了一个问题,如果日期是闰年二月的最后一天。向 2016-02-29 加两年会导致无效年份。

date::year_month_day
	ymd_feb_end{ date::year{2016}, date::month{2}, date::day{29} };

ymd_feb_end += date::years{ 2 };	// Invalid result: 2018-02-29

std::chrono 中,日期既不会抛出异常,也不会调整日期,因此开发人员需要处理将年份添加到闰年二月 29 日的情况。

1.6.2 添加月份和月末边缘情况

year_month_day 对象添加月份类似于添加年份,但现在需要处理多个月末边缘情况,因为不同月份的天数不同,还有闰年中二月的情况。

如果没有涉及月末日期,操作非常直接,类似于添加年份,使用加法赋值运算符。与添加年份类似,月份数量需要表示为 duration 对象,在这种情况下是一个代表一个月的周期的别名。

date::year_month_day ymd{ date::year{2002}, date::month{11}, date::day{14} };
ymd += date::months(1);			// Result: 2002-12-14
ymd += date::months(18);		// Result: 2004-06-14

也可以使用减法赋值:

ymd -= date::months(2);		// Result: 2004-04-14

同样,使用 += 运算符进行月末情况操作可能会导致无效日期。为了验证这一点,构造以下月末日期:

date::year_month_day ymd_eom_1{ date::year{2015}, date::month{1},
	date::day{31} };
date::year_month_day ymd_eom_2{ date::year{2014}, date::month{8},
	date::day{31} };
date::year_month_day ymd_eom_3{ date::year{2016}, date::month{2},
	date::day{29} };

简单地尝试添加月份会导致无效日期:

ymd_eom_1 += date::months{ 1 };		// 2015-02-31 is not a valid date
ymd_eom_2 += date::months{ 1 };		// 2014-09-31 is not a valid date
ymd_eom_3 += date::months{ 12 };	// 2017-02-29 is not a valid date

虽然结果无效,但每一个年份和月份都是正确的。也就是说,例如,将 2015-01-31 加一个月应映射到 2015-02-28。

反过来说,如果我们从 2015-02-28 开始,再加一个月,结果将是正确的:2015-03-28。

回顾先前定义的last_day_of_the_month函数,一个解决方法相当简单。添加赋值是天真地应用的,但如果结果无效,则必须是由于天数值超过了一个月中实际天数。在这种情况下,因为结果年份和月份将是有效的,所以这只是将天数重置为该月的天数。

auto add_months = [](date::year_month_day& ymd, unsigned mths) -> void
{
    ymd += date::months(mths);    // Naively attempt the addition

    if (!ymd.ok())
    {
        ymd = ymd.year() / ymd.month() / date::day{ last_day_of_the_month(ymd) };
    }
}

1.6.3 添加天数

对于年份和月份而言,并没有为添加天数定义+=运算符。因此,在添加天数之前,我们需要获取sys_days的等效值。

date::year_month_day ymd{date::year(2022), date::month(10), date::day(7)};

// Obtain the sys_days equivalent of ymd, and then add three days:
auto add_days = date::sys_days(ymd3) + date::days(3);  // ymd still = 2022/10/07

注意,在这一点上,ymd还没有被修改,而结果add_days也是一个sys_days类型。为了将year_month_day对象设置为等效值,赋值运算符提供了隐式转换。类似于先前应用的sys_days,我们只需将原始ymd日期更新为三天后:

ymd = add_days;	// Implicit conversion to year_month_day
				// ymd is now = 2022-10-10

更多有关添加天数的信息可以在{5}中找到。

一个日期类包装器

正如你现在可能看到的那样,管理std::chrono日期的所有复杂性最终可能变得复杂起来。因此,我们现在将概述财务日期计算的典型要求,并在基于year_month_day成员的类中声明它们。这样,调整和year_month_day函数调用将在接口成员函数和运算符的后面一次性实现,这些运算符对于消费者来说可能更直观。这些可以分为两大类,即检查日期可能状态和执行日期的算术操作。我们已经涵盖的总结列在以下的要求列表中。大多数这些结果将被集成到类的实现中。

状态

  • 月份中的天数

  • 闰年

算术操作

  • 两个日期之间的天数

  • 添加

    • 年份

    • 天数

    • 月份

我们想要的额外功能列在下面。这些额外要求也将成为实现的一部分。

访问器

  • 年、月、日

  • 串行日期整数表示(自纪元以来的天数)

  • year_month_day数据成员

比较运算符

==
<=>

首先,类声明将为我们提供一个实现路线图来遵循。

类声明

我们将上述列出的要求整合到一个名为ChronoDate的类中。它将包装一个std::chrono::year_month_day对象以及一些在财务计算中有用的关联成员函数。日期对象的唯一其他数据成员将是日期对象的串行日期表示。

在处理成员函数之前,让我们从构造函数开始。

构造函数

为了方便起见,提供了一个构造函数,它接受整数值用于年、月和日,而不是要求用户创建单独的 yearmonthday 对象。ChronoDate{ int year, unsigned month, unsigned day }; 请注意,年份的参数是 int,而月份和日期的参数是 unsigned。这是由于year_month_day类的设计,正如前面讨论的那样。

正如我们稍后将看到的方便起见,第二个构造函数将接受一个 year_month_day 对象:

ChronoDate{ date::year_month_day };

最后,一个默认构造函数将构造一个设置为 UNIX 纪元的 ChronoDate

公共成员函数和运算符

这些大部分应该从下面的成员函数声明中可以自解释。此外,主要是将先前开发的功能集成到各自的成员函数中。至于比较运算符 ==<⇒,以及友元流运算符,这些已经在 year_month_day 类上定义好了,因此只需将它们包装成 ChronoDate 上的相同运算符即可。

在声明中还有一个未涵盖的公共函数 weekend_roll,它将用于在日期落在星期六或星期日时将日期滚动到最近的工作日。其实现将很快介绍。

// Check state:
int days_in_month() const;
bool leap_year() const;

// Arithmetic operations:
unsigned operator - (const ChronoDate& rhs) const;
ChronoDate& add_years(int rhs_years);
ChronoDate& add_months(int rhs_months);
ChronoDate& add_days(int rhs_days);

// Accessors
int year() const;
unsigned month() const;
unsigned day() const;
int serial_date() const;
date::year_month_day ymd() const;

// Modfying function
ChronoDate& weekend_roll(); 		 // Roll to business day if weekend

// Operators
bool operator == (const ChronoDate& rhs) const;
std::strong_ordering operator <=> (const ChronoDate& rhs) const;

// friend operator so that we can output date details with cout
friend std::ostream& operator << (std::ostream& os, const ChronoDate& rhs);

私有成员和辅助函数

两个私有成员变量将存储基础的 year_month_day 对象和日期的序列化表示。一个私有函数将封装所需的函数调用,以获取自 UNIX 纪元以来的天数,以便可以在构造时设置序列化日期,并且在修改类对象的状态时随时更新。

private:
    date::year_month_day date_;
    int serial_date_;
    void reset_serial_date_();

类的实现

由于我们几乎已经具备了所有必要的功能,剩下的主要是将其包装成成员函数,再实现 weekend_roll 函数和一些私有辅助函数。此外,还介绍了两个构造函数,以及私有的 reset_serial_date_ 方法,它将计算并设置日期的序列化表示,无论是在构造时,还是在修改活动 ChronoDate 的状态时。

构造函数

第一个声明的构造函数的实现允许使用整数值(intunsigned)创建一个 ChronoDate 的实例,而不是需要单独的 yearmonthday 对象。

ChronoDate::ChronoDate(int year, unsigned month, unsigned day) :
	date_{ date::year{year} / date::month{month} / date::day{day} }
{
	if(!date_.ok())		// std::chrono member function to check if valid date
	{
		throw std::exception e{ "ChronoDate constructor: Invalid date." };
	}
	reset_serial_date_();
}

还要记住,由于可能构造无效的 year_month_day 对象,比如 2 月 30 日,构造函数中还包括一个验证检查,利用 year_month_day 上的 ok 成员函数。在构造日期时还需要进行的一个设置是序列化日期。这委托给了私有方法 reset_serial_date_。正如本章开头所示,这是对 sys_days 操作符的应用,以提供自 UNIX 纪元以来的天数。

void ChronoDate::reset_serial_date_()
{
	serial_date_ = date::sys_days(date_).time_since_epoch().count();
}

这个函数也将从每个修改成员函数中调用。

最后,默认构造函数仅将日期设置为 UNIX 纪元,并将序列日期初始化为 0:

ChronoDate::ChronoDate():date_{date::year(1970), date::month{1}, date::day{1} } :
    serial_date_{0} {}

成员函数和运算符

以下描述了先前在声明部分引入的函数的实现。

访问器

为序列日期和year_month_day成员实现访问器的实现是微不足道的,但在返回年份、月份和日期的整数值时需要做更多工作。std::chrono::year对象可以转换为int,而monthday可以转换为unsigned类型。考虑到这一点,它们的访问器实现是直接的:

int ChronoDate::year() const
{
	return static_cast<int>(date_.year());
}

unsigned ChronoDate::month() const
{
	return static_cast<unsigned>(date_.month());
}

unsigned ChronoDate::day() const
{
	return static_cast<unsigned>(date_.day());
}

状态方法

检查日期是否为闰年只需包装相应的year_month_day成员函数即可。

bool ChronoDate::leap_year() const
{
	return date_.year().is_leap();
}

获取月份中的天数更加复杂,但只是对来自第[1.4]节中std::chrono低级算法的函数进行了重新调整。

unsigned ChronoDate::days_in_month() const
{
	unsigned m = static_cast<unsigned>(date_.month());
	std::array<unsigned, 12>
        normal_end_dates{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };

	return (m != 2 || !date_.year().is_leap() ? normal_end_dates[m - 1] : 29);
}

算术运算

这些是将用于典型固定收益应用程序的核心成员函数,例如计算年分数和生成支付计划。首先,让我们重新审视计算两个日期之间天数的计算。由于我们已经在类上存储了序列日期,并且仅在构造或修改日期时更新它,因此我们可以删除sys_days转换和函数调用,并将减法运算符实现为整数等效值之间的差异。

unsigned ChronoDate::operator - (const ChronoDate& rhs) const
{
	return this->serial_date_ - rhs.serial_date_;
}

添加年份和月份也很简单,因为现在我们有了处理问题月末问题的方法。添加年份时唯一的问题是,如果结果日期落在非闰年的 2 月 29 日,那么可以通过将天数值重置为 28 来轻松解决此问题。请注意,由于结果基于底层的year_month_day +=运算符,对象的状态被修改,因此有必要同时更新序列日期。

ChronoDate& ChronoDate::add_years(int rhs_years)
{
	// Proceed naively:
	date_ += date::years(rhs_years);

	if (!date_.ok())
	{
		date_ = date_.year() / date_.month() / 28;
	}

	reset_serial_date_();

	return *this;
}

当向日期添加月份时,情况变得更加复杂,因为每个月的天数不同,并且在 2 月中有闰年条件,但现在有了days_in_month成员函数,这变得相对容易。再次尝试添加月份时,如果结果月份无效,则调整天数。这种错误状态只有在朴素结果的天数超过其相应月份的天数时才会发生。

ChronoDate& ChronoDate::add_months(int rhs_months)
{
	date_ += date::months(rhs_months);    // Naively attempt the addition

	// If the date is invalid, it is because the
	// result is an invalid end-of-month:
	if (!date_.ok())
	{
		date_ = date_.year() / date_.month() / date::day{ days_in_month() };
	}

	reset_serial_date_();

	return *this;
}

正如前面所见,没有添加天数的加法赋值运算符,因此在std::chrono中,这将需要转换为sys_days

ChronoDate& ChronoDate::add_days(int rhs_days)
{
	date_ = date::sys_days(date_) + date::days(rhs_days);

	return *this;
}

注意,将sys_days的总和和要添加的days隐式转换回year_month_day对象时,将分配给date_成员。关于此背后的更多细节也在{5}中可用。

工作日滚动规则

我们还没有讨论的一个重要函数是将周末日期滚动到下一个工作日的函数。

在实践中,有各种常用的计息方法。在本讨论中,我们将选择一个在实践中经常使用的方法,即 Modified Following 规则。在继续之前,让我们回顾一下使用std::chrono中的weekday类确定星期几的方法。

如前所述,“weekday”这个术语可能有些混淆。它不是指星期一到星期五,而是指一周中的“星期几”。iso_encoding成员函数将返回每周的整数代码,从星期一的 1 开始,到星期日的 7;因此,6 或 7 的值将表示日期落在周末。

weekend_roll函数将简单地重用这个功能,首先确定日期是否落在周末。如果是,它将首先朴素地向前滚动到下一个星期一。然而,如果这个新日期进到了下个月,它将滚动回到上个工作日,即原月份的星期五。这就是为什么首先存储原月份的原因。

ChronoDate& ChronoDate::weekend_roll() {
    date::weekday wd{ sys_days(date_) };
    month orig_mth{ date_.month() };

    unsigned wdn{ wd.iso_encoding() }; // Mon =  1, ..., Sat = 6, Sun = 7
    if (wdn > 5) date_ = sys_days(date_) + days(8 - wdn);

    // If advance to next month, roll back; also handle roll to January
    if (orig_mth < date_.month()
        || (orig_mth == December && date_.month() == January))
            date_ = sys_days(date_) - days(3);

    reset_serial_date_();
    return *this;
}

滚动日期将被修改,因此在这里也需要更新序列日期。

比较和流运算符

比较运算符==<⇒是即时的,因为它们对year_month_day进行了定义。我们只需要确保对<⇒使用std::strong_ordering作为返回类型,因为最终比较的是两个整数值 - 自历元以来的天数。

bool ChronoDate::operator == (const ChronoDate& rhs) const
{
	return date_ == rhs.date_;
}

std::strong_ordering ChronoDate::operator <=> (const ChronoDate& rhs) const
{
	return date_ <=> rhs.date_;
}

我们还可以依靠year_month_day的流操作符,并将其定义为ChronoDate的友元运算符。

// This is a 'friend' of the ChronoDate class
export std::ostream& operator << (std::ostream& os, const ChronoDate& rhs)
{
	os << rhs.ymd();
	return os;
}
注意

因为这个操作符是ChronoDate类的友元,所以即使在同一个模块中包含它,也需要单独的实现export

现在ChronoDate类已准备就绪,我们可以继续处理与固定收益交易相关的日计数约定和其他通常所需的组件。

日计数基准

日计数基准用于将两个日期之间的间隔转换为以年为单位的时间,或者年分数,在固定收益交易中通常被称为。每当进行利息计算时都会使用日计数基准。利率由三个属性定义:年百分比值,例如 3%,类型,例如简单或复利,以及日计数基准。考虑一个定期存款,1000 美元以 3%复利投资,投资于 2022 年 10 月 25 日,到期于 2023 年 12 月 31 日。计算 F,即到期时投资的价值的公式为

F = 1000 (1+0.03) t

t的值取决于日计数基准。美国和欧盟的货币市场计算最可能使用 Actual/360 日计数基准:

A c t 360 ( d 1 , d 2 ) = d 2 -d 1 360

在英国、加拿大和澳大利亚的货币市场中,实际/365 日计算基数 —— 将 360 替换为 365 —— 更为常见。在更广泛的固定收益交易中使用的其他常见日计算基数包括 30/360 方法,该方法假设每个月有 30 天,一年有 360 天。实际/实际方法使用分子和分母中的实际天数。在股票组合管理中,经常使用实际/252 基数,假设一年有 252 个工作日。

在 C++中实现日计算约定是接口继承能够有用的一个例子。我们可以定义一个纯抽象基类,强制实施日计算调整年分数的实现,然后由派生类实施具体的计算。

接口简单声明一个纯虚拟的 operator() 用于派生类的计算。

export class DayCount
{
public:
	virtual double operator()
        (const ChronoDate& date1, const ChronoDate& date2) const = 0;

	virtual ~DayCount() = default;
};

实际/365 年分数计算是微不足道的:

export class Act365 : public DayCount
{
public:
	double operator() (const ChronoDate& date1, const ChronoDate& date2) const
        override
    {
        return (date2 - date1) / 365.0;
    }
};

一个 Act360 类将与之相同,只是分母被 360 替换。

30/360 情况稍微复杂,因为分子必须根据公式 {放在这里} 首先进行计算。

日值的月末调整将取决于 30/360 的具体形式,其中有几种可以依赖于交易桌的地理位置。在美国,国际掉期与衍生品协会(ISDA)版本{6}常用,并在下面的示例中作为私有的 date_diff_ 助手函数实现。然后,结果在公共操作符重载中除以 360。

export class Thirty360 : public DayCount
{
public:
	double operator()
        (const ChronoDate& date1, const ChronoDate& date2) const override
    {
        return static_cast<double>( date_diff_(date1, date2)) / 360.0;
    }

private:
	unsigned date_diff_(const ChronoDate& date1, const ChronoDate& date2) const
	{
	    unsigned d1, d2;
        d1 = date1.day();
        d2 = date2.day();

        auto f = [](unsigned& d) {
	    if (d == 31)
		{
			d = 30;
		}
	}

        f(d1);
        f(d2);

        return 360 * (date2.year() - date1.year()) + 30 * (date2.month() –
            date1.month()) + d2 - d1;
    }
};

然后,举几个例子:

	Act365 act_365{};
	Act360 act_360{};
	Thirty360 thirty_360{};

	ChronoDate sd1{ 2021, 4, 26 };
	ChronoDate ed1{ 2023, 10, 26 };
	ChronoDate sd2{ 2022, 10, 10 };
	ChronoDate ed2{ 2023, 4, 10 };

	auto yf_act_365_01 = act_365(sd1, ed1);	// 2.50137
	auto yf_act_365_02 = act_365(sd2, ed2);	// 0.49863

	auto yf_act_360_01 = act_360(sd1, ed1);	// 2.53611
	auto yf_act_360_02 = act_360(sd2, ed2);	// 0.505556

	auto yf_thirty_01 = thirty_360(sd1, ed1);	// 2.5
	auto yf_thirty_02 = thirty_360(sd2, ed2);	// 0.5

结果显示在注释中。注意,只有 30/360 日计算基数产生半年的年分数完全精确。

作为对日计算基数 {5.5}(斯泰纳)的快速应用,考虑获取短期政府国债的价格。在美国,这些国债的到期期限从四个月到一年不等,定价基于实际/365 天。在英国,到期期限可能长达六个月,使用实际/360 天基数。我们可以编写一个估值函数,通过运行时多态性来适应任意的日计算基数,因此美国和英国的情况可以使用相同的函数定价。

double treasury_bill(const ChronoDate& sett_date,
	const ChronoDate& maturity_date, double mkt_yield, double face_value,
	const DayCount& dc)
{
	// pp 40-41, Steiner
	return face_value / (1.0 + mkt_yield * dc(sett_date, maturity_date));
}

收益曲线

收益曲线源自市场数据 —— 一组离散日期上的固定点 —— 截至其结算日期(价格观察日,例如当前交易日)。固定收益头寸的估值过程依赖于从贴现因子获得的标准化收益曲线,以计算每笔未来支付的现值。这个过程的审查在下一节中详细说明。然后,这些结果将在随后的收益曲线类设计中实施。

从市场数据中派生收益曲线

本质上,收益率是从不同角度看待的利率。如果将资金投资于已知利率的存款账户,则可以计算出投资在未来某个日期的累积价值。然而,假设我们可以在 2022 年 10 月 25 日投资 1000 美元,并在 2023 年 12 月 31 日收到 1035.60 美元。为了与其他投资进行比较,我们计算其收益率。假设复利和实际/365 天计息基准,则

1000 (1+y) 432/365 = 1035 . 60

从中我们找到了产量

y = exp ( ln ( 1035 . 60 / 1000 ) × 365 / 432 ) - 1 = 3 %

一般而言,收益率曲线是时间的函数,记为 y ( t ),并且是由市场数据构建的,如国库券、互换和债券。时间值以年(或年分数)为单位。

这些产品都有已知的未来现金流,被称为固定收益证券。此外,每种固定收益证券都有其自己的收益类型(简单、折扣或复利),以及其自己的计息基准,这些可能在单一产品组内有所不同。为避免使用多种利率类型和计息基准,收益率曲线通常将其收益定义为以实际/365 天计息基准连续复利。

为了说明收益率曲线的输入是如何推导出来的,考虑一个美国国库券;其收益类型为折扣,计息基准为 Act/360,市场报价为该券的收益率。假设面值为F,到期日期为m,结算时的市场收益率为y m。则该券的价格P

P = F 1 - Act 360 ( s , m ) y m

对于收益率曲线,对应的收益率为 y ( t ),其中 t = Act 365 ( s , m ),以便

P e ty(t) = F

\(y(t)\)的值可以从这两个方程中找到。它是

y ( t ) = -ln(1- Act 360(s,m)y m ) t

任何用于创建收益率曲线的一组利率产品都必须具有相同的结算日期至关重要。让产品的到期日期为d[1],< d[2] < …​ < d[n],其中 s < d[1],关联的收益率为y[1], y[2], …​, y[n],其中 y i = y ( t i )t[i] = Act365(s,d[i]). 由于这些收益率是针对一个以结算日期为第一天的日期区间计算的,因此这些收益率被称为即期收益率。

有许多连续曲线穿过这些点

( t 1 , y 1 ) , ( t 2 , y 2 ) , , ( t n , y n )

选择适当的曲线是收益曲线的使用者所做的商业决策。这通常基于一种采用特定插值方法的曲线拟合技术。

贴现因子

考虑在时间 m 进行单位支付,其在结算日期 s < m 的价值是多少?设 P(s,m) 为在结算日期支付的价格,设 t=Act365(s,m),设 y(t) 为相关收益率。那么

P ( s , m ) e ty(t) = 1

从中可以得到 P(s,m) = e^(-ty(t)). 现在 P(s,m) 是在日期 s 看到的现值,用于在日期 m 进行单位支付。换句话说,这是从 sm 期间的贴现因子。由于 y(t) 是即期收益率,这是一个即期贴现因子。

远期贴现因子

我们如何计算从时间 d[1] 开始到时间 d[2] 结束的期间的贴现因子,其中 s < d[1] \leq d_2?

考虑在时间 d[2] 进行单位支付,并且让其在 d[1] 的价值表示为 P(s; d[1],d[2])。其即期价值 - 因此参数 s - 是

P ( s , d 1 ) P ( s ; d 1 , d 2 )

为了避免套利机会,我们必须有

P ( s , d 2 ) = P ( s , d 1 ) P ( s ; d 1 , d 2 ) , s o t h a tP ( s ; d 1 , d 2 ) = P(s,d 2 ) P(s,d 1 )

替换即期贴现因子:

P ( s ; d 1 , d 2 ) = e -t 2 y(t 2 ) e -t 1 y(t 1 ) = e t 1 y(t 1 )-t 2 y(t 2 )

由于 d[1] > sP(s;d[1],d[2]) 是一个远期贴现因子。

接下来的部分将描述 C++ 中的收益曲线框架,然后是一个简单的收益曲线示例及其用于估值债券的用法

一个收益曲线类

收益率曲线类的基本功能将返回两个任意日期之间的连续复利前向折现因子,如前一节详细说明。通过点 ( t 1 , y 1 ) , ( t 2 , y 2 ) , , ( t n , y n ) 的结果曲线拟合可以基于文献中的大量数值方法。例如,这些可以是从简单的线性插值收益率曲线到更复杂的示例,如立方样条插值曲线、最平滑收益率曲线{7}或单调凸方法{8}。

在较高层面上,我们可以定义一个抽象基类,该类

  1. 基于上述结果,提供一种将市场收益率数据转换为连续复利收益率的常用方法{[4.1]}。

  2. 要求派生类实现自己的曲线拟合方法作为私有成员函数

基类将包含一个非虚公共函数,用于计算两个日期之间的前向折现因子,使用从每个派生类的重载yield_curve_方法确定的插值收益率。假定每个派生类确定的插值收益率使用 Act/365 日计算基础进行连续复利。重载的yield_curve_方法将依赖于与特定结算日期相关的市场数据。

export class YieldCurve
{
public:
	// d1 <= d2 < infinity
	double discount_factor(const ChronoDate& d1, const ChronoDate& d2) const;
	virtual ~YieldCurve() = default;

protected:
	ChronoDate settle_;

private:
	Act365 act365_{};

	virtual double yield_curve_(double t) const = 0;
};

然后根据数学推导,实施discount_factor,如在[[4.1.2]]中所示。请注意,如果第一个日期d1是结算日期,则结果默认为日期d2处的即期折现因子。

double YieldCurve::discount_factor(const ChronoDate& d1, const ChronoDate& d2) const
{
	if (d2 < d1)
		throw std::exception("YieldCurve::discount_factor: d2 < d1");

	if (d1 < settle_ || d2 < settle_)
		throw std::exception("YieldCurve::discount_factor: date < settle");

	// P(t1, t2) = exp( -(t2-t1) * f(t1, t2) )

	// if d1 == settle_ then P(t1,t2) = P(0,t2) = exp(-t2 * y2 )
	double t2 = act365_(settle_, d2);
	double y2 = yield_curve_(t2);
	if (d1 == settle_) return exp(-t2 * y2);

	double t1 = act365_(settle_, d1);
	double y1 = yield_curve_(t1);
	// (t2-t1) f(t1,t2) = t2 * y2 - t1 * y1
	return exp(t1 * y1 - t2 * y2);
}

线性插值收益率曲线类实现

最简单的曲线拟合方法——有时仍然在实践中使用——是线性插值。还存在更复杂的插值方法,例如上面提到的方法,但这些方法需要更多的数学计算能力。因此,为了讨论简洁,我们将在此处将例子限制在线性插值情况下,但重要的是要记住,更高级的方法也可以集成到相同的继承结构中。

export class LinearInterpYieldCurve final : public YieldCurve
{
public:
	LinearInterpYieldCurve(
		const ChronoDate& settle_date,
		const vector<double>& maturities,   	// In Act/365 years.
		const vector<double>& spot_yields); 	// Continuously compounded,
												// Act/365 day count basis

private:
	vector<double> maturities_; // maturities in years
	vector<double> yields_;

	double yield_curve_(const double t) const override;
};

构造函数将根据实际/365 天计息基础,根据结算日期接收与每个收益数据点相关的到期日期。相应的即期收益值随后在第三个参数的向量中跟随。其实现将检查到期和收益向量是否长度相同,以及结算日期值是否为负。如果有任何一个条件为真,则抛出异常。为了演示目的,我们假设到期日期按升序排列,但在实际生产中,这将是另一个不变量要检查的条件。

LinearInterpYieldCurve::LinearInterpYieldCurve(
	const ChronoDate& settle_date,
	const vector<double>& maturities,
	const vector<double>& spot_yields) : maturities_{ maturities },
		yields_{ spot_yields }	// Maybe move semantics instead?
{
	settle_ = settle_date;

	if (maturities.size() != spot_yields.size())
    throw std::exception("LinearInterpYieldCurve: maturities and spot_yields are different lengths");

	if (maturities.front() < 0.0 )
	  throw std::exception("LinearInterpYieldCurve: first maturity cannot be negative");

	// Assume maturities are in order
}

线性插值方法实现在指定的yield_curve_私有成员函数中。如果要插值的年分超过数据中的最大时间值,则结果就是最后的收益值。否则,while循环将定位围绕时间t输入值的时间点间隔。然后,计算并返回比例加权收益。

double LinearInterpYieldCurve::yield_curve_(const double t) const
{
	// interp_yield called from discount_factor, so maturities_front() <= t

	if (t >= maturities_.back())
	{
		auto check{ maturities_.back() };
		return yields_.back();
	}

	// Now know maturities_front() <= t < maturities_.back()
	size_t indx{ 0 };
	while (maturities_[indx + 1] < t) ++indx;
	return yields_[indx] + (yields_[indx + 1] - yields_[indx])
		/ (maturities_[indx + 1] - maturities_[indx]) * (t - maturities_[indx]);
}

一个债券类

现在,我们可以利用前面类的对象以及用户定义的Bond类来计算支付票息的债券的价值。包括在其中的普通债券的常见例子是由政府发行的国库券,机构债券(由美国政府赞助的企业如美国政府全国抵押协会(GNMA)发行),企业债券以及地方州和市政债券。作为债务义务,发行者以交换所借金额为代价,在一段时间内进行一系列定期支付。与传统贷款的主要区别在于,面值即本金金额在债券到期时偿还,而不是在一段时间内摊销。

债券支付和估值

在继续进一步的代码开发之前,总结债券支付结构和债券通常如何估值可能是值得的。这些背后的细节在编写现实世界中的债券交易软件中非常重要,但令人惊讶的是,在计算金融课程和教科书中,它们经常被忽视。接下来的讨论将基本上成为随后实现的Bond类的设计要求。

总体想法是债券按照固定的时间表支付固定金额;例如,假设一张债券的票面价值为$1000,并且每六个月支付其面值的 5%。然后支付频率是每年两次,票息金额将是

0.05(1000) 2 = 25

通常的公式是

r e g u l a r c o u p o n a m o u n t = ( c o u p o n r a t e ) (facevalue) (couponfrequency)

首要任务是创建一个付款日期列表,以及每个日期的票息支付金额。除了票面价值和年息率外,债券的合同条款还包括以下四个日期:

  • 发行日期

  • 第一张票息付款日

  • 倒数第二张票息付款日

  • 到期日期

发行日期是债券首次上市销售的日期。通常会支付一系列固定付款,例如每六个月一次,从第一个优惠券日期开始。倒数第二笔付款发生在倒数第二个优惠券日期,最后一笔付款包括最后一个优惠券支付和面值的偿还,发生在到期日期。我们再次假设优惠券日期小于 29,以避免月底问题。

确定支付时间表

回到上面的例子,一张面值为$1000 的债券支付 5%的优惠券,优惠券频率为 2,支付日期将是什么时候?

在第一个优惠券日期和倒数第二个日期之间,支付截止日期将按照常规时间表安排。这意味着每六个月支付$25 的常量。为确保这些日期属于常规截止日期时间表,通常对第一个和倒数第二个优惠券日期以及优惠券频率有一些限制。这两个日期必须是相同的工作日,并且必须以 12/(优惠券频率)个月的倍数相差。

由于到期日期可能不是工作日,债券还有相应的支付日期,会根据周末和节假日进行调整。为了简化问题,我们假设除了星期六和星期日外没有其他假期,并且假设支付日期不会落在值大于 28 的任何一天上,因此如果到期日期落在周末,则正常的优惠券支付将在下一个周一进行。

例如,假设债券的第一个优惠券日期是 2023 年 3 月 17 日,倒数第二个优惠券日期是 2025 年 3 月 17 日。这两个日期相差 36 个月,这是 12/2=6 的倍数,符合要求。然后,中间的到期日期分别是 2023 年 9 月 17 日、2024 年 3 月 17 日,……、2025 年 3 月 17 日。这些到期日期不会全部落在工作日,因此每个日期都需要检查并在必要时向前推移,以符合支付日期的要求。

如果第一个和最后一个支付也按照常规周期发生,它们将分别是$25 和$1025。但是,第一个和最后一个优惠券期间可能是不规则的。通常引用的案例是第一个支付期间不规则,但也可能存在从倒数第二个到到期日期的不规则期间。

对于第一个期间较短的情况,票息支付通过将年票息率乘以期间实际天数与正常第一个期间天数的比率来计算。再次举例说明,假设一张面值为$1000、年票息率为 5%、半年付息($25 定期票息支付)的十年期债券于 2022 年 7 月 12 日发行,第一次支付日期为 2022 年 12 月 21 日。随后的支付日期分别为 6 月 21 日和 12 月 21 日。如果第一次支付期间是常规的,发行日期将是 2022 年 6 月 21 日。这个日期称为第一个先前日期。现在,第一次支付如下分摊:

(couponpayment)(numberofdaysfromissueto1stpmt) (numberofdaysfrom1stpriorto1stpmt =25 ( 162 183 ) = $ 22 . 13

图 9-1:不规则的短第一票息期间

对于第一个期间较长的情况,假设我们有一张面值为$1000、年票息率为 5%、半年付息的债券,发行日期现在是 2022 年 5 月 12 日。

图 9-2:不规则的长第一票息期间

在这种情况下,第一次的票息支付将包括从发行之前的第一个日期到发行日期的正常票息$25,加上从第二个先前日期到第一个日期之间的部分支付(以红色显示)。这额外支付按照从第二个先前日期到第一个日期的六个月期间进行分摊。即,

(couponpayment)(numberofdaysfromissueto1stprior) (numberofdaysfrom2ndpriorto1stprior) =25 ( 40 182 ) = ¥ 5 . 49

总第一次票息支付为 25 + 5.49 = 30.49

计算不规则最终期间的比例付款类似,不过会利用在发行之前的前期付款期间,以及延伸到到期后的额外付款期间。

估值债券

发行人在发行日出售债券,向债券所有者支付票面利息,并在到期时还本付息。债券所有者随后可以在二级市场上出售债券,买卖双方会商定业务日期,称为债券结算日,交易将在此日进行。在此结算日,卖方收到款项,买方成为注册所有者,有权收取结算日后到期的所有付款。如果债券所有者希望随时估值债券,可以通过使用自己选择的收益率曲线,以及从结算日到相应付款日期计算的折现因子来完成。

Figure 9-3: 债券在结算日的估值

回顾图 9-1 中的示例,假设债券在 2023-10-24 以现金交换,如图 9-3 中的红色哈希标记所示。所有之前的付息已支付给前任所有者,因此债券的价值仅取决于从第三次付息开始到到期的付款。如果债券和收益率曲线的结算日期相同,则在此日期上使用上述折现因子表示法,债券的价值将为

25 ( P ( s , d 3 ) + + P ( s , d p ) ) + ( 1000 + 25 ) P ( s , d m )

其中s = 2023-10-24 是收益率曲线结算日期,d[p] = 2032-6-21 是倒数第二次付息日,d[m] = 2032-12-21 是到期日。

债券结算日期也可能晚于收益率曲线结算日期。假设债券交易发生在收益率曲线结算日期后的两个工作日。如果我们设定s[b] = 2023-10-26,则债券价值变为

25 ( P ( s ; s b , d 3 ) + + P ( s ; s b , d p ) ) + ( 1000 + 25 ) P ( s ; s b , d m )

折现估值也是交易和风险管理软件中的常规计算。现代专业交易员使用这个值作为确定债券公平(或均衡)市场价格的基准。风险管理人员将根据多个受冲击或随机收益率曲线情景计算债券,以确定其投资组合面临的市场风险度量。

债券类

现在我们的任务是在用户定义的Bond类中实现上述要求,使用债券的合同条款作为输入数据。首先,让我们在这里整合和审视与债券发行相关的基本数据输入。

  • 面值

  • 年度利率

  • 每年的付息次数(付息频率)

  • 发行日期

  • 首次付息日

  • 倒数第二次付息日

  • 到期日期

  • 日计数基础

我们的Bond类可以如下类声明正式总结。

export class Bond
{
public:
	Bond(string bond_id, const ChronoDate& issue_date, const ChronoDate& first_coupon_date,
		const ChronoDate& penultimate_couppn_date, const ChronoDate& maturity_date,
		int coupon_frequency, double coupon_rate, double face_value);

	double discounted_value(const ChronoDate& bond_settle_date,
		const YieldCurve& yield_curve);

	string bond_id() const;

private:
	string bond_id_;

	vector<ChronoDate> due_dates_;    	// Dates on which payments are due,
										// whether business days or not.
	vector<ChronoDate> payment_dates_;	// Business dates on which payments are made.
	vector<double> payment_amounts_;
};

注意,所有合同信息都包含在构造函数中,债券的估值将委托给公共的discounted_value函数。这将“接口”(即合同债券数据的输入和处理)与“实现”(计算债券价值的地方)分离开来。根据{[5.1]}的讨论,这个估值函数基于债券结算日期和市场收益率曲线,这些输入独立于构造函数参数。其中一个特定优势是可以创建单个Bond实例,并在不同市场情景下多次调用其估值函数,用于风险报告目的,如前面提到的。

三个长度相等的向量,due_dates_payment_dates_payment_amounts_,分别对应上述第{[5.1.1]} {确定付款计划}部分的描述。计算债券的折现价值需要这三个向量。

债券 ID 字段通常也需要用于交易和风险应用程序,因此它作为构造函数参数和数据成员添加,以及一个公共访问器。coupon_frequency参数表示每年的票息支付次数 - 即半年度为 2 次,季度为 4 次 - 如在债券合同中定义。

债券类实现

接下来,我们将逐步完成类实现。构造函数将生成到期日和支付日期以及支付金额。请注意,first_coupon_datepenultimate_coupon_datematurity_date是落在工作日的到期日。first_coupon_datepenultimate_coupon_date输入对象也是常规到期日计划的一部分。到期日可能是常规到期日计划的一部分,也可能不是,如前面讨论过的。

Bond::Bond(string bond_id, const ChronoDate& issue_date, const ChronoDate& first_coupon_date,
	const ChronoDate& penultimate_coupon_date, const ChronoDate& maturity_date,
	int coupon_frequency, double coupon_rate, double face_value) : bond_id_(bond_id)
{

 	// (1) Number of months in coupon period:
	const int months_in_regular_coupon_period = 12 / coupon_frequency;

	// (2) Regular coupon payment:
	const double regular_coupon_payment = coupon_rate * face_value / coupon_frequency;

	// (3) Generate vectors containing due dates, payment dates,
	// and regular coupon payment amounts:
	for (ChronoDate regular_due_date{ first_coupon_date };
		regular_due_date <= penultimate_coupon_date;
		regular_due_date.add_months(months_in_regular_coupon_period))
	{
		// The due and payment Dates
		due_dates_.push_back(regular_due_date);
		ChronoDate payment_date{ regular_due_date };

		// (4) Roll any due dates falling on a weekend:
		payment_dates_.push_back(payment_date.weekend_roll());
		// Assume all coupons are regular; deal with short first period later.
		payment_amounts_.push_back(regular_coupon_payment);
	}

	// (5) If first coupon is irregular, amend the coupon payment:
	// Calculate the first_prior, the last regular date before first_coupon_date.
	ChronoDate first_prior{ first_coupon_date };
	first_prior.add_months(-months_in_regular_coupon_period);
	if (first_prior != issue_date) // if true then irregular coupon
	{
		if (first_prior < issue_date) // if true then short coupon period
		{
			double coupon_fraction =
				static_cast<double>(first_coupon_date - issue_date) /
				static_cast<double>(first_coupon_date - first_prior );
			payment_amounts_[0] *= coupon_fraction;
		}
		else // issue_date < first_prior, so long coupon period
		{
			// long_first_coupon = regular_coupon + extra_interest
			// Calculate the second_prior, the last regular date before the first_prior
			ChronoDate second_prior{ first_prior };
			second_prior.add_months(-months_in_regular_coupon_period);
			double coupon_fraction =
				static_cast<double>(first_prior - issue_date) /
					static_cast<double>(first_prior - second_prior);
			payment_amounts_[0] += coupon_fraction * regular_coupon_payment;
		}
	}

  // (6) The maturity date is a due date which falls on a business day:
	due_dates_.push_back( maturity_date );
	payment_dates_.push_back( maturity_date );
	// Assume maturity date is a regular due date:
	double final_coupon{ regular_coupon_payment };

  // (7) If final coupon period is irregular amend the coupon payment
  // Calculate maturity_regular_date, the first regular date after penultimate_coupon_date
	ChronoDate maturity_regular_date{ penultimate_coupon_date };
	maturity_regular_date.add_months(months_in_regular_coupon_period);
	if (maturity_regular_date != maturity_date) // if true then irregular coupon period
	{
		if (maturity_date < maturity_regular_date) // if true then short coupon period
		{
			double coupon_fraction =
				static_cast<double>(maturity_date - penultimate_coupon_date) /
				static_cast<double>(maturity_regular_date - penultimate_coupon_date);
			final_coupon *= coupon_fraction;
		}
		else  // maturity_regular_date < maturity_date, do long coupon period
		{
			// final_coupon = regular_coupon_amount + extra_interest
			// Calculate the next_regular_date, the first regular date
			// after the maturity_regular_date
			ChronoDate next_regular_date{ maturity_regular_date };
			next_regular_date.add_months(months_in_regular_coupon_period);
			double extra_coupon_fraction =
				static_cast<double>(maturity_date - maturity_regular_date) /
			static_cast<double>(next_regular_date - maturity_regular_date);
			final_coupon += extra_coupon_fraction * regular_coupon_payment;
	  }
	}

	// (8) Calculate final payment:
	payment_amounts_.push_back(face_value + final_coupon);

}

首先(1),尽管coupon_frequency值在债券合同中定义,并且通常存储在债券数据库中,但在接下来的任务中使用常规票息期间的长度更容易 - 例如 3 个月,6 个月等等。这个等效的月数是按照上面显示的方式计算并存储为常量整数值months_in_regular_coupon_period。接下来(2),根据上面提出的公式{[5.1]},regular_coupon_payment将此值存储为常量。请记住,常规票息期间是跨越两个相邻到期日的期间,除了第一个和最后一个之外,所有票息期间都保证是常规的。

生成日期和支付向量

现在(3),构造函数实现将生成到期日和支付日期以及支付金额。due_dates_向量将包含为每个相应票息期间生成的常规日期 - 例如每六个月 - 直到合同倒数第二个票息日期。这些日期不会调整为周末。因为std::chrono::year_month_day上的+=运算符保证相同的日期值,具有正确的年份和月份结果,只要连续日期有效,我们就没问题。债券有许多变体,但由于这不是生产代码,因此通过假设票息日小于 29 来简化。这避免了月底计算。

在(4)点,weekend_roll成员函数被应用于每个到期日的连续副本,并在倒数第二个支付日期之前推送到payment_dates_向量中。因此,任何落在周末的到期日都会滚动到下一个工作日。常规票息支付金额也附加到与每个常规日期对应的payment_amounts_向量中。

在前一步骤中,第一次利息支付被天真地设置为正常金额,因此在(5)中,检查第一次支付期间是否不规则。如果是,则嵌套条件语句确定此期间是短期还是长期。在短期情况下,第一个先前日期是通过从第一个支付日期减去常规期间的月份数来确定,然后计算按比例分配的利息支付。在长期情况下,确定第二个先前日期,然后计算从发行到第一个先前日期间隔的按比例分配的利息支付。然后,总的第一次利息支付即为此按比例分配的金额加上正常支付 {[见 5.1.1]}。

到期日是工作日,并附加到每个日期向量。暂定假设遵循正常支付周期,因此在这一点上(6),最终支付设为此时债券的正常利息金额加上面值。然后,另一个条件语句检查最后期间是否规则。如果是,则对最终支付进行调整。执行类似于不规则首期的计算,但使用前瞻性而不是回顾性延伸(7)。

最后(8),最终支付包括最后的利息支付和面值的返回,附加到支付向量中。

债券估值

尽管债券可以按照买卖双方的协议进行交易,但交易者通常需要访问基准“公平”价格,即债券结算日期剩余折现支付总和。正如第 {[5.1.2]} 节所述,债券买方有权获得在结算日期之后严格到期的所有支付。这引入了一个特殊情况,在代码中进行了处理,即如果债券结算日期恰逢到期日,则利息支付将支付给卖方。因此,只有结算日期后到期的利息支付以折现金额的形式增加到债券价值中。如果到期日恰逢周末,则不能作为结算日期,因此支付日期被推迟到下个周一并支付给买方。正因如此,Bond 类具有到期日和支付日期向量作为数据成员。

double Bond::discounted_value(const ChronoDate& bond_settle_date,
     const YieldCurve& yield_curve)
{
	// The buyer receives the payments which fall due after the bond_settle_date
	// If the bond_settle_date falls on a due_date the seller receives the payment
	double pv{ 0.0 };
	for (size_t i{ 0 }; i < due_dates_.size(); i++)
	{
		if (bond_settle_date < due_dates_[i])
			pv += yield_curve.discount_factor(bond_settle_date, payment_dates_[i])
				* payment_amounts_[i];
	}
	return pv;
}

代码将循环遍历 due_dates_ 成员向量,直到找到严格晚于结算的第一个到期日期。此时,从 payment_amounts_ 向量获取每个剩余支付 - 从与 due_dates_ 相同的当前索引开始。每个支付值从支付日期折现回债券结算日期。通过 yield_curve 输入对象的同名成员函数轻松获得乘以每笔支付的折现因子。然后将这些折现支付的累积总和作为债券的公平市场价值返回。

正如您所注意到的,这个估值函数简短而紧凑,因为到期日已经由构造函数生成,并根据需要调整为工作日的付款日期。付款金额 - 包括最后一次付息和面值归还组成的最后一笔付款 - 也是由构造函数计算的,包括在不规则的短期或长期付款期间的第一笔付款的任何调整。

从债券结算日期回溯的折现因子可以轻松地从输入yield_curve对象上的成员函数discounted_value中获得,而所有日期功能都封装在ChronoDate类中。实质上,discounted_value函数不需要关心折现因子或日期计算是如何获得的。它只是使用对象上的公共成员函数获取所需的信息并计算结果。

债券估值示例

现在我们可以将之前介绍的各个类放入一个定价债券的示例中。请记住,YieldCurve抽象基类将需要一个派生的曲线拟合方法。同样,有许多不同的方法可供选择,从简单到高级不等,但为了保持示例简洁,我们将使用线性插值。在构建一个Bond对象时,我们需要提供债券的面值、发行日期、首次付息日期、倒数第二次付款日期和到期日期,以及其面值。

{UML 图在此处} 举例来说,假设一张 20 年期债券的条款如下:

表 4-1。表 9-1:合同债券条款 - 示例

面值 $1000
年付息率 6.2%
付息频率 每六个月(半年度)
发行日期 周一,2023 年 5 月 8 日
首次付息日 周二,2023 年 11 月 7 日
倒数第二次付息日 周三,2042 年 5 月 7 日
到期日 周五,2042 年 11 月 7 日

在实践中,数据将从接口中输入并转换为ChronoDate类型,但我们可以如下复制结果:

std::string bond_id = "20 yr bond"; // normal 20 year bond

ChronoDate issue_date{ 2023, 5, 8 };		   		// (Mon)
ChronoDate first_coupon_date{ 2023, 11, 7 };       	// Short first coupon (Tue)
ChronoDate penultimate_coupon_date{ 2042, 5, 7 };  	// (Wed)
ChronoDate maturity_date{ 2042, 11, 7 };           	// Long final coupon (Fri)

int coupon_frequency{ 2 };
double coupon_rate{ 0.062 };
double face_value{ 1000.00 };
Construction of the bond is then straightforward:
Bond bond_20_yr{ bond_id, issue_date, first_coupon_date, penultimate_coupon_date,
		maturity_date, coupon_frequency, coupon_rate, face_value, day_count };

但请记住,到期日、付款日期和付款金额都是在构造函数的主体中生成和调整的。每个到期日都将携带一个 7 的日期值,付款日期相同,除了落在周末的到期日会被推迟到下一个周一:

2026 年 11 月 9 日,2027 年 11 月 8 日,2028 年 5 月 8 日,

2032 年 11 月 8 日,2033 年 5 月 9 日,2034 年 5 月 8 日,

2037 年 11 月 9 日,2038 年 11 月 8 日,2039 年 5 月 9 日

常规付息金额为

1000(0.0625) 2 = $ 31 . 00

唯一的不规则期间将是从结算日到第一次付息日,从 2023 年 5 月 8 日到 2023 年 11 月 7 日,导致计算出的付息金额为

31 ( 183 184 ) = $ 24 . 86

其中183 184是第一个期间实际天数与前一个日期到第一次付息日期天数的比率。

接下来,假设我们希望在发行日和第一个票息支付日之间的某一日期对债券进行估值,例如 2023 年 10 月 10 日星期二。假设截至此日期的市场数据表明以下即期收益率:

表 4-2. 表 9-2:即期收益率 - 示例(数字已四舍五入)

期间 到期日 年分数 收益率
隔夜 2023-10-11 0.00274 2%
3 个月 2024-01-10 0.25205 2.19%
6 个月 2024-04-10 0.50137 2.37%
1 年 2024-10-10 1.00274 2.67%
2 年 2025-10-10 2.00274 3.12%
3 年 2026-10-12 3.00822 3.43%
5 年 2028-10-10 5.00548 3.78%
7 年 2030-10-10 7.00548 3.93%
10 年 2033-10-10 10.0082 4%
15 年 2038-10-11 15.0137 4.01%
20 年 2043-10-12 20.0192 4.01%
30 年 2053-10-10 30.0219 4%

创建两个包含上述到期期限(作为年分数)和贴现债券价格的向量(再次强调,这些通常在接口中被初始化的容器被代替):

std::vector<double> maturities{0.00273973, 0.252055, . . ., 30.0219};
std::vector<double> spot_yields{0.0200219, 0.021924, . . ., 0.0400049};

并且结算日期:

ChronoDate spot_settle_date{ 2023, 10, 10 };

有了这些,我们可以创建一个线性插值收益率曲线的实例:

LinearInterpYieldCurve yc{ spot_settle_date, maturities , spot_yields };

然后,根据相同的结算日期将结算日期和收益率曲线数据提供给Bond对象的相应成员函数以对债券进行估值:

double value = bond_20_yr.discounted_value(spot_settle_date, yc);

此函数将定位结算日期后的第一个到期日(在本例中为第一个票息支付日),使用收益率曲线上的插值率计算从每个支付日期到结算日期的每个连续复利贴现因子,将每个支付乘以此贴现因子,然后将贴现值求和以确定债券的贴现值。在本例中,结果为$1315.34。

注意设计,将债券数据的“接口”与“实现”分开,可提供两方面的灵活性。首先,如上所述,可以创建一个Bond对象,然后将多个随机或冲击收益率曲线场景应用于对同一债券的估值。这可以通过避免为每个场景创建全新的Bond对象来提高风险测量的效率。在这些情况下通常应用数千个场景,并且在金融机构的所有国际交易运营中可能持有数千只债券的多个债券组合。在每一步避免创建新对象可以显著减少计算风险价值所需的时间。

另一种情况可能是债券结算日期设定在(近)未来某一点,但需要根据当前市场条件进行预期估值。只要债券结算日期在或之后的收益率曲线结算日期,估值将有效。

摘要

待定

参考资料

{0} 尼古拉·约苏蒂斯(Nicolai Josuttis),C++ 标准库第二版,5.7.1 节,第 143-144 页

{1} std::chrono date GitHub 仓库:https://github.com/HowardHinnant/date

{2} Howard Hinnant, Stack Overflow(Fact 5),https://stackoverflow.com/questions/59418514/using-c20-chrono-how-to-compute-various-facts-about-a-date

{3} chrono-兼容低级日期算法 https://howardhinnant.github.io/date_algorithms.html

{4} Howard Hinnant, Stack Overflow, “C++ chrono: 确定一个日期是否为周末” https://stackoverflow.com/questions/52776999/c-chrono-determine-whether-day-is-a-weekend

{5} Howard Hinnant, Stack Overflow, “如何在 C++20 chrono 中添加若干天到日期” https://stackoverflow.com/questions/62734974/how-do-i-add-a-number-of-days-to-a-date-in-c20-chrono

{6} ISDA 30/360 日计数基础 https://www.iso20022.org/15022/uhb/mt565-16-field-22f.htm

{6.5} Steiner 页码 40-41

{7} Kenneth J Adams, 平滑插值的零曲线, Algo Research Quarterly, 4(1/2):11-22, 2001

{8} Hagan 和 West, 曲线构建的插值方法, Applied Mathematical Finance, Vol. 13, No. 2. 89-129, June 2006

{9} C++ 给 chrono::system_clock::time_point 添加月份, Stack Overflow, https://stackoverflow.com/questions/43010362/c-add-months-to-chronosystem-clocktime-point/43018120#43018120 (未直接引用)

第五章:线性代数

介绍

线性代数是计算金融的重要组成部分,因此对于金融 C++软件开发而言,它是一个必要且基本的组件。目前在标准库中存在的选项大多限于valarray容器,稍后将简要讨论。

由于 C++在上世纪 90 年代没有 Fortran 平台提供的便利的内置多维数组功能,那些转向 C++的量化程序员经常陷入不便之境,选项有限。这些选项包括从头开始构建这些功能,与数值 Fortran 库(如 BLAS 和 LAPACK)的接口斗争,或以某种方式说服管理层投资于第三方 C++商业库。

当时有时采用的人为 DIY 解决方案包括将矩阵表示为vectorvector,或将数据保存在二维动态 C 数组中。这两种方法都不太理想,前者繁琐且低效,后者则暴露软件于原始指针和动态内存管理相关的风险。标准库中似乎有用的一个特性是std::valarray,但也不是没有争议。它一直存活至今,提供了适合矩阵和向量数学的向量化操作和函数。它的优缺点将在稍后讨论。

多年来,情况有了显著改善,发布了多个开源线性代数库供 C++使用。在这些库中,两个在计算金融领域中获得了相当大批评群体的是Eigen{1}Armadillo {2}。在高性能计算(HPC)领域,第三个备受关注的选择是Blaze{3}。早期的库uBLAS {4} 也作为 Boost 库的一部分可用;但它不包括前述库中提供的矩阵分解和其他功能。

作为一个旁注,这些库各自都有开源 R 接口包 {5}。这些包使得依赖于其中一个或多个库的 C++代码可以集成到 R 包中,通常是为了提高运行时性能。

最近,NVIDIA 发布了作为其 HPC SDK 的一部分的GPU 加速 C++线性代数库 {6}

包括这里提到的内容在内,比较列表中涵盖了开源和商业 C++线性代数库,可在Wikipedia上找到{7}。本章后面将更详细地介绍 Eigen 库。

计划中的 C++23 和 C++26 的新功能似乎最终将为标准库提供既强大又得到长久支持的线性代数功能。这些新功能中的核心是为 C++23 计划的std::mdspan多维数组表示。C++26 应当随后更新,增加一个标准化接口以支持外部 BLAS 兼容库,以及其自身的线性代数工具集。

下面,我们首先将时光倒流,探讨valarray的方便数学特性,然后演示它如何作为矩阵的代理使用。随后,我们将深入介绍当前的 Eigen 库,并展示基本的矩阵操作,以及在金融建模中经常使用的矩阵分解。最后,还将简要介绍近期标准库发布的建议。

valarray和矩阵操作

我们已经看到,作为工作马的 STL 容器std::vector是表示数学向量的一个选项。可以使用 STL 算法执行常见的向量算术,如内积。然而,“设计为一种用于保存值的通用机制...并且适应容器、迭代器和算法的架构” {2.5 Stroustrup, Tour 2E} {8},作为其实现的一部分,常见的算术向量运算符如加法和乘法并未包含在其中。

一个独立于 STL 的标准库容器类,称为valarray,确实支持算术运算符,并提供“通常被认为是严肃数值工作所必需的优化” {ibid}。随着切片和步幅函数也伴随在valarray类旁,它也可以促进更高维度的数组表示,特别是矩阵。

虽然valarray具有这些非常有用的属性,似乎使其成为矩阵运算的明显选择,但它却饱受好评与差评并存。这一情况可以追溯到其最初的规范,由于当时是否要求一种新的技术——表达式模板(即将推出),可以显著优化性能而进行的辩论而从未完全完成。最终,这一要求未被强制执行。因此,“最初的实现速度较慢,因此用户不愿依赖它。”(来源:链接 {9}

但截至本文撰写时,两个主流的标准库发行版已经实现了 valarray 的表达式模板版本,即伴随着 gcc 和 Clang 编译器的那些版本。此外,Intel oneAPI DPC++/C++ Compiler {10} 还配备了其自己的高性能 valarray 实现。作为一个附带说明,beginend 函数的特化也作为 C++11 的增强功能包含在内。

故事的寓意似乎是:了解你打算使用的实现的能力。如果其性能适合你的需求,那么它可能是矩阵/向量操作以及常见数学函数的矢量化版本的非常便利选项。此外,检查 valarray 的属性可能为标准库计划中未来的线性代数增强提供一些背景,尽管在幕后的实现在某些情况下会有显著差异。

算术运算符和数学函数

valarray 容器支持按元素进行的标准算术运算符,以及标量乘法。

例如,向量和表达式 3 𝐯 1 + 1 2 𝐯 2 可以自然地转录为 C++ 中使用 valarray 对象的数学语句:

import <valarray>;
. . .

	std::valarray<double> v1{ 1.0, 2.0, 3.0,
				  1.5, 2.5 };

	std::valarray<double> v2{ 10.0, -20.0, 30.0,
				 -15.0, 25.0 };

	double vec_sum = 3.0 * v1 + 0.5 * v2;    // vec_sum is also a valarray <double>

结果是

8 -4 24 -3 20

逐元素乘法也是通过*运算符实现的:

double prod = v1 * v2;

这给了我们

10 -40 90 -22.5 62.5

向量 v 1v 2 的点(或内)积可通过在 valarray 类的 sum() 成员函数上调用前述结果来轻松获得:

double dot_prod = prod.sum();  	// Result = 100

除了 sum 外,valarray 还有 maxmin 函数,以及一个 apply(.) 成员函数,它类似于 std::transform 应用辅助函数:

double v1_max = v1.max();	// 3.0
double v1_min = v1.min();	// 1.0

// u and w are valarray<double> types
auto u = v1.apply([](double x) -> double {return x * x; });
// Result: 1, 4, 9, 2.25, 6.25

auto w = v1.apply([](double x) -> double {return std::sin(x) + std::cos(x);});
// Result: 1.38177 0.493151 -0.848872 1.06823 -0.202671

cmath 函数的子集被方便地定义用于 valarray 的矢量化操作。例如,以下操作将返回一个包含应用于 v1neg_val 中每个元素的相应函数映像的 valarray。请注意,我们也可以像普通数值类型一样使用减法运算符对每个元素取反。

// The result in each is a valarray<double>
auto sine_v1 = std::sin(v1);
auto log_v1 = std::log(v1);
auto abs_v1 = std::abs(neg_val);
auto exp_v1 = std::exp(neg_val);
auto neg_v1 = - v1;

最后,截至 C++11,类似于为 STL 容器提供的 beginend 函数的特化已经为 valarray 实现。一个简单的例子如下:

template<typename T>
void print(T t) { cout << t << " "; }

std::for_each(std::begin(w), std::end(w), print<double>);

鉴于在valarray上给定apply(.)成员函数和已有的内置向量化数学函数,与 STL 容器相比,在valarray的情况下,可能不经常需要使用 STL 算法for_eachtransform

作为矩阵代理的valarray

valarray提供了表示多维数组的功能。在我们的情况下,我们特别关注将二维数组表示为矩阵的代理。这可以通过slice(.)成员函数来实现,该函数可以提取对单个行或列的引用。

为了演示这一点,让我们首先通过定义别名来简化表示方式

using mtx_array = std::valarray<double>;

接下来,创建一个valarray对象val,并将代码格式化,使其看起来像一个 4 × 3 矩阵:

mtx_array val{ 1.0, 2.0, 3.0,
			   1.5, 2.5, 3.5,
			   7.0, 8.0, 9.0,
			   7.5, 8.5, 9.5};

可以使用为valarray定义的std::slice函数检索第一行,使用方括号操作符。

auto slice_row01 = val[std::slice(0, 3, 1)];

这句话的意思是:

  • 转到valarray的第一个元素:索引 0,值为 1.0。

  • 选择 3 个元素,从第一个开始

  • 使用步长为 1,在这种情况下意味着选择连续的三个按行排列的元素

同样地,第二列可以使用步长为 3 来检索,列数为:

auto slice_col02 = val[std::slice(1, 4, 3)];		// The 2nd rowwise element has index 1

需要注意的是,slice(.)函数返回一个较轻的slice_array类型,作为所选元素的引用,而不是完整的valarray。然而,它并不提供访问单个元素或计算新行(例如矩阵乘积)所需的成员函数和运算符。如果我们想将这些函数应用于行或列数据,我们将需要构造相应的新valarray对象。这将在下一个示例中看到,即计算一个矩阵中一行与另一个矩阵中一列的点积,这在执行矩阵乘法时是必需的选项。

为了演示这一点,假设我们有一个 5 × 3 和一个 3 × 5 的矩阵,每个都表示为valarray。注意,我们还分别存储了每个的行数和列数。

mtx_array va01{ 1.0, 2.0, 3.0,
 	            1.5, 2.5, 3.5,
		        4.0, 5.0, 6.0,
		        4.5, 5.5, 6.5,
			    7.0, 8.0, 9.0 };

unsigned va01_rows{ 5 }, va01_cols{ 3 };

mtx_array va02{ 1.0, 2.0, 3.0, 4.0, 5.0,
			    1.5, 2.5, 3.5, 4.5, 5.5,
		  	    5.0, 6.0, 7.0, 8.0, 8.5 };

unsigned va02_rows{ 3 }, va02_cols{ 5 };

如果我们要应用矩阵乘法,需要计算第一个“矩阵”的每一行与第二个“矩阵”的每一列的点积。例如,为了获得第三行与第二列的点积,我们首先需要对每个进行切片:

auto slice_01_row_03 = va01[std::slice(9, va01_cols, 1)];
auto slice_02_col_02 = va02[std::slice(1, va02_rows, 5)];

然而,slice_array上既不定义逐元素乘法,也不定义sum()成员函数,因此我们需要构造相应的valarray对象:

mtx_array va01_row03{ slice_01_row_03 };
mtx_array va02_col02{ slice_02_col_02 };

然后按通常的方式计算点积:

double dot_prod = (va01_row03 * va02_col02).sum();
注意

正如前面所述,slice_array充当valarray中块的引用。在slice_array上未定义操作和成员函数,如\*sum,但是赋值运算符如*=+=是定义过的。因此,对slice_array的修改,如下面的示例中所示,也会反映在valarray本身中。如果我们将va01的第一行作为一个切片:

auto slice_01_row_01 = va01[std::slice(0, va01_cols, 1)];

然后应用赋值运算符

slice_01_row_01[0] *= 10.0;
slice_01_row_01[1] += 1.0;
slice_01_row_01[2] -= 3.0;

然后valarray的内容将是

10	3	0
1.5	2.5	3.5
7	8	9
7.5	8.5	9.5

总之,valarray方便地提供了在整个数组上应用数学运算符和函数的能力,类似于 Fortran 90,以及更多专注于数学的语言如 R 和 Matlab。然而,请记住,性能很大程度上取决于您标准库分发中使用的实现。

有关valarray、其历史以及其优缺点的更多信息,请参阅《C++标准库 第二版》附带的在线补充章节)。{11}

valarray和 C++98 之后,C++在线性代数方面有了一些非常积极的发展,其中一些现在将被介绍。

Eigen

Eigen 库的第一个版本于 2006 年发布。自那时起,截至 2021 年 8 月,它已扩展到版本 3.4.0。从版本 3.3.1 开始,它已根据合理宽松的 Mozilla Public License (MPL) 2.0 许可发布。

Eigen 由模板代码组成,使其非常容易包含到其他 C++项目中,在其标准安装中不需要链接到外部二进制文件。它的表达式模板的整合,促进了惰性评估,提供了增强的计算性能。在被选择用于著名的TensorFlow机器学习库以及Stan 数学库合并之后,它的受欢迎程度进一步提升。{12} 关于它在金融领域适用性和受欢迎程度的更多背景可以在最近的Quantstart文章中找到。{13}

最后,Eigen 库有非常好的文档,配有教程和示例,可以帮助新手快速上手。

惰性评估

惰性评估推迟和最小化了矩阵和向量运算中所需的操作数。C++中的表达式模板用于封装算术操作(即表达式)在模板内,延迟直到实际需要它们。这可以减少使用传统方法时创建的总操作数、赋值和临时对象的数量。

下面是基于更全面和图解讨论的延迟评估示例,可以在彼得·戈特林的现代科学程序设计中找到更详细的信息。 {15}

假设您在数学意义上有四个向量,每个向量都有相同数量的固定元素,比如

𝐯 1 , 𝐯 2 , 𝐯 3 , 𝐯 4

并且您希望将它们的和存储在向量y中。传统方法是定义加法运算符,连续求和并将它们存储在临时对象中,最终计算最终和并分配给y

在代码中,操作符可以以通用方式定义,使得向量加法对于任何算术类型都定义明确:

template <typename T>
std::vector<T> operator + (const std::vector<T>& a,
	const std::vector<T>& b)
{
	std::vector<T> res(a.size());
	for (size_t i = 0; i < a.size(); ++i)
		res[i] = a[i] + b[i];
	return res;
}

计算四个向量的和如下

	vector<double> v1{ 1.0, 2.0, 3.0 };
	vector<double> v2{ 1.5, 2.5, 3.5 };
	vector<double> v3{ 4.0, 5.0, 6.0 };
	vector<double> v4{ 4.5, 5.5, 6.5 };

	auto y = v1 + v2 + v3 + v4;

导致以下结果:

  • 4 - 1 = 3 个vector实例创建(两个临时加一个最终的y实例)

  • (4 - 1) × 3 个double变量的分配

当向量数量(比如m)和每个向量中的元素数量(比如n)增加时,这将变得更加普遍:

  • m – 1 个堆上分配的vector对象:m - 2 个临时对象,加上一个返回对象(y)

  • ( m - 1 ) n 分配

使用延迟评估,我们可以减少总步骤数,从而提高“大”mn的效率。更具体地说,这可以通过延迟添加直到所有数据准备好,然后仅在那时为结果中的每个元素执行求和来实现。在代码中,这可以通过编写如下函数来实现。

template <typename T>
std::vector<T> sum_four_vectors(const std::vector<T>& a, const std::vector<T>& b,
	const std::vector<T>& c, const std::vector<T>& d)
{
	// Assume a, b, c, and d all contain the same
	// number of elements:
	std::vector<T> sum(a.size());

	for (size_t i = 0; i < a.size(); ++i)
	{
		sum[i] = a[i] + b[i] + c[i] + d[i];
	}

	return sum;
}

现在,在这种情况下,

  • 没有临时vector对象;只需要sum结果

  • 减少分配数量至 n = 4

Eigen 文档在延迟评估和别名部分提供了更多背景信息。

上述示例演示了延迟评估如何工作,但显而易见的问题是为所有可能的固定向量数编写单独的求和函数是不现实的。使用表达式模板进行泛化是一个更具挑战性的问题,这里不包括详细介绍,但可以在戈特林的书中找到更多信息{ibid 12},以及在Vandevoorde、Josuttis 和 Gregor 的 C++模板综合书籍的第二十七章中 {16}

与任何其他优化工具一样,不应盲目应用以为它会自动使您的代码更高效,因为在某些情况下,性能实际上可能会降低。

最后,对于金融风险管理中表达式模板的非常有趣的实际案例演示,建议观看由 Bowie Owens 在 CppCon 2019 上展示的主题演讲 presented by Bowie Owens at CppCon 2019 {17}

Eigen 矩阵和向量

Eigen 库的核心不出意外地是Matrix模板类。它在Eigen命名空间中定义,并需要包含Dense头文件。在撰写本文时,对应的模块导入尚未标准化。这意味着头文件需要包含在模块的全局片段中。

Matrix类具有六个模板参数,但提供了多种别名作为特定类型。这些包括固定方阵维度最多为四的类型,以及用于任意行和列数的动态类型。Matrix所持有的数值类型也是一个模板参数,但此设置也已合并到各个别名中。例如,以下代码将构造并显示一个double值的固定 3 × 3 矩阵,以及一个int的 4 × 4 矩阵。在构造时可以使用花括号(统一)初始化按行加载数据。

#include <Eigen/Dense>
. . .

Eigen::Matrix3d dbl_mtx			// Contains 'double' elements
{
	{10.6, 41.2, 2.16},
	{41.9, 5.31, 13.68},
	{22.47, 57.43, 8.82}
};

Eigen::Matrix4i int_mtx			// Contains 'int' elements
{
	{24, 0, 23, 13},
	{8, 75, 0, 98},
	{11, 60, 1, 3 },
	{422, 55, 11, 55}
};

cout << dbl_mtx << endl << endl;
cout << int_mtx << endl << endl;

还要注意<<流操作符被重载,因此可以轻松地将结果显示在屏幕上(按行主序)。

 10.6  87.4 58.63
 41.9  53.1 13.68
22.47 57.43  88.2

 24   0  23  13
  8  75   0  98
 11  60   1   3
422  55  11  55

可以访问单独的行和列,使用基于 0 的索引。例如,第一个矩阵的第一列和第二个矩阵的第三列可以分别通过相应的访问器函数获取:

cout << dbl_mtx.col(0) << endl << endl;
cout << int_mtx.row(2) << endl << endl;

这导致以下屏幕输出:

10.6
41.9
22.47

11 60  1  3

从技术上讲,由rowcol访问器返回的类型是Eigen::Block。它类似于从valarray访问的slice_array,因为它作为对数据的轻量级引用。与slice_array不同,它不包含任何数学运算符,如+=

对于本书中考虑的大多数金融示例,矩阵的维度事先不会知道,也不一定是方阵。此外,内容通常是实数。因此,我们将主要关注double类型的 Eigen 动态形式,别名为Eigen::MatrixXd

注意
  1. 正如刚才提到的,我们主要使用动态的 Eigen MatrixXd矩阵形式(其中d表示double数值元素);然而,成员函数和非成员函数通常适用于任何从Matrix模板类派生的类。讨论这些函数时,它们与Matrix而不是MatrixXd的关系也可能被提及。类似地,Eigen 中的向量表示将使用VectorXd

  2. 线性代数必然涉及下标和上标,例如 x ij ,在数学符号中,i 可能从 1 到 mj 从 1 到 n。然而,C++ 中是从 0 开始索引的,因此数学语句中的 i = 1 将在 C++ 中表示为 i = 0j = n 将表示为 j = n - 1,依此类推。

构造 MatrixXd 可以采用多种形式。数据可以按行主序输入,每行的初始化统一决定了行数和列数。或者,可以使用构造函数参数作为维度,通过按行主序流式输入数据。还有一种方法是逐个设置每个元素。这里展示了每种方法的示例:

using Eigen::MatrixXd;
. . .

MatrixXd mtx0
{
	{1.0, 2.0, 3.0},
	{4.0, 5.0, 6.0},
	{7.0, 8.0, 9.0},
	{10.0, 11.0, 12.0}
};

MatrixXd mtx1{4, 3};		// 4 rows, 3 columns
mtx1 << 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0;

MatrixXd mtx3{2, 2};
mtx3(0, 0) = 3.0;
mtx3(1, 0) = 2.5;
mtx3(0, 1) = -1.0;
mtx3(1, 1) = mtx3(1, 0) + mtx3(0, 1);

请注意,圆括号运算符同时作为改变器和访问器,如第三个示例所示。

两个特殊情况,其中列数或行数为一,别名为 VectorXdRowVectorXd。构造选项与上述 MatrixXd 的示例类似,如同在 Eigen 文档中展示的那样:

using Eigen::VectorXd;
using Eigen::RowVectorXd;
. . .

VectorXd a 	{ {1.5, 2.5, 3.5} };             // A column-vector with 3 coefficients
RowVectorXd b { {1.0, 2.0, 3.0, 4.0} };      // A row-vector with 4 coefficients

Eigen::VectorXd v(2);
v(0) = 4.0;
v(1) = v(0) - 1.0;

矩阵和向量数学运算

矩阵加法和减法都方便地通过+-运算符的重载实现,类似于valarray。同样,这些也适用于向量。但与valarray不同的是,乘法运算符*表示矩阵乘法而不是逐元素乘积。针对广泛的逐元素操作,提供了单独的函数集。

要将两个矩阵 AB 相乘,代码遵循自然的数学顺序:

MatrixXd A
{
	{1.0, 2.0, 3.0},
	{1.5, 2.5, 3.5},
	{4.0, 5.0, 6.0},
	{4.5, 5.5, 6.5},
	{7.0, 8.0, 9.0}
};

MatrixXd B
{
	{1.0, 2.0, 3.0, 4.0, 5.0},
	{1.5, 2.5, 3.5, 4.5, 5.5},
	{5.0, 6.0, 7.0, 8.0, 8.5}
};

MatrixXd prod_ab = A * B;

这使我们的输出结果为:

   19    25    31    37  41.5
22.75 30.25 37.75 45.25    51
 41.5  56.5  71.5  86.5  98.5
45.25 61.75 78.25 94.75   108
   64    88   112   136 155.5
注意

在 Eigen 文档中,强烈建议“不要在 Eigen 的表达式中使用 auto 关键字,除非你对自己所做的事情非常确定。特别是,不要将 auto 关键字用作 Matrix<> 类型的替代品。”

其背后的原因需要进行关于与懒惰求值相关的模板的高级讨论。懒惰求值在效率上提供了优势,但它也可能涉及返回类型的使用 auto,可能是引用而不是完整的 Matrix 类型。这可能导致意外或未定义的行为。随着您对各种 Eigen 类型的了解越来越深入,这个问题会变得不那么重要,但在这个入门演示中,我们大多数时候会遵循这个警告。

更多信息请参阅文档{18}

* 运算符还被重载用于矩阵-向量和行向量-矩阵乘法。例如,假设我们有一个包含三种基金的投资组合,具有收益相关性矩阵和给定的个别基金波动率向量(年化)。作为一个典型问题,我们可能需要以这种形式构造协方差矩阵,以计算投资组合波动率。首先,为了形成协方差矩阵,我们将通过包含基金波动率的对角矩阵对相关性矩阵进行前置和后置乘法:

MatrixXd corr_mtx
{
	{1.0, 0.5, 0.25},
	{0.5, 1.0, -0.7},
	{0.25, -0.7, 1.0}
};

VectorXd vols{ {0.2, 0.1, 0.4 } };

MatrixXd cov_mtx = vols.asDiagonal() * corr_mtx * vols.asDiagonal();

注意 VectorXd 的成员函数 asDiagonal() 如何方便地形成一个以向量元素为对角线的对角矩阵。

然后,给定一个资金权重向量 ω 总和为 1,投资组合波动率则是二次形式的平方根

ω 𝖳 Σ ω

其中 Σ 是协方差矩阵:

VectorXd fund_weights{ {0.6, -0.3, 0.7 } };
double port_vol = std::sqrt(fund_weights.transpose() * cov_mtx * fund_weights);

对于逐元素矩阵乘法,使用 cwiseProduct 成员函数。例如,要对维度相同的矩阵 𝐀𝐁 𝖳 中的个别元素进行乘法运算,我们可以这样写:

MatrixXd cwise_prod = A.cwiseProduct(B.transpose());

实际上,Eigen 的 Matrix 上有一组 cwise...(意思是逐系数)成员函数,可以在两个兼容矩阵上执行逐元素操作,如 cwiseQuotientcwiseNotEqual。还有一元的 cwise 成员函数,返回每个元素的绝对值和平方根。这些可以在Eigen 文档中找到。{19}

* 运算符应用于两个向量时,取决于哪个向量被转置。对于两个向量 u 和 v,点(内)积计算为

𝐮 𝖳 𝐯

当应用转置于 v 时,外积的结果是:

u v T

因此,在使用向量和 * 运算符时需要小心。假设我们有:

VectorXd u{ {1.0, 2.0, 3.0} };
VectorXd v{ {0.5, -0.5, 1.0} };

下面的向量乘法将得到不同的结果:

double dp = u.transpose() * v;			// Returns 'double'
MatrixXd op = u * v.transpose();		// Returns a Matrix

第一个将产生一个实值为 2.5,而第二个将给出一个 3 × 3 的矩阵:

  0.5 -0.5   1
   1   -1    2
 1.5 -1.5    3

为了简化操作,Eigen 在 VectorXd 类上提供了一个成员函数 dot。通过以下方式编写

dp = u.dot(v);

或许应该更清楚地表明我们想要哪种乘积。结果将与以前相同,此操作也是可交换的。

STL 兼容性

Eigen VectorMatrix 类的一个非常好的特性是它们与标准模板库的兼容性。这意味着你可以遍历 Eigen 容器,应用 STL 算法,并与 STL 容器交换数据。

STL 和 VectorXd

作为第一个例子,假设您希望从 t 分布中生成 12 个随机变量,并将结果放入VectorXd容器中。该过程本质上与我们看到的使用std::vector并应用带有 lambda 辅助函数的std::generate算法相同:

VectorXd u(12);							// 12 elements
std::mt19937_64 mt(100);				// Mersenne Twister engine, seed = 100
std::student_t_distribution<> tdist(5);	// 5 degrees of freedom
std::generate(u.begin(), u.end(), [&mt, &tdist]() {return tdist(mt); });
注意

在最近的 Eigen 3.4 版本发布之前,beginend成员函数未定义。在这种情况下,您需要使用datasize函数,如下所示:

std::generate(u.data()), u.data() + u.size(),
[&mt, &tdist]() {return tdist(mt); });

非修改算法(如std::max_element)也是有效的:

auto max_u = std::max_element(u.begin(), u.end());		// Returns iterator

数值算法,例如std::inner_product,也可以应用:

double dot_prod = std::inner_product(u.begin(), u.end(), v.begin(), 0.0);

VectorXd上也支持更干净的 C++20 范围版本:

VectorXd w(v.size());
std::ranges::transform(u, v, w.begin(), std::plus{});

从 STL 容器数据构造矩阵

矩阵数据也可以从 STL 容器中获取。这很方便,因为数据通常可以通过从其他未包含 Eigen 的源接口接收。关键是使用Eigen::Map,它设置对(视图的)vector数据的引用,而不是复制它。

作为第一个例子,存储在std::vector容器中的数据可以转移到Eigen::Map,然后可以用作MatrixXd的构造函数参数。注意,Map在其构造函数中接受指向vector第一个元素的指针(使用data成员函数),以及行数和列数。

std::vector<double> v{ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0,
	7.0, 8.0, 9.0, 10.0, 11.0, 12.0 };

Eigen::Map<MatrixXd> mtx_map(v.data(), 4, 3);

默认情况下,mtx_map将提供对数据的行/列访问。请注意,与以前使用数据初始化列表创建MatrixXd不同,顺序将是列主序而不是行主序。使用cout输出如下:

1  5  9
2  6  10
3  7  11
4  8  12

由于Map是对v中数据的轻量级视图,它不像以前用数据初始化列表创建MatrixXd对象那样具有所有功能。在某种意义上类似于从valarray中取出的切片。如果您需要此功能,则可以通过将Map对象放入其构造函数中来构造MatrixXd实例:

MatrixXd mtx_from_std_vector{ mtx_map };

Map中数据的默认排列顺序将是列主序,这与早期使用数值数据构造的MatrixXd示例不同。如果需要行主序,可以在一开始指定行主序,但这将需要将Eigen::Matrix模板参数明确设置为RowMajor,因为MatrixXd没有自己的存储方法模板参数:

Eigen::Map<Eigen::Matrix<double, 4, 3, Eigen::RowMajor>>
    mtx_row_major_map{ v.data(), 4, 3 };

如果矩阵是方阵,您可以直接就地转置Map以将其放置在行主序中:

// Square matrix, place in row-major order:
std::vector<double> sq_data{ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0,
	7.0, 8.0, 9.0 };

Eigen::Map<MatrixXd> sq_mtx_map{ sq_data.data(), 3, 3 };
sq_mtx_map.transposeInPlace();

结果如下:

1 2 3
4 5 6
7 8 9
警告

尝试使用Map转置非方阵可能导致程序崩溃。在这种情况下,您需要在应用transposeInPlace之前创建一个完整的MatrixXd对象。

将 STL 算法应用于矩阵

STL 算法也可以按行或按列应用于矩阵。假设我们有一个 4 × 3 矩阵如下所示:

	MatrixXd vals
	{
		{ 9.0, 8.0, 7.0 },
		{ 3.0, 2.0, 1.0 },
		{ 9.5, 8.5, 7.5 },
		{ 3.5, 2.5, 1.5 }
	};

Eigen 中的rowwise()成员函数在Matrix上设置按行迭代。每个row是对相应数据的引用(视图),因此我们可以按如下方式就地对矩阵的每个元素进行平方:

for (auto row : vals.rowwise())
{
	std::ranges::transform(row, row.begin(), [](double x) {return x * x; });
}

colwise()成员函数类似,在这种情况下,对矩阵的每一列进行排序:

for (auto col : vals.colwise())
{
	std::ranges::sort(col);
}

应用这两种算法后的最终结果是

    9     4     1
12.25  6.25  2.25
   81    64    49
90.25 72.25 56.25

每个元素已经被平方,并且每列已按升序重新排列。

金融程序员经常需要编写代码来计算一组股票或基金价格的对数收益率。例如,假设我们有三个 ETF 的 11 个月价格,分别放在以下三列中:

25.5  8.0 70.5
31.0  7.5 71.0
29.5  8.5 77.5
33.5  5.5 71.5
26.5  9.5 72.5
34.5  8.5 75.5
28.5  9.0 72.0
23.5  7.5 73.5
28.0  8.0 72.5
31.5  9.0 73.0
32.5  9.5 74.5

计算对数收益率的第一步是计算每个价格的自然对数。可以通过逐行应用transform算法来完成:

for (auto row : prices_to_returns.rowwise())
{
	std::ranges::transform(row, row.begin(), [](double x) {return std::log(x); });
}

然后,要获得对数收益率,我们需要从每个对数价格中减去其前任。为此,我们可以对每列应用adjacent_difference数值算法:

for (auto col : prices_to_returns.colwise())
{
	std::adjacent_difference(col.begin(), col.end(), col.begin());
}

此结果仍然是一个 11 × 3 矩阵,第一行仍包含第一行中价格的对数。

   3.23868    2.07944    4.25561
  0.195309 -0.0645385 0.00706717
-0.0495969   0.125163  0.0875981
  0.127155  -0.435318 -0.0805805
 -0.234401   0.546544  0.0138891
  0.263815  -0.111226  0.0405461
 -0.191055  0.0571584 -0.0474665
 -0.192904  -0.182322  0.0206193
  0.175204  0.0645385 -0.0136988
  0.117783   0.117783 0.00687288
 0.0312525  0.0540672  0.0203397

我们需要的是仅月度回报,因此需要移除第一行。这可以通过应用 Eigen 3.4 中引入的seq函数来实现,它提供了从Matrix对象中提取子矩阵视图(Eigen::Block)的直观方式。这里的示例展示了如何提取第一行以下的所有行:

MatrixXd returns_mtx{ prices_to_returns(Eigen::seq(1, Eigen::last),
	Eigen::seq(0, Eigen::last)) };

这句话的意思是:

  1. 从第二行(索引为 1)开始,包括到最后一行的所有行:Eigen::seq(1, Eigen::last)

  2. 从第一个(索引为 0)到最后一个列获取所有列:Eigen::seq(0, Eigen::last)

  3. 仅使用构造返回的returns_mtx中的此子矩阵数据。

returns_mtx中保存的结果仅为对数收益率:

  0.195309 -0.0645385 0.00706717
-0.0495969   0.125163  0.0875981
  0.127155  -0.435318 -0.0805805
 -0.234401   0.546544  0.0138891
  0.263815  -0.111226  0.0405461
 -0.191055  0.0571584 -0.0474665
 -0.192904  -0.182322  0.0206193
  0.175204  0.0645385 -0.0136988
  0.117783   0.117783 0.00687288
 0.0312525  0.0540672  0.0203397

现在,假设投资组合分配分别固定在 35%、40%和 25%(按列)。我们可以通过将分配向量乘以returns_mtx来获得每月的投资组合回报:

VectorXd monthly_returns = returns_mtx * allocations;

结果是

 0.0443094
 0.0546058
 -0.149768
   0.14005
 0.0579814
-0.0558726
  -0.13529
 0.0837121
 0.0900555
 0.0376502

矩阵分解与应用

矩阵分解在各种金融工程问题中当然至关重要。接下来,我们将讨论几个示例。

线性方程组和 LU 分解

在金融和经济中,线性方程组是无处不在的,特别是在优化、套期保值和预测问题中。为了展示如何在 Eigen 中找到解决方案,让我们看一个通用问题并在代码中实现它。LU(下/上三角矩阵)分解是数值方法中的常见方法。与自己编写代码相反,Eigen 可以在两行代码中完成工作。

假设我们想要解决以下线性方程组,求解x 1x 2x 3

3 x 1 - 5 x 2 + x 3 = 0- x 1 - x 2 + x 3 = - 42 x 1 - 4 x 2 + x 3 = - 1

这设置了通常的矩阵方程

𝐀𝐱 = 𝐛

where

𝐱 = x 1 x 2 x 3 𝖳

矩阵 𝐀 包含系数,列向量 𝐛 包含方程右侧的常数。LU 算法将矩阵 𝐀 分解为下三角和上三角矩阵的乘积 𝐋𝐔,以便解 𝐱

在 Eigen 中,形成矩阵 𝐀 和向量 𝐛

_

MatrixXd A		// Row-major
{
	{3.0, -5.0, 1.0},
	{-1.0, -1.0, 1.0},
	{2.0, -4.0, 1.0}
};

VectorXd b
{
	{0.0, -4.0, 1.0}
};

下一步是创建 Eigen::FullPivLU 类的实例,使用模板参数 MatrixXd。这设置了 LU 分解。要找到包含解的向量,只需在此对象上调用 solve(.) 成员函数。这意味着只需两行代码,如约定:

Eigen::FullPivLU<MatrixXd> lu(A);
VectorXd x = lu.solve(b);

x 的解法是(从上到下 x 1 , x 2 , x 3

_

2.5
1.5
  0

Eigen 中还提供了其他几种分解方法来解线性系统,通常需要在速度和精度之间做出选择。上面示例中的 LU 分解在精度方面是最佳的,尽管还有其他一些可能更快但稳定性不同的方法。完整列表可在此处找到:

基本线性求解 {20}

Eigen 中的矩阵分解方法比较也可用:

Eigen 提供的分解目录 {21}

使用多元回归和奇异值分解进行基金跟踪

金融中另一个常见的编程问题是使用多元回归进行基金跟踪。例如

  • 追踪一个对冲基金组合是否按照其声明的配置目标进行,通过将其回报与一组对冲基金风格指数的回归进行比较。

  • 追踪投资组合对不同市场部门变化的敏感性。

  • 追踪提供在保本投资产品(如可变年金)中的互惠基金的适应性,关于其各自的基金组指数。

在多元回归中,任务是找到一个向量

β ^ = β 1 β 2 β n 𝖳 / / β ^ = β 1 β 2 β n / / β 1 β 2 β n / / β ^ = β 1 β 2 β n / / β ^ = β 1 β 2 β n 𝖳 / / w t (1) w t (m) / / β ^ = [β 1 β 2 β n ] 𝖳

使得满足正规方程的矩阵形式

𝛃 ^ = [ X 𝖳 X ] 1 X 𝖳 Y 𝛃 ^ = [ X 𝖳 X ] 1 X 𝖳 𝐲

其中 𝐗 是包含 p 列自变量数据和 n 个观察(行)的设计矩阵,且观察数 n “舒适地”大于数据列数 p,以确保稳定性。这在这类基金追踪应用中通常是情况。

注意

对于基金追踪应用程序,拦截项 β 0 通常可以被删除,因此在此处被省略。对于需要截距的回归案例,当使用 Eigen 时,需要在设计矩阵中附加一个全为 1 的最后一列。 _

一种常用的解决方案是紧凑的奇异值分解(SVD),它用分解 𝐔 Σ 𝐕 T 替换矩阵 𝐗,其中 𝐔 是一个 n × p 矩阵,𝐕p × p 矩阵,Σ 是一个由严格正数值构成的 p × p 对角矩阵。将这一替换应用到 β ^ 的原始公式中,我们得到

β ^ = 𝐕 Σ 𝐔 𝖳 𝐲

Eigen 提供了两个可用于获取回归系数最小二乘估计的 SVD 求解器。其中之一,如 Eigen 文档中所述,是 Jacobi SVD decomposition of a rectangular matrix_ {22}。文档还指出,Jacobi 版本建议用于设计矩阵列数不超过 16 列的情况,这在某些基金追踪问题中已足够。

这种工作方式是,在给定设计矩阵预测数据包含在 MatrixXd X 中时,Eigen 在类模板 Eigen::JacobiSVD 中设置了 SVD 逻辑。然后,给定响应数据包含在 VectorXd Y 中,求解 β ^ 只需两行代码:

Eigen::JacobiSVD<MatrixXd> svd{ X, Eigen::ComputeThinU | Eigen::ComputeThinV };
VectorXd beta = svd.solve(Y);
注意

Eigen::ComputeThinU | Eigen::ComputeThinV 的按位或参数指示程序使用 SVD 的紧凑版本。如果需要完整的 SVD,则以上JacobiSVD实例化将是:

Eigen::JacobiSVD<MatrixXd> svd{ X, Eigen::ComputeFullU | Eigen::ComputeFullV };

在这种情况下,𝐔 矩阵将是 n × nΣ 将是 n × p 伪逆。

没有默认设置。

例如,假设我们有三个行业 ETF,并希望研究它们与更广泛市场(如标准普尔 500 指数)的关系,假设我们有 30 天的每日观察数据。

设计矩阵将包含三个 ETF 回报,并存储在名为XMatrixXd中,如下所示:

MatrixXd X{ 3, 30 };	// 3 sector funds, 30 observations (will transpose)
X	<<
	// Sector fund 1
	-0.044700388, -0.007888394, 0.042980064, 0.016416586, -0.01779658, -0.016714149,
	0.019472031, 0.029853293, 0.023126097, -0.033879088, -0.00338369, -0.018474493,
	-0.012509815, -0.01834808, 0.010626754, 0.036669407, 0.010811115, -0.035571742,
	0.027474007, 0.005406069, -0.010159427, -0.006145632, -0.0103273, -0.010435171,
	0.011127197, -0.023793709, -0.028009362, 0.00218235, 0.008683152, 0.001440032,

	// Sector fund 2
	-0.019002703, 0.026036835, 0.03782709, 0.010629292, -0.008382267, 0.001121697,
	-0.004494407, 0.017304537, -0.006106293, 0.012174645, -0.003305029, 0.027219671,
	-0.036089287, -0.00222959, -0.015748493, -0.02061919, -0.011641386, 0.023148757,
	-0.002290732, 0.006288094, -0.012038397, -0.029258743, 0.011219297, -0.008846992,
	-0.033738048, 0.02061908, -0.012077677, 0.015672887, 0.041012907, 0.052195282,

	// Sector fund 3
	-0.030629136, 0.024918984, -0.001715798, 0.008561614, 0.003406931, -0.010823864,
	-0.010361097, -0.009302434, 0.008142014, -0.004064208, 0.000584335, 0.004640294,
	0.031893332, -0.013544321, -0.023573641, -0.004665085, -0.006446259, -0.005311412,
	0.045096308, -0.007374697, -0.00514201, -0.001715798, -0.005176363, -0.002884991,
	0.002309361, -0.014521608, -0.017711709, 0.001192088, -0.00238233, -0.004395918;

X.transposeInPlace();

类似地,市场回报以VectorXd数组Y存储:

VectorXd Y{ 30 };	// 30 observations of market returns
Y <<
	-0.039891316, 0.00178709, -0.0162018, 0.056452057, 0.00342504, -0.012038314,
	-0.009997657, 0.013452043, 0.013485674, -0.007898137, 0.008111428, -0.015424523,
	-0.002161451, -0.028752191, 0.011292655, -0.007958389, -0.004002386, -0.031690771,
	0.026776892, 0.009803957, 0.000886608, 0.01495181, -0.004155781, -0.001535225,
	0.013517306, -0.021228542, 0.001988701, -0.02051788, 0.005841347, 0.011248933;

获得回归系数只是通过编译和运行在一开始显示的两行代码,这给我们beta

  0.352339
-0.0899004
  0.391252

如果需要,还可以使用以下访问器函数获得𝐔𝐕 矩阵,以及 Σ 矩阵:

cout << svd.matrixU() << endl;			// U: n x p = 30 x 3
cout << svd.matrixV() << endl;			// V: p x p = 3 x 3
cout << svd.singularValues().asDiagonal() << endl;		// Sigma: p x p = 3 x 3

另一种可选的 SVD 求解器,Bidiagonal Divide and Conquer SVD{23} 也可在 Eigen 中找到。根据文档,推荐用于设计矩阵列数大于 16 的情况以获得更好的性能。

设置与 Jacobi 情况相同,但使用JacobiSVD的位置上的BDCSVD类:

Eigen::BDCSVD<MatrixXd> svd(X, Eigen::ComputeThinU | Eigen::ComputeThinV);
VectorXd beta = svd.solve(Y);

值得注意的是 Eigen 还提供 QR 分解以及使用 Eigen 矩阵和操作进行正常方程求解的能力。如文档所述,解线性最小二乘系统,这些方法可能比 SVD 方法更快,但可能精度较低。{24} 如果速度是问题,这些都是您可以考虑的备选方案,但需要在适当的条件下使用。

相关的随机股票路径和乔列斯基分解

在金融中,乔列斯基分解是生成相关的蒙特卡洛股票路径模拟的一种常用工具。例如,在定价篮子期权时,需要考虑篮子证券运动之间的协方差,特别是在生成相关的随机正态抽取时。这与生成单个基础证券的随机价格路径形成对比,正如我们在第八章中看到的那样。

S t = S t-1 e (r-σ 2 2)Δt+σε t Δt

其中再次 ε t N ( 0 , 1 )σ 是股票的波动率,r 表示无风险利率,

在篮子期权的情况下,现在我们需要为每个资产(总数为 m 个)在每个时间点 t 生成一条路径,其中术语 σ ε t 被一个随机项 w t (i) 取代,这个随机项再次基于标准正态分布抽取,但其波动也包含与篮子中其他资产的相关性。因此,我们需要生成一组价格 S t (i),其中

S t (1) = S t-1 (1) e (r-σ 2 2)Δt+w t (1) Δt ( * )S t (m) = S t-1 (m) e (r-σ 2 2)Δt+w t (m) Δt

对于每个资产 i = 1 , , m 在每个时间步 t j , j = 1 , , n 。我们的任务是计算随机但相关的向量

w t (1) w t (m) 𝖳

对于每个时间 t。这就是 Cholesky 分解的作用。对于一个 n × n 协方差矩阵 Σ,假设它是正定的,将有一个 Cholesky 分解

Σ = L L 𝖳

其中 L 是一个下三角矩阵。然后,对于标准正态变量向量 𝐳

z 1 z 2 z m 𝖳

n × 1 向量生成的 𝐋 𝐳 𝖳 将提供一组相关波动率,可以用来生成每个基础证券的价格的随机场景。对于每个时间步 t,我们用 𝐳 替换为 𝐳 𝐭,从而得到我们想要的结果:

𝐰 𝐭 = 𝐋𝐳 t 𝖳

然后,通过将每个向量 𝐳 𝐭 放置到矩阵的一列中,比如 𝐙,可以扩展任意数量的 n 个时间步骤。然后,我们可以一次生成一整套相关随机变量向量,并将结果放置在一个矩阵中

𝐖 = 𝐋𝐙

Eigen 提供了对 MatrixXd 对象的 Cholesky 分解,使用 Eigen::LLT 类模板和参数 MatrixXd。它再次包括创建这个类的对象,然后调用成员函数 matrixL,该函数返回上述矩阵 𝐋

举个例子,假设我们篮子里有四种证券,其协方差矩阵如下。

MatrixXd cov_basket
{
    { 0.01263, 0.00025, -0.00017, 0.00503},
    { 0.00025, 0.00138,  0.00280, 0.00027},
    {-0.00017, 0.00280,  0.03775, 0.00480},
    { 0.00503, 0.00027,  0.00480, 0.02900}
};

当使用矩阵数据构造 Eigen::LLT 对象时,Cholesky 分解被设置。调用成员函数 matrixL() 计算分解并返回结果的下三角矩阵:

Eigen::LLT<Eigen::MatrixXd> chol{ cov_basket };
MatrixXd chol_mtx = chol.matrixL();

这给了我们

    0.1124          0          0          0
  0.002226  0.0370332          0          0
-0.0015544  0.0756179   0.178975          0
 0.0447889 0.00464393  0.0252348   0.162289

现在假设在蒙特卡罗模型中将有六个时间步长,持续一年。这意味着我们将需要六个包含四个标准正态变量的向量。为此,我们可以使用标准库 <random> 函数生成一个 4 × 6 矩阵,并将随机向量放置在连续的列中。

首先,创建一个有四行六列的 MatrixXd 对象:

MatrixXd corr_norms{ 4, 6 };

首先,将此矩阵用无关的标准正态分布填充。如我们之前所做(第八章),我们可以设置一个随机引擎和分布,并将它们捕获在一个 lambda 函数中以生成标准正态变量:

std::mt19937_64 mt_norm{ 100 };		// Seed is arbitrary, just set to 100 again
std::normal_distribution<> std_nd;

auto std_norm = &mt_norm, &std_nd
{
    return std_nd(mt_norm);
};

因为每个MatrixXd中的列可以被视为VectorXd,我们可以再次通过矩阵按列进行迭代,并在基于范围的for循环中对每列应用std::ranges::transform算法。

for (auto col : corr_norms.colwise())
{
    std::ranges::transform(col,
        col.begin(), std_norm);
}

此中期结果将类似于以下内容,实际结果取决于编译器(在本例中使用 Microsoft Visual Studio 2022 编译器):

   0.201395    0.197482     1.22857     1.40751     1.82789   -0.150014
 -0.0769593   0.0830647     1.86252    0.122389   -0.949222    0.667817
   0.936051     1.16233   -0.642932    0.538005    -1.82688   -0.451039
-0.00916217    -2.79186   -0.434655  -0.0553752     1.46312    0.345527

然后,为了得到相关正常值,将上述结果乘以 Cholesky 矩阵,并将corr_norms重新分配为结果。

MatrixXd corr_norms = chol_mtx * corr_norms;

这个结果对应于数学推导中的矩阵𝐖,其结果如下:

  0.0226368   0.0221969    0.138091    0.158204    0.205455  -0.0168616
-0.00240174  0.00351574   0.0717097  0.00766557  -0.0310838   0.0243974
   0.161397    0.214002   0.0238613    0.103356   -0.401585  -0.0299926
  0.0307971   -0.414526  -0.0230881   0.0681989    0.268808   0.0410757

corr_norms中的每个连续列将提供一组四个相关随机变量,可以替换(*)中每个时间点w t (1) w t (4)。首先,我们需要四个基础股票的现货价格,可以存储在 Eigen 的VectorXd中(假设它们来自实时市场数据源)。例如:

VectorXd spots(4);          // Init spot prices from market
spots << 100.0, 150.0, 25.0, 50.0;

接下来,我们需要一个矩阵来存储每个股票的随机价格路径,从每个现货价格开始,在额外的第一列中存储,使其成为一个 4 × 7 矩阵。

MatrixXd integ_scens{ corr_norms.rows(), corr_norms.cols() + 1 }	// 4 x 7 matrix
integ_scens.col(0) = spots;

假设到期时间为一年,分为六个等时间步。因此,我们可以得到Δ t值,比如dt

double time_to_maturity = 1.0;
unsigned num_time_steps = 6;
double dt = time_to_maturity / num_time_steps;

如(*)中所示,每个给定基础股票的每个连续价格可以在 lambda 函数中计算,其中price是场景中的前期价格,而vol是特定股票的波动率。

auto gen_price = dt, rf_rate -> double
{
    double expArg1 = (rf_rate - ((vol * vol) / 2.0)) * dt;
    double expArg2 = corr_norm * std::sqrt(dt);
    double next_price = price * std::exp(expArg1 + expArg2);
    return next_price;
};

最后,在每个时间步骤的每个基础股票上,我们可以设置一个迭代,并在每个步骤调用 lambda 函数:

for (unsigned j = 1; j < integ_scens.cols(); ++j)
{
    for (unsigned i = 0; i < integ_scens.rows(); ++i)
    {
        integ_scens(i, j) = gen_price(integ_scens(i, j - 1), vols(i), corr_norms(i, j - 1));
    }
}

对于此示例,结果如下:

100		100.99		101.972		107.952		115.226		125.384		124.6
150		150.086 	150.535 	155.248 	155.976 	154.248 	156.034
25 		26.6633 	29.0545 	29.2955 	30.5129 	25.8607 	25.5082
50 		50.5946 	42.6858 	42.2536  	43.414 		48.4132 	49.1949

再次提醒,由于不同供应商的标准库版本中<random>实现的差异,结果可能会有所不同。

收益率曲线动态和主成分分析

主成分分析(PCA)是确定驱动收益率曲线形状变化的变动源和幅度的工具。通过首先计算跨多个债券到期期限的每日收益率变化的协方差矩阵的特征值,并按降序排序,然后通过将每个特征值除以所有特征值的总和来计算权重。

实证研究表明,前三个特征值的贡献几乎构成了权重的全部,其中第一个权重对应于收益曲线的平行移动,第二个对应于其“倾斜”或“斜率”的变化,第三个对应于曲率。关于这一点的原因和详细内容可以在 Ruppert 和 Matteson 的计算金融优秀著作第十八章中找到 {25},以及 Rebonato 关于利率衍生品的经典文本第三章中 {26}

存在严格的统计检验来衡量显著性,但仅权重本身可以提供每个变化来源的相对估计量。

Ruppert 和 Matteson 的文本第 18.2 节 {op cit 25} 提供了一个关于如何应用主成分分析到公开可用的美国国债收益数据的优秀示例 {put URL here}。结果的协方差矩阵 — 作为下述MatrixXd对象的构造器数据,基于十一种不同到期限的美国国债收益率的波动,从一个月到 30 年。基础数据来自 1990 年 1 月到 2008 年 10 月的时期。

要计算特征值,首先将协方差矩阵数据加载到MatrixXd实例的上三角区域。

	MatrixXd term_struct_cov_mtx
	{
		// 1 month
		{ 0.018920,	0.009889, 0.005820,	0.005103, 0.003813,	0.003626,
			0.003136, 0.002646, 0.002015, 0.001438, 0.001303 },

		// 3 months
		{ 0.0, 0.010107, 0.006123, 0.004796, 0.003532, 0.003414,
			0.002893, 0.002404, 0.001815, 0.001217, 0.001109},

		// 6 months
		{ 0.0, 0.0, 0.005665, 0.004677, 0.003808, 0.003790,
			0.003255, 0.002771, 0.002179, 0.001567, 0.001400 },

		// 1 year
			{ 0.0, 0.0, 0.0, 0.004830, 0.004695, 0.004672,
				0.004126, 0.003606, 0.002952, 0.002238, 0.002007},

		// 2 years
		{ 0.0, 0.0, 0.0, 0.0, 0.006431, 0.006338,
			0.005789, 0.005162, 0.004337, 0.003343, 0.003004},

		// 3 years
		{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.006524,
			0.005947, 0.005356, 0.004540, 0.003568, 0.003231 },

		// 5 years
		{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
			0.005800, 0.005291, 0.004552, 0.003669, 0.003352 },

		// 7 years
		{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
			0.0, 0.004985, 0.004346, 0.003572, 0.003288 },

		// 10 years
		{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
			0.0, 0.0, 0.003958, 0.003319, 0.003085 },

		// 20 years
		{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
			0.0, 0.0, 0.0, 0.003062, 0.002858 },

		// 30 years
		{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
			0.0, 0.0, 0.0, 0.0, 0.002814 }
	};

由于实对称矩阵在无复杂分量时是平凡自共轭的,Eigen 可以将selfadjointView<.>()函数应用于上(或下)三角矩阵,以定义对称协方差矩阵的视图。然后,可以通过在对称结果上应用eigenvalues()函数来获得特征值(全部为实数):

VectorXd eigenvals = term_struct_cov_mtx.selfadjointView<Eigen::Upper>().eigenvalues();

为了确定每个主成分的权重,我们需要将每个特征值除以所有特征值的总和:

double total_ev = std::accumulate(eigenvals.cbegin(), eigenvals.cend(), 0.0);
std::ranges::transform(eigenvals, eigenvals.begin(),
	total_ev {return x / total_ev; });

最后,为了按主成分顺序查看结果,需要将加权值从大到小排列:

std::ranges::sort(eigenvals, std::greater{});

检查eigenvals的内容,我们会发现以下结果:

0.67245 0.213209 0.073749
0.023811 0.00962511 0.00275773
0.0016744 0.00114298 0.000740139
0.000528862 0.000311391

由此可见,平行移动和收益曲线的“倾斜”效应是主导效应,相对权重分别为 67.2%和 21.3%,而曲率效应较小,为 7.4%。

未来方向:标准库中的线性代数

已向 ISO C++委员会提交了三个与线性代数相关的提案。

mdspan(P0009)可以在对容器的引用上施加多维数组结构,例如 STL vector。使用包含数据的vector和表示矩阵的引用mdspan作为示例,在构造mdspan时设置行数和列数。mdspan也可以采用更高维度的数组形式,但我们的目的是关注矩阵表示的二维情况。mdspan正式计划在 C++23 中发布。

第二个提案(P1673)是为外部线性代数提供标准接口,“基于密集型基本线性代数子程序(BLAS)”,对应于“BLAS 标准的子集”。换句话说,无论使用哪个外部线性代数库,代码都可以独立编写,从而使代码维护更加容易且不易出错。正如提案中所述,“接口的设计以 C++标准库的算法精神为基础”,并使用“mdspan……表示矩阵和向量”(同上)。此外,“接口的设计以 C++标准库的算法精神为基础”(同上)。目前计划在 C++26 中发布。

第三个提案(P1385)是在标准库中提供实际的 BLAS 类型功能。其主要目标是“为表示与线性代数相关的数学对象和基本操作提供矩阵词汇类型”(见 30 页)。该提案目前也计划在 C++26 中发布。

mdspan(P0009)

如上所述,为了表示矩阵,mdspan建立对连续容器的引用,然后强加行数和列数。如果这些参数在编译时已知,创建mdspan很容易:

vector<int> v{ 101, 102, 103, 104, 105, 106 };
auto mds1 = std::mdspan{ v.data(), 3, 2 };

注意,mdspan使用vectordata()成员函数访问其内容。

mdspan术语中,行和列被称为extents,通过每个索引访问行数和列数,行数索引为 0,列数索引为 1。总的rank表示为 2,对于更高阶的多维数组,rank会大于两个。

size_t n_rows{ mds1.extent(0) };		// 3
size_t n_cols{ mds1.extent(1) };		// 2
size_t n_extents{ mds1.rank() };		// 2
注意

术语rank在应用于mdspan时与矩阵秩的数学定义不同。这种命名可能会令人困惑,但这是需要注意的事项。

在 C++23 中,mdspan对象的元素可以通过另一个新功能访问,即具有多个索引的方括号运算符:

for (size_t i = 0; i < mds1.extent(0); ++i)
{
	for (size_t j = 0; j < mds1.extent(1); ++j)
		cout << mds1[i, j] << "\t";

	cout << "\n";
}

这是一个受欢迎的改进,因为不再需要将每个索引放在单独的括号对中,就像 C 风格数组的情况一样。

double ** a[3][2];		// Ugh

// Could put a[2, 1] if it were an mdspan instead:
a[2][1] = 5.4;

...

delete [] a;

上述嵌套循环的运行时结果显示一个 3 × 2 矩阵:

101     102
103     104
105     106

也可以使用相同的数据定义具有不同维度的矩阵:

auto mds2 = std::mdspan(v.data(), 2, 3);		// 2 x 3

应用上述相同的循环,但将mds1替换为mds2,则会如预期显示:

101     102     103
104     105     106

需要小心的一点是修改mdspan对象或原始vector中的数据将由于两者之间的引用关系而同时更改。例如,修改向量的最后一个元素

v[5] = 874;

将显示在两个mdspan对象中。mds1变成

101     102
103     104
105     874

mds2现在是

101     102     103
104     105     874

同样,更改mds2中的元素值将反映在mds1和向量v中。

在量化金融应用中,往往情况是在编译时不知道固定的行数和列数。假设mn是在运行时确定的行和列数。这些维度可以通过在前面示例中的固定设置 2 和 3 处动态设置来动态设置

auto mds2 = std::mdspan(v.data(), 2, 3);

std::extents{m, n}对象表示,如此处mdspan定义所示:

void dynamic_mdspan(size_t m, size_t n, vector<double> vec)
{

	std::mdspan md{ vec.data(), std::extents{m, n} };

	. . .

}

std::extents{m, n}参数表示每个 extent(即每行(m)和列(n))中的元素数量,这些在运行时动态确定。

假设我们在运行时有以下数据集:

vector<double> w{ 10.1, 10.2, 10.3, 10.4, 10.5, 10.6 };
size_t m{3};
size_t n{2};

使用这些作为dynamic_mdspan(.)函数的输入,然后将生成一个 3 × 2 mdspan矩阵:

10.1    10.2
10.3    10.4
10.5    10.6
注意

上面的例子利用了类模板自动推导(CTAD)。mdspanextents都是类模板,但是因为上面示例中的wvectordouble类型,而vector的大小是size_t类型,当替换为dynamic_mdspan函数中的vec时,编译器将推断mdspanextents对象要使用doublesize_t作为模板参数。

没有 CTAD,函数将写成如下形式:

template<typename T, typename S>
void dynamic_mdspan(S m, S n, T vec)
{
	using ext_t = std::extents<S, std::dynamic_extent, std::dynamic_extent>;
	std::mdspan<T, ext_t> mds_dyn{ vec.data(), m, n };

	. . .

}

本章的讨论将依赖于 CTAD,但在需要更一般性的情况下,将需要完全书写模板参数。

还有一件事要注意的是,在迄今为止的每个例子中,mdspan将默认按行主顺序排列数据。通过定义一个layout_left策略映射,在本例中称为col_major,可以将其排列为列主顺序,如下所示:

std::layout_left::mapping col_major{ std::extents{ m, n } };

然后可以通过在mdspan构造函数的 extents 参数中替换此映射来定义相应的列主矩阵。

std::mdspan md{ v.data(), col_major };

结果是矩阵的列主版本:

10.1    10.4
10.2    10.5
10.3    10.6
注意

可以使用std::layout_right映射来明确设置行主顺序。

mdspan提案还包括一个名为submdspan(.)的“切片”函数,用于返回由 mdspan 表示的矩阵的单个行或列的引用。更一般地说,这将扩展到更高维数组的子集。

返回到行主整数 3 × 2 mds1示例,如果我们想要提取对第一行(索引 0)的引用,可以如下获得,使用submdspan中的第一个 extent(行)参数中的索引:

auto row_1 = std::submdspan(mds1, 0, std::full_extent)

这将给我们:

row_1[0] = 101  row_1[1] = 102

第 2 和第 3 行也可以通过将 0 替换为 12 进行引用。

通过将第二个(列)范围参数显式设置为列大小减 1,我们还可以访问最后一列:

auto col_last = std::submdspan(mds1, std::full_extent, mds1.extent(1)-1);

这将包括:

col_2[0] = 102  col_2[1] = 104  col_2[2] = 106

因为 submdspan 是指向包含行或列的 mdspan 的引用(视图),所以不需要生成额外的 mdspan,与从 valarray 中取得的 slice_array 不同。可以对 submdspan 应用在 mdspan 上的任何公共成员函数。然而,由于其引用性质,不包括提供给 valarray 的向量化数学运算符或函数,尽管在 P1673 中将提供运算符的另一种方法,该方法将在下一节讨论。

另一方面,由于其是一个引用,修改 submdspan 的元素也会修改底层的 mdspan 对象。假设重置 col_last 中的最后一个元素:

col_2[2] = 3333;

然后原始的 mds1 将变为:

101     102
103     104
105     3333

最后一点是关于多维数组(P1684)的提议,称为 mdarray,也正在审核中。如在 提议 {31} 中所述,“mdarray 尽可能与 mdspan 相似,但其具有容器语义而不是引用语义”。换句话说,mdarray 对象“拥有”其数据,类似于 vector,而不是像 mdspan 那样作为对另一个容器拥有的数据的引用。它可能最早会在 C++26 中发布。

注意

上述 mdspan 的代码示例可以在 C++20 中使用当前在 P1673 GitHub 站点 {31} 上可用的工作代码编译。安装和构建说明包含在存储库中,但需要了解的两个特定项是,首先,该代码目前位于命名空间 std::experimental 下,其次,由于多重索引的方括号运算符设置为 C++23,您可以在 C++20 及更早版本中用圆括号运算符替换它;即,

_

namespace stdex = std::experimental;
auto mds1 = stdex::mdspan{ v.data(), 3, 2 };

// Replace the square brackets here:
for (size_t i = 0; i < n_rows; ++i)
{
	for (size_t j = 0; j < n_cols; ++j)
		cout << mds1[i, j]			<< "\t";
}

// with round brackets:
for (size_t i = 0; i < n_rows; ++i)
{
	for (size_t j = 0; j < n_cols; ++j)
		cout << mds1(i, j) << "\t";
}

BLAS 接口(P1673)

此提议是关于基于密集基本线性代数子程序(BLAS)的 C++ 标准库稠密线性代数接口的提案 {见引用 29},也简称为“stdBLAS”。BLAS 库可以追溯到几十年前,最初是用 Fortran 编写的,但在 2000 年初发展为标准,现在有其他语言的实现,如 C(OpenBLAS)和 CUDA C++(NVIDIA),同时还有用于 Fortran 的 C 绑定。

Fortran BLAS 分发支持四种数值类型:FLOAT, DOUBLE, COMPLEX, 和 DOUBLE COMPLEX。其 C++ 等效类型为 float, double, std::complex<float>, 和 std::complex<double>。BLAS 库包含多种矩阵格式(标准、对称、上/下三角形)、矩阵和向量操作,如逐元素加法和矩阵/向量乘法。

使用此提议的实现,可以将相同的 C++ 代码库应用于任何包含 BLAS 功能的兼容库,前提是库供应商已经提供了接口。这将允许可移植的代码,独立于所使用的基础库。在这个阶段,尚不清楚哪些供应商最终会参与,但 NVIDIA 的实现是一个重要的进展,包括它们的 HPC SDK {33}

值得注意的是,即使底层库提供额外的功能如矩阵分解、线性和最小二乘求解器等,stdBLAS 本身也只提供对特定矩阵操作的访问权限 — 下一步将进行讨论 — 。

BLAS 函数根据它们应用的矩阵和/或向量中包含的类型进行前置。例如,矩阵乘以向量的乘法函数的形式如下

xGEMV(.)

其中 x 可以是 SDCZ,分别表示单精度 (REAL 在 Fortran 中)、双精度 (DOUBLE)、复数 (COMPLEX) 和双精度复数 (DOUBLE COMPLEX)。

在提议中的 C++ 等效函数 matrix_vector_product 取代了以前使用的 mdspan 对象表示矩阵和向量。例如,我们可以查看一个涉及 double 值的案例,使用 mn 表示行数和列数。

std::vector<double> A_vec(m * n);
std::vector<double> x_vec(n);

// A_vec and x_vec are then populated with data...

std::vector<double> y_vec(n);		// empty vector

std::mdspan A{ A_vec.data(), std::extents{m, n} };
std::mdspan x{ x_vec.data(), std::extents{n} };
std::mdspan y{ y_vec.data(), std::extents{m} };

然后,进行乘法运算,向量积存储在 y 中:

std::linalg::matrix_vector_product(A, x, y); 	// y = A * x

下表提供了在金融编程中可能有用的 P1673 中的 BLAS 函数子集。假设 BLAS 函数为双精度,并且任何给定的矩阵/向量表达式均为适当的维度。

表 5-1. 提议 P1673 中选择的 BLAS 函数

BLAS 函数 P1673 函数 描述
DSCAL scale 向量的标量乘法
DCOPY copy 将一个向量复制到另一个向量
DAXPY add 计算 α 𝐱 + 𝐲,向量 𝐱𝐲,标量 α
DDOT dot 两个向量的点积
DNRM2 vector_norm2 向量的欧几里德范数
DGEMV matrix_vector_product 计算 α 𝐀𝐱 + β 𝐲,矩阵 𝐀,向量 𝐲,标量 αβ
DSYMV symmetric_matrix_vector_product 与 DGEMV (matrix_vector_product) 相同,但矩阵 𝐀 是对称的
DGEMM matrix_product 计算α 𝐀𝐁 + β 𝐂,对于矩阵𝐀𝐁𝐂,以及标量αβ

线性代数(P1385)

本提案的作者特别认识到线性代数在金融建模中的重要性,以及在医学成像、机器学习和高性能计算等其他应用中的重要性。{引用 28}。

C++中线性代数库的初始技术要求在之前的提案(P1166) {34} 中概述,这里直接引用(斜体):

类型和函数的集合应该是在有限维空间中执行函数所需的最小集合。这包括

  • 矩阵模板

  • 矩阵加法、减法和乘法的二元运算

  • 标量乘法和矩阵除法的二元运算

在此基础上,P1385 提案规定了两个主要目标,即库应该易于使用,并且“运行时计算性能接近于用户通过等效的函数调用序列获得的传统线性代数库(如 LAPACK、Blaze、Eigen 等)”。{引用 30}

从高层次来看,矩阵通常由MathObj类型泛化表示,而engine“是一个管理与MathObj实例相关资源的实现类型。”讨论仍在进行中,但“MathObj可能拥有存储其元素的内存,或者它可能使用一些非拥有视图类型,如mdspan,来操作由其他对象拥有的元素”。{同上}。engine对象被建议作为MathObj的私有成员,并且MathObj可以具有固定或动态维度。

在未来几年内,关于 P1385 线性代数库的形式应该会有更多细节浮出水面,但目前,这希望为金融软件开发人员提供一个初步的高层次预览,这应该是 C++中非常受欢迎的一个补充。

摘要(线性代数提案)

回顾我们在valarray中看到的结果,有了上述提案,同样方便的功能应该在 C++26 中得以实现,这一次将有严格、高效和一致的规范,避免了valarray困扰的问题。虽然mdspan作为非拥有引用与valarray不同,但它仍允许数组存储(如vector)通过指定行数和列数来适应矩阵代理。submdspan将承担类似于valarray切片的角色,但不会因对象复制而带来性能损失。P1673 将为包含 BLAS 函数的库提供一个公共接口,函数命名,例如matrix_vector_product,将比它们的 Fortran 等价物(如DGEMV(.))更具表达性。而 P1385 中的+-*运算符将提供在自然数学格式中实现线性代数表达式的能力,这与我们在valarray中看到的结果类似。

这是一个令人兴奋的发展,将最终为 C++实现长期以来期待的基本矩阵计算提供高效可靠的方法。希望我们最终也能看到 P1673 BLAS 接口在 Eigen 等流行的开源库中的实现,但在撰写本文时,这仍是未知数。

章节总结

本章探讨了 C++中二维数组管理和线性代数的过去、现在和预期未来。valarray 自 C++98 起提供了类似矩阵的功能,对量化开发者来说应该有大量使用案例。不幸的是,尽管在 20 世纪 90 年代末 C++被宣传为计算金融的未来语言,但它从未得到委员会和社区的关注和支持。对于特定的编译器来说,它可能仍然是一个可行的选择,但由于其在标准库供应商中实现的不一致性,它会限制在不同平台上的代码复用。

在 2000 年代末,像 Eigen 和 Armadillo 这样的高质量开源线性代数库出现了,并受到金融量化 C++编程社区的好评。如今,这些库不仅包含 BLAS 标准中的许多功能,还包括在金融应用中经常使用的大量矩阵分解功能。

最终,ISO C++ 的未来看起来更加光明,因为三个提案——mdspan(P0009)、BLAS 接口(P1673)、线性代数库(P1385)——计划在接下来的三年内被纳入标准库中。这些功能可以说是早就应该有的,但它们将大步满足不仅来自金融行业,还包括其他计算密集型领域的需求。

参考文献

美国每日财政部利率曲线收益率 https://home.treasury.gov/resource-center/data-chart-center/interest-rates/TextView?type=daily_treasury_yield_curve&field_tdr_date_value_month=202211 _

附加章节,_C++标准库,第 2 版 _

{2.5} Stroustrup,C++之旅(第 2 版,现在是第 3 版)

Quantstart 关于 Eigen 的文章 https://www.quantstart.com/articles/Eigen-Library-for-Matrix-Algebra-in-C/

Gottschling _ 发现现代 C++

Bowie Owens,CppCon 2019,https://www.youtube.com/watch?v=4IUCBx5fIv0

关于雅可比和双对角分解 SVD 类的更多信息:

矩形矩阵的双边雅可比 SVD 分解

双对角分解 SVD

asciidoctor-latex -b html Ch10_v10_Split_LinearAlgebraOnly.adoc

{5} Rcpp 包:

RcppEigen

RcppArmadillo

RcppBlaze3

Boost Headers,包括 uBLAS

posted @ 2025-11-24 09:13  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报