在 Python 中处理股票收益率

翻译自 
https://www.tidy-finance.org/python/working-with-stock-returns.html

你正在阅读 Tidy Finance with Python。你可以在 这里 找到与之对应的 Tidy Finance with R 的章节。

本章的主要目标是让你熟悉如何使用 tidyverse 来处理股票市场数据。我们专注于从数据提供商雅虎财经下载和可视化股票数据。

在每次会话开始时,我们会加载所需的 Python 包。在本书的整个过程中,我们始终使用 pandas(McKinney,2010)和 numpy(Harris 等,2020)包。在本章中,我们还会加载 yfinance(Aroussi,2023)包来下载股票价格数据。

如果你尚未安装某个包,那么在将其加载到你的 Python 会话之前,你通常需要先安装一次。例如,你可以在终端中调用 pip install pandas

1
2
3
import pandas as pd
import numpy as np
import yfinance as yf

下载数据

请注意,import pandas as pd 意味着我们可以在后续使用 pd.function() 的方式调用所有 pandas 函数。相反,使用 from pandas import * 是不被推荐的,因为它会导致命名空间污染。该语句会将 pandas 中的所有函数和类导入到你的当前命名空间中,可能会与你自己定义的函数或其他已导入库中的函数产生冲突。使用 pd 缩写是一种非常方便的方式来避免这种情况。

我们首先从雅虎财经直接下载一只股票的每日价格,例如苹果公司股票(AAPL)。要下载数据,你可以使用 yf.download() 函数。

在以下代码中,我们请求从 2000 年初到上一年年底的数据,这是一个超过 20 年的时期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
prices = (yf.download(
tickers="AAPL",
start="2000-01-01",
end="2023-12-31",
progress=False,
auto_adjust=False,
multi_level_index=False,
)
.reset_index()
.assign(symbol="AAPL")
.rename(columns={
"Date": "date",
"Open": "open",
"High": "high",
"Low": "low",
"Close": "close",
"Adj Close": "adjusted",
"Volume": "volume"}
)
)
prices.head().round(3)
date adjusted close high low open volume symbol
0 2000-01-03 0.842 0.999 1.004 0.908 0.936 535796800 AAPL
1 2000-01-04 0.771 0.915 0.988 0.903 0.967 512377600 AAPL
2 2000-01-05 0.782 0.929 0.987 0.920 0.926 778321600 AAPL
3 2000-01-06 0.715 0.848 0.955 0.848 0.948 767972800 AAPL
4 2000-01-07 0.749 0.888 0.902 0.853 0.862 460734400 AAPL

yf.download() 从雅虎财经下载股票市场数据。上述代码块返回一个包含八列的 DataFrame,这些列的含义都很明确:symboldate、每日 volume(以交易股票数量表示)、开盘、最高、最低、收盘的市场价格以及以美元为单位的 adjusted 价格。调整后的价格会纠正任何可能在市场收盘后影响股票价格的因素,例如股票拆分和股息。这些行为会影响报价价格,但对持有股票的投资者没有直接影响。因此,在分析投资者通过持续持有股票所获得的收益时,我们通常依赖于调整后的价格。

接下来,我们使用 plotnine 包(Kibirige,2023)来可视化调整后价格的时间序列(见图 1)。该包基于图形语法(Wilkinson,2012)的原则来处理可视化任务。请注意,我们通常不推荐使用 * 导入方式。然而,我们在这里仅将其用于绘图函数,这些函数是 plotnine 所特有的,并且名称与绘图密切相关。因此,由于命名空间污染而导致误用的风险很小。

使用图形语法创建图表非常直观,如下述代码块所示。

1
2
3
4
5
6
7
8
9
(
ggplot(prices,
aes(y="adjusted", x="date"))
+ geom_line()
+ labs(
x="", y="",
title="2000 年至 2023 年苹果公司股票价格"
)
)

img

图 1:价格以美元为单位,已调整股息支付和股票拆分。

计算收益率

我们不分析价格,而是计算每日收益率,定义为 ( \frac{P_t - P_{t-1}}{P_{t-1}} ),其中 ( P_t ) 是第 ( t ) 天末的调整后价格。在这种情况下,lag() 函数很有帮助,它可以返回之前的值。

