精通-Python-金融第二版-一-

精通 Python 金融第二版(一)

原文:zh.annas-archive.org/md5/8b046e39ce2c1a10ac13fd89834aaadc

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的第二版Mastering Python for Finance将指导您使用下一代方法在金融行业中进行复杂的金融计算。您将通过利用公开可用的工具来掌握 Python 生态系统,成功进行研究和建模,并学习如何使用高级示例来管理风险。

您将首先设置一个 Jupyter 笔记本,以实现本书中的任务。您将学习如何使用流行的库(如 TensorFlow、Keras、NumPy、SciPy、scikit-learn 等)做出高效而强大的数据驱动金融决策。您还将学习如何通过掌握股票、期权、利率及其衍生品以及使用计算方法进行风险分析等概念来构建金融应用程序。有了这些基础,您将学习如何对时间序列数据进行统计分析,并了解如何利用高频数据来设计交易策略,从而构建算法交易*台。您将学习通过实施事件驱动的回测系统来验证您的交易策略,并衡量其性能。最后,您将探索在金融领域应用的机器学习和深度学习技术。

通过本书,您将学会如何将 Python 应用于金融行业中的不同范式,并进行高效的数据分析。

这本书是为谁准备的

如果您是金融或数据分析师,或者是金融行业的软件开发人员,有兴趣使用高级 Python 技术进行量化方法,那么这本书就是您需要的!如果您想要使用智能机器学习技术扩展现有金融应用程序的功能,您也会发现这本书很有用。

充分利用本书

需要有 Python 的先验经验。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.com/support并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packt.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的软件解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-Python-for-Finance-Second-Edition。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789346466_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:“默认情况下,pandas 的.plot()命令使用matplotlib库来显示图形。”

代码块设置如下:

In [ ]:
     %matplotlib inline
     import quandl

     quandl.ApiConfig.api_key = QUANDL_API_KEY
     df = quandl.get('EURONEXT/ABN.4')
     daily_changes = df.pct_change(periods=1)
     daily_changes.plot();

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

2015-02-26 TICK WIKI/AAPL open: 128.785 close: 130.415
2015-02-26 FILLED BUY 1 WIKI/AAPL at 128.785
2015-02-26 POSITION value:-128.785 upnl:1.630 rpnl:0.000
2015-02-27 TICK WIKI/AAPL open: 130.0 close: 128.46

任何命令行输入或输出都是按照以下格式编写的:

$ cd my_project_folder
$ virtualenv my_env

粗体:表示一个新术语,一个重要词或屏幕上看到的词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“要启动你的第一个笔记本,选择新建,然后Python 3。”

警告或重要提示会显示为这样。提示和技巧会显示为这样。

第一部分:开始使用 Python

本节将帮助我们在准备在本书中运行代码示例之前,在我们的机器上设置 Python。

本节将只包含一个章节:

  • 第一章,使用 Python 进行财务分析概述

第一章:使用 Python 进行金融分析概述

自从我之前的书《精通 Python 金融》出版以来,Python 本身和许多第三方库都有了重大升级。许多工具和功能已被弃用,取而代之的是新的工具和功能。本章将指导您如何获取最新的可用工具,并准备本书其余部分将使用的环境。

本书中涵盖的大部分数据集将使用 Quandl。Quandl 是一个提供金融、经济和替代数据的*台。这些数据来源于各种数据发布者,包括联合国、世界银行、中央银行、交易所、投资研究公司,甚至 Quandl 社区的成员。通过 Python Quandl 模块,您可以轻松下载数据集并进行金融分析,以得出有用的见解。

我们将使用pandas模块探索时间序列数据操作。pandas中的两个主要数据结构是 Series 对象和 DataFrame 对象。它们可以一起用于绘制图表和可视化复杂信息。本章将涵盖金融时间序列计算和分析的常见方法。

本章的目的是为您设置工作环境,使用本书中将使用的库。多年来,像任何软件包一样,pandas模块已经发生了巨大的演变,许多重大变化。多年前编写的代码与旧版本的pandas接口将不再起作用,因为许多方法已被弃用。本书中使用的pandas版本是 0.23。本书中编写的代码符合这个版本的pandas

在本章中,我们将涵盖以下内容:

  • 为您的环境设置 Python、Jupyter、Quandl 和其他库

  • 从 Quandl 下载数据集并绘制您的第一个图表

  • 绘制最后价格、成交量和蜡烛图

  • 计算和绘制每日百分比和累积收益

  • 绘制波动率、直方图和 Q-Q 图

  • 可视化相关性并生成相关矩阵

  • 可视化简单移动*均线和指数移动*均线

获取 Python

在撰写本文时,最新的 Python 版本是 3.7.0。您可以从官方 Python 网站www.python.org/downloads/下载 Windows、macOS X、Linux/UNIX 和其他操作系统的最新版本。按照安装说明在您的操作系统上安装基本的 Python 解释器。

安装过程应该将 Python 添加到您的环境路径中。要检查已安装的 Python 版本,请在 macOS X/Linux 上的终端中输入以下命令,或者在 Windows 上的命令提示符中输入以下命令:

$ python --version
Python 3.7.0

为了方便安装 Python 库,考虑使用 Anaconda(www.anaconda.com/download/)、Miniconda(conda.io/miniconda.html)或 Enthought Canopy(www.enthought.com/product/enthought-python-distribution/)等一体化 Python 发行版。然而,高级用户可能更喜欢控制哪些库与他们的基本 Python 解释器一起安装。

准备虚拟环境

此时,建议设置 Python 虚拟环境。虚拟环境允许您管理特定项目所需的单独包安装,隔离其他环境中安装的包。

要在终端窗口中安装虚拟环境包,请输入以下命令:

$ pip install virtualenv

在某些系统上,Python 3 可能使用不同的pip可执行文件,并且可能需要通过替代的pip命令进行安装;例如:$ pip3 install virtualenv

要创建虚拟环境,请转到项目目录并运行virtualenv。例如,如果您的项目文件夹的名称是my_project_folder,请输入以下内容:

$ cd my_project_folder
$ virtualenv my_venv

virtualenv my_venv将在当前工作目录中创建一个文件夹,其中包含您之前安装的基本 Python 解释器的 Python 可执行文件,以及pip库的副本,您可以使用它来安装其他软件包。

在使用新的虚拟环境之前,需要激活它。在 macOS X 或 Linux 终端中,输入以下命令:

$ source my_venv/bin/activate

在 Windows 上,激活命令如下:

$ my_project_folder\my_venv\Scripts\activate

当前虚拟环境的名称现在将显示在提示的左侧(例如,(my_venv) current_folder$),以让您知道所选的 Python 环境已激活。从同一终端窗口进行的软件包安装将放在my_venv文件夹中,与全局 Python 解释器隔离开来。

虚拟环境可以帮助防止冲突,如果您有多个应用程序使用相同模块但来自不同版本。这一步(创建虚拟环境)完全是可选的,因为您仍然可以使用默认的基本解释器来安装软件包。

运行 Jupyter Notebook

Jupyter Notebook 是一个基于浏览器的交互式计算环境,用于创建、执行和可视化各种编程语言的交互式数据。它以前被称为IPython Notebook。IPython 仍然存在作为 Python shell 和 Jupyter 的内核。Jupyter 是一个开源软件,所有人都可以免费使用和学习各种主题,从基本编程到高级统计学或量子力学。

要安装 Jupyter,在终端窗口中输入以下命令:

$ pip install jupyter

安装后,使用以下命令启动 Jupyter:

$ jupyter notebook 
... 
Copy/paste this URL into your browser when you connect for the first time, to login with a token: 
 http://127.0.0.1:8888/?token=27a16ee4d6042a53f6e31161449efcf7e71418f23e17549d

观察您的终端窗口。当 Jupyter 启动时,控制台将提供有关其运行状态的信息。您还应该看到一个 URL。将该 URL 复制到 Web 浏览器中,即可进入 Jupyter 计算界面。

由于 Jupyter 在您发出前面的命令的目录中启动,Jupyter 将列出工作目录中所有保存的笔记本。如果这是您在该目录中工作的第一次,列表将为空。

要启动您的第一个笔记本,请选择 New,然后选择 Python 3。一个新的 Jupyter Notebook 将在新窗口中打开。今后,本书中的大多数计算将在 Jupyter 中进行。

Python Enhancement Proposal

Python 编程语言中的任何设计考虑都被记录为Python Enhancement ProposalPEP)。已经编写了数百个 PEP,但您可能应该熟悉的是PEP 8,这是 Python 开发人员编写更好、更可读代码的风格指南。PEP 的官方存储库是github.com/python/peps

什么是 PEP?

PEP 是一系列编号的设计文档,描述与 Python 相关的特性、过程或环境。每个 PEP 都在一个文本文件中进行精心维护,包含特定特性的技术规范及其存在的原因。例如,PEP 0 用作所有 PEP 的索引,而 PEP 1 提供了 PEP 的目的和指南。作为软件开发人员,我们经常阅读代码而不是编写代码。为了创建清晰、简洁和可读的代码,我们应该始终使用编码约定作为风格指南。PEP 8 是一组编写 Python 代码的风格指南。您可以在www.python.org/dev/peps/pep-0008/上了解更多关于 PEP 8 的信息。

Python 之禅

PEP 20 体现了 Python 之禅,这是一组指导 Python 编程语言设计的 20 个软件原则。要显示这个彩蛋,在 Python shell 中输入以下命令:

>> import this
The Zen of Python, by Tim Peters Beautiful is better than ugly. Explicit is better than implicit. 
Simple is better than complex. 
Complex is better than complicated. 
Flat is better than nested. 
Sparse is better than dense. 
Readability counts. 
Special cases aren't special enough to break the rules. 
Although practicality beats purity. 
Errors should never pass silently. 
Unless explicitly silenced. 
In the face of ambiguity, refuse the temptation to guess. 
There should be one-- and preferably only one --obvious way to do it. 
Although that way may not be obvious at first unless you're Dutch. 
Now is better than never. 
Although never is often better than *right* now. 
If the implementation is hard to explain, it's a bad idea. 
If the implementation is easy to explain, it may be a good idea. 
Namespaces are one honking great idea -- let's do more of those!

只显示了 20 条格言中的 19 条。你能猜出最后一条是什么吗?我留给你的想象!

Quandl 简介

Quandl 是一个提供金融、经济和替代数据的*台。这些数据来源于各种数据发布者,包括联合国、世界银行、中央银行、交易所和投资研究公司。

使用 Python Quandl 模块,您可以轻松地将金融数据集导入 Python。Quandl 提供免费数据集,其中一些是样本。访问高级数据产品需要付费。

为您的环境设置 Quandl

Quandl包需要最新版本的 NumPy 和pandas。此外,我们将在本章的其余部分需要matplotlib

要安装这些包,请在终端窗口中输入以下代码:

$ pip install quandl numpy pandas matplotlib

多年来,pandas库发生了许多变化。为旧版本的pandas编写的代码可能无法与最新版本一起使用,因为有许多已弃用的内容。我们将使用的pandas版本是 0.23。要检查您正在使用的pandas版本,请在 Python shell 中键入以下命令:

>>> import pandas
>>> pandas.__version__'0.23.3'

使用 Quandl 请求数据时需要一个 API(应用程序编程接口)密钥。

如果您没有 Quandl 账户,请按以下步骤操作:

  1. 打开浏览器,在地址栏中输入www.quandl.com。这将显示以下页面:

  1. 选择注册并按照说明创建一个免费账户。成功注册后,您将会看到您的 API 密钥。

  2. 复制此密钥并将其安全地保存在其他地方,因为您以后会需要它。否则,您可以在您的账户设置中再次检索此密钥。

  3. 请记住检查您的电子邮件收件箱,查看欢迎消息并验证您的 Quandl 账户,因为继续使用 API 密钥需要验证和有效的 Quandl 账户。

匿名用户每 10 分钟最多可以调用 20 次,每天最多可以调用 50 次。经过身份验证的免费用户每 10 秒最多可以调用 300 次,每 10 分钟最多可以调用 2,000 次,每天最多可以调用 50,000 次。

绘制时间序列图表

在图表上可视化时间序列数据是一种简单而有效的分析技术,通过它我们可以推断出某些假设。本节将指导您完成从 Quandl 下载股价数据集并在价格和成交量图上绘制的过程。我们还将介绍绘制蜡烛图表,这将为我们提供比线图更多的信息。

从 Quandl 检索数据

从 Quandl 中获取数据到 Python 是相当简单的。假设我们对来自 Euronext 股票交易所的 ABN Amro Group 感兴趣。在 Quandl 中的股票代码是EURONEXT/ABN。在 Jupyter 笔记本单元格中,运行以下命令:

In [ ]:
    import quandl

    # Replace with your own Quandl API key
    QUANDL_API_KEY = 'BCzkk3NDWt7H9yjzx-DY' 
    quandl.ApiConfig.api_key = QUANDL_API_KEY
    df = quandl.get('EURONEXT/ABN')

将 Quandl API 密钥存储在常量变量中是一个好习惯。这样,如果您的 API 密钥发生变化,您只需要在一个地方更新它!

导入quandl包后,我们将 Quandl API 密钥存储在常量变量QUANDL_API_KEY中,这将在本章的其余部分中重复使用。这个常量值用于设置 Quandl 模块的 API 密钥,每次导入quandl包时只需要执行一次。接下来的一行调用quandl.get()方法,将 ABN 数据集从 Quandl 直接下载到我们的df变量中。请注意,EURONEXT是数据提供者 Euronext 股票交易所的缩写。

默认情况下,Quandl 将数据集检索到pandas DataFrame 中。我们可以按以下方式检查 DataFrame 的头和尾:

In [ ]: 
    df.head()
Out[ ]: 
                 Open   High     Low   Last      Volume      Turnover
    Date                                                             
    2015-11-20  18.18  18.43  18.000  18.35  38392898.0  7.003281e+08
    2015-11-23  18.45  18.70  18.215  18.61   3352514.0  6.186446e+07
    2015-11-24  18.70  18.80  18.370  18.80   4871901.0  8.994087e+07
    2015-11-25  18.85  19.50  18.770  19.45   4802607.0  9.153862e+07
    2015-11-26  19.48  19.67  19.410  19.43   1648481.0  3.220713e+07

In [ ]:
    df.tail()
Out[ ]:
                 Open   High    Low   Last     Volume      Turnover
    Date                                                           
    2018-08-06  23.50  23.59  23.29  23.34  1126371.0  2.634333e+07
    2018-08-07  23.59  23.60  23.31  23.33  1785613.0  4.177652e+07
    2018-08-08  24.00  24.39  23.83  24.14  4165320.0  1.007085e+08
    2018-08-09  24.40  24.46  24.16  24.37  2422470.0  5.895752e+07
    2018-08-10  23.70  23.94  23.28  23.51  3951850.0  9.336493e+07

默认情况下,head()tail()命令将分别显示 DataFrame 的前五行和后五行。您可以通过在参数中传递一个数字来定义要显示的行数。例如,head(100)将显示 DataFrame 的前 100 行。

对于get()方法没有设置任何额外参数,将检索整个时间序列数据集,从上一个工作日一直回溯到 2015 年 11 月,每天一次。

要可视化这个 DataFrame,我们可以使用plot()命令绘制图表:

In [ ]:
    %matplotlib inline
    import matplotlib.pyplot as plt

    df.plot();

最后一个命令输出一个简单的图表:

pandasplot()方法返回一个 Axes 对象。这个对象的字符串表示形式与plot()命令一起打印在控制台上。要抑制这些信息,可以在最后一个语句的末尾添加一个分号(😉。或者,可以在单元格的底部添加一个pass语句。另外,将绘图函数分配给一个变量也会抑制输出。

默认情况下,pandas中的plot()命令使用matplotlib库来显示图表。如果出现错误,请检查是否安装了该库,并且至少调用了%matplotlib inline。您可以自定义图表的外观和感觉。有关pandas DataFrame 中plot命令的更多信息,请参阅pandas文档pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.plot.html

绘制价格和成交量图表

当没有参数提供给plot()命令时,将使用目标 DataFrame 的所有列绘制一条线图,放在同一张图上。这会产生一个混乱的视图,无法提供太多信息。为了有效地从这些数据中提取见解,我们可以绘制一个股票的财务图表,显示每日收盘价与交易量的关系。为了实现这一点,输入以下命令:

In [ ]:
    prices = df['Last']
    volumes = df['Volume']

上述命令将我们感兴趣的数据分别存储到closing_pricesvolumes变量中。我们可以使用head()tail()命令查看生成的pandas Series 数据类型的前几行和最后几行:

In [ ]:
    prices.head()
Out[ ]:
    Date
    2015-11-20    18.35
    2015-11-23    18.61
    2015-11-24    18.80
    2015-11-25    19.45
    2015-11-26    19.43
    Name: Last, dtype: float64

In [ ]:
    volumes.tail()
Out[ ]:   
    Date
    2018-08-03    1252024.0
    2018-08-06    1126371.0
    2018-08-07    1785613.0
    2018-08-08    4165320.0
    2018-08-09    2422470.0
    Name: Volume, dtype: float64

要找出特定变量的类型,使用type()命令。例如,type(volumes)会产生pandas.core.series.Series,告诉我们volumes变量实际上是一个pandas Series 数据类型对象。

注意,数据可从 2018 年一直回溯到 2015 年。现在我们可以绘制价格和成交量图表:

In [ ]:
    # The top plot consisting of daily closing prices
    top = plt.subplot2grid((4, 4), (0, 0), rowspan=3, colspan=4)
    top.plot(prices.index, prices, label='Last')
    plt.title('ABN Last Price from 2015 - 2018')
    plt.legend(loc=2)

    # The bottom plot consisting of daily trading volume
    bottom = plt.subplot2grid((4, 4), (3,0), rowspan=1, colspan=4)
    bottom.bar(volumes.index, volumes)
    plt.title('ABN Daily Trading Volume')

    plt.gcf().set_size_inches(12, 8)
    plt.subplots_adjust(hspace=0.75)

这将产生以下图表:

在第一行中,subplot2grid命令的第一个参数(4,4)将整个图表分成 4x4 的网格。第二个参数(0,0)指定给定的图表将锚定在图表的左上角。关键字参数rowspan=3表示图表将占据网格中可用的 4 行中的 3 行,实际上占据了图表的 75%高度。关键字参数colspan=4表示图表将占据网格的所有 4 列,使用了所有可用的宽度。该命令返回一个matplotlib轴对象,我们将使用它来绘制图表的上部分。

在第二行,plot()命令呈现了上部图表,x轴上是日期和时间值,y轴上是价格。在接下来的两行中,我们指定了当前图表的标题,以及放在左上角的时间序列数据的图例。

接下来,我们执行相同的操作,在底部图表上呈现每日交易量,指定一个 1 行 4 列的网格空间,锚定在图表的左下角。

legend()命令中,loc关键字接受一个整数值作为图例的位置代码。值为2表示左上角位置。有关位置代码的表格,请参阅matplotlib的图例文档matplotlib.org/api/legend_api.html?highlight=legend#module-matplotlib.legend

为了使我们的图形看起来更大,我们调用set_size_inches()命令将图形设置为宽 9 英寸、高 6 英寸,从而产生一个长方形的图形。前面的gcf()命令简单地表示获取当前图形。最后,我们调用带有hspace参数的subplots_adjust()命令,以在顶部和底部子图之间添加一小段高度。

subplots_adjust()命令调整了子图布局。可接受的参数有leftrightbottomtopwspacehspace。有关这些参数的更多信息,请参阅matplotlib.org/api/_as_gen/matplotlib.pyplot.subplots_adjust.html中的matplotlib文档。

绘制蜡烛图

蜡烛图是另一种流行的金融图表类型,它显示了比单一价格更多的信息。蜡烛图代表了特定时间点的每个标记,其中包含四个重要的信息:开盘价、最高价、最低价和收盘价。

matplotlib.finance模块已被弃用。相反,我们可以使用另一个包mpl_finance,其中包含了提取的代码。要安装此包,在您的终端窗口中,输入以下命令:

$ pip install mpl-finance

为了更仔细地可视化蜡烛图,我们将使用 ABN 数据集的子集。在下面的示例中,我们从 Quandl 查询 2018 年 7 月的每日价格作为我们的数据集,并绘制一个蜡烛图,如下所示:

In [ ]:
    %matplotlib inline
    import quandl
    from mpl_finance import candlestick_ohlc
    import matplotlib.dates as mdates
    import matplotlib.pyplot as plt

    quandl.ApiConfig.api_key = QUANDL_API_KEY
    df_subset = quandl.get('EURONEXT/ABN', 
                           start_date='2018-07-01', 
                           end_date='2018-07-31')

    df_subset['Date'] = df_subset.index.map(mdates.date2num)
    df_ohlc = df_subset[['Date','Open', 'High', 'Low', 'Last']]

    figure, ax = plt.subplots(figsize = (8,4))
    formatter = mdates.DateFormatter('%Y-%m-%d')
    ax.xaxis.set_major_formatter(formatter)
    candlestick_ohlc(ax, 
                     df_ohlc.values, 
                     width=0.8, 
                     colorup='green', 
                     colordown='red')
    plt.show()

这将产生一个蜡烛图,如下截图所示:

您可以在quandl.get()命令中指定start_dateend_date参数,以检索所选日期范围的数据集。

从 Quandl 检索的价格被放置在一个名为df_dataset的变量中。由于matplotlibplot函数需要自己的格式,mdates.date2num命令将包含日期和时间的索引值转换,并将它们放在一个名为Date的新列中。

蜡烛图的日期、开盘价、最高价、最低价和收盘价数据列被明确提取为df_ohlc变量中的 DataFrame。plt.subplots()创建了一个宽 8 英寸、高 4 英寸的图形。x轴上的标签被格式化为人类可读的格式。

我们的数据现在已准备好通过调用candlestick_ohlc()命令作为蜡烛图来绘制,蜡烛图的宽度为 0.8(或全天宽度的 80%)。收盘价高于开盘价的上涨标记以绿色表示,而收盘价低于开盘价的下跌标记以红色表示。最后,我们添加了plt.show()命令来显示蜡烛图。

在时间序列数据上执行金融分析

在本节中,我们将可视化金融分析中使用的时间序列数据的一些统计属性。

绘制收益

安全性表现的经典指标之一是其在先前时期的收益。在pandas中计算收益的一种简单方法是pct_change,它计算了每行在 DataFrame 中的前一行的百分比变化。

在下面的示例中,我们使用 ABN 股票数据绘制了每日百分比收益的简单图表:

In [ ]:
     %matplotlib inline
     import quandl

     quandl.ApiConfig.api_key = QUANDL_API_KEY
     df = quandl.get('EURONEXT/ABN.4')
     daily_changes = df.pct_change(periods=1)
     daily_changes.plot();

每日百分比收益的折线图如下所示:

quandl.get()方法中,我们使用后缀符号.4来指定仅检索数据集的第四列,其中包含最后的价格。在调用pct_change时,period参数指定了要移动以形成百分比变化的周期数,默认为1

我们可以使用column_index参数和列的索引来代替使用后缀符号来指定要下载的数据集的列。例如,quandl.get('EURONEXT/ABN.4')与调用quandl.get('EURONEXT/ABN', column_index=4)是相同的。

绘制累积收益

为了了解我们的投资组合的表现,我们可以在一段时间内对其收益进行求和。pandascumsum方法返回 DataFrame 的累积和。

在下面的例子中,我们绘制了之前计算的 ABN 的daily_changes的累积和:

In [ ]:
    df_cumsum = daily_changes.cumsum()
    df_cumsum.plot();

这给我们以下输出图表:

绘制直方图

直方图告诉我们数据的分布情况。在这个例子中,我们对 ABN 的每日收益的分布情况感兴趣。我们在一个具有 50 个箱子大小的 DataFrame 上使用hist()方法:

In [ ]:
    daily_changes.hist(bins=50, figsize=(8, 4));

直方图输出如下:

pandas DataFrame 中有多个数据列时,hist()方法将自动在单独的图表上绘制每个直方图。

我们可以使用describe()方法来总结数据集分布的中心趋势、离散度和形状:

In [ ]:
    daily_changes.describe()
Out[ ]:
                 Last
    count  692.000000
    mean     0.000499
    std      0.016701
    min     -0.125527
    25%     -0.007992
    50%      0.000584
    75%      0.008777
    max      0.059123

从直方图中可以看出,收益倾向于围绕着 0.0 的均值分布,或者确切地说是0.000499。除了这个微小的右偏移,数据看起来相当对称和正态分布。标准偏差为0.016701。百分位数告诉我们,25%的点在-0.007992以下,50%在0.000584以下,75%在0.008777以下。

绘制波动率

分析收益分布的一种方法是测量其标准偏差。标准偏差是均值周围离散度的度量。过去收益的高标准偏差值表示股价波动的历史波动性较高。

pandasrolling()方法帮助我们在一段时间内可视化特定的时间序列操作。为了计算我们计算的 ABN 数据集的收益百分比的标准偏差,我们使用std()方法,它返回一个 DataFrame 或 Series 对象,可以用来绘制图表。下面的例子说明了这一点:

In [ ]:
    df_filled = df.asfreq('D', method='ffill')
    df_returns = df_filled.pct_change()
    df_std = df_returns.rolling(window=30, min_periods=30).std()
    df_std.plot();

这给我们以下波动率图:

我们原始的时间序列数据集不包括周末和公共假期,在使用rolling()方法时必须考虑这一点。df.asfreq()命令将时间序列数据重新索引为每日频率,在缺失的索引位置创建新的索引。method参数的值为ffill,指定我们在重新索引时将最后一个有效观察结果向前传播,以替代缺失值。

rolling()命令中,我们指定了window参数的值为 30,这是用于计算统计量的观察次数。换句话说,每个期间的标准偏差是用样本量 30 来计算的。由于前 30 行没有足够的样本量来计算标准偏差,我们可以通过将min_periods指定为30来排除这些行。

选择的值 30 接*月度收益的标准偏差。请注意,选择更宽的窗口期代表着被测量的数据量较少。

一个分位数-分位数图

Q-Q(分位数-分位数)图是一个概率分布图,其中两个分布的分位数相互绘制。如果分布是线性相关的,Q-Q 图中的点将位于一条直线上。与直方图相比,Q-Q 图帮助我们可视化偏离正态分布线的点,以及过度峰度的正负偏差。

scipy.statsprobplot()帮助我们计算并显示概率图的分位数。数据的最佳拟合线也被绘制出来。在下面的例子中,我们使用 ABN 股票数据集的最后价格,并计算每日百分比变化以绘制 Q-Q 图:

In [ ]:
    %matplotlib inline
    import quandl
    from scipy import stats
    from scipy.stats import probplot

    quandl.ApiConfig.api_key = QUANDL_API_KEY
    df = quandl.get('EURONEXT/ABN.4')
    daily_changes = df.pct_change(periods=1).dropna()

    figure = plt.figure(figsize=(8,4))
    ax = figure.add_subplot(111)
    stats.probplot(daily_changes['Last'], dist='norm', plot=ax)
    plt.show();

这给我们以下的 Q-Q 图:

当所有点恰好落在红线上时,数据的分布意味着与正态分布完全对应。我们的大部分数据在分位数-2 和+2 之间几乎完全相关。在这个范围之外,分布的相关性开始有所不同,在尾部有更多的负偏斜。

下载多个时间序列数据

我们将单个 Quandl 代码作为字符串对象传递给quandl.get()命令的第一个参数,以下载单个数据集。要下载多个数据集,我们可以传递一个 Quandl 代码的列表。

在下面的例子中,我们对三家银行股票的价格感兴趣——ABN Amro、Banco Santander 和 Kas Bank。2016 年至 2017 年的两年价格存储在df变量中,只下载了最后价格:

In [ ]:
    %matplotlib inline
    import quandl

    quandl.ApiConfig.api_key = QUANDL_API_KEY
    df = quandl.get(['EURONEXT/ABN.4', 
                     'EURONEXT/SANTA.4', 
                     'EURONEXT/KA.4'], 
                    collapse='monthly', 
                    start_date='2016-01-01', 
                    end_date='2017-12-31')
    df.plot();

生成了以下图表:

默认情况下,quandl.get()返回每日价格。我们还可以指定数据集下载的其他类型频率。在这个例子中,我们指定collapse='monthly'来下载月度价格。

显示相关矩阵

相关性是两个变量之间线性关系有多密切的统计关联。我们可以对两个时间序列数据集的收益进行相关性计算,得到一个介于-1 和 1 之间的值。相关值为 0 表示两个时间序列的收益之间没有关系。接* 1 的高相关值表示两个时间序列数据的收益倾向于一起变动。接*-1 的低值表示收益倾向于相互反向变动。

pandas中,corr()方法计算其提供的 DataFrame 中列之间的相关性,并将这些值输出为矩阵。在前面的例子中,我们在 DataFrame df中有三个可用的数据集。要输出收益的相关矩阵,运行以下命令:

In [ ]:
    df.pct_change().corr()
Out[ ]:
                           EURONEXT/ABN - Last ... EURONEXT/KA - Last
    EURONEXT/ABN - Last               1.000000 ...           0.096238
    EURONEXT/SANTA - Last             0.809824 ...           0.058095
    EURONEXT/KA - Last                0.096238 ...           1.000000

从相关矩阵输出中,我们可以推断出 ABN Amro 和 Banco Santander 股票在 2016 年至 2017 年的两年时间内高度相关,相关值为0.809824

默认情况下,corr()命令使用 Pearson 相关系数来计算成对相关性。这相当于调用corr(method='pearson')。其他有效值是kendallspearman,分别用于 Kendall Tau 和 Spearman 秩相关系数。

绘制相关性

也可以使用rolling()命令来可视化相关性。我们将使用 2016 年至 2017 年从 Quandl 获取的 ABN 和 SANTA 的每日最后价格。这两个数据集被下载到 DataFrame df中,并且其滚动相关性如下所示:

In [ ]:
    %matplotlib inline
    import quandl

    quandl.ApiConfig.api_key = QUANDL_API_KEY
    df = quandl.get(['EURONEXT/ABN.4', 'EURONEXT/SANTA.4'], 
                    start_date='2016-01-01', 
                    end_date='2017-12-31')

    df_filled = df.asfreq('D', method='ffill')
    daily_changes= df_filled.pct_change()
    abn_returns = daily_changes['EURONEXT/ABN - Last']
    santa_returns = daily_changes['EURONEXT/SANTA - Last']
    window = int(len(df_filled.index)/2)
    df_corrs = abn_returns\
        .rolling(window=window, min_periods=window)\
        .corr(other=santa_returns)
        .dropna()
    df_corrs.plot(figsize=(12,8));

以下是相关性图的截图:

df_filled变量包含一个 DataFrame,其索引以每日频率重新索引,并且准备好进行rolling()命令的缺失值前向填充。DataFrame daily_changes存储每日百分比收益,并且其列被提取为一个单独的 Series 对象,分别为abn_returnssanta_returnswindow变量存储了两年数据集中每年的*均天数。这个变量被提供给rolling()命令的参数。参数window表示我们将执行一年的滚动相关性。参数min_periods表示当只有完整样本大小用于计算时才会计算相关性。在这种情况下,在df_corrs数据集中的第一年没有相关性值。最后,plot()命令显示了 2017 年全年每日收益的一年滚动相关性图表。

简单移动*均线

用于时间序列数据分析的常见技术指标是移动*均线。mean()方法可用于计算rolling()命令中给定窗口的值的*均值。例如,5 天的简单移动*均线SMA)是最*五个交易日的价格的*均值,每天在一段时间内计算一次。同样,我们也可以计算一个更长期的 30 天简单移动*均线。这两个移动*均线可以一起使用以生成交叉信号。

在下面的示例中,我们下载 ABN 的每日收盘价,计算短期和长期 SMA,并在单个图表上可视化它们:

In [ ]:
    %matplotlib inline
    import quandl
    import pandas as pd

    quandl.ApiConfig.api_key = QUANDL_API_KEY
    df = quandl.get('EURONEXT/ABN.4')

    df_filled = df.asfreq('D', method='ffill')
    df_last = df['Last']

    series_short = df_last.rolling(window=5, min_periods=5).mean()
    series_long = df_last.rolling(window=30, min_periods=30).mean()

    df_sma = pd.DataFrame(columns=['short', 'long'])
    df_sma['short'] = series_short
    df_sma['long'] = series_long
    df_sma.plot(figsize=(12, 8));

这产生了以下的图表:

我们使用 5 天的*均值作为短期 SMA,30 天作为长期 SMA。min_periods参数用于排除前几行,这些行没有足够的样本大小来计算 SMA。df_sma变量是一个新创建的pandas DataFrame,用于存储 SMA 计算。然后我们绘制一个 12 英寸乘 8 英寸的图表。从图表中,我们可以看到许多点,短期 SMA 与长期 SMA 相交。图表分析师使用交叉点来识别趋势并生成信号。5 和 10 的窗口期纯粹是建议值;您可以调整这些值以找到适合自己的解释。

指数移动*均线

在计算移动*均线时的另一种方法是指数移动*均线EMA)。请记住,简单移动*均线在窗口期内为价格分配相等的权重。然而,在 EMA 中,最*的价格被分配比旧价格更高的权重。这种权重是以指数方式分配的。

pandas DataFrame 的ewm()方法提供了指数加权函数。span参数指定了衰减行为的窗口期。使用相同的 ABN 数据集绘制 EMA 如下:

In [ ]:
    %matplotlib inline
    import quandl
    import pandas as pd

    quandl.ApiConfig.api_key = QUANDL_API_KEY
    df = quandl.get('EURONEXT/ABN.4')

    df_filled = df.asfreq('D', method='ffill')
    df_last = df['Last']

    series_short = df_last.ewm(span=5).mean()
    series_long = df_last.ewm(span=30).mean()

    df_sma = pd.DataFrame(columns=['short', 'long'])
    df_sma['short'] = series_short
    df_sma['long'] = series_long
    df_sma.plot(figsize=(12, 8));

这产生了以下的图表:

SMA 和 EMA 的图表模式基本相同。由于 EMA 对最*的数据赋予的权重比旧数据更高,因此它们对价格变动的反应比 SMA 更快。

除了不同的窗口期,您还可以尝试使用 SMA 和 EMA 价格的组合来得出更多见解!

摘要

在本章中,我们使用 Python 3.7 建立了我们的工作环境,并使用虚拟环境包来管理单独的包安装。pip命令是一个方便的 Python 包管理器,可以轻松下载和安装 Python 模块,包括 Jupyter、Quandl 和pandas。Jupyter 是一个基于浏览器的交互式计算环境,用于执行 Python 代码和可视化数据。有了 Quandl 账户,我们可以轻松获取高质量的时间序列数据集。这些数据来源于各种数据发布者。数据集直接下载到一个pandas DataFrame 对象中,使我们能够执行金融分析,如绘制每日百分比收益、直方图、Q-Q 图、相关性、简单移动*均线和指数移动*均线。

第二部分:金融概念

本节涵盖了金融工程实践者讨论的金融概念和数学模型。

在本节中,我们将介绍以下章节:

  • 第二章,线性在金融中的重要性

  • 第三章,金融中的非线性

  • 第四章,定价期权的数值方法

  • 第五章,建模利率和衍生品

  • 第六章,时间序列数据的统计分析

第二章:金融中线性的重要性

非线性动力学在我们的世界中起着至关重要的作用。由于更容易研究和更容易建模的能力,线性模型经常在经济学中使用。在金融领域,线性模型被广泛用于帮助定价证券和执行最优投资组合分配,以及其他有用的事情。金融建模中线性的一个重要方面是它保证问题在全局最优解处终止。

为了进行预测和预测,回归分析在统计学领域被广泛使用,以估计变量之间的关系。由于 Python 具有丰富的数学库是其最大的优势之一,因此 Python 经常被用作科学脚本语言来帮助解决这些问题。像 SciPy 和 NumPy 这样的模块包含各种线性回归函数,供数据科学家使用。

在传统的投资组合管理中,资产配置遵循线性模式,投资者有个人的投资风格。我们可以将投资组合分配问题描述为一个线性方程组,包含等式或不等式。然后,这些线性系统可以以矩阵形式表示为Ax=B,其中A是已知的系数值,B是观察到的结果,x是我们想要找出的值的向量。往往,x包含最大化效用的最优证券权重。使用矩阵代数,我们可以使用直接或间接方法高效地解出x

在本章中,我们将涵盖以下主题:

  • 检查资本资产定价模型和证券市场线

  • 使用回归解决证券市场线问题

  • 检查 APT 模型并执行多元线性回归

  • 理解投资组合中的线性优化

  • 使用 Pulp 软件包执行线性优化

  • 理解线性规划的结果

  • 整数规划简介

  • 使用二进制条件实现线性整数规划模型

  • 使用矩阵线性代数解等式的线性方程组

  • 使用 LU、Cholesky 和 QR 分解直接解线性方程组

  • 使用 Jacobi 和 Gauss-Seidel 方法间接解线性方程组

资本资产定价模型和证券市场线

很多金融文献都专门讨论了资本资产定价模型CAPM)。在本节中,我们将探讨突出金融中线性的重要性的关键概念。

在著名的 CAPM 中,描述了证券的风险和回报率之间的关系如下:

对于证券i,其回报被定义为R[i],其 beta 被定义为β[i]。CAPM 将证券的回报定义为无风险利率R[f]和其 beta 与风险溢价的乘积之和。风险溢价可以被视为市场投资组合的超额回报,不包括无风险利率。以下是 CAPM 的可视化表示:

Beta 是股票系统风险的度量 - 无法分散的风险。实质上,它描述了股票回报与市场波动的敏感性。例如,beta 为零的股票无论市场走向如何都不会产生超额回报。它只能以无风险利率增长。beta 为 1 的股票表示该股票与市场完全同步。

beta 是通过将股票与市场回报的协方差除以市场回报的方差来数学推导的。

CAPM 模型衡量了投资组合篮子中每支股票的风险和回报之间的关系。通过概述这种关系的总和,我们可以得到在每个投资组合回报水*下产生最低投资组合风险的风险证券的组合或权重。希望获得特定回报的投资者将拥有一个最佳投资组合的组合,以提供可能的最低风险。最佳投资组合的组合位于一条称为有效边界的线上。

在有效边界上,存在一个切线点,表示最佳的可用最优投资组合,并以可能的最低风险换取最高的回报率。切线点处的最佳投资组合被称为市场投资组合

从市场投资组合到无风险利率之间存在一条直线。这条线被称为资本市场线CML)。CML 可以被认为是所有最优投资组合中最高夏普比率的夏普比率。夏普比率是一个风险调整后的绩效指标,定义为投资组合超额回报与标准差风险单位的比率。投资者特别感兴趣持有沿着 CML 线的资产组合。以下图表说明了有效边界、市场投资组合和 CML:

CAPM 研究中另一个有趣的概念是证券市场线SML)。SML 绘制了资产的预期回报与其贝塔值的关系。对于贝塔值为 1 的证券,其回报完全匹配市场回报。任何定价高于 SML 的证券被认为是被低估的,因为投资者期望在相同风险下获得更高的回报。相反,任何定价低于 SML 的证券被认为是被高估的。

假设我们有兴趣找到证券的贝塔值β[i]。我们可以对公司的股票回报R[i]与市场回报R[M]进行回归分析,同时加上一个截距α,形成R[i]=α+βR[M]的方程。

考虑以下一组在五个时间段内测得的股票回报和市场回报数据:

时间段 股票回报 市场回报
1 0.065 0.055
2 0.0265 -0.09
3 -0.0593 -0.041
4 -0.001 0.045
5 0.0346 0.022

使用 SciPy 的stats模块,我们将对 CAPM 模型进行最小二乘回归,并通过在 Python 中运行以下代码来得出α和β[i]的值:

In [ ]:
    """ 
    Linear regression with SciPy 
    """
    from scipy import stats

    stock_returns = [0.065, 0.0265, -0.0593, -0.001, 0.0346]
    mkt_returns = [0.055, -0.09, -0.041, 0.045, 0.022]
    beta, alpha, r_value, p_value, std_err = \
        stats.linregress(stock_returns, mkt_returns)

scipty.stats.linregress函数返回五个值:回归线的斜率、回归线的截距、相关系数、零斜率假设的假设检验的 p 值,以及估计的标准误差。我们有兴趣通过打印betaalpha的值来找到线的斜率和截距,分别为:

In [ ]:
    print(beta, alpha)
Out[ ]:
 0.5077431878770808 -0.008481900352462384 

股票的贝塔值为 0.5077,α几乎为零。

描述 SML 的方程可以写成:

术语E[R[M]]−R[f]是市场风险溢价,E[R[M]]是市场投资组合的预期回报。R[f]是无风险利率的回报,E[R[i]]是资产i的预期回报,β[i]是资产的贝塔值。

假设无风险利率为 5%,市场风险溢价为 8.5%。股票的预期回报率是多少?根据 CAPM,贝塔值为 0.5077 的股票将有 0.5077×8.5%的风险溢价,即 4.3%。无风险利率为 5%,因此股票的预期回报率为 9.3%。

如果在同一时间段内观察到证券的回报率高于预期的股票回报(例如,10.5%),则可以说该证券被低估,因为投资者可以期望在承担相同风险的情况下获得更高的回报。

相反,如果观察到证券的回报率低于 SML 所暗示的预期回报率(例如,7%),则可以说该证券被高估。投资者在承担相同风险的情况下获得了降低的回报。

套利定价理论模型

CAPM 存在一些局限性,比如使用均值-方差框架和事实上回报被一个风险因素(市场风险因素)捕获。在一个分散投资组合中,各种股票的非系统风险相互抵消,基本上被消除了。

套利定价理论APT)模型被提出来解决这些缺点,并提供了一种除了均值和方差之外确定资产价格的一般方法。

APT 模型假设证券回报是根据多因素模型生成的,这些模型由几个系统风险因素的线性组合组成。这些因素可能是通货膨胀率、GDP 增长率、实际利率或股利。

根据 APT 模型的均衡资产定价方程如下:

在这里,E[R[i]]是第i个证券的预期回报率,α[i]是如果所有因素都可以忽略时第i个股票的预期回报,β[i,j]是第i个资产对第j个因素的敏感性,F[j]是影响第i个证券回报的第j个因素的值。

由于我们的目标是找到α[i]β的所有值,我们将在 APT 模型上执行多元线性回归

因子模型的多元线性回归

许多 Python 包(如 SciPy)都带有几种回归函数的变体。特别是,statsmodels包是 SciPy 的补充,具有描述性统计信息和统计模型的估计。Statsmodels 的官方页面是www.statsmodels.org

如果您的 Python 环境中尚未安装 Statsmodels,请运行以下命令进行安装:

$ pip install -U statsmodels

如果您已经安装了一个现有的包,-U开关告诉pip将选定的包升级到最新可用版本。

在这个例子中,我们将使用statsmodels模块的ols函数执行普通最小二乘回归,并查看其摘要。

假设您已经实现了一个包含七个因素的 APT 模型,返回Y的值。考虑在九个时间段t[1]t[9]内收集的以下数据集。X[1]到X[7]是在每个时间段观察到的自变量。因此,回归问题的结构如下:

可以使用以下代码对XY的值进行简单的普通最小二乘回归:

In [ ]:
    """ 
    Least squares regression with statsmodels 
    """
    import numpy as np
    import statsmodels.api as sm

    # Generate some sample data
    num_periods = 9
    all_values = np.array([np.random.random(8) \
                           for i in range(num_periods)])

    # Filter the data
    y_values = all_values[:, 0] # First column values as Y
    x_values = all_values[:, 1:] # All other values as X
    x_values = sm.add_constant(x_values) # Include the intercept
    results = sm.OLS(y_values, x_values).fit() # Regress and fit the model

让我们查看回归的详细统计信息:

In [ ]:
    print(results.summary())

OLS 回归结果将输出一个相当长的统计信息表。然而,我们感兴趣的是一个特定部分,它给出了我们 APT 模型的系数:

===================================================================
                 coef    std err          t      P>|t|      [0.025      
-------------------------------------------------------------------
const          0.7229      0.330      2.191      0.273      -3.469
x1             0.4195      0.238      1.766      0.328      -2.599
x2             0.4930      0.176      2.807      0.218      -1.739
x3             0.1495      0.102      1.473      0.380      -1.140
x4            -0.1622      0.191     -0.847      0.552      -2.594
x5            -0.6123      0.172     -3.561      0.174      -2.797
x6            -0.2414      0.161     -1.499      0.375      -2.288
x7            -0.5079      0.200     -2.534      0.239      -3.054

coef列给出了我们回归的系数值,包括c常数和X[1]X[7]。同样,我们可以使用params属性来显示这些感兴趣的系数:

In [ ]:    
    print(results.params)
Out[ ]:
    [ 0.72286627  0.41950411  0.49300959  0.14951292 -0.16218313 -0.61228465 -0.24143028 -0.50786377]

两个函数调用以相同的顺序产生了 APT 模型的相同系数值。

线性优化

在 CAPM 和 APT 定价理论中,我们假设模型是线性的,并使用 Python 中的回归来解决预期的证券价格。

随着我们投资组合中证券数量的增加,也会引入一定的限制。投资组合经理在追求投资者规定的某些目标时会受到这些规则的约束。

线性优化有助于克服投资组合分配的问题。优化侧重于最小化或最大化目标函数的值。一些例子包括最大化回报和最小化波动性。这些目标通常受到某些规定的约束,例如不允许空头交易规则,或者对要投资的证券数量的限制。

不幸的是,在 Python 中,没有一个官方的包支持这个解决方案。但是,有第三方包可用,其中包含线性规划的单纯形算法的实现。为了演示目的,我们将使用 Pulp,一个开源线性规划建模器,来帮助我们解决这个特定的线性规划问题。

获取 Pulp

您可以从github.com/coin-or/pulp获取 Pulp。该项目页面包含了一份全面的文档列表,以帮助您开始优化过程。

您还可以使用pip包管理器获取 Pulp 包:

$ pip install pulp

线性规划的最大化示例

假设我们有兴趣投资两种证券XY。我们想要找出每三单位X和两单位Y的实际投资单位数,使得总投资单位数最大化,如果可能的话。然而,我们的投资策略有一定的限制:

  • 对于每 2 单位X和 1 单位Y的投资,总量不得超过 100

  • 对于每单位XY的投资,总量不得超过 80

  • 允许投资X的总量不得超过 40

  • 不允许对证券进行空头交易

最大化问题可以用数学表示如下:

受限于:

通过在xy图上绘制约束条件,可以看到一组可行解,由阴影区域给出:

该问题可以用pulp包在 Python 中进行翻译,如下所示:

In [ ]:
    """ 
    A simple linear optimization problem with 2 variables 
    """
    import pulp

    x = pulp.LpVariable('x', lowBound=0)
    y = pulp.LpVariable('y', lowBound=0)

    problem = pulp.LpProblem(
        'A simple maximization objective', 
        pulp.LpMaximize)
    problem += 3*x + 2*y, 'The objective function'
    problem += 2*x + y <= 100, '1st constraint'
    problem += x + y <= 80, '2nd constraint'
    problem += x <= 40, '3rd constraint'
    problem.solve()

LpVariable函数声明要解决的变量。LpProblem函数用问题的文本描述和优化类型初始化问题,本例中是最大化方法。+=操作允许添加任意数量的约束,以及文本描述。最后,调用.solve()方法开始执行线性优化。要显示优化器解决的值,使用.variables()方法循环遍历每个变量并打印出其varValue

当代码运行时生成以下输出:

In [ ]:
    print("Maximization Results:")
    for variable in problem.variables():
        print(variable.name, '=', variable.varValue)
Out[ ]:
    Maximization Results:
    x = 20.0
    y = 60.0

结果显示,在满足给定的一组约束条件的情况下,当x的值为 20,y的值为 60 时,可以获得最大值 180。

线性规划的结果

线性优化有三种结果,如下:

  • 线性规划的局部最优解是一个可行解,其目标函数值比其附*的所有其他可行解更接*。它可能是也可能不是全局最优解,即优于每个可行解的解。

  • 如果找不到解决方案,线性规划是不可行的。

  • 如果最优解是无界的或无限的,线性规划是无界的。

整数规划

在我们之前调查的简单优化问题中,线性规划的最大化示例,变量被允许是连续的或分数的。如果使用分数值或结果不现实怎么办?这个问题被称为线性整数规划问题,其中所有变量都受限于整数。整数变量的一个特殊情况是二进制变量,可以是 0 或 1。在给定一组选择时,二进制变量在模型决策时特别有用。

整数规划模型经常用于运筹学中来模拟现实工作问题。通常情况下,将非线性问题陈述为线性或甚至二进制的问题需要更多的艺术而不是科学。

整数规划的最小化示例

假设我们必须从三家经销商那里购买 150 份某种场外奇特证券。经销商X报价每份合同 500 美元加上 4000 美元的手续费,无论卖出的合同数量如何。经销商Y每份合同收费 450 美元,加上 2000 美元的交易费。经销商Z每份合同收费 450 美元,加上 6000 美元的费用。经销商X最多销售 100 份合同,经销商Y最多销售 90 份,经销商Z最多销售 70 份。从任何经销商那里交易的最低交易量是 30 份合同。我们应该如何最小化购买 150 份合同的成本?

使用pulp包,让我们设置所需的变量:

In [ ]:
    """ 
    An example of implementing an integer 
    programming model with binary conditions 
    """
    import pulp

    dealers = ['X', 'Y', 'Z']
    variable_costs = {'X': 500, 'Y': 350, 'Z': 450}
    fixed_costs = {'X': 4000, 'Y': 2000, 'Z': 6000}

    # Define PuLP variables to solve
    quantities = pulp.LpVariable.dicts('quantity', 
                                       dealers, 
                                       lowBound=0,
                                       cat=pulp.LpInteger)
    is_orders = pulp.LpVariable.dicts('orders', 
                                      dealers,
                                      cat=pulp.LpBinary)

dealers变量只是包含用于稍后引用列表和字典的字典标识符的字典。variable_costsfixed_costs变量是包含每个经销商收取的相应合同成本和费用的字典对象。Pulp 求解器解决了quantitiesis_orders的值,这些值由LpVariable函数定义。dicts()方法告诉 Pulp 将分配的变量视为字典对象,使用dealers变量进行引用。请注意,quantities变量具有一个下限(0),防止我们在任何证券中进入空头头寸。is_orders值被视为二进制对象,指示我们是否应该与任何经销商进行交易。

对建模这个整数规划问题的最佳方法是什么?乍一看,通过应用这个方程似乎相当简单:

其中以下内容为真:

该方程简单地陈述了我们希望最小化总成本,并使用二进制变量isOrder[i]来确定是否考虑从特定经销商购买的成本。

让我们在 Python 中实现这个模型:

In [ ]:
    """
    This is an example of implementing an integer programming model
    with binary variables the wrong way.
    """
    # Initialize the model with constraints
    model = pulp.LpProblem('A cost minimization problem',
                           pulp.LpMinimize)
    model += sum([(variable_costs[i] * \
                   quantities[i] + \
                   fixed_costs[i])*is_orders[i] \
                  for i in dealers]), 'Minimize portfolio cost'
    model += sum([quantities[i] for i in dealers]) == 150\
        , 'Total contracts required'
    model += 30 <= quantities['X'] <= 100\
        , 'Boundary of total volume of X'
    model += 30 <= quantities['Y'] <= 90\
        , 'Boundary of total volume of Y'
    model += 30 <= quantities['Z'] <= 70\
        , 'Boundary of total volume of Z'
    model.solve() # You will get an error running this code!

当我们运行求解器时会发生什么?看一下:

Out[ ]:
    TypeError: Non-constant expressions cannot be multiplied

事实证明,我们试图对两个未知变量quantitiesis_order进行乘法运算,无意中导致我们执行了非线性规划。这就是在执行整数规划时遇到的陷阱。

我们应该如何解决这个问题?我们可以考虑使用二进制变量,如下一节所示。

具有二进制条件的整数规划

制定最小化目标的另一种方法是将所有未知变量线性排列,使它们是可加的:

与先前的目标方程相比,我们将获得相同的固定成本值。但是,未知变量quantity[i]仍然在方程的第一项中。因此,需要将quantity[i]变量作为isOrder[i]的函数来求解,约束如下所述:

让我们在 Python 中应用这些公式:

In [ ]:
    """
    This is an example of implementing an 
    IP model with binary variables the correct way.
    """
    # Initialize the model with constraints
    model = pulp.LpProblem('A cost minimization problem',
                           pulp.LpMinimize)
    model += sum(
        [variable_costs[i]*quantities[i] + \
             fixed_costs[i]*is_orders[i] for i in dealers])\
        , 'Minimize portfolio cost'
    model += sum([quantities[i] for i in dealers]) == 150\
        ,  'Total contracts required'
    model += is_orders['X']*30 <= quantities['X'] <= \
        is_orders['X']*100, 'Boundary of total volume of X'
    model += is_orders['Y']*30 <= quantities['Y'] <= \
        is_orders['Y']*90, 'Boundary of total volume of Y'
    model += is_orders['Z']*30 <= quantities['Z'] <= \
        is_orders['Z']*70, 'Boundary of total volume of Z'
    model.solve()

当我们尝试运行求解器时会发生什么?让我们看看:

In [ ]:
    print('Minimization Results:')
    for variable in model.variables():
        print(variable, '=', variable.varValue)

    print('Total cost:',  pulp.value(model.objective))
Out[ ]:
    Minimization Results:
    orders_X = 0.0
    orders_Y = 1.0
    orders_Z = 1.0
    quantity_X = 0.0
    quantity_Y = 90.0
    quantity_Z = 60.0
    Total cost: 66500.0

输出告诉我们,从经销商Y购买 90 份合同和从经销商Z购买 60 份合同可以以最低成本 66,500 美元满足所有其他约束。

正如我们所看到的,需要在整数规划模型的设计中进行仔细规划,以便得出准确的解决方案,使其在决策中有用。

使用矩阵解决线性方程

在前一节中,我们看到了如何解决带有不等式约束的线性方程组。如果一组系统线性方程有确定性约束,我们可以将问题表示为矩阵,并应用矩阵代数。矩阵方法以紧凑的方式表示多个线性方程,同时使用现有的矩阵库函数。

假设我们想要建立一个包含三种证券abc的投资组合。投资组合的分配必须满足一定的约束条件:必须持有证券a的多头头寸 6 单位。对于每两单位的证券a,必须投资一单位的证券b和一单位的证券c,净头寸必须是多头四单位。对于每一单位的证券a,必须投资三单位的证券b和两单位的证券c,净头寸必须是多头五单位。

要找出要投资的证券数量,我们可以用数学方式表述问题,如下:

在所有系数可见的情况下,方程如下:

让我们把方程的系数表示成矩阵形式:

线性方程现在可以陈述如下:

要解出包含要投资的证券数量的x向量,需要取矩阵A的逆,方程写为:

使用 NumPy 数组,AB矩阵分配如下:

In [ ]:
    """ 
    Linear algebra with NumPy matrices 
    """
    import numpy as np

    A = np.array([[2, 1, 1],[1, 3, 2],[1, 0, 0]])
    B = np.array([4, 5, 6])

我们可以使用 NumPy 的linalg.solve函数来解决一组线性标量方程:

In [ ]:
    print(np.linalg.solve(A, B))
Out[ ]:
   [  6\.  15\. -23.]

投资组合需要持有 6 单位的证券a的多头头寸,15 单位的证券b,和 23 单位的证券c的空头头寸。

在投资组合管理中,我们可以使用矩阵方程系统来解决给定一组约束条件的证券的最佳权重分配。随着投资组合中证券数量的增加,A矩阵的大小增加,计算A的矩阵求逆变得计算成本高昂。因此,人们可以考虑使用 Cholesky 分解、LU 分解、QR 分解、雅各比方法或高斯-赛德尔方法等方法,将A矩阵分解为更简单的矩阵进行因式分解。

LU 分解

LU 分解,又称下三角-上三角分解,是解决方阵线性方程组的方法之一。顾名思义,LU 分解将矩阵A分解为两个矩阵的乘积:一个下三角矩阵L和一个上三角矩阵U。分解可以表示如下:

在这里,我们可以看到a=l[11]u[11]b=l[11]u[12],依此类推。下三角矩阵是一个矩阵,它在其下三角中包含值,其余的上三角中填充了零。上三角矩阵的情况相反。

LU 分解方法相对于 Cholesky 分解方法的明显优势在于它适用于任何方阵。后者只适用于对称和正定矩阵。

回想一下前面的例子,使用矩阵解线性方程,一个 3 x 3 的A矩阵。这次,我们将使用 SciPy 模块的linalg包来执行 LU 分解,使用以下代码:

In  [ ]:
    """ 
    LU decomposition with SciPy 
    """
    import numpy as np
    import scipy.linalg as linalg

    # Define A and B
    A = np.array([
        [2., 1., 1.],
        [1., 3., 2.],
        [1., 0., 0.]])
    B = np.array([4., 5., 6.])

    # Perform LU decomposition
    LU = linalg.lu_factor(A)
    x = linalg.lu_solve(LU, B)

要查看x的值,请执行以下命令:

In  [ ]:
   print(x)
Out[ ]:
   [  6\.  15\. -23.]

我们得到了abc的值分别为615-23

请注意,我们在这里使用了scipy.linalglu_factor()方法,它给出了A矩阵的置换 LU 分解作为LU变量。我们使用了lu_solve()方法,它接受置换的 LU 分解和B向量来解方程组。

我们可以使用scipy.linalglu()方法显示A矩阵的 LU 分解。lu()方法返回三个变量——置换矩阵P,下三角矩阵L和上三角矩阵U——分别返回:

In [ ]:
    import scipy

    P, L, U = scipy.linalg.lu(A)

    print('P=\n', P)
    print('L=\n', L)
    print('U=\n', U)

当我们打印出这些变量时,我们可以得出 LU 分解和A矩阵之间的关系如下:

LU 分解可以看作是在两个更简单的矩阵上执行的高斯消元的矩阵形式:上三角矩阵和下三角矩阵。

Cholesky 分解

Cholesky 分解是解线性方程组的另一种方法。它可以比 LU 分解快得多,并且使用的内存要少得多,因为它利用了对称矩阵的性质。然而,被分解的矩阵必须是 Hermitian(或者是实对称的并且是方阵)和正定的。这意味着A矩阵被分解为A=LLT*,其中*L*是一个下三角矩阵,对角线上有实数和正数,*LTL的共轭转置。

让我们考虑另一个线性方程组的例子,其中A矩阵既是 Hermitian 又是正定的。同样,方程的形式是Ax=B,其中AB取以下值:

让我们将这些矩阵表示为 NumPy 数组:

In  [ ]:
    """ 
    Cholesky decomposition with NumPy 
    """
    import numpy as np

    A = np.array([
        [10., -1., 2., 0.],
        [-1., 11., -1., 3.],
        [2., -1., 10., -1.],
        [0., 3., -1., 8.]])
    B = np.array([6., 25., -11., 15.])

    L = np.linalg.cholesky(A)

numpy.linalgcholesky()函数将计算A矩阵的下三角因子。让我们查看下三角矩阵:

In  [ ]:
    print(L)
Out[ ]:
   [[ 3.16227766  0\.          0\.          0\.        ]
    [-0.31622777  3.3015148   0\.          0\.        ]
    [ 0.63245553 -0.24231301  3.08889696  0\.        ]
    [ 0\.          0.9086738  -0.25245792  2.6665665 ]]

为了验证 Cholesky 分解的结果是否正确,我们可以使用 Cholesky 分解的定义,将L乘以它的共轭转置,这将使我们回到A矩阵的值:

In  [ ]:
    print(np.dot(L, L.T.conj())) # A=L.L*
Out [ ]:
    [[10\. -1\.  2\.  0.]
     [-1\. 11\. -1\.  3.]
     [ 2\. -1\. 10\. -1.]
     [ 0\.  3\. -1\.  8.]]

在解出x之前,我们需要将L^Tx解为y。让我们使用numpy.linalgsolve()方法:

In  [ ]:
    y = np.linalg.solve(L, B)  # L.L*.x=B; When L*.x=y, then L.y=B

要解出x,我们需要再次使用L的共轭转置和y来解:

In  [ ]:
    x = np.linalg.solve(L.T.conj(), y)  # x=L*'.y

让我们打印出x的结果:

In  [ ]:
    print(x)
Out[ ]:
   [ 1\.  2\. -1\.  1.]

输出给出了我们的abcdx的值。

为了证明 Cholesky 分解给出了正确的值,我们可以通过将A矩阵乘以x的转置来验证答案,从而得到B的值:

In [ ] :
    print(np.mat(A) * np.mat(x).T)  # B=Ax
Out[ ]:
    [[  6.]
     [ 25.]
     [-11.]
     [ 15.]]

这表明通过 Cholesky 分解得到的x的值将与B给出的相同。

QR 分解

QR 分解,也称为QR 分解,是使用矩阵解线性方程的另一种方法,非常类似于 LU 分解。要解的方程是Ax=B的形式,其中矩阵A=QR。然而,在这种情况下,A是正交矩阵Q和上三角矩阵R的乘积。QR 算法通常用于解线性最小二乘问题。

正交矩阵具有以下特性:

  • 它是一个方阵。

  • 将正交矩阵乘以其转置返回单位矩阵:

  • 正交矩阵的逆等于其转置:

单位矩阵也是一个方阵,其主对角线包含 1,其他位置包含 0。

现在问题Ax=B可以重新表述如下:

使用 LU 分解示例中的相同变量,我们将使用scipy.linalgqr()方法来计算我们的QR的值,并让y变量代表我们的BQ^T的值,代码如下:

In  [ ]:
    """ 
    QR decomposition with scipy 
    """
    import numpy as np
    import scipy.linalg as linalg

    A = np.array([
        [2., 1., 1.],
        [1., 3., 2.],
        [1., 0., 0]])
    B = np.array([4., 5., 6.])

    Q, R = scipy.linalg.qr(A)  # QR decomposition
    y = np.dot(Q.T, B)  # Let y=Q'.B
    x = scipy.linalg.solve(R, y)  # Solve Rx=y

注意Q.T只是Q的转置,也就是Q的逆:

In [ ]:
    print(x)
Out[ ]:
    [  6\.  15\. -23.]

我们得到了与 LU 分解示例中相同的答案。

使用其他矩阵代数方法求解

到目前为止,我们已经看过了使用矩阵求逆、LU 分解、Cholesky 分解和 QR 分解来解线性方程组。如果A矩阵中的财务数据规模很大,可以通过多种方案进行分解,以便使用矩阵代数更快地收敛。量化投资组合分析师应该熟悉这些方法。

在某些情况下,我们寻找的解可能不会收敛。因此,您可能需要考虑使用迭代方法。解决线性方程组的常见迭代方法包括雅各比方法、高斯-赛德尔方法和 SOR 方法。我们将简要介绍实现雅各比和高斯-赛德尔方法的示例。

雅各比方法

雅各比方法沿着其对角线元素迭代地解决线性方程组。当解收敛时,迭代过程终止。同样,要解决的方程式是Ax=B的形式,其中矩阵A可以分解为两个相同大小的矩阵,使得A=D+R。矩阵 D 仅包含 A 的对角分量,另一个矩阵 R 包含其余分量。让我们看一个 4 x 4 的A矩阵的例子:

然后迭代地获得解如下:

与高斯-赛德尔方法相反,在雅各比方法中,需要在每次迭代中使用x[n]的值来计算x[n+1],并且不能被覆盖。这将占用两倍的存储空间。然而,每个元素的计算可以并行进行,这对于更快的计算是有用的。

如果A矩阵是严格不可约对角占优的,这种方法保证收敛。严格不可约对角占优矩阵是指每一行的绝对对角元素大于其他项的绝对值之和。

在某些情况下,即使不满足这些条件,雅各比方法也可以收敛。Python 代码如下:

In [ ]:
    """
    Solve Ax=B with the Jacobi method 
    """
    import numpy as np

    def jacobi(A, B, n, tol=1e-10):
        # Initializes x with zeroes with same shape and type as B
        x = np.zeros_like(B)

        for iter_count in range(n):
            x_new = np.zeros_like(x)
            for i in range(A.shape[0]):
                s1 = np.dot(A[i, :i], x[:i])
                s2 = np.dot(A[i, i + 1:], x[i + 1:])
                x_new[i] = (B[i] - s1 - s2) / A[i, i]

            if np.allclose(x, x_new, tol):
                break

            x = x_new

        return x

考虑 Cholesky 分解示例中的相同矩阵值。我们将在我们的jacobi函数中使用 25 次迭代来找到x的值:

In [ ] :
    A = np.array([
        [10., -1., 2., 0.], 
        [-1., 11., -1., 3.], 
        [2., -1., 10., -1.], 
        [0.0, 3., -1., 8.]])
    B = np.array([6., 25., -11., 15.])
    n = 25

初始化值后,我们现在可以调用函数并求解x

In [ ]:
    x = jacobi(A, B, n)
    print('x', '=', x)
Out[ ]:
    x = [ 1\.  2\. -1\.  1.]

我们求解了x的值,这与 Cholesky 分解的答案类似。

高斯-赛德尔方法

高斯-赛德尔方法与雅各比方法非常相似。这是使用迭代过程以Ax=B形式的方程解决线性方程组的另一种方法。在这里,A矩阵被分解为A=L+U,其中A矩阵是下三角矩阵L和上三角矩阵U的和。让我们看一个 4 x 4 A矩阵的例子:

然后通过迭代获得解决方案,如下所示:

使用下三角矩阵L,其中零填充上三角,可以在每次迭代中覆盖x[n]的元素,以计算x[n+1]。这样做的好处是使用雅各比方法时所需的存储空间减少了一半。

高斯-赛德尔方法的收敛速度主要取决于A矩阵的性质,特别是如果需要严格对角占优或对称正定的A矩阵。即使不满足这些条件,高斯-赛德尔方法也可能收敛。

高斯-赛德尔方法的 Python 实现如下:

In  [ ]:
    """ 
    Solve Ax=B with the Gauss-Seidel method 
    """
    import numpy as np

    def gauss(A, B, n, tol=1e-10):
        L = np.tril(A)  # returns the lower triangular matrix of A
        U = A-L  # decompose A = L + U
        L_inv = np.linalg.inv(L)
        x = np.zeros_like(B)

        for i in range(n):
            Ux = np.dot(U, x)
            x_new = np.dot(L_inv, B - Ux)

            if np.allclose(x, x_new, tol):
                break

            x = x_new

        return x

在这里,NumPy 的tril()方法返回下三角A矩阵,从中我们可以找到下三角U矩阵。将剩余的值迭代地插入x,将会得到以下解,其中由tol定义了一些容差。

让我们考虑雅各比方法和乔列斯基分解示例中的相同矩阵值。我们将在我们的gauss()函数中使用最多 100 次迭代来找到x的值,如下所示:

In  [ ]:
    A = np.array([
        [10., -1., 2., 0.], 
        [-1., 11., -1., 3.], 
        [2., -1., 10., -1.], 
        [0.0, 3., -1., 8.]])
    B = np.array([6., 25., -11., 15.])
    n = 100
    x = gauss(A, B, n)

让我们看看我们的x值是否与雅各比方法和乔列斯基分解中的值匹配:

In [ ]:
    print('x', '=', x)
Out[ ]:   
    x = [ 1\.  2\. -1\.  1.]

我们解出了x的值,这些值与雅各比方法和乔列斯基分解的答案类似。

总结

在本章中,我们简要介绍了 CAPM 模型和 APT 模型在金融中的应用。在 CAPM 模型中,我们访问了 CML 的有效边界,以确定最佳投资组合和市场投资组合。然后,我们使用回归解决了 SML,这有助于我们确定资产是被低估还是被高估。在 APT 模型中,我们探讨了除使用均值方差框架之外,各种因素如何影响证券回报。我们进行了多元线性回归,以帮助我们确定导致证券价格估值的因素的系数。

在投资组合配置中,投资组合经理通常被投资者授权实现一组目标,同时遵循某些约束。我们可以使用线性规划来建模这个问题。使用 Pulp Python 包,我们可以定义一个最小化或最大化的目标函数,并为我们的问题添加不等式约束以解决未知变量。线性优化的三种结果可以是无界解、仅有一个解或根本没有解。

线性优化的另一种形式是整数规划,其中所有变量都受限于整数,而不是分数值。整数变量的特殊情况是二进制变量,它可以是 0 或 1,特别适用于在给定一组选择时建模决策。我们研究了一个包含二进制条件的简单整数规划模型,并看到了遇到陷阱有多容易。需要仔细规划整数规划模型的设计,以便它们在决策中有用。

投资组合分配问题也可以表示为一个具有相等性的线性方程组,可以使用矩阵形式的Ax=B来求解。为了找到x的值,我们使用了各种类型的A矩阵分解来求解A^(−1)B。矩阵分解方法有两种类型,直接和间接方法。直接方法在固定次数的迭代中执行矩阵代数运算,包括 LU 分解、Cholesky 分解和 QR 分解方法。间接或迭代方法通过迭代计算x的下一个值,直到达到一定的精度容差。这种方法特别适用于计算大型矩阵,但也面临着解不收敛的风险。我们使用的间接方法有雅各比方法和高斯-赛德尔方法。

在下一章中,我们将讨论金融中的非线性建模。

第三章:金融中的非线性

*年来,对经济和金融理论中的非线性现象的研究越来越受到关注。由于非线性串行依赖在许多金融时间序列的回报中起着重要作用,这使得证券估值和定价非常重要,从而导致对金融产品的非线性建模研究增加。

金融业的从业者使用非线性模型来预测波动性、价格衍生品,并计算风险价值(VAR)。与线性模型不同,线性代数用于寻找解决方案,非线性模型不一定推断出全局最优解。通常采用数值根查找方法来收敛到最*的局部最优解,即根。

在本章中,我们将讨论以下主题:

  • 非线性建模

  • 非线性模型的例子

  • 根查找算法

  • 根查找中的 SciPy 实现

非线性建模

尽管线性关系旨在以最简单的方式解释观察到的现象,但许多复杂的物理现象无法用这样的模型来解释。非线性关系定义如下:

尽管非线性关系可能很复杂,但为了充分理解和建模它们,我们将看一下在金融和时间序列模型的背景下应用的例子。

非线性模型的例子

许多非线性模型已被提出用于学术和应用研究,以解释线性模型无法解释的经济和金融数据的某些方面。金融领域的非线性文献实在太广泛和深刻,无法在本书中得到充分解释。在本节中,我们将简要讨论一些非线性模型的例子,这些模型可能在实际应用中遇到:隐含波动率模型、马尔可夫转换模型、阈值模型和*滑转换模型。

隐含波动率模型

也许最广泛研究的期权定价模型之一是 Black-Scholes-Merton 模型,或简称 Black-Scholes 模型。看涨期权是在特定价格和时间购买基础证券的权利,而不是义务。看跌期权是在特定价格和时间出售基础证券的权利,而不是义务。Black-Scholes 模型有助于确定期权的公*价格,假设基础证券的回报服从正态分布(N(.))或资产价格呈对数正态分布。

该公式假定以下变量:行权价(K)、到期时间(T)、无风险利率(r)、基础收益的波动率(σ)、基础资产的当前价格(S)和其收益(q)。看涨期权的数学公式,C(S,t),表示如下:

在这里:

通过市场力量,期权的价格可能偏离从 Black-Scholes 公式推导出的价格。特别是,实现波动性(即从历史市场价格观察到的基础收益的波动性)可能与由 Black-Scholes 模型隐含的波动性值不同,这由σ表示。

回想一下在第二章中讨论的资本资产定价模型CAPM),即金融中的线性重要性。一般来说,具有更高回报的证券表现出更高的风险,这表明回报的波动性或标准差。

由于波动性在证券定价中非常重要,因此已经提出了许多波动性模型进行研究。其中一种模型是期权价格的隐含波动率建模。

假设我们绘制了给定特定到期日的 Black-Scholes 公式给出的股票期权的隐含波动率值。一般来说,我们得到一个常被称为波动率微笑的曲线,因为它的形状:

隐含波动率通常在深度实值和虚值期权上最高,受到大量投机驱动,而在*值期权上最低。

期权的特征解释如下:

  • 实值期权ITM):当认购期权的行权价低于标的资产的市场价格时,被视为实值期权。当认沽期权的行权价高于标的资产的市场价格时,被视为实值期权。实值期权在行使时具有内在价值。

  • 虚值期权OTM):当认购期权的行权价高于标的资产的市场价格时,被视为虚值期权。当认沽期权的行权价低于标的资产的市场价格时,被视为虚值期权。虚值期权在行使时没有内在价值,但可能仍具有时间价值。

  • *值期权ATM):当期权的行权价与标的资产的市场价格相同时,被视为*值期权。*值期权在行使时没有内在价值,但可能仍具有时间价值。

从前述波动率曲线中,隐含波动率建模的一个目标是寻找可能的最低隐含波动率值,或者换句话说,找到根。一旦找到,就可以推断出特定到期日的*值期权的理论价格,并与市场价格进行比较,以寻找潜在的机会,比如研究接**值期权或远虚值期权。然而,由于曲线是非线性的,线性代数无法充分解决根的问题。我们将在下一节根查找算法中看一些根查找方法。

马尔可夫转换模型

为了对经济和金融时间序列中的非线性行为进行建模,可以使用马尔可夫转换模型来描述不同世界或状态下的时间序列。这些状态的例子可能是一个波动状态,就像在 2008 年全球经济衰退中看到的,或者是稳步复苏经济的增长状态。能够在这些结构之间转换的能力让模型能够捕捉复杂的动态模式。

股票价格的马尔可夫性质意味着只有当前价值对于预测未来是相关的。过去的股价波动对于当前的出现方式是无关紧要的。

让我们以m=2个状态的马尔可夫转换模型为例:

ϵ[t]是一个独立同分布i.i.d)白噪声。白噪声是一个均值为零的正态分布随机过程。同样的模型可以用虚拟变量表示:

马尔可夫转换模型的应用包括代表实际 GDP 增长率和通货膨胀率动态。这些模型反过来推动利率衍生品的估值模型。从前一状态i转换到当前状态j的概率可以写成如下形式:

阈自回归模型

一个流行的非线性时间序列模型类别是阈自回归TAR)模型,它看起来与马尔可夫转换模型非常相似。使用回归方法,简单的 AR 模型可以说是最流行的模型来解释非线性行为。阈值模型中的状态是由其自身时间序列的过去值d相对于阈值c确定的。

以下是一个自激励 TAR(SETAR)模型的例子。SETAR 模型是自激励的,因为在不同制度之间的切换取决于其自身时间序列的过去值:

使用虚拟变量,SETAR 模型也可以表示如下:

TAR 模型的使用可能会导致状态之间出现急剧的转变,这由阈值变量c控制。

*滑转换模型

阈值模型中的突然制度变化似乎与现实世界的动态不符。通过引入一个*滑变化的连续函数,可以克服这个问题,从一个制度*滑地过渡到另一个制度。SETAR 模型成为一个逻辑*滑转换阈值自回归LSTAR)模型,其中使用逻辑函数G(y[t−1];γ,c)

SETAR 模型现在变成了 LSTAR 模型,如下方程所示:

参数γ控制从一个制度到另一个制度的*滑过渡。对于γ的大值,过渡是最快的,因为y[t−d]接*阈值变量c。当γ=0 时,LSTAR 模型等同于简单的AR(1)单制度模型。

根查找算法

在前面的部分,我们讨论了一些用于研究经济和金融时间序列的非线性模型。从连续时间给定的模型数据,因此意图是搜索可能推断有价值信息的极值点。使用数值方法,如根查找算法,可以帮助我们找到连续函数f的根,使得f(x)=0,这可能是函数的极大值或极小值。一般来说,一个方程可能包含多个根,也可能根本没有根。

在非线性模型上使用根查找方法的一个例子是前面讨论的 Black-Scholes 隐含波动率建模,在隐含波动率模型部分。期权交易员有兴趣根据 Black-Scholes 模型推导隐含价格,并将其与市场价格进行比较。在下一章,期权定价的数值方法,我们将看到如何将根查找方法与数值期权定价程序结合起来,以根据特定期权的市场价格创建一个隐含波动率模型。

根查找方法使用迭代程序,需要一个起始点或根的估计。根的估计可能会收敛于一个解,收敛于一个不需要的根,或者根本找不到解决方案。因此,找到根的良好*似是至关重要的。

并非每个非线性函数都可以使用根查找方法解决。下图显示了一个连续函数的例子,,在这种情况下,根查找方法可能无法找到解。在x=0x=2处,y值在-20 到 20 的范围内存在不连续性:

并没有固定的规则来定义良好的*似。建议在开始根查找迭代程序之前,先确定下界和上界的搜索范围。我们当然不希望在错误的方向上无休止地搜索我们的根。

增量搜索

解决非线性函数的一种粗糙方法是进行增量搜索。使用任意起始点a,我们可以获得每个dx增量的f(a)值。我们假设f(a+dx),f(a+2dx),f(a+3dx)…的值与它们的符号指示的方向相同。一旦符号改变,解决方案被认为已找到。否则,当迭代搜索越过边界点b时,迭代搜索终止。

迭代的根查找方法的图示示例如下图所示:

可以从以下 Python 代码中看到一个例子:

In [ ]:
    """ 
    An incremental search algorithm 
    """
    import numpy as np

    def incremental_search(func, a, b, dx):
        """
        :param func: The function to solve
        :param a: The left boundary x-axis value
        :param b: The right boundary x-axis value
        :param dx: The incremental value in searching
        :return: 
            The x-axis value of the root,
            number of iterations used
        """
        fa = func(a)
        c = a + dx
        fc = func(c)
        n = 1
        while np.sign(fa) == np.sign(fc):
            if a >= b:
                return a - dx, n

            a = c
            fa = fc
            c = a + dx
            fc = func(c)
            n += 1

        if fa == 0:
            return a, n
        elif fc == 0:
            return c, n
        else:
            return (a + c)/2., n

在每次迭代过程中,a将被c替换,并且在下一次比较之前,c将被dx递增。如果找到了根,那么它可能位于ac之间,两者都包括在内。如果解决方案不在任何一个点上,我们将简单地返回这两个点的*均值作为最佳估计。变量n跟踪经历了寻找根的过程的迭代次数。

我们将使用具有解析解的方程来演示和测量我们的根查找器,其中x被限制在-5 和 5 之间。给出了一个小的dx值 0.001,它也充当精度工具。较小的dx值产生更好的精度,但也需要更多的搜索迭代:

In [ ]:
    # The keyword 'lambda' creates an anonymous function
    # with input argument x
    y = lambda x: x**3 + 2.*x**2 - 5.
    root, iterations = incremental_search (y, -5., 5., 0.001)
    print("Root is:", root)
    print("Iterations:", iterations)
Out[ ]:
    Root is: 1.2414999999999783
    Iterations: 6242

增量搜索根查找方法是根查找算法基本行为的基本演示。当由dx定义时,精度最佳,并且在最坏的情况下需要极长的计算时间。要求的精度越高,解决方案收敛所需的时间就越长。出于实际原因,这种方法是所有根查找算法中最不受欢迎的,我们将研究替代方法来找到我们方程的根,以获得更好的性能。

二分法

二分法被认为是最简单的一维根查找算法。一般的兴趣是找到连续函数f的值x,使得f(x)=0

假设我们知道区间ab的两个点,其中a<b,并且连续函数上有f(a)<0f(b)>0,则取该区间的中点作为c,其中;然后二分法计算该值f(c)

让我们用以下图示来说明沿着非线性函数设置点的情况:

由于f(a)的值为负,f(b)的值为正,二分法假设根x位于ab之间,并给出f(x)=0

如果f(c)=0或者非常接*零,通过预定的误差容限值,就宣布找到了一个根。如果f(c)<0,我们可以得出结论,根存在于cb的区间,或者ac的区间。

在下一次评估中,c将相应地替换为ab。随着新区间缩短,二分法重复相同的评估,以确定c的下一个值。这个过程继续,缩小ab的宽度,直到根被认为找到。

使用二分法的最大优势是保证在给定预定的误差容限水*和允许的最大迭代次数下收敛到根的*似值。应该注意的是,二分法不需要未知函数的导数知识。在某些连续函数中,导数可能是复杂的,甚至不可能计算。这使得二分法对于处理不*滑函数非常有价值。

由于二分法不需要来自连续函数的导数信息,其主要缺点是在迭代评估中花费更多的计算时间。此外,由于二分法的搜索边界位于ab区间内,因此需要一个良好的*似值来确保根落在此范围内。否则,可能会得到不正确的解,甚至根本没有解。使用较大的ab值可能会消耗更多的计算时间。

二分法被认为是稳定的,无需使用初始猜测值即可收敛。通常,它与其他方法结合使用,例如更快的牛顿法,以便快速收敛并获得精度。

二分法的 Python 代码如下。将其保存为bisection.py

In [ ]:
    """ 
    The bisection method 
    """
    def bisection(func, a, b, tol=0.1, maxiter=10):
        """
        :param func: The function to solve
        :param a: The x-axis value where f(a)<0
        :param b: The x-axis value where f(b)>0
        :param tol: The precision of the solution
        :param maxiter: Maximum number of iterations
        :return: 
            The x-axis value of the root,
            number of iterations used
        """
        c = (a+b)*0.5  # Declare c as the midpoint ab
        n = 1  # Start with 1 iteration
        while n <= maxiter:
            c = (a+b)*0.5
            if func(c) == 0 or abs(a-b)*0.5 < tol:
                # Root is found or is very close
                return c, n

            n += 1
            if func(c) < 0:
                a = c
            else:
                b = c

        return c, n
In [ ]:
    y = lambda x: x**3 + 2.*x**2 - 5
    root, iterations = bisection(y, -5, 5, 0.00001, 100)
    print("Root is:", root)
    print("Iterations:", iterations)
Out[ ]:
    Root is: 1.241903305053711
    Iterations: 20

再次,我们将匿名的lambda函数绑定到y变量,带有输入参数x,并尝试解决方程,与之前一样,在-5 到 5 之间的区间内,精度为 0.00001,最大迭代次数为 100。

正如我们所看到的,与增量搜索方法相比,二分法给出了更好的精度,迭代次数更少。

牛顿法

牛顿法,也称为牛顿-拉弗森法,使用迭代过程来求解根,利用函数的导数信息。导数被视为一个要解决的线性问题。函数的一阶导数f′代表切线。给定x的下一个值的*似值,记为x[1],如下所示:

在这里,切线与x轴相交于x[1],产生y=0。这也表示关于x[1]的一阶泰勒展开,使得新点解决以下方程:

重复这个过程,x取值为x[1],直到达到最大迭代次数,或者x[1]x之间的绝对差在可接受的精度水*内。

需要一个初始猜测值来计算f(x)f'(x)的值。收敛速度是二次的,被认为是以极高的精度获得解决方案的非常快速的速度。

牛顿法的缺点是它不能保证全局收敛到解决方案。当函数包含多个根或算法到达局部极值且无法计算下一步时,就会出现这种情况。由于该方法需要知道其输入函数的导数,因此需要输入函数可微。然而,在某些情况下,函数的导数是无法知道的,或者在数学上很容易计算。

牛顿法的图形表示如下截图所示。x[0]是初始x值。评估f(x[0])的导数,这是一个切线,穿过x轴在x[1]处。迭代重复,评估点x[1]x[2]x[3]等处的导数:

Python 中牛顿法的实现如下:

In  [ ]:
    """ 
    The Newton-Raphson method 
    """
    def newton(func, df, x, tol=0.001, maxiter=100):
        """
        :param func: The function to solve
        :param df: The derivative function of f
        :param x: Initial guess value of x
        :param tol: The precision of the solution
        :param maxiter: Maximum number of iterations
        :return: 
            The x-axis value of the root,
            number of iterations used
        """
        n = 1
        while n <= maxiter:
            x1 = x - func(x)/df(x)
            if abs(x1 - x) < tol: # Root is very close
                return x1, n

            x = x1
            n += 1

        return None, n

我们将使用二分法示例中使用的相同函数,并查看牛顿法的结果:

In  [ ]:
    y = lambda x: x**3 + 2*x**2 - 5
    dy = lambda x: 3*x**2 + 4*x
    root, iterations = newton(y, dy, 5.0, 0.00001, 100)
    print("Root is:", root)
    print("Iterations:", iterations)
Out [ ]:
    Root is: 1.241896563034502
    Iterations: 7

注意除零异常!在 Python 2 中,使用值如 5.0,而不是 5,让 Python 将变量识别为浮点数,避免了将变量视为整数进行计算的问题,并给出了更好的精度。

使用牛顿法,我们获得了一个非常接*的解,迭代次数比二分法少。

割线法

使用割线法来寻找根。割线是一条直线,它与曲线的两个点相交。在割线法中,画一条直线连接连续函数上的两个点,使其延伸并与x轴相交。这种方法可以被视为拟牛顿法。通过连续画出这样的割线,可以逼*函数的根。

割线法在以下截图中以图形方式表示。需要找到两个x轴值的初始猜测,ab,以找到f(a)f(b)。从f(b)f(a)画一条割线y,并在x轴上的点c处相交,使得:

因此,c的解如下:

在下一次迭代中,ab将分别取bc的值。该方法重复自身,为x轴值abbccd等画出割线。当达到最大迭代次数或bc之间的差异达到预先指定的容限水*时,解决方案终止,如下图所示:

割线法的收敛速度被认为是超线性的。其割线法比二分法收敛速度快得多,但比牛顿法慢。在牛顿法中,浮点运算的数量在每次迭代中占用的时间是割线法的两倍,因为需要计算函数和导数。由于割线法只需要在每一步计算其函数,因此在绝对时间上可以认为更快。

割线法的初始猜测值必须接*根,否则无法保证收敛到解。

割线法的 Python 代码如下所示:

In [ ]:
    """ 
    The secant root-finding method 
    """
    def secant(func, a, b, tol=0.001, maxiter=100):
        """
        :param func: The function to solve
        :param a: Initial x-axis guess value
        :param b: Initial x-axis guess value, where b>a
        :param tol: The precision of the solution
        :param maxiter: Maximum number of iterations
        :return: 
            The x-axis value of the root,
            number of iterations used
        """
        n = 1
        while n <= maxiter:
            c = b - func(b)*((b-a)/(func(b)-func(a)))
            if abs(c-b) < tol:
                return c, n

            a = b
            b = c
            n += 1

        return None, n

再次重用相同的非线性函数,并返回割线法的结果:

In [ ]:
    y = lambda x: x**3 + 2.*x**2 - 5.
    root, iterations = secant(y, -5.0, 5.0, 0.00001, 100)
    print("Root is:", root)
    print("Iterations:", iterations)
Out[ ]:   
    Root is: 1.2418965622558549
    Iterations: 14

尽管所有先前的根查找方法都给出了非常接*的解,割线法与二分法相比,迭代次数更少,但比牛顿法多。

组合根查找方法

完全可以使用前面提到的根查找方法的组合来编写自己的根查找算法。例如,可以使用以下实现:

  1. 使用更快的割线法将问题收敛到预先指定的误差容限值或最大迭代次数

  2. 一旦达到预先指定的容限水*,就切换到使用二分法,通过每次迭代将搜索区间减半,直到找到根

布伦特法Wijngaarden-Dekker-Brent方法结合了二分根查找方法、割线法和反向二次插值。该算法尝试在可能的情况下使用割线法或反向二次插值,并在必要时使用二分法。布伦特法也可以在 SciPy 的scipy.optimize.brentq函数中找到。

根查找中的 SciPy 实现

在开始编写根查找算法来解决非线性甚至线性问题之前,先看看scipy.optimize方法的文档。SciPy 包含一系列科学计算函数,作为 Python 的扩展。这些开源算法很可能适合您的应用程序。

查找标量函数的根

scipy.optimize模块中可以找到一些根查找函数,包括bisectnewtonbrentqridder。让我们使用 SciPy 的实现来设置我们在增量搜索部分中讨论过的示例:

In [ ]:
    """
    Documentation at
    http://docs.scipy.org/doc/scipy/reference/optimize.html
    """
    import scipy.optimize as optimize

    y = lambda x: x**3 + 2.*x**2 - 5.
    dy = lambda x: 3.*x**2 + 4.*x

    # Call method: bisect(f, a, b[, args, xtol, rtol, maxiter, ...])
    print("Bisection method:", optimize.bisect(y, -5., 5., xtol=0.00001))

    # Call method: newton(func, x0[, fprime, args, tol, ...])
    print("Newton's method:", optimize.newton(y, 5., fprime=dy))
    # When fprime=None, then the secant method is used.
    print("Secant method:", optimize.newton(y, 5.))

    # Call method: brentq(f, a, b[, args, xtol, rtol, maxiter, ...])
    print("Brent's method:", optimize.brentq(y, -5., 5.))

当运行上述代码时,将生成以下输出:

Out[ ]:
    Bisection method: 1.241903305053711
    Newton's method: 1.2418965630344798
    Secant method: 1.2418965630344803
    Brent's method: 1.241896563034559

我们可以看到,SciPy 的实现给出了与我们推导的答案非常相似的答案。

值得注意的是,SciPy 对每个实现都有一组明确定义的条件。例如,在文档中二分法例程的函数调用如下所示:

scipy.optimize.bisect(f, a, b, args=(), xtol=1e-12, rtol=4.4408920985006262e-16, maxiter=100, full_output=False, disp=True)

该函数将严格评估函数f以返回函数的零点。f(a)f(b)不能具有相同的符号。在某些情况下,很难满足这些约束条件。例如,在解决非线性隐含波动率模型时,波动率值不能为负。在活跃市场中,如果不修改基础实现,几乎不可能找到波动率函数的根或零点。在这种情况下,实现我们自己的根查找方法也许可以让我们更加掌控我们的应用程序应该如何执行。

一般非线性求解器

scipy.optimize模块还包含了我们可以利用的多维通用求解器。rootfsolve函数是一些具有以下函数属性的例子:

  • root(fun, x0[, args, method, jac, tol, ...]):这找到向量函数的根。

  • fsolve(func, x0[, args, fprime, ...]):这找到函数的根。

输出以字典对象的形式返回。使用我们的示例作为这些函数的输入,我们将得到以下输出:

In [ ]:
    import scipy.optimize as optimize

    y = lambda x: x**3 + 2.*x**2 - 5.
    dy = lambda x: 3.*x**2 + 4.*x

    print(optimize.fsolve(y, 5., fprime=dy))
Out[ ]:    
    [1.24189656]
In [ ]:
    print(optimize.root(y, 5.))
Out[ ]:
    fjac: array([[-1.]])
     fun: array([3.55271368e-15])
 message: 'The solution converged.'
    nfev: 12
     qtf: array([-3.73605502e-09])
       r: array([-9.59451815])
  status: 1
 success: True
       x: array([1.24189656])

使用初始猜测值5,我们的解收敛到了根1.24189656,这与我们迄今为止得到的答案非常接*。当我们选择图表另一侧的值时会发生什么?让我们使用初始猜测值-5

In [ ]:
    print(optimize.fsolve(y, -5., fprime=dy))
Out[ ]:
   [-1.33306553]
   c:\python37\lib\site-packages\scipy\optimize\minpack.py:163:         RuntimeWarning: The iteration is not making good progress, as measured by the 
  improvement from the last ten iterations.
  warnings.warn(msg, RuntimeWarning)
In [ ]:
    print(optimize.root(y, -5.))
Out[ ]:
    fjac: array([[-1.]])
     fun: array([-3.81481496])
 message: 'The iteration is not making good progress, as measured by the \n  improvement from the last ten iterations.'
    nfev: 28
     qtf: array([3.81481521])
       r: array([-0.00461503])
  status: 5
 success: False
       x: array([-1.33306551])

从显示输出中可以看出,算法没有收敛,并返回了一个与我们先前答案略有不同的根。如果我们在图表上看方程,会发现曲线上有许多点非常接*根。需要一个根查找器来获得所需的精度水*,而求解器则试图以最快的时间解决最*的答案。

总结

在本章中,我们简要讨论了经济和金融中非线性的持久性。我们看了一些在金融中常用的非线性模型,用来解释线性模型无法解释的数据的某些方面:Black-Scholes 隐含波动率模型、Markov 转换模型、阈值模型和*滑转换模型。

在 Black-Scholes 隐含波动率建模中,我们讨论了波动率微笑,它由通过 Black-Scholes 模型从特定到期日的看涨或看跌期权的市场价格推导出的隐含波动率组成。您可能会对寻找可能的最低隐含波动率值感兴趣,这对于推断理论价格并与潜在机会的市场价格进行比较可能是有用的。然而,由于曲线是非线性的,线性代数无法充分解决最优点的问题。为此,我们将需要使用根查找方法。

根查找方法试图找到函数的根或零点。我们讨论了常见的根查找方法,如二分法、牛顿法和割线法。使用根查找算法的组合可能有助于更快地找到复杂函数的根。Brent 方法就是一个例子。

我们探讨了scipy.optimize模块中包含的这些根查找方法的功能,尽管有约束条件。其中一个约束条件要求两个边界输入值被评估为一个负值和一个正值的对,以便解收敛成功。在隐含波动率建模中,这种评估几乎是不可能的,因为波动率没有负值。实现我们自己的根查找方法也许可以让我们更加掌控我们的应用程序应该如何执行。

使用通用求解器是另一种寻找根的方法。它们可能会更快地收敛到我们的解,但这种收敛并不由初始给定的值保证。

非线性建模和优化本质上是一项复杂的任务,没有通用的解决方案或确定的方法来得出结论。本章旨在介绍金融领域的非线性研究。

在下一章中,我们将介绍常用于期权定价的数值方法。通过将数值程序与寻根算法配对,我们将学习如何利用股票期权的市场价格构建隐含波动率模型。

第四章:期权定价的数值方法

衍生品是一种合同,其回报取决于某些基础资产的价值。在封闭形式衍生品定价可能复杂甚至不可能的情况下,数值程序表现出色。数值程序是使用迭代计算方法试图收敛到解的方法。其中一个基本实现是二项树。在二项树中,一个节点代表某一时间点的资产状态,与价格相关。每个节点在下一个时间步骤导致另外两个节点。同样,在三项树中,每个节点在下一个时间步骤导致另外三个节点。然而,随着树的节点数量或时间步骤的增加,消耗的计算资源也会增加。栅格定价试图通过仅在每个时间步骤存储新信息,同时在可能的情况下重复使用价值来解决这个问题。

在有限差分定价中,树的节点也可以表示为网格。网格上的终端值包括终端条件,而网格的边缘代表资产定价中的边界条件。我们将讨论有限差分方案的显式方法、隐式方法和 Crank-Nicolson 方法,以确定资产的价格。

尽管香草期权和某些奇异期权,如欧式障碍期权和回望期权,可能具有封闭形式解,但其他奇异产品,如亚洲期权,没有封闭形式解。在这些情况下,可以使用数值程序来定价期权。

在本章中,我们将涵盖以下主题:

  • 使用二项树定价欧式和美式期权

  • 使用 Cox-Ross-Rubinstein 二项树

  • 使用 Leisen-Reimer 树定价期权

  • 使用三项树定价期权

  • 从树中推导希腊字母

  • 使用二项和三项栅格定价期权

  • 使用显式、隐式和 Crank-Nicolson 方法的有限差分

  • 使用 LR 树和二分法的隐含波动率建模

期权介绍

期权是资产的衍生品,它赋予所有者在特定日期以特定价格交易基础资产的权利,称为到期日和行权价格。

认购期权赋予买方在特定日期以特定价格购买资产的权利。认购期权的卖方或写方有义务在约定日期以约定价格向买方出售基础证券,如果买方行使其权利。

认沽期权赋予买方在特定日期以特定价格出售基础资产的权利。认沽期权的卖方或写方有义务在约定日期以约定价格从买方购买基础证券,如果买方行使其权利。

最常见的期权包括欧式期权和美式期权。其他奇异期权包括百慕大期权和亚洲期权。本章主要涉及欧式期权和美式期权。欧式期权只能在到期日行使。而美式期权则可以在期权的整个生命周期内的任何时间行使。

期权定价中的二项树

在二项期权定价模型中,假设在一个时间段内,代表具有给定价格的节点的基础证券在下一个时间步骤中遍历到另外两个节点,代表上升状态和下降状态。由于期权是基础资产的衍生品,二项定价模型以离散时间为基础跟踪基础条件。二项期权定价可用于估值欧式期权、美式期权以及百慕大期权。

根节点的初始值是基础证券的现货价格S[0],具有风险中性概率的上涨q和下跌的风险中性概率1-q,在下一个时间步骤。基于这些概率,为每个价格上涨或下跌的状态计算了证券的预期值。终端节点代表了每个预期证券价格的值,对应于上涨状态和下跌状态的每种组合。然后我们可以计算每个节点的期权价值,通过风险中性期望遍历树状图,并在从远期利率贴现后,我们可以推导出看涨期权或看跌期权的价值。

定价欧式期权

考虑一个两步二叉树。不支付股息的股票价格从 50 美元开始,在两个时间步骤中,股票可能上涨 20%,也可能下跌 20%。假设无风险利率为每年 5%,到期时间T为两年。我们想要找到行权价K为 52 美元的欧式看跌期权的价值。以下图表显示了使用二叉树定价股票和终端节点的回报:

这里,节点的计算如下:

在终端节点,行使欧式看涨期权的回报如下:

在欧式看跌期权的情况下,回报如下:

欧式看涨期权和看跌期权通常用小写字母cp表示,而美式看涨期权和看跌期权通常用大写字母CP表示。

从期权回报值中,我们可以向后遍历二叉树到当前时间,并在从无风险利率贴现后,我们将获得期权的现值。向后遍历树状图考虑了期权上涨状态和下跌状态的风险中性概率。

我们可以假设投资者对风险不感兴趣,并且所有资产的预期回报相等。在通过风险中性概率投资股票的情况下,持有股票的回报并考虑上涨和下跌状态的可能性将等于在下一个时间步骤中预期的连续复利无风险利率,如下所示:

投资股票的风险中性概率q可以重写如下:

这些公式与股票相关吗?期货呢?

与投资股票不同,投资者无需提前付款来持有期货合约。在风险中性意义上,持有期货合约的预期增长率为零,投资期货的风险中性概率q可以重写如下:

让我们计算前面示例中给出的股票的风险中性概率q

In [ ]:
    import math

    r = 0.05
    T = 2
    t = T/2
    u = 1.2
    d = 0.8

    q = (math.exp(r*t)-d)/(u-d)
In [ ]:
    print('q is', q)
Out[ ]:   
    q is 0.6281777409400603

在终端节点行使欧式看跌期权的回报分别为 0 美元、4 美元和 20 美元。看跌期权的现值可以定价如下:

这给出了看跌期权价格为 4.19 美元。使用二叉树估算每个节点的欧式看跌期权的价值如下图所示:

编写 StockOption 基类

在进一步实现我们将要讨论的各种定价模型之前,让我们创建一个StockOption类,用于存储和计算股票期权的共同属性,这些属性将在本章中被重复使用:

In [ ]:
    import math

    """ 
    Stores common attributes of a stock option 
    """
    class StockOption(object):
        def __init__(
            self, S0, K, r=0.05, T=1, N=2, pu=0, pd=0, 
            div=0, sigma=0, is_put=False, is_am=False):
            """
            Initialize the stock option base class.
            Defaults to European call unless specified.

            :param S0: initial stock price
            :param K: strike price
            :param r: risk-free interest rate
            :param T: time to maturity
            :param N: number of time steps
            :param pu: probability at up state
            :param pd: probability at down state
            :param div: Dividend yield
            :param is_put: True for a put option,
                    False for a call option
            :param is_am: True for an American option,
                    False for a European option
            """
            self.S0 = S0
            self.K = K
            self.r = r
            self.T = T
            self.N = max(1, N)
            self.STs = [] # Declare the stock prices tree

            """ Optional parameters used by derived classes """
            self.pu, self.pd = pu, pd
            self.div = div
            self.sigma = sigma
            self.is_call = not is_put
            self.is_european = not is_am

        @property
        def dt(self):
            """ Single time step, in years """
            return self.T/float(self.N)

        @property
        def df(self):
            """ The discount factor """
            return math.exp(-(self.r-self.div)*self.dt)  

当前的标的价格、行权价格、无风险利率、到期时间和时间步数是定价期权的强制共同属性。时间步长dt和折现因子df的增量作为类的属性计算,并且如果需要,可以被实现类覆盖。

使用二项式树的欧式期权类

欧式期权的 Python 实现是BinomialEuropeanOption类,它继承自StockOption类的共同属性。该类中方法的实现如下:

  1. BinomialEuropeanOption类的price()方法是该类所有实例的入口

  2. 它调用setup_parameters()方法来设置所需的模型参数,然后调用init_stock_price_tree()方法来模拟期间内股票价格的预期值直到T

  3. 最后,调用begin_tree_traversal()方法来初始化支付数组并存储折现支付值,因为它遍历二项式树回到现在的时间

  4. 支付树节点作为 NumPy 数组对象返回,其中欧式期权的现值在初始节点处找到

BinomialEuropeanOption的类实现如下 Python 代码:

In [ ]:
    import math
    import numpy as np
    from decimal import Decimal

    """ 
    Price a European option by the binomial tree model 
    """
    class BinomialEuropeanOption(StockOption):

        def setup_parameters(self):
            # Required calculations for the model
            self.M = self.N+1  # Number of terminal nodes of tree
            self.u = 1+self.pu  # Expected value in the up state
            self.d = 1-self.pd  # Expected value in the down state
            self.qu = (math.exp(
                (self.r-self.div)*self.dt)-self.d)/(self.u-self.d)
            self.qd = 1-self.qu

        def init_stock_price_tree(self):
            # Initialize terminal price nodes to zeros
            self.STs = np.zeros(self.M)

            # Calculate expected stock prices for each node
            for i in range(self.M):
                self.STs[i] = self.S0 * \
                    (self.u**(self.N-i)) * (self.d**i)

        def init_payoffs_tree(self):
            """
            Returns the payoffs when the option 
            expires at terminal nodes
            """ 
            if self.is_call:
                return np.maximum(0, self.STs-self.K)
            else:
                return np.maximum(0, self.K-self.STs)

        def traverse_tree(self, payoffs):
            """
            Starting from the time the option expires, traverse
            backwards and calculate discounted payoffs at each node
            """
            for i in range(self.N):
                payoffs = (payoffs[:-1]*self.qu + 
                           payoffs[1:]*self.qd)*self.df

            return payoffs

        def begin_tree_traversal(self):
            payoffs = self.init_payoffs_tree()
            return self.traverse_tree(payoffs)

        def price(self):
            """ Entry point of the pricing implementation """
            self.setup_parameters()
            self.init_stock_price_tree()
            payoffs = self.begin_tree_traversal()

            # Option value converges to first node
            return payoffs[0]

让我们使用我们之前讨论的两步二项式树示例中的值来定价欧式看跌期权:

In [ ]:
    eu_option = BinomialEuropeanOption(
        50, 52, r=0.05, T=2, N=2, pu=0.2, pd=0.2, is_put=True)
In [ ]:
    print('European put option price is:', eu_option.price())
Out[ ]:    
    European put option price is: 4.1926542806038585

使用二项式期权定价模型,我们得到了欧式看跌期权的现值为 4.19 美元。

使用二项式树的美式期权类

与只能在到期时行使的欧式期权不同,美式期权可以在其寿命内的任何时候行使。

为了在 Python 中实现美式期权的定价,我们可以像BinomialEuropeanOption类一样创建一个名为BinomialTreeOption的类,该类继承自Stockoption类。setup_parameters()方法中使用的参数保持不变,只是删除了一个未使用的M参数。

美式期权中使用的方法如下:

  • init_stock_price_tree:使用二维 NumPy 数组存储所有时间步的股票价格的预期回报。这些信息用于计算在每个期间行使期权时的支付值。该方法编写如下:
def init_stock_price_tree(self):
    # Initialize a 2D tree at T=0
    self.STs = [np.array([self.S0])]

    # Simulate the possible stock prices path
    for i in range(self.N):
        prev_branches = self.STs[-1]
        st = np.concatenate(
            (prev_branches*self.u, 
             [prev_branches[-1]*self.d]))
        self.STs.append(st) # Add nodes at each time step
  • init_payoffs_tree:创建支付树作为二维 NumPy 数组,从期满时期的期权内在价值开始。该方法编写如下:
def init_payoffs_tree(self):
    if self.is_call:
        return np.maximum(0, self.STs[self.N]-self.K)
    else:
        return np.maximum(0, self.K-self.STs[self.N])
  • check_early_exercise:返回在提前行使美式期权和根本不行使期权之间的最大支付值。该方法编写如下:
def check_early_exercise(self, payoffs, node):
    if self.is_call:
        return np.maximum(payoffs, self.STs[node] - self.K)
    else:
        return np.maximum(payoffs, self.K - self.STs[node])
  • traverse_tree:这还包括调用check_early_exercise()方法,以检查是否在每个时间步提前行使美式期权是最优的。该方法编写如下:
def traverse_tree(self, payoffs):
    for i in reversed(range(self.N)):
        # The payoffs from NOT exercising the option
        payoffs = (payoffs[:-1]*self.qu + 
                   payoffs[1:]*self.qd)*self.df

        # Payoffs from exercising, for American options
        if not self.is_european:
            payoffs = self.check_early_exercise(payoffs,i)

    return payoffs

begin_tree_traversal()price()方法的实现保持不变。

当在类的实例化期间将is_put关键字参数设置为FalseTrue时,BinomialTreeOption类可以定价欧式和美式期权。

以下代码是用于定价美式期权的:

In [ ]:
    am_option = BinomialTreeOption(50, 52, 
        r=0.05, T=2, N=2, pu=0.2, pd=0.2, is_put=True, is_am=True)
In [ ]:
    print('American put option price is:', am_option.price())
Out[ ]:    
    American put option price is: 5.089632474198373

美式看跌期权的价格为 5.0896 美元。由于美式期权可以在任何时候行使,而欧式期权只能在到期时行使,因此美式期权的这种灵活性在某些情况下增加了其价值。

对于不支付股息的基础资产的美式看涨期权,可能没有超过其欧式看涨期权对应的额外价值。由于时间价值的原因,今天在行权价上行使美式看涨期权的成本比在未来以相同行权价行使更高。对于实值的美式看涨期权,提前行使期权会失去对抗行权价以下不利价格波动的保护,以及其内在时间价值。没有股息支付的权利,没有动机提前行使美式看涨期权。

Cox–Ross–Rubinstein 模型

在前面的例子中,我们假设基础股价在相应的u上升状态和d下降状态分别增加 20%和减少 20%。Cox-Ross-RubinsteinCRR)模型提出,在风险中性世界的短时间内,二项式模型与基础股票的均值和方差相匹配。基础股票的波动性,或者股票回报的标准差,如下所示:

CRR 二项式树期权定价模型的类

二项式 CRR 模型的实现与我们之前讨论的二项式树相同,唯一的区别在于ud模型参数。

在 Python 中,让我们创建一个名为BinomialCRROption的类,并简单地继承BinomialTreeOption类。然后,我们只需要覆盖setup_parameters()方法,使用 CRR 模型中的值。

BinomialCRROption对象的实例将调用price()方法,该方法调用父类BinomialTreeOption的所有其他方法,除了被覆盖的setup_parameters()方法:

In [ ]:
    import math

    """ 
    Price an option by the binomial CRR model 
    """
    class BinomialCRROption(BinomialTreeOption):
        def setup_parameters(self):
            self.u = math.exp(self.sigma * math.sqrt(self.dt))
            self.d = 1./self.u
            self.qu = (math.exp((self.r-self.div)*self.dt) - 
                       self.d)/(self.u-self.d)
            self.qd = 1-self.qu

再次考虑两步二项式树。不支付股息的股票当前价格为 50 美元,波动率为 30%。假设无风险利率为年利率 5%,到期时间T为两年。我们想要找到 CRR 模型下的行权价为 52 美元的欧式看跌期权的价值:

In [ ]:
    eu_option = BinomialCRROption(
        50, 52, r=0.05, T=2, N=2, sigma=0.3, is_put=True)
In [ ]:
    print('European put:', eu_option.price())
Out[ ]:
    European put: 6.245708445206436
In [ ]:
    am_option = BinomialCRROption(50, 52, 
        r=0.05, T=2, N=2, sigma=0.3, is_put=True, is_am=True)
In [ ]:
    print('American put option price is:', am_option.price())
Out[ ]:
    American put option price is: 7.428401902704834

通过使用 CRR 两步二项式树模型,欧式看跌期权和美式看跌期权的价格分别为 6.2457 美元和 7.4284 美元。

使用 Leisen-Reimer 树

在我们之前讨论的二项式模型中,我们对上升和下降状态的概率以及由此产生的风险中性概率做出了几个假设。除了我们讨论的具有 CRR 参数的二项式模型之外,在数学金融中广泛讨论的其他形式的参数化包括 Jarrow-Rudd 参数化、Tian 参数化和 Leisen-Reimer 参数化。让我们详细看看 Leisen-Reimer 模型。

Dietmar Leisen 博士和 Matthias Reimer 提出了一个二项式树模型,旨在在步数增加时逼* Black-Scholes 解。它被称为Leisen-ReimerLR)树,节点不会在每个交替步骤重新组合。它使用反演公式在树遍历期间实现更好的准确性。

有关该公式的详细解释可在 1995 年 3 月的论文Binomial Models For Option Valuation - Examining And Improving Convergence中找到,网址为papers.ssrn.com/sol3/papers.cfm?abstract_id=5976。我们将使用 Peizer 和 Pratt 反演函数f的第二种方法,具有以下特征参数:

S[0]参数是当前股票价格,K是期权的行权价格,σ是基础股票的年化波动率,T是期权的到期时间,r是年化无风险利率,y是股息收益,Δt是每个树步之间的时间间隔。

LR 二项树期权定价模型的一个类

LR 树的 Python 实现在以下BinomialLROption类中给出。与BinomialCRROption类类似,我们只需继承BinomialTreeOption类,并用 LR 树模型的变量覆盖setup_parameters方法中的变量:

In [ ]:
    import math

    """ 
    Price an option by the Leisen-Reimer tree
    """
    class BinomialLROption(BinomialTreeOption):

        def setup_parameters(self):
            odd_N = self.N if (self.N%2 == 0) else (self.N+1)
            d1 = (math.log(self.S0/self.K) +
                  ((self.r-self.div) +
                   (self.sigma**2)/2.)*self.T)/\
                (self.sigma*math.sqrt(self.T))
            d2 = (math.log(self.S0/self.K) +
                  ((self.r-self.div) -
                   (self.sigma**2)/2.)*self.T)/\
                (self.sigma * math.sqrt(self.T))

            pbar = self.pp_2_inversion(d1, odd_N)
            self.p = self.pp_2_inversion(d2, odd_N)
            self.u = 1/self.df * pbar/self.p
            self.d = (1/self.df-self.p*self.u)/(1-self.p)
            self.qu = self.p
            self.qd = 1-self.p

        def pp_2_inversion(self, z, n):
            return .5 + math.copysign(1, z)*\
                math.sqrt(.25 - .25*
                    math.exp(
                        -((z/(n+1./3.+.1/(n+1)))**2.)*(n+1./6.)
                    )
                )

使用我们之前使用的相同示例,我们可以使用 LR 树定价期权:

In [ ]:
    eu_option = BinomialLROption(
        50, 52, r=0.05, T=2, N=4, sigma=0.3, is_put=True)
In [ ]:
    print('European put:', eu_option.price())
Out[ ]:      
    European put: 5.878650106601964
In [ ]:
    am_option = BinomialLROption(50, 52, 
        r=0.05, T=2, N=4, sigma=0.3, is_put=True, is_am=True)
In [ ]:
    print('American put:', am_option.price())
Out[ ]:
    American put: 6.763641952939979

通过使用具有四个时间步长的 LR 二项树模型,欧式看跌期权的价格和美式看跌期权的价格分别为$5.87865 和$6.7636。

希腊字母免费

在我们迄今为止涵盖的二项树定价模型中,我们在每个时间点上上下遍历树来确定节点值。根据每个节点的信息,我们可以轻松地重用这些计算出的值。其中一种用途是计算希腊字母。

希腊字母衡量衍生品价格对基础资产参数变化的敏感性,例如期权,通常用希腊字母表示。在数学金融中,与希腊字母相关的常见名称包括 alpha、beta、delta、gamma、vega、theta 和 rho。

期权的两个特别有用的希腊字母是 delta 和 gamma。Delta 衡量期权价格对基础资产价格的敏感性。Gamma 衡量 delta 相对于基础价格的变化率。

如下图所示,在我们原始的两步树周围添加了额外的节点层,使其成为一个四步树,向时间向后延伸了两步。即使有额外的期末支付节点,所有节点将包含与我们原始的两步树相同的信息。我们感兴趣的期权价值现在位于树的中间,即t=0

注意,在t=0处存在两个额外节点的信息,我们可以使用它们来计算 delta 公式,如下所示:

三角洲公式规定,期权价格在上涨和下跌状态之间的差异表示为时间t=0时各自股票价格之间的差异的单位。

相反,gamma 公式可以计算如下:

伽玛公式规定,上节点和下节点中期权价格的 delta 之间的差异与初始节点值相对于各自状态下股票价格的差异的单位进行计算。

LR 二项树的希腊字母类

为了说明在 LR 树中计算希腊字母的过程,让我们创建一个名为BinomialLRWithGreeks的新类,该类继承了BinomialLROption类,并使用我们自己的price方法的实现。

price方法中,我们将首先调用父类的setup_parameters()方法来初始化 LR 树所需的所有变量。然而,这一次,我们还将调用new_stock_price_tree()方法,这是一个用于在原始树周围创建额外节点层的新方法。

调用begin_tree_traversal()方法执行父类中的通常 LR 树实现。返回的 NumPy 数组对象现在包含t=0处三个节点的信息,其中中间节点是期权价格。在数组的第一个和最后一个索引处是t=0处上升和下降状态的支付。

有了这些信息,price()方法计算并返回期权价格、delta 和 gamma 值:

In [ ]:
    import numpy as np

    """ 
    Compute option price, delta and gamma by the LR tree 
    """
    class BinomialLRWithGreeks(BinomialLROption):

        def new_stock_price_tree(self):
            """
            Creates an additional layer of nodes to our
            original stock price tree
            """
            self.STs = [np.array([self.S0*self.u/self.d,
                                  self.S0,
                                  self.S0*self.d/self.u])]

            for i in range(self.N):
                prev_branches = self.STs[-1]
                st = np.concatenate((prev_branches*self.u,
                                     [prev_branches[-1]*self.d]))
                self.STs.append(st)

        def price(self):
            self.setup_parameters()
            self.new_stock_price_tree()
            payoffs = self.begin_tree_traversal()

            # Option value is now in the middle node at t=0
            option_value = payoffs[len(payoffs)//2]

            payoff_up = payoffs[0]
            payoff_down = payoffs[-1]
            S_up = self.STs[0][0]
            S_down = self.STs[0][-1]
            dS_up = S_up - self.S0
            dS_down = self.S0 - S_down

            # Calculate delta value
            dS = S_up - S_down
            dV = payoff_up - payoff_down
            delta = dV/dS

            # calculate gamma value
            gamma = ((payoff_up-option_value)/dS_up - 
                     (option_value-payoff_down)/dS_down) / \
                ((self.S0+S_up)/2\. - (self.S0+S_down)/2.)

            return option_value, delta, gamma

使用 LR 树的相同示例,我们可以计算具有 300 个时间步的欧式看涨期权和看跌期权的期权价值和希腊值:

In [ ]:
    eu_call = BinomialLRWithGreeks(50, 52, r=0.05, T=2, N=300, sigma=0.3)
    results = eu_call.price()
In [ ]:
    print('European call values')
    print('Price: %s\nDelta: %s\nGamma: %s' % results)
Out[ ]:
    European call values
    Price: 9.69546807138366
    Delta: 0.6392477816643529
    Gamma: 0.01764795890533088

In [ ]:
    eu_put = BinomialLRWithGreeks(
        50, 52, r=0.05, T=2, N=300, sigma=0.3, is_put=True)
    results = eu_put.price()
In [ ]:
    print('European put values')
    print('Price: %s\nDelta: %s\nGamma: %s' % results)
Out[ ]:   
    European put values
    Price: 6.747013809252746
    Delta: -0.3607522183356649
    Gamma: 0.0176479589053312

price()方法和结果中可以看出,我们成功地从修改后的二项树中获得了希腊附加信息,而没有增加计算复杂性。

期权定价中的三项树

在二项树中,每个节点导致下一个时间步中的两个其他节点。同样,在三项树中,每个节点导致下一个时间步中的三个其他节点。除了具有上升和下降状态外,三项树的中间节点表示状态不变。当扩展到两个以上的时间步时,三项树可以被视为重新组合树,其中中间节点始终保留与上一个时间步相同的值。

让我们考虑 Boyle 三项树,其中树被校准,使得上升、下降和*稳移动的概率udm与风险中性概率q[u]q[d]q[m]如下:

我们可以看到  重新组合为 m =1。通过校准,无状态移动 m 以 1 的固定利率增长,而不是以无风险利率增长。变量 v 是年化股息收益,σ 是基础股票的年化波动率。

一般来说,处理更多节点时,三项树在建模较少时间步时比二项树具有更好的精度,可以节省计算速度和资源。下图说明了具有两个时间步的三项树的股价变动:

三项树期权定价模型的类

让我们创建一个TrinomialTreeOption类,继承自BinomialTreeOption类。

TrinomialTreeOption的方法如下所示:

  • setup_parameters()方法实现了三项树的模型参数。该方法编写如下:
def setup_parameters(self):
    """ Required calculations for the model """
    self.u = math.exp(self.sigma*math.sqrt(2.*self.dt))
    self.d = 1/self.u
    self.m = 1
    self.qu = ((math.exp((self.r-self.div) *
                         self.dt/2.) -
                math.exp(-self.sigma *
                         math.sqrt(self.dt/2.))) /
               (math.exp(self.sigma *
                         math.sqrt(self.dt/2.)) -
                math.exp(-self.sigma *
                         math.sqrt(self.dt/2.))))**2
    self.qd = ((math.exp(self.sigma *
                         math.sqrt(self.dt/2.)) -
                math.exp((self.r-self.div) *
                         self.dt/2.)) /
               (math.exp(self.sigma *
                         math.sqrt(self.dt/2.)) -
                math.exp(-self.sigma *
                         math.sqrt(self.dt/2.))))**2.

    self.qm = 1 - self.qu - self.qd
  • init_stock_price_tree()方法设置了三项树,包括股价的*稳移动。该方法编写如下:
def init_stock_price_tree(self):
    # Initialize a 2D tree at t=0
    self.STs = [np.array([self.S0])]

    for i in range(self.N):
        prev_nodes = self.STs[-1]
        self.ST = np.concatenate(
            (prev_nodes*self.u, [prev_nodes[-1]*self.m,
                                 prev_nodes[-1]*self.d]))
        self.STs.append(self.ST)
  • traverse_tree()方法在打折后考虑中间节点的收益:
def traverse_tree(self, payoffs):
    # Traverse the tree backwards 
    for i in reversed(range(self.N)):
        payoffs = (payoffs[:-2] * self.qu +
                   payoffs[1:-1] * self.qm +
                   payoffs[2:] * self.qd) * self.df

        if not self.is_european:
            payoffs = self.check_early_exercise(payoffs,i)

    return payoffs
  • 使用二项树的相同示例,我们得到以下结果:
In [ ]:
   eu_put = TrinomialTreeOption(
        50, 52, r=0.05, T=2, N=2, sigma=0.3, is_put=True)
In [ ]:
   print('European put:', eu_put.price())
Out[ ]:
   European put: 6.573565269142496
In [ ]:
   am_option = TrinomialTreeOption(50, 52, 
        r=0.05, T=2, N=2, sigma=0.3, is_put=True, is_am=True)
In [ ]:
   print('American put:', am_option.price())
Out[ ]:
   American put: 7.161349217272585

通过三项树模型,我们得到了欧式看跌期权和美式看跌期权的价格分别为$6.57 和$7.16。

期权定价中的栅格

在二项树中,每个节点在每个交替节点处重新组合。在三项树中,每个节点在每个其他节点处重新组合。重新组合树的这种属性也可以表示为栅格,以节省内存而无需重新计算和存储重新组合的节点。

使用二项栅格

我们将从二项 CRR 树创建一个二项栅格,因为在每个交替的上升和下降节点处,价格重新组合为相同的ud=1概率。在下图中,S[u]S[d]S[du] = S[ud] = S[0]重新组合。现在可以将树表示为单个列表:

对于N步二项式树,需要一个大小为2N +1的列表来包含关于基础股票价格的信息。对于欧式期权定价,列表的奇数节点代表到期时的期权价值。树向后遍历以获得期权价值。对于美式期权定价,随着树向后遍历,列表的两端收缩,奇数节点代表任何时间步的相关股票价格。然后可以考虑早期行权的回报。

CRR 二项式栅格期权定价模型的类

让我们通过 CRR 将二项式树定价转换为栅格。我们可以继承BinomialCRROption类(该类又继承自BinomialTreeOption类),并创建一个名为BinomialCRRLattice的新类,如下所示:

In [ ]:
    import numpy as np

    class BinomialCRRLattice(BinomialCRROption):

        def setup_parameters(self):
            super(BinomialCRRLattice, self).setup_parameters()
            self.M = 2*self.N + 1

        def init_stock_price_tree(self):
            self.STs = np.zeros(self.M)
            self.STs[0] = self.S0 * self.u**self.N

            for i in range(self.M)[1:]:
                self.STs[i] = self.STs[i-1]*self.d

        def init_payoffs_tree(self):
            odd_nodes = self.STs[::2]  # Take odd nodes only
            if self.is_call:
                return np.maximum(0, odd_nodes-self.K)
            else:
                return np.maximum(0, self.K-odd_nodes)

        def check_early_exercise(self, payoffs, node):
            self.STs = self.STs[1:-1]  # Shorten ends of the list
            odd_STs = self.STs[::2]  # Take odd nodes only
            if self.is_call:
                return np.maximum(payoffs, odd_STs-self.K)
            else:
                return np.maximum(payoffs, self.K-odd_STs)

以下方法被覆盖,同时保留所有其他定价函数的行为:

  • setup_parameters:覆盖父类方法以初始化父类的 CRR 参数,并声明新变量M为列表大小

  • init_stock_price_tree:覆盖父类方法,设置一个一维 NumPy 数组作为具有M大小的栅格

  • init_payoffs_treecheck_early_exercise:覆盖父类方法,只考虑奇数节点的回报

使用我们二项式 CRR 模型示例中的相同股票信息,我们可以使用二项式栅格定价来定价欧式和美式看跌期权:

In [ ]:
    eu_option = BinomialCRRLattice(
        50, 52, r=0.05, T=2, N=2, sigma=0.3, is_put=True)
In [ ] :
    print('European put:', eu_option.price())
Out[ ]:  European put: 6.245708445206432
In [ ]:
    am_option = BinomialCRRLattice(50, 52, 
        r=0.05, T=2, N=2, sigma=0.3, is_put=True, is_am=True)
In [ ] :
    print("American put:", am_option.price())
Out[ ]:   
    American put: 7.428401902704828

通过使用 CRR 二项式树格定价模型,我们得到了欧式和美式看跌期权的价格分别为$6.2457 和$7.428。

使用三项式栅格

三项式栅格与二项式栅格的工作方式基本相同。由于每个节点在每个其他节点重新组合,而不是交替节点,因此不需要从列表中提取奇数节点。由于列表的大小与二项式栅格中的大小相同,在三项式栅格定价中没有额外的存储要求,如下图所示:

三项式栅格期权定价模型的类

在 Python 中,让我们创建一个名为TrinomialLattice的类,用于继承TrinomialTreeOption类的三项式栅格实现。

就像我们为BinomialCRRLattice类所做的那样,覆盖了setup_parametersinit_stock_price_treeinit_payoffs_treecheck_early_exercise方法,而不必考虑奇数节点的回报:

In [ ]:
    import numpy as np

    """ 
    Price an option by the trinomial lattice 
    """
    class TrinomialLattice(TrinomialTreeOption):

        def setup_parameters(self):
            super(TrinomialLattice, self).setup_parameters()
            self.M = 2*self.N + 1

        def init_stock_price_tree(self):
            self.STs = np.zeros(self.M)
            self.STs[0] = self.S0 * self.u**self.N

            for i in range(self.M)[1:]:
                self.STs[i] = self.STs[i-1]*self.d

        def init_payoffs_tree(self):
            if self.is_call:
                return np.maximum(0, self.STs-self.K)
            else:
                return np.maximum(0, self.K-self.STs)

        def check_early_exercise(self, payoffs, node):
            self.STs = self.STs[1:-1]  # Shorten ends of the list
            if self.is_call:
                return np.maximum(payoffs, self.STs-self.K)
            else:
                return np.maximum(payoffs, self.K-self.STs)

使用与之前相同的示例,我们可以使用三项式栅格模型定价欧式和美式期权:

In [ ]:
    eu_option = TrinomialLattice(
        50, 52, r=0.05, T=2, N=2, sigma=0.3, is_put=True)
    print('European put:', eu_option.price())
Out[ ]:
    European put: 6.573565269142496
In [ ]:
    am_option = TrinomialLattice(50, 52, 
        r=0.05, T=2, N=2, sigma=0.3, is_put=True, is_am=True)
    print('American put:', am_option.price())
Out[ ]:
    American put: 7.161349217272585

输出与从三项式树期权定价模型获得的结果一致。

期权定价中的有限差分

有限差分方案与三项式树期权定价非常相似,其中每个节点依赖于另外三个节点,即上升、下降和*移。有限差分的动机是应用 Black-Scholes偏微分方程PDE)框架(涉及函数及其偏导数),其中价格S(t)f(S,t)的函数,r是无风险利率,t是到期时间,σ是基础证券的波动率:

有限差分技术往往比栅格更快地收敛,并且很好地逼*复杂的异国期权。

通过有限差分向后工作来解决 PDE,建立大小为M乘以N的离散时间网格,以反映一段时间内的资产价格,使得St在网格上的每个点上取以下值:

由网格符号表示,f[i,j]=f( idS, j dt)S[max]是一个适当大的资产价格,无法在到期时间T到达。因此dSdt是网格中每个节点之间的间隔,分别由价格和时间递增。到期时间T的终端条件对于每个S的值是一个具有行权价K的看涨期权的max(S − K, 0)和一个具有行权价K的看跌期权的max(K − S, 0)。网格从终端条件向后遍历,遵守 PDE,同时遵守网格的边界条件,例如早期行权的支付。

边界条件是节点的两端的定义值,其中i=0i=N对于每个时间t。边界处的值用于使用 PDE 迭代计算所有其他格点的值。

网格的可视化表示如下图所示。当ij从网格的左上角增加时,价格S趋向于网格的右下角的S[max](可能的最高价格):

*似 PDE 的一些方法如下:

  • 前向差分:

  • 后向差分:

  • 中心或对称差分:

  • 二阶导数:

一旦我们设置好边界条件,现在可以使用显式、隐式或 Crank-Nicolson 方法进行迭代处理。

显式方法

用于*似f[i,j]的显式方法由以下方程给出:

在这里,我们可以看到第一个差分是关于t的后向差分,第二个差分是关于S的中心差分,第三个差分是关于S的二阶差分。当我们重新排列项时,我们得到以下方程:

其中:

然后:

显式方法的迭代方法可以通过以下图表进行可视化表示:

编写有限差分基类

由于我们将在 Python 中编写有限差分的显式、隐式和 Crank-Nicolson 方法,让我们编写一个基类,该基类继承了所有三种方法的共同属性和函数。

我们将创建一个名为FiniteDifferences的类,该类在__init__构造方法中接受并分配所有必需的参数。price()方法是调用特定有限差分方案实现的入口点,并将按以下顺序调用这些方法:setup_boundary_conditions()setup_coefficients()traverse_grid()interpolate()。这些方法的解释如下:

  • setup_boundary_conditions:设置网格结构的边界条件为 NumPy 二维数组

  • setup_coefficients:设置用于遍历网格结构的必要系数

  • traverse_grid:向后迭代网格结构,将计算值存储到网格的第一列

  • interpolate:使用网格第一列上的最终计算值,这种方法将插值这些值以找到最接*初始股价S0的期权价格

所有这些方法都是可以由派生类实现的抽象方法。如果我们忘记实现这些方法,将抛出NotImplementedError异常类型。

基类应该包含以下强制方法:

In [ ]:
    from abc import ABC, abstractmethod
    import numpy as np

    """ 
    Base class for sharing 
    attributes and functions of FD 
    """
    class FiniteDifferences(object):

        def __init__(
            self, S0, K, r=0.05, T=1, 
            sigma=0, Smax=1, M=1, N=1, is_put=False
        ):
            self.S0 = S0
            self.K = K
            self.r = r
            self.T = T
            self.sigma = sigma
            self.Smax = Smax
            self.M, self.N = M, N
            self.is_call = not is_put

            self.i_values = np.arange(self.M)
            self.j_values = np.arange(self.N)
            self.grid = np.zeros(shape=(self.M+1, self.N+1))
            self.boundary_conds = np.linspace(0, Smax, self.M+1)

        @abstractmethod
        def setup_boundary_conditions(self):
            raise NotImplementedError('Implementation required!')

        @abstractmethod
        def setup_coefficients(self):
            raise NotImplementedError('Implementation required!')

        @abstractmethod
        def traverse_grid(self):
            """  Iterate the grid backwards in time"""
            raise NotImplementedError('Implementation required!')

        @abstractmethod
        def interpolate(self):
            """ Use piecewise linear interpolation on the initial
            grid column to get the closest price at S0.
            """
            return np.interp(
                self.S0, self.boundary_conds, self.grid[:,0])

抽象基类ABCs)提供了定义类接口的方法。@abstractmethod()装饰器声明了子类应该实现的抽象方法。与 Java 的抽象方法不同,这些方法可能有一个实现,并且可以通过super()机制从覆盖它的类中调用。

除了这些方法,我们还需要定义dSdt,即每单位时间内S的变化和每次迭代中T的变化。我们可以将这些定义为类属性:

@property
def dS(self):
    return self.Smax/float(self.M)

@property
def dt(self):
    return self.T/float(self.N)

最后,将price()方法添加为入口点,显示调用我们讨论的抽象方法的步骤:

def price(self):
    self.setup_boundary_conditions()
    self.setup_coefficients()
    self.traverse_grid()
    return self.interpolate()

使用有限差分的显式方法对欧式期权进行定价的类

使用显式方法的有限差分的 Python 实现如下FDExplicitEu类,它继承自FiniteDifferences类并覆盖了所需的实现方法:

In [ ]:
    import numpy as np

    """ 
    Explicit method of Finite Differences 
    """
    class FDExplicitEu(FiniteDifferences):

        def setup_boundary_conditions(self):
            if self.is_call:
                self.grid[:,-1] = np.maximum(
                    0, self.boundary_conds - self.K)
                self.grid[-1,:-1] = (self.Smax-self.K) * \
                    np.exp(-self.r*self.dt*(self.N-self.j_values))
            else:
                self.grid[:,-1] = np.maximum(
                    0, self.K-self.boundary_conds)
                self.grid[0,:-1] = (self.K-self.Smax) * \
                    np.exp(-self.r*self.dt*(self.N-self.j_values))

        def setup_coefficients(self):
            self.a = 0.5*self.dt*((self.sigma**2) *
                                  (self.i_values**2) -
                                  self.r*self.i_values)
            self.b = 1 - self.dt*((self.sigma**2) *
                                  (self.i_values**2) +
                                  self.r)
            self.c = 0.5*self.dt*((self.sigma**2) *
                                  (self.i_values**2) +
                                  self.r*self.i_values)

        def traverse_grid(self):
            for j in reversed(self.j_values):
                for i in range(self.M)[2:]:
                    self.grid[i,j] = \
                        self.a[i]*self.grid[i-1,j+1] +\
                        self.b[i]*self.grid[i,j+1] + \
                        self.c[i]*self.grid[i+1,j+1]

完成网格结构的遍历后,第一列包含t=0时刻的初始资产价格的现值。NumPy 的interp函数用于执行线性插值以*似期权价值。

除了线性插值作为插值方法的最常见选择外,还可以使用其他方法,如样条或三次插值来*似期权价值。

考虑一个欧式看跌期权的例子。标的股票价格为 50 美元,波动率为 40%。看跌期权的行权价为 50 美元,到期时间为五个月。无风险利率为 10%。

我们可以使用显式方法对该期权进行定价,Smax值为100M值为100N值为1000

In [ ]:
    option = FDExplicitEu(50, 50, r=0.1, T=5./12., 
        sigma=0.4, Smax=100, M=100, N=1000, is_put=True)
    print(option.price())
Out[ ]:
    4.072882278148043

当选择其他不合适的MN值时会发生什么?

In [ ]:
    option = FDExplicitEu(50, 50, r=0.1, T=5./12., 
        sigma=0.4, Smax=100, M=80, N=100, is_put=True)
    print(option.price())
Out[ ]:   
    -8.109445694129245e+35

显然,有限差分方案的显式方法存在不稳定性问题。

隐式方法

显式方法的不稳定问题可以通过对时间的前向差分来克服。用于*似f[i,j]的隐式方法由以下方程给出:

在这里,可以看到隐式和显式*似方案之间唯一的区别在于第一个差分,隐式方案中使用了对t的前向差分。当我们重新排列项时,我们得到以下表达式:

其中:

在这里:

隐式方案的迭代方法可以用以下图表进行可视化表示:

从前面的图表中,我们可以注意到需要在下一次迭代步骤中计算出j+1的值,因为网格是向后遍历的。在隐式方案中,网格可以被认为在每次迭代中代表一个线性方程组,如下所示:

通过重新排列项,我们得到以下方程:

线性方程组可以表示为Ax = B的形式,我们希望在每次迭代中解出x的值。由于矩阵A是三对角的,我们可以使用 LU 分解,其中A=LU,以加快计算速度。请记住,我们在第二章中使用 LU 分解解出了线性方程组,该章节名为《金融中的线性关系的重要性》。

使用有限差分的隐式方法对欧式期权进行定价的类

隐式方案的 Python 实现在以下FDImplicitEu类中给出。我们可以从之前讨论的FDExplicitEu类中继承显式方法的实现,并覆盖感兴趣的必要方法,即setup_coefficientstraverse_grid方法:

In [ ]:
    import numpy as np
    import scipy.linalg as linalg

    """ 
    Explicit method of Finite Differences 
    """
    class FDImplicitEu(FDExplicitEu):

        def setup_coefficients(self):
            self.a = 0.5*(self.r*self.dt*self.i_values -
                          (self.sigma**2)*self.dt*\
                              (self.i_values**2))
            self.b = 1 + \
                     (self.sigma**2)*self.dt*\
                        (self.i_values**2) + \
                    self.r*self.dt
            self.c = -0.5*(self.r*self.dt*self.i_values +
                           (self.sigma**2)*self.dt*\
                               (self.i_values**2))
            self.coeffs = np.diag(self.a[2:self.M],-1) + \
                          np.diag(self.b[1:self.M]) + \
                          np.diag(self.c[1:self.M-1],1)

        def traverse_grid(self):
            """ Solve using linear systems of equations """
            P, L, U = linalg.lu(self.coeffs)
            aux = np.zeros(self.M-1)

            for j in reversed(range(self.N)):
                aux[0] = np.dot(-self.a[1], self.grid[0, j])
                x1 = linalg.solve(L, self.grid[1:self.M, j+1]+aux)
                x2 = linalg.solve(U, x1)
                self.grid[1:self.M, j] = x2

使用与显式方案相同的示例,我们可以使用隐式方案定价欧式看跌期权:

In [ ]:
    option = FDImplicitEu(50, 50, r=0.1, T=5./12., 
        sigma=0.4, Smax=100, M=100, N=1000, is_put=True)
    print(option.price())
Out[ ]:
    4.071594188049893
In [ ]:
    option = FDImplicitEu(50, 50, r=0.1, T=5./12., 
        sigma=0.4, Smax=100, M=80, N=100, is_put=True)
    print(option.price())
Out[ ]:
    4.063684691731647

鉴于当前参数和输入数据,我们可以看到隐式方案没有稳定性问题。

Crank-Nicolson 方法

另一种避免稳定性问题的方法,如显式方法中所见,是使用 Crank-Nicolson 方法。Crank-Nicolson 方法通过使用显式和隐式方法的组合更快地收敛,取两者的*均值。这导致以下方程:

这个方程也可以重写如下:

其中:

隐式方案的迭代方法可以用以下图表形式表示:

我们可以将方程视为矩阵形式的线性方程组:

其中:

我们可以在每个迭代过程中解出矩阵M

使用有限差分的 Crank-Nicolson 方法定价欧式期权的类

Crank-Nicolson 方法的 Python 实现在以下FDCnEu类中给出,该类继承自FDExplicitEu类,并仅覆盖setup_coefficientstraverse_grid方法:

In [ ]:
    import numpy as np
    import scipy.linalg as linalg

    """ 
    Crank-Nicolson method of Finite Differences 
    """
    class FDCnEu(FDExplicitEu):

        def setup_coefficients(self):
            self.alpha = 0.25*self.dt*(
                (self.sigma**2)*(self.i_values**2) - \
                self.r*self.i_values)
            self.beta = -self.dt*0.5*(
                (self.sigma**2)*(self.i_values**2) + self.r)
            self.gamma = 0.25*self.dt*(
                (self.sigma**2)*(self.i_values**2) +
                self.r*self.i_values)
            self.M1 = -np.diag(self.alpha[2:self.M], -1) + \
                      np.diag(1-self.beta[1:self.M]) - \
                      np.diag(self.gamma[1:self.M-1], 1)
            self.M2 = np.diag(self.alpha[2:self.M], -1) + \
                      np.diag(1+self.beta[1:self.M]) + \
                      np.diag(self.gamma[1:self.M-1], 1)

        def traverse_grid(self):
            """ Solve using linear systems of equations """
            P, L, U = linalg.lu(self.M1)

            for j in reversed(range(self.N)):
                x1 = linalg.solve(
                    L, np.dot(self.M2, self.grid[1:self.M, j+1]))
                x2 = linalg.solve(U, x1)
                self.grid[1:self.M, j] = x2

使用与显式和隐式方法相同的示例,我们可以使用 Crank-Nicolson 方法为不同的时间点间隔定价欧式看跌期权:

In [ ]:
    option = FDCnEu(50, 50, r=0.1, T=5./12.,
        sigma=0.4, Smax=100, M=100, N=1000, is_put=True)
    print(option.price())
Out[ ]:   
    4.072238354486825
In [ ]:
    option = FDCnEu(50, 50, r=0.1, T=5./12., 
        sigma=0.4, Smax=100, M=80, N=100, is_put=True)
    print(option.price())
Out[ ]: 
    4.070145703042843

从观察到的值来看,Crank-Nicolson 方法不仅避免了我们在显式方案中看到的不稳定性问题,而且比显式和隐式方法都更快地收敛。隐式方法需要更多的迭代,或者更大的N值,才能产生接* Crank-Nicolson 方法的值。

定价异国情调的障碍期权

有限差分在定价异国情调期权方面特别有用。期权的性质将决定边界条件的规格。

在本节中,我们将看一个使用有限差分的 Crank-Nicolson 方法定价低迷障碍期权的例子。由于其相对复杂性,通常会使用其他分析方法,如蒙特卡罗方法,而不是有限差分方案。

一种低迷的选择

让我们看一个低迷期权的例子。在期权的任何生命周期中,如果标的资产价格低于S[barrier]障碍价格,则认为该期权毫无价值。由于在网格中,有限差分方案代表所有可能的价格点,我们只需要考虑以下价格范围的节点:

然后我们可以设置边界条件如下:

使用有限差分的 Crank-Nicolson 方法定价低迷期权的类

让我们创建一个名为FDCnDo的类,它继承自之前讨论的FDCnEu类。我们将在构造方法中考虑障碍价格,而将FDCnEu类中的 Crank-Nicolson 实现的其余部分保持不变:

In [ ]:
    import numpy as np

    """
    Price a down-and-out option by the Crank-Nicolson
    method of finite differences.
    """
    class FDCnDo(FDCnEu):

        def __init__(
            self, S0, K, r=0.05, T=1, sigma=0, 
            Sbarrier=0, Smax=1, M=1, N=1, is_put=False
        ):
            super(FDCnDo, self).__init__(
                S0, K, r=r, T=T, sigma=sigma,
                Smax=Smax, M=M, N=N, is_put=is_put
            )
            self.barrier = Sbarrier
            self.boundary_conds = np.linspace(Sbarrier, Smax, M+1)
            self.i_values = self.boundary_conds/self.dS

        @property
        def dS(self):
            return (self.Smax-self.barrier)/float(self.M)

让我们考虑一个敲出期权的例子。标的股票价格为 50 美元,波动率为 40%。期权的行权价为 50 美元,到期时间为五个月。无风险利率为 10%。敲出价格为 40 美元。

我们可以使用 Smax100M120N500 来定价看涨期权和敲出看跌期权:

In [ ]:
    option = FDCnDo(50, 50, r=0.1, T=5./12., 
        sigma=0.4, Sbarrier=40, Smax=100, M=120, N=500)
    print(option.price())
Out[ ]:   
    5.491560552934787
In [ ]:
    option = FDCnDo(50, 50, r=0.1, T=5./12., sigma=0.4, 
        Sbarrier=40, Smax=100, M=120, N=500, is_put=True)
    print(option.price())
Out[ ]:
   0.5413635028954452

敲出看涨期权和敲出看跌期权的价格分别为 5.4916 美元和 0.5414 美元。

使用有限差分定价美式期权

到目前为止,我们已经定价了欧式期权和奇异期权。由于美式期权中存在提前行权的可能性,因此定价此类期权并不那么直接。在隐式 Crank-Nicolson 方法中需要迭代过程,当前期内的提前行权收益要考虑先前期内的提前行权收益。在 Crank-Nicolson 方法中,建议使用高斯-西德尔迭代方法定价美式期权。

回顾一下,在第二章中,我们讨论了在金融中线性性的重要性,我们介绍了解决线性方程组的高斯-西德尔方法,形式为 Ax=B。在这里,矩阵 A 被分解为 A=L+U,其中 L 是下三角矩阵,U 是上三角矩阵。让我们看一个 4 x 4 矩阵 A 的例子:

然后通过迭代方式获得解决方案,如下所示:

我们可以将高斯-西德尔方法调整到我们的 Crank-Nicolson 实现中,如下所示:

这个方程满足提前行权特权方程:

使用有限差分的 Crank-Nicolson 方法定价美式期权的类

让我们创建一个名为 FDCnAm 的类,该类继承自 FDCnEu 类,后者是定价欧式期权的 Crank-Nicolson 方法的对应物。setup_coefficients 方法可以被重用,同时覆盖所有其他方法,以包括先前行权的收益,如果有的话。

__init__() 构造函数和 setup_boundary_conditions() 方法在 FDCnAm 类中给出:

In [ ]:
    import numpy as np
    import sys

    """ 
    Price an American option by the Crank-Nicolson method 
    """
    class FDCnAm(FDCnEu):

        def __init__(self, S0, K, r=0.05, T=1, 
                Smax=1, M=1, N=1, omega=1, tol=0, is_put=False):
            super(FDCnAm, self).__init__(S0, K, r=r, T=T, 
                sigma=sigma, Smax=Smax, M=M, N=N, is_put=is_put)
            self.omega = omega
            self.tol = tol
            self.i_values = np.arange(self.M+1)
            self.j_values = np.arange(self.N+1)

        def setup_boundary_conditions(self):
            if self.is_call:
                self.payoffs = np.maximum(0, 
                    self.boundary_conds[1:self.M]-self.K)
            else:
                self.payoffs = np.maximum(0, 
                    self.K-self.boundary_conds[1:self.M])

            self.past_values = self.payoffs
            self.boundary_values = self.K * np.exp(
                    -self.r*self.dt*(self.N-self.j_values))

接下来,在同一类中实现 traverse_grid() 方法:

def traverse_grid(self):
    """ Solve using linear systems of equations """
    aux = np.zeros(self.M-1)
    new_values = np.zeros(self.M-1)

    for j in reversed(range(self.N)):
        aux[0] = self.alpha[1]*(self.boundary_values[j] +
                                self.boundary_values[j+1])
        rhs = np.dot(self.M2, self.past_values) + aux
        old_values = np.copy(self.past_values)
        error = sys.float_info.max

        while self.tol < error:
            new_values[0] = \
                self.calculate_payoff_start_boundary(
                    rhs, old_values)    

            for k in range(self.M-2)[1:]:
                new_values[k] = \
                    self.calculate_payoff(
                        k, rhs, old_values, new_values)                  

            new_values[-1] = \
                self.calculate_payoff_end_boundary(
                    rhs, old_values, new_values)

            error = np.linalg.norm(new_values-old_values)
            old_values = np.copy(new_values)

        self.past_values = np.copy(new_values)

    self.values = np.concatenate(
        ([self.boundary_values[0]], new_values, [0]))

while 循环的每个迭代过程中,计算收益时要考虑开始和结束边界。此外,new_values 不断根据现有和先前的值进行新的收益计算替换。

在开始边界处,索引为 0 时,通过省略 alpha 值来计算收益。在类内实现 calculate_payoff_start_boundary() 方法:

 def calculate_payoff_start_boundary(self, rhs, old_values):
    payoff = old_values[0] + \
        self.omega/(1-self.beta[1]) * \
            (rhs[0] - \
             (1-self.beta[1])*old_values[0] + \
             self.gamma[1]*old_values[1])

    return max(self.payoffs[0], payoff)       

在结束边界处,最后一个索引时,通过省略 gamma 值来计算收益。在类内实现 calculate_payoff_end_boundary() 方法:

 def calculate_payoff_end_boundary(self, rhs, old_values, new_values):
    payoff = old_values[-1] + \
        self.omega/(1-self.beta[-2]) * \
            (rhs[-1] + \
             self.alpha[-2]*new_values[-2] - \
             (1-self.beta[-2])*old_values[-1])

    return max(self.payoffs[-1], payoff)

对于不在边界的收益,通过考虑 alpha 和 gamma 值来计算收益。在类内实现 calculate_payoff() 方法:

def calculate_payoff(self, k, rhs, old_values, new_values):
    payoff = old_values[k] + \
        self.omega/(1-self.beta[k+1]) * \
            (rhs[k] + \
             self.alpha[k+1]*new_values[k-1] - \
             (1-self.beta[k+1])*old_values[k] + \
             self.gamma[k+1]*old_values[k+1])

    return max(self.payoffs[k], payoff)

由于新变量 values 包含我们的终端收益值作为一维数组,因此重写父类的 interpolate 方法以考虑这一变化,使用以下代码:

def interpolate(self):
    # Use linear interpolation on final values as 1D array
    return np.interp(self.S0, self.boundary_conds, self.values)

容差参数用于高斯-西德尔方法的收敛标准。omega 变量是过度松弛参数。更高的 omega 值提供更快的收敛速度,但这也伴随着算法不收敛的可能性更高。

让我们定价一个标的资产价格为 50,波动率为 40%,行权价为 50,无风险利率为 10%,到期日为五个月的美式看涨期权和看跌期权。我们选择 Smax 值为 100M100N42omega 参数值为 1.2,容差值为 0.001

In [ ]:
    option = FDCnAm(50, 50, r=0.1, T=5./12., 
        sigma=0.4, Smax=100, M=100, N=42, omega=1.2, tol=0.001)
    print(option.price())
Out[ ]:
    6.108682815392217
In [ ]:
    option = FDCnAm(50, 50, r=0.1, T=5./12., sigma=0.4, Smax=100, 
        M=100, N=42, omega=1.2, tol=0.001, is_put=True)
    print(option.price())
Out[ ]:   
    4.277764229383736

使用 Crank-Nicolson 方法计算美式看涨和看跌期权的价格分别为 6.109 美元和 4.2778 美元。

将所有内容整合在一起-隐含波动率建模

到目前为止,我们学到的期权定价方法中,有一些参数被假定为常数:利率、行权价、股息和波动率。在这里,感兴趣的参数是波动率。在定量研究中,波动率比率被用来预测价格趋势。

要得出隐含波动率,我们需要参考第三章金融中的非线性,在那里我们讨论了非线性函数的根查找方法。在我们的下一个示例中,我们将使用数值程序的二分法来创建一个隐含波动率曲线。

AAPL 美式看跌期权的隐含波动率

让我们考虑股票苹果AAPL)的期权数据,这些数据是在 2014 年 10 月 3 日收集的。以下表格提供了这些细节。期权到期日为 2014 年 12 月 20 日。所列价格为买入价和卖出价的中间价:

行权价 看涨期权价格 看跌期权价格
75 30 0.16
80 24.55 0.32
85 20.1 0.6
90 15.37 1.22
92.5 10.7 1.77
95 8.9 2.54
97.5 6.95 3.55
100 5.4 4.8
105 4.1 7.75
110 2.18 11.8
115 1.05 15.96
120 0.5 20.75
125 0.26 25.8

AAPL 的最后交易价格为 99.62 美元,利率为 2.48%,股息率为 1.82%。美式期权在 78 天后到期。

利用这些信息,让我们创建一个名为ImpliedVolatilityModel的新类,它在构造函数中接受股票期权的参数。如果需要,导入我们在本章前面部分介绍的用于 LR 二项树的BinomialLROption类。还需要导入我们在第三章金融中的非线性中介绍的bisection函数。

option_valuation()方法接受K行权价和sigma波动率值,计算期权的价值。在这个例子中,我们使用BinomialLROption定价方法。

get_implied_volatilities()方法接受一个行权价和期权价格的列表,通过bisection方法计算每个价格的隐含波动率。因此,这两个列表的长度必须相同。

ImpliedVolatilityModel类的 Python 代码如下所示:

In [ ]:
    """
    Get implied volatilities from a Leisen-Reimer binomial
    tree using the bisection method as the numerical procedure.
    """
    class ImpliedVolatilityModel(object):

        def __init__(self, S0, r=0.05, T=1, div=0, 
                     N=1, is_put=False):
            self.S0 = S0
            self.r = r
            self.T = T
            self.div = div
            self.N = N
            self.is_put = is_put

        def option_valuation(self, K, sigma):
            """ Use the binomial Leisen-Reimer tree """
            lr_option = BinomialLROption(
                self.S0, K, r=self.r, T=self.T, N=self.N, 
                sigma=sigma, div=self.div, is_put=self.is_put
            )
            return lr_option.price()

        def get_implied_volatilities(self, Ks, opt_prices):
            impvols = []
            for i in range(len(strikes)):
                # Bind f(sigma) for use by the bisection method
                f = lambda sigma: \
                    self.option_valuation(Ks[i], sigma)-\
                    opt_prices[i]
                impv = bisection(f, 0.01, 0.99, 0.0001, 100)[0]
                impvols.append(impv)

            return impvols

导入我们在上一章讨论过的bisection函数:

In [ ]:
    def bisection(f, a, b, tol=0.1, maxiter=10):
        """
        :param f: The function to solve
        :param a: The x-axis value where f(a)<0
        :param b: The x-axis value where f(b)>0
        :param tol: The precision of the solution
        :param maxiter: Maximum number of iterations
        :return: The x-axis value of the root,
                    number of iterations used
        """
        c = (a+b)*0.5  # Declare c as the midpoint ab
        n = 1  # Start with 1 iteration
        while n <= maxiter:
            c = (a+b)*0.5
            if f(c) == 0 or abs(a-b)*0.5 < tol:
                # Root is found or is very close
                return c, n

            n += 1
            if f(c) < 0:
                a = c
            else:
                b = c

        return c, n

利用这个模型,让我们使用这组特定数据找出美式看跌期权的隐含波动率:

In [ ]:
    strikes = [75, 80, 85, 90, 92.5, 95, 97.5, 
               100, 105, 110, 115, 120, 125]
    put_prices = [0.16, 0.32, 0.6, 1.22, 1.77, 2.54, 3.55, 
                  4.8, 7.75, 11.8, 15.96, 20.75, 25.81]
In [ ]:
    model = ImpliedVolatilityModel(
        99.62, r=0.0248, T=78/365., div=0.0182, N=77, is_put=True)
    impvols_put = model.get_implied_volatilities(strikes, put_prices)

隐含波动率值现在以list对象的形式存储在impvols_put变量中。让我们将这些值绘制成隐含波动率曲线:

In [ ]:
    %matplotlib inline
    import matplotlib.pyplot as plt

    plt.plot(strikes, impvols_put)
    plt.xlabel('Strike Prices')
    plt.ylabel('Implied Volatilities')
    plt.title('AAPL Put Implied Volatilities expiring in 78 days')
    plt.show()

这将给我们提供波动率微笑,如下图所示。在这里,我们建立了一个包含 77 个步骤的 LR 树,每一步代表一天:

当然,每天定价一个期权可能并不理想,因为市场变化是以毫秒为单位的。我们使用了二分法来解决隐含波动率,这是由二项树隐含的,而不是直接从市场价格观察到的实现波动率值。

我们是否应该将这条曲线与多项式曲线拟合,以确定潜在的套利机会?或者推断曲线,以从远离实值和虚值期权的隐含波动率中获得更多见解?好吧,这些问题是供像你这样的期权交易员去发现的!

总结

在本章中,我们研究了衍生品定价中的一些数值程序,最常见的是期权。其中一种程序是使用树,二叉树是最简单的结构来建模资产信息,其中一个节点在每个时间步长延伸到另外两个节点,分别代表上升状态和下降状态。在三叉树中,每个节点在每个时间步长延伸到另外三个节点,分别代表上升状态、下降状态和无移动状态。随着树向上遍历,基础资产在每个节点处被计算和表示。然后期权采用这棵树的结构,并从期末回溯并向根部遍历,收敛到当前折现期权价格。除了二叉树和三叉树,树还可以采用 CRR、Jarrow-Rudd、Tian 或 LR 参数的形式。

通过在我们的树周围添加另一层节点,我们引入了额外的信息,从中我们可以推导出希腊字母,如 delta 和 gamma,而不会产生额外的计算成本。

晶格被引入是为了节省存储成本,相比二叉树和三叉树。在晶格定价中,只保存具有新信息的节点一次,并在以后需要不改变信息的节点上重复使用。

我们还讨论了期权定价中的有限差分方案,包括期末和边界条件。从期末条件开始,网格使用显式方法、隐式方法和 Crank-Nicolson 方法向后遍历时间。除了定价欧式和美式期权,有限差分定价方案还可以用于定价异国期权,我们看了一个定价下触及障碍期权的例子。

通过引入在第三章中学到的二分根查找方法,以及本章中的二叉 LR 树模型,我们使用美式期权的市场价格来创建隐含波动率曲线以进行进一步研究。

在下一章中,我们将研究利率和衍生品建模。

第五章:建模利率和衍生品

利率影响各个层面的经济活动。包括美联储(俗称为联邦储备系统)在内的中央银行将利率作为一种政策工具来影响经济活动。利率衍生品受到投资者的欢迎,他们需要定制的现金流需求或对利率变动的特定观点。

利率衍生品交易员面临的一个关键挑战是为这些产品制定一个良好而稳健的定价程序。这涉及理解单个利率变动的复杂行为。已经提出了几种利率模型用于金融研究。金融学中研究的一些常见模型是 Vasicek、CIR 和 Hull-White 模型。这些利率模型涉及对短期利率的建模,并依赖于因素(或不确定性来源),其中大多数只使用一个因素。已经提出了双因素和多因素利率模型。

在本章中,我们将涵盖以下主题:

  • 理解收益曲线

  • 估值零息债券

  • 引导收益曲线

  • 计算远期利率

  • 计算债券的到期收益率和价格

  • 使用 Python 计算债券久期和凸性

  • 短期利率建模

  • Vasicek 短期利率模型

  • 债券期权的类型

  • 定价可赎回债券期权

固定收益证券

公司和政府发行固定收益证券是为了筹集资金。这些债务的所有者借钱,并期望在债务到期时收回本金。希望借钱的发行人可能在债务寿命期间的预定时间发行固定金额的利息支付。

债务证券持有人,如美国国债、票据和债券,面临发行人违约的风险。联邦政府和市政府被认为面临最低的违约风险,因为他们可以轻松提高税收并创造更多货币来偿还未偿还的债务。

大多数债券每半年支付固定金额的利息,而有些债券每季度或每年支付。这些利息支付也被称为票息。它们以债券的面值或票面金额的百分比来报价,以年为单位。

例如,一张面值为 10,000 美元的 5 年期国库券,票面利率为 5%,每年支付 500 美元的票息,或者每 6 个月支付 250 美元的票息,直到到期日。如果利率下降,新的国库券只支付 3%的票息,那么新债券的购买者每年只会收到 300 美元的票息,而 5%债券的现有持有人将继续每年收到 500 美元的票息。由于债券的特性影响其价格,它们与当前利率水*呈反向关系:随着利率的上升,债券的价值下降。随着利率的下降,债券价格上升。

收益曲线

在正常的收益曲线环境中,长期利率高于短期利率。投资者希望在较长时间内借出资金时获得更高的回报,因为他们面临更高的违约风险。正常或正收益曲线被认为是向上倾斜的,如下图所示:

在某些经济条件下,收益曲线可能会倒挂。长期利率低于短期利率。当货币供应紧缩时会出现这种情况。投资者愿意放弃长期收益,以保护他们的短期财富。在通货膨胀高涨的时期,通货膨胀率超过票息利率时,可能会出现负利率。投资者愿意在短期内支付费用,只是为了保护他们的长期财富。倒挂的收益曲线被认为是向下倾斜的,如下图所示:

估值零息债券

零息债券是一种除到期日外不支付任何定期利息的债券,到期时偿还本金或面值。零息债券也被称为纯贴现债券

零息债券的价值可以如下计算:

在这里,y是债券的年复利收益率或利率,t是债券到期的剩余时间。

让我们来看一个面值 100 美元的五年期零息债券的例子。年收益率为 5%,年复利。价格可以如下计算:

一个简单的 Python 零息债券计算器可以用来说明这个例子:

In [ ]:
    def zero_coupon_bond(par, y, t):
        """
        Price a zero coupon bond.

        :param par: face value of the bond.
        :param y: annual yield or rate of the bond.
        :param t: time to maturity, in years.
        """
        return par/(1+y)**t

使用上述例子,我们得到以下结果:

In [ ]:
    print(zero_coupon_bond(100, 0.05, 5))
Out[ ]:
    78.35261664684589

在上述例子中,我们假设投资者能够以 5%的年利率在 5 年内年复利投资 78.35 美元。

现在我们有了一个零息债券计算器,我们可以用它通过抽靴法收益率曲线来确定零息率,如下一节所述。

即期和零息率

随着复利频率的增加(比如,从年复利到日复利),货币的未来价值达到了一个指数极限。也就是说,今天的 100 美元在以连续复利率R投资T时间后将达到未来价值 100e^(RT)。如果我们对一个在未来时间T支付 100 美元的证券进行贴现,使用连续复利贴现率R,其在时间零的价值为。这个利率被称为即期利率

即期利率代表了当前的利率,用于几种到期日,如果我们现在想要借款或出借资金。零息率代表了零息债券的内部收益率。

通过推导具有不同到期日的债券的即期利率,我们可以使用零息债券通过抽靴过程构建当前收益率曲线。

抽靴法收益率曲线

短期即期利率可以直接从各种短期证券(如零息债券、国库券、票据和欧元存款)中推导出来。然而,长期即期利率通常是通过一个抽靴过程从长期债券的价格中推导出来的,考虑到与票息支付日期相对应的到期日的即期利率。在获得短期和长期即期利率之后,就可以构建收益率曲线。

抽靴法收益率曲线的一个例子

让我们通过一个例子来说明抽靴法收益率曲线。以下表格显示了具有不同到期日和价格的债券列表:

债券面值(美元) 到期年限(年) 年息(美元) 债券现金价格(美元)
100 0.25 0 97.50
100 0.50 0 94.90
100 1.00 0 90.00
100 1.50 8 96.00
100 2.00 12 101.60

今天以 97.50 美元购买三个月的零息债券的投资者将获得 2.50 美元的利息。三个月的即期利率可以如下计算:

因此,3 个月的零息率是 10.127%,采用连续复利。零息债券的即期利率如下表所示:

到期年限(年) 即期利率(百分比)
0.25 10.127
0.50 10.469
1.00 10.536

使用这些即期利率,我们现在可以如下定价 1.5 年期债券:

y的值可以通过重新排列方程轻松求解,如下所示:

有了 1.5 年期债券的即期利率为 10.681%,我们可以使用它来定价 2 年期债券,年息为 6 美元,半年付息,如下所示:

重新排列方程并解出y,我们得到 2 年期债券的即期利率为 10.808。

通过这种迭代过程,按照到期日递增的顺序计算每个债券的即期利率,并在下一次迭代中使用它,我们得到了一个可以用来构建收益率曲线的不同到期日的即期利率列表。

编写收益率曲线引导类

编写 Python 代码引导收益率曲线并生成图表输出的步骤如下:

  1. 创建一个名为BootstrapYieldCurve的类,它将在 Python 代码中实现收益率曲线的引导:
import math

class BootstrapYieldCurve(object):    

    def __init__(self):
        self.zero_rates = dict()
        self.instruments = dict()
  1. 在构造函数中,声明了两个zero_ratesinstruments字典变量,并将被几种方法使用,如下所示:
  • 添加一个名为add_instrument()的方法,该方法将债券信息的元组附加到以到期时间为索引的instruments字典中。此方法的编写如下:
def add_instrument(self, par, T, coup, price, compounding_freq=2):
    self.instruments[T] = (par, coup, price, compounding_freq)
  • 添加一个名为get_maturities()的方法,它简单地按升序返回一个可用到期日列表。此方法的编写如下:
def get_maturities(self):
    """ 
    :return: a list of maturities of added instruments 
    """
    return sorted(self.instruments.keys())
  • 添加一个名为get_zero_rates()的方法,该方法对收益率曲线进行引导,计算沿该收益率曲线的即期利率,并按到期日升序返回零息率的列表。该方法的编写如下:
def get_zero_rates(self):
    """ 
    Returns a list of spot rates on the yield curve.
    """
    self.bootstrap_zero_coupons()    
    self.get_bond_spot_rates()
    return [self.zero_rates[T] for T in self.get_maturities()]
  • 添加一个名为bootstrap_zero_coupons()的方法,该方法计算给定零息债券的即期利率,并将其添加到以到期日为索引的zero_rates字典中。此方法的编写如下:
def bootstrap_zero_coupons(self):
    """ 
    Bootstrap the yield curve with zero coupon instruments first.
    """
    for (T, instrument) in self.instruments.items():
        (par, coup, price, freq) = instrument
        if coup == 0:
            spot_rate = self.zero_coupon_spot_rate(par, price, T)
            self.zero_rates[T] = spot_rate  
  • 添加一个名为zero_coupon_spot_rate()的方法,该方法计算零息债券的即期利率。此方法由bootstrap_zero_coupons()调用,并编写如下:
def zero_coupon_spot_rate(self, par, price, T):
    """ 
    :return: the zero coupon spot rate with continuous compounding.
    """
    spot_rate = math.log(par/price)/T
    return spot_rate
  • 添加一个名为get_bond_spot_rates()的方法,它计算非零息债券的即期利率,并将其添加到以到期日为索引的zero_rates字典中。此方法的编写如下:
def get_bond_spot_rates(self):
    """ 
    Get spot rates implied by bonds, using short-term instruments.
    """
    for T in self.get_maturities():
        instrument = self.instruments[T]
        (par, coup, price, freq) = instrument
        if coup != 0:
            spot_rate = self.calculate_bond_spot_rate(T, instrument)
            self.zero_rates[T] = spot_rate
  • 添加一个名为calculate_bond_spot_rate()的方法,该方法由get_bond_spot_rates()调用,用于计算特定到期期间的即期利率。此方法的编写如下:
def calculate_bond_spot_rate(self, T, instrument):
    try:
        (par, coup, price, freq) = instrument
        periods = T*freq
        value = price
        per_coupon = coup/freq
        for i in range(int(periods)-1):
            t = (i+1)/float(freq)
            spot_rate = self.zero_rates[t]
            discounted_coupon = per_coupon*math.exp(-spot_rate*t)
            value -= discounted_coupon

        last_period = int(periods)/float(freq)        
        spot_rate = -math.log(value/(par+per_coupon))/last_period
        return spot_rate
    except:
        print("Error: spot rate not found for T=", t)
  1. 实例化BootstrapYieldCurve类,并从前表中添加每个债券的信息:
In [ ]:
    yield_curve = BootstrapYieldCurve()
    yield_curve.add_instrument(100, 0.25, 0., 97.5)
    yield_curve.add_instrument(100, 0.5, 0., 94.9)
    yield_curve.add_instrument(100, 1.0, 0., 90.)
    yield_curve.add_instrument(100, 1.5, 8, 96., 2)
    yield_curve.add_instrument(100, 2., 12, 101.6, 2)
In [ ]:
    y = yield_curve.get_zero_rates()
    x = yield_curve.get_maturities()
  1. 在类实例中调用get_zero_rates()方法会返回一个即期利率列表,顺序与分别存储在xy变量中的到期日相同。发出以下 Python 代码以在图表上绘制xy
In [ ]:
    %pylab inline

    fig = plt.figure(figsize=(12, 8))
    plot(x, y)
    title("Zero Curve") 
    ylabel("Zero Rate (%)")
    xlabel("Maturity in Years");
  1. 我们得到以下的收益率曲线:

在正常的收益率曲线环境中,随着到期日的增加,利率也会增加,我们得到一个上升的收益率曲线。

远期利率

计划在以后投资的投资者可能会好奇知道未来的利率会是什么样子,这是由当今的利率期限结构所暗示的。例如,您可能会问,“一年后的一年期即期利率是多少?”为了回答这个问题,您可以使用以下公式计算T[1]T[2]之间的期间的远期利率:

在这里,r[1]r[2]分别是T[1]T[2]时期的连续复利年利率。

以下的ForwardRates类帮助我们从即期利率列表生成远期利率列表:

class ForwardRates(object):

    def __init__(self):
        self.forward_rates = []
        self.spot_rates = dict()

    def add_spot_rate(self, T, spot_rate):
        self.spot_rates[T] = spot_rate

    def get_forward_rates(self):
        """
        Returns a list of forward rates
        starting from the second time period.
        """
        periods = sorted(self.spot_rates.keys())
        for T2, T1 in zip(periods, periods[1:]):
            forward_rate = self.calculate_forward_rate(T1, T2)
            self.forward_rates.append(forward_rate)

        return self.forward_rates

    def calculate_forward_rate(self, T1, T2):
        R1 = self.spot_rates[T1]
        R2 = self.spot_rates[T2]
        forward_rate = (R2*T2-R1*T1)/(T2-T1)
        return forward_rate        

使用从前述收益率曲线派生的即期利率,我们得到以下结果:

In [ ]:
    fr = ForwardRates()
    fr.add_spot_rate(0.25, 10.127)
    fr.add_spot_rate(0.50, 10.469)
    fr.add_spot_rate(1.00, 10.536)
    fr.add_spot_rate(1.50, 10.681)
    fr.add_spot_rate(2.00, 10.808)
In [ ]:
    print(fr.get_forward_rates())
Out[ ]:
    [10.810999999999998, 10.603, 10.971, 11.189]

调用ForwardRates类的get_forward_rates()方法返回一个从下一个时间段开始的远期利率列表。

计算到期收益率

到期收益YTM)衡量了债券隐含的利率,考虑了所有未来票息支付和本金的现值。假设债券持有人可以以 YTM 利率投资收到的票息,直到债券到期;根据风险中性预期,收到的支付应与债券支付的价格相同。

让我们来看一个例子,一个 5.75%的债券,将在 1.5 年内到期,面值为 100。债券价格为 95.0428 美元,票息每半年支付一次。定价方程可以陈述如下:

这里:

  • c是每个时间段支付的票面金额

  • T是以年为单位的支付时间段

  • n是票息支付频率

  • y是我们感兴趣的 YTM 解决方案

解决 YTM 通常是一个复杂的过程,大多数债券 YTM 计算器使用牛顿法作为迭代过程。

债券 YTM 计算器由以下bond_ytm()函数说明:

import scipy.optimize as optimize

def bond_ytm(price, par, T, coup, freq=2, guess=0.05):
    freq = float(freq)
    periods = T*2
    coupon = coup/100.*par
    dt = [(i+1)/freq for i in range(int(periods))]
    ytm_func = lambda y: \
        sum([coupon/freq/(1+y/freq)**(freq*t) for t in dt]) +\
        par/(1+y/freq)**(freq*T) - price

    return optimize.newton(ytm_func, guess)

请记住,我们在第三章中介绍了牛顿法和其他非线性函数根求解器的使用,金融中的非线性。对于这个 YTM 计算器函数,我们使用了scipy.optimize包来解决 YTM。

使用债券示例的参数,我们得到以下结果:

In [ ] :
    ytm = bond_ytm(95.0428, 100, 1.5, 5.75, 2)
In [ ]:
    print(ytm)
Out[ ]:
    0.09369155345239522

债券的 YTM 为 9.369%。现在我们有一个债券 YTM 计算器,可以帮助我们比较债券的预期回报与其他证券的回报。

计算债券价格

当 YTM 已知时,我们可以以与使用定价方程相同的方式获得债券价格。这是由bond_price()函数实现的:

In [ ]:
    def bond_price(par, T, ytm, coup, freq=2):
        freq = float(freq)
        periods = T*2
        coupon = coup/100.*par
        dt = [(i+1)/freq for i in range(int(periods))]
        price = sum([coupon/freq/(1+ytm/freq)**(freq*t) for t in dt]) + \
            par/(1+ytm/freq)**(freq*T)
        return price

插入先前示例中的相同值,我们得到以下结果:

In [ ]:
    price = bond_price(100, 1.5, ytm, 5.75, 2)
    print(price)
Out[ ]:   
    95.04279999999997

这给我们了先前示例中讨论的相同原始债券价格,计算到期收益。使用bond_ytm()bond_price()函数,我们可以将这些应用于债券定价的进一步用途,例如查找债券的修改后持续时间和凸性。债券的这两个特征对于债券交易员来说非常重要,可以帮助他们制定各种交易策略并对冲风险。

债券持续时间

持续时间是债券价格对收益变化的敏感度度量。一些持续时间度量是有效持续时间,麦考利持续时间和修改后的持续时间。我们将讨论的持续时间类型是修改后的持续时间,它衡量了债券价格相对于收益变化的百分比变化(通常为 1%或 100 个基点bps))。

债券的持续时间越长,对收益变化的敏感度就越高。相反,债券的持续时间越短,对收益变化的敏感度就越低。

债券的修改后持续时间可以被认为是价格和收益之间关系的第一导数:

这里:

    • dY *是给定的收益变化
  • P^−是债券因* dY *减少而导致的价格

  • P^+是债券因* dY *增加而导致的价格

  • P[0]是债券的初始价格

应该注意,持续时间描述了Y的小变化对价格-收益关系的线性关系。由于收益曲线不是线性的,使用较大的dY不能很好地*似持续时间度量。

修改后的持续时间计算器的实现在以下bond_mod_duration()函数中给出。它使用了本章前面讨论的bond_ytm()函数,计算到期收益,来确定具有给定初始值的债券的收益。此外,它使用bond_price()函数来确定具有给定收益变化的债券的价格:

In [ ]:
    def bond_mod_duration(price, par, T, coup, freq, dy=0.01):
        ytm = bond_ytm(price, par, T, coup, freq)

        ytm_minus = ytm - dy    
        price_minus = bond_price(par, T, ytm_minus, coup, freq)

        ytm_plus = ytm + dy
        price_plus = bond_price(par, T, ytm_plus, coup, freq)

        mduration = (price_minus-price_plus)/(2*price*dy)
        return mduration

我们可以找出之前讨论的 5.75%债券的修改后持续时间,计算到期收益,它将在 1.5 年内到期,面值为 100,债券价格为 95.0428:

In [ ]:
    mod_duration = bond_mod_duration(95.0428, 100, 1.5, 5.75, 2)
In [ ]:
    print(mod_duration)
Out[ ]:
    1.3921935426561034

债券的修正久期为 1.392 年。

债券凸性

凸性是债券久期对收益率变化的敏感度度量。将凸性视为价格和收益率之间关系的二阶导数:

债券交易员使用凸性作为风险管理工具,以衡量其投资组合中的市场风险。相对于债券久期和收益率相同的低凸性投资组合,高凸性投资组合受利率波动的影响较小。因此,其他条件相同的情况下,高凸性债券比低凸性债券更昂贵。

债券凸性的实现如下:

In [ ]:
    def bond_convexity(price, par, T, coup, freq, dy=0.01):
        ytm = bond_ytm(price, par, T, coup, freq)

        ytm_minus = ytm - dy    
        price_minus = bond_price(par, T, ytm_minus, coup, freq)

        ytm_plus = ytm + dy
        price_plus = bond_price(par, T, ytm_plus, coup, freq)

        convexity = (price_minus + price_plus - 2*price)/(price*dy**2)
        return convexity

现在我们可以找到之前讨论的 5.75%债券的凸性,它将在 1.5 年后到期,票面价值为 100,债券价格为 95.0428:

In [ ]:
    convexity = bond_convexity(95.0428, 100, 1.5, 5.75, 2)
In [ ]:
    print(convexity)
Out[ ] :    
    2.633959390331875

债券的凸性为 2.63。对于两个具有相同票面价值、票息和到期日的债券,它们的凸性可能不同,这取决于它们在收益率曲线上的位置。相对于收益率的变化,高凸性债券的价格变化更大。

短期利率建模

在短期利率建模中,短期利率r(t)是特定时间的即期利率。它被描述为收益率曲线上无限短的时间内的连续复利化年化利率。短期利率在利率模型中采用随机变量的形式,其中利率可能在每个时间点上以微小的变化。短期利率模型试图模拟利率随时间的演变,并希望描述特定时期的经济状况。

短期利率模型经常用于评估利率衍生品。债券、信用工具、抵押贷款和贷款产品对利率变化敏感。短期利率模型被用作利率组成部分,结合定价实现,如数值方法,以帮助定价这些衍生品。

利率建模被认为是一个相当复杂的话题,因为利率受到多种因素的影响,如经济状态、政治决策、政府干预以及供求法则。已经提出了许多利率模型,以解释利率的各种特征。

在本节中,我们将研究金融研究中使用的一些最流行的一因素短期利率模型,即瓦西切克、考克斯-英格索尔-罗斯、伦德尔曼和巴特尔、布伦南和施瓦茨模型。使用 Python,我们将执行一条路径模拟,以获得对利率路径过程的一般概述。金融学中常讨论的其他模型包括何厉、赫尔-怀特和布莱克-卡拉辛基。

瓦西切克模型

在一因素瓦西切克模型中,短期利率被建模为单一随机因素:

在这里,Kθσ是常数,σ是瞬时标准差。W(t)是随机维纳过程。瓦西切克遵循奥恩斯坦-乌伦贝克过程,模型围绕均值θ回归,K是均值回归速度。因此,利率可能变为负值,这在大多数正常经济条件下是不希望的特性。

为了帮助我们理解这个模型,以下代码生成了一组利率:

In [ ]:
    import math
    import numpy as np

    def vasicek(r0, K, theta, sigma, T=1., N=10, seed=777):    
        np.random.seed(seed)
        dt = T/float(N)    
        rates = [r0]
        for i in range(N):
            dr = K*(theta-rates[-1])*dt + \
                sigma*math.sqrt(dt)*np.random.normal()
            rates.append(rates[-1]+dr)

        return range(N+1), rates

vasicek()函数返回瓦西切克模型的一组时间段和利率。它接受一些输入参数:r0t=0时的初始利率;Kthetasigma是常数;T是以年为单位的期间;N是建模过程的间隔数;seed是 NumPy 标准正态随机数生成器的初始化值。

假设当前利率接*零,为 0.5%,长期均值水*theta0.15,瞬时波动率sigma为 5%。我们将使用T值为10N值为200来模拟不同均值回归速度K的利率,使用值为0.0020.020.2

In [ ]:
    %pylab inline

    fig = plt.figure(figsize=(12, 8))

    for K in [0.002, 0.02, 0.2]:
        x, y = vasicek(0.005, K, 0.15, 0.05, T=10, N=200)
        plot(x,y, label='K=%s'%K)
        pylab.legend(loc='upper left');

    pylab.legend(loc='upper left')
    pylab.xlabel('Vasicek model');

运行前述命令后,我们得到以下图表:

在这个例子中,我们只运行了一个模拟,以查看 Vasicek 模型的利率是什么样子。请注意,利率在某个时候变为负值。当均值回归速度K较高时,该过程更快地达到其长期水* 0.15。

Cox-Ingersoll-Ross 模型

Cox-Ingersoll-RossCIR)模型是一个一因素模型,旨在解决 Vasicek 模型中发现的负利率。该过程如下:

术语随着短期利率的增加而增加标准差。现在vasicek()函数可以重写为 Python 中的 CIR 模型:

In [ ]:
    import math
    import numpy as np

    def CIR(r0, K, theta, sigma, T=1.,N=10,seed=777):        
        np.random.seed(seed)
        dt = T/float(N)    
        rates = [r0]
        for i in range(N):
            dr = K*(theta-rates[-1])*dt + \
                sigma*math.sqrt(rates[-1])*\
                math.sqrt(dt)*np.random.normal()
            rates.append(rates[-1] + dr)

        return range(N+1), rates

使用Vasicek 模型部分中给出的相同示例,假设当前利率为 0.5%,theta0.15sigma0.05。我们将使用T值为10N值为200来模拟不同均值回归速度K的利率,使用值为0.0020.020.2

In [ ] :
    %pylab inline

    fig = plt.figure(figsize=(12, 8))

    for K in [0.002, 0.02, 0.2]:
        x, y = CIR(0.005, K, 0.15, 0.05, T=10, N=200)
        plot(x,y, label='K=%s'%K)

    pylab.legend(loc='upper left')
    pylab.xlabel('CRR model');

以下是前述命令的输出:

请注意,CIR 利率模型没有负利率值。

Rendleman 和 Bartter 模型

在 Rendleman 和 Bartter 模型中,短期利率过程如下:

这里,瞬时漂移是θr(t),瞬时标准差为σr(t)。Rendleman 和 Bartter 模型可以被视为几何布朗运动,类似于对数正态分布的股价随机过程。该模型缺乏均值回归属性。均值回归是利率似乎被拉回到长期*均水*的现象。

以下 Python 代码模拟了 Rendleman 和 Bartter 的利率过程:

In [ ]:
    import math
    import numpy as np

    def rendleman_bartter(r0, theta, sigma, T=1.,N=10,seed=777):        
        np.random.seed(seed)
        dt = T/float(N)    
        rates = [r0]
        for i in range(N):
            dr = theta*rates[-1]*dt + \
                sigma*rates[-1]*math.sqrt(dt)*np.random.normal()
            rates.append(rates[-1] + dr)

        return range(N+1), rates

我们将继续使用前几节中的示例并比较模型。

假设当前利率为 0.5%,sigma0.05。我们将使用T值为10N值为200来模拟不同瞬时漂移theta的利率,使用值为0.010.050.1

In [ ]:
    %pylab inline

    fig = plt.figure(figsize=(12, 8))

    for theta in [0.01, 0.05, 0.1]:
        x, y = rendleman_bartter(0.005, theta, 0.05, T=10, N=200)
        plot(x,y, label='theta=%s'%theta)

    pylab.legend(loc='upper left')
    pylab.xlabel('Rendleman and Bartter model');

以下图表是前述命令的输出:

总的来说,该模型缺乏均值回归属性,并向长期*均水*增长。

Brennan 和 Schwartz 模型

Brennan 和 Schwartz 模型是一个双因素模型,其中短期利率向长期利率作为均值回归,也遵循随机过程。短期利率过程如下:

可以看出,Brennan 和 Schwartz 模型是几何布朗运动的另一种形式。

我们的 Python 代码现在可以这样实现:

In [ ]:
    import math
    import numpy as np

    def brennan_schwartz(r0, K, theta, sigma, T=1., N=10, seed=777):    
        np.random.seed(seed)
        dt = T/float(N)    
        rates = [r0]
        for i in range(N):
            dr = K*(theta-rates[-1])*dt + \
                sigma*rates[-1]*math.sqrt(dt)*np.random.normal()
            rates.append(rates[-1] + dr)

        return range(N+1), rates

假设当前利率保持在 0.5%,长期均值水*theta为 0.006。sigma0.05。我们将使用T值为10N值为200来模拟不同均值回归速度K的利率,使用值为0.20.020.002

In [ ]:
    %pylab inline

    fig = plt.figure(figsize=(12, 8))

    for K in [0.2, 0.02, 0.002]:
        x, y = brennan_schwartz(0.005, K, 0.006, 0.05, T=10, N=200)
        plot(x,y, label='K=%s'%K)

    pylab.legend(loc='upper left')
    pylab.xlabel('Brennan and Schwartz model');

运行前述命令后,我们将得到以下输出:

当 k 为 0.2 时,均值回归速度最快,达到长期均值 0.006。

债券期权

当债券发行人,如公司,发行债券时,他们面临的风险之一是利率风险。利率下降时,债券价格上升。现有债券持有人会发现他们的债券更有价值,而债券发行人则处于不利地位,因为他们将发行高于市场利率的利息支付。相反,当利率上升时,债券发行人处于有利地位,因为他们能够继续按债券合同规定的低利息支付。

为了利用利率变化,债券发行人可以在债券中嵌入期权。这使得发行人有权利,但没有义务,在特定时间段内以预定价格买入或卖出发行的债券。美式债券期权允许发行人在债券的任何时间内行使期权的权利。欧式债券期权允许发行人在特定日期行使期权的权利。行使日期的确切方式因债券期权而异。一些发行人可能选择在债券在市场上流通超过一年后行使债券期权的权利。一些发行人可能选择在几个特定日期中的一个上行使债券期权的权利。无论债券的行使日期如何,您可以按以下方式定价嵌入式期权的债券:

债券价格=没有期权的债券价格-嵌入式期权的价格

没有期权的债券定价相当简单:未来日期收到的债券的现值,包括所有票息支付。必须对未来的理论利率进行一些假设,以便将票息支付再投资。这样的假设可能是短期利率模型所暗示的利率变动,我们在前一节中介绍了短期利率建模。另一个假设可能是在二项或三项树中的利率变动。为简单起见,在债券定价研究中,我们将定价零息债券,这些债券在债券的寿命期间不发行票息。

要定价期权,必须确定可行的行使日期。从债券的未来价值开始,将债券价格与期权的行使价格进行比较,并使用数值程序(如二项树)回溯到现在的时间。这种价格比较是在债券期权可能行使的时间点进行的。根据无套利理论,考虑到行使时债券的现值超额值,我们得到了期权的价格。为简单起见,在本章后面的债券定价研究中,定价可赎回债券期权,我们将把零息债券的嵌入式期权视为美式期权。

可赎回债券

在利率较高的经济条件下,债券发行人可能面临利率下降的风险,并不得不继续发行高于市场利率的利息支付。因此,他们可能选择发行可赎回债券。可赎回债券包含一项嵌入式协议,约定在约定日期赎回债券。现有债券持有人被认为已经向债券发行人出售了一项认购期权。

如果利率下降,公司有权在特定价格回购债券的期间行使该选择权,他们可能会选择这样做。公司随后可以以较低的利率发行新债券。这也意味着公司能够以更高的债券价格形式筹集更多资本。

可回售债券

与可赎回债券不同,可赎回债券的持有人有权利,但没有义务,在一定期限内以约定价格将债券卖回给发行人。可赎回债券的持有人被认为是从债券发行人购买了一个认沽期权。当利率上升时,现有债券的价值变得更不值钱,可赎回债券持有人更有动力行使以更高行使价格出售债券的权利。由于可赎回债券对买方更有利而对发行人不利,它们通常比可赎回债券更少见。可赎回债券的变体可以在贷款和存款工具的形式中找到。向金融机构存入固定利率存款的客户在指定日期收到利息支付。他们有权随时提取存款。因此,固定利率存款工具可以被视为带有内嵌美式认沽期权的债券。

希望从银行借款的投资者签订贷款协议,在协议的有效期内进行利息支付,直到债务连同本金和约定利息全部偿还。银行可以被视为在债券上购买了一个看跌期权。在某些情况下,银行可能行使赎回贷款协议全部价值的权利。

因此,可赎回债券的价格可以如下所示:

可赎回债券的价格=无期权债券价格+认沽期权价格

可转换债券

公司发行的可转换债券包含一个内嵌期权,允许持有人将债券转换为一定数量的普通股。债券转换为股票的数量由转换比率定义,该比率被确定为使股票的美元金额与债券价值相同。

可转换债券与可赎回债券有相似之处。它们允许债券持有人在约定时间以约定的转换比率行使债券,换取相等数量的股票。可转换债券通常发行的票面利率低于不可转换债券,以补偿行使权利的附加价值。

当可转换债券持有人行使其股票权利时,公司的债务减少。另一方面,随着流通股数量的增加,公司的股票变得更加稀释,公司的股价预计会下跌。

随着公司股价的上涨,可转换债券价格往往会上涨。相反,随着公司股价的下跌,可转换债券价格往往会下跌。

优先股

优先股是具有债券特性的股票。优先股股东在普通股股东之前对股利支付具有优先权,通常作为其面值的固定百分比进行协商。虽然不能保证股利支付,但所有股利都优先支付给优先股股东而不是普通股股东。在某些优先股协议中,未按约支付的股利可能会累积直到以后支付。这些优先股被称为累积

优先股的价格通常与其普通股同步变动。它们可能具有与普通股股东相关的表决权。在破产情况下,优先股在清算时对其票面价值具有第一顺位权。

定价可赎回债券期权

在本节中,我们将研究定价可赎回债券。我们假设要定价的债券是一种带有内嵌欧式认购期权的零息支付债券。可赎回债券的价格可以如下所示:

可赎回债券的价格=无期权债券价格-认购期权价格

通过 Vasicek 模型定价零息债券

在时间t和当前利率r下,面值为 1 的零息债券的价值定义如下:

由于利率r总是在变化,我们将零息债券重写如下:

现在,利率r是一个随机过程,考虑从时间tT的债券价格,其中T是零息债券的到期时间。

为了对利率r进行建模,我们可以使用短期利率模型作为随机过程之一。为此,我们将使用 Vasicek 模型来对短期利率过程进行建模。

对于对数正态分布变量X的期望值如下:

对对数正态分布变量X的矩:

我们得到了对数正态分布变量的期望值,我们将在零息债券的利率过程中使用。

记住 Vasicek 短期利率过程模型:

然后,r(t)可以如下导出:

利用特征方程和 Vasicek 模型的利率变动,我们可以重写零息债券价格的期望值:

这里:

零息债券价格的 Python 实现在exact_zcb函数中给出:

In [ ]:
    import numpy as np
    import math

    def exact_zcb(theta, kappa, sigma, tau, r0=0.):
        B = (1 - np.exp(-kappa*tau)) / kappa
        A = np.exp((theta-(sigma**2)/(2*(kappa**2)))*(B-tau) - \
                   (sigma**2)/(4*kappa)*(B**2))
        return A * np.exp(-r0*B)

例如,我们有兴趣找出多种到期日的零息债券价格。我们使用 Vasicek 短期利率过程,theta值为0.5kappa值为0.02sigma值为0.03,初始利率r00.015进行建模。

将这些值代入exact_zcb函数,我们得到了从 0 到 25 年的时间段内以 0.5 年为间隔的零息债券价格,并绘制出图表:

In [ ]:    
    Ts = np.r_[0.0:25.5:0.5]
    zcbs = [exact_zcb(0.5, 0.02, 0.03, t, 0.015) for t in Ts]
In [ ]:
    %pylab inline

    fig = plt.figure(figsize=(12, 8))
    plt.title("Zero Coupon Bond (ZCB) Values by Time")
    plt.plot(Ts, zcbs, label='ZCB')
    plt.ylabel("Value ($)")
    plt.xlabel("Time in years")
    plt.legend()
    plt.grid(True)
    plt.show()

以下图表是上述命令的输出:

提前行使权的价值

可赎回债券的发行人可以按合同规定的约定价格赎回债券。为了定价这种债券,可以将折现的提前行使价值定义如下:

这里,k是行权价格与票面价值的价格比率,r是行权价格的利率。

然后,提前行使期权的 Python 实现可以写成如下形式:

In [ ]:
    import math

    def exercise_value(K, R, t):
        return K*math.exp(-R*t)

在上述示例中,我们有兴趣对行权比率为 0.95 且初始利率为 1.5%的认购期权进行定价。然后,我们可以将这些值作为时间函数绘制,并将它们叠加到零息债券价格的图表上,以更好地呈现零息债券价格与可赎回债券价格之间的关系:

In [ ]:
    Ts = np.r_[0.0:25.5:0.5]
    Ks = [exercise_value(0.95, 0.015, t) for t in Ts]
    zcbs = [exact_zcb(0.5, 0.02, 0.03, t, 0.015) for t in Ts]
In [ ]:
    import matplotlib.pyplot as plt

    fig = plt.figure(figsize=(12, 8))
    plt.title("Zero Coupon Bond (ZCB) and Strike (K) Values by Time")
    plt.plot(Ts, zcbs, label='ZCB')
    plt.plot(Ts, Ks, label='K', linestyle="--", marker=".")
    plt.ylabel("Value ($)")
    plt.xlabel("Time in years")
    plt.legend()
    plt.grid(True)
    plt.show()

以下是上述命令的输出:

从上述图表中,我们可以*似计算可赎回零息债券的价格。由于债券发行人拥有行权,可赎回零息债券的价格可以如下所述:

这种可赎回债券价格是一种*似值,考虑到当前的利率水*。下一步将是通过进行一种政策迭代来处理提前行使,这是一种用于确定最佳提前行使价值及其对其他节点的影响的循环,并检查它们是否到期提前行使。在实践中,这样的迭代只会发生一次。

通过有限差分的政策迭代

到目前为止,我们已经在我们的短期利率过程中使用了 Vasicek 模型来模拟零息债券。我们可以通过有限差分进行政策迭代,以检查提前行使条件及其对其他节点的影响。我们将使用有限差分的隐式方法进行数值定价程序,如第四章,期权定价的数值程序中所讨论的那样。

让我们创建一个名为VasicekCZCB的类,该类将包含用于实现 Vasicek 模型定价可赎回零息债券的所有方法。该类及其构造函数定义如下:

import math
import numpy as np
import scipy.stats as st

class VasicekCZCB:

    def __init__(self):
        self.norminv = st.distributions.norm.ppf
        self.norm = st.distributions.norm.cdf    

在构造函数中,norminvnormv变量对于所有需要计算 SciPy 的逆正态累积分布函数和正态累积分布函数的方法都是可用的。

有了这个基类,让我们讨论所需的方法并将它们添加到我们的类中:

  • 添加vasicek_czcb_values()方法作为开始定价过程的入口点。r0变量是时间t=0的短期利率;R是债券价格的零利率;ratio是债券的面值每单位的行权价格;T是到期时间;sigma是短期利率r的波动率;kappa是均值回归率;theta是短期利率过程的均值;M是有限差分方案中的步数;probvasicek_limits方法后用于确定短期利率的正态分布曲线上的概率;max_policy_iter是用于找到提前行使节点的最大政策迭代次数;grid_struct_const是确定calculate_N()方法中的Ndt移动的最大阈值;rs是短期利率过程遵循的利率列表。

该方法返回一列均匀间隔的短期利率和一列期权价格,写法如下:

def vasicek_czcb_values(self, r0, R, ratio, T, sigma, kappa, theta,
                        M, prob=1e-6, max_policy_iter=10, 
                        grid_struct_const=0.25, rs=None):
    (r_min, dr, N, dtau) = \
        self.vasicek_params(r0, M, sigma, kappa, theta,
                            T, prob, grid_struct_const, rs)
    r = np.r_[0:N]*dr + r_min
    v_mplus1 = np.ones(N)

    for i in range(1, M+1):
        K = self.exercise_call_price(R, ratio, i*dtau)
        eex = np.ones(N)*K
        (subdiagonal, diagonal, superdiagonal) = \
            self.vasicek_diagonals(
                sigma, kappa, theta, r_min, dr, N, dtau)
        (v_mplus1, iterations) = \
            self.iterate(subdiagonal, diagonal, superdiagonal,
                         v_mplus1, eex, max_policy_iter)
    return r, v_mplus1
  • 添加vasicek_params()方法来计算 Vasicek 模型的隐式方案参数。它返回一个元组r_mindrNdt。如果未向rs提供值,则r_minr_max的值将由vasicek_limits()方法自动生成,作为prob的正态分布函数的函数。该方法的写法如下:
def vasicek_params(self, r0, M, sigma, kappa, theta, T,
                  prob, grid_struct_const=0.25, rs=None):
    if rs is not None:
        (r_min, r_max) = (rs[0], rs[-1])
    else:
        (r_min, r_max) = self.vasicek_limits(
            r0, sigma, kappa, theta, T, prob)      

    dt = T/float(M)
    N = self.calculate_N(grid_struct_const, dt, sigma, r_max, r_min)
    dr = (r_max-r_min)/(N-1)

    return (r_min, dr, N, dt)

  • 添加calculate_N()方法,该方法由vasicek_params()方法使用,用于计算网格大小参数N。该方法的写法如下:
def calculate_N(self, max_structure_const, dt, sigma, r_max, r_min):
    N = 0
    while True:
        N += 1
        grid_structure_interval = \
            dt*(sigma**2)/(((r_max-r_min)/float(N))**2)
        if grid_structure_interval > max_structure_const:
            break
    return N

  • 添加vasicek_limits()方法来计算 Vasicek 利率过程的最小值和最大值,通过正态分布过程。Vasicek 模型下短期利率过程r(t)的期望值如下:

方差定义如下:

该方法返回一个元组,其中包括由正态分布过程的概率定义的最小和最大利率水*,写法如下:

def vasicek_limits(self, r0, sigma, kappa, theta, T, prob=1e-6):
    er = theta+(r0-theta)*math.exp(-kappa*T)
    variance = (sigma**2)*T if kappa==0 else \
                (sigma**2)/(2*kappa)*(1-math.exp(-2*kappa*T))
    stdev = math.sqrt(variance)
    r_min = self.norminv(prob, er, stdev)
    r_max = self.norminv(1-prob, er, stdev)
    return (r_min, r_max)
  • 添加vasicek_diagonals()方法,该方法返回有限差分隐式方案的对角线,其中:

边界条件是使用诺伊曼边界条件实现的。该方法的写法如下:

def vasicek_diagonals(self, sigma, kappa, theta, r_min,
                      dr, N, dtau):
    rn = np.r_[0:N]*dr + r_min
    subdiagonals = kappa*(theta-rn)*dtau/(2*dr) - \
                    0.5*(sigma**2)*dtau/(dr**2)
    diagonals = 1 + rn*dtau + sigma**2*dtau/(dr**2)
    superdiagonals = -kappa*(theta-rn)*dtau/(2*dr) - \
                    0.5*(sigma**2)*dtau/(dr**2)

    # Implement boundary conditions.
    if N > 0:
        v_subd0 = subdiagonals[0]
        superdiagonals[0] = superdiagonals[0]-subdiagonals[0]
        diagonals[0] += 2*v_subd0
        subdiagonals[0] = 0

    if N > 1:
        v_superd_last = superdiagonals[-1]
        superdiagonals[-1] = superdiagonals[-1] - subdiagonals[-1]
        diagonals[-1] += 2*v_superd_last
        superdiagonals[-1] = 0

    return (subdiagonals, diagonals, superdiagonals)

诺伊曼边界条件指定了给定常规或偏微分方程的边界。更多信息可以在mathworld.wolfram.com/NeumannBoundaryConditions.html找到。

  • 添加check_exercise()方法,返回一个布尔值列表,指示建议从提前行使中获得最佳回报的索引。该方法的写法如下:
def check_exercise(self, V, eex):
    return V > eex
  • 添加exercise_call_price()方法,该方法返回折现值的行权价比率,写法如下:
def exercise_call_price(self, R, ratio, tau):
    K = ratio*np.exp(-R*tau)
    return K
  • 添加vasicek_policy_diagonals()方法,该方法被政策迭代过程调用,用于更新一个迭代的子对角线、对角线和超对角线。在进行早期行权的索引中,子对角线和超对角线的值将被设置为 0,对角线上的剩余值。该方法返回新的子对角线、对角线和超对角线值的逗号分隔值。该方法的写法如下:
 def vasicek_policy_diagonals(self, subdiagonal, diagonal, \
                             superdiagonal, v_old, v_new, eex):
    has_early_exercise = self.check_exercise(v_new, eex)
    subdiagonal[has_early_exercise] = 0
    superdiagonal[has_early_exercise] = 0
    policy = v_old/eex
    policy_values = policy[has_early_exercise]
    diagonal[has_early_exercise] = policy_values
    return (subdiagonal, diagonal, superdiagonal)
  • 添加iterate()方法,该方法通过执行政策迭代来实现有限差分的隐式方案,其中每个周期都涉及解决三对角方程组,调用vasicek_policy_diagonals()方法来更新三个对角线,并在没有更多早期行权机会时返回可赎回零息债券价格。它还返回执行的政策迭代次数。该方法的写法如下:
def iterate(self, subdiagonal, diagonal, superdiagonal,
            v_old, eex, max_policy_iter=10):
    v_mplus1 = v_old
    v_m = v_old
    change = np.zeros(len(v_old))
    prev_changes = np.zeros(len(v_old))

    iterations = 0
    while iterations <= max_policy_iter:
        iterations += 1

        v_mplus1 = self.tridiagonal_solve(
                subdiagonal, diagonal, superdiagonal, v_old)
        subdiagonal, diagonal, superdiagonal = \
            self.vasicek_policy_diagonals(
                subdiagonal, diagonal, superdiagonal, 
                v_old, v_mplus1, eex)

        is_eex = self.check_exercise(v_mplus1, eex)
        change[is_eex] = 1

        if iterations > 1:
            change[v_mplus1 != v_m] = 1

        is_no_more_eex = False if True in is_eex else True
        if is_no_more_eex:
            break

        v_mplus1[is_eex] = eex[is_eex]
        changes = (change == prev_changes)

        is_no_further_changes = all((x == 1) for x in changes)
        if is_no_further_changes:
            break

        prev_changes = change
        v_m = v_mplus1

    return v_mplus1, iterations-1
  • 添加tridiagonal_solve()方法,该方法实现了 Thomas 算法来解决三对角方程组。方程组可以写成如下形式:

这个方程可以用矩阵形式表示:

这里,a是子对角线的列表,b是对角线的列表,c是矩阵的超对角线。

Thomas 算法是一个矩阵算法,用于使用简化的高斯消元法解决三对角方程组。更多信息可以在faculty.washington.edu/finlayso/ebook/algebraic/advanced/LUtri.htm找到。

tridiagonal_solve()方法的写法如下:

def tridiagonal_solve(self, a, b, c, d):
    nf = len(a)  # Number of equations
    ac, bc, cc, dc = map(np.array, (a, b, c, d))  # Copy the array
    for it in range(1, nf):
        mc = ac[it]/bc[it-1]
        bc[it] = bc[it] - mc*cc[it-1] 
        dc[it] = dc[it] - mc*dc[it-1]

    xc = ac
    xc[-1] = dc[-1]/bc[-1]

    for il in range(nf-2, -1, -1):
        xc[il] = (dc[il]-cc[il]*xc[il+1])/bc[il]

    del bc, cc, dc  # Delete variables from memory

    return xc

有了这些定义的方法,我们现在可以运行我们的代码,并使用 Vasicek 模型定价可赎回零息债券。

假设我们使用以下参数运行此模型:r00.05R0.05ratio0.95sigma0.03kappa0.15theta0.05prob1e-6M250max_policy_iter10grid_struc_interval0.25,我们对 0%到 2%之间的利率感兴趣。

以下 Python 代码演示了这个模型的 1 年、5 年、7 年、10 年和 20 年到期的情况:

In [ ]:
    r0 = 0.05
    R = 0.05
    ratio = 0.95
    sigma = 0.03
    kappa = 0.15
    theta = 0.05
    prob = 1e-6
    M = 250
    max_policy_iter=10
    grid_struct_interval = 0.25
    rs = np.r_[0.0:2.0:0.1]
In [ ]:
    vasicek = VasicekCZCB()
    r, vals = vasicek.vasicek_czcb_values(
        r0, R, ratio, 1., sigma, kappa, theta, 
        M, prob, max_policy_iter, grid_struct_interval, rs)
In [ ]:
    %pylab inline

    fig = plt.figure(figsize=(12, 8))
    plt.title("Callable Zero Coupon Bond Values by r")
    plt.plot(r, vals, label='1 yr')

    for T in [5., 7., 10., 20.]:
        r, vals = vasicek.vasicek_czcb_values(
            r0, R, ratio, T, sigma, kappa, theta, 
            M, prob, max_policy_iter, grid_struct_interval, rs)
        plt.plot(r, vals, label=str(T)+' yr', linestyle="--", marker=".")

    plt.ylabel("Value ($)")
    plt.xlabel("r")
    plt.legend()
    plt.grid(True)
    plt.show()

运行上述命令后,您应该得到以下输出:

我们得到了各种到期日和各种利率下可赎回零息债券的理论价值。

可赎回债券定价的其他考虑

在定价可赎回零息债券时,我们使用 Vasicek 利率过程来模拟利率的变动,借助正态分布过程。在Vasicek 模型部分,我们演示了 Vasicek 模型可以产生负利率,这在大多数经济周期中可能不太实际。定量分析师通常在衍生品定价中使用多个模型以获得现实的结果。CIR 和 Hull-White 模型是金融研究中常讨论的模型之一。这些模型的限制在于它们只涉及一个因素,或者说只有一个不确定性来源。

我们还研究了有限差分的隐式方案,用于早期行权的政策迭代。另一种考虑方法是有限差分的 Crank-Nicolson 方法。其他方法包括蒙特卡洛模拟来校准这个模型。

最后,我们得到了一份短期利率和可赎回债券价格的最终清单。为了推断特定短期利率的可赎回债券的公*价值,需要对债券价格清单进行插值。通常使用线性插值方法。其他考虑的插值方法包括三次和样条插值方法。

总结

在本章中,我们专注于使用 Python 进行利率和相关衍生品定价。大多数债券,如美国国债,每半年支付固定利息,而其他债券可能每季度或每年支付。债券的一个特点是它们的价格与当前利率水*密切相关,但是呈现出相反的关系。正常或正斜率的收益曲线,即长期利率高于短期利率,被称为向上倾斜。在某些经济条件下,收益曲线可能会倒挂,被称为向下倾斜。

零息债券是一种在其存续期内不支付利息的债券,只有在到期时偿还本金或面值时才支付。我们用 Python 实现了一个简单的零息债券计算器。

收益曲线可以通过零息债券、国债、票据和欧元存款的短期零点利率推导出来,使用引导过程。我们使用 Python 使用大量债券信息来绘制收益曲线,并从收益曲线中推导出远期利率、到期收益率和债券价格。

对债券交易员来说,两个重要的指标是久期和凸性。久期是债券价格对收益率变化的敏感度度量。凸性是债券久期对收益率变化的敏感度度量。我们在 Python 中实现了使用修正久期模型和凸性计算器进行计算。

短期利率模型经常用于评估利率衍生品。利率建模是一个相当复杂的话题,因为它们受到诸多因素的影响,如经济状态、政治决策、政府干预以及供求法则。已经提出了许多利率模型来解释利率的各种特征。我们讨论的一些利率模型包括 Vasicek、CIR 和 Rendleman 和 Bartter 模型。

债券发行人可能在债券中嵌入期权,以使他们有权利(但非义务)在规定的时间内以预定价格购买或出售发行的债券。可赎回债券的价格可以被视为不带期权的债券价格与嵌入式认购期权价格之间的价格差异。我们使用 Python 来通过有限差分的隐式方法来定价可赎回的零息债券,应用了 Vasicek 模型。然而,这种方法只是量化分析师在债券期权建模中使用的众多方法之一。

在下一章中,我们将讨论时间序列数据的统计分析。

第六章:时间序列数据的统计分析

在金融投资组合中,其组成资产的回报取决于许多因素,如宏观和微观经济条件以及各种金融变量。随着因素数量的增加,建模投资组合行为所涉及的复杂性也在增加。鉴于计算资源是有限的,再加上时间限制,为新因素进行额外计算只会增加投资组合建模计算的瓶颈。一种用于降维的线性技术是主成分分析(PCA)。正如其名称所示,PCA 将投资组合资产价格的变动分解为其主要成分或共同因素,以进行进一步的统计分析。不能解释投资组合资产变动很多的共同因素在其因素中获得较少的权重,并且通常被忽略。通过保留最有用的因素,可以大大简化投资组合分析,而不会影响计算时间和空间成本。

在时间序列数据的统计分析中,数据保持*稳对于避免虚假回归是很重要的。非*稳数据可能由受趋势影响的基础过程、季节效应、单位根的存在或三者的组合产生。非*稳数据的统计特性,如均值和方差,会随时间变化。非*稳数据需要转换为*稳数据,以便进行统计分析以产生一致和可靠的结果。这可以通过去除趋势和季节性成分来实现。然后可以使用*稳数据进行预测或预测。

在本章中,我们将涵盖以下主题:

  • 对道琼斯及其 30 个成分进行主成分分析

  • 重建道琼斯指数

  • 理解*稳和非*稳数据之间的区别

  • 检查数据的*稳性

  • *稳和非*稳过程的类型

  • 使用增广迪基-富勒检验来检验单位根的存在

  • 通过去趋势化、差分和季节性分解制作*稳数据

  • 使用自回归积分移动*均法进行时间序列预测和预测

道琼斯工业*均指数及其 30 个成分

道琼斯工业*均指数(DJIA)是由 30 家最大的美国公司组成的股票市场指数。通常被称为道琼斯,它由 S&P 道琼斯指数有限责任公司拥有,并以价格加权的方式计算(有关道琼斯的更多信息,请参见us.spindices.com/index-family/us-equity/dow-jones-averages)。

本节涉及将道琼斯及其成分的数据集下载到pandas DataFrame 对象中,以供本章后续部分使用。

从 Quandl 下载道琼斯成分数据集

以下代码从 Quandl 检索道琼斯成分数据集。我们将使用的数据提供者是 WIKI Prices,这是一个由公众成员组成的社区,向公众免费提供数据集。这些数据并非没有错误,因此请谨慎使用。在撰写本文时,该数据源不再受到 Quandl 社区的积极支持,尽管过去的数据集仍可供使用。我们将下载 2017 年的历史每日收盘价:

In [ ]:
    import quandl

    QUANDL_API_KEY = 'BCzkk3NDWt7H9yjzx-DY'  # Your own Quandl key here
    quandl.ApiConfig.api_key = QUANDL_API_KEY

    SYMBOLS = [
        'AAPL','MMM', 'AXP', 'BA', 'CAT',
        'CVX', 'CSCO', 'KO', 'DD', 'XOM',
        'GS', 'HD', 'IBM', 'INTC', 'JNJ',
        'JPM', 'MCD', 'MRK', 'MSFT', 'NKE',
        'PFE', 'PG', 'UNH', 'UTX', 'TRV', 
        'VZ', 'V', 'WMT', 'WBA', 'DIS',
    ]

    wiki_symbols = ['WIKI/%s'%symbol for symbol in SYMBOLS]
    df_components = quandl.get(
        wiki_symbols, 
        start_date='2017-01-01', 
        end_date='2017-12-31', 
        column_index=11)
    df_components.columns = SYMBOLS  # Renaming the columns

wiki_symbols变量包含我们用于下载的 Quandl 代码列表。请注意,在quandl.get()的参数中,我们指定了column_index=11。这告诉 Quandl 仅下载每个数据集的第 11 列,这与调整后的每日收盘价相符。数据集以单个pandas DataFrame 对象的形式下载到我们的df_components变量中。

让我们在分析之前对数据集进行归一化处理:

In [ ]:
    filled_df_components = df_components.fillna(method='ffill')
    daily_df_components = filled_df_components.resample('24h').ffill()
    daily_df_components = daily_df_components.fillna(method='bfill')

如果您检查这个数据源中的每个值,您会注意到NaN值或缺失数据。由于我们使用的是容易出错的数据,并且为了快速研究 PCA,我们可以通过传播先前观察到的值临时填充这些未知变量。fillna(method='ffill')方法有助于执行此操作,并将结果存储在filled_df_components变量中。

标准化的另一个步骤是以固定间隔重新取样时间序列,并将其与我们稍后将要下载的道琼斯时间序列数据集完全匹配。daily_df_components变量存储了按日重新取样时间序列的结果,重新取样期间的任何缺失值都使用向前填充方法传播。最后,为了解决起始数据不完整的问题,我们将简单地使用fillna(method='bfill')对值进行回填。

为了展示 PCA 的目的,我们必须使用免费的低质量数据集。如果您需要高质量的数据集,请考虑订阅数据发布商。

Quandl 不提供道琼斯工业*均指数的免费数据集。在下一节中,我们将探索另一个名为 Alpha Vantage 的数据提供商,作为下载数据集的替代方法。

关于 Alpha Vantage

Alpha Vantage (www.alphavantage.co)是一个数据提供商,提供股票、外汇和加密货币的实时和历史数据。与 Quandl 类似,您可以获得 Alpha Vantage REST API 接口的 Python 包装器,并直接将免费数据集下载到pandas DataFrame 中。

获取 Alpha Vantage API 密钥

从您的网络浏览器访问www.alphavantage.co,并从主页点击立即获取您的免费 API 密钥。您将被带到注册页面。填写关于您自己的基本信息并提交表单。您的 API 密钥将显示在同一页。复制此 API 密钥以在下一节中使用:

安装 Alpha Vantage Python 包装器

从您的终端窗口,输入以下命令以安装 Alpha Vantage 的 Python 模块:

$ pip install alpha_vantage

从 Alpha Vantage 下载道琼斯数据集

以下代码连接到 Alpha Vantage 并下载道琼斯数据集,股票代码为^DJI。用您自己的 API 密钥替换常量变量ALPHA_VANTAGE_API_KEY的值:

In [ ]:
    """
    Download the all-time DJIA dataset
    """
    from alpha_vantage.timeseries import TimeSeries

    # Update your Alpha Vantage API key here...
    ALPHA_VANTAGE_API_KEY = 'PZ2ISG9CYY379KLI'

    ts = TimeSeries(key=ALPHA_VANTAGE_API_KEY, output_format='pandas')
    df, meta_data = ts.get_daily_adjusted(symbol='^DJI', outputsize='full')

alpha_vantage.timeseries模块的TimeSeries类是用 API 密钥实例化的,并指定数据集自动下载为pandas DataFrame 对象。get_daily_adjusted()方法使用outputsize='full'参数下载给定股票符号的整个可用每日调整价格,并将其存储在df变量中作为DataFrame对象。

让我们使用info()命令检查一下这个 DataFrame:

In [ ]:
    df.info()
Out[ ]:
    <class 'pandas.core.frame.DataFrame'>
    Index: 4760 entries, 2000-01-03 to 2018-11-30
    Data columns (total 8 columns):
    1\. open                 4760 non-null float64
    2\. high                 4760 non-null float64
    3\. low                  4760 non-null float64
    4\. close                4760 non-null float64
    5\. adjusted close       4760 non-null float64
    6\. volume               4760 non-null float64
    7\. dividend amount      4760 non-null float64
    8\. split coefficient    4760 non-null float64
    dtypes: float64(8)
    memory usage: 316.1+ KB

我们从 Alpha Vantage 下载的道琼斯数据集提供了从最*可用交易日期一直回到 2000 年的完整时间序列数据。它包含几列给我们额外的信息。

让我们也检查一下这个 DataFrame 的索引:

In [ ]:
    df.index
Out[ ]:
    Index(['2000-01-03', '2000-01-04', '2000-01-05', '2000-01-06', '2000-01-07',
           '2000-01-10', '2000-01-11', '2000-01-12', '2000-01-13', '2000-01-14',
           ...
           '2018-08-17', '2018-08-20', '2018-08-21', '2018-08-22', '2018-08-23',
           '2018-08-24', '2018-08-27', '2018-08-28', '2018-08-29', '2018-08-30'],
          dtype='object', name='date', length=4696)

输出表明索引值由字符串类型的对象组成。让我们将这个 DataFrame 转换为适合我们分析的形式:

In [ ]:
    import pandas as pd

    # Prepare the dataframe
    df_dji = pd.DataFrame(df['5\. adjusted close'])
    df_dji.columns = ['DJIA']
    df_dji.index = pd.to_datetime(df_dji.index)

    # Trim the new dataframe and resample
    djia_2017 = pd.DataFrame(df_dji.loc['2017-01-01':'2017-12-31'])
    djia_2017 = djia_2017.resample('24h').ffill()

在这里,我们正在获取 2017 年道琼斯的调整收盘价,按日重新取样。结果的 DataFrame 对象存储在djia_2017中,我们可以用它来应用 PCA。

应用核 PCA

在本节中,我们将执行核 PCA 以找到特征向量和特征值,以便我们可以重建道琼斯指数。

寻找特征向量和特征值

我们可以使用 Python 的sklearn.decomposition模块的KernelPCA类执行核 PCA。默认的核方法是线性的。在 PCA 中使用的数据集需要被标准化,我们可以使用 z-scoring 来实现。以下代码执行此操作:

In [ ]:
    from sklearn.decomposition import KernelPCA

    fn_z_score = lambda x: (x - x.mean()) / x.std()

    df_z_components = daily_df_components.apply(fn_z_score)
    fitted_pca = KernelPCA().fit(df_z_components)

fn_z_score变量是一个内联函数,用于对pandas DataFrame 执行 z 得分,该函数使用apply()方法应用。这些归一化的数据集可以使用fit()方法拟合到核 PCA 中。每日道琼斯成分价格的拟合结果存储在fitted_pca变量中,该变量是相同的KernelPCA对象。

PCA 的两个主要输出是特征向量和特征值。特征向量是包含主成分线方向的向量,当应用线性变换时不会改变。特征值是标量值,指示数据在特定特征向量方向上的方差量。实际上,具有最高特征值的特征向量形成主成分。

KernelPCA对象的alphas_lambdas_属性返回中心化核矩阵数据集的特征向量和特征值。当我们绘制特征值时,我们得到以下结果:

In [ ]:
    %matplotlib inline
    import matplotlib.pyplot as plt

    plt.rcParams['figure.figsize'] = (12,8)
    plt.plot(fitted_pca.lambdas_)
    plt.ylabel('Eigenvalues')
    plt.show();

然后我们应该得到以下输出:

我们可以看到,前几个特征值解释了数据中的大部分方差,并且在后面的成分中变得更加忽略。获取前五个特征值,让我们看看每个特征值给我们提供了多少解释:

In [ ]:
    fn_weighted_avg = lambda x: x / x.sum()
    weighted_values = fn_weighted_avg(fitted_pca.lambdas_)[:5]
In [ ]:
    print(weighted_values)
Out[ ]:
    array([0.64863002, 0.13966718, 0.05558246, 0.05461861, 0.02313883])

我们可以看到,第一个成分解释了数据方差的 65%,第二个成分解释了 14%,依此类推。将这些值相加,我们得到以下结果:

In [ ]:
    weighted_values.sum()
Out[ ]:
    0.9216371041932268

前五个特征值将解释数据集方差的 92%。

使用 PCA 重建道琼斯指数

默认情况下,KernelPCA实例化时使用n_components=None参数,这将构建一个具有非零成分的核 PCA。我们还可以创建一个具有五个成分的 PCA 指数:

In [ ]:
    import numpy as np

    kernel_pca = KernelPCA(n_components=5).fit(df_z_components)
    pca_5 = kernel_pca.transform(-daily_df_components)

    weights = fn_weighted_avg(kernel_pca.lambdas_)
    reconstructed_values = np.dot(pca_5, weights)

    # Combine DJIA and PCA index for comparison
    df_combined = djia_2017.copy()
    df_combined['pca_5'] = reconstructed_values
    df_combined = df_combined.apply(fn_z_score)
    df_combined.plot(figsize=(12, 8));

使用fit()方法,我们使用具有五个成分的线性核 PCA 函数拟合了归一化数据集。transform()方法使用核 PCA 转换原始数据集。这些值使用由特征向量指示的权重进行归一化,通过点矩阵乘法计算。然后,我们使用copy()方法创建了道琼斯时间序列pandas DataFrame 的副本,并将其与df_combined DataFrame 中的重建值组合在一起。

新的 DataFrame 通过 z 得分进行归一化,并绘制出来,以查看重建的 PCA 指数跟踪原始道琼斯运动的情况。这给我们以下输出:

上面的图显示了 2017 年原始道琼斯指数与重建的道琼斯指数相比,使用了五个主成分。

*稳和非*稳时间序列

对于进行统计分析的时间序列数据,重要的是数据是*稳的,以便正确进行统计建模,因为这样的用途可能是用于预测和预测。本节介绍了时间序列数据中的*稳性和非*稳性的概念。

*稳性和非*稳性

在经验时间序列研究中,观察到价格变动向某些长期均值漂移,要么向上,要么向下。*稳时间序列是其统计特性(如均值、方差和自相关)随时间保持恒定的时间序列。相反,非*稳时间序列数据的观察结果其统计特性随时间变化,很可能是由于趋势、季节性、存在单位根或三者的组合。

在时间序列分析中,假设基础过程的数据是*稳的。否则,对非*稳数据进行建模可能会产生不可预测的结果。这将导致一种称为伪回归的情况。伪回归是指产生误导性的统计证据,表明独立的非*稳变量之间存在关系的回归。为了获得一致和可靠的结果,非*稳数据需要转换为*稳数据。

检查*稳性

有多种方法可以检查时间序列数据是*稳还是非*稳:

  • 通过可视化:您可以查看时间序列图,以明显指示趋势或季节性。

  • 通过统计摘要:您可以查看数据的统计摘要,寻找显著差异。例如,您可以对时间序列数据进行分组,并比较每组的均值和方差。

  • 通过统计检验:您可以使用统计检验,如增广迪基-富勒检验,来检查是否满足或违反了*稳性期望。

非*稳过程的类型

以下几点有助于识别时间序列数据中的非*稳行为,以便考虑转换为*稳数据:

  • 纯随机游走:具有单位根或随机趋势的过程。这是一个非均值回归的过程,其方差随时间演变并趋于无穷大。

  • 带漂移的随机游走:具有随机游走和恒定漂移的过程。

  • 确定性趋势:均值围绕着固定的趋势增长的过程,该趋势是恒定的且与时间无关。

  • 带漂移和确定性趋势的随机游走:将随机游走与漂移分量和确定性趋势结合的过程。

*稳过程的类型

以下是时间序列研究中可能遇到的*稳性定义:

  • *稳过程:生成*稳观测序列的过程。

  • 趋势*稳:不呈现趋势的过程。

  • 季节性*稳:不呈现季节性的过程。

  • 严格*稳:也称为强*稳。当随机变量的无条件联合概率分布在时间(或x轴上)移动时不发生变化的过程。

  • 弱*稳:也称为协方差*稳二阶*稳。当随机变量的均值、方差和相关性在时间移动时不发生变化的过程。

增广迪基-富勒检验

增广迪基-富勒检验ADF)是一种统计检验,用于确定时间序列数据中是否存在单位根。单位根可能会导致时间序列分析中的不可预测结果。对单位根检验形成零假设,以确定时间序列数据受趋势影响的程度。通过接受零假设,我们接受时间序列数据是非*稳的证据。通过拒绝零假设,或接受备择假设,我们接受时间序列数据是由*稳过程生成的证据。这个过程也被称为趋势*稳。增广迪基-富勒检验统计量的值为负数。较低的 ADF 值表示更强烈地拒绝零假设。

以下是用于 ADF 测试的一些基本自回归模型:

  • 没有常数和趋势:

  • 没有常数和趋势:

  • 带有常数和趋势:

这里,α是漂移常数,β是时间趋势的系数,γ是我们的假设系数,p是一阶差分自回归过程的滞后阶数,ϵ[t]是独立同分布的残差项。当α=0β=0时,模型是一个随机游走过程。当β=0时,模型是一个带漂移的随机游走过程。滞后阶数p的选择应使得残差不具有序列相关性。一些选择滞后阶数的信息准则的方法包括最小化阿卡信息准则AIC)、贝叶斯信息准则BIC)和汉南-奎恩信息准则

然后可以将假设表述如下:

  • 零假设,H[0]:如果未能被拒绝,表明时间序列包含单位根并且是非*稳的

  • 备择假设,H[1]:如果拒绝H[0],则表明时间序列不包含单位根并且是*稳的

为了接受或拒绝零假设,我们使用 p 值。如果 p 值低于 5%甚至 1%的阈值,我们拒绝零假设。如果 p 值高于此阈值,我们可能未能拒绝零假设,并将时间序列视为非*稳的。换句话说,如果我们的阈值为 5%或 0.05,请注意以下内容:

  • p 值> 0.05:我们未能拒绝零假设H[0],并得出结论,数据具有单位根并且是非*稳的

  • p 值≤0.05:我们拒绝零假设H[0],并得出结论,数据具有单位根并且是非*稳的

statsmodels库提供了实现此测试的adfuller()函数。

分析具有趋势的时间序列

让我们检查一个时间序列数据集。例如,考虑在芝加哥商品交易所交易的黄金期货价格。在 Quandl 上,可以通过以下代码下载黄金期货连续合约:CHRIS/CME_GC1。这些数据由维基连续期货社区小组策划,仅考虑了最*的合约。数据集的第六列包含了结算价格。以下代码从 2000 年开始下载数据集:

In [ ]:
    import quandl

    QUANDL_API_KEY = 'BCzkk3NDWt7H9yjzx-DY'  # Your Quandl key here
    quandl.ApiConfig.api_key = QUANDL_API_KEY

    df = quandl.get(
        'CHRIS/CME_GC1', 
        column_index=6,
        collapse='monthly',
        start_date='2000-01-01')

使用以下命令检查数据集的头部:

In [ ]:
    df.head()

我们得到以下表格:

Settle Date
2000-01-31 283.2
2000-02-29 294.2
2000-03-31 278.4
2000-04-30 274.7
2000-05-31 271.7

将滚动均值和标准差计算到df_meandf_std变量中,窗口期为一年:

In [ ] :
    df_settle = df['Settle'].resample('MS').ffill().dropna()

    df_rolling = df_settle.rolling(12)
    df_mean = df_rolling.mean()
    df_std = df_rolling.std()

resample()方法有助于确保数据在月度基础上*滑,并且ffill()方法向前填充任何缺失值。

可以在pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html找到用于指定resample()方法的常见有用时间序列频率列表.

让我们可视化滚动均值与原始时间序列的图表:

In [ ] :
    plt.figure(figsize=(12, 8))
    plt.plot(df_settle, label='Original')
    plt.plot(df_mean, label='Mean')
    plt.legend();

我们获得以下输出:

将滚动标准差可视化分开,我们得到以下结果:

In [ ] :
    df_std.plot(figsize=(12, 8));

我们获得以下输出:

使用statsmodels模块,用adfuller()方法对我们的数据集进行 ADF 单位根检验:

In [ ]:
    from statsmodels.tsa.stattools import adfuller

    result = adfuller(df_settle)
    print('ADF statistic: ',  result[0])
    print('p-value:', result[1])

    critical_values = result[4]
    for key, value in critical_values.items():
        print('Critical value (%s): %.3f' % (key, value))
Out[ ]:
    ADF statistic:  -1.4017828015895548
    p-value: 0.5814211232134314
    Critical value (1%): -3.461
    Critical value (5%): -2.875
    Critical value (10%): -2.574

adfuller()方法返回一个包含七个值的元组。特别地,我们对第一个、第二个和第五个值感兴趣,它们分别给出了检验统计量、p 值和临界值字典。

从图表中可以观察到,均值和标准差随时间波动,均值呈现总体上升趋势。ADF 检验统计值大于临界值(特别是在 5%时),p-value大于 0.05。基于这些结果,我们无法拒绝存在单位根的原假设,并认为我们的数据是非*稳的。

使时间序列*稳

非*稳时间序列数据可能受到趋势或季节性的影响。趋势性时间序列数据的均值随时间不断变化。受季节性影响的数据在特定时间间隔内有变化。在使时间序列数据*稳时,必须去除趋势和季节性影响。去趋势、差分和分解就是这样的方法。然后得到的*稳数据适合进行统计预测。

让我们详细看看所有三种方法。

去趋势

从非*稳数据中去除趋势线的过程称为去趋势。这涉及一个将大值归一化为小值的转换步骤。例如可以是对数函数、*方根函数,甚至是立方根。进一步的步骤是从移动*均值中减去转换值。

让我们对相同的数据集df_settle执行去趋势,使用对数变换并从两个周期的移动*均值中减去,如下 Python 代码所示:

In [ ]:
    import numpy as np

    df_log = np.log(df_settle)
In [ ]:
    df_log_ma= df_log.rolling(2).mean()
    df_detrend = df_log - df_log_ma
    df_detrend.dropna(inplace=True)

    # Mean and standard deviation of detrended data
    df_detrend_rolling = df_detrend.rolling(12)
    df_detrend_ma = df_detrend_rolling.mean()
    df_detrend_std = df_detrend_rolling.std()

    # Plot
    plt.figure(figsize=(12, 8))
    plt.plot(df_detrend, label='Detrended')
    plt.plot(df_detrend_ma, label='Mean')
    plt.plot(df_detrend_std, label='Std')
    plt.legend(loc='upper right');

df_log变量是我们使用numpy模块的对数函数转换的pandas DataFrame,df_detrend变量包含去趋势数据。我们绘制这些去趋势数据,以可视化其在滚动一年期间的均值和标准差。

我们得到以下输出:

观察到均值和标准差没有表现出长期趋势。

观察去趋势数据的 ADF 检验统计量,我们得到以下结果:

In [ ]:
    from statsmodels.tsa.stattools import adfuller

    result = adfuller(df_detrend)
    print('ADF statistic: ', result[0])
    print('p-value: %.5f' % result[1])

    critical_values = result[4]
    for key, value in critical_values.items():
        print('Critical value (%s): %.3f' % (key, value))
Out[ ]:
    ADF statistic:  -17.04239232215001
    p-value: 0.00000
    Critical value (1%): -3.460
    Critical value (5%): -2.874
    Critical value (10%): -2.574

这个去趋势数据的p-value小于 0.05。我们的 ADF 检验统计量低于所有临界值。我们可以拒绝原假设,并说这个数据是*稳的。

通过差分去除趋势

差分涉及将时间序列值与时间滞后进行差分。时间序列的一阶差分由以下公式给出:

我们可以重复使用前一节中的df_log变量作为我们的对数转换时间序列,并利用 NumPy 模块的diff()shift()方法进行差分,代码如下:

In [ ]:
    df_log_diff = df_log.diff(periods=3).dropna()

    # Mean and standard deviation of differenced data
    df_diff_rolling = df_log_diff.rolling(12)
    df_diff_ma = df_diff_rolling.mean()
    df_diff_std = df_diff_rolling.std()

    # Plot the stationary data
    plt.figure(figsize=(12, 8))
    plt.plot(df_log_diff, label='Differenced')
    plt.plot(df_diff_ma, label='Mean')
    plt.plot(df_diff_std, label='Std')
    plt.legend(loc='upper right');

diff()的参数periods=3表示在计算差异时数据集向后移动三个周期。

这提供了以下输出:

从图表中可以观察到,滚动均值和标准差随时间变化很少。

观察我们的 ADF 检验统计量,我们得到以下结果:

In [ ]:
    from statsmodels.tsa.stattools import adfuller

    result = adfuller(df_log_diff)

    print('ADF statistic:', result[0])
    print('p-value: %.5f' % result[1])

    critical_values = result[4]
    for key, value in critical_values.items():
        print('Critical value (%s): %.3f' % (key, value))
Out[ ]:
    ADF statistic: -2.931684356800213
    p-value: 0.04179
    Critical value (1%): -3.462
    Critical value (5%): -2.875
    Critical value (10%): -2.574

从 ADF 检验中,此数据的p-value小于 0.05。我们的 ADF 检验统计量低于 5%的临界值,表明此数据在 95%的置信水*下是*稳的。我们可以拒绝原假设,并说这个数据是*稳的。

季节性分解

分解涉及对趋势和季节性进行建模,然后将它们移除。我们可以使用statsmodel.tsa.seasonal模块来使用移动*均模型非*稳时间序列数据,并移除其趋势和季节性成分。

通过重复使用包含先前部分数据集对数的df_log变量,我们得到以下结果:

In [ ]:
    from statsmodels.tsa.seasonal import seasonal_decompose

    decompose_result = seasonal_decompose(df_log.dropna(), freq=12)

    df_trend = decompose_result.trend
    df_season = decompose_result.seasonal
    df_residual = decompose_result.resid

statsmodels.tsa.seasonalseasonal_decompose()方法需要一个参数freq,它是一个整数值,指定每个季节周期的周期数。由于我们使用的是月度数据,我们期望每个季节年有 12 个周期。该方法返回一个对象,主要包括趋势和季节分量,以及最终的pandas系列数据,其趋势和季节分量已被移除。

有关statsmodels.tsa.seasonal模块的seasonal_decompose()方法的更多信息可以在www.statsmodels.org/dev/generated/statsmodels.tsa.seasonal.seasonal_decompose.html找到。

通过运行以下 Python 代码来可视化不同的图表:

In [ ]:
    plt.rcParams['figure.figsize'] = (12, 8)
    fig = decompose_result.plot()

我们得到以下图表:

在这里,我们可以看到单独的趋势和季节分量从数据集中被移除并绘制,残差在底部绘制。让我们可视化一下我们的残差的统计特性:

In [ ]:
    df_log_diff = df_residual.diff().dropna()

    # Mean and standard deviation of differenced data
    df_diff_rolling = df_log_diff.rolling(12)
    df_diff_ma = df_diff_rolling.mean()
    df_diff_std = df_diff_rolling.std()

    # Plot the stationary data
    plt.figure(figsize=(12, 8))
    plt.plot(df_log_diff, label='Differenced')
    plt.plot(df_diff_ma, label='Mean')
    plt.plot(df_diff_std, label='Std')
    plt.legend();

我们得到以下图表:

从图表中观察到,滚动均值和标准差随时间变化很少。

通过检查我们的残差数据的*稳性,我们得到以下结果:

In [ ]:
    from statsmodels.tsa.stattools import adfuller    

    result = adfuller(df_residual.dropna())

    print('ADF statistic:',  result[0])
    print('p-value: %.5f' % result[1])

    critical_values = result[4]
    for key, value in critical_values.items():
        print('Critical value (%s): %.3f' % (key, value))
Out[ ]:
    ADF statistic: -6.468683205304995
    p-value: 0.00000
    Critical value (1%): -3.463
    Critical value (5%): -2.876
    Critical value (10%): -2.574

从 ADF 测试中,这些数据的p-value小于 0.05。我们的 ADF 测试统计量低于所有临界值。我们可以拒绝零假设,并说这些数据是*稳的。

ADF 测试的缺点

在使用 ADF 测试可靠检查非*稳数据时,有一些考虑事项:

  • ADF 测试不能真正区分纯单元根生成过程和非单元根生成过程。在长期移动*均过程中,ADF 测试在拒绝零假设方面存在偏差。其他检验*稳性的方法,如Kwiatkowski–Phillips–Schmidt–ShinKPSS)检验和Phillips-Perron检验,采用了不同的方法来处理单位根的存在。

  • 在确定滞后长度p时没有固定的方法。如果p太小,剩余误差中的序列相关性可能会影响检验的大小。如果p太大,检验的能力将会下降。对于这个滞后阶数,还需要进行额外的考虑。

  • 随着确定性项被添加到测试回归中,单位根测试的能力减弱。

时间序列的预测和预测

在上一节中,我们确定了时间序列数据中的非*稳性,并讨论了使时间序列数据*稳的技术。有了*稳的数据,我们可以进行统计建模,如预测和预测。预测涉及生成样本内数据的最佳估计。预测涉及生成样本外数据的最佳估计。预测未来值是基于先前观察到的值。其中一个常用的方法是自回归积分移动*均法。

关于自回归积分移动*均

自回归积分移动*均ARIMA)是基于线性回归的*稳时间序列的预测模型。顾名思义,它基于三个组件:

  • 自回归AR):使用观察和滞后值之间的依赖关系的模型

  • 积分I):使用差分观察和以前时间戳的观察来使时间序列*稳

  • 移动*均MA):使用观察误差项和先前误差项的组合之间的依赖关系的模型,e[t]

ARIMA 模型的标记是ARIMA(p, d, q),对应于三个组件的参数。可以通过改变pdq的值来指定非季节性 ARIMA 模型,如下所示:

  • ARIMA(p,0,0): 一阶自回归模型,用AR(p)表示。p是滞后阶数,表示模型中滞后观察值的数量。例如,ARIMA(2,0,0)AR(2),表示如下:

在这里,ϕ[1]ϕ[2]是模型的参数。

  • ARIMA(0,d,0): 整合分量中的一阶差分,也称为随机游走,用I(d)表示。d是差分的程度,表示数据被减去过去值的次数。例如,ARIMA(0,1,0)I(1),表示如下:

在这里,μ是季节差分的均值。

  • ARIMA(0,0,q):移动*均分量,用MA(q)表示。阶数q决定了模型中要包括的项数:

通过网格搜索找到模型参数

网格搜索,也称为超参数优化方法,可用于迭代地探索不同的参数组合,以拟合我们的 ARIMA 模型。我们可以在每次迭代中使用statsmodels模块的SARIMAX()函数拟合季节性 ARIMA 模型,返回一个MLEResults类的对象。MLEResults对象具有一个aic属性,用于返回 AIC 值。具有最低 AIC 值的模型为我们提供了最佳拟合模型,确定了我们的pdq参数。有关 SARIMAX 的更多信息,请访问www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html

我们将网格搜索过程定义为arima_grid_search()函数,如下所示:

In [ ]:
    import itertools    
    import warnings
    from statsmodels.tsa.statespace.sarimax import SARIMAX

    warnings.filterwarnings("ignore")

    def arima_grid_search(dataframe, s):
        p = d = q = range(2)
        param_combinations = list(itertools.product(p, d, q))
        lowest_aic, pdq, pdqs = None, None, None
        total_iterations = 0
        for order in param_combinations:    
            for (p, q, d) in param_combinations:
                seasonal_order = (p, q, d, s)
                total_iterations += 1
                try:
                    model = SARIMAX(df_settle, order=order, 
                        seasonal_order=seasonal_order, 
                        enforce_stationarity=False,
                        enforce_invertibility=False,
                        disp=False
                    )
                    model_result = model.fit(maxiter=200, disp=False)

                    if not lowest_aic or model_result.aic < lowest_aic:
                        lowest_aic = model_result.aic
                        pdq, pdqs = order, seasonal_order

                except Exception as ex:
                    continue

        return lowest_aic, pdq, pdqs 

我们的变量df_settle保存了我们在上一节中下载的期货数据的月度价格。在SARIMAX(具有外生回归器的季节性自回归整合移动*均模型)函数中,我们提供了seasonal_order参数,这是ARIMA(p,d,q,s)季节性分量,其中s是数据集中一个季节的周期数。由于我们使用的是月度数据,我们使用 12 个周期来定义季节模式。enforce_stationarity=False参数不会将 AR 参数转换为强制模型的 AR 分量。enforce_invertibility=False参数不会将 MA 参数转换为强制模型的 MA 分量。disp=False参数在拟合模型时抑制输出信息。

定义了网格函数后,我们现在可以使用我们的月度数据调用它,并打印出具有最低 AIC 值的模型参数:

In [ ]:
    lowest_aic, order, seasonal_order = arima_grid_search(df_settle, 12)
In [ ]:
    print('ARIMA{}x{}'.format(order, seasonal_order))
    print('Lowest AIC: %.3f'%lowest_aic)
Out[ ]:
    ARIMA(0, 1, 1)x(0, 1, 1, 12)
    Lowest AIC: 2149.636

ARIMA(0,1,1,12)季节性分量模型将在 2149.636 的 AIC 值处得到最低值。我们将使用这些参数在下一节中拟合我们的 SARIMAX 模型。

拟合 SARIMAX 模型

获得最佳模型参数后,使用summary()方法检查拟合结果的模型属性,以查看详细的统计信息:

In [ ]:
    model = SARIMAX(
        df_settle,
        order=order,
        seasonal_order=seasonal_order,
        enforce_stationarity=False,
        enforce_invertibility=False,
        disp=False
    )

    model_results = model.fit(maxiter=200, disp=False)
    print(model_results.summary())

这给出了以下输出:

                                 Statespace Model Results                                 
==========================================================================================
Dep. Variable:                             Settle   No. Observations:                  226
Model:             SARIMAX(0, 1, 1)x(0, 1, 1, 12)   Log Likelihood               -1087.247
Date:                            Sun, 02 Dec 2018   AIC                           2180.495
Time:                                    17:38:32   BIC                           2190.375
Sample:                                02-01-2000   HQIC                          2184.494
                                     - 11-01-2018                                         
Covariance Type:                              opg                                         
==============================================================================
                 coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
ma.L1         -0.1716      0.044     -3.872      0.000      -0.258      -0.085
ma.S.L12      -1.0000    447.710     -0.002      0.998    -878.496     876.496
sigma2      2854.6342   1.28e+06      0.002      0.998    -2.5e+06    2.51e+06
===================================================================================
Ljung-Box (Q):                       67.93   Jarque-Bera (JB):                52.74
Prob(Q):                              0.00   Prob(JB):                         0.00
Heteroskedasticity (H):               6.98   Skew:                            -0.34
Prob(H) (two-sided):                  0.00   Kurtosis:                         5.43
===================================================================================

Warnings:
[1] Covariance matrix calculated using the outer product of gradients (complex-step).

重要的是要运行模型诊断,以调查模型假设是否被违反:

In [ ]:
    model_results.plot_diagnostics(figsize=(12, 8));

我们得到以下输出:

右上角的图显示了标准化残差的核密度估计KDE),这表明误差服从均值接*于零的高斯分布。让我们看一下残差的更准确的统计信息:

In [ ] :
    model_results.resid.describe()
Out[ ]:
   count    223.000000
    mean       0.353088
    std       57.734027
    min     -196.799109
    25%      -22.036234
    50%        3.500942
    75%       22.872743
    max      283.200000
    dtype: float64

从残差的描述中,非零均值表明预测可能存在正向偏差。

预测和预测 SARIMAX 模型

model_results变量是statsmodel模块的SARIMAXResults对象,代表 SARIMAX 模型的输出。它包含一个get_prediction()方法,用于执行样本内预测和样本外预测。它还包含一个conf_int()方法,返回预测的置信区间,包括拟合参数的下限和上限,默认情况下为 95%置信区间。让我们应用这些方法:

In [ ]:
    n = len(df_settle.index)
    prediction = model_results.get_prediction(
        start=n-12*5, 
        end=n+5
    )
    prediction_ci = prediction.conf_int()

get_prediction()方法中的start参数表示我们正在对最*五年的价格进行样本内预测。同时,使用end参数,我们正在对接下来的五个月进行样本外预测。

通过检查前三个预测的置信区间值,我们得到以下结果:

In [ ]:
    print(prediction_ci.head(3))
Out[ ]:
                lower Settle  upper Settle
    2017-09-01   1180.143917   1396.583325
    2017-10-01   1204.307842   1420.747250
    2017-11-01   1176.828881   1393.268289

让我们将预测和预测的价格与我们的原始数据集从 2008 年开始进行对比:

In  [ ]:
    plt.figure(figsize=(12, 6))

    ax = df_settle['2008':].plot(label='actual')
    prediction_ci.plot(
        ax=ax, style=['--', '--'],
        label='predicted/forecasted')

    ci_index = prediction_ci.index
    lower_ci = prediction_ci.iloc[:, 0]
    upper_ci = prediction_ci.iloc[:, 1]

    ax.fill_between(ci_index, lower_ci, upper_ci,
        color='r', alpha=.1)

    ax.set_xlabel('Time (years)')
    ax.set_ylabel('Prices')

    plt.legend()
    plt.show()

这给我们以下输出:

实线图显示了观察值,而虚线图显示了五年滚动预测,紧密跟随并受到阴影区域内的置信区间的限制。请注意,随着接下来五个月的预测进入未来,置信区间扩大以反映对前景的不确定性。

摘要

在本章中,我们介绍了 PCA 作为投资组合建模中的降维技术。通过将投资组合资产价格的波动分解为其主要成分或共同因素,可以保留最有用的因素,并且可以大大简化投资组合分析,而不会影响计算时间和空间复杂性。通过使用sklearn.decomposition模块的KernelPCA函数将 PCA 应用于道琼指数及其 30 个成分,我们获得了特征向量和特征值,用于用五个成分重构道琼指数。

在时间序列数据的统计分析中,数据被视为*稳或非*稳。*稳的时间序列数据是其统计特性随时间保持不变的数据。非*稳的时间序列数据其统计特性随时间变化,很可能是由于趋势、季节性、存在单位根或三者的组合。从非*稳数据建模可能产生虚假回归。为了获得一致和可靠的结果,非*稳数据需要转换为*稳数据。

我们使用统计检验,如 ADF,来检查是否满足或违反了*稳性期望。statsmodels.tsa.stattools模块的adfuller方法提供了检验统计量、p 值和临界值,从中我们可以拒绝零假设,即数据具有单位根且是非*稳的。

我们通过去趋势化、差分和季节性分解将非*稳数据转换为*稳数据。通过使用 ARIMA,我们使用statsmodels.tsa.statespace.sarimax模块的SARIMAX函数拟合模型,以找到通过迭代网格搜索过程给出最低 AIC 值的合适模型参数。拟合结果用于预测和预测。

在下一章中,我们将使用 VIX 进行交互式金融分析。

第三部分:实践方法

在本部分中,我们将应用第 1 部分Python 入门和第 2 部分金融概念中涵盖的理论概念,构建完全功能的工作系统。

本部分将包括以下章节:

  • [第七章],使用 VIX 进行交互式金融分析

  • [第八章],构建算法交易*台

  • [第九章],实施回测系统

  • [第十章],金融的机器学习

  • [第十一章],金融的深度学习

第七章:与 VIX 一起进行交互式金融分析

投资者使用波动率衍生品来分散和对冲他们在股票和信用组合中的风险。由于股票基金的长期投资者面临下行风险,波动率可以用作尾部风险的对冲和看涨期权的替代品。在美国,芝加哥期权交易所(CBOE)的波动率指数(VIX),或简称 VIX,衡量了具有*均到期日为 30 天的标准普尔 500 股票指数期权隐含的短期波动率。世界各地许多人使用 VIX 来衡量未来 30 天的股票市场波动性。在欧洲,等效的波动率对应指标是 EURO STOXX 50 波动率(VSTOXX)市场指数。对于利用 S&P 500 指数的基准策略,其与 VIX 的负相关性的性质提供了一种可行的方式来避免基准再*衡成本。波动性的统计性质允许交易员执行均值回归策略、离散交易和波动性价差交易等策略。

在本章中,我们将看看如何对 VIX 和 S&P 500 指数进行数据分析。使用 S&P 500 指数期权,我们可以重建 VIX 并将其与观察值进行比较。这里呈现的代码在 Jupyter Notebook 上运行,这是 Python 的交互式组件,可以帮助我们可视化数据并研究它们之间的关系。

在本章中,我们将讨论以下主题:

  • 介绍 EURO STOXX 50 指数、VSTOXX 和 VIX

  • 对 S&P 500 指数和 VIX 进行金融分析

  • 根据 CBOE VIX 白皮书逐步重建 VIX 指数

  • 寻找 VIX 指数的*期和次期期权

  • 确定期权数据集的行权价边界

  • 通过行权价对 VIX 的贡献进行制表

  • 计算*期和次期期权的远期水*

  • 计算*期和次期期权的波动率值

  • 同时计算多个 VIX 指数

  • 将计算出的指数结果与实际的标准普尔 500 指数进行比较

波动率衍生品

全球最受欢迎的两个波动率指数是 VIX 和 VSTOXX,分别在美国和欧洲可用。VIX 基于标准普尔 500 指数,在 CBOE 上发布。虽然 VIX 本身不进行交易,但 VIX 的衍生产品,如期权、期货、交易所交易基金和一系列基于波动性的证券可供投资者选择。CBOE 网站提供了许多期权和市场指数的全面信息,如标准普尔 500 标准和周期期权,以及我们可以分析的 VIX。在本章的后面部分,我们将首先了解这些产品的背景,然后进行金融分析。

STOXX 和 Eurex

在美国,标准普尔 500 指数是最广泛关注的股票市场指数之一,由标准普尔道琼斯指数创建。在欧洲,STOXX 有限公司是这样一家公司。

成立于 1997 年,STOXX 有限公司总部位于瑞士苏黎世,在全球计算大约 7000 个指数。作为一个指数提供商,它开发、维护、分发和推广一系列严格基于规则和透明的指数。

STOXX 在这些类别提供了许多股票指数:基准指数、蓝筹股指数、股息指数、规模指数、行业指数、风格指数、优化指数、策略指数、主题指数、可持续性指数、信仰指数、智能贝塔指数和计算产品。

Eurex 交易所是德国法兰克福的衍生品交易所,提供超过 1900 种产品,包括股票指数、期货、期权、交易所交易基金、股息、债券和回购。STOXX 的许多产品和衍生品在 Eurex 上交易。

EURO STOXX 50 指数

由 STOXX 有限公司设计,EURO STOXX 50 指数是全球最流动的股票指数之一,服务于 Eurex 上列出的许多指数产品。它于 1998 年 2 月 26 日推出,由来自奥地利、比利时、芬兰、法国、德国、希腊、爱尔兰、意大利、卢森堡、荷兰、葡萄牙和西班牙的 50 只蓝筹股组成。EURO STOXX 50 指数期货和期权合约可在 Eurex 交易所上交易。指数每 15 秒基于实时价格重新计算一次。

EURO STOXX 50 指数的股票代码是 SX5E。EURO STOXX 50 指数期权的股票代码是 OESX。

VSTOXX

VSTOXX 或 EURO STOXX 50 波动率是由 Eurex 交易所提供服务的一类波动率衍生品。VSTOXX 市场指数基于一篮子 OESX 报价的*价或虚价。它衡量了未来 30 天在 EURO STOXX 50 指数上的隐含市场波动率。

投资者利用波动率衍生品进行基准策略,利用 EURO STOXX 50 指数的负相关性,可以避免基准再*衡成本。波动率的统计性质使交易员能够执行均值回归策略、离散交易和波动率价差交易等。指数每 5 秒重新计算一次。

VSTOXX 的股票代码是 V2TX。基于 VSTOXX 指数的 VSTOXX 期权和 VSTOXX 迷你期货在 Eurex 交易所交易。

标普 500 指数

标普 500 指数(SPX)的历史可以追溯到 1923 年,当时它被称为综合指数。最初,它跟踪了少量股票。1957 年,跟踪的股票数量扩大到 500 只,并成为了 SPX。

构成 SPX 的股票在纽约证券交易所(NYSE)或全国证券经纪人自动报价系统(NASDAQ)上公开上市。该指数被认为是美国经济的主要代表,通过大市值普通股。指数每 15 秒重新计算一次,并由路透美国控股公司分发。

交易所使用的常见股票代码是 SPX 和 INX,一些网站上是^GSPC。

SPX 期权

芝加哥期权交易所提供各种期权合约进行交易,包括标普 500 指数等股票指数期权。SPX 指数期权产品具有不同的到期日。标准或传统的 SPX 期权每月第三个星期五到期,并在交易日开始结算。SPX 周期(SPXW)期权产品可能每周到期,分别在星期一、星期三和星期五,或每月在月末最后一个交易日到期。如果到期日落在交易所假日,到期日将提前至前一个交易日。其他 SPX 期权是迷你期权,交易量为名义规模的十分之一,以及标普 500 指数存托凭证交易基金(SPDR ETF)。大多数 SPX 指数期权是欧式风格,除了 SPDR ETF 是美式风格。

VIX

与 STOXX 一样,芝加哥期权交易所 VIX 衡量了标普 500 股票指数期权价格隐含的短期波动率。芝加哥期权交易所 VIX 始于 1993 年,基于标普 100 指数,于 2003 年更新为基于 SPX,并于 2014 年再次更新以包括 SPXW 期权。全球许多人认为 VIX 是未来 30 天股市波动的流行测量工具。VIX 每 15 秒重新计算一次,并由芝加哥期权交易所分发。

VIX 期权和 VIX 期货基于 VIX,在芝加哥期权交易所交易。

标普 500 和 VIX 的金融分析

在本节中,我们将研究 VIX 与标普 500 市场指数之间的关系。

收集数据

我们将使用 Alpha Vantage 作为我们的数据提供商。让我们按以下步骤下载 SPX 和 VIX 数据集:

  1. 查询具有股票代码^GSPC的全时 S&P 500 历史数据:
In [ ]:
    from alpha_vantage.timeseries import TimeSeries

     # Update your Alpha Vantage API key here...
     ALPHA_VANTAGE_API_KEY = 'PZ2ISG9CYY379KLI'

     ts = TimeSeries(key=ALPHA_VANTAGE_API_KEY, output_format='pandas')
     df_spx_data, meta_data = ts.get_daily_adjusted(
         symbol='^GSPC', outputsize='full')
  1. 对于具有股票代码^VIX的 VIX 指数也做同样的操作:
In [ ]:
    df_vix_data, meta_data = ts.get_daily_adjusted(
         symbol='^VIX', outputsize='full')
  1. 检查 DataFrame 对象df_spx_data的内容:
In [ ]:
    df_spx_data.info()
Out[ ]:   
    <class 'pandas.core.frame.DataFrame'>
    Index: 4774 entries, 2000-01-03 to 2018-12-21
    Data columns (total 8 columns):
    1\. open                 4774 non-null float64
    2\. high                 4774 non-null float64
    3\. low                  4774 non-null float64
    4\. close                4774 non-null float64
    5\. adjusted close       4774 non-null float64
    6\. volume               4774 non-null float64
    7\. dividend amount      4774 non-null float64
    8\. split coefficient    4774 non-null float64
    dtypes: float64(8)
    memory usage: 317.0+ KB
  1. 检查 DataFrame 对象df_vix_data的内容:
In [ ]:
    df_vix_data.info()
Out[ ]: 
    <class 'pandas.core.frame.DataFrame'>
    Index: 4774 entries, 2000-01-03 to 2018-12-21
    Data columns (total 8 columns):
    1\. open                 4774 non-null float64
    2\. high                 4774 non-null float64
    3\. low                  4774 non-null float64
    4\. close                4774 non-null float64
    5\. adjusted close       4774 non-null float64
    6\. volume               4774 non-null float64
    7\. dividend amount      4774 non-null float64
    8\. split coefficient    4774 non-null float64
    dtypes: float64(8)
    memory usage: 317.0+ KB
  1. 注意,两个数据集的开始日期都是从 2000 年 1 月 3 日开始的,第五列标记为5\. adjusted close包含我们感兴趣的值。提取这两列并将它们合并成一个pandas DataFrame:
In [ ]:
    import pandas as pd

    df = pd.DataFrame({
        'SPX': df_spx_data['5\. adjusted close'],
        'VIX': df_vix_data['5\. adjusted close']
    })
    df.index = pd.to_datetime(df.index)
  1. pandasto_datetime()方法将作为字符串对象给出的交易日期转换为 pandas 的DatetimeIndex对象。检查我们最终的 DataFrame 对象df的头部,得到以下结果:
In [ ]:
    df.head(3)

这给我们以下表格:

日期 SPX VIX
2000-01-03 1455.22 24.21
2000-01-04 1399.42 27.01
2000-01-05 1402.11 26.41

查看我们格式化的指数,得到以下结果:

In [ ]:
    df.index
Out[ ]:
    DatetimeIndex(['2000-01-03', '2000-01-04', '2000-01-05', '2000-01-06',
                   '2000-01-07', '2000-01-10', '2000-01-11', '2000-01-12',
                   '2000-01-13', '2000-01-14',
                   ...
                   '2018-10-11', '2018-10-12', '2018-10-15', '2018-10-16',
                   '2018-10-17', '2018-10-18', '2018-10-19', '2018-10-22',
                   '2018-10-23', '2018-10-24'],
                  dtype='datetime64[ns]', name='date', length=4734, freq=None)

有了正确格式的pandas DataFrame,让我们继续处理这个数据集。

执行分析

pandasdescribe()方法给出了 DataFrame 对象中每列的摘要统计和值的分布:

In [ ]:
    df.describe()

这给我们以下表格:

SPX
count 4734.000000
mean 1493.538998
std 500.541938
min 676.530000
25% 1140.650000
50% 1332.730000
75% 1840.515000
max 2930.750000

另一个相关的方法info(),之前使用过,给我们 DataFrame 的技术摘要,如指数范围和内存使用情况:

In [ ]:
    df.info()
Out[ ]:    
    <class 'pandas.core.frame.DataFrame'>
    DatetimeIndex: 4734 entries, 2000-01-03 to 2018-10-24
    Data columns (total 2 columns):
    SPX    4734 non-null float64
    VIX    4734 non-null float64
    dtypes: float64(2)
    memory usage: 111.0 KB

让我们绘制 S&P 500 和 VIX,看看它们从 2010 年开始是什么样子:

In [ ]:
    %matplotlib inline
    import matplotlib.pyplot as plt

    plt.figure(figsize = (12, 8))

    ax_spx = df['SPX'].plot()
    ax_vix = df['VIX'].plot(secondary_y=True)

    ax_spx.legend(loc=1)
    ax_vix.legend(loc=2)

    plt.show();

这给我们以下图表:

注意,当 S&P 500 上涨时,VIX 似乎下降,表现出负相关关系。我们需要进行更多的统计分析来确保。

也许我们对两个指数的日回报感兴趣。diff()方法返回前一期值之间的差异集。直方图可用于在 100 个 bin 间隔上给出数据密度估计的大致感觉:

In [ ]:
    df.diff().hist(
        figsize=(10, 5),
        color='blue',
        bins=100);

hist()方法给出了以下直方图:

使用pct_change()命令也可以实现相同的效果,它给出了前一期值的百分比变化:

In [ ]:
    df.pct_change().hist(
         figsize=(10, 5),
          color='blue',
          bins=100);

我们得到了相同的直方图,以百分比变化为单位:

对于收益的定量分析,我们对每日收益的对数感兴趣。为什么使用对数收益而不是简单收益?有几个原因,但最重要的是归一化,这避免了负价格的问题。

我们可以使用pandasshift()函数将值向前移动一定数量的期间。dropna()方法删除对数计算转换末尾未使用的值。NumPy 的log()方法帮助计算 DataFrame 对象中所有值的对数,并将其存储在log_returns变量中作为 DataFrame 对象。然后可以绘制对数值,得到每日对数收益的图表。以下是绘制对数值的代码:

In [ ]:
    import numpy as np

    log_returns = np.log(df / df.shift(1)).dropna()
    log_returns.plot(
        subplots=True,
        figsize=(10, 8),
        color='blue',
        grid=True
    );
    for ax in plt.gcf().axes:
        ax.legend(loc='upper left')

我们得到以下输出:

顶部和底部图表分别显示了 SPX 和 VIX 的对数收益,从 2000 年到现在的期间。

SPX 和 VIX 之间的相关性

我们可以使用corr()方法来推导pandas DataFrame 对象中每列值之间的相关值,如以下 Python 示例所示:

In [ ]:
    log_returns.corr()

这给我们以下相关性表:

SPX VIX
SPX 1.000000 -0.733161
VIX -0.733161 1.000000

在-0.731433 处,SPX 与 VIX 呈负相关。为了更好地可视化这种关系,我们可以将每组日对数收益值绘制成散点图。使用statsmodels.api模块来获得散点数据之间的普通最小二乘回归线:

In [ ]:
    import statsmodels.api as sm

    log_returns.plot(
        figsize=(10,8),
         x="SPX",
         y="VIX",
         kind='scatter')

    ols_fit = sm.OLS(log_returns['VIX'].values,
    log_returns['SPX'].values).fit()

    plt.plot(log_returns['SPX'], ols_fit.fittedvalues, 'r');

我们得到以下输出:

如前图所示的向下倾斜回归线证实了标普 500 和 VIX 指数之间的负相关关系。

pandasrolling().corr()方法计算两个时间序列之间的滚动窗口相关性。我们使用252的值来表示移动窗口中的交易日数,以计算年度滚动相关性,使用以下命令:

In [ ]:
    plt.ylabel('Rolling Annual Correlation')

    df_corr = df['SPX'].rolling(252).corr(other=df['VIX'])
    df_corr.plot(figsize=(12,8));

我们得到以下输出:

从前面的图表可以看出,SPX 和 VIX 呈负相关,在大部分时间内在 0.0 和-0.9 之间波动,每年使用 252 个交易日。

计算 VIX 指数

在本节中,我们将逐步复制 VIX 指数。VIX 指数的计算在芝加哥期权交易所网站上有记录。您可以在www.cboe.com/micro/vix/vixwhite.pdf获取芝加哥期权交易所 VIX 白皮书的副本。

导入 SPX 期权数据

假设您从经纪人那里收集了 SPX 期权数据,或者从 CBOE 网站等外部来源购买了历史数据。为了本章的目的,我们观察了 2018 年 10 月 15 日(星期一)到 2018 年 10 月 19 日(星期五)的期末 SPX 期权链价格,并将其保存为逗号分隔值CSV)文件。这些文件的示例副本提供在源代码存储库的文件夹下。

在下面的示例中,编写一个名为read_file()的函数,它接受文件路径作为第一个参数,指示 CSV 文件的位置,并返回元数据列表和期权链数据列表的元组:

In [ ]:
    import csv 

    META_DATA_ROWS = 3  # Header data starts at line 4
    COLS = 7  # Each option data occupy 7 columns

    def read_file(filepath):
        meta_rows = []
        calls_and_puts = []

        with open(filepath, 'r') as file:
            reader = csv.reader(file)
            for row, cells in enumerate(reader):
                if row < META_DATA_ROWS:
                    meta_rows.append(cells)
                else:
                    call = cells[:COLS]
                    put = cells[COLS:-1]

                    calls_and_puts.append((call, put))                        

        return (meta_rows, calls_and_puts)

请注意,您自己的期权数据结构可能与此示例不同。请谨慎检查并相应修改此函数。导入数据集后,我们可以继续解析和提取有用信息。

解析 SPX 期权数据

在这个例子中,我们假设 CSV 文件的前三行包含元信息,后面的期权链价格从第四行开始。对于每一行期权定价数据,前七列包含看涨合同的买入和卖出报价,接下来的七列是看跌合同的。每七列的第一列包含描述到期日、行权价和合同代码的字符串。按照以下步骤从我们的 CSV 文件中解析信息:

  1. 每行元信息都被添加到名为meta_data的列表变量中,而每行期权数据都被添加到名为calls_and_puts的列表变量中。使用此函数读取单个文件会得到以下结果:
In [ ]:
    (meta_rows, calls_and_puts) = \
        read_file('files/chapter07/SPX_EOD_2018_10_15.csv')
  1. 打印每行元数据提供以下内容:
In [ ]:
    for line in meta_rows:
        print(line)
Out[ ]:
    ['SPX (S&P 500 INDEX)', '2750.79', '-16.34']
    ['Oct 15 2018 @ 20:00 ET']
    ['Calls', 'Last Sale', 'Net', 'Bid', 'Ask', 'Vol', 'Open Int', 'Puts', 'Last Sale', 'Net', 'Bid', 'Ask', 'Vol', 'Open Int']
  1. 期权报价的当前时间可以在我们的元数据的第二行找到。由于东部时间比格林威治标准时间GMT)晚 5 小时,我们将替换ET字符串并将整个字符串解析为datetime对象。以下函数get_dt_current()演示了这一点:
In [ ]:
    from dateutil import parser

    def get_dt_current(meta_rows):
        """
        Extracts time information.

        :param meta_rows: 2D array
        :return: parsed datetime object
        """
        # First cell of second row contains time info
        date_time_row = meta_rows[1][0]

        # Format text as ET time string
        current_time = date_time_row.strip()\
            .replace('@ ', '')\
            .replace('ET', '-05:00')\
            .replace(',', '')

        dt_current =  parser.parse(current_time)
        return dt_current
  1. 从我们的期权数据的元信息中提取日期和时间信息作为芝加哥当地时间:
In [ ]:
    dt_current =  get_dt_current(meta_rows)
    print(dt_current)
Out[ ]:    
    2018-10-15 20:00:00-05:00
  1. 现在,让我们看一下我们的期权报价数据的前两行:
In [ ]:
    for line in calls_and_puts[:2]:
        print(line)
Out[ ]:
    (['2018 Oct 15 1700.00 (SPXW1815J1700)', '0.0', '0.0', '1039.30', '1063.00', '0',     '0'], ['2018 Oct     15 1700.00 (SPXW1815V1700)', '0.15', '0.0', ' ', '0.05', '0'])
    (['2018 Oct 15 1800.00 (SPXW1815J1800)', '0.0', '0.0', '939.40', '963.00', '0',     '0'], ['2018 Oct     15 1800.00 (SPXW1815V1800)', '0.10', '0.0', ' ', '0.05', '0'])

列表中的每个项目都包含两个对象的元组,每个对象都包含一个看涨期权和一个看跌期权定价数据的列表,这些数据具有相同的行权价。参考我们打印的标题,每个期权价格列表数据的七个项目包含合同代码和到期日、最后成交价、价格净变动、买价、卖价、成交量和未*仓量。

让我们编写一个函数来解析每个 SPX 期权数据集的描述:

In [ ]:
    from decimal import Decimal

    def parse_expiry_and_strike(text):
        """
        Extracts information about the contract data.

        :param text: the string to parse.
        :return: a tuple of expiry date and strike price
        """
        # SPXW should expire at 3PM Chicago time.
        [year, month, day, strike, option_code] = text.split(' ')
        expiry = '%s %s %s 3:00PM -05:00' % (year, month, day)
        dt_object = parser.parse(expiry)    

        """
        Third friday SPX standard options expire at start of trading
        8.30 A.M. Chicago time.
        """
        if is_third_friday(dt_object):
            dt_object = dt_object.replace(hour=8, minute=30)

        strike = Decimal(strike)    
        return (dt_object, strike)

实用函数parse_expiry_and_strike()返回一个到期日期对象的元组,以及一个Decimal对象作为行权价。

每个合同数据都是一个字符串,包含到期年、月、日和行权价,后跟合同代码,所有用空格分隔。我们取日期组件并重构一个日期和时间字符串,可以轻松地由之前导入的dateutil解析函数解析。周期期权在纽约时间下午 4 点到期,或芝加哥时间下午 3 点到期。标准的第三个星期五期权是上午结算的,将在交易日开始时上午 8 点 30 分到期。我们根据执行is_third_friday()检查的需要替换到期时间,实现如下:

In [ ]:
    def is_third_friday(dt_object):
        return dt_object.weekday() == 4 and 15 <= dt_object.day <= 21

使用一个简单的合同代码数据测试我们的函数并打印结果。

In [ ]:
    test_contract_code = '2018 Sep 26 1800.00 (*)'
    (expiry, strike) = parse_expiry_and_strike(test_contract_code)
In [ ]:
    print('Expiry:', expiry)
    print('Strike price:', strike)
Out[ ]:
    Expiry: 2018-09-26 15:00:00-05:00
    Strike price: 1800.00

自 2018 年 9 月 26 日起,星期三,SPXW 期权将在芝加哥当地时间下午 3 点到期。

这一次,让我们使用一个落在第三个星期五的合同代码数据来测试我们的函数:

In [ ]:
    test_contract_code = '2018 Oct 19 2555.00 (*)'
    (expiry, strike) = parse_expiry_and_strike(test_contract_code)
In [ ]:    
    print('Expiry:', expiry)
    print('Strike price:', strike)
Out[ ]:
    Expiry: 2018-10-19 08:30:00-05:00
    Strike price: 2555.00

我们使用的测试合同代码数据是 2018 年 10 月 19 日,这是 10 月的第三个星期五。这是一个标准的 SPX 期权,将在交易日开始时,在芝加哥时间上午 8 点 30 分结算。

有了我们的实用函数,我们现在可以继续解析单个看涨或看跌期权价格条目,并返回我们可以使用的有用信息:

In [ ]:
    def format_option_data(option_data):
        [desc, _, _, bid_str, ask_str] = option_data[:5]
        bid = Decimal(bid_str.strip() or '0')
        ask = Decimal(ask_str.strip() or '0')
        mid = (bid+ask) / Decimal(2)
        (expiry, strike) = parse_expiry_and_strike(desc)
        return (expiry, strike, bid, ask, mid)

实用函数format_option_data()option_data作为其参数,其中包含我们之前看到的数据列表。索引零处的描述性数据包含合同代码数据,我们可以使用parse_expiry_and_strike()函数进行解析。索引三和四包含买价和卖价,用于计算中间价。中间价是买价和卖价的*均值。该函数返回期权到期日的元组,以及买价、卖价和中间价作为Decimal对象。

寻找*期和次*期期权

VIX 指数使用 24 天到 36 天到期的看涨和看跌期权的市场报价来衡量 SPX 的 30 天预期波动率。在这些日期之间,将有两个 SPX 期权合同到期日。最接*到期的期权被称为*期期权,而稍后到期的期权被称为次*期期权。每周发生一次,当期权到期日超出 24 到 36 天的范围时,新的合同到期日将被选择为新的*期和次*期期权。

为了帮助我们找到*期和次*期期权,让我们按到期日对看涨和看跌期权数据进行组织,每个期权数据都有一个以行权价为索引的pandas DataFrame。我们需要以下 DataFrame 列定义:

In [ ]:
    CALL_COLS = ['call_bid', 'call_ask', 'call_mid']
    PUT_COLS = ['put_bid', 'put_ask', 'put_mid']
    COLUMNS = CALL_COLS + PUT_COLS + ['diff']

以下函数generate_options_chain()将我们的列表数据集calls_and_puts组织成一个单一的字典变量chain

In [ ]:
    import pandas as pd

    def generate_options_chain(calls_and_puts):
        chain = {}

        for row in calls_and_puts:
            (call, put) = row

            (call_expiry, call_strike, call_bid, call_ask, call_mid) = \
                format_option_data(call)
            (put_expiry, put_strike, put_bid, put_ask, put_mid) = \
                format_option_data(put)

            # Ensure each line contains the same put and call maturity
            assert(call_expiry == put_expiry)

            # Get or create the DataFrame at the expiry
            df = chain.get(call_expiry, pd.DataFrame(columns=COLUMNS))

            df.loc[call_strike, CALL_COLS] = \
                [call_bid, call_ask, call_mid]
            df.loc[call_strike, PUT_COLS] = \
                [put_bid, put_ask, put_mid]
            df.loc[call_strike, 'diff'] = abs(put_mid-call_mid)

            chain[call_expiry] = df

        return chain
In [ ]:
    chain = generate_options_chain(calls_and_puts)

chain变量的键是期权到期日,每个键都引用一个pandas DataFrame 对象。对format_option_data()函数进行两次调用,以获取感兴趣的看涨和看跌数据。assert关键字确保了我们的看涨和看跌到期日的完整性,基于我们数据集中的每行都指向相同的到期日的假设。否则,将抛出异常并要求我们检查数据集是否存在任何损坏的迹象。

loc关键字为特定行权价分配列值,对于看涨期权和看跌期权数据。此外,diff列包含看涨和看跌报价的中间价格的绝对差异,我们稍后将使用。

让我们查看我们的chain字典中的前两个和最后两个键:

In [ ]:
    chain_keys = list(chain.keys())
    for row in chain_keys[:2]:
        print(row)
    print('...')
    for row in chain_keys[-2:]:
        print(row)
Out[ ]:
    2018-10-15 15:00:00-05:00
    2018-10-17 15:00:00-05:00
    ...
    2020-06-19 08:30:00-05:00
    2020-12-18 08:30:00-05:00

我们的数据集包含未来两年内到期的期权价格。从中,我们使用以下函数选择我们的*期和下期到期日:

In [ ]:
    def find_option_terms(chain, dt_current):
        """
        Find the near-term and next-term dates from
        the given indexes of the dictionary.

        :param chain: dictionary object
        :param dt_current: DateTime object of option quotes
        :return: tuple of 2 datetime objects
        """
        dt_near = None
        dt_next = None

        for dt_object in chain.keys():
            delta = dt_object - dt_current
            if delta.days > 23:
                # Skip non-fridays
                if dt_object.weekday() != 4:
                    continue

                # Save the near term date
                if dt_near is None:
                    dt_near = dt_object            
                    continue

                # Save the next term date
                if dt_next is None:
                    dt_next = dt_object            
                    break

        return (dt_near, dt_next)
Out[ ]:
    (dt_near, dt_next) = find_option_terms(chain, dt_current)

在这里,我们只是选择了到数据集时间后 23 天内到期的前两个期权。这两个期权到期日如下:

In [ ]:
    print('Found near-term maturity', dt_near, 
          'with', dt_near-dt_current, 'to expiry')
    print('Found next-term maturity', dt_next, 
          'with', dt_next-dt_current, 'to expiry')
Out[ ]:
    Found near-term maturity 2018-11-09 15:00:00-05:00 with 24 days, 19:00:00 to expiry
    Found next-term maturity 2018-11-16 08:30:00-05:00 with 31 days, 12:30:00 to expiry

*期到期日为 2018 年 11 月 9 日,下期到期日为 2018 年 11 月 16 日。

计算所需的分钟数

计算 VIX 的公式如下:

在这里,适用以下规定:

  • T[1]是到*期期权结算的年数

  • T[2]是到下期期权结算的年数

  • N[T1]是到*期期权结算的分钟数

  • N[T2]是到下期期权结算的分钟数

  • N[30]是 30 天内的分钟数

  • N[365]是一年 365 天的分钟数

让我们在 Python 中找出这些值:

In [ ]:
    dt_start_year = dt_current.replace(
        month=1, day=1, hour=0, minute=0, second=0)
    dt_end_year = dt_start_year.replace(year=dt_current.year+1)

    N_t1 = Decimal((dt_near-dt_current).total_seconds() // 60)
    N_t2 = Decimal((dt_next-dt_current).total_seconds() // 60)
    N_30 = Decimal(30 * 24 * 60)
    N_365 = Decimal((dt_end_year-dt_start_year).total_seconds() // 60)

两个datetime对象的差异返回一个timedelta对象,其“total_seconds()”方法以秒为单位给出差异。将秒数除以六十即可得到分钟数。一年的分钟数是通过取下一年的开始和当前年的开始之间的差异来找到的,而一个月的分钟数简单地是三十天内的秒数之和。

获得的值如下:

In [ ]:
    print('N_365:', N_365)
    print('N_30:', N_30)
    print('N_t1:', N_t1)
    print('N_t2:', N_t2)
Out[ ]:
    N_365: 525600
    N_30: 43200
    N_t1: 35700
    N_t2: 45390

计算 T 的一般公式如下:

在这里,适用以下规定:

  • M[当前日]是直到当天午夜剩余的分钟数

  • M[其他日]是当前日和到期日之间的分钟总和

  • M[结算日]是从到期日的午夜到到期时间的分钟数

有了这些,我们可以找到 T[1]和 T[2],即*期和下期期权每年剩余的时间:

In [ ]:
    t1 = N_t1 / N_365
    t2 = N_t2 / N_365
In [ ]:
    print('t1:%.5f'%t1)
    print('t2:%.5f'%t2)
Out[ ]:
    t1:0.06792
    t2:0.08636

*期期权到期日为 0.6792 年,下期期权到期日为 0.08636 年。

计算前向 SPX 指数水*

对于每个合同月,前向 SPX 水*F如下所示:

在这里,选择绝对差异最小的行权价。请注意,对于 VIX 指数的计算,不考虑出价为零的期权。这表明随着 SPX 和期权的波动性变化,出价可能变为零,并且用于计算 VIX 指数的期权数量可能在任何时刻发生变化!

我们可以用“determine_forward_level()”函数表示前向指数水*的计算,如下面的代码所示:

In [ ]:
    import math

    def determine_forward_level(df, r, t):
        """
        Calculate the forward SPX Index level.

        :param df: pandas DataFrame for a single option chain
        :param r: risk-free interest rate for t
        :param t: time to settlement in years
        :return: Decimal object
        """
        min_diff = min(df['diff'])
        pd_k = df[df['diff'] == min_diff]
        k = pd_k.index.values[0]

        call_price = pd_k.loc[k, 'call_mid']
        put_price = pd_k.loc[k, 'put_mid']
        return k + Decimal(math.exp(r*t))*(call_price-put_price

df参数是包含*期或下期期权价格的数据框。 min_diff变量包含在先前的差异列中计算的所有绝对价格差异的最小值。 pd_k变量包含我们将选择的 DataFrame,其中我们将选择具有最小绝对价格差异的行权价。

请注意,出于简单起见,我们假设两个期权链的利率均为 2.17%。在实践中,*期和次期期权的利率基于美国国债收益率曲线利率的三次样条计算,或者恒定到期国债收益率CMTs)。美国国债收益率曲线利率可从美国财政部网站www.treasury.gov/resource-center/data-chart-center/interest-rates/Pages/TextView.aspx?data=yieldYear&year=2018获取。

让我们计算*期期权的前向 SPX 水*为f1

In [ ]:
    r = Decimal(2.17/100)
In [ ]:
    df_near = chain.get(dt_near)
    f1 = determine_forward_level(df_near, r, t1)
In [ ]:
    print('f1:', f1)
Out[ ]:
    f1: 2747.596459994546094129930225

我们将使用前向 SPX 水*F作为 2747.596。

寻找所需的前向行权价格

前向行权价格是紧挨着前向 SPX 水*的行权价格,用k0表示,并由以下find_k0()函数确定:

In [ ]:
    def find_k0(df, f):
        return df[df.index<f].tail(1).index.values[0]

*期期权的k0值可以通过以下函数调用简单找到:

In [ ]:
    k0_near = find_k0(df_near, f1)
In [ ]:
    print('k0_near:', k0_near)
Out[ ]:
    k0_near: 2745.00

*期前向行权价格被确定为 2745。

确定行权价格边界

在选择用于 VIX 指数计算的期权时,忽略买价为零的认购和认沽期权。对于远虚值OTM)认沽期权,其行权价格低于k0,下限价格边界在遇到两个连续的零买价时终止。同样,对于行权价格高于k0的远虚值认购期权,上限价格边界在遇到两个连续的零买价时终止。

以下函数find_lower_and_upper_bounds()说明了在 Python 代码中找到下限和上限的过程:

In [ ]:
    def find_lower_and_upper_bounds(df, k0):
        """
        Find the lower and upper boundary strike prices.

        :param df: the pandas DataFrame of option chain
        :param k0: the forward strike price
        :return: a tuple of two Decimal objects
        """
        # Find lower bound
        otm_puts = df[df.index<k0].filter(['put_bid', 'put_ask'])
        k_lower = 0
        for i, k in enumerate(otm_puts.index[::-1][:-2]):
            k_lower = k
            put_bid_t1 = otm_puts.iloc[-i-1-1]['put_bid']
            put_bid_t2 = otm_puts.iloc[-i-1-2]['put_bid']
            if put_bid_t1 == 0 and put_bid_t2 == 0:
                break
            if put_bid_t2 == 0:
                k_lower = otm_puts.index[-i-1-1]

        # Find upper bound
        otm_calls = df[df.index>k0].filter(['call_bid', 'call_ask'])    
        k_upper = 0
        for i, k in enumerate(otm_calls.index[:-2]):
            call_bid_t1 = otm_calls.iloc[i+1]['call_bid']
            call_bid_t2 = otm_calls.iloc[i+2]['call_bid']
            if call_bid_t1 == 0 and call_bid_t2 == 0:
                k_upper = k
                break

        return (k_lower, k_upper)

df参数是期权价格的pandas DataFrame。otm_puts变量包含虚值认沽期权数据,并通过for循环按降序迭代。在每次迭代时,k_lower变量存储当前行权价格,同时我们在循环中向前查看两个报价。当for循环由于遇到两个零报价而终止,或者到达列表末尾时,k_lower将包含下限行权价格。

在寻找上限行权价格时采用相同的方法。由于虚值认购期权的行权价格已经按降序排列,我们只需使用iloc命令上的前向索引引用来读取价格。

当我们将*期期权链数据提供给这个函数时,下限和上限行权价格可以从k_lowerk_upper变量中获得,如下面的代码所示:

In [ ]:
    (k_lower_near, k_upper_near) = \
        find_lower_and_upper_bounds(df_near, k0_near)
In [ ]:
    print(k_lower_near, k_upper_near
Out[ ]:
    1250.00 3040.00

将使用行权价格从 1,500 到 3,200 的*期期权来计算 VIX 指数。

按行权价格制表

由于 VIX 指数由*均到期日为 30 天的认购和认沽期权的价格组成,所以所选到期日的每个期权都会对 VIX 指数的计算产生一定的影响。这个影响量可以用以下一般公式表示:

在这里,T是期权到期时间,R是期权到期时的无风险利率,K[i]是第i个虚值期权的行权价格,△K[i]是K[i]两侧的半差,使得△K[i]=0.5(K[i+1]-K[i-1])。

我们可以用以下calculate_contrib_by_strike()函数来表示这个公式:

In [ ]:
    def calculate_contrib_by_strike(delta_k, k, r, t, q):
        return (delta_k / k**2)*Decimal(math.exp(r*t))*q

在计算△K[i]=0.5(K[i+1]-K[i-1])时,我们使用实用函数find_prev_k()来寻找K[i-1],如下所示:

In [ ]:
    def find_prev_k(k, i, k_lower, df, bid_column):
        """
        Finds the strike price immediately below k 
        with non-zero bid.

        :param k: current strike price at i
        :param i: current index of df
        :param k_lower: lower strike price boundary of df
        :param bid_column: The column name that reads the bid price.
            Can be 'put_bid' or 'call_bid'.
        :return: strike price as Decimal object.
        """    
        if k <= k_lower:
            k_prev = df.index[i-1]
            return k_prev

        # Iterate backwards to find put bids           
        k_prev = 0
        prev_bid = 0
        steps = 1
        while prev_bid == 0:                                
            k_prev = df.index[i-steps]
            prev_bid = df.loc[k_prev][bid_column]
            steps += 1

        return k_prev

类似地,我们使用相同的程序来寻找K[i+1],使用实用函数find_next_k(),如下所示:

In [ ]:
    def find_next_k(k, i, k_upper, df, bid_column):
        """
        Finds the strike price immediately above k 
        with non-zero bid.

        :param k: current strike price at i
        :param i: current index of df
        :param k_upper: upper strike price boundary of df
        :param bid_column: The column name that reads the bid price.
            Can be 'put_bid' or 'call_bid'.
        :return: strike price as Decimal object.
        """    
        if k >= k_upper:
            k_next = df.index[i+1]
            return k_next

        k_next = 0
        next_bid = 0
        steps = 1
        while next_bid == 0:
            k_next = df.index[i+steps]
            next_bid = df.loc[k_next][bid_column]
            steps += 1

        return k_next

有了前面的实用函数,我们现在可以创建一个名为tabulate_contrib_by_strike()的函数,使用迭代过程来计算pandas DataFrame df中可用的每个行权价的期权的贡献,返回一个包含用于计算 VIX 指数的最终数据集的新 DataFrame:

In [ ]:
    import pandas as pd

    def tabulate_contrib_by_strike(df, k0, k_lower, k_upper, r, t):
        """
        Computes the contribution to the VIX index
        for every strike price in df.

        :param df: pandas DataFrame containing the option dataset
        :param k0: forward strike price index level
        :param k_lower: lower boundary strike price
        :param k_upper: upper boundary strike price
        :param r: the risk-free interest rate
        :param t: the time to expiry, in years
        :return: new pandas DataFrame with contributions by strike price
        """
        COLUMNS = ['Option Type', 'mid', 'contrib']
        pd_contrib = pd.DataFrame(columns=COLUMNS)

        for i, k in enumerate(df.index):
            mid, bid, bid_column = 0, 0, ''
            if k_lower <= k < k0:
                option_type = 'Put'
                bid_column = 'put_bid'
                mid = df.loc[k]['put_mid']
                bid = df.loc[k][bid_column]
            elif k == k0:
                option_type = 'atm'
            elif k0 < k <= k_upper:
                option_type = 'Call'
                bid_column = 'call_bid'
                mid = df.loc[k]['call_mid']
                bid = df.loc[k][bid_column]
            else:
                continue  # skip out-of-range strike prices

            if bid == 0:
                continue  # skip zero bids

            k_prev = find_prev_k(k, i, k_lower, df, bid_column)
            k_next = find_next_k(k, i, k_upper, df, bid_column)
            delta_k = Decimal((k_next-k_prev)/2)

            contrib = calculate_contrib_by_strike(delta_k, k, r, t, mid)
            pd_contrib.loc[k, COLUMNS] = [option_type, mid, contrib]

        return pd_contrib

生成的 DataFrame 以行权价为索引,包含三列——期权类型为看涨看跌,买卖价差的*均值,以及对 VIX 指数的贡献。

列出我们*期期权的贡献给出以下结果:

In [ ]:
    pd_contrib_near = tabulate_contrib_by_strike(
        df_near, k0_near, k_lower_near, k_upper_near, r, t1)

查看结果的头部提供以下信息:

In [ ]:
    pd_contrib_near.head()

这给出以下表格:

期权类型 中间值 贡献
1250.00 看跌期权 0.10 0.000003204720007271874493426366826
1300.00 看跌期权 0.125 0.000003703679742131881579865901010
1350.00 看跌期权 0.15 0.000004121296305647986745661479970
1400.00 看跌期权 0.20 0.000005109566338124799893855814454
1450.00 看跌期权 0.20 0.000004763258036967708819004706934

查看结果的尾部提供以下信息:

In [ ]:
    pd_contrib_near.tail()

这也给我们提供了以下表格:

期权类型 中间值 贡献
3020.00 看涨期权 0.175 9.608028452572290489411343569E-8
3025.00 看涨期权 0.225 1.231237623174939828257858985E-7
3030.00 看涨期权 0.175 9.544713775211615220689389699E-8
3035.00 看涨期权 0.20 1.087233242345573774601901086E-7
3040.00 看涨期权 0.15 8.127448187590304540304760266E-8

pd_contrib_near变量包含了单个 DataFrame 中包含的*期看涨和看跌虚值期权。

计算波动性

所选期权的波动性计算如下:

由于我们已经计算了求和项的贡献,这个公式可以简单地在 Python 中写成calculate_volatility()函数:

In [ ]:
    def calculate_volatility(pd_contrib, t, f, k0):
        """
        Calculate the volatility for a single-term option

        :param pd_contrib: pandas DataFrame 
            containing contributions by strike
        :param t: time to settlement of the option
        :param f: forward index level
        :param k0: immediate strike price below the forward level
        :return: volatility as Decimal object
        """
        term_1 = Decimal(2/t)*pd_contrib['contrib'].sum()
        term_2 = Decimal(1/t)*(f/k0 - 1)**2
        return term_1 - term_2

计算*期期权的波动性给出以下结果:

In [ ]:
    volatility_near = calculate_volatility(
        pd_contrib_near, t1, f1, k0_near)
In [ ]:
    print('volatility_near:', volatility_near)
Out[ ]:
    volatility_near: 0.04891704334249740486501736967

*期期权的波动性为 0.04891。

计算下一个期权

就像我们对*期期权所做的那样,使用已经定义好的函数进行下一个期权的计算是非常简单的:

In [ ] :
    df_next = chain.get(dt_next)

    f2 = determine_forward_level(df_next, r, t2)
    k0_next = find_k0(df_next, f2)
    (k_lower_next, k_upper_next) = \
        find_lower_and_upper_bounds(df_next, k0_next)
    pd_contrib_next = tabulate_contrib_by_strike(
        df_next, k0_next, k_lower_next, k_upper_next, r, t2)
    volatility_next = calculate_volatility(
        pd_contrib_next, t2, f2, k0_next)
In [ ]:
    print('volatility_next:', volatility_next)
Out[ ]:
    volatility_next: 0.04524308316212813982254693873

由于dt_next是我们的下一个到期日,调用chain.get()从期权链存储中检索下一个到期期权的价格。有了这些数据,我们确定了下一个到期期权的前向 SPX 水*f2,找到了它的前向行权价k0_next,并找到了它的下限和上限行权价边界。接下来,我们列出了在行权价边界内计算 VIX 指数的每个期权的贡献,从中我们使用calculate_volatility()函数计算了下一个期权的波动性。

下一个期权的波动性为 0.0452。

计算 VIX 指数

最后,30 天加权*均的 VIX 指数写成如下形式:

在 Python 代码中表示这个公式给出以下结果:

In [ ]:
    def calculate_vix_index(t1, volatility_1, t2, 
                            volatility_2, N_t1, N_t2, N_30, N_365):
        inner_term_1 = t1*Decimal(volatility_1)*(N_t2-N_30)/(N_t2-N_t1)
        inner_term_2 = t2*Decimal(volatility_2)*(N_30-N_t1)/(N_t2-N_t1)
        sqrt_terms = math.sqrt((inner_term_1+inner_term_2)*N_365/N_30)
        return 100 * sqrt_terms

用*期和下一个期权的值进行替换得到以下结果:

In [ ]:
    vix = calculate_vix_index(
        t1, volatility_near, t2, 
        volatility_next, N_t1, N_t2, 
        N_30, N_365)
In [ ]:
    print('At', dt_current, 'the VIX is', vix)
Out[ ]:
    At 2018-10-15 20:00:00-05:00 the VIX is 21.431114075693934

我们得到了 2018 年 10 月 15 日收盘时的 VIX 指数为 21.43。

计算多个 VIX 指数

对于特定交易日计算出的单个 VIX 值,我们可以重复使用定义的函数来计算一段时间内的 VIX 值。

让我们编写一个名为process_file()的函数,来处理单个文件路径,并返回计算出的 VIX 指数:

In [ ]:
    def process_file(filepath):
        """
        Reads the filepath and calculates the VIX index.

        :param filepath: path the options chain file
        :return: VIX index value
        """
        headers, calls_and_puts = read_file(filepath)    
        dt_current = get_dt_current(headers)

        chain = generate_options_chain(calls_and_puts)
        (dt_near, dt_next) = find_option_terms(chain, dt_current)

        N_t1 = Decimal((dt_near-dt_current).total_seconds() // 60)
        N_t2 = Decimal((dt_next-dt_current).total_seconds() // 60)
        t1 = N_t1 / N_365
        t2 = N_t2 / N_365

        # Process near-term options
        df_near = chain.get(dt_near)
        f1 = determine_forward_level(df_near, r, t1)
        k0_near = find_k0(df_near, f1)
        (k_lower_near, k_upper_near) = find_lower_and_upper_bounds(
            df_near, k0_near)
        pd_contrib_near = tabulate_contrib_by_strike(
            df_near, k0_near, k_lower_near, k_upper_near, r, t1)
        volatility_near = calculate_volatility(
            pd_contrib_near, t1, f1, k0_near)

        # Process next-term options
        df_next = chain.get(dt_next)
        f2 = determine_forward_level(df_next, r, t2)
        k0_next = find_k0(df_next, f2)
        (k_lower_next, k_upper_next) = find_lower_and_upper_bounds(
            df_next, k0_next)
        pd_contrib_next = tabulate_contrib_by_strike(
            df_next, k0_next, k_lower_next, k_upper_next, r, t2)
        volatility_next = calculate_volatility(
            pd_contrib_next, t2, f2, k0_next)

        vix = calculate_vix_index(
            t1, volatility_near, t2, 
            volatility_next, N_t1, N_t2, 
            N_30, N_365)

        return vix

假设我们观察了期权链数据,并将其收集到 2018 年 10 月 15 日至 19 日的 CSV 文件中。我们可以将文件名和文件路径模式定义为常量变量:

In [ ]:
    FILE_DATES = [
        '2018_10_15',
        '2018_10_16',
        '2018_10_17',
        '2018_10_18',
        '2018_10_19',
    ]
    FILE_PATH_PATTERN = 'files/chapter07/SPX_EOD_%s.csv'

通过日期进行迭代,并将计算出的 VIX 值设置到一个名为'VIX'的pandas DataFrame 列中,得到以下结果:

In [ ] :
    pd_calcs = pd.DataFrame(columns=['VIX'])

    for file_date in FILE_DATES:
        filepath = FILE_PATH_PATTERN % file_date

        vix = process_file(filepath)    
        date_obj = parser.parse(file_date.replace('_', '-'))

        pd_calcs.loc[date_obj, 'VIX'] = vix

使用head()命令观察我们的数据提供了以下结果:

In [ ]:
    pd_calcs.head(5)

这给我们提供了以下表格,其中包含了 VIX 在 5 天内的数值:

VIX
2018-10-15 21.4311
2018-10-16 17.7384
2018-10-17 17.4741
2018-10-18 20.0477
2018-10-19 19.9196

比较结果

让我们通过重用在之前的部分中下载的 DataFrame df_vix_data VIX 指数,提取出 2018 年 10 月 15 日至 19 日对应周的相关数值,比较计算出的 VIX 值与实际 VIX 值:

In [ ]:
    df_vix = df_vix_data['2018-10-14':'2018-10-21']['5\. adjusted close']

该时期的实际 VIX 收盘价如下:

In [ ]:
    df_vix.head(5)
Out [ ]:
    date
    2018-10-15    21.30
    2018-10-16    17.62
    2018-10-17    17.40
    2018-10-18    20.06
    2018-10-19    19.89
    Name: 5\. adjusted close, dtype: float64

让我们将实际的 VIX 值和计算出的值合并到一个 DataFrame 中,并绘制它们:

In [ ]:
    df_merged = pd.DataFrame({
         'Calculated': pd_calcs['VIX'],
         'Actual': df_vix,
    })
    df_merged.plot(figsize=(10, 6), grid=True, style=['b', 'ro']);

这给我们提供了以下输出:

红点中的计算值似乎非常接*实际的 VIX 值。

总结

在本章中,我们研究了波动率衍生品及其在投资者中的用途,以实现在股票和信用组合中的多样化和对冲风险。由于股票基金的长期投资者面临下行风险,波动率可以用作尾部风险的对冲工具,并替代认购期权。在美国,芝加哥期权交易所 VIX 衡量了由 SPX 期权价格隐含的短期波动率。在欧洲,VSTOXX 市场指数基于 OESX 一篮子的市场价格,并衡量了下一个 30 天内欧洲 STOXX 50 指数的隐含市场波动率。世界各地的许多人使用 VIX 作为下一个 30 天股票市场波动率的流行测量工具。为了帮助我们更好地理解 VIX 指数是如何计算的,我们研究了它的组成部分和确定其价值的公式。

为了帮助我们确定 SPX 和 VIX 之间的关系,我们下载了这些数据并进行了各种金融分析,得出它们之间存在负相关的结论。这种关系提供了一种可行的方式,通过基于基准的交易策略来避免频繁的再*衡成本。波动性的统计性质使波动率衍生品交易者能够通过利用均值回归策略、离散交易和波动率价差交易等方式获得回报。

在研究基于 VIX 的交易策略时,我们复制了单个时间段的 VIX 指数。由于 VIX 指数是对未来 30 天波动性展望的一种情绪,它由两个 SPX 期权链组成,到期日在 24 至 36 天之间。随着 SPX 的涨跌,SPX 期权的波动性也会发生变化,期权买价可能会变为零。用于计算 VIX 指数的期权数量可能会因此而改变。为了简化本章中对 VIX 计算的分解,我们假设包括的期权数量是静态的。我们还假设了在 5 天内 CMT 是恒定的。实际上,期权价格和无风险利率是不断变化的,VIX 指数大约每 15 秒重新计算一次。

在下一节中,我们将建立一个算法交易*台。

第八章:构建算法交易*台

算法交易自动化系统交易流程,根据定价、时机和成交量等多种因素以尽可能最佳价格执行订单。经纪公司可能会为希望部署自己交易算法的客户提供应用程序编程接口API)作为其服务提供的一部分。算法交易系统必须非常健壮,以处理订单执行过程中的任何故障点。网络配置、硬件、内存管理、速度和用户体验是设计执行订单系统时需要考虑的一些因素。设计更大的系统不可避免地会给框架增加更多复杂性。

一旦在市场上开立头寸,就会面临各种风险,如市场风险、利率风险和流动性风险。为了尽可能保护交易资本,将风险管理措施纳入交易系统非常重要。金融行业中最常用的风险度量可能是风险价值VaR)技术。我们将讨论 VaR 的优点和缺点,以及如何将其纳入我们将在本章开发的交易系统中。

在本章中,我们将涵盖以下主题:

  • 算法交易概述

  • 具有公共 API 的经纪人和系统供应商列表

  • 为交易系统选择编程语言

  • 设计算法交易*台

  • 在 Oanda v20 Python 模块上设置 API 访问

  • 实施均值回归算法交易策略

  • 实施趋势跟踪算法交易策略

  • 在我们的交易系统中引入 VaR 进行风险管理

  • 在 Python 上对 AAPL 进行 VaR 计算

介绍算法交易

上世纪 90 年代,交易所已经开始使用电子交易系统。到 1997 年,全球 44 个交易所使用自动化系统进行期货和期权交易,更多交易所正在开发自动化技术。芝加哥期货交易所(CBOT)和伦敦国际金融期货和期权交易所(LIFFE)等交易所将他们的电子交易系统用作传统的公开喊价交易场所之外的交易补充,从而使交易者可以全天候访问交易所的风险管理工具。随着技术的改进,基于技术的交易变得更加廉价,推动了更快更强大的交易*台的增长。订单执行的可靠性更高,消息传输错误率更低,这加深了金融机构对技术的依赖。大多数资产管理人、专有交易者和做市商已经从交易场所转移到了电子交易场所。

随着系统化或计算机化交易变得更加普遍,速度成为决定交易结果的最重要因素。通过利用复杂的基本模型,量化交易者能够动态重新计算交易产品的公*价值并执行交易决策,从而能够以牺牲使用传统工具的基本交易者的利润。这催生了高频交易HFT)这一术语,它依赖快速计算机在其他人之前执行交易决策。事实上,高频交易已经发展成为一个价值数十亿美元的行业。

算法交易是指对系统化交易流程的自动化,其中订单执行被大大优化以获得最佳价格。它不是投资组合配置过程的一部分。

银行、对冲基金、经纪公司、结算公司和交易公司通常会将他们的服务器放置在电子交易所旁边,以接收最新的市场价格,并在可能的情况下执行最快的订单。他们给交易所带来了巨大的交易量。任何希望参与低延迟、高交易量活动(如复杂事件处理或捕捉瞬息的价格差异)的人,可以通过获得交易所连接的方式来进行,可以选择共同定位的形式,他们的服务器硬件可以放置在交易所旁边的机架上,需要支付一定费用。

金融信息交换FIX)协议是与交易所进行电子通信的行业标准,从私人服务器实现直接市场访问DMA)到实时信息。C++是在 FIX 协议上进行交易的常见选择,尽管其他语言,如.NET Framework 公共语言和 Java 也可以使用。表述性状态转移REST)API 提供正在变得越来越普遍,供零售投资者使用。在创建算法交易*台之前,您需要评估各种因素,如学习的速度和便捷性,然后才能决定特定的语言用于此目的。

经纪公司将为他们的客户提供某种交易*台,以便他们可以在选定的交易所上执行订单,作为佣金费用的回报。一些经纪公司可能会提供 API 作为他们的服务提供的一部分,以满足技术倾向的客户希望运行自己的交易算法。在大多数情况下,客户也可以从第三方供应商提供的多个商业交易*台中进行选择。其中一些交易*台也可能提供 API 访问以将订单电子路由到交易所。在开发算法交易系统之前,重要的是事先阅读 API 文档,了解经纪人提供的技术能力,并制定开发算法交易系统的方法。

具有公共 API 的交易*台

以下表格列出了一些经纪人和交易*台供应商,他们的 API 文档是公开可用的:

经纪人/供应商 网址 支持的编程语言
CQG www.cqg.com REST, FIX, C#, C++, and VB/VBA
Cunningham Trading Systems www.ctsfutures.com Microsoft .Net Framework 4.0 and FIX
E*Trade developer.etrade.com/home Python, Java, and Node.js
Interactive Brokers www.interactivebrokers.com/en/index.php?f=5041 Java, C++, Python, C#, C++, and DDE
IG labs.ig.com/ REST, Java, JavaScript, .NET, Clojure, and Node.js
Tradier developer.tradier.com/ REST
Trading Technologies www.tradingtechnologies.com/trading/apis/ REST, .NET, and FIX
OANDA developer.oanda.com/ REST, Java, and FIX
FXCM www.fxcm.com/uk/algorithmic-trading/api-trading/ REST, Java, and FIX

选择编程语言

对于与经纪人或供应商进行接口的多种编程语言选择,对于刚开始进行算法交易*台开发的人来说,自然而然会产生一个问题:我应该使用哪种语言?

在回答这个问题之前,重要的是要弄清楚你的经纪人是否提供开发者工具。RESTful API 正变得越来越普遍,与 FIX 协议访问并列。少数经纪人支持 Java 和 C#。使用 RESTful API,几乎可以在支持超文本传输协议HTTP)的任何编程语言中搜索或编写其包装器。

请记住,每个工具选项都有其自身的限制。你的经纪人可能会对价格和事件更新进行速率限制。产品的开发方式、要遵循的性能指标、涉及的成本、延迟阈值、风险度量以及预期的用户界面都是需要考虑的因素。风险管理、执行引擎和投资组合优化器是会影响系统设计的一些主要组件。你现有的交易基础设施、操作系统的选择、编程语言编译器的能力以及可用的软件工具对系统设计、开发和部署提出了进一步的限制。

系统功能

定义交易系统的结果非常重要。结果可能是一个基于研究的系统,涉及从数据供应商获取高质量数据,执行计算或运行模型,并通过信号生成评估策略。研究组件的一部分可能包括数据清理模块或回测界面,以在历史数据上使用理论参数运行策略。在设计我们的系统时,CPU 速度、内存大小和带宽是需要考虑的因素。

另一个结果可能是一个更关注风险管理和订单处理功能以确保多个订单及时执行的执行型系统。系统必须非常健壮,以处理订单执行过程中的任何故障点。因此,在设计执行订单的系统时需要考虑网络配置、硬件、内存管理和速度以及用户体验等因素。

一个系统可能包含一个或多个这些功能。设计更大的系统不可避免地会给框架增加复杂性。建议选择一个或多个编程语言,可以解决和*衡交易系统的开发速度、开发便捷性、可扩展性和可靠性。

构建算法交易*台

在这一部分,我们将使用 Python 设计和构建一个实时算法交易系统。由于每个经纪人的开发工具和服务都不同,因此需要考虑与我们自己的交易系统集成所需的不同编程实现。通过良好的系统设计,我们可以构建一个通用服务,允许配置不同经纪人的插件,并与我们的交易系统良好地协同工作。

设计经纪人接口

在设计交易*台时,以下三个功能对于实现任何给定的交易计划都是非常理想的:

  • 获取价格:定价数据是交易所提供的最基本信息之一。它代表了市场为购买或出售交易产品所做的报价价格。经纪人可能会重新分发交易所的数据,以自己的格式传递给你。可用的价格数据的最基本形式是报价的日期和时间、交易产品的符号以及交易产品的报价买入和卖出价格。通常情况下,这些定价数据对基于交易的决策非常有用。

最佳的报价和询价价格被称为Level 1报价。在大多数情况下,可以向经纪人请求 Level 2、3 甚至更多的报价级别。

  • 向市场发送订单:当向市场发送订单时,它可能会被您的经纪人或交易所执行,也可能不会。如果订单得到执行,您将在交易产品中开立一个持仓,并使自己承担各种风险以及回报。最简单的订单形式指定了要交易的产品(通常用符号表示)、要交易的数量、您想要采取的持仓(即您是买入还是卖出),以及对于非市价订单,要交易的价格。根据您的需求,有许多不同类型的订单可用于帮助管理您的交易风险。

您的经纪人可能不支持所有订单类型。最好与您的经纪人核实可用的订单类型以及哪种订单类型可以最好地管理您的交易风险。市场参与者最常用的订单类型是市价订单、限价订单和长期有效订单。市价订单是立即在市场上买入或卖出产品的订单。由于它是根据当前市场价格执行的,因此不需要为此类型的订单指定执行价格。限价订单是以特定或更好的价格买入或卖出产品的订单。长期有效GTC)订单是一种保持在交易所队列中等待执行的订单,直到规定的到期时间。除非另有规定,大多数订单都是在交易日结束时到期的长期有效订单。您可以在www.investopedia.com/university/how-start-trading/how-start-trading-order-types.asp找到更多有关各种订单类型的信息。

  • 跟踪持仓:一旦您的订单执行,您将进入一个持仓。跟踪您的持仓将有助于确定您的交易策略表现如何(好坏皆有可能!),并管理和规划您的风险。您的持仓盈亏根据市场波动而变化,并被称为未实现盈利和亏损。关闭持仓后,您将获得实现盈利和亏损,这是您交易策略的最终结果。

有了这三个基本功能,我们可以设计一个通用的Broker类,实现这些功能,并可以轻松扩展到任何特定经纪人的配置。

Python 库要求

在本章中,我们将使用公开可用的 v20 模块,Oanda 作为我们的经纪人。本章中提到的所有方法实现都使用v20 Python 库作为示例。

安装 v20

OANDA v20 REST API 的官方存储库位于github.com/oanda/v20-python。使用终端命令使用 pip 进行安装。

pip install v20 

有关 OANDA v20 REST API 的详细文档可以在developer.oanda.com/rest-live-v20/introduction/找到。API 的使用因经纪人而异,因此在编写交易系统实现之前,请务必与您的经纪人咨询适当的文档。

编写基于事件驱动的经纪人类

无论是获取价格、发送订单还是跟踪持仓,基于事件驱动的系统设计将以多线程方式触发我们系统的关键部分,而不会阻塞主线程。

让我们开始编写我们的 PythonBroker类,如下所示:

from abc import abstractmethod

class Broker(object):
    def __init__(self, host, port):
        self.host = host
        self.port = port

        self.__price_event_handler = None
        self.__order_event_handler = None
        self.__position_event_handler = None

在构造函数中,我们可以为继承子类提供我们的经纪人的hostport公共连接配置。分别声明了三个变量,用于存储价格、订单和持仓更新的事件处理程序。在这里,我们设计了每个事件只有一个监听器。更复杂的交易系统可能支持同一事件处理程序上的多个监听器。

存储价格事件处理程序

Broker类内部,分别添加以下两个方法作为价格事件处理程序的 getter 和 setter:

@property
def on_price_event(self):
    """
    Listeners will receive: symbol, bid, ask
    """
    return self.__price_event_handler

@on_price_event.setter
def on_price_event(self, event_handler):
    self.__price_event_handler = event_handler

继承的子类将通过on_price_event方法调用通知监听器有关符号、买价和卖价的信息。稍后,我们将使用这些基本信息做出我们的交易决策。

存储订单事件处理程序

分别添加以下两个方法作为订单事件处理程序的获取器和设置器:

@property
def on_order_event(self):
    """
    Listeners will receive: transaction_id
    """
    return self.__order_event_handler

@on_order_event.setter
def on_order_event(self, event_handler):
    self.__order_event_handler = event_handler

在订单被路由到您的经纪人之后,继承的子类将通过on_order_event方法调用通知监听器,同时附带订单交易 ID。

存储持仓事件处理程序

添加以下两个方法作为持仓事件处理程序的获取器和设置器:

@property
def on_position_event(self):
    """
    Listeners will receive:
    symbol, is_long, units, unrealized_pnl, pnl
    """
    return self.__position_event_handler

@on_position_event.setter
def on_position_event(self, event_handler):
    self.__position_event_handler = event_handler

当从您的经纪人接收到持仓更新事件时,继承的子类将通过on_position_event方法通知监听器,其中包含符号信息、表示多头或空头持仓的标志、交易单位数、未实现的盈亏和已实现的盈亏。

声明一个用于获取价格的抽象方法

由于从数据源获取价格是任何交易系统的主要要求,创建一个名为get_prices()的抽象方法来执行这样的功能。它期望一个symbols参数,其中包含一个经纪人定义的符号列表,将用于从我们的经纪人查询数据。继承的子类应该实现这个方法,否则会抛出NotImplementedError异常:

@abstractmethod
def get_prices(self, symbols=[]):
    """
    Query market prices from a broker
    :param symbols: list of symbols recognized by your broker
    """
    raise NotImplementedError('Method is required!')

请注意,get_prices()方法预计执行一次获取当前市场价格的操作。这给我们提供了特定时间点的市场快照。对于一个持续运行的交易系统,我们将需要实时流式传输市场价格来满足我们的交易逻辑,接下来我们将定义这一点。

声明一个用于流式传输价格的抽象方法

添加一个stream_prices()抽象方法,使用以下代码接受一个符号列表来流式传输价格:

@abstractmethod
def stream_prices(self, symbols=[]):
    """"
    Continuously stream prices from a broker.
    :param symbols: list of symbols recognized by your broker
    """
    raise NotImplementedError('Method is required!')

继承的子类应该在从您的经纪人流式传输价格时实现这个方法,否则会抛出NotImplementedError异常消息。

声明一个用于发送订单的抽象方法

为继承的子类添加一个send_market_order()抽象方法,用于在向您的经纪人发送市价订单时实现:

@abstractmethod
def send_market_order(self, symbol, quantity, is_buy):
    raise NotImplementedError('Method is required!')

使用我们的Broker基类中编写的前述方法,我们现在可以在下一节中编写特定于经纪人的类。

实现经纪人类

在本节中,我们将实现特定于我们的经纪人 Oanda 的Broker类的抽象方法。这需要使用v20库。但是,您可以轻松地更改配置和任何特定于您选择的经纪人的实现方法。

初始化经纪人类

编写以下OandaBroker类,它是特定于我们经纪人的类,扩展了通用的Broker类:

import v20

class OandaBroker(Broker):
    PRACTICE_API_HOST = 'api-fxpractice.oanda.com'
    PRACTICE_STREAM_HOST = 'stream-fxpractice.oanda.com'

    LIVE_API_HOST = 'api-fxtrade.oanda.com'
    LIVE_STREAM_HOST = 'stream-fxtrade.oanda.com'

    PORT = '443'

    def __init__(self, accountid, token, is_live=False):
        if is_live:
            host = self.LIVE_API_HOST
            stream_host = self.LIVE_STREAM_HOST
        else:
            host = self.PRACTICE_API_HOST
            stream_host = self.PRACTICE_STREAM_HOST

        super(OandaBroker, self).__init__(host, self.PORT)

        self.accountid = accountid
        self.token = token

        self.api = v20.Context(host, self.port, token=token)
        self.stream_api = v20.Context(stream_host, self.port, token=token)

请注意,Oanda 使用两个不同的主机用于常规 API 端点和流式 API 端点。这些端点对于他们的模拟和实盘交易环境是不同的。所有端点都连接在标准的安全套接字层SSL)端口 440 上。在构造函数中,is_live布尔标志选择适合所选交易环境的适当端点,以保存在父类中。is_liveTrue值表示实盘交易环境。构造函数参数还保存了账户 ID 和令牌,这些信息是用于验证用于交易的账户的。这些信息可以从您的经纪人那里获取。

apistream_api变量保存了v20库的Context对象,通过调用方法向您的经纪人发送指令时使用。

实现获取价格的方法

以下代码实现了OandaBroker类中的父get_prices()方法,用于从您的经纪人获取价格:

def get_prices(self, symbols=[]):
    response = self.api.pricing.get(
        self.accountid,
        instruments=",".join(symbols),
        snapshot=True,
        includeUnitsAvailable=False
    )
    body = response.body
    prices = body.get('prices', [])
    for price in prices:
        self.process_price(price)

响应主体包含一个 prices 属性和一个对象列表。列表中的每个项目都由 process_price() 方法处理。让我们也在 OandaBroker 类中实现这个方法:

def process_price(self, price):
    symbol = price.instrument

    if not symbol:
        print('Price symbol is empty!')
        return

    bids = price.bids or []
    price_bucket_bid = bids[0] if bids and len(bids) > 0 else None
    bid = price_bucket_bid.price if price_bucket_bid else 0

    asks = price.asks or []
    price_bucket_ask = asks[0] if asks and len(asks) > 0 else None
    ask = price_bucket_ask.price if price_bucket_ask else 0

    self.on_price_event(symbol, bid, ask)

price 对象包含一个字符串对象的 instrument 属性,以及 bidsasks 属性中的 list 对象。通常,Level 1 报价是可用的,所以我们读取每个列表的第一项。列表中的每个项目都是一个 price_bucket 对象,我们从中提取买价和卖价。

有了这些提取的信息,我们将其传递给 on_price_event() 事件处理程序方法。请注意,在这个例子中,我们只传递了三个值。在更复杂的交易系统中,您可能希望考虑提取更详细的信息,比如成交量、最后成交价格或多级报价,并将其传递给价格事件监听器。

实现流动价格的方法

OandaBroker 类中添加以下 stream_prices() 方法,以从经纪人那里开始流动价格:

def stream_prices(self, symbols=[]):
    response = self.stream_api.pricing.stream(
        self.accountid,
        instruments=",".join(symbols),
        snapshot=True
    )

    for msg_type, msg in response.parts():
        if msg_type == "pricing.Heartbeat":
            continue
        elif msg_type == "pricing.ClientPrice":
            self.process_price(msg)

由于主机连接期望连续流,response 对象有一个 parts() 方法来监听传入的数据。msg 对象本质上是一个 price 对象,我们可以重复使用它来通知监听器有一个传入的价格事件。

实现发送市价订单的方法

OandaBroker 类中添加以下 send_market_order() 方法,它将向您的经纪人发送一个市价订单:

def send_market_order(self, symbol, quantity, is_buy):
    response = self.api.order.market(
        self.accountid,
        units=abs(quantity) * (1 if is_buy else -1),
        instrument=symbol,
        type='MARKET',
    )
    if response.status != 201:
        self.on_order_event(symbol, quantity, is_buy, None, 'NOT_FILLED')
        return

    body = response.body
    if 'orderCancelTransaction' in body:
        self.on_order_event(symbol, quantity, is_buy, None, 'NOT_FILLED')
        return transaction_id = body.get('lastTransactionID', None) 
    self.on_order_event(symbol, quantity, is_buy, transaction_id, 'FILLED')

当调用 v20 order 库的 market() 方法时,预期响应的状态为 201,表示成功连接到经纪人。建议进一步检查响应主体,以查看我们订单执行中的错误迹象。在成功执行的情况下,交易 ID 和订单的详细信息通过调用 on_order_event() 事件处理程序传递给监听器。否则,订单事件将以空的交易 ID 触发,并带有 NOT_FILLED 状态,表示订单不完整。

实现获取头寸的方法

OandaBroker 类中添加以下 get_positions() 方法,它将为给定账户获取所有可用的头寸信息:

def get_positions(self):
    response = self.api.position.list(self.accountid)
    body = response.body
    positions = body.get('positions', [])
    for position in positions:
        symbol = position.instrument
        unrealized_pnl = position.unrealizedPL
        pnl = position.pl
        long = position.long
        short = position.short

        if short.units:
            self.on_position_event(
                symbol, False, short.units, unrealized_pnl, pnl)
        elif long.units:
            self.on_position_event(
                symbol, True, long.units, unrealized_pnl, pnl)
        else:
            self.on_position_event(
                symbol, None, 0, unrealized_pnl, pnl)

在响应主体中,position 属性包含一个 position 对象列表,每个对象都有合同符号、未实现和已实现的盈亏、多头和空头头寸的数量属性。这些信息通过 on_position_event() 事件处理程序传递给监听器。

获取价格

现在定义了来自我们经纪人的价格事件监听器的方法,我们可以通过阅读当前市场价格来测试与我们经纪人之间建立的连接。可以使用以下 Python 代码实例化 Broker 类:

# Replace these 2 values with your own!
ACCOUNT_ID = '101-001-1374173-001'
API_TOKEN = '6ecf6b053262c590b78bb8199b85aa2f-d99c54aecb2d5b4583a9f707636e8009'

broker = OandaBroker(ACCOUNT_ID, API_TOKEN)

用您的经纪人提供的自己的凭据替换两个常量变量 ACCOUNT_IDAPI_TOKEN,这些凭据标识了您自己的交易账户。broker 变量是 OandaBroker 的一个实例,我们可以使用它来执行各种特定于经纪人的调用。

假设我们有兴趣了解 EUR/USD 货币对的当前市场价格。让我们定义一个常量变量来保存这个工具的符号,这个符号被我们的经纪人所认可:

SYMBOL = 'EUR_USD'

接下来,使用以下代码定义来自我们经纪人的价格事件监听器:

import datetime as dt

def on_price_event(symbol, bid, ask):
   print(
        dt.datetime.now(), '[PRICE]',
        symbol, 'bid:', bid, 'ask:', ask
    )

broker.on_price_event = on_price_event

on_price_event() 函数被定义为监听器,用于接收价格信息,并分配给 broker.on_price_event 事件处理程序。我们期望从定价事件中获得三个值 - 合同符号、买价和卖价 - 我们只是简单地将它们打印到控制台。

调用 get_prices() 方法来从我们的经纪人那里获取当前市场价格:

broker.get_prices(symbols=[SYMBOL])

我们应该在控制台上得到类似的输出:

2018-11-19 21:29:13.214893 [PRICE] EUR_USD bid: 1.14361 ask: 1.14374

输出是一行,显示 EUR/USD 货币对的买价和卖价分别为 1.143611.14374

发送一个简单的市价订单

与获取价格时一样,我们可以重用broker变量向我们的经纪人发送市价订单。

现在假设我们有兴趣购买一单位相同的 EUR/USD 货币对;以下代码执行此操作:

def on_order_event(symbol, quantity, is_buy, transaction_id, status):
    print(
        dt.datetime.now(), '[ORDER]',
        'transaction_id:', transaction_id,
        'status:', status,
        'symbol:', symbol,
        'quantity:', quantity,
        'is_buy:', is_buy,
    )

broker.on_order_event = on_order_event
broker.send_market_order(SYMBOL, 1, True)

on_order_event()函数被定义为监听来自我们经纪人的订单更新的函数,并分配给broker.on_order_event事件处理程序。例如,执行的限价订单或取消的订单将通过此方法调用。最后,send_market_order()方法表示我们有兴趣购买一单位 EUR/USD 货币对。

如果在运行上述代码时货币市场开放,您应该会得到以下结果,交易 ID 不同:

2018-11-19 21:29:13.484685 [ORDER] transaction_id: 754 status: FILLED symbol: EUR_USD quantity: 1 is_buy: True

输出显示订单成功填写,购买一单位 EUR/USD 货币对,交易 ID 为754

获取持仓更新

通过发送市价订单进行开仓,我们应该能够查看当前的 EUR/USD 头寸。我们可以在broker对象上使用以下代码来实现:

def on_position_event(symbol, is_long, units, upnl, pnl):
    print(
        dt.datetime.now(), '[POSITION]',
        'symbol:', symbol,
        'is_long:', is_long,
        'units:', units,
        'upnl:', upnl,
        'pnl:', pnl
    )

broker.on_position_event = on_position_event
broker.get_positions()

on_position_event()函数被定义为监听来自我们经纪人的持仓更新的函数,并分配给broker.on_position_event事件处理程序。当调用get_positions()方法时,经纪人返回持仓信息并触发以下输出:

2018-11-19 21:29:13.752886 [POSITION] symbol: EUR_USD is_long: True units: 1.0 upnl: -0.0001 pnl: 0.0

我们的头寸报告目前是 EUR/USD 货币对的一个多头单位,未实现损失为$0.0001。由于这是我们的第一笔交易,我们还没有实现任何利润或损失。

构建均值回归算法交易系统

现在我们的经纪人接受订单并响应我们的请求,我们可以开始设计一个完全自动化的交易系统。在本节中,我们将探讨如何设计和实施一个均值回归算法交易系统。

设计均值回归算法

假设我们相信在正常的市场条件下,价格会波动,但往往会回归到某个短期水*,例如最*价格的*均值。在这个例子中,我们假设 EUR/USD 货币对在*期短期内表现出均值回归特性。首先,我们将原始的 tick 级数据重新采样为标准时间序列间隔,例如一分钟间隔。然后,取最*几个周期来计算短期*均价格(例如,使用五个周期),我们认为 EUR/USD 价格将向前五分钟的价格*均值回归。

一旦 EUR/USD 货币对的出价价格超过短期*均价格,以五分钟为例,我们的交易系统将生成一个卖出信号,我们可以选择通过卖出市价订单进入空头头寸。同样,当 EUR/USD 的询价价格低于*均价格时,将生成买入信号,我们可以选择通过买入市价订单进入多头头寸。

一旦开仓,我们可以使用相同的信号来*仓。当开多头头寸时,我们在卖出信号时通过输入市价卖出订单来*仓。同样,当开空头头寸时,我们在买入信号时通过输入市价买入订单来*仓。

您可能会观察到我们交易策略中存在许多缺陷。*仓并不保证盈利。我们对市场的看法可能是错误的;在不利的市场条件下,信号可能会在一个方向上持续一段时间,并且有很高的可能性以巨大的损失*仓!作为交易员,您应该找出适合自己信念和风险偏好的个人交易策略。

实施均值回归交易员类

我们交易系统需要的两个重要参数是重新取样间隔和计算周期数。首先,创建一个名为MeanReversionTrader的类,我们可以实例化并作为我们的交易系统运行:

import time
import datetime as dt
import pandas as pd

class MeanReversionTrader(object):
    def __init__(
        self, broker, symbol=None, units=1,
        resample_interval='60s', mean_periods=5
    ):
        """
        A trading platform that trades on one side
            based on a mean-reverting algorithm.

        :param broker: Broker object
        :param symbol: A str object recognized by the broker for trading
        :param units: Number of units to trade
        :param resample_interval: 
            Frequency for resampling price time series
        :param mean_periods: Number of resampled intervals
            for calculating the average price
        """
        self.broker = self.setup_broker(broker)

        self.resample_interval = resample_interval
        self.mean_periods = mean_periods
        self.symbol = symbol
        self.units = units

        self.df_prices = pd.DataFrame(columns=[symbol])
        self.pnl, self.upnl = 0, 0

        self.mean = 0
        self.bid_price, self.ask_price = 0, 0
        self.position = 0
        self.is_order_pending = False
        self.is_next_signal_cycle = True

构造函数中的五个参数初始化了我们交易系统的状态 - 使用的经纪人、要交易的标的、要交易的单位数、我们价格数据的重新取样间隔,以及我们均值计算的周期数。这些值只是存储为类变量。

setup_broker()方法调用设置我们的类来处理我们即将定义的broker对象的事件。当我们接收到价格数据时,这些数据存储在一个pandas DataFrame 变量df_prices中。最新的买入和卖出价格存储在bid_priceask_price变量中,用于计算信号。mean变量将存储先前mean_period价格的计算均值。position变量将存储我们当前持仓的单位数。负值表示空头持仓,正值表示多头持仓。

is_order_pending布尔标志指示是否有订单正在等待经纪人执行,is_next_signal_cycle布尔标志指示当前交易状态周期是否开放。请注意,我们的系统状态可以如下:

  1. 等待买入或卖出信号。

  2. 在买入或卖出信号上下订单。

  3. 当持仓被打开时,等待卖出或买入信号。

  4. 在卖出或买入信号上下订单。

  5. 当持仓被*仓时,转到步骤 1。

在步骤 1 到 5 的每个周期中,我们只交易一个单位。这些布尔标志作为锁,防止多个订单同时进入系统。

添加事件监听器

让我们在我们的MeanReversionTrader类中连接价格、订单和持仓事件。

setup_broker()方法添加到这个类中,如下所示:

def setup_broker(self, broker):
    broker.on_price_event = self.on_price_event
    broker.on_order_event = self.on_order_event
    broker.on_position_event = self.on_position_event
    return broker

我们只是将三个类方法分配为经纪人生成的任何事件的监听器,以监听价格、订单和持仓更新。

on_price_event()方法添加到这个类中,如下所示:

def on_price_event(self, symbol, bid, ask):
    print(dt.datetime.now(), '[PRICE]', symbol, 'bid:', bid, 'ask:', ask)

    self.bid_price = bid
    self.ask_price = ask
    self.df_prices.loc[pd.Timestamp.now(), symbol] = (bid + ask) / 2.

    self.get_positions()
    self.generate_signals_and_think()

    self.print_state()

当收到价格事件时,我们将它们存储在我们的bid_priceask_pricedf_prices类变量中。随着价格的变化,我们的持仓和信号值也会发生变化。get_position()方法调用将检索我们持仓的最新信息,generate_signals_and_think()调用将重新计算我们的信号并决定是否进行交易。使用print_state()命令将系统的当前状态打印到控制台。

编写get_position()方法来从我们的经纪人中检索持仓信息,如下所示:

def get_positions(self):
    try:
        self.broker.get_positions()
    except Exception as ex:
        print('get_positions error:', ex)

on_order_event()方法添加到我们的类中,如下所示:

def on_order_event(self, symbol, quantity, is_buy, transaction_id, status):
    print(
        dt.datetime.now(), '[ORDER]',
        'transaction_id:', transaction_id,
        'status:', status,
        'symbol:', symbol,
        'quantity:', quantity,
        'is_buy:', is_buy,
    )
    if status == 'FILLED':
        self.is_order_pending = False
        self.is_next_signal_cycle = False

        self.get_positions()  # Update positions before thinking
        self.generate_signals_and_think()

当接收到订单事件时,我们将它们打印到控制台上。在我们的经纪人的on_order_event实现中,成功执行的订单将传递status值为FILLEDUNFILLED。只有在成功的订单中,我们才能关闭我们的布尔锁,检索我们的最新持仓,并进行决策以*仓我们的持仓。

on_position_event()方法添加到我们的类中,如下所示:

def on_position_event(self, symbol, is_long, units, upnl, pnl):
    if symbol == self.symbol:
        self.position = abs(units) * (1 if is_long else -1)
        self.pnl = pnl
        self.upnl = upnl
        self.print_state()

当接收到我们预期交易标的的持仓更新事件时,我们存储我们的持仓信息、已实现收益和未实现收益。使用print_state()命令将系统的当前状态打印到控制台。

print_state()方法添加到我们的类中,如下所示:

def print_state(self):
    print(
        dt.datetime.now(), self.symbol, self.position_state, 
        abs(self.position), 'upnl:', self.upnl, 'pnl:', self.pnl
    )

一旦我们的订单、持仓或市场价格有任何更新,我们就会将系统的最新状态打印到控制台。

编写均值回归信号生成器

我们希望我们的决策算法在每次价格或订单更新时重新计算交易信号。让我们在MeanReversionTrader类中创建一个generate_signals_and_think()方法来做到这一点:

def generate_signals_and_think(self):
    df_resampled = self.df_prices\
        .resample(self.resample_interval)\
        .ffill()\
        .dropna()
    resampled_len = len(df_resampled.index)

    if resampled_len < self.mean_periods:
        print(
            'Insufficient data size to calculate logic. Need',
            self.mean_periods - resampled_len, 'more.'
        )
        return

    mean = df_resampled.tail(self.mean_periods).mean()[self.symbol]

    # Signal flag calculation
    is_signal_buy = mean > self.ask_price
    is_signal_sell = mean < self.bid_price

    print(
        'is_signal_buy:', is_signal_buy,
        'is_signal_sell:', is_signal_sell,
        'average_price: %.5f' % mean,
        'bid:', self.bid_price,
        'ask:', self.ask_price
    )

    self.think(is_signal_buy, is_signal_sell)

由于价格数据存储在df_prices变量中作为 pandas DataFrame,我们可以按照构造函数中给定的resample_interval变量的定义,定期对其进行重新采样。ffill()方法向前填充任何缺失的数据,dropna()命令在重新采样后移除第一个缺失值。必须有足够的数据可用于计算均值,否则此方法将简单退出。mean_periods变量表示必须可用的重新采样数据的最小长度。

tail(self.mean_periods)方法获取最*的重新采样间隔并使用mean()方法计算*均值,从而得到另一个 pandas DataFrame。*均水*通过引用 DataFrame 的列来获取,该列简单地是工具符号。

使用均值回归算法可用的*均价格,我们可以生成买入和卖出信号。在这里,当*均价格超过市场要价时,会生成买入信号,当*均价格超过市场竞价时,会生成卖出信号。我们的短期信念是市场价格将回归到*均价格。

在将这些计算出的值打印到控制台以便更好地调试后,我们现在可以利用买入和卖出信号来执行实际交易,这在同一类中的名为think()的方法中完成:

def think(self, is_signal_buy, is_signal_sell):
    if self.is_order_pending:
        return

    if self.position == 0:
        self.think_when_flat_position(is_signal_buy, is_signal_sell)
    elif self.position > 0:
        self.think_when_position_long(is_signal_sell)
    elif self.position < 0: 
        self.think_when_position_short(is_signal_buy)       

如果订单仍处于待处理状态,我们只需不做任何操作并退出该方法。由于市场条件可能随时发生变化,您可能希望添加自己的逻辑来处理待处理状态已经过长时间的订单,并尝试另一种策略。

这三个 if-else 语句分别处理了当我们的仓位是*的、多头的或空头的交易逻辑。当我们的仓位是*的时,将调用think_when_position_flat()方法,写成如下:

def think_when_position_flat(self, is_signal_buy, is_signal_sell):
    if is_signal_buy and self.is_next_signal_cycle:
        print('Opening position, BUY', 
              self.symbol, self.units, 'units')
        self.is_order_pending = True
        self.send_market_order(self.symbol, self.units, True)
        return

    if is_signal_sell and self.is_next_signal_cycle:
        print('Opening position, SELL', 
              self.symbol, self.units, 'units')
        self.is_order_pending = True
        self.send_market_order(self.symbol, self.units, False)
        return

    if not is_signal_buy and not is_signal_sell:
        self.is_next_signal_cycle = True

第一个if语句处理的是,在买入信号时,当前交易周期处于开放状态时,我们通过发送市价订单来买入并将该订单标记为待处理的条件。相反,第二个if语句处理的是在卖出信号时进入空头仓位的条件。否则,由于仓位是*的,既没有买入信号也没有卖出信号,我们只需将is_next_signal_cycle设置为True,直到有信号可用为止。

当我们处于多头仓位时,将调用think_when_position_long()方法,写成如下:

def think_when_position_long(self, is_signal_sell):
    if is_signal_sell:
        print('Closing position, SELL', 
              self.symbol, self.units, 'units')
        self.is_order_pending = True
        self.send_market_order(self.symbol, self.units, False)

在卖出信号时,我们将订单标记为待处理,并立即通过发送市价订单来卖出来*仓我们的多头仓位。

同样,当我们处于空头仓位时,将调用think_when_position_short()方法,写成如下:

def think_when_position_short(self, is_signal_buy):
    if is_signal_buy:
        print('Closing position, BUY', 
              self.symbol, self.units, 'units')
        self.is_order_pending = True
        self.send_market_order(self.symbol, self.units, True)

在买入信号时,我们将订单标记为待处理,并立即通过发送市价订单来买入来*仓我们的空头仓位。

为了执行订单路由功能,将以下send_market_order()类方法添加到我们的MeanReversionTrader类中:

def send_market_order(self, symbol, quantity, is_buy):
    self.broker.send_market_order(symbol, quantity, is_buy)

订单信息简单地转发给我们的Broker类进行执行。

运行我们的交易系统

最后,为了开始运行我们的交易系统,我们需要一个入口点。将以下run()类方法添加到MeanReversionTrader类中:

def run(self):
    self.get_positions()
    self.broker.stream_prices(symbols=[self.symbol])

在我们的交易系统的第一次运行期间,我们读取我们当前的仓位并使用该信息来初始化所有与仓位相关的信息。然后,我们请求我们的经纪人开始为给定的符号流式传输价格,并保持连接直到程序终止。

有了入场点的定义,我们只需要初始化我们的MeanReversionTrader类,并使用以下代码调用run()命令:

trader = MeanReversionTrader(
    broker, 
    symbol='EUR_USD', 
    units=1
    resample_interval='60s', 
    mean_periods=5,
)
trader.run()

请记住,broker变量包含了前面获取价格部分定义的OandaBroker类的实例,我们可以重复使用它。我们的交易系统将使用这个经纪人对象来执行与经纪人相关的调用。我们对 EUR/USD 货币对感兴趣,每次交易一单位。resample_interval变量的值为60s表示我们的存储价格将以一分钟的间隔重新采样。mean_periods变量的值为5表示我们将取最*五个间隔的*均值,或者过去五分钟的*均价格。

要启动我们的交易系统,请调用run();定价更新将开始涓涓流入,使我们的系统能够自行交易。您应该在控制台上看到类似以下的输出:

...
2018-11-21 15:19:34.487216 [PRICE] EUR_USD bid: 1.1393 ask: 1.13943
2018-11-21 15:19:35.686323 EUR_USD FLAT 0 upnl: 0.0 pnl: 0.0
Insufficient data size to calculate logic. Need 5 more.
2018-11-21 15:19:35.694619 EUR_USD FLAT 0 upnl: 0.0 pnl: 0.0
...

从输出中看,我们的头寸目前是*的,并且没有足够的定价数据来计算我们的交易信号。

五分钟后,当有足够的数据进行交易信号计算时,我们应该能够观察到以下结果:

...
2018-11-21 15:25:07.075883 EUR_USD FLAT 0 upnl: 0.0 pnl: -0.3246
is_signal_buy: False is_signal_sell: True average_price: 1.13934 bid: 1.13936 ask: 1.13949
Opening position, SELL EUR_USD 1 units
2018-11-21 15:25:07.356520 [ORDER] transaction_id: 2848 status: FILLED symbol: EUR_USD quantity: 1 is_buy: False
2018-11-21 15:25:07.688082 EUR_USD SHORT 1.0 upnl: -0.0001 pnl: 0.0
is_signal_buy: False is_signal_sell: True average_price: 1.13934 bid: 1.13936 ask: 1.13949
2018-11-21 15:25:07.692292 EUR_USD SHORT 1.0 upnl: -0.0001 pnl: 0.0

...

过去五分钟的*均价格为1.13934。由于 EUR/USD 的当前市场竞价价格为1.13936,高于*均价格,生成了一个卖出信号。生成一个卖出市价订单,开设 EUR/USD 的一个单位的空头头寸。这导致了 0.0001 美元的未实现损失。

让系统自行运行一段时间,它应该能够自行*仓。要停止交易,请使用Ctrl + Z或类似的方法终止运行的进程。请记住,一旦程序停止运行,手动*仓任何剩余的交易头寸。现在您拥有一个完全功能的自动交易系统了!

这里的系统设计和交易参数仅作为示例,并不一定会产生积极的结果!您应该尝试不同的交易参数,并改进事件处理,以找出您交易计划的最佳策略。

构建趋势跟踪交易*台

在前一节中,我们按照构建均值回归交易*台的步骤进行了操作。相同的功能可以很容易地扩展到包括任何其他交易策略。在本节中,我们将看看如何重用MeanReversionTrader类来实现一个趋势跟踪交易系统。

设计趋势跟踪算法

假设这一次,我们相信当前的市场条件呈现出趋势跟踪的模式,可能是由于季节性变化、经济预测或政府政策。随着价格的波动,短期*均价格水*穿过*均长期价格水*的某个阈值,我们生成买入或卖出信号。

首先,我们将原始的 tick 级数据重新采样为标准的时间序列间隔,例如,每分钟一次。其次,我们取最*的若干个周期,例如,五个周期,计算过去五分钟的短期*均价格。最后,取最*的较大数量的周期,例如,十个周期,计算过去十分钟的长期*均价格。

在没有市场波动的市场中,短期*均价格应该与长期*均价格相同,比率为一 - 这个比率也被称为贝塔。当短期*均价格增加超过长期*均价格时,贝塔大于一,市场可以被视为处于上升趋势。当短期价格下降超过长期*均价格时,贝塔小于一,市场可以被视为处于下降趋势。

在上升趋势中,一旦 beta 穿过某个价格阈值水*,我们的交易系统将生成买入信号,我们可以选择以买入市价单进入多头头寸。同样,在下降趋势中,当 beta 跌破某个价格阈值水*时,将生成卖出信号,我们可以选择以卖出市价单进入空头头寸。

一旦开仓,相同的信号可以用来*仓。当开多头头寸时,我们在卖出信号时*仓,通过以市价卖出单进入卖出订单。同样,当开空头头寸时,我们在买入信号时*仓,通过以市价买入单进入买入订单。

上述机制与均值回归交易系统设计非常相似。请记住,该算法不能保证任何利润,只是对市场的简单看法。您应该有一个与此不同(更好)的观点。

编写趋势跟踪交易员类

让我们为我们的趋势跟踪交易系统编写一个新的名为TrendFollowingTreader的类,它简单地扩展了MeanReversionTrader类,使用以下 Python 代码:

class TrendFollowingTrader(MeanReversionTrader):
    def __init__(
        self, *args, long_mean_periods=10,
        buy_threshold=1.0, sell_threshold=1.0, **kwargs
    ):
        super(TrendFollowingTrader, self).__init__(*args, **kwargs)

        self.long_mean_periods = long_mean_periods
        self.buy_threshold = buy_threshold
        self.sell_threshold = sell_threshold

在我们的构造函数中,我们定义了三个额外的关键字参数,long_mean_periodsbuy_thresholdsell_threshold,保存为类变量。long_mean_periods变量定义了我们的时间序列价格的重新采样间隔数量,用于计算长期*均价格。请注意,父构造函数中现有的mean_periods变量用于计算短期*均价格。buy_thresholdsell_threshold变量包含确定生成买入或卖出信号的 beta 边界值。

编写趋势跟踪信号生成器

因为只有决策逻辑需要从我们的父类MeanReversionTrader类中进行修改,而其他所有内容,包括订单、下单和流动价格,都保持不变,我们只需覆盖generate_signals_and_think()方法,并使用以下代码实现我们的新趋势跟踪信号生成器:

def generate_signals_and_think(self):
    df_resampled = self.df_prices\
        .resample(self.resample_interval)\
        .ffill().dropna()
    resampled_len = len(df_resampled.index)

    if resampled_len < self.long_mean_periods:
        print(
            'Insufficient data size to calculate logic. Need',
            self.mean_periods - resampled_len, 'more.'
        )
        return

    mean_short = df_resampled\
        .tail(self.mean_periods).mean()[self.symbol]
    mean_long = df_resampled\
        .tail(self.long_mean_periods).mean()[self.symbol]
    beta = mean_short / mean_long

    # Signal flag calculation
    is_signal_buy = beta > self.buy_threshold
    is_signal_sell = beta < self.sell_threshold

    print(
        'is_signal_buy:', is_signal_buy,
        'is_signal_sell:', is_signal_sell,
        'beta:', beta,
        'bid:', self.bid_price,
        'ask:', self.ask_price
    )

    self.think(is_signal_buy, is_signal_sell)

与以前一样,在每次调用generate_signals_and_think()方法时,我们以resample_interval定义的固定间隔重新采样价格。现在,用于计算信号的最小间隔由long_mean_periods而不是mean_periods定义。mean_short变量指的是短期*均重新采样价格,mean_long变量指的是长期*均重新采样价格。

beta变量是短期*均价格与长期*均价格的比率。当 beta 上升到buy_threshold值以上时,将生成买入信号,并且is_signal_buy变量为True。同样,当 beta 跌破sell_threshold值时,将生成卖出信号,并且is_signal_sell变量为True

交易参数被打印到控制台以进行调试,并且对父类think()类方法的调用会触发使用市价订单进行买入和卖出的通常逻辑。

运行趋势跟踪交易系统

通过实例化TrendFollowingTrader类并使用以下代码运行我们的趋势跟踪交易系统:

trader = TrendFollowingTrader(
    broker,
    resample_interval='60s',
    symbol='EUR_USD',
    units=1,
    mean_periods=5,
    long_mean_periods=10,
    buy_threshold=1.000010,
    sell_threshold=0.99990,
)
trader.run()

第一个参数broker与上一节中为我们的经纪人创建的对象相同。同样,我们以一分钟间隔重新取样我们的时间序列价格,并且我们对交易 EUR/USD 货币对感兴趣,在任何给定时间最多进入一单位的头寸。使用mean_periods值为5,我们对最*的五个重新取样间隔感兴趣,以计算过去五分钟的*均价格作为我们的短期*均价格。使用long_mean_period值为10,我们对最*的 10 个重新取样间隔感兴趣,以计算过去 10 分钟的*均价格作为我们的长期*均价格。

短期*均价格与长期*均价格的比率被视为贝塔。当贝塔上升到超过buy_threshold定义的值时,将生成买入信号。当贝塔下降到低于sell_threshold定义的值时,将生成卖出信号。

设置好交易参数后,调用run()方法启动交易系统。我们应该在控制台上看到类似以下的输出:

...
2018-11-23 08:51:12.438684 [PRICE] EUR_USD bid: 1.14018 ask: 1.14033
2018-11-23 08:51:13.520880 EUR_USD FLAT 0 upnl: 0.0 pnl: 0.0
Insufficient data size to calculate logic. Need 10 more.
2018-11-23 08:51:13.529919 EUR_USD FLAT 0 upnl: 0.0 pnl: 0.0
... 

在交易开始时,我们获得了当前市场价格,保持*仓状态,既没有盈利也没有损失。没有足够的数据可用于做出任何交易决策,我们将不得不等待 10 分钟,然后才能看到计算参数生效。

如果您的交易系统依赖于更长时间的过去数据,并且不希望等待所有这些数据被收集,考虑使用历史数据对您的交易系统进行引导。

过一段时间后,您应该会看到类似以下的输出:

...
is_signal_buy: True is_signal_sell: False beta: 1.0000333228980047 bid: 1.14041 ask: 1.14058
Opening position, BUY EUR_USD 1 units
2018-11-23 09:01:01.579208 [ORDER] transaction_id: 2905 status: FILLED symbol: EUR_USD quantity: 1 is_buy: True
2018-11-23 09:01:01.844743 EUR_USD LONG 1.0 upnl: -0.0002 pnl: 0.0
...

让系统自行运行一段时间,它应该能够自行*仓。要停止交易,请使用Ctrl + Z或类似的方法终止运行进程。记得在程序停止运行后手动*仓任何剩余的交易头寸。采取措施改变您的交易参数和决策逻辑,使您的交易系统成为盈利性的!

请注意,作者对您的交易系统的任何结果概不负责!在实时交易环境中,需要更多的控制参数、订单管理和头寸跟踪来有效管理风险。

在接下来的部分,我们将讨论一个可以应用于我们交易计划的风险管理策略。

VaR 用于风险管理

一旦我们在市场上开仓,就会面临各种风险,如波动风险和信用风险。为了尽可能保护我们的交易资本,将风险管理措施纳入我们的交易系统是非常重要的。

也许金融行业中最常用的风险度量是 VaR 技术。它旨在简单回答以下问题:在特定概率水*(例如 95%)和一定时间段内,预期的最坏损失金额是多少? VaR 的美妙之处在于它可以应用于多个层次,从特定头寸的微观层面到基于组合的宏观层面。例如,对于 1 天的时间范围,95%的置信水*下的 100 万美元 VaR 表明,*均而言,你只有 20 天中的 1 天可能会因市场波动而损失超过 100 万美元。

以下图表说明了一个均值为 0%的正态分布组合收益率,VaR 是分布中第 95 百分位数对应的损失:

假设我们在一家声称具有与标普 500 指数基金相同风险的基金中管理了 1 亿美元,预期收益率为 9%,标准偏差为 20%。使用方差-协方差方法计算 5%风险水*或 95%置信水*下的每日 VaR,我们将使用以下公式:

在这里,P是投资组合的价值,N^(−1)(α,u,σ)是具有风险水*α、*均值u和标准差σ的逆正态概率分布。每年的交易日数假定为 252 天。结果表明,5%水*的每日 VaR 为$2,036,606.50。

然而,VaR 的使用并非没有缺陷。它没有考虑正态分布曲线尾端发生极端事件的损失概率。超过一定 VaR 水*的损失规模也很难估计。我们调查的 VaR 使用历史数据和假定的恒定波动率水* - 这些指标并不代表我们未来的表现。

让我们采取一种实际的方法来计算股票价格的每日 VaR;我们将通过从数据源下载 AAPL 股票价格来调查:

"""
Download the all-time AAPL dataset
"""
from alpha_vantage.timeseries import TimeSeries

# Update your Alpha Vantage API key here...
ALPHA_VANTAGE_API_KEY = 'PZ2ISG9CYY379KLI'

ts = TimeSeries(key=ALPHA_VANTAGE_API_KEY, output_format='pandas')
df, meta_data = ts.get_daily_adjusted(symbol='AAPL', outputsize='full')

数据集将作为 pandas DataFrame 下载到df变量中:

df.info()

这给我们以下输出:

<class 'pandas.core.frame.DataFrame'>
Index: 5259 entries, 1998-01-02 to 2018-11-23
Data columns (total 8 columns):
1\. open                 5259 non-null float64
2\. high                 5259 non-null float64
3\. low                  5259 non-null float64
4\. close                5259 non-null float64
5\. adjusted close       5259 non-null float64
6\. volume               5259 non-null float64
7\. dividend amount      5259 non-null float64
8\. split coefficient    5259 non-null float64
dtypes: float64(8)
memory usage: 349.2+ KB

我们的 DataFrame 包含八列,价格从 1998 年开始到现在的交易日。感兴趣的列是调整后的收盘价。假设我们有兴趣计算 2017 年的每日 VaR;让我们使用以下代码获取这个数据集:

import datetime as dt
import pandas as pd

# Define the date range
start = dt.datetime(2017, 1, 1)
end = dt.datetime(2017, 12, 31)

# Cast indexes as DateTimeIndex objects
df.index = pd.to_datetime(df.index)
closing_prices = df['5\. adjusted close']
prices = closing_prices.loc[start:end]

prices变量包含了我们 2017 年的 AAPL 数据集。

使用前面讨论的公式,您可以使用以下代码实现calculate_daily_var()函数:

from scipy.stats import norm

def calculate_daily_var(
    portfolio, prob, mean, 
    stdev, days_per_year=252.
):
    alpha = 1-prob
    u = mean/days_per_year
    sigma = stdev/np.sqrt(days_per_year)
    norminv = norm.ppf(alpha, u, sigma)
    return portfolio - portfolio*(norminv+1)

假设我们持有$100 百万的 AAPL 股票,并且有兴趣找到 95%置信水*下的每日 VaR。我们可以使用以下代码定义 VaR 参数:

import numpy as np

portfolio = 100000000.00
confidence = 0.95

daily_returns = prices.pct_change().dropna()
mu = np.mean(daily_returns)
sigma = np.std(daily_returns)

musigma变量分别代表每日*均百分比收益和每日收益的标准差。

我们可以通过调用calculate_daily_var()函数获得 VaR,如下所示:

VaR = calculate_daily_var(
    portfolio, confidence, mu, sigma, days_per_year=252.)
print('Value-at-Risk: %.2f' % VaR)

我们将得到以下输出:

Value-at-Risk: 114248.72

假设每年有 252 个交易日,2017 年 AAPL 股票的每日 VaR 在 95%的置信水*下为$114,248.72。

摘要

在本章中,我们介绍了交易从交易场到电子交易*台的演变,并了解了算法交易的产生过程。我们看了一些经纪人提供 API 访问其交易服务。为了帮助我们开始开发算法交易系统,我们使用 Oanda v20库来实现一个均值回归交易系统。

在设计一个事件驱动的经纪人接口类时,我们为监听订单、价格和持仓更新定义了事件处理程序。继承Broker类的子类只需用经纪人特定的函数扩展这个接口类,同时保持底层交易函数与我们的交易系统兼容。我们通过获取市场价格、发送市价订单和接收持仓更新成功测试了与我们经纪人的连接。

我们讨论了一个简单的均值回归交易系统的设计,该系统根据历史*均价格的波动以及开仓和*仓市价订单来生成买入或卖出信号。由于这个交易系统只使用了一个交易逻辑来源,因此需要更多的工作来构建一个健壮、可靠和盈利的交易系统。

我们还讨论了一个趋势跟随交易系统的设计,该系统根据短期*均价格与长期*均价格的波动来生成买入或卖出信号。通过一个设计良好的系统,我们看到了通过简单地扩展均值回归父类并覆盖决策方法来修改现有交易逻辑是多么容易。

交易的一个关键方面是有效地管理风险。在金融行业,VaR 是用来衡量风险的最常见的技术。使用 Python,我们采取了一种实际的方法来计算 AAPL 过去数据集的每日 VaR。

一旦我们建立了一个有效的算法交易系统,我们可以探索其他衡量交易策略表现的方式。其中之一是回测;我们将在下一章讨论这个话题。

第九章:实施回测系统

回测是对模型驱动的投资策略对历史数据的响应进行模拟。在设计和开发回测时,以创建视频游戏的概念思考会很有帮助。

在这一章中,我们将使用面向对象的方法设计和实现一个事件驱动的回测系统。我们交易模型的结果利润和损失可以绘制成图表,以帮助可视化我们交易策略的表现。然而,这足以确定它是否是一个好模型吗?

在回测中有许多问题需要解决,例如交易成本的影响、订单执行的延迟、获取详细交易信息的途径以及历史数据的质量。尽管存在这些因素,创建回测系统的主要目标是尽可能准确地测试模型。

回测涉及大量值得研究的内容,这些内容值得有专门的文献。我们将简要讨论一些在实施回测时可能要考虑的想法。通常,回测中会使用多种算法。我们将简要讨论其中一些:k 均值聚类、k 最*邻、分类和回归树、2k 因子设计和遗传算法。

在这一章中,我们将涵盖以下主题:

  • 介绍回测

  • 回测中的关注点

  • 事件驱动回测系统的概念

  • 设计和实施回测系统

  • 编写类来存储 tick 数据和市场数据

  • 编写订单和持仓类

  • 编写一个均值回归策略

  • 运行回测引擎单次和多次

  • 回测模型的十个考虑因素

  • 回测中的算法讨论

介绍回测

回测是对模型驱动的投资策略对历史数据的响应进行模拟。进行回测实验的目的是发现有关过程或系统的发现。通过使用历史数据,您可以节省测试投资策略的时间。它帮助您测试基于被测试期间的运动的投资理论。它也用于评估和校准投资模型。创建模型只是第一步。投资策略通常会使用该模型来帮助您进行模拟交易决策并计算与风险或回报相关的各种因素。这些因素通常一起使用,以找到一个能够预测回报的组合。

回测中的关注点

然而,在回测中有许多问题需要解决:

  • 回测永远无法完全复制投资策略在实际交易环境中的表现。

  • 历史数据的质量是有问题的,因为它受第三方数据供应商的异常值影响。

  • 前瞻性偏差有很多形式。例如,上市公司可能会分拆、合并或退市,导致其股价发生重大变化。

  • 对于基于订单簿信息的策略,市场微观结构极其难以真实模拟,因为它代表了连续时间内的集体可见供需。这种供需反过来受到世界各地新闻事件的影响。

  • 冰山和挂单是市场的一些隐藏元素,一旦激活就可能影响结构

  • 其他需要考虑的因素包括交易成本、订单执行的延迟以及从回测中获取详细交易信息的途径。

尽管存在这些因素,创建回测系统的主要目标是尽可能准确地测试模型。

前瞻性偏差是在分析期间使用可用的未来数据,导致模拟或研究结果不准确。在金融领域,冰山订单是将大订单分成几个小订单。订单的一小部分对公众可见,就像冰山的一角一样,而实际订单的大部分是隐藏的。挂单是一个价格远离市场并等待执行的订单。

事件驱动回测系统的概念

在设计和开发回测时,以创建视频游戏的概念来思考会很有帮助。毕竟,我们正在尝试创建一个模拟的市场定价和订单环境,非常类似于创建一个虚拟的游戏世界。交易也可以被视为一个买低卖高的刺激游戏。

在虚拟交易环境中,需要组件来模拟价格数据源、订单匹配引擎、订单簿管理,以及账户和持仓更新功能。为了实现这些功能,我们可以探索事件驱动回测系统的概念。

让我们首先了解贯穿游戏开发过程的事件驱动编程范式的概念。系统通常将事件作为其输入接收。它可能是用户输入的按键或鼠标移动。其他事件可能是由另一个系统、进程或传感器生成的消息,用于通知主机系统有一个传入事件。

以下图表说明了游戏引擎系统涉及的阶段:

让我们看一下主游戏引擎循环的伪代码实现:

while is_main_loop:  # Main game engine loop
     handle_input_events()
     update_AI()
     update_physics()
     update_game_objects()
     render_screen()
     sleep(1/60)  # Assuming a 60 frames-per-second video game rate

主游戏引擎循环中的核心功能可能会处理生成的系统事件,就像handle_input_events()函数处理键盘事件一样:

def handle_input_events()
    event = get_latest_event()
    if event.type == 'UP_KEY_PRESS':
        move_player_up()
    elif event.type == 'DOWN_KEY_PRESS':
        move_player_down()

使用事件驱动系统,例如前面的例子,可以通过能够交换和使用来自不同系统组件的类似事件来实现代码模块化和可重用性。面向对象编程的使用进一步得到加强,其中类定义了游戏中的对象。这些特性在设计交易*台时特别有用,可以与不同的市场数据源、多个交易算法和运行时环境进行接口。模拟交易环境接*真实环境,有助于防止前瞻性偏差。

设计和实施回测系统

现在我们已经有了一个设计视频游戏来创建回测交易系统的想法,我们可以通过首先定义交易系统中各个组件所需的类来开始我们的面向对象方法。

我们有兴趣实施一个简单的回测系统来测试一个均值回归策略。使用数据源提供商的每日历史价格,我们将取每天的收盘价来计算特定工具价格回报的波动率,以 AAPL 股价为例。我们想要测试一个理论,即如果过去一定数量的日子的回报标准差远离零的均值达到特定阈值,就会生成买入或卖出信号。当确实生成这样的信号时,市场订单将被发送到交易所,以在下一个交易日的开盘价执行。

一旦我们开仓,我们希望追踪到目前为止的未实现利润和已实现利润。我们的持仓可以在生成相反信号时关闭。在完成回测后,我们将绘制利润和损失,以查看我们的策略表现如何。

我们的理论听起来像是一个可行的交易策略吗?让我们来看看!以下部分解释了实施回测系统所需的类。

编写一个类来存储 tick 数据

编写一个名为TickData的类,表示从市场数据源接收的单个数据单元的 Python 代码:

class TickData(object):
    """ Stores a single unit of data """

    def __init__(self, timestamp='', symbol='', 
                 open_price=0, close_price=0, total_volume=0):
        self.symbol = symbol
        self.timestamp = timestamp
        self.open_price = open_price
        self.close_price = close_price
        self.total_volume = total_volume

在这个例子中,我们对存储时间戳、工具的符号、开盘价和收盘价以及总成交量感兴趣。随着系统的发展,可以添加单个 tick 数据的详细描述,比如最高价或最后成交量。

编写一个类来存储市场数据

MarketData类的一个实例在整个系统中用于存储和检索由各个组件引用的价格。它本质上是一个用于存储最后可用 tick 数据的容器。还包括额外的get辅助函数,以提供对所需信息的便捷引用:

class MarketData(object):
    """ Stores the most recent tick data for all symbols """

    def __init__(self):
        self.recent_ticks = dict()  # indexed by symbol

    def add_tick_data(self, tick_data):
        self.recent_ticks[tick_data.symbol] = tick_data

    def get_open_price(self, symbol):
        return self.get_tick_data(symbol).open_price

    def get_close_price(self, symbol):
        return self.get_tick_data(symbol).close_price

    def get_tick_data(self, symbol):
        return self.recent_ticks.get(symbol, TickData())

    def get_timestamp(self, symbol):
        return self.recent_ticks[symbol].timestamp

编写一个类来生成市场数据的来源

编写一个名为MarketDataSource的类,以帮助我们从外部数据提供商获取历史数据。在本例中,我们将使用Quandl作为我们的数据提供商。该类的构造函数定义如下:

class MarketDataSource(object):
    def __init__(self, symbol, tick_event_handler=None, start='', end=''):
        self.market_data = MarketData()

        self.symbol = symbol
        self.tick_event_handler = tick_event_handler
        self.start, self.end = start, end
        self.df = None

在构造函数中,symbol参数包含了我们的数据提供商识别的值,用于下载我们需要的数据集。实例化了一个MarketData对象来存储最新的市场数据。tick_event_handler参数存储了方法处理程序,当我们迭代数据源时使用。startend参数指的是我们希望保留在pandas DataFrame 变量df中的数据集的开始和结束日期。

MarketDataSource方法中添加fetch_historical_prices()方法,其中包含从数据提供商下载并返回所需的pandas DataFrame 对象的具体指令,该对象保存我们的每日市场价格,如下所示:

def fetch_historical_prices(self):
   import quandl

   # Update your Quandl API key here...
  QUANDL_API_KEY = 'BCzkk3NDWt7H9yjzx-DY'
  quandl.ApiConfig.api_key = QUANDL_API_KEY
   df = quandl.get(self.symbol, start_date=self.start, end_date=self.end)
   return df

由于此方法特定于 Quandl 的 API,您可以根据自己的数据提供商重新编写此方法。

此外,在MarketDataSource类中添加run()方法来模拟在回测期间从数据提供商获取流式价格:

def run(self):
    if self.df is None:
        self.df = self.fetch_historical_prices()

    total_ticks = len(self.df)
    print('Processing total_ticks:', total_ticks)

    for timestamp, row in self.df.iterrows():
        open_price = row['Open']
        close_price = row['Close']
        volume = row['Volume']

        print(timestamp.date(), 'TICK', self.symbol,
              'open:', open_price,
              'close:', close_price)
        tick_data = TickData(timestamp, self.symbol, open_price,
                            close_price, volume)
        self.market_data.add_tick_data(tick_data)

        if self.tick_event_handler:
            self.tick_event_handler(self.market_data)

请注意,第一个if语句在执行从数据提供商下载之前对现有市场数据的存在进行检查。这使我们能够在回测中运行多个模拟,使用缓存数据,避免不必要的下载开销,并使我们的回测运行更快。

for循环在我们的df市场数据变量上用于模拟流式价格。每个 tick 数据被转换和格式化为TickData的一个实例,并添加到market_data对象中作为特定符号的最新可用 tick 数据。然后将此对象传递给任何监听 tick 事件的 tick 数据事件处理程序。

编写订单类

以下代码中的Order类表示策略发送到服务器的单个订单。每个订单包含时间戳、符号、数量和指示买入或卖出订单的标志。在以下示例中,我们将仅使用市价订单,并且预计is_market_orderTrue。如果需要,可以实现其他订单类型,如限价和止损订单。一旦订单被执行,订单将进一步更新为填充价格、时间和数量。按照以下代码给出的方式编写此类:

class Order(object):
    def __init__(self, timestamp, symbol, 
        qty, is_buy, is_market_order, 
        price=0
    ):
        self.timestamp = timestamp
        self.symbol = symbol
        self.qty = qty
        self.price = price
        self.is_buy = is_buy
        self.is_market_order = is_market_order
        self.is_filled = False
        self.filled_price = 0
        self.filled_time = None
        self.filled_qty = 0

编写一个类来跟踪持仓。

Position类帮助我们跟踪我们对交易工具的当前市场位置和账户余额,并且定义如下:

class Position(object):
    def __init__(self, symbol=''):
        self.symbol = symbol
        self.buys = self.sells = self.net = 0
        self.rpnl = 0
        self.position_value = 0

已声明买入、卖出和净值的单位数量分别为buyssellsnet变量。rpnl变量存储了该符号的最*实现利润和损失。请注意,position_value变量的初始值为零。当购买证券时,证券的价值从此账户中借记。当出售证券时,证券的价值记入此账户。

当订单被填充时,账户的持仓会发生变化。在Position类中编写一个名为on_position_event()的方法来处理这些持仓事件:

def on_position_event(self, is_buy, qty, price):
    if is_buy:
        self.buys += qty
    else:
        self.sells += qty

    self.net = self.buys - self.sells
    changed_value = qty * price * (-1 if is_buy else 1)
    self.position_value += changed_value

    if self.net == 0:
        self.rpnl = self.position_value
        self.position_value = 0

在我们的持仓发生变化时,我们更新并跟踪买入和卖出的证券数量,以及证券的当前价值。当净头寸为零时,持仓被*仓,我们获得当前的实现利润和损失。

每当持仓开启时,我们的证券价值会受到市场波动的影响。有一个未实现的利润和损失的度量有助于跟踪每次 tick 移动中市场价值的变化。在Position类中添加以下calculate_unrealized_pnl()方法:

def calculate_unrealized_pnl(self, price):
    if self.net == 0:
        return 0

    market_value = self.net * price
    upnl = self.position_value + market_value
    return upnl

使用当前市场价格调用calculate_unrealized_pnl()方法可以得到特定证券当前市场价值。

编写一个抽象策略类

以下代码中给出的Strategy类是所有其他策略实现的基类,并且被写成:

from abc import abstractmethod

class Strategy:
    def __init__(self, send_order_event_handler):
        self.send_order_event_handler = send_order_event_handler

    @abstractmethod
    def on_tick_event(self, market_data):
        raise NotImplementedError('Method is required!')

    @abstractmethod
    def on_position_event(self, positions):
        raise NotImplementedError('Method is required!')

    def send_market_order(self, symbol, qty, is_buy, timestamp):
        if self.send_order_event_handler:
            order = Order(
                timestamp,
                symbol,
                qty,
                is_buy,
                is_market_order=True,
                price=0,
            )
            self.send_order_event_handler(order)

当新的市场 tick 数据到达时,将调用on_tick_event()抽象方法。子类必须实现这个抽象方法来对传入的市场价格进行操作。每当我们的持仓有更新时,将调用on_position_event()抽象方法。子类必须实现这个抽象方法来对传入的持仓更新进行操作。

send_market_order()方法由子策略类调用,将市价订单路由到经纪人。这样的事件处理程序存储在构造函数中,实际的实现由本类的所有者在下一节中完成,并直接与经纪人 API 进行接口。

编写一个均值回归策略类

在这个例子中,我们正在实现一个关于 AAPL 股票价格的均值回归交易策略。编写一个继承上一节中Strategy类的MeanRevertingStrategy子类:

import pandas as pd

class MeanRevertingStrategy(Strategy):
    def __init__(self, symbol, trade_qty,
        send_order_event_handler=None, lookback_intervals=20,
        buy_threshold=-1.5, sell_threshold=1.5
    ):
        super(MeanRevertingStrategy, self).__init__(
            send_order_event_handler)

        self.symbol = symbol
        self.trade_qty = trade_qty
        self.lookback_intervals = lookback_intervals
        self.buy_threshold = buy_threshold
        self.sell_threshold = sell_threshold

        self.prices = pd.DataFrame()
        self.is_long = self.is_short = False

在构造函数中,我们接受参数值,告诉我们的策略要交易的证券符号和每笔交易的单位数。send_order_event_handler函数变量被传递给父类进行存储。lookback_intervalsbuy_thresholdsell_threshold变量是与使用均值回归计算生成交易信号相关的参数。

pandas DataFrame prices变量将用于存储传入的价格,is_longis_short布尔变量存储此策略的当前持仓,任何时候只有一个可以为True。这些变量在MeanRevertingStrategy类中的on_position_event()方法中分配:

def on_position_event(self, positions):
    position = positions.get(self.symbol)

    self.is_long = position and position.net > 0
    self.is_short = position and position.net < 0

on_position_event()方法实现了父抽象方法,并在我们的持仓更新时被调用。

此外,在MeanRevertingStrategy类中实现on_tick_event()抽象方法:

def on_tick_event(self, market_data):
    self.store_prices(market_data)

    if len(self.prices) < self.lookback_intervals:
        return

    self.generate_signals_and_send_order(market_data)

在每个 tick-data 事件中,市场价格存储在当前策略类中,用于计算交易信号,前提是有足够的数据。在这个例子中,我们使用 20 天的日历史价格回溯期。换句话说,我们将使用过去 20 天价格的*均值来确定均值回归。在没有足够数据的情况下,我们只是跳过这一步。

MeanRevertingStrategy类中添加store_prices()方法:

def store_prices(self, market_data):
    timestamp = market_data.get_timestamp(self.symbol)
    close_price = market_data.get_close_price(self.symbol)
    self.prices.loc[timestamp, 'close'] = close_price

在每个 tick 事件上,prices DataFrame 存储每日收盘价,由时间戳索引。

生成交易信号的逻辑在MeanRevertingStrategy类中的generate_signals_and_send_order()方法中给出:

def generate_signals_and_send_order(self, market_data):
    signal_value = self.calculate_z_score()
    timestamp = market_data.get_timestamp(self.symbol)

    if self.buy_threshold > signal_value and not self.is_long:
        print(timestamp.date(), 'BUY signal')
        self.send_market_order(
            self.symbol, self.trade_qty, True, timestamp)
    elif self.sell_threshold < signal_value and not self.is_short:
        print(timestamp.date(), 'SELL signal')
        self.send_market_order(
            self.symbol, self.trade_qty, False, timestamp)

在每个 tick 事件上,计算当前时期的z-score,我们将很快介绍。一旦 z-score 超过我们的买入阈值值,就会生成买入信号。我们可以通过向经纪人发送买入市价订单来关闭空头头寸或进入多头头寸。相反,当 z-score 超过我们的卖出阈值值时,就会生成卖出信号。我们可以通过向经纪人发送卖出市价订单来关闭多头头寸或进入空头头寸。在我们的回测系统中,订单将在第二天开盘时执行。

MeanRevertingStrategy类中添加calculate_z_score()方法,用于在每个 tick 事件上计算 z-score:

def calculate_z_score(self):
    self.prices = self.prices[-self.lookback_intervals:]
    returns = self.prices['close'].pct_change().dropna()
    z_score = ((returns - returns.mean()) / returns.std())[-1]
    return z_score

使用以下公式对收盘价的每日百分比收益进行 z-score 标准化:

在这里,x是最*的收益,μ是收益的*均值,σ是收益的标准差。 z-score 值为 0 表示该分数与*均值相同。例如,买入阈值值为-1.5。当 z-score 低于-1.5 时,这表示强烈的买入信号,因为预计随后的时期的 z-score 将恢复到零的*均值。同样,卖出阈值值为 1.5 可能表示强烈的卖出信号,预计 z-score 将恢复到*均值。

因此,这个回测系统的目标是找到最优的阈值,以最大化我们的利润。

将我们的模块与回测引擎绑定

在定义了所有核心模块化组件之后,我们现在准备实现回测引擎,作为BacktestEngine类,使用以下代码:

class BacktestEngine:
    def __init__(self, symbol, trade_qty, start='', end=''):
        self.symbol = symbol
        self.trade_qty = trade_qty
        self.market_data_source = MarketDataSource(
            symbol,
            tick_event_handler=self.on_tick_event,
            start=start, end=end
        )

        self.strategy = None
        self.unfilled_orders = []
        self.positions = dict()
        self.df_rpnl = None

在回测引擎中,我们存储标的物和交易单位数量。使用标的物创建一个MarketDataSource实例,同时定义数据集的开始和结束日期。发出的 tick 事件将由我们的本地on_tick_event()方法处理,我们将很快实现。strategy变量用于存储我们均值回归策略类的一个实例。unfilled_orders变量充当我们的订单簿,将存储下一个交易日执行的市场订单。positions变量用于存储Position对象的实例,由标的物索引。df_rpnl变量用于存储我们在回测期间的实现利润和损失,我们可以在回测结束时使用它来绘图。

运行回测引擎的入口点是Backtester类中给出的start()方法。

def start(self, **kwargs):
    print('Backtest started...')

    self.unfilled_orders = []
    self.positions = dict()
    self.df_rpnl = pd.DataFrame()

    self.strategy = MeanRevertingStrategy(
        self.symbol,
        self.trade_qty,
        send_order_event_handler=self.on_order_received,
        **kwargs
    )
    self.market_data_source.run()

    print('Backtest completed.')

通过调用start()方法可以多次运行单个Backtester实例。在每次运行开始时,我们初始化unfilled_orderspositionsdf_rpl变量。使用策略类的一个新实例化,传入标的物和交易单位数量,以及一个名为on_order_received()的方法,用于接收来自策略的订单触发,以及策略需要的任何关键字kwargs参数。

BacktestEngine类中实现on_order_received()方法:

def on_order_received(self, order):
    """ Adds an order to the order book """
    print(
        order.timestamp.date(),
        'ORDER',
        'BUY' if order.is_buy else 'SELL',
        order.symbol,
        order.qty
    )
    self.unfilled_orders.append(order)

当订单生成并添加到订单簿时,我们会在控制台上收到通知。

BacktestEngine类中实现on_tick_event()方法,用于处理市场数据源发出的 tick 事件:

def on_tick_event(self, market_data):
    self.match_order_book(market_data)
    self.strategy.on_tick_event(market_data)
    self.print_position_status(market_data)

在这个例子中,市场数据源预计是每日的历史价格。接收到的 tick 事件代表一个新的交易日。在交易日开始时,我们通过调用match_order_book()方法来检查我们的订单簿,并匹配开盘时的任何未成交订单。之后,我们将最新的市场数据market_data变量传递给策略的 tick 事件处理程序,执行交易功能。在交易日结束时,我们将我们的持仓信息打印到控制台上。

BacktestEngine类中实现match_order_book()match_unfilled_orders()方法:

def match_order_book(self, market_data):
    if len(self.unfilled_orders) > 0:
        self.unfilled_orders = [
            order for order in self.unfilled_orders
            if self.match_unfilled_orders(order, market_data)
        ]

def match_unfilled_orders(self, order, market_data):
    symbol = order.symbol
    timestamp = market_data.get_timestamp(symbol)

    """ Order is matched and filled """
    if order.is_market_order and timestamp > order.timestamp:
        open_price = market_data.get_open_price(symbol)

        order.is_filled = True
        order.filled_timestamp = timestamp
        order.filled_price = open_price

        self.on_order_filled(
            symbol, order.qty, order.is_buy,
            open_price, timestamp
        )
        return False

    return True

在每次调用match_order_book()命令时,都会检查存储在unfilled_orders变量中的待处理订单列表,以便在市场中执行,并在此操作成功时从列表中移除。match_unfilled_orders()方法中的if语句验证订单是否处于正确状态,并立即以当前市场开盘价标记订单为已填充。这将触发on_order_filled()方法上的一系列事件。在BacktestEngine类中实现这个方法:

def on_order_filled(self, symbol, qty, is_buy, filled_price, timestamp):
    position = self.get_position(symbol)
    position.on_position_event(is_buy, qty, filled_price)
    self.df_rpnl.loc[timestamp, "rpnl"] = position.rpnl

    self.strategy.on_position_event(self.positions)

    print(
        timestamp.date(),
        'FILLED', "BUY" if is_buy else "SELL",
        qty, symbol, 'at', filled_price
    )

一旦订单被执行,就需要更新交易符号的相应头寸。position变量包含检索到的Position实例,并且调用其on_position_event()命令会更新其状态。实现的利润和损失会被计算并保存到pandas DataFrame df_rpnl中,并附上时间戳。通过调用on_position_event()命令,策略也会被通知头寸的变化。当这样的事件发生时,我们会在控制台上收到通知。

BacktestEngine类中添加以下get_position()方法:

 def get_position(self, symbol):
    if symbol not in self.positions:
        self.positions[symbol] = Position(symbol)

    return self.positions[symbol]

get_position()方法是一个辅助方法,简单地获取一个交易符号的当前Position对象。如果找不到实例,则创建一个。

on_tick_event()最后一次调用的命令是print_position_status()。在BacktestEngine类中实现这个方法:

def print_position_status(self, market_data):
    for symbol, position in self.positions.items():
        close_price = market_data.get_close_price(symbol)
        timestamp = market_data.get_timestamp(symbol)

        upnl = position.calculate_unrealized_pnl(close_price)

        print(
            timestamp.date(),
            'POSITION',
            'value:%.3f' % position.position_value,
            'upnl:%.3f' % upnl,
            'rpnl:%.3f' % position.rpnl
        )

在每次 tick 事件中,我们打印当前市场价值、实现和未实现利润和损失的任何可用头寸信息到控制台。

运行我们的回测引擎

BacktestEngine类中定义了所有必需的方法后,我们现在可以使用以下代码创建这个类的一个实例:

engine = BacktestEngine(
    'WIKI/AAPL', 1,
    start='2015-01-01',
    end='2017-12-31'
)

在这个例子中,我们对每次交易感兴趣,使用 2015 年到 2017 年三年的每日历史数据进行回测。

发出start()命令来运行回测引擎:

engine.start(
    lookback_intervals=20,
    buy_threshold=-1.5,
    sell_threshold=1.5
)

lookback_interval参数参数值为 20 告诉我们的策略在计算 z 分数时使用最* 20 天的历史每日价格。buy_thresholdsell_threshold参数参数定义了生成买入或卖出信号的边界限制。在这个例子中,-1.5 的买入阈值值表示当 z 分数低于-1.5 时希望持有多头头寸。同样,1.5 的卖出阈值值表示当 z 分数上升到 1.5 以上时希望持有空头头寸。

当引擎运行时,您将看到以下输出:

Backtest started...
Processing total_ticks: 753
2015-01-02 TICK WIKI/AAPL open: 111.39 close: 109.33
...
2015-02-25 TICK WIKI/AAPL open: 131.56 close: 128.79
2015-02-25 BUY signal
2015-02-25 ORDER BUY WIKI/AAPL 1
2015-02-26 TICK WIKI/AAPL open: 128.785 close: 130.415
2015-02-26 FILLED BUY 1 WIKI/AAPL at 128.785
2015-02-26 POSITION value:-128.785 upnl:1.630 rpnl:0.000
2015-02-27 TICK WIKI/AAPL open: 130.0 close: 128.46

从输出日志中,我们可以看到在 2015 年 2 月 25 日生成了一个买入信号,并且在下一个交易日 2 月 26 日开盘时以 128.785 美元的价格向订单簿中添加了一个市价订单以执行。到交易日结束时,我们的多头头寸将有 1.63 美元的未实现利润:

...
2015-03-30 TICK WIKI/AAPL open: 124.05 close: 126.37
2015-03-30 SELL signal
2015-03-30 ORDER SELL WIKI/AAPL 1
2015-03-30 POSITION value:-128.785 upnl:-2.415 rpnl:0.000
2015-03-31 TICK WIKI/AAPL open: 126.09 close: 124.43
2015-03-31 FILLED SELL 1 WIKI/AAPL at 126.09
2015-03-31 POSITION value:0.000 upnl:0.000 rpnl:-2.695
...

继续向下滚动日志,您会看到在 2015 年 3 月 30 日生成了一个卖出信号,并且在下一天 3 月 31 日以 126.09 美元的价格执行了一个卖出市价订单。这关闭了我们的多头头寸,并使我们遭受了 2.695 美元的实现损失。

当回测引擎完成时,我们可以使用以下 Python 代码将我们的策略实现的利润和损失绘制到图表上,以可视化这个交易策略:

%matplotlib inline
import matplotlib.pyplot as plt

engine.df_rpnl.plot(figsize=(12, 8));

这给我们以下输出:

请注意,回测结束时,实现的利润和损失并不完整。我们可能仍然持有未实现的利润或损失的多头或空头头寸。在评估策略时,请确保考虑到这个剩余价值。

回测引擎的多次运行

使用固定的策略参数,我们能够让回测引擎运行一次并可视化其性能。由于回测的目标是找到适用于交易系统考虑的最佳策略参数,我们希望我们的回测引擎在不同的策略参数上多次运行。

例如,定义我们想要在名为THRESHOLDS的常量变量中测试的阈值列表:

THRESHOLDS = [
    (-0.5, 0.5),
    (-1.5, 1.5),
    (-2.5, 2.0),
    (-1.5, 2.5),
]

列表中的每个项目都是买入和卖出阈值值的元组。我们可以使用for循环迭代这些值,调用engine.start()命令,并在每次迭代时绘制图表,使用以下代码:

%matplotlib inline import matplotlib.pyplot as plt

fig, axes = plt.subplots(nrows=len(THRESHOLDS)//2, 
    ncols=2, figsize=(12, 8))
fig.subplots_adjust(hspace=0.4)
for i, (buy_threshold, sell_threshold) in enumerate(THRESHOLDS):
     engine.start(
         lookback_intervals=20,
         buy_threshold=buy_threshold,
         sell_threshold=sell_threshold
     )
     df_rpnls = engine.df_rpnl
     ax = axes[i // 2, i % 2]
     ax.set_title(
         'B/S thresholds:(%s,%s)' % 
         (buy_threshold, sell_threshold)
     )
     df_rpnls.plot(ax=ax)

我们得到以下输出:

四个图显示了在我们的策略中使用各种阈值时的结果。通过改变策略参数,我们得到了不同的风险和回报概况。也许您可以找到更好的策略参数,以获得比这更好的结果!

改进您的回测系统

在本章中,我们基于每日收盘价创建了一个简单的回测系统,用于均值回归策略。有几个方面需要考虑,以使这样一个回测模型更加现实。历史每日价格足以测试我们的模型吗?应该使用日内限价单吗?我们的账户价值从零开始;如何能够准确反映我们的资本需求?我们能够借股做空吗?

由于我们在创建回测系统时采用了面向对象的方法,将来集成其他组件会有多容易?交易系统可以接受多个市场数据源。我们还可以创建组件,使我们能够将系统部署到生产环境中。

上述提到的关注点列表并不详尽。为了指导我们实施健壮的回测模型,下一节详细阐述了设计这样一个系统的十个考虑因素。

回测模型的十个考虑因素

在上一节中,我们进行了一次回测的复制。我们的结果看起来相当乐观。然而,这足以推断这是一个好模型吗?事实是,回测涉及大量研究,值得有自己的文献。以下列表简要涵盖了在实施回测时您可能想要考虑的一些想法。

限制模型的资源

可用于您的回测系统的资源限制了您可以实施回测的程度。只使用最后收盘价生成信号的金融模型需要一组收盘价的历史数据。需要从订单簿中读取的交易系统需要在每个 tick 上都有订单簿数据的所有级别。这增加了存储复杂性。其他资源,如交易所数据、估计技术和计算机资源,对可以使用的模型的性质施加了限制。

模型评估标准

我们如何得出模型好坏的结论?一些要考虑的因素包括夏普比率、命中率、*均收益率、VaR 统计数据,以及遇到的最小和最大回撤。这些因素的组合如何*衡,使模型可用?在实现高夏普比率时,最大回撤能够容忍多少?

估计回测参数的质量

在模型上使用各种参数通常会给我们带来不同的结果。从多个模型中,我们可以获得每个模型的额外数据集。最佳表现模型的参数可信吗?使用模型*均等方法可以帮助我们纠正乐观的估计。

模型*均技术是对多个模型的*均拟合,而不是使用单个最佳模型。

做好面对模型风险的准备

也许经过广泛的回测,你可能会发现自己拥有一个高质量的模型。它会保持多久?在模型风险中,市场结构或模型参数可能会随时间改变,或者制度变革可能会导致你的模型的功能形式突然改变。到那时,你甚至可能不确定你的模型是否正确。解决模型风险的方法是模型*均。

使用样本内数据进行回测

回测帮助我们进行广泛的参数搜索,优化模型的结果。这利用了样本数据的真实和特异方面。此外,历史数据永远无法模仿整个数据来自实时市场的方式。这些优化的结果将始终产生对模型和使用的策略的乐观评估。

解决回测中的常见陷阱

回测中最常见的错误是前瞻性偏差,它有许多形式。例如,参数估计可能来自样本数据的整个时期,这构成了使用未来信息。这些统计估计和模型选择应该按顺序估计,这实际上可能很难做到。

数据错误以各种形式出现,从硬件、软件和人为错误,可能在数据分发供应商路由时发生。上市公司可能会分拆、合并或退市,导致其股价发生重大变化。这些行动可能导致我们的模型中出现生存偏差。未能正确清理数据将给予数据的特异方面不当的影响,从而影响模型参数。

生存偏差是一种逻辑错误,它集中于经历了某种过去选择过程的结果。例如,股市指数可能会报告在不好的时候也有强劲的表现,因为表现不佳的股票被从其组成权重中剔除,导致对过去收益的高估。

未使用收缩估计量或模型*均可能会报告包含极端值的结果,使比较和评估变得困难。

在统计学中,收缩估计量被用作普通最小二乘估计量的替代,以产生最小均方误差。它们可以用来将模型输出的原始估计值收缩到零或另一个固定的常数值。

对模型有一个常识性的想法

在我们的模型中常常缺乏常识。我们可能会尝试用趋势变量解释无趋势变量,或者从相关性推断因果关系。当上下文需要或不需要时,可以使用对数值吗?让我们在接下来的部分看看。

了解模型的背景

对模型有一个常识性的想法几乎是不够的。一个好的模型考虑了历史、参与人员、运营约束、常见的特殊情况,以及对模型的理性理解。商品价格是否遵循季节性变动?数据是如何收集的?用于计算变量的公式可靠吗?这些问题可以帮助我们确定原因,如果出现问题。

确保你有正确的数据

我们中的许多人都无法访问 tick 级别的数据。低分辨率的 tick 数据可能会错过详细信息。即使是 tick 级别的数据也可能充满错误。使用摘要统计数据,如均值、标准误差、最大值、最小值和相关性,告诉我们很多关于数据的性质,无论我们是否真的可以使用它,或者推断回测参数估计。

当进行数据清理时,我们可能会问这些问题:需要注意什么?数值是否现实和合理?缺失数据是如何编码的?

制定一套报告数据和结果的系统。使用图表有助于人眼可视化可能出乎意料的模式。直方图可能显示出意想不到的分布,或者残差图可能显示出意想不到的预测误差模式。残差化数据的散点图可能显示出额外的建模机会。

残差化数据是观察值与模型值之间的差异或残差

挖掘你的结果

通过对多次回测进行迭代,结果代表了关于模型的信息来源。在实时条件下运行模型会产生另一个结果来源。通过数据挖掘所有这些丰富的信息,我们可以获得一个避免将模型规格定制到样本数据的数据驱动结果。建议在报告结果时使用收缩估计或模型*均。

回测中的算法讨论

在考虑设计回测模型时,可以使用一个或多个算法来持续改进模型。本节简要介绍了在回测领域使用的一些算法技术,如数据挖掘和机器学习。

K 均值聚类

k 均值聚类算法是数据挖掘中的一种聚类分析方法。从n次观察的回测结果中,k 均值算法旨在根据它们相对距离将数据分类为k个簇。计算每个簇的中心点。目标是找到给出模型*均点的簇内*方和。模型*均点表示模型的可能*均性能,可用于与其他模型的性能进行进一步比较。

K 最*邻机器学习算法

k 最*邻KNN)是一种懒惰学习技术,不构建任何模型。

初始的回测模型参数集是随机选择或最佳猜测。

在分析模型结果之后,将使用与原始集最接*的k个参数集进行下一步计算。然后模型将选择给出最佳结果的参数集。

该过程持续进行,直到达到终止条件,从而始终提供可用的最佳模型参数集。

分类和回归树分析

分类和回归树CART)分析包含两个用于数据挖掘的决策树。分类树使用分类规则通过决策树中的节点和分支对模型的结果进行分类。回归树试图为分类结果分配一个实际值。得到的值被*均以提供决策质量的度量。

2k 阶乘设计

在设计回测实验时,可以考虑使用2k 阶乘设计。假设有两个因素 A 和 B。每个因素都是布尔值,取值为+1 或-1。+1 表示定量高值,而-1 表示低值。这给我们提供了 2²=4 种结果的组合。对于 3 因素模型,这给我们提供了 2³=8 种结果的组合。以下表格说明了具有 W、X、Y 和 Z 结果的两个因素的示例:

A B 复制 I
+1 +1 W
+1 -1 X
-1 +1 Y
-1 -1 Z

请注意,我们正在生成一个回测的复制,以产生一组结果。进行额外的复制可以为我们提供更多信息。从这些数据中,我们可以进行回归分析和分析其方差。这些测试的目标是确定哪些因素 A 或 B 对另一个更有影响,并选择哪些值,使结果要么接*某个期望值,能够实现低方差,或者最小化不可控变量的影响。

遗传算法

遗传算法(GA)是一种技术,其中每个个体通过自然选择的过程进化,以优化问题。在优化问题中,候选解的种群经历选择的迭代过程,成为父代,经历突变和交叉以产生下一代后代。经过连续世代的循环,种群朝着最优解进化。

遗传算法的应用可以应用于各种优化问题,包括回测,特别适用于解决标准优化、不连续或非可微问题或非线性结果。

总结

回测是模型驱动的投资策略对历史数据的响应的模拟。进行回测实验的目的是发现有关过程或系统的信息,并计算与风险或回报相关的各种因素。这些因素通常一起使用,以找到预测回报的组合。

在设计和开发回测时,以创建视频游戏的概念思考将会很有帮助。在虚拟交易环境中,需要组件来模拟价格流、订单匹配引擎、订单簿管理,以及账户和持仓更新的功能。为了实现这些功能,我们可以探索事件驱动的回测系统的概念。

在本章中,我们设计并实现了一个回测系统,与处理 tick 数据的各种组件进行交互,从数据提供商获取历史价格,处理订单和持仓更新,并模拟触发我们策略执行均值回归计算的流动价格。每个周期的 z 分数被评估为交易信号,这导致生成市场订单,以在下一个交易日开盘时执行。我们进行了单次回测运行以及多次运行,参数不同的策略,绘制了结果的利润和损失,以帮助我们可视化我们交易策略的表现。

回测涉及大量研究,值得有专门的文献。在本章中,我们探讨了设计回测模型的十个考虑因素。为了持续改进我们的模型,可以在回测中使用许多算法。我们简要讨论了其中一些:k 均值聚类,k 最*邻,分类和回归树,2k 因子设计和遗传算法。

在下一章中,我们将学习使用机器学习进行预测。

第十章:金融机器学习

机器学习正在迅速被金融服务行业广泛采用。金融服务业对机器学习的采用受到供应因素的推动,如数据存储、算法和计算基础设施的技术进步,以及需求因素的推动,如盈利需求、与其他公司的竞争,以及监管和监督要求。金融中的机器学习包括算法交易、投资组合管理、保险承保和欺诈检测等多个领域。

有几种类型的机器学习算法,但在机器学习文献中你通常会遇到的两种主要算法是监督学习和无监督学习。我们本章的讨论重点在监督学习上。监督学习涉及提供输入和输出数据来帮助机器预测新的输入数据。监督学习可以是基于回归或基于分类的。基于回归的机器学习算法预测连续值,而基于分类的机器学习算法预测类别或标签。

在本章中,我们将介绍机器学习,研究其在金融领域的概念和应用,并查看一些应用机器学习来辅助交易决策的实际例子。我们将涵盖以下主题:

  • 探索金融中的机器学习应用

  • 监督学习和无监督学习

  • 基于分类和基于回归的机器学习

  • 使用 scikit-learn 实现机器学习算法

  • 应用单资产回归机器学习来预测价格

  • 了解风险度量标准以衡量回归模型

  • 应用多资产回归机器学习来预测回报

  • 应用基于分类的机器学习来预测趋势

  • 了解风险度量标准以衡量分类模型

机器学习简介

在机器学习算法成熟之前,许多软件应用决策都是基于规则的,由一堆ifelse语句组成,以生成适当的响应以交换一些输入数据。一个常见的例子是电子邮箱收件箱中的垃圾邮件过滤器功能。邮箱可能包含由邮件服务器管理员或所有者定义的黑名单词。传入的电子邮件内容会被扫描以检查是否包含黑名单词,如果黑名单条件成立,邮件将被标记为垃圾邮件并发送到“垃圾邮件”文件夹。随着不受欢迎的电子邮件的性质不断演变以避免被检测,垃圾邮件过滤机制也必须不断更新自己以做得更好。然而,通过机器学习,垃圾邮件过滤器可以自动从过去的电子邮件数据中学习,并在收到新的电子邮件时计算分类新邮件是否为垃圾邮件的可能性。

面部识别和图像检测背后的算法基本上是相同的。存储在位和字节中的数字图像被收集、分析和分类,根据所有者提供的预期响应。这个过程被称为“训练”,使用“监督学习”方法。经过训练的数据随后可以用于预测下一组输入数据作为某种输出响应,并带有一定的置信水*。另一方面,当训练数据不包含预期的响应时,机器学习算法被期望从训练数据中学习,这个过程被称为“无监督学习”。

金融中的机器学习应用

机器学习在金融领域的许多领域中越来越多地发挥作用,如数据安全、客户服务、预测和金融服务。许多使用案例也利用了大数据和人工智能,它们并不仅限于机器学习。在本节中,我们将探讨机器学习如何改变金融行业的一些方式。

算法交易

机器学习算法研究高度相关资产价格的统计特性,在回测期间测量它们对历史数据的预测能力,并预测价格在一定精度范围内。机器学习交易算法可能涉及对订单簿、市场深度和成交量、新闻发布、盈利电话或财务报表的分析,分析结果转化为价格变动可能性,并纳入生成交易信号的考虑。

投资组合管理

*年来,“机器顾问”这一概念越来越受欢迎,作为自动化对冲基金经理。它们帮助进行投资组合构建、优化、配置和再*衡,甚至根据客户的风险承受能力和首选投资工具建议客户投资的工具。这些咨询服务作为与数字财务规划师互动的*台,提供财务建议和投资组合管理。

监管和监管职能

金融机构和监管机构正在采用人工智能和机器学习来分析、识别和标记需要进一步调查的可疑交易。像证券交易委员会这样的监管机构采取数据驱动的方法,利用人工智能、机器学习和自然语言处理来识别需要执法的行为。全球范围内,中央机构正在开发监管职能的机器学习能力。

保险和贷款承销

保险公司积极利用人工智能和机器学习来增强一些保险行业的功能,改善保险产品的定价和营销,减少理赔处理时间和运营成本。在贷款承销方面,单个消费者的许多数据点,如年龄、收入和信用评分,与候选人数据库进行比较,以建立信用风险概况,确定信用评分,并计算贷款违约的可能性。这些数据依赖于金融机构的交易和支付历史。然而,放贷人越来越多地转向社交媒体活动、手机使用和消息活动,以捕捉对信用价值的更全面的观点,加快放贷决策,限制增量风险,并提高贷款的评级准确性。

新闻情绪分析

自然语言处理,作为机器学习的一个子集,可以用于分析替代数据、财务报表、新闻公告,甚至是 Twitter 动态,以创建由对冲基金、高频交易公司、社交交易和投资*台使用的投资情绪指标,用于实时分析市场。政治家的演讲,或者重要的新发布,比如中央银行发布的,也正在实时分析,每个字都在被审查和计算,以预测资产价格可能会如何变动以及变动的幅度。机器学习不仅能理解股价和交易的波动,还能理解社交媒体动态、新闻趋势和其他数据来源。

金融之外的机器学习

机器学习越来越多地应用于面部识别、语音识别、生物识别、贸易结算、聊天机器人、销售推荐、内容创作等领域。随着机器学习算法的改进和采用速度的加快,使用案例的列表变得更加长。

让我们通过了解一些术语来开始我们的机器学习之旅,这些术语在机器学习文献中经常出现。

监督和无监督学习

有许多类型的机器学习算法,但你通常会遇到的两种主要类型是监督和无监督机器学习。

监督学习

监督学习从给定的输入中预测特定的输出。这些输入到输出数据的配对被称为训练数据。预测的质量完全取决于训练数据;不正确的训练数据会降低机器学习模型的有效性。例如,一个带有标签的交易数据集,标识哪些是欺诈交易,哪些不是。然后可以构建模型来预测新交易是否是欺诈交易。

监督学习中一些常见的算法包括逻辑回归、支持向量机和随机森林。

无监督学习

无监督学习是基于给定的不包含标签的输入数据构建模型,而是要求检测数据中的模式。这可能涉及识别具有相似基本特征的观察值的聚类。无监督学习旨在对新的、以前未见过的数据进行准确预测。

例如,无监督学习模型可以通过寻找具有相似特征的证券群来定价不流动的证券。常见的无监督学习算法包括 k 均值聚类、主成分分析和自动编码器。

监督机器学习中的分类和回归

有两种主要类型的监督机器学习算法,主要是分类和回归。分类机器学习模型试图从预定义的可能性列表中预测和分类响应。这些预定义的可能性可能是二元分类(例如对问题的“是这封电子邮件是垃圾吗?”的回答)或多类分类。

回归机器学习模型试图预测连续的输出值。例如,预测房价或温度都期望连续范围的输出值。常见的回归形式有普通最小二乘(OLS)回归、LASSO 回归、岭回归和弹性网络正则化。

过度拟合和欠拟合模型

机器学习模型的性能不佳可能是由于过度拟合或欠拟合造成的。过度拟合的机器学习模型是指在训练数据上训练得太好,导致在新数据上表现不佳。这是因为训练数据适应了每一个微小的变化,包括噪音和随机波动。无监督学习算法非常容易过度拟合,因为模型从每个数据中学习,包括好的和坏的。

欠拟合的机器学习模型预测准确性差。这可能是由于可用于构建准确模型的训练数据太少,或者数据不适合提取其潜在趋势。欠拟合模型很容易检测,因为它们始终表现不佳。要改进这样的模型,可以提供更多的训练数据或使用另一个机器学习算法。

特征工程

特征是定义数据特征的属性。通过使用数据的领域知识,可以创建特征来帮助机器学习算法提高其预测性能。这可以是简单的将现有数据的相关部分分组或分桶以形成定义特征。甚至删除不需要的特征也是特征工程。

例如,假设我们有以下时间序列价格数据:

编号 日期和时间 价格 价格行动
1 2019-01-02 09:00:01 55.00 上涨
2 2019-01-02 10:03:42 45.00 下跌
3 2019-01-02 10:31:23 48.00 上涨
4 2019-01-02 11:14:02 33.00 DOWN

通过一天中的小时将时间序列分组,并在每个时间段内采取最后的价格行动,我们得到了这样一个特征:

No. Hour of Day Last Price Action
1 9 UP
2 10 UP
3 11 DOWN

特征工程的过程包括以下四个步骤:

  1. 构思要包括在训练模型中的特征

  2. 创建这些特征

  3. 检查特征如何与模型配合

  4. 从步骤 1 重复,直到特征完美工作

在构建特征方面,没有绝对的硬性规则。特征工程被认为更像是一门艺术而不是科学。

用于机器学习的 Scikit-learn

Scikit-learn 是一个专为科学计算设计的 Python 库,包含一些最先进的机器学习算法,用于分类、回归、聚类、降维、模型选择和预处理。其名称源自 SciPy 工具包,这是 SciPy 模块的扩展。有关 scikit-learn 的详细文档可以在scikit-learn.org找到。

SciPy 是用于科学计算的 Python 模块集合,包含一些核心包,如 NumPy、Matplotlib、IPython 等。

在本章中,我们将使用 scikit-learn 的机器学习算法来预测证券的走势。Scikit-learn 需要安装 NumPy 和 SciPy。使用以下命令通过pip包管理器安装 scikit-learn:

 pip install scikit-learn

使用单一资产回归模型预测价格

配对交易是一种常见的统计套利交易策略,交易者使用一对协整和高度正相关的资产,尽管也可以考虑负相关的配对。

在本节中,我们将使用机器学习来训练基于回归的模型,使用一对可能用于配对交易的证券的历史价格。给定某一天某一证券的当前价格,我们每天预测另一证券的价格。以下示例使用了纽约证券交易所NYSE)上交易的高盛GS)和摩根大通JPM)的历史每日价格。我们将预测 2018 年 JPM 股票价格。

通过 OLS 进行线性回归

让我们从一个简单的线性回归模型开始我们的基于回归的机器学习调查。一条直线的形式如下:

这尝试通过 OLS 拟合数据:

  • a是斜率或系数

  • cy截距的值

  • x是输入数据集

  • 是直线的预测值

系数和截距由最小化成本函数确定:

y是用于执行直线拟合的观察实际值的数据集。换句话说,我们正在执行最小化*方误差和,以找到系数ac,从中我们可以预测当前时期。

在开发模型之前,让我们下载并准备所需的数据集。

准备自变量和目标变量

让我们使用以下代码从 Alpha Vantage 获取 GS 和 JPM 的价格数据集:

In [ ]:
    from alpha_vantage.timeseries import TimeSeries

    # Update your Alpha Vantage API key here...
    ALPHA_VANTAGE_API_KEY = 'PZ2ISG9CYY379KLI'

    ts = TimeSeries(key=ALPHA_VANTAGE_API_KEY, output_format='pandas')
    df_jpm, meta_data = ts.get_daily_adjusted(
        symbol='JPM', outputsize='full')
    df_gs, meta_data = ts.get_daily_adjusted(
        symbol='GS', outputsize='full')

pandas DataFrame 对象df_jpmdf_gs包含了 JPM 和 GS 的下载价格。我们将从每个数据集的第五列中提取调整后的收盘价。

让我们使用以下代码准备我们的自变量:

In [ ]:
    import pandas as pd

    df_x = pd.DataFrame({'GS': df_gs['5\. adjusted close']})

从 GS 的调整收盘价中提取到一个新的 DataFrame 对象df_x。接下来,使用以下代码获取我们的目标变量:

In [ ]: 
    jpm_prices = df_jpm['5\. adjusted close']

JPM 的调整收盘价被提取到jpm_prices变量中,作为一个pandas Series 对象。准备好我们的数据集以用于建模后,让我们继续开发线性回归模型。

编写线性回归模型

我们将创建一个类,用于使用线性回归模型拟合和预测值。这个类还用作在本章中实现其他模型的基类。以下步骤说明了这个过程。

  1. 声明一个名为LinearRegressionModel的类,如下所示:
from sklearn.linear_model import LinearRegression

class LinearRegressionModel(object):
    def __init__(self):
        self.df_result = pd.DataFrame(columns=['Actual', 'Predicted'])

    def get_model(self):
        return LinearRegression(fit_intercept=False)

    def get_prices_since(self, df, date_since, lookback):
        index = df.index.get_loc(date_since)
        return df.iloc[index-lookback:index]        

在我们新类的构造函数中,我们声明了一个名为df_result的 pandas DataFrame,用于存储之后绘制图表时的实际值和预测值。get_model()方法返回sklearn.linear_model模块中LinearRegression类的一个实例,用于拟合和预测数据。set_intercept参数设置为True,因为数据没有居中(即在xy轴上都围绕 0)。

有关 scikit-learn 的LinearRegression的更多信息可以在scikit-learn.org/stable/modules/generated/sklearn.linear_model.LinearRegression.html找到。

get_prices_since()方法使用iloc命令从给定的日期索引date_since开始,获取由lookback值定义的较早期间的子集。

  1. LinearRegressionModel类中添加一个名为learn()的方法,如下所示:
def learn(self, df, ys, start_date, end_date, lookback_period=20):
     model = self.get_model()

     for date in df[start_date:end_date].index:
         # Fit the model
         x = self.get_prices_since(df, date, lookback_period)
         y = self.get_prices_since(ys, date, lookback_period)
         model.fit(x, y.ravel())

         # Predict the current period
         x_current = df.loc[date].values
         [y_pred] = model.predict([x_current])

         # Store predictions
         new_index = pd.to_datetime(date, format='%Y-%m-%d')
         y_actual = ys.loc[date]
         self.df_result.loc[new_index] = [y_actual, y_pred]

learn()方法作为运行模型的入口点。它接受dfys参数作为我们的自变量和目标变量,start_dateend_date作为对应于我们将要预测的数据集索引的字符串,以及lookback_period参数作为用于拟合当前期间模型的历史数据点的数量。

for循环模拟了每日的回测。调用get_prices_since()获取数据集的子集,用于在xy轴上拟合模型。ravel()命令将给定的pandas Series 对象转换为用于拟合模型的目标值的扁*列表。

x_current变量表示指定日期的自变量值,输入到predict()方法中。预测的输出是一个list对象,我们从中提取第一个值。实际值和预测值都保存到df_result DataFrame 中,由当前日期作为pandas对象的索引。

  1. 让我们实例化这个类,并通过以下命令运行我们的机器学习模型:
In [ ]:
    linear_reg_model = LinearRegressionModel()
    linear_reg_model.learn(df_x, jpm_prices, start_date='2018', 
                           end_date='2019', lookback_period=20)

learn()命令中,我们提供了我们准备好的数据集df_xjpm_prices,并指定了 2018 年的预测。在这个例子中,我们假设一个月有 20 个交易日。使用lookback_period值为20,我们使用过去一个月的价格来拟合我们的模型以进行每日预测。

  1. 让我们从模型中检索结果的df_result DataFrame,并绘制实际值和预测值:
In [ ]:
    %matplotlib inline

    linear_reg_model.df_result.plot(
        title='JPM prediction by OLS', 
        style=['-', '--'], figsize=(12,8));

style参数中,我们指定实际值绘制为实线,预测值绘制为虚线。这给我们以下图表:

图表显示我们的预测结果在一定程度上紧随实际值。我们的模型实际上表现如何?在下一节中,我们将讨论用于衡量基于回归的模型的几种常见风险指标。

用于衡量预测性能的风险指标

sklearn.metrics模块实现了几种用于衡量预测性能的回归指标。我们将在接下来的部分讨论*均绝对误差、均方误差、解释方差得分和 R²得分。

作为风险指标的*均绝对误差

*均绝对误差MAE)是一种风险度量,衡量了*均绝对预测误差,可以表示如下:

这里,y分别是实际值和预测值的列表,长度相同,为ny[i]分别是索引i处的预测值和实际值。取绝对值意味着我们的输出结果为正小数。希望 MAE 的值尽可能低。完美的分数为 0 表示我们的预测能力与实际值完全一致,因为两者之间没有差异。

使用sklearn.metrics模块的mean_abolute_error函数获得我们预测的 MAE 值,以下是代码:

In [ ]:
    from sklearn.metrics import mean_absolute_error

    actual = linear_reg_model.df_result['Actual']
    predicted = linear_reg_model.df_result['Predicted']

    mae = mean_absolute_error(actual, predicted)
    print('mean absolute error:', mae)
Out[ ]:
    mean absolute error: 2.4581692107823367

我们的线性回归模型的 MAE 为 2.458。

均方误差作为风险度量

与 MAE 类似,均方误差MSE)也是一种风险度量,衡量了预测误差的*方的*均值,可以表示如下:

*方误差意味着 MSE 的值始终为正,并且希望 MSE 的值尽可能低。完美的 MSE 分数为 0 表示我们的预测能力与实际值完全一致,这些差异的*方可以忽略不计。虽然 MSE 和 MAE 的应用有助于确定模型预测能力的强度,但 MSE 通过对偏离均值较远的错误进行惩罚而胜过 MAE。*方误差对风险度量施加了更重的偏见。

使用以下代码通过sklearn.metrics模块的mean_squared_error函数获得我们预测的 MSE 值:

In [ ]:
    from sklearn.metrics import mean_squared_error
    mse = mean_squared_error(actual, predicted)
    print('mean squared error:', mse)
Out[ ]:
    mean squared error: 12.156835196436589

我们的线性回归模型的 MSE 为 12.156。

解释方差分数作为风险度量

解释方差分数解释了给定数据集的误差分散,公式如下:

这里,Var(y)分别是预测误差和实际值的方差。接* 1.0 的分数是非常理想的,表示误差标准差的*方更好。

使用sklearn.metrics模块的explained_variance_score函数获得我们预测的解释方差分数的值,以下是代码:

In [ ]:
    from sklearn.metrics import explained_variance_score
    eva = explained_variance_score(actual, predicted)
    print('explained variance score:', eva)
Out[ ]:
    explained variance score: 0.5332235487812286

我们线性回归模型的解释方差分数为 0.533。

R²作为风险度量

R²分数也被称为确定系数,它衡量了模型对未来样本的预测能力。它的表示如下:

这里,是实际值的均值,可以表示如下:

R²分数的范围从负值到 1.0。R²分数为 1 表示回归分析没有误差,而分数为 0 表示模型总是预测目标值的*均值。负的 R²分数表示预测表现低于*均水*。

使用sklearn.metrics模块的r2_score函数获得我们预测的 R²分数,以下是代码:

In [ ]:
    from sklearn.metrics import r2_score
    r2 = r2_score(actual, predicted) 
    print('r2 score:', r2)
Out[ ]:
    r2 score: 0.41668246393290576

我们的线性回归模型的 R²为 0.4167。这意味着 41.67%的目标变量的可变性已经被解释。

岭回归

岭回归,或 L2 正则化,通过惩罚模型系数的*方和来解决 OLS 回归的一些问题。岭回归的代价函数可以表示如下:

在这里,α参数预期是一个控制收缩量的正值。较大的 alpha 值会产生更大的收缩,使得系数对共线性更加稳健。

sklearn.linear_model模块的Ridge类实现了岭回归。要实现这个模型,创建一个名为RidgeRegressionModel的类,扩展LinearRegressionModel类,并运行以下代码:

In [ ]:
    from sklearn.linear_model import Ridge

    class RidgeRegressionModel(LinearRegressionModel): 
        def get_model(self):
            return Ridge(alpha=.5)

    ridge_reg_model = RidgeRegressionModel()
    ridge_reg_model.learn(df_x, jpm_prices, start_date='2018', 
                          end_date='2019', lookback_period=20)

在新类中,重写get_model()方法以返回 scikit-learn 的岭回归模型,同时重用父类中的其他方法。将alpha值设为 0.5,其余模型参数保持默认。ridge_reg_model变量表示我们的岭回归模型的一个实例,并使用通常的参数值运行learn()命令。

创建一个名为print_regression_metrics()的函数,以打印之前介绍的各种风险指标:

In [ ]:
    from sklearn.metrics import (
        accuracy_score, mean_absolute_error, 
        explained_variance_score, r2_score
    )
    def print_regression_metrics(df_result):
        actual = list(df_result['Actual'])
        predicted = list(df_result['Predicted'])
        print('mean_absolute_error:', 
            mean_absolute_error(actual, predicted))
        print('mean_squared_error:', mean_squared_error(actual, predicted))
        print('explained_variance_score:', 
            explained_variance_score(actual, predicted))
        print('r2_score:', r2_score(actual, predicted)) 

df_result变量传递给此函数,并在控制台显示风险指标:

In [ ]:
    print_regression_metrics(ridge_reg_model.df_result)
Out[ ]:
    mean_absolute_error: 1.5894879428144535
    mean_squared_error: 4.519795633665941
    explained_variance_score: 0.7954229624785825
    r2_score: 0.7831280913202121

岭回归模型的*均误差得分都低于线性回归模型,并且更接*于零。解释方差得分和 R²得分都高于线性回归模型,并且更接*于 1。这表明我们的岭回归模型在预测方面比线性回归模型做得更好。除了性能更好外,岭回归计算成本也比原始线性回归模型低。

其他回归模型

sklearn.linear_model模块包含了我们可以考虑在模型中实现的各种回归模型。其余部分简要描述了它们。线性模型的完整列表可在scikit-learn.org/stable/modules/classes.html#module-sklearn.linear_model找到。

Lasso 回归

与岭回归类似,最小绝对值收缩和选择算子LASSO)回归也是正则化的另一种形式,涉及对回归系数的绝对值之和进行惩罚。它使用 L1 正则化技术。LASSO 回归的成本函数可以写成如下形式:

与岭回归类似,alpha 参数α控制惩罚的强度。然而,由于几何原因,LASSO 回归产生的结果与岭回归不同,因为它强制大多数系数被设为零。它更适合估计稀疏系数和具有较少参数值的模型。

sklearn.linear_modelLasso类实现了 LASSO 回归。

弹性网络

弹性网络是另一种正则化回归方法,结合了 LASSO 和岭回归方法的 L1 和 L2 惩罚。弹性网络的成本函数可以写成如下形式:

这里解释了 alpha 值:

在这里,alphal1_ratioElasticNet函数的参数。当alpha为零时,成本函数等同于 OLS。当l1_ratio为零时,惩罚是岭或 L2 惩罚。当l1_ratio为 1 时,惩罚是 LASSO 或 L1 惩罚。当l1_ratio在 0 和 1 之间时,惩罚是 L1 和 L2 的组合。

sklearn.linear_modelElasticNet类实现了弹性网络回归。

结论

我们使用了单资产的趋势跟随动量策略通过回归来预测使用 GS 的 JPM 价格,假设这对是协整的并且高度相关。我们也可以考虑跨资产动量来从多样化中获得更好的结果。下一节将探讨用于预测证券回报的多资产回归。

使用跨资产动量模型预测回报

在本节中,我们将通过拥有四种多样化资产的价格来预测 2018 年 JPM 的每日回报,创建一个跨资产动量模型。我们将使用 S&P 500 股票指数、10 年期国库券指数、美元指数和黄金价格的先前 1 个月、3 个月、6 个月和 1 年的滞后回报来拟合我们的模型。这给我们总共 16 个特征。让我们开始准备我们的数据集来开发我们的模型。

准备独立变量

我们将再次使用 Alpha Vantage 作为我们的数据提供者。由于这项免费服务没有提供我们调查所需的所有数据集,我们将考虑其他相关资产作为代理。标准普尔 500 股票指数的股票代码是 SPX。我们将使用 SPDR Gold Trust(股票代码:GLD)来表示黄金价格的代理。Invesco DB 美元指数看涨基金(股票代码:UUP)将代表美元指数。iShares 7-10 年期国库券 ETF(股票代码:IEF)将代表 10 年期国库券指数。运行以下代码来下载我们的数据集:

In [ ]:
    df_spx, meta_data = ts.get_daily_adjusted(
        symbol='SPX', outputsize='full')
    df_gld, meta_data = ts.get_daily_adjusted(
        symbol='GLD', outputsize='full')
    df_dxy, dxy_meta_data = ts.get_daily_adjusted(
        symbol='UUP', outputsize='full')
    df_ief, meta_data = ts.get_daily_adjusted(
        symbol='IEF', outputsize='full')

ts变量是在上一节中创建的 Alpha Vantage 的TimeSeries对象。使用以下代码将调整后的收盘价合并到一个名为df_assetspandas DataFrame 中,并使用dropna()命令删除空值:

In [ ]:
    import pandas as pd

    df_assets = pd.DataFrame({
        'SPX': df_spx['5\. adjusted close'],
        'GLD': df_gld['5\. adjusted close'],
        'UUP': df_dxy['5\. adjusted close'],
        'IEF': df_ief['5\. adjusted close'],
    }).dropna()

使用以下代码计算我们的df_assets数据集的滞后百分比回报:

IN [ ]:
    df_assets_1m = df_assets.pct_change(periods=20)
    df_assets_1m.columns = ['%s_1m'%col for col in df_assets.columns]

    df_assets_3m = df_assets.pct_change(periods=60)
    df_assets_3m.columns = ['%s_3m'%col for col in df_assets.columns]

    df_assets_6m = df_assets.pct_change(periods=120)
    df_assets_6m.columns = ['%s_6m'%col for col in df_assets.columns]

    df_assets_12m = df_assets.pct_change(periods=240)
    df_assets_12m.columns = ['%s_12m'%col for col in df_assets.columns]

pct_change()命令中,periods参数指定要移动的周期数。在计算滞后回报时,我们假设一个月有 20 个交易日。使用join()命令将四个pandas DataFrame 对象合并成一个 DataFrame:

In [ ]:
    df_lagged = df_assets_1m.join(df_assets_3m)\
        .join(df_assets_6m)\
        .join(df_assets_12m)\
        .dropna()

使用info()命令查看其属性:

In [ ]:
    df_lagged.info()
Out[ ]:
    <class 'pandas.core.frame.DataFrame'>
    Index: 2791 entries, 2008-02-12 to 2019-03-14
    Data columns (total 16 columns):
    ...

输出被截断,但您可以看到 16 个特征作为我们的独立变量,跨越 2008 年至 2019 年。让我们继续获取我们目标变量的数据集。

准备目标变量

JPM 的收盘价早些时候已经下载到pandas Series 对象jpm_prices中,只需使用以下代码计算实际百分比收益:

In [ ]:
    y = jpm_prices.pct_change().dropna()

我们获得一个pandas Series 对象作为我们的目标变量y

多资产线性回归模型

在上一节中,我们使用了单一资产 GS 的价格来拟合我们的线性回归模型。这个相同的模型LinearRegressionModel可以容纳多个资产。运行以下命令来创建这个模型的实例并使用我们的新数据集:

In [ ]:
    multi_linear_model = LinearRegressionModel()
    multi_linear_model.learn(df_lagged, y, start_date='2018', 
                             end_date='2019', lookback_period=10)

在线性回归模型实例multi_linear_model中,learn()命令使用具有 16 个特征的df_lagged数据集和y作为 JPM 的百分比变化。考虑到有限的滞后回报数据,lookback_period值被减少。让我们绘制 JPM 的实际与预测百分比变化:

In [ ]:
    multi_linear_model.df_result.plot(
        title='JPM actual versus predicted percentage returns',
        style=['-', '--'], figsize=(12,8));

这将给我们以下图表,其中实线显示了 JPM 的实际百分比回报,而虚线显示了预测的百分比回报:

我们的模型表现如何?让我们在前一节中定义的print_regression_metrics()函数中运行相同的性能指标:

In [ ]:
    print_regression_metrics(multi_linear_model.df_result)
Out[ ]:
    mean_absolute_error: 0.01952328066607389
    mean_squared_error: 0.0007225502867195044
    explained_variance_score: -2.729798588246765
    r2_score: -2.738404583097052

解释的方差分数和 R²分数都在负数范围内,表明模型表现低于*均水*。我们能做得更好吗?让我们探索更复杂的用于回归的树模型。

决策树的集成

决策树是用于分类和回归任务的广泛使用的模型,就像二叉树一样,其中每个节点代表一个问题,导致对相应左右节点的是或否答案。目标是通过尽可能少的问题得到正确答案。

可以在arxiv.org/pdf/1806.06988.pdf找到描述深度神经决策树的论文。

深入决策树很快会导致给定数据的过拟合,而不是从中抽取的分布的整体特性。为了解决这个过拟合问题,数据可以分成子集,并在不同的树上进行训练,每个子集上进行训练。这样,我们最终得到了不同决策树模型的集成。当用替换抽取预测的随机样本子集时,这种方法称为装袋自举聚合。我们可能会或可能不会在这些模型中获得一致的结果,但通过对自举模型进行*均得到的最终模型比使用单个决策树产生更好的结果。使用随机化决策树的集成被称为随机森林

让我们在 scikit-learn 中访问一些决策树模型,我们可能考虑在我们的多资产回归模型中实施。

装袋回归器

sklearn.ensembleBaggingRegressor类实现了装袋回归器。我们可以看看装袋回归器如何对 JPM 的百分比收益进行多资产预测。以下代码说明了这一点:

In [ ]:
    from sklearn.ensemble import BaggingRegressor

    class BaggingRegressorModel(LinearRegressionModel):
        def get_model(self):
            return BaggingRegressor(n_estimators=20, random_state=0) 
In [ ]:
 bagging = BaggingRegressorModel()
    bagging.learn(df_lagged, y, start_date='2018', 
                  end_date='2019', lookback_period=10) 

我们创建了一个名为BaggingRegressorModel的类,它扩展了LinearRegressionModel,并且get_model()方法被重写以返回装袋回归器。n_estimators参数指定了集成中的20个基本估计器或决策树,random_state参数作为随机数生成器使用的种子为0。其余参数为默认值。我们使用相同的数据集运行这个模型。

运行相同的性能指标,看看我们的模型表现如何:

In [ ]:
    print_regression_metrics(bagging.df_result)
Out[ ]:
    mean_absolute_error: 0.0114699264723
    mean_squared_error: 0.000246352185742
    explained_variance_score: -0.272260304849
    r2_score: -0.274602137956

MAE 和 MSE 值表明,决策树的集成产生的预测误差比简单线性回归模型少。此外,尽管解释方差分数和 R²分数为负值,但它表明数据的方差向均值的方向比简单线性回归模型提供的更好。

梯度树提升回归模型

梯度树提升,或简单地说梯度提升,是一种利用梯度下降过程来最小化损失函数,从而改善或提升弱学习器性能的技术。树模型,通常是决策树,一次添加一个,并以分阶段的方式构建模型,同时保持模型中现有的树不变。由于梯度提升是一种贪婪算法,它可以很快地过拟合训练数据集。然而,它可以受益于对各个部分进行惩罚的正则化方法,并减少过拟合以改善其性能。

sklearn.ensemble模块提供了一个梯度提升回归器,称为GradientBoostingRegressor

随机森林回归

随机森林由多个决策树组成,每个决策树都基于训练数据的随机子样本,并使用*均化来提高预测准确性和控制过拟合。随机选择无意中引入了某种形式的偏差。然而,由于*均化,它的方差也减小,有助于补偿偏差的增加,并被认为产生了一个整体更好的模型。

sklearn.ensemble模块提供了一个随机森林回归器,称为RandomForestRegressor

更多集成模型

sklearn.ensemble模块包含各种其他集成回归器,以及分类器模型。更多信息可以在scikit-learn.org/stable/modules/classes.html#module-sklearn.ensemble找到。

使用基于分类的机器学习预测趋势

基于分类的机器学习是一种监督机器学习方法,模型从给定的输入数据中学习,并根据新的观察结果进行分类。分类可以是双类别的,比如识别一个期权是否应该行使,也可以是多类别的,比如价格变化的方向,可以是上升、下降或不变。

在这一部分,我们将再次看一下通过四种多元资产的价格来预测 2018 年 JPM 每日趋势的动量模型。我们将使用 S&P 500 股票指数、10 年期国债指数、美元指数和黄金价格的前 1 个月和 3 个月滞后收益来拟合预测模型。我们的目标变量包括布尔指示器,其中True值表示与前一个交易日收盘价相比的增加或不变,而False值表示减少。

让我们开始准备我们模型的数据集。

准备目标变量

我们已经在之前的部分将 JPM 数据集下载到了pandas DataFrame df_jpm中,而y变量包含了 JPM 的每日百分比变化。使用以下代码将这些值转换为标签:

In [ ]:
    import numpy as np
    y_direction = y >= 0
    y_direction.head(3)
Out[ ]:
    date
    1998-01-05     True
    1998-01-06    False
    1998-01-07     True
    Name: 5\. adjusted close, dtype: bool

使用head()命令,我们可以看到y_direction变量成为了一个pandas Series 对象,其中包含布尔值。百分比变化为零或更多的值被分类为True标签,否则为False。让我们使用unique()命令提取唯一值作为以后使用的列名:

In [ ]:
    flags = list(y_direction.unique())
    flags.sort()
    print(flags)
Out[ ]:    
    [False, True]

列名被提取到一个名为flags的变量中。有了我们的目标变量,让我们继续获取我们的独立多资产变量。

准备多个资产作为输入变量的数据集

我们将重用前一部分中包含四种资产的滞后 1 个月和 3 个月百分比收益的pandas DataFrame 变量df_assets_1mdf_assets_3m,并将它们合并为一个名为df_input的单个变量,使用以下代码:

In [ ]:    
    df_input = df_assets_1m.join(df_assets_3m).dropna()

使用info()命令查看其属性:

In [ ]:
    df_input.info()
Out[ ]:
    <class 'pandas.core.frame.DataFrame'>
    Index: 2971 entries, 2007-05-25 to 2019-03-14
    Data columns (total 8 columns):
    ...

输出被截断了,但是你可以看到我们有八个特征作为我们的独立变量,跨越了 2007 年到 2019 年。有了我们的输入和目标变量,让我们探索 scikit-learn 中可用的各种分类器模型。

逻辑回归

尽管其名称是逻辑回归,但实际上它是用于分类的线性模型。它使用逻辑函数,也称为S 形函数,来模拟描述单次试验可能结果的概率。逻辑函数有助于将任何实值映射到 0 和 1 之间的值。标准的逻辑函数如下所示:

e是自然对数的底,x是 S 形函数中点的X值。是预测的实际值,介于 0 和 1 之间,可以通过四舍五入或截断值转换为 0 或 1 的二进制等价值。

sklean.linear_model模块的LogisticRegression类实现了逻辑回归。让我们通过编写一个名为LogisticRegressionModel的新类来实现这个分类器模型,该类扩展了LinearRegressionModel,使用以下代码:

In [ ]:
    from sklearn.linear_model import LogisticRegression

    class LogisticRegressionModel(LinearRegressionModel):
        def get_model(self):
            return LogisticRegression(solver='lbfgs')

我们的新分类器模型使用了相同的基本线性回归逻辑。get_model()方法被重写以返回一个使用 LBFGS 求解器算法的LogisticRegression分类器模型的实例。

有关用于机器学习的有限内存 BroydenFletcherGoldfarbShanno (LBFGS)算法的论文可以在arxiv.org/pdf/1802.05374.pdf上阅读。

创建这个模型的实例并提供我们的数据:

In [ ]:
    logistic_reg_model = LogisticRegressionModel()
    logistic_reg_model.learn(df_input, y_direction, start_date='2018', 
                             end_date='2019', lookback_period=100)

再次,参数值表明我们有兴趣对 2018 年进行预测,并且在拟合模型时将使用lookback_period值为100作为每日历史数据点的数量。让我们使用head()命令检查存储在df_result中的结果:

In [ ]:
    logistic_reg_model.df_result.head()

这产生了以下表格:

日期 实际 预测
2018-01-02 True True
2018-01-03 True True
2018-01-04 True True
2018-01-05 False True
2018-01-08 True True

由于我们的目标变量是布尔值,模型输出也预测布尔值。但我们的模型表现如何?在接下来的部分中,我们将探讨用于测量我们预测的风险指标。这些指标与前面部分用于基于回归的预测的指标不同。基于分类的机器学习采用另一种方法来测量输出标签。

用于测量基于分类的预测的风险指标

在本节中,我们将探讨用于测量基于分类的机器学习预测的常见风险指标,即混淆矩阵、准确度分数、精度分数、召回率分数和 F1 分数。

混淆矩阵

混淆矩阵,或错误矩阵,是一个帮助可视化和描述分类模型性能的方阵,其中真实值是已知的。sklearn.metrics模块的confusion_matrix函数帮助我们计算这个矩阵,如下面的代码所示:

In [ ]:
    from sklearn.metrics import confusion_matrix

    df_result = logistic_reg_model.df_result 
    actual = list(df_result['Actual'])
    predicted = list(df_result['Predicted'])

    matrix = confusion_matrix(actual, predicted)
In [ ]:
    print(matrix)
Out[ ]:
    [[60 66]
     [55 70]]

我们将实际值和预测值作为单独的列表获取。由于我们有两种类标签,我们获得一个二乘二的矩阵。seaborn库的heatmap模块帮助我们理解这个矩阵。

Seaborn 是一个基于 Matplotlib 的数据可视化库。它提供了一个高级接口,用于绘制引人注目和信息丰富的统计图形,是数据科学家的流行工具。如果您没有安装 Seaborn,只需运行命令:pip install seaborn

运行以下 Python 代码生成混淆矩阵:

In [ ]:
    %matplotlib inline
    import seaborn as sns
    import matplotlib.pyplot as plt

    plt.subplots(figsize=(12,8))
    sns.heatmap(matrix.T, square=True, annot=True, fmt='d', cbar=False, 
                xticklabels=flags, yticklabels=flags)
    plt.xlabel('Actual')
    plt.ylabel('Predicted')
    plt.title('JPM percentage returns 2018');

这将产生以下输出:

不要让混淆矩阵让你困惑。让我们以一种逻辑的方式分解数字,看看混淆矩阵是如何工作的。从左列开始,我们有 126 个样本被分类为 False,分类器正确预测了 60 次,这些被称为真负例(TNs)。然而,分类器错误预测了 66 次,这些被称为假负例(FNs)。在右列,我们有 125 个属于 True 类的样本。分类器错误预测了 55 次,这些被称为假正例(FPs)。分类器虽然有 70 次预测正确,这些被称为真正例(TPs)。这些计算出的比率在其他风险指标中使用,我们将在接下来的部分中发现。

准确度分数

准确度分数是正确预测的观测值与总观测值的比率。默认情况下,它表示为 0 到 1 之间的分数。当准确度分数为 1.0 时,意味着样本中的整个预测标签集与真实标签集匹配。准确度分数可以写成如下形式:

这里,I(x)是指示函数,对于正确的预测返回 1,否则返回 0。sklearn.metrics模块的accuracy_score函数使用以下代码为我们计算这个分数:

In [ ]:
    from sklearn.metrics import accuracy_score
    print('accuracy_score:', accuracy_score(actual, predicted))
Out[ ]:
    accuracy_score: 0.5179282868525896

准确度分数表明我们的模型有 52%的正确率。准确度分数非常适合测量对称数据集的性能,其中假正例和假负例的值几乎相同。为了充分评估我们模型的性能,我们需要查看其他风险指标。

精度分数

精度分数是正确预测的正例观测值与总预测的正例观测值的比率,可以写成如下形式:

这给出了一个介于 0 和 1 之间的精度分数,1 表示模型一直正确分类。sklearn.metrics模块的precision_score函数使用以下代码为我们计算这个分数:

In [ ]:
    from sklearn.metrics import precision_score
    print('precision_score:', precision_score(actual, predicted))
Out[ ]:
    precision_score: 0.5147058823529411

精度分数表明我们的模型能够正确预测分类的 52%的时间。

召回分数

召回分数是正确预测的正样本占实际类别中所有样本的比率,可以写成如下形式:

这给出了一个介于 0 和 1 之间的召回分数,1 是最佳值。sklearn.metrics模块的recall_score函数使用以下代码为我们计算这个分数:

In [ ]:
    from sklearn.metrics import recall_score
    print('recall_score:', recall_score(actual, predicted))
Out[ ]:
    recall_score: 0.56

召回分数表明我们的逻辑回归模型正确识别正样本的时间为 56%。

F1 分数

F1 分数,或 F-度量,是精度分数和召回分数的加权*均值,可以写成如下形式:

这给出了介于 0 和 1 之间的 F1 分数。当精度分数或召回分数为 0 时,F1 分数将为 0。但是,当精度分数和召回分数都为正时,F1 分数对两个度量给予相等的权重。最大化 F1 分数可以创建一个具有最佳召回和精度*衡的*衡分类模型。

sklearn.metrics模块的f1_score函数使用以下代码为我们计算这个分数:

In [ ]:
    from sklearn.metrics import f1_score
    print('f1_score:', f1_score(actual, predicted))
Out[ ]:
    f1_score: 0.5363984674329502

我们的逻辑回归模型的 F1 分数为 0.536。

支持向量分类器

支持向量分类器SVC)是使用支持向量对数据集进行分类的支持向量机SVM)的概念。

有关 SVM 的更多信息可以在www.statsoft.com/textbook/support-vector-machines找到。

SVC类的sklean.svm模块实现了 SVM 分类器。编写一个名为SVCModel的类,并使用以下代码扩展LogisticRegressionModel

In [ ]:
    from sklearn.svm import SVC

    class SVCModel(LogisticRegressionModel):
        def get_model(self):
            return SVC(C=1000, gamma='auto')
In [ ]:
    svc_model = SVCModel()
    svc_model.learn(df_input, y_direction, start_date='2018', 
                    end_date='2019', lookback_period=100)

在这里,我们重写get_model()方法以返回 scikit-learn 的SVC类。指定了高惩罚C值为1000gamma参数是具有默认值auto的核系数。使用我们通常的模型参数执行learn()命令。有了这些,让我们在这个模型上运行风险指标:

In [ ]:
    df_result = svc_model.df_result
    actual = list(df_result['Actual'])
    predicted = list(df_result['Predicted'])
In [ ]:
    print('accuracy_score:', accuracy_score(actual, predicted))
    print('precision_score:', precision_score(actual, predicted))
    print('recall_score:', recall_score(actual, predicted))
    print('f1_score:', f1_score(actual, predicted)) 
Out[ ]:
    accuracy_score: 0.5577689243027888
    precision_score: 0.5538461538461539
    recall_score: 0.576
    f1_score: 0.5647058823529412

我们获得的分数比逻辑回归分类器模型更好。默认情况下,线性 SVM 的C值为 1.0,这在实践中通常会给我们与逻辑回归模型相当的性能。选择C值没有固定的规则,因为它完全取决于训练数据集。可以通过向SVC()模型提供kernel参数来考虑非线性 SVM 核。有关 SVM 核的更多信息,请访问scikit-learn.org/stable/modules/svm.html#svm-kernels

其他类型的分类器

除了逻辑回归和 SVC,scikit-learn 还包含许多其他类型的分类器用于机器学习。以下部分讨论了一些我们也可以考虑在我们的基于分类的模型中实现的分类器。

随机梯度下降

随机梯度下降SGD)是一种使用迭代过程来估计梯度以最小化目标损失函数的梯度下降形式,例如线性支持向量机或逻辑回归。随机项是因为样本是随机选择的。当使用较少的迭代时,会采取更大的步骤来达到解决方案,模型被认为具有高学习率。同样,使用更多的迭代,会采取更小的步骤,导致具有小学习率的模型。SGD 是从业者中机器学习算法的流行选择,因为它已经在大规模文本分类和自然语言处理模型中得到有效使用。

sklearn.linear_model模块的SGDClassifier类实现了 SGD 分类器。

线性判别分析

线性判别分析LDA)是一种经典的分类器,它使用线性决策面,其中估计了数据每个类的均值和方差。它假设数据是高斯分布的,每个属性具有相同的方差,并且每个变量的值都在均值附*。LDA 通过使用贝叶斯定理为每个观测计算判别分数,以确定它属于哪个类。

sklearn.discriminant_analysis模块的LinearDiscriminantAnalysis类实现了 LDA 分类器。

二次判别分析

二次判别分析QDA)与 LDA 非常相似,但使用二次决策边界,每个类使用自己的方差估计。运行风险指标显示,QDA 模型不一定比 LDA 模型表现更好。必须考虑所需模型的决策边界类型。QDA 更适用于具有较低偏差和较高方差的大型数据集。另一方面,LDA 适用于具有较低偏差和较高方差的较小数据集。

sklearn.discriminant_analysis模块的QuadraticDiscriminantAnalysis类实现了 QDA 模型。

KNN 分类器

k-最*邻k-NN)分类器是一种简单的算法,它对每个点的最*邻进行简单的多数投票,并将该点分配给在该点的最*邻中具有最多代表的类。虽然不需要为泛化训练模型,但预测阶段在时间和内存方面较慢且成本较高。

sklearn.neighbors模块的KNeighborsClassifier类实现了 KNN 分类器。

对机器学习算法的使用结论

您可能已经注意到,我们模型的预测值与实际值相差甚远。本章旨在展示 scikit-learn 提供的机器学习功能的最佳特性,这可能用于预测时间序列数据。迄今为止,没有研究表明机器学习算法可以预测价格接* 100%的时间。构建和有效运行机器学习系统需要付出更多的努力。

总结

在本章中,我们介绍了金融领域的机器学习。我们讨论了人工智能和机器学习如何改变金融行业。机器学习可以是监督的或无监督的,监督算法可以是基于回归或基于分类的。Python 的 scikit-learn 库提供了各种机器学习算法和风险指标。

我们讨论了基于回归的机器学习模型的使用,如 OLS 回归、岭回归、LASSO 回归和弹性网络正则化,用于预测证券价格等连续值。还讨论了决策树的集成,如装袋回归器、梯度树提升和随机森林。为了衡量回归模型的性能,我们讨论了均方误差、*均绝对误差、解释方差分数和 R²分数。

基于分类的机器学习将输入值分类为类别或标签。这些类别可以是二元或多元的。我们讨论了逻辑回归、支持向量机、LDA 和 QDA 以及 k-NN 分类器用于预测价格趋势。为了衡量分类模型的性能,我们讨论了混淆矩阵、准确率、精确度和召回率,以及 F1 分数。

在下一章中,我们将探讨在金融领域中使用深度学习。

第十一章:金融领域的深度学习

深度学习代表着人工智能AI)的最前沿。与机器学习不同,深度学习通过使用神经网络来进行预测。人工神经网络是模仿人类神经系统的,包括一个输入层和一个输出层,中间有一个或多个隐藏层。每一层都由并行工作的人工神经元组成,并将输出传递给下一层作为输入。深度学习中的深度一词来源于这样一个观念,即当数据通过人工神经网络中的更多隐藏层时,可以提取出更复杂的特征。

TensorFlow是由谷歌开发的开源、强大的机器学习和深度学习框架。在本章中,我们将采用实践方法来学习 TensorFlow,通过构建一个具有四个隐藏层的深度学习模型来预测某项证券的价格。深度学习模型是通过将整个数据集前向和后向地通过网络进行训练的,每次迭代称为一个时代。由于输入数据可能太大而无法被馈送,训练可以分批进行,这个过程称为小批量训练

另一个流行的深度学习库是 Keras,它利用 TensorFlow 作为后端。我们还将采用实践方法来学习 Keras,并看看构建一个用于预测信用卡支付违约的深度学习模型有多容易。

在本章中,我们将涵盖以下主题:

  • 神经网络简介

  • 神经元、激活函数、损失函数和优化器

  • 不同类型的神经网络架构

  • 如何使用 TensorFlow 构建安全价格预测深度学习模型

  • Keras,一个用户友好的深度学习框架

  • 如何使用 Keras 构建信用卡支付违约预测深度学习模型

  • 如何在 Keras 历史记录中显示记录的事件

深度学习的简要介绍

深度学习的理论早在 20 世纪 40 年代就开始了。然而,由于计算硬件技术的改进、更智能的算法和深度学习框架的采用,它*年来的流行度飙升。这本书之外还有很多内容要涵盖。本节作为一个快速指南,旨在为后面本章将涵盖的示例提供一个工作知识。

什么是深度学习?

在第十章中,金融领域的机器学习,我们了解了机器学习如何用于进行预测。监督学习使用误差最小化技术来拟合训练数据的模型,可以是基于回归或分类的。

深度学习通过使用神经网络来进行预测采用了一种不同的方法。人工神经网络是模仿人脑和神经系统的,由一系列层组成,每一层由许多称为神经元的简单单元并行工作,并将输入数据转换为抽象表示作为输出数据,然后将其作为输入馈送到下一层。以下图示说明了一个人工神经网络:

人工神经网络由三种类型的层组成。接受输入的第一层称为输入层。收集输出的最后一层称为输出层。位于输入和输出层之间的层称为隐藏层,因为它们对网络的接口是隐藏的。隐藏层可以有许多组合,执行不同的激活函数。自然地,更复杂的计算导致对更强大的机器的需求增加,例如计算它们所需的 GPU。

人工神经元

人工神经元接收一个或多个输入,并由称为权重的值相乘,然后求和并传递给激活函数。激活函数计算的最终值构成了神经元的输出。偏置值可以包含在求和项中以帮助拟合数据。以下图示了一个人工神经元:

求和项可以写成线性方程,如 Z=x[1]w[1]+x[2]w[2]+...+b. 神经元使用非线性激活函数 f 将输入转换为输出 ,可以写成

激活函数

激活函数是人工神经元的一部分,它将加权输入的总和转换为下一层的另一个值。通常,此输出值的范围为-1 或 0 到 1。当人工神经元向另一个神经元传递非零值时,它被激活。主要有几种类型的激活函数,包括:

  • 线性

  • Sigmoid

  • 双曲正切

  • 硬双曲正切

  • 修正线性单元

  • Leaky ReLU

  • Softplus

例如,修正线性单元(ReLU)函数可以写成:

ReLU 仅在输入大于零时激活节点的输入值相同。研究人员更喜欢使用 ReLU,因为它比 Sigmoid 激活函数训练效果更好。我们将在本章的后面部分使用 ReLU。

在另一个例子中,leaky ReLU 可以写成:

Leaky ReLU 解决了当  时死亡 ReLU 的问题,当 x 为零或更小时,它具有约 0.01 的小负斜率。

损失函数

损失函数计算模型的预测值与实际值之间的误差。误差值越小,模型的预测就越好。一些用于基于回归的模型的损失函数包括:

  • 均方误差(MSE)损失

  • *均绝对误差(MAE)损失

  • Huber 损失

  • 分位数损失

一些用于基于分类的模型的损失函数包括:

  • 焦点损失

  • 铰链损失

  • 逻辑损失

  • 指数损失

优化器

优化器有助于在最小化损失函数时最佳地调整模型权重。在深度学习中可能会遇到几种类型的优化器:

  • 自适应梯度(AdaGrad)

  • 自适应矩估计(Adam)

  • 有限内存 Broyden-Fletcher-Goldfarb-Shannon(LBFGS)

  • 鲁棒反向传播(Rprop)

  • 根均方传播(RMSprop)

  • 随机梯度下降(SGD)

Adam 是一种流行的优化器选择,被视为 RMSprop 和带动量的 SGD 的组合。它是一种自适应学习率优化算法,为不同参数计算单独的学习率。

网络架构

神经网络的网络架构定义了其行为。有许多形式的网络架构可用;其中一些是:

  • 感知器(P)

  • 前馈(FF)

  • 深度前馈(DFF)

  • 径向基函数网络(RBF)

  • 循环神经网络(RNN)

  • 长/短期记忆(LSTM)

  • 自动编码器(AE)

  • Hopfield 网络(HN)

  • 玻尔兹曼机(BM)

  • 生成对抗网络(GAN)

最著名且易于理解的神经网络是前馈多层神经网络。它可以使用输入层、一个或多个隐藏层和一个输出层来表示任何函数。可以在www.asimovinstitute.org/neural-network-zoo/找到神经网络列表。

TensorFlow 和其他深度学习框架

TensorFlow 是来自谷歌的免费开源库,可用于 Python、C++、Java、Rust 和 Go。它包含各种神经网络,用于训练深度学习模型。TensorFlow 可应用于各种场景,如图像分类、恶意软件检测和语音识别。TensorFlow 的官方页面是www.tensorflow.org

在行业中使用的其他流行的深度学习框架包括 Theano、PyTorch、CNTK(Microsoft Cognitive Toolkit)、Apache MXNet 和 Keras。

张量是什么?

TensorFlow 中的“Tensor”表示这些框架定义和运行涉及张量的计算。张量只不过是具有特定变换属性的一种n维向量类型。非维度张量是标量或数字。一维张量是向量。二维张量是矩阵。张量提供了数据的更自然表示,例如在计算机视觉领域的图像中。

向量空间的基本属性和张量的基本数学属性使它们在物理学和工程学中特别有用。

使用 TensorFlow 的深度学习价格预测模型

在本节中,我们将学习如何使用 TensorFlow 作为深度学习框架来构建价格预测模型。我们将使用 2013 年至 2017 年的五年定价数据来训练我们的深度学习模型。我们将尝试预测 2018 年苹果(AAPL)的价格。

特征工程我们的模型

我们的数据的每日调整收盘价构成了目标变量。定义我们模型特征的自变量由这些技术指标组成:

  • 相对强弱指数RSI

  • 威廉指标WR

  • 令人敬畏的振荡器AO

  • 成交量加权*均价格VWAP

  • *均每日交易量ADTV

  • 5 天移动*均MA

  • 15 天移动*均

  • 30 天移动*均

这为我们的模型提供了八个特征。

要求

如前几章所述,您应该已安装了 NumPy、pandas、Jupyter 和 scikit-learn 库。以下部分重点介绍了构建我们的深度学习模型所需的其他重要要求。

Intrinio 作为我们的数据提供商

Intrinio(intrinio.com/)是一个高级 API 金融数据提供商。我们将使用美国基本面和股价订阅,这使我们可以访问美国历史股价和精心计算的技术指标值。注册账户后,您的 API 密钥可以在您的账户设置中找到,稍后我们将使用它们。

TensorFlow 的兼容 Python 环境

在撰写本文时,TensorFlow 的最新稳定版本是 r1.13。该版本兼容 Python 2.7、3.4、3.5 和 3.6。由于本书前面的章节使用 Python 3.7,我们需要为本章的示例设置一个单独的 Python 3.6 环境。建议使用 virtualenv 工具(virtualenv.pypa.io/)来隔离 Python 环境。

requests 库

需要requests Python 库来帮助我们调用 Intrinio 的 API。requests的官方网页是docs.python-requests.org/en/master/。在终端中运行以下命令来安装requestspip install requests

TensorFlow 库

有许多 TensorFlow 的变体可供安装。您可以选择仅 CPU 或 GPU 支持版本、alpha 版本和 nightly 版本。更多安装说明请参阅www.tensorflow.org/install/pip。至少,以下终端命令将安装最新的 CPU-only 稳定版本的 TensorFlow:pip install tensorflow

下载数据集

本节描述了从 Intrinio 下载所需价格和技术指标值的步骤。API 调用的全面文档可以在docs.intrinio.com/documentation/api_v2找到。如果决定使用另一个数据提供商,请继续并跳过本节:

  1. 编写一个query_intrinio()函数,该函数将调用 Intrinio 的 API,具有以下代码:
In [ ]:
    import requests

    BASE_URL = 'https://api-v2.intrinio.com'

    # REPLACE YOUR INTRINIO API KEY HERE!
    INTRINIO_API_KEY = 'Ojc3NjkzOGNmNDMxMGFiZWZiMmMxMmY0Yjk3MTQzYjdh'

    def query_intrinio(path, **kwargs):   
        url = '%s%s'%(BASE_URL, path)
        kwargs['api_key'] = INTRINIO_API_KEY
        response = requests.get(url, params=kwargs)

        status_code = response.status_code
        if status_code == 401: 
            raise Exception('API key is invalid!')
        if status_code == 429: 
            raise Exception('Page limit hit! Try again in 1 minute')
        if status_code != 200: 
            raise Exception('Request failed with status %s'%status_code)

        return response.json()

该函数接受pathkwargs参数。path参数是指特定的 Intrinio API 上下文路径。kwargs关键字参数是一个字典,作为请求参数传递给 HTTP GET 请求调用。API 密钥被插入到这个字典中,以便在每次 API 调用时识别用户帐户。预期任何 API 响应都以 JSON 格式呈现,HTTP 状态码为 200;否则,将抛出异常。

  1. 编写一个get_technicals()函数,使用以下代码从 Intrinio 下载技术指标值:
In [ ]:
    import pandas as pd
    from pandas.io.json import json_normalize

    def get_technicals(ticker, indicator, **kwargs):    
        url_pattern = '/securities/%s/prices/technicals/%s'
        path = url_pattern%(ticker, indicator)
        json_data = query_intrinio(path, **kwargs)

        df = json_normalize(json_data.get('technicals'))    
        df['date_time'] = pd.to_datetime(df['date_time'])
        df = df.set_index('date_time')
        df.index = df.index.rename('date')
        return df

tickerindicator参数构成了下载特定安全性指标的 API 上下文路径。预期响应以 JSON 格式呈现,其中包含一个名为technicals的键,其中包含技术指标值列表。pandas 的json_normalize()函数有助于将这些值转换为*面表 DataFrame 对象。需要额外的格式设置以将日期和时间值设置为date名称下的索引。

  1. 定义请求参数的值:
In [ ]:
    ticker = 'AAPL'
    query_params = {'start_date': '2013-01-01', 'page_size': 365*6}

我们将查询 2013 年至 2018 年(含)期间的安全性AAPL的数据。大的page_size值为我们提供了足够的空间,以便在单个查询中请求六年的数据。

  1. 以一分钟的间隔运行以下命令来下载技术指标数据:
In [ ]:
    df_rsi = get_technicals(ticker, 'rsi', **query_params)
    df_wr = get_technicals(ticker, 'wr', **query_params)
    df_vwap = get_technicals(ticker, 'vwap', **query_params)
    df_adtv = get_technicals(ticker, 'adtv', **query_params)
    df_ao = get_technicals(ticker, 'ao', **query_params)
    df_sma_5d = get_technicals(ticker, 'sma', period=5, **query_params)
    df_sma_5d = df_sma_5d.rename(columns={'sma':'sma_5d'})
    df_sma_15d = get_technicals(ticker, 'sma', period=15, **query_params)
    df_sma_15d = df_sma_15d.rename(columns={'sma':'sma_15d'})
    df_sma_30d = get_technicals(ticker, 'sma', period=30, **query_params)
    df_sma_30d = df_sma_30d.rename(columns={'sma':'sma_30d'})

在执行 Intrinio API 查询时要注意分页限制!page_size大于 100 的 API 请求受到每分钟请求限制。如果调用失败并显示状态码 429,请在一分钟后重试。有关 Intrinio 限制的信息可以在docs.intrinio.com/documentation/api_v2/limits找到。

这给我们了八个变量,每个变量都包含各自技术指标值的 DataFrame 对象。稍后加入数据时,MA 数据列被重命名以避免命名冲突。

  1. 编写一个get_prices()函数,使用以下代码下载安全性的历史价格:
In [ ]:
    def get_prices(ticker, tag, **params):
        url_pattern = '/securities/%s/historical_data/%s'
        path = url_pattern%(ticker, tag)
        json_data = query_intrinio(path, **params)

        df = json_normalize(json_data.get('historical_data'))    
        df['date'] = pd.to_datetime(df['date'])
        df = df.set_index('date')
        df.index = df.index.rename('date')
        return df.rename(columns={'value':tag})

tag参数指定要下载的安全性的数据标签。预期 JSON 响应包含一个名为historical_data的键,其中包含值列表。DataFrame 对象中包含价格的列从value重命名为其数据标签。

Intrinio 数据标签用于从系统中下载特定值。可在data.intrinio.com/data-tags/all找到带有解释的数据标签列表。

  1. 使用get_prices()函数,下载 AAPL 的调整收盘价:
In [ ]:
    df_close = get_prices(ticker, 'adj_close_price', **query_params)
  1. 由于特征用于预测第二天的收盘价,我们需要将价格向后移动一天以对齐这种映射。创建目标变量:
In [ ]:
    df_target = df_close.shift(1).dropna()
  1. 最后,使用join()命令将所有 DataFrame 对象组合在一起,并删除空值:
In [ ]:
    df = df_rsi.join(df_wr).join(df_vwap).join(df_adtv)\
         .join(df_ao).join(df_sma_5d).join(df_sma_15d)\
         .join(df_sma_30d).join(df_target).dropna()

我们的数据集现在已经准备好,包含在dfDataFrame 中。我们可以继续拆分训练数据。

缩放和拆分数据

我们有兴趣使用最早的五年定价数据来训练我们的模型,并使用 2018 年的最*一年来测试我们的预测。运行以下代码来拆分我们的df数据集:

In [ ]:
    df_train = df['2017':'2013']
    df_test = df['2018']

df_traindf_test变量分别包含我们的训练和测试数据。

数据预处理中的一个重要步骤是对数据集进行归一化。这将使输入特征值转换为零的*均值和一个的方差。归一化有助于避免由于输入特征的不同尺度而导致训练中的偏差。

sklearn模块的MinMaxScaler函数有助于将每个特征转换为-1 到 0 之间的范围,使用以下代码:

In [ ]:
    from sklearn.preprocessing import MinMaxScaler

    scaler = MinMaxScaler(feature_range=(-1, 1))
    train_data = scaler.fit_transform(df_train.values)
    test_data = scaler.transform(df_test.values)

fit_transform()函数计算用于缩放和转换数据的参数,而transform()函数仅通过重用计算的参数来转换数据。

接下来,将缩放的训练数据集分成独立的和目标变量。目标值在最后一列,其余列为特征:

In [ ]:
    x_train = train_data[:, :-1]
    y_train = train_data[:, -1]

在我们的测试数据上只针对特征执行相同的操作:

In [ ]:
    x_test = test_data[:, :-1]

准备好我们的训练和测试数据集后,让我们开始使用 TensorFlow 构建一个人工神经网络。

使用 TensorFlow 构建人工神经网络

本节将指导您完成设置具有四个隐藏层的深度学习人工神经网络的过程。涉及两个阶段;首先是组装图形,然后是训练模型。

第一阶段 - 组装图形

以下步骤描述了设置 TensorFlow 图的过程:

  1. 使用以下代码为输入和标签创建占位符:
In [ ]:
    import tensorflow as tf

    num_features = x_train.shape[1]

    x = tf.placeholder(dtype=tf.float32, shape=[None, num_features])
    y = tf.placeholder(dtype=tf.float32, shape=[None])

TensorFlow 操作始于占位符。在这里,我们定义了两个占位符xy,分别用于包含网络输入和输出。shape参数定义了要提供的张量的形状,其中None表示此时观察数量是未知的。x的第二个维度是我们拥有的特征数量,反映在num_features变量中。稍后,我们将看到,占位符值是使用feed_dict命令提供的。

  1. 为隐藏层创建权重和偏差初始化器。我们的模型将包括四个隐藏层。第一层包含 512 个神经元,大约是输入大小的三倍。第二、第三和第四层分别包含 256、128 和 64 个神经元。在后续层中减少神经元的数量会压缩网络中的信息。

初始化器用于在训练之前初始化网络变量。在优化问题开始时使用适当的初始化非常重要,以产生潜在问题的良好解决方案。以下代码演示了使用方差缩放初始化器和零初始化器:

In [ ]:
    nl_1, nl_2, nl_3, nl_4 = 512, 256, 128, 64

    wi = tf.contrib.layers.variance_scaling_initializer(
         mode='FAN_AVG', uniform=True, factor=1)
    zi = tf.zeros_initializer()

    # 4 Hidden layers
    wt_hidden_1 = tf.Variable(wi([num_features, nl_1]))
    bias_hidden_1 = tf.Variable(zi([nl_1]))

    wt_hidden_2 = tf.Variable(wi([nl_1, nl_2]))
    bias_hidden_2 = tf.Variable(zi([nl_2]))

    wt_hidden_3 = tf.Variable(wi([nl_2, nl_3]))
    bias_hidden_3 = tf.Variable(zi([nl_3]))

    wt_hidden_4 = tf.Variable(wi([nl_3, nl_4]))
    bias_hidden_4 = tf.Variable(zi([nl_4]))

    # Output layer
    wt_out = tf.Variable(wi([nl_4, 1]))
    bias_out = tf.Variable(zi([1]))

除了占位符,TensorFlow 中的变量在图执行期间会被更新。在这里,变量是在训练期间会发生变化的权重和偏差。variance_scaling_initializer()命令返回一个初始化器,用于生成我们的权重张量而不缩放方差。FAN_AVG模式指示初始化器使用输入和输出连接的*均数量,uniform参数为True表示使用均匀随机初始化和缩放因子为 1。这类似于训练 DFF 神经网络。

多层感知器MLP)中,例如我们的模型,权重层的第一个维度与上一个权重层的第二个维度相同。偏差维度对应于当前层中的神经元数量。预期最后一层的神经元只有一个输出。

  1. 现在是时候使用以下代码将我们的占位符输入与权重和偏差结合起来,用于四个隐藏层。
In [ ]:
    hidden_1 = tf.nn.relu(
        tf.add(tf.matmul(x, wt_hidden_1), bias_hidden_1))
    hidden_2 = tf.nn.relu(
        tf.add(tf.matmul(hidden_1, wt_hidden_2), bias_hidden_2))
    hidden_3 = tf.nn.relu(
        tf.add(tf.matmul(hidden_2, wt_hidden_3), bias_hidden_3))
    hidden_4 = tf.nn.relu(
        tf.add(tf.matmul(hidden_3, wt_hidden_4), bias_hidden_4))
    out = tf.transpose(tf.add(tf.matmul(hidden_4, wt_out), bias_out))

tf.matmul命令将输入和权重矩阵相乘,使用tf.add命令添加偏差值。神经网络的每个隐藏层都通过激活函数进行转换。在这个模型中,我们使用tf.nn.relu命令将 ReLU 作为所有层的激活函数。每个隐藏层的输出被馈送到下一个隐藏层的输入。最后一层是输出层,具有单个向量输出,必须使用tf.transpose命令进行转置。

  1. 指定网络的损失函数,用于在训练期间测量预测值和实际值之间的误差。对于像我们这样的基于回归的模型,通常使用 MSE:
In [ ]:
    mse = tf.reduce_mean(tf.squared_difference(out, y))

tf.squared_difference命令被定义为返回预测值和实际值之间的*方误差,tf.reduce_mean命令是用于在训练期间最小化均值的损失函数。

  1. 使用以下代码创建优化器:
In [ ]:
    optimizer = tf.train.AdamOptimizer().minimize(mse)

在最小化损失函数时,优化器在训练期间帮助计算网络的权重和偏差。在这里,我们使用默认值的 Adam 算法。完成了这一重要步骤后,我们现在可以开始进行模型训练的第二阶段。

第二阶段 - 训练我们的模型

以下步骤描述了训练我们的模型的过程:

  1. 创建一个 TensorFlow Session对象来封装神经网络模型运行的环境:
In [ ]:
    session = tf.InteractiveSession()

在这里,我们正在指定一个会话以在交互式环境中使用,即 Jupyter 笔记本。常规的tf.Session是非交互式的,需要在运行操作时使用with关键字传递一个显式的Session对象。InteractiveSession消除了这种需要,更方便,因为它重用了session变量。

  1. TensorFlow 要求在训练之前初始化所有全局变量。使用session.run命令进行初始化。
In [ ]:
    session.run(tf.global_variables_initializer())
  1. 运行以下代码使用小批量训练来训练我们的模型:
In [ ]:
    from numpy import arange
    from numpy.random import permutation

    BATCH_SIZE = 100
    EPOCHS = 100

    for epoch in range(EPOCHS):
        # Shuffle the training data
        shuffle_data = permutation(arange(len(y_train)))
        x_train = x_train[shuffle_data]
        y_train = y_train[shuffle_data]

        # Mini-batch training
        for i in range(len(y_train)//BATCH_SIZE):
            start = i*BATCH_SIZE
            batch_x = x_train[start:start+BATCH_SIZE]
            batch_y = y_train[start:start+BATCH_SIZE]
            session.run(optimizer, feed_dict={x: batch_x, y: batch_y})

一个时期是整个数据集通过网络前向和后向传递的单次迭代。通常对训练数据的不同排列执行几个时期,以便网络学习其行为。对于一个好的模型,没有固定的时期数量,因为它取决于数据的多样性。因为数据集可能太大而无法在一个时期内输入模型,小批量训练将数据集分成部分,并将其馈送到session.run命令进行学习。第一个参数指定了优化算法实例。feed_dict参数接收一个包含我们的xy占位符的字典,分别映射到我们的独立值和目标值的批次。

  1. 在我们的模型完全训练后,使用它对包含特征的测试数据进行预测:
In [ ]:
    [predicted_values] = session.run(out, feed_dict={x: x_test})

使用session.run命令,第一个参数是输出层的转换函数。feed_dict参数用我们的测试数据进行馈送。输出列表中的第一项被读取为最终输出的预测值。

  1. 由于预测值也被标准化,我们需要将它们缩放回原始值:
In [ ]:
    predicted_scaled_data = test_data.copy()
    predicted_scaled_data[:, -1] = predicted_values
    predicted_values = scaler.inverse_transform(predicted_scaled_data)

使用copy()命令创建我们初始训练数据的副本到新的predicted_scaled_data变量。最后一列将被替换为我们的预测值。接下来,inverse_transform()命令将我们的数据缩放回原始大小,给出我们的预测值,以便与实际观察值进行比较。

绘制预测值和实际值

让我们将预测值和实际值绘制到图表上,以可视化我们深度学习模型的性能。运行以下代码提取我们感兴趣的值:

In [ ]:
    predictions = predicted_values[:, -1][::-1]
    actual = df_close['2018']['adj_close_price'].values[::-1]

重新缩放的predicted_values数据集是一个带有预测值的 NumPy ndarray对象,这些值和 2018 年的实际调整收盘价分别提取到predictionsactual变量中。由于原始数据集的格式是按时间降序排列的,我们将它们反转为升序以绘制图表。运行以下代码生成图表:

In [ ]:
    %matplotlib inline 
    import matplotlib.pyplot as plt

    plt.figure(figsize=(12,8))
    plt.title('Actual and predicted prices of AAPL 2018')
    plt.plot(actual, label='Actual')
    plt.plot(predictions, linestyle='dotted', label='Predicted')
    plt.legend()

生成以下输出:

实线显示了实际调整后的收盘价,而虚线显示了预测价格。请注意,尽管模型没有任何关于 2018 年实际价格的知识,我们的预测仍然遵循实际价格的一般趋势。然而,我们的深度学习预测模型还有很多改进空间,比如神经元网络架构、隐藏层、激活函数和初始化方案的设计。

使用 Keras 进行信用卡支付违约预测

另一个流行的深度学习 Python 库是 Keras。在本节中,我们将使用 Keras 构建一个信用卡支付违约预测模型,并看看相对于 TensorFlow,构建一个具有五个隐藏层的人工神经网络、应用激活函数并训练该模型有多容易。

Keras 简介

Keras 是一个开源的 Python 深度学习库,旨在高层次、用户友好、模块化和可扩展。Keras 被设计为一个接口,而不是一个独立的机器学习框架,运行在 TensorFlow、CNTK 和 Theano 之上。其拥有超过 20 万用户的庞大社区使其成为最受欢迎的深度学习库之一。

安装 Keras

Keras 的官方文档页面位于keras.io。安装 Keras 的最简单方法是在终端中运行以下命令:pip install keras。默认情况下,Keras 将使用 TensorFlow 作为其张量操作库,但也可以配置其他后端实现。

获取数据集

我们将使用从 UCI 机器学习库下载的信用卡客户违约数据集(archive.ics.uci.edu/ml/datasets/default+of+credit+card+clients)。来源:Yeh, I. C., and Lien, C. H.(2009).* The comparisons of data mining techniques for the predictive accuracy of probability of default of credit card clients. Expert Systems with Applications, 36(2), 2473-2480.*

该数据集包含台湾客户的违约支付。请参考网页上的属性信息部分,了解数据集中列的命名约定。由于原始数据集是以 Microsoft Excel 电子表格 XLS 格式存在的,需要进行额外的数据处理。打开文件并删除包含附加属性信息的第一行和第一列,然后将其保存为 CSV 文件。源代码存储库的files\chapter11\default_cc_clients.csv中可以找到此文件的副本。

将此数据集读取为一个名为dfpandas DataFrame 对象:

In [ ]:
    import pandas as pd

    df = pd.read_csv('files/chapter11/default_cc_clients.csv')

使用info()命令检查这个 DataFrame:

In [ ]:
    df.info()
Out[ ]:
    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 30000 entries, 0 to 29999
    Data columns (total 24 columns):
    LIMIT_BAL                     30000 non-null int64
    SEX                           30000 non-null int64
    EDUCATION                     30000 non-null int64
    MARRIAGE                      30000 non-null int64
    AGE                           30000 non-null int64
    PAY_0                         30000 non-null int64
    ...
    PAY_AMT6                      30000 non-null int64
    default payment next month    30000 non-null int64
    dtypes: int64(24)
    memory usage: 5.5 MB

输出被截断,但总结显示我们有 30,000 行信用违约数据,共 23 个特征。目标变量是名为default payment next month的最后一列。值为 1 表示发生了违约,值为 0 表示没有。

如果有机会打开 CSV 文件,您会注意到数据集中的所有值都是数字格式,而诸如性别、教育和婚姻状况等值已经转换为整数等效值,省去了额外的数据预处理步骤。如果您的数据集包含字符串或布尔值,请记得执行标签编码并将它们转换为虚拟或指示器值。

拆分和缩放数据

在将数据集输入模型之前,我们必须以适当的格式准备它。以下步骤将指导您完成这个过程:

  1. 将数据集拆分为独立变量和目标变量:
In [ ]:
    feature_columns= df.columns[:-1]
    features = df.loc[:, feature_columns]
    target = df.loc[:, 'default payment next month']

数据集中最后一列中的目标值被分配给 target 变量,而剩余的值是特征值,并被分配给 features 变量。

  1. 将数据集拆分为训练数据和测试数据:
In [ ]:
    from sklearn.model_selection import train_test_split

    train_features, test_features, train_target, test_target = \
        train_test_split(features, target, test_size=0.20, random_state=0)

sklearntrain_test_split() 命令有助于将数组或矩阵拆分为随机的训练和测试子集。提供的每个非关键字参数都提供了一对输入的训练-测试拆分。在这里,我们将为输入和输出数据获得两个这样的拆分对。test_size 参数表示我们将在测试拆分中包含 20% 的输入。random_state 参数将随机数生成器设置为零。

  1. 将拆分的数据转换为 NumPy 数组对象:
In [ ]:
    import numpy as np

    train_x, train_y = np.array(train_features), np.array(train_target)
    test_x, test_y = np.array(test_features), np.array(test_target)
  1. 最后,通过使用 sklearn 模块的 MinMaxScaler() 来对特征进行缩放,标准化数据集:
In [ ]:
    from sklearn.preprocessing import MinMaxScaler

    scaler = MinMaxScaler()
    train_scaled_x = scaler.fit_transform(train_x)
    test_scaled_x = scaler.transform(test_x)

与上一节一样,应用了 fit_transform()transform() 命令。但是,这次默认的缩放范围是 0 到 1。准备好我们的数据集后,我们可以开始使用 Keras 设计神经网络。

使用 Keras 设计一个具有五个隐藏层的深度神经网络

Keras 在处理模型时使用层的概念。有两种方法可以做到这一点。最简单的方法是使用顺序模型来构建层的线性堆叠。另一种是使用功能 API 来构建复杂的模型,如多输出模型、有向无环图或具有共享层的模型。这意味着可以使用来自层的张量输出来定义模型,或者模型本身可以成为一个层:

  1. 让我们使用 Keras 库并创建一个 Sequential 模型:
In [ ]:
    from keras.models import Sequential
    from keras.layers import Dense
    from keras.layers import Dropout
    from keras.layers.normalization import BatchNormalization

    num_features = train_scaled_x.shape[1]

    model = Sequential()
    model.add(Dense(80, input_dim=num_features, activation='relu'))
    model.add(Dropout(0.2))
    model.add(Dense(80, activation='relu'))
    model.add(Dropout(0.2))
    model.add(Dense(40, activation='relu'))
    model.add(BatchNormalization())
    model.add(Dense(1, activation='sigmoid'))

add() 方法简单地向我们的模型添加层。第一层和最后一层分别是输入层和输出层。每个 Dense() 命令创建一个密集连接神经元的常规层。它们之间,使用了一个 dropout 层来随机将输入单元设置为零,有助于防止过拟合。在这里,我们将 dropout 率指定为 20%,尽管通常使用 20% 到 50%。

具有值为 80 的第一个 Dense() 命令参数指的是输出空间的维度。可选的 input_dim 参数仅适用于输入层的特征数量。ReLU 激活函数被指定为除输出层外的所有层。在输出层之前,批量归一化层将激活均值转换为零,标准差接*于一。与最终输出层的 sigmoid 激活函数一起,输出值可以四舍五入到最*的 0 或 1,满足我们的二元分类解决方案。

  1. summary() 命令打印模型的摘要:
In [ ]:
    model.summary()
Out[ ]:
    _________________________________________________________________
    Layer (type)                 Output Shape              Param #   
    =================================================================
    dense_17 (Dense)             (None, 80)                1920      
    _________________________________________________________________
    dropout_9 (Dropout)          (None, 80)                0         
    _________________________________________________________________
    dense_18 (Dense)             (None, 80)                6480      
    _________________________________________________________________
    dropout_10 (Dropout)         (None, 80)                0         
    _________________________________________________________________
    dense_19 (Dense)             (None, 40)                3240      
    _________________________________________________________________
    batch_normalization_5 (Batch (None, 40)                160       
    _________________________________________________________________
    dense_20 (Dense)             (None, 1)                 41        
    =================================================================
    Total params: 11,841
    Trainable params: 11,761
    Non-trainable params: 80
    _________________________________________________________________

我们可以看到每一层的输出形状和权重。密集层的参数数量计算为权重矩阵的总数加上偏置矩阵中的元素数量。例如,第一个隐藏层 dense_17 将有 23×80+80=1920 个参数。

Keras 提供的激活函数列表可以在 keras.io/activations/ 找到。

  1. 使用 compile() 命令为训练配置此模型:
In [ ]:
    import tensorflow as tf

    model.compile(optimizer=tf.train.AdamOptimizer(), 
                  loss='binary_crossentropy',
                  metrics=['accuracy'])

optimizer 参数指定了用于训练模型的优化器。Keras 提供了一些优化器,但我们可以选择使用自定义优化器实例,例如在前面的 TensorFlow 中使用 Adam 优化器。选择二元交叉熵计算作为损失函数,因为它适用于我们的二元分类问题。metrics 参数指定在训练和测试期间要生成的指标列表。在这里,准确度将在拟合模型后生成。

在 Keras 中可以找到一系列可用的优化器列表,网址为keras.io/optimizers/。在 Keras 中可以找到一系列可用的损失函数列表,网址为keras.io/losses/

  1. 现在是使用fit()命令进行 100 个时期的模型训练的时候了:
In [ ]:
    from keras.callbacks import History 

    callback_history = History()

    model.fit(
        train_scaled_x, train_y,
        validation_split=0.2,
        epochs=100, 
        callbacks=[callback_history]
    )
Out [ ]:
    Train on 19200 samples, validate on 4800 samples
    Epoch 1/100
    19200/19200 [==============================] - 2s 106us/step - loss: 0.4209 - acc: 0.8242 - val_loss: 0.4456 - val_acc: 0.8125        
...

由于模型为每个时期生成详细的训练更新,因此上述输出被截断。创建一个History()对象并将其馈送到模型的回调中以记录训练期间的事件。fit()命令允许指定时期数和批量大小。设置validation_split参数,使得 20%的训练数据将被保留为验证数据,在每个时期结束时评估损失和模型指标。

您也可以分批训练数据,而不是一次性训练数据。使用fit()命令和epochsbatch_size参数,如下所示:model.fit(x_train, y_train, epochs=5, batch_size=32)。您也可以使用train_on_batch()命令手动训练批次,如下所示:model.train_on_batch(x_batch, y_batch)

衡量我们模型的性能

使用我们的测试数据,我们可以计算模型的损失和准确率:

In [ ]:
    test_loss, test_acc = model.evaluate(test_scaled_x, test_y)
    print('Test loss:', test_loss)
    print('Test accuracy:', test_acc)
Out[ ]:
    6000/6000 [==============================] - 0s 33us/step
    Test loss: 0.432878403028
    Test accuracy: 0.824166666667

我们的模型有 82%的预测准确率。

运行风险指标

在第十章中,金融机器学习,我们讨论了混淆矩阵、准确率、精确度分数、召回率和 F1 分数在测量基于分类的预测时的应用。我们也可以在我们的模型上重复使用这些指标。

由于模型输出以 0 到 1 之间的标准化小数格式为基础,我们将其四舍五入到最接*的 0 或 1 整数,以获得预测的二元分类标签:

In [ ]:
    predictions = model.predict(test_scaled_x)
    pred_values = predictions.round().ravel()

ravel()命令将结果呈现为存储在pred_values变量中的单个列表。

计算并显示混淆矩阵:

In [ ]:
    from sklearn.metrics import confusion_matrix

    matrix = confusion_matrix(test_y, pred_values)
In [ ]:
    %matplotlib inline
    import seaborn as sns
    import matplotlib.pyplot as plt

    flags = ['No', 'Yes']
    plt.subplots(figsize=(12,8))
    sns.heatmap(matrix.T, square=True, annot=True, fmt='g', cbar=True,
        cmap=plt.cm.Blues, xticklabels=flags, yticklabels=flags)
    plt.xlabel('Actual')
    plt.ylabel('Predicted')
    plt.title('Credit card payment default prediction');

这产生了以下输出:

使用sklearn模块打印准确率、精确度分数、召回率和 F1 分数:

In [ ]:
    from sklearn.metrics import (
        accuracy_score, precision_score, recall_score, f1_score
    )
    actual, predicted = test_y, pred_values
    print('accuracy_score:', accuracy_score(actual, predicted))
    print('precision_score:', precision_score(actual, predicted))
    print('recall_score:', recall_score(actual, predicted))
    print('f1_score:', f1_score(actual, predicted))    
Out[ ]:
    accuracy_score: 0.818666666667
    precision_score: 0.641025641026
    recall_score: 0.366229760987
    f1_score: 0.466143277723

低召回率和略低于*均水*的 F1 分数暗示我们的模型不够竞争力。也许我们可以在下一节中查看历史指标以了解更多信息。

在 Keras 历史记录中显示记录的事件

让我们回顾一下callback_history变量,这是在fit()命令期间填充的History对象。History.history属性是一个包含四个键的字典,存储训练和验证期间的准确率和损失值。这些值被保存在每个时期之后。将这些信息提取到单独的变量中:

In [ ]:
    train_acc = callback_history.history['acc']
    val_acc = callback_history.history['val_acc']
    train_loss = callback_history.history['loss']
    val_loss = callback_history.history['val_loss']

使用以下代码绘制训练和验证损失:

In [ ]:
    %matplotlib inline
    import matplotlib.pyplot as plt

    epochs = range(1, len(train_acc)+1)

    plt.figure(figsize=(12,6))
    plt.plot(epochs, train_loss, label='Training')
    plt.plot(epochs, val_loss, '--', label='Validation')
    plt.title('Training and validation loss')
    plt.xlabel('epochs')
    plt.ylabel('loss')
    plt.legend();

这产生了以下损失图:

实线显示了随着时期数的增加,训练损失在减少,这意味着我们的模型随着时间更好地学习训练数据。虚线显示了随着时期数的增加,验证损失在增加,这意味着我们的模型在验证集上的泛化能力不够好。这些趋势表明我们的模型容易过拟合。

使用以下代码绘制训练和验证准确率:

In [ ]:
    plt.clf()  # Clear the figure
    plt.plot(epochs, train_acc, '-', label='Training')
    plt.plot(epochs, val_acc, '--', label='Validation')
    plt.title('Training and validation accuracy')
    plt.xlabel('epochs')
    plt.ylabel('accuracy')
    plt.legend();

这产生了以下图形:

实线显示了随着时期数量的增加,训练准确性增加的路径,而虚线显示了验证准确性的下降。这两个图表强烈暗示我们的模型正在过度拟合训练数据。看起来还需要做更多的工作!为了防止过度拟合,可以使用更多的训练数据,减少网络的容量,添加权重正则化,和/或使用一个丢失层。实际上,深度学习建模需要理解潜在问题,找到合适的神经网络架构,并调查每一层激活函数的影响,以产生良好的结果。

总结

在本章中,我们介绍了深度学习和神经网络的使用。人工神经网络由输入层和输出层组成,在中间有一个或多个隐藏层。每一层都由人工神经元组成,每个人工神经元接收加权输入,这些输入与偏差相加。激活函数将这些输入转换为输出,并将其作为输入馈送到另一个神经元。

使用 TensorFlow Python 库,我们构建了一个具有四个隐藏层的深度学习模型,用于预测证券的价格。数据集经过缩放预处理,并分为训练和测试数据。设计人工神经网络涉及两个阶段。第一阶段是组装图形,第二阶段是训练模型。TensorFlow 会话对象提供了一个执行环境,在那里训练在多个时期内进行,每个时期使用小批量训练。由于模型输出包括归一化值,我们将数据缩放回其原始表示以返回预测价格。

Keras 是另一个流行的深度学习库,利用 TensorFlow 作为后端。我们构建了另一个深度学习模型,用于预测信用卡支付违约,其中包括五个隐藏层。Keras 在处理模型时使用层的概念,我们看到添加层、配置模型、训练和评估性能是多么容易。Keras 的History对象记录了连续时期的训练和验证数据的损失和准确性。

实际上,一个良好的深度学习模型需要努力和理解潜在问题,以产生良好的结果。

posted @ 2025-01-21 21:17  绝不原创的飞龙  阅读(75)  评论(0)    收藏  举报