1
2
3
4
5
6
returns = (prices
.sort_values("date")
.assign(ret=lambda x: x["adjusted"].pct_change())
.get(["symbol", "date", "ret"])
)
returns
symbol date ret
0 AAPL 2000-01-03 NaN
1 AAPL 2000-01-04 -0.084310
2 AAPL 2000-01-05 0.014633
3 AAPL 2000-01-06 -0.086538
4 AAPL 2000-01-07 0.047369
6032 AAPL 2023-12-22 -0.005548
6033 AAPL 2023-12-26 -0.002841
6034 AAPL 2023-12-27 0.000518
6035 AAPL 2023-12-28 0.002226
6036 AAPL 2023-12-29 -0.005424

6037 行 × 3 列

得到的 DataFrame 包含三列,最后一列包含每日收益率(ret)。请注意,第一行自然包含一个缺失值(NaN),因为没有前一个价格。显然,如果时间序列未按升序日期排序,使用 pct_change() 将毫无意义。sort_values() 函数提供了一种方便的方式来按正确的方式排序观测值。如果你想按降序日期排序观测值,可以使用参数 ascending=False

对于接下来的示例,我们移除缺失值,因为这些值在许多应用中需要单独处理。例如,缺失值可能会影响总和和平均值,因为如果未正确处理,它们会减少有效数据点的数量。一般来说,始终要确保你明白 NaN 值出现的原因,并仔细检查是否可以简单地移除这些观测值。

1
returns = returns.dropna()

接下来,我们在图 2 中用直方图可视化每日收益率的分布。此外,我们在直方图中画了一条虚线,表示每日收益率的历史 5% 分位数,这是最坏可能收益率的粗略代理,其概率最多
为 5%。这个分位数与(历史)风险价值密切相关,这是一种通常由监管机构监控的风险度量。我们参考 Tsay(2010)以更全面地介绍金融收益率的典型事实。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from mizani.formatters import percent_format

quantile_05 = returns["ret"].quantile(0.05)

(
ggplot(returns, aes(x="ret"))
+ geom_histogram(bins=100)
+ geom_vline(aes(xintercept=quantile_05), linetype="dashed")
+ labs(
x="", y="",
title="苹果公司每日股票收益率的分布"
)
+ scale_x_continuous(labels=percent_format())
)

img

图 2:虚线垂直线表示每日收益率的历史 5% 分位数。

这里,bins=100 决定了用于插图的箱数,因此隐含地设置了箱的宽度。在继续之前,请确保你理解如何使用 geom_vline() 添加一条虚线,以表示每日收益率的历史 5% 分位数。在处理任何数据之前,一个典型的任务是计算并分析主要变量的描述性统计量。

1
pd.DataFrame(returns["ret"].describe()).round(3).T
count mean std min 25% 50% 75% max
ret 6036.0 0.001 0.025 -0.519 -0.01 0.001 0.013 0.139

我们看到,最大 收益率为 13.9%。也许并不令人惊讶的是,平均日收益率接近但略高于 0。与上述插图一致的是,最低收益率当天的巨大损失表明收益率分布存在显著的不对称性。

你还可以通过调用 groupby(returns["date"].dt.year) 为每个单独的年份计算这些描述性统计量,其中 dt.year 返回年份。更具体地说,下面的几行代码为由列 year 的值定义的各个数据组计算上述描述性统计量。因此,描述性统计量允许对日收益率分布的时间序列动态进行初步分析。

1
2
3
4
5
(returns["ret"]
.groupby(returns["date"].dt.year)
.describe()
.round(3)
)
count mean std min 25% 50% 75% max
date
2000 251.0 -0.003 0.055 -0.519 -0.034 -0.002 0.027 0.137
2001 248.0 0.002 0.039 -0.172 -0.023 -0.001 0.027 0.129
2002 252.0 -0.001 0.031 -0.150 -0.019 -0.003 0.018 0.085
2003 252.0 0.002 0.023 -0.081 -0.012 0.002 0.015 0.113
2004 252.0 0.005 0.025 -0.056 -0.009 0.003 0.016 0.132
2005 252.0 0.003 0.024 -0.092 -0.010 0.003 0.017 0.091
2006 251.0 0.001 0.024 -0.063 -0.014 -0.002 0.014 0.118
2007 251.0 0.004 0.024 -0.070 -0.009 0.003 0.018 0.105
2008 253.0 -0.003 0.037 -0.179 -0.024 -0.001 0.019 0.139
2009 252.0 0.004 0.021 -0.050 -0.009 0.002 0.015 0.068
2010 252.0 0.002 0.017 -0.050 -0.006 0.002 0.011 0.077
2011 252.0 0.001 0.017 -0.056 -0.009 0.001 0.011 0.059
2012 250.0 0.001 0.019 -0.064 -0.008 0.000 0.012 0.089
2013 252.0 0.000 0.018 -0.124 -0.009 -0.000 0.011 0.051
2014 252.0 0.001 0.014 -0.080 -0.006 0.001 0.010 0.082
2015 252.0 0.000 0.017 -0.061 -0.009 -0.001 0.009 0.057
2016 252.0 0.001 0.015 -0.066 -0.006 0.001 0.008 0.065
2017 251.0 0.002 0.011 -0.039 -0.004 0.001 0.007 0.061
2018 251.0 -0.000 0.018 -0.066 -0.009 0.001 0.009 0.070
2019 252.0 0.003 0.016 -0.100 -0.005 0.003 0.012 0.068
2020 253.0 0.003 0.029 -0.129 -0.010 0.002 0.017 0.120
2021
252.0 0.001 0.016 -0.042 -0.008 0.001 0.012 0.054
2022 251.0 -0.001 0.022 -0.059 -0.016 -0.001 0.014 0.089
2023 250.0 0.002 0.013 -0.048 -0.006 0.002 0.009 0.047

扩展分析

下一步,我们将之前的代码进行扩展,以便能够处理任意数量的股票代码(例如某个指数的所有成分股)。遵循整洁数据原则,将之前的计算扩展到任意数量的资产或分组变得非常容易。以下代码可以处理任意数量的股票代码,例如 symbol = ["AAPL", "MMM", "BA"],并自动化下载以及绘制价格时间序列的图表。最后,我们一次性为所有资产创建描述性统计量的表格。在这个例子中,我们分析道琼斯工业平均指数所有当前成分股的数据。

我们首先从一个外部网站下载一个包含道琼斯成分股的表格。请注意,当你直接从网络读取成分股时,需要临时修改 Python 的 ssl 模块处理 SSL 证书的行为。这种方法应该谨慎使用,因此我们在数据下载成功后将设置恢复为默认行为。

1
2
3
4
5
6
7
8
9
10
11
12
import ssl
ssl._create_default_https_context = ssl._create_unverified_context

url = ("https://www.ssga.com/us/en/institutional/etfs/library-content/"
"products/fund-data/etfs/us/holdings-daily-us-en-dia.xlsx")

symbols = (pd.read_excel(url, skiprows=4, nrows=30)
.get("Ticker")
.tolist()
)

ssl._create_default_https_context = ssl.create_default_context

幸运的是,yf.download() 提供了在单次调用中获取某个时间点指数中所有股票价格的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
prices_daily = (yf.download(
tickers=symbols,
start="2000-01-01",
end="2023-12-31",
progress=False,
auto_adjust=False,
multi_level_index=False
))

prices_daily = (prices_daily
.stack()
.reset_index(level=1, drop=False)
.reset_index()
.rename(columns={
"Date": "date",
"Ticker": "symbol",
"Open": "open",
"High": "high",
"Low": "low",
"Close": "close",
"Adj Close": "adjusted",
"Volume": "volume"}
)
)

得到的 DataFrame 包含 177,925 个每日观测值,涵盖 30 种不同的股票。图 3 展示了道琼斯指数成分股的调整后价格的时间序列。请确保你理解代码的每一行!aes() 的参数是什么?你可以使用哪些替代的 geoms 来可视化时间序列?提示:如果你不知道答案,尝试更改代码,看看你的干预会有什么不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from mizani.breaks import date_breaks
from mizani.formatters import date_format

(
ggplot(prices_daily,
aes(y="adjusted", x="date", color="symbol"))
+ geom_line()
+ scale_x_datetime(date_breaks="5 years", date_labels="%Y")
+ labs(
x="", y="", color="",
title="道琼斯指数成分股的股票价格"
)
+ theme(legend_position="none")
)

img

图 3:价格以美元为单位,已调整股息支付和股票拆分。

你是否注意到与之前使用的代码的细微差别?为了同时绘制所有股票代码,我们只需要在 ggplot 美学中包含 color = symbol。这样,我们就可以为每个股票代码生成一条单独的线。当然,图上有太多条线,无法正确识别各个股票,但它很好地说明了如何将特定分析扩展到任意数量的主题。

同样的逻辑也适用于股票收益率。在计算收益率之前,我们使用 groupby("symbol"),以便 assign() 命令针对每个股票代码分别执行。同样的逻辑也适用于描述性统计量的计算:groupby("symbol") 是将时间序列聚合为符号特定变量的关键。

1
2
3
4
5
6
7
8
9
10
11
returns_daily = (prices_daily
.assign(ret=lambda x: x.groupby("symbol")["adjusted"].pct_change())
.get(["symbol", "date", "ret"])
.dropna(subset="ret")
)

(returns_daily
.groupby("symbol")["ret"]
.describe()
.round(3)
)
count mean std min 25% 50% 75% max
symbol
AAPL 6036.0 0.001 0.025 -0.519 -0.010 0.001 0.013 0.139
AMGN 6036.0 0.000 0.019 -0.134 -0.009 0.000 0.009 0.151
AMZN 6036.0 0.001 0.032 -0.248 -0.012 0.000 0.014 0.345
AXP 6036.0 0.001 0.023 -0.176 -0.009 0.000 0.010 0.219
BA 6036.0 0.001 0.022 -0.238 -0.010 0.001 0.011 0.243
CAT 6036.0 0.001 0.020 -0.145 -0.010 0.001 0.011 0.147
CRM 4914.0 0.001 0.027 -0.271 -0.012 0.001 0.014 0.260
CSCO 6036.0 0.000 0.023 -0.162 -0.009 0.000 0.010 0.244
CVX 6036.0 0.001 0.018 -0.221 -0.008 0.001 0.009 0.227
DIS 6036.0 0.000 0.019 -0.184 -0.009 0.000 0.009 0.160
GS 6036.0 0.001 0.023 -0.190 -0.010 0.000 0.011 0.265
HD 6036.0 0.001 0.019 -0.287 -0.008 0.001 0.009 0.141
HON 6036.0 0.000 0.019 -0.174 -0.008 0.001 0.009 0.282
IBM
6036.0 0.000 0.016 -0.155 -0.007 0.000 0.008 0.120
JNJ 6036.0 0.000 0.012 -0.158 -0.005 0.000 0.006 0.122
JPM 6036.0 0.001 0.024 -0.207 -0.009 0.000 0.010 0.251
KO 6036.0 0.000 0.013 -0.101 -0.005 0.000 0.006 0.139
MCD 6036.0 0.001 0.015 -0.159 -0.006 0.001 0.007 0.181
MMM 6036.0 0.000 0.015 -0.129 -0.007 0.000 0.008 0.126
MRK 6036.0 0.000 0.017 -0.268 -0.007 0.000 0.008 0.130
MSFT 6036.0 0.001 0.019 -0.156 -0.008 0.000 0.009 0.196
NKE 6036.0 0.001 0.019 -0.198 -0.008 0.001 0.009 0.155
NVDA 6036.0 0.002 0.038 -0.352 -0.016 0.001 0.018 0.424
PG 6036.0 0.000 0.013 -0.302 -0.005 0.000 0.006 0.120
SHW 6036.0 0.001 0.018 -0.208 -0.008 0.001 0.009 0.153
TRV 6036.0 0.001 0.018 -0.208 -0.007 0.001 0.008 0.256
UNH 6036.0 0.001 0.020 -0.186 -0.008 0.001 0.010 0.348
V 3973.0 0.001 0.019 -0.136 -0.008 0.001 0.009 0.150
VZ 6036.0 0.000 0.015 -0.118 -0.007 0.000 0.007 0.146
WMT 6036.0 0.000 0.015 -0.114 -0.007 0.000 0.007 0.117

不同频率的数据

金融数据通常由于不同的报告时间表、交易日历和经济数据发布时间而以不同的频率存在。例如,股票价格通常每天记录一次,而宏观经济指标(如 GDP 或通货膨胀率)则每月或每季度报告一次。此外,一些数据集仅在发生交易时记录,导致时间戳不规则。为了有意义地比较数据,我们需要适当地对齐不同频率的数据。例如,为了比较不同频率的收益率,我们使用年化技术。

到目前为止,我们一直在处理日收益率,但我们可以轻松地将数据转换为其他频率。让我们从日数据中创建月收益率:

1
2
3
4
5
returns_monthly = (returns_daily
.assign(date=returns_daily["date"].dt.to_period("M").dt.to_timestamp())
.groupby(["symbol", "date"], as_index=False)
.agg(ret=("ret", lambda x: np.prod(1 + x) - 1))
)

在这段代码中,我们首先按股票代码和月份对数据进行分组,然后通过复合日收益率来计算月收益率:( \text{ret}{\text{monthly}} = \prod{i=1}^{n} (1 + \text{ret}_{\text{daily}, i}) - 1 )。为了可视化收益率特征如何随不同频率变化,我们可以比较直方图,如图 4 所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apple_daily = (returns_daily
.query("symbol == 'AAPL'")
.assign(frequency="Daily")
)

apple_monthly = (returns_monthly
.query("symbol == 'AAPL'")
.assign(frequency="Monthly")
)

apple_returns = pd.concat([apple_daily, apple_monthly], ignore_index=True)

(
ggplot(apple_returns, aes(x="ret", fill="frequency"))
+ geom_histogram(position="identity", bins=50)
+ labs(
x="", y="", fill="频率",
title="苹果公司不同频率的收益率分布"
)
+ scale_x_continuous(labels=percent_format())
+ facet_wrap("frequency", scales="free")
+ theme(legend_position="none")
)

img

图 4:收益率基于调整后的价格,已调整股息支付和股票拆分。

其他形式的数据聚合

当然,除了按 symboldate 进行聚合之外,按其他变量进行聚合也可能有意义。例如,假设你想回答这样的问题:交易量高的日子是否通常会跟随交易量高的日子?为了对这个问题进行初步分析,我们取下载的数据并计算所有道琼斯指数成分股的每日总交易量(以美元为单位)。回想一下,volume 列是以交易股票数量表示的。因此,我们将交易量乘以每日调整后的收盘价,得到一个以美元为单位的总交易量的代理值。乘以 1e-9(Python 可以处理科学计数法)表示每日交易量以十亿美元为单位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
trading_volume = (prices_daily
.assign(trading_volume=lambda x: (x["volume"]*x["adjusted"])/1e9)
.groupby("date")["trading_volume"]
.sum()
.reset_index()
.assign(trading_volume_lag=lambda x: x["trading_volume"].shift(periods=1))
)

(
ggplot(trading_volume,
aes(x="date", y="trading_volume"))
+ geom_line()
+ scale_x_datetime(date_breaks="5 years", date_labels="%Y")
+ labs(
x="", y="",
title="道琼斯指数成分股的每日总交易量(以十亿美元为单位)"
)
)

图 5:每日总交易量(以十亿美元为单位)。

图 5 显示出总交易量有明显的上升趋势。特别是自新冠疫情爆发以来,市场处理了大量的交易量,正如 Goldstein、Koijen 和 Mueller(2021)所分析的那样。一种展示交易量持续性的方式是绘制第 ( t ) 天的交易量与第 ( t-1 ) 天的交易量之间的关系,如下面的例子所示。在图 6 中
,我们添加了一条虚线的 45° 线,以表示假设的一比一关系(通过 geom_abline()),以解决轴刻度可能不同的问题。

1
2
3
4
5
6
7
8
9
(
ggplot(trading_volume,
aes(x="trading_volume_lag", y="trading_volume")) +
geom_point() +
geom_abline(aes(intercept=0, slope=1), linetype="dashed") +
labs(x="前一天的总交易量",
y="总交易量",
title="道琼斯成分股每日交易量的持续性(以十亿美元为单位)")
)

图 6:每日总交易量(以十亿美元为单位)。

仅凭肉眼观察就可以发现,交易量高的日子通常会跟随交易量高的日子。

重点总结

在本章中,你学习了如何使用 Python 和整洁数据原则来下载、分析和可视化股票市场数据:

  • 整洁数据原则可以高效地分析金融数据。
  • 调整后的价格考虑了股票拆分和股息等公司行为。
  • 描述性统计量有助于识别金融数据中的关键模式。
  • 可视化技术可以揭示收益率的趋势和分布。
  • 使用 tidyverse 进行的数据操作可以轻松扩展到多个资产。
  • 一致的工作流程是高级金融分析的基础。

练习

  1. 使用 yf.download() 从雅虎财经下载你选择的另一个股票市场的每日价格。绘制该股票的未调整收盘价和调整后收盘价的时间序列图。解释两者之间的任何可见差异。
  2. 计算你选择的资产的日净收益率,并使用 100 个箱体在直方图中可视化日收益率的分布。同时,使用 geom_vline() 添加一条红色虚线,表示日收益率的 5% 分位数。计算日收益率的描述性统计量(均值、标准差、最小值和最大值)。
  3. 将你在前面练习中的代码进行扩展,以便你可以对任意数量的股票代码(例如 symbol = ["AAPL", "MMM", "BA"])进行所有计算。自动化下载、价格时间序列的绘制,并为这些任意数量的资产创建收益率描述性统计量的表格。
  4. 为了便于计算年化因子,编写一个函数,输入为收益率日期向量,确定频率后返回适当的年化因子。
  5. 交易量高的日子是否通常也是绝对收益率大的日子?使用 AAPL 股票找到一个合适的可视化方法来分析这个问题。

江达小记