from datetime import datetime
now = datetime.now()
now9 时间序列导论
时间序列数据是许多领域中一种重要的结构化数据形式,例如金融、经济学、生态学、神经科学和物理学。任何在多个时间点上重复记录的数据都构成了时间序列。许多时间序列是固定频率 (fixed frequency)的,也就是说,数据点按照某种规则以固定的时间间隔出现,例如每15秒、每5分钟或每月一次。时间序列也可以是不规则 (irregular)的,没有固定的时间单位或单位之间的偏移。如何标记和引用时间序列数据取决于具体的应用场景,你可能会遇到以下几种情况:
- 时间戳 (Timestamps): 时间中的特定瞬间。
- 固定时期 (Fixed periods): 例如2017年整个一月份,或2020年全年。
- 时间区间 (Intervals of time): 由一个开始时间戳和一个结束时间戳表示。时期可以被看作是区间的特例。
- 实验或流逝时间 (Experiment or elapsed time): 每个时间戳都是相对于某个特定开始时间的度量(例如,一个饼干从放入烤箱开始,每秒钟测量其直径),从0开始。
本章我们主要关注前三类时间序列,不过许多技术也可以应用于实验性时间序列,其索引可能是一个表示从实验开始后流逝时间的整数或浮点数。最简单的时间序列类型是由时间戳索引的。
timedelta 索引的说明
虽然 pandas 支持基于时间差(timedelta)的索引,这对于表示实验或流逝时间非常有用,但本书我们不深入探讨 timedelta 索引。感兴趣的同学可以查阅 pandas 的官方文档以了解更多信息。
pandas 提供了许多内置的时间序列工具和算法。你可以高效地处理大规模时间序列,并对不规则和固定频率的时间序列进行切片、聚合和重采样。这些工具中的一些对于金融和经济学应用特别有用,但你当然也可以用它们来分析服务器日志数据。
和之前的章节一样,我们首先导入 NumPy 和 pandas:
import numpy as np
import pandas as pd9.1 日期和时间数据类型及工具
Python 标准库包含了用于日期和时间数据的类型,以及与日历相关的功能。datetime、time 和 calendar 模块是入门的主要地方。其中,datetime.datetime 类型,或简称 datetime,被广泛使用:
now.year, now.month, now.day一个 datetime 对象同时存储了日期和时间,精度可以达到微秒。而 datetime.timedelta,或简称 timedelta,则表示两个 datetime 对象之间的时间差:
from datetime import timedelta
delta = datetime(2011, 1, 7) - datetime(2008, 6, 24, 8, 15)
deltadelta.daysdelta.seconds你可以将一个 timedelta(或其倍数)添加(或减去)到一个 datetime 对象上,从而得到一个新的、经过移位的 datetime 对象:
start = datetime(2011, 1, 7)
start + timedelta(12)start - 2 * timedelta(12)datetime.datetime vs. pandas.Timestamp
请同学们注意,Python 标准库的 datetime 对象与 pandas 库中的 Timestamp 对象是不同的。虽然它们在很多情况下可以互换使用,但 pandas.Timestamp 提供了更为强大的功能,这对于我们经济金融领域的量化分析至关重要:
- 精度更高:
Timestamp支持纳秒 (ns) 级精度,而datetime仅支持微秒 (us) 级。这对于处理高频交易等金融数据是必不可少的。 - 包含频率信息:
Timestamp对象可以携带频率信息(例如,‘D’ 代表每日),这在时间序列重采样和对齐操作中非常有用。 - 时区感知:
pandas提供了更便捷、更强大的时区处理能力,我们稍后会详细讨论。
在本书中,我们将主要使用 pandas 的时间序列对象,但了解其底层的 Python datetime 类型对于我们理解其工作原理是有益的。
表 tbl-datetime-types 总结了 datetime 模块中的数据类型。虽然本章主要关注 pandas 中的数据类型和更高级别的时间序列操作,但你们在 Python 的其他应用场景中可能会遇到这些基于 datetime 的类型。
datetime 模块中的主要类型
| 类型 | 描述 |
|---|---|
date |
使用公历存储日历日期(年、月、日) |
time |
存储一天中的时间(时、分、秒、微秒) |
datetime |
同时存储日期和时间 |
timedelta |
两个 datetime 值之间的差异(以天、秒和微秒表示) |
tzinfo |
用于存储时区信息的基类 |
9.1.1 字符串与 datetime 对象的相互转换
你可以使用 str() 函数或 strftime 方法,并传入一个格式规范,将 datetime 对象和 pandas 的 Timestamp 对象格式化为字符串。strftime 是 “string format time” 的缩写。
stamp = datetime(2011, 1, 3)
str(stamp)stamp.strftime('%Y-%m-%d')反之,你可以使用 datetime.strptime (“string parse time”) 将字符串转换为日期,但这要求你必须确切地知道输入字符串的格式。表 tbl-strftime-codes 提供了一个全面的格式代码列表。
datetime 格式化规范 (兼容 ISO C89)
| 代码 | 描述 |
|---|---|
%Y |
四位数年份 |
%y |
两位数年份 |
%m |
两位数月份 [01, 12] |
%d |
两位数日期 [01, 31] |
%H |
小时 (24小时制) [00, 23] |
%I |
小时 (12小时制) [01, 12] |
%M |
两位数分钟 [00, 59] |
%S |
秒 [00, 61] (秒数60, 61用于解释闰秒) |
%f |
微秒,作为零填充的整数 (从000000到999999) |
%w |
星期几,作为整数 [0 (周日), 6] |
%u |
星期几,作为整数,从1开始,其中1是周一 |
%U |
一年中的周数 [00, 53]; 周日是一周的第一天 |
%W |
一年中的周数 [00, 53]; 周一是一周的第一天 |
%z |
UTC时区偏移,格式为 +HHMM 或 -HHMM |
%Z |
时区名称字符串 |
%F |
%Y-%m-%d 的简写 |
%D |
%m/%d/%y 的简写 |
value = '2011-01-03'
datetime.strptime(value, '%Y-%m-%d')datestrs = ['7/6/2011', '8/6/2011']
[datetime.strptime(x, '%m/%d/%Y') for x in datestrs]datetime.strptime 是解析已知格式日期的一种方法。然而,在实际工作中,数据常常以各种不同的格式出现。pandas 更加面向处理日期数组,其 to_datetime 方法非常强大,因为它能自动解析许多不同种类的日期表示形式。
datestrs = ['2011-07-06 12:00:00', '2011-08-06 00:00:00']
pd.to_datetime(datestrs)它还能处理应被视为缺失值的情况(如 None、空字符串等),将它们转换为 NaT(Not a Time),这是 pandas 中用于时间戳数据的空值。
datestrs_with_null = ['2011-07-06 12:00:00', '2011-08-06 00:00:00', None]
idx = pd.to_datetime(datestrs_with_null)
idxpd.isna(idx)dateutil.parser 的作用
pandas 的 to_datetime 之所以如此强大,部分原因在于它在底层使用了 dateutil.parser 这个库。这个工具非常灵活,但有时也可能过于“智能”。例如,它会将 “42” 这样的字符串解析为当前日期的2042年。在处理不规整的数据时,虽然这种灵活性很有用,但也需要我们保持谨慎,因为它可能会导致意想不到的解析结果。在进行任何重要的分析之前,检查日期解析的结果总是一个好习惯。
datetime 对象还有许多针对其他国家或语言系统的本地化格式选项。例如,在德语或法语系统中,月份的缩写会与英语系统不同。具体请参见 表 tbl-locale-specific-formats。
本地化日期格式
| 代码 | 描述 |
|---|---|
%a |
星期几的缩写名称 |
%A |
星期几的完整名称 |
%b |
月份的缩写名称 |
%B |
月份的完整名称 |
%c |
完整的日期和时间 (例如, ‘Tue 01 May 2012 04:20:57 PM’) |
%p |
AM 或 PM 的本地化等价表示 |
%x |
本地化格式的日期 (例如, 在美国是 ‘05/01/2012’) |
%X |
本地化格式的时间 (例如, ‘04:24:12 PM’) |
9.2 时间序列基础
pandas 中最基本的一种时间序列对象是由时间戳索引的 Series。为了我们的示例,让我们获取一些真实的经济数据。我们将从联邦储备经济数据库 (FRED) 获取每日的10年期中国国债收益率。
from fredapi import Fred
# 使用课程提供的 API Key
fred_key = 'f2a2c60b6dc82682031f4ce84bf6da18'
fred = Fred(api_key=fred_key)
# 获取特定时期的每日10年期中国国债收益率
# 10年期中国国债的序列ID是 'INTGSBCN10Y'
ts = fred.get_series('INTGSBCN10Y', start_date='2011-01-01', end_date='2011-01-14')
ts = ts.dropna() # 移除没有交易的日期,这些日期的值为NaN
ts在底层,这些 datetime 对象被放入了一个 DatetimeIndex:
ts.index和其他 Series 一样,不同索引的时间序列之间的算术运算会自动按日期对齐:
ts + ts[::2]结果中包含了日期未对齐处的 NaN 值。
pandas 使用 NumPy 的 datetime64 数据类型以纳秒级的分辨率存储时间戳。
ts.index.dtype从 DatetimeIndex 中取出的标量值是 pandas 的 Timestamp 对象:
stamp = ts.index[0]
stamp一个 pandas.Timestamp 对象在大多数情况下可以替代 datetime 对象使用。然而,反过来则不成立,因为 pandas.Timestamp 可以存储纳秒级精度的数据,而 datetime 最高只能到微秒。此外,pandas.Timestamp 还可以存储频率信息(如果有的话),并且知道如何进行时区转换和其他类型的操作。
9.2.1 索引、选择与子集构造
当你基于标签进行索引和选择数据时,时间序列的行为与其他 Series 类似:
stamp = ts.index[2]
ts[stamp]为方便起见,你也可以传入一个可以被解释为日期的字符串:
ts['2011-01-10']对于更长的时间序列,这种方式尤其有用。让我们获取一个更长的数据序列,例如上证综合指数。
import yfinance as yf
# 上证综指的代码是 000001.SS
longer_ts = yf.download('000001.SS', start='2000-01-01', end='2002-12-31')['Adj Close'].dropna()
longer_ts.head()你可以传入年份或年份加月份来轻松选择数据的切片:
longer_ts['2001'].head()longer_ts['2001-05'].head()使用 datetime 对象进行切片同样有效:
ts[datetime(2011, 1, 7):]因为大多数时间序列数据是按时间顺序排列的,所以你可以使用不包含在时间序列中的时间戳进行切片,以执行范围查询:
ts['2011-01-06':'2011-01-11']请记住,以这种方式切片会产生源时间序列的视图,就像对 NumPy 数组进行切片一样。这意味着没有数据被复制,对切片的修改将反映在原始数据中。
还有一个等效的实例方法 truncate,它可以在两个日期之间对 Series 进行切片:
ts.truncate(after='2011-01-09')以上所有操作对于 DataFrame 也同样适用,在其行上进行索引。让我们获取几家有代表性的A股上市公司的股价数据,来构造一个 DataFrame。
我们现在可以使用日期字符串对这个 DataFrame 进行切片:
long_df.loc['2012-05']9.2.2 含有重复索引的时间序列
在某些应用中,可能会有多个数据观测值落在同一个时间戳上。例如,金融领域的交易数据可能在同一纳秒内有多条记录。这里有一个例子:
dates = pd.DatetimeIndex(['2000-01-01', '2000-01-02', '2000-01-02',
'2000-01-02', '2000-01-03'])
dup_ts = pd.Series(np.arange(5), index=dates)
dup_ts我们可以通过检查其 is_unique 属性来判断索引是否唯一:
dup_ts.index.is_unique现在对这个时间序列进行索引,将根据时间戳是否重复而产生标量值或切片:
# 不重复
dup_ts['2000-01-03']# 重复
dup_ts['2000-01-02']假设你想对具有非唯一时间戳的数据进行聚合。一种方法是使用 groupby 并传入 level=0(第一个也是唯一的索引层级):
grouped = dup_ts.groupby(level=0)
grouped.mean()grouped.count()9.3 日期范围、频率和移位
在 pandas 中,通用的时间序列被假定为不规则的;也就是说,它们没有固定的频率。对于许多应用来说,这已经足够了。然而,通常我们希望相对于一个固定的频率工作,比如每日、每月或每15分钟,即使这意味着要在时间序列中引入缺失值。幸运的是,pandas 拥有一整套标准的时间序列频率和用于重采样、推断频率以及生成固定频率日期范围的工具。
例如,我们最初的国债收益率序列 ts 是不规则的,因为它省略了周末和节假日。我们可以通过调用 resample 将其转换为固定的每日频率。
ts# 重采样到每日频率。我们稍后会更详细地讨论这个。
resampler = ts.resample('D')
resampler字符串 'D' 被解释为每日频率。频率之间的转换或重采样 (resampling) 是一个足够大的主题,我们稍后会有一个专门的章节来讨论。在这里,我们将向你展示如何使用基本频率及其倍数。
9.3.1 生成日期范围
pandas.date_range 负责根据特定的频率生成一个指定长度的 DatetimeIndex。
index = pd.date_range('2012-04-01', '2012-06-01')
index默认情况下,pandas.date_range 生成每日的时间戳。如果你只传入开始日期或结束日期,你必须传入一个周期数来生成:
pd.date_range(start='2012-04-01', periods=20)pd.date_range(end='2012-06-01', periods=20)开始和结束日期为生成的日期索引定义了严格的边界。例如,如果你想要一个包含每个月最后一个工作日的日期索引,你可以传入 'BM' 频率(business end of month;更完整的频率列表见 表 tbl-freq-aliases),并且只有落在日期区间内或边界上的日期才会被包含:
pd.date_range('2000-01-01', '2000-12-01', freq='BM')pandas.date_range 默认会保留开始或结束时间戳的时间信息(如果有的话):
pd.date_range('2012-05-02 12:56:31', periods=5)有时你的开始或结束日期带有时间信息,但你希望生成一组规范化到午夜的时间戳。为此,有一个 normalize 选项:
pd.date_range('2012-05-02 12:56:31', periods=5, normalize=True)9.3.2 频率和日期偏移量
pandas 中的频率由一个基本频率和一个乘数组成。基本频率通常由一个字符串别名引用,比如 'M' 代表每月或 'H' 代表每小时。对于每个基本频率,都有一个被称为日期偏移量 (date offset) 的对象。表 tbl-freq-aliases 提供了这些别名的列表。
基本时间序列频率(非详尽列表)
| 别名 | 偏移类型 | 描述 |
|---|---|---|
D |
Day | 日历日 |
B |
BusinessDay | 工作日 |
H |
Hour | 小时 |
T or min |
Minute | 分钟 |
S |
Second | 秒 |
L or ms |
Milli | 毫秒 |
U or us |
Micro | 微秒 |
M |
MonthEnd | 月末(日历日) |
BM |
BusinessMonthEnd | 月末(工作日) |
MS |
MonthBegin | 月初(日历日) |
BMS |
BusinessMonthBegin | 月初(工作日) |
W-MON, W-TUE,… |
Week | 每周的指定星期几 |
WOM-1MON,… |
WeekOfMonth | 生成月中第n周的周度日期 |
Q-JAN, Q-FEB,… |
QuarterEnd | 季度末,锚定在指定月份的最后一个日历日 |
BQ-JAN, BQ-FEB,… |
BusinessQuarterEnd | 季度末,锚定在指定月份的最后一个工作日 |
QS-JAN, QS-FEB,… |
QuarterBegin | 季度初,锚定在指定月份的第一个日历日 |
BQS-JAN, BQS-FEB,… |
BusinessQuarterBegin | 季度初,锚定在指定月份的第一个工作日 |
A-JAN, A-FEB,… |
YearEnd | 年末,锚定在指定月份的最后一个日历日 |
BA-JAN, BA-FEB,… |
BusinessYearEnd | 年末,锚定在指定月份的最后一个工作日 |
AS-JAN, AS-FEB,… |
YearBegin | 年初,锚定在指定月份的第一个日历日 |
BAS-JAN, BAS-FEB,… |
BusinessYearBegin | 年初,锚定在指定月份的第一个工作日 |
例如,小时频率可以用 Hour 类来表示:
from pandas.tseries.offsets import Hour, Minute
hour = Hour()
hour你可以通过传入一个整数来定义一个偏移量的倍数:
four_hours = Hour(4)
four_hours在大多数应用中,你永远不需要显式地创建这些对象;而是使用像 'H' 或 '4H' 这样的字符串别名。
pd.date_range('2000-01-01', '2000-01-03 23:59', freq='4H')许多偏移量可以通过加法组合。这对于构建复杂的自定义频率特别有用。
Hour(2) + Minute(30)同样,你也可以传入像 '1h30min' 这样的频率字符串,它们实际上会被解析为相同的表达式:
pd.date_range('2000-01-01', periods=10, freq='1h30min')有些频率描述的时间点不是均匀分布的。例如,'M'(日历月末)和 'BM'(业务月末/工作日)取决于月份的天数。我们称之为锚定偏移量 (anchored offsets)。
9.3.2.1 月中周日期
一个有用的频率类是“月中周”,以 WOM 开头。这使你能够获得像每个月第三个星期五这样的日期:
monthly_dates = pd.date_range('2012-01-01', '2012-09-01', freq='WOM-3FRI')
list(monthly_dates)9.3.3 数据移位(领先和滞后)
移位 (Shifting) 是指在时间上向前或向后移动数据。这在计量经济学中是一个至关重要的操作,用于为自回归模型创建滞后变量或为预测创建领先变量。Series 和 DataFrame 都有一个 shift 方法,用于进行简单的向前或向后移位,而不修改索引。
让我们看一个中国月度城镇调查失业率的时间序列。
# FRED 中对应的序列ID为 'LRUNTTTTCNM156S'
ts_unemp = fred.get_series('LRUNTTTTCNM156S', start_date='2018-01-01', end_date='2018-04-30')
ts_unemp向 .shift() 传递一个正数参数会将数据在时间上向前移动。这会创建一个理论上称之为滞后 (lagged) 变量。注意序列的开头是如何引入缺失数据的。
ts_unemp.shift(2)一个负数参数会将数据在时间上向后移动,创建一个领先 (leading) 变量。缺失数据会在序列的末尾引入。
ts_unemp.shift(-2)shift 在经济学中的应用:计算回报率与构建自回归模型
同学们,.shift() 是我们进行时间序列分析时最常用的函数之一,它的应用非常广泛。
计算回报率:对于一个价格序列 \(P_t\),其简单回报率可以精确地表示为 \(\frac{P_t - P_{t-1}}{P_{t-1}} = \frac{P_t}{P_{t-1}} - 1\)。在
pandas中,这可以被高效地计算为ts / ts.shift(1) - 1。这是所有金融分析的起点。构建自回归 (AR) 模型:一个 AR(p) 模型试图用变量过去的p个值来预测当前值,例如一个AR(2)模型可以写为 \(Y_t = c + \phi_1 Y_{t-1} + \phi_2 Y_{t-2} + \epsilon_t\)。在构建这样的模型时,我们需要创建 \(Y_{t-1}\) 和 \(Y_{t-2}\) 这两个滞后变量 (lagged variables)。使用
.shift(1)和.shift(2)就可以轻松地为我们的数据集创建这些特征,然后将它们作为自变量进行回归分析。
让我们演示一下计算失业率的百分比变化:
# 这等价于 ts_unemp.pct_change()
ts_unemp / ts_unemp.shift(1) - 1因为简单的移位不修改索引,所以一些数据会被丢弃。如果频率是已知的,可以将其传递给 shift 来移动时间戳,而不是仅仅移动数据:
ts_unemp.shift(2, freq='M')也可以传递其他频率,这让你在如何领先和滞后数据方面有更大的灵活性:
ts_unemp.shift(3, freq='D')# '90T' 表示 90 分钟
ts_unemp.shift(1, freq='90T')9.3.3.1 使用偏移量移动日期
pandas 的日期偏移量也可以用于 datetime 或 Timestamp 对象:
from pandas.tseries.offsets import Day, MonthEnd
now = datetime(2011, 11, 17)
now + 3 * Day()如果你添加一个像 MonthEnd 这样的锚定偏移量,第一次增量会根据频率规则将日期“滚动”到下一个日期:
now + MonthEnd()now + MonthEnd(2)锚定偏移量可以通过分别使用它们的 rollforward 和 rollback 方法来显式地向前或向后“滚动”日期:
offset = MonthEnd()
offset.rollforward(now)offset.rollback(now)日期偏移量的一个创造性用法是将这些方法与 groupby 结合使用。假设我们有一个每日观测的时间序列,我们想要计算每个月的平均值。
ts_daily = fred.get_series('INTGSBCN10Y', '2020-01-01', '2020-03-31').dropna()
ts_daily.groupby(MonthEnd().rollforward).mean()当然,有一种更简单、更快捷的方法是使用 resample,我们稍后会更深入地讨论它。
ts_daily.resample('M').mean()9.4 时区处理
处理时区可能是时间序列操作中最令人头疼的部分之一。因此,许多量化分析师,尤其是在金融领域,选择在协调世界时(UTC)中处理时间序列,这是一个与地理位置无关的国际标准。时区表示为与UTC的偏移量;例如,纽约在夏令时(DST)期间比UTC晚四个小时,在一年中的其余时间晚五个小时。我们中国的标准时间,即北京时间,比UTC早8个小时(UTC+8)。
在Python中,时区信息来自第三方库 pytz,它公开了奥尔森数据库(Olson database),这是一个世界时区信息的汇编。这对于历史数据尤其重要,因为DST的转换日期(甚至UTC偏移量)根据地区法律已经改变了无数次。
由于 pandas 对 pytz 有硬性依赖,因此无需单独安装。时区名称可以在交互式环境中查找,也可以在文档中找到:
import pytz
pytz.common_timezones[-5:]要从 pytz 获取一个时区对象,请使用 pytz.timezone:
tz = pytz.timezone('Asia/Shanghai')
tzpandas 中的方法既可以接受时区名称,也可以接受这些对象。
9.4.1 时区本地化与转换
默认情况下,pandas 中的时间序列是不感知时区 (time zone naive) 的。例如,考虑以下时间序列:
dates = pd.date_range('2012-03-09 09:30', periods=6)
ts = pd.Series(np.random.standard_normal(len(dates)), index=dates)
ts索引的 tz 字段是 None:
print(ts.index.tz)可以设置时区来生成日期范围:
pd.date_range('2012-03-09 09:30', periods=10, tz='UTC')从不感知时区到本地化 (localized)(即被重新解释为在特定时区观测到的)的转换由 tz_localize 方法处理:
ts_utc = ts.tz_localize('UTC')
ts_utcts_utc.index一旦时间序列被本地化到特定时区,就可以使用 tz_convert 将其转换为另一个时区:
ts_utc.tz_convert('Asia/Shanghai')对于前面的时间序列,我们可以将其本地化为北京时间,然后转换为,比如说,UTC或柏林时间:
ts_shanghai = ts.tz_localize('Asia/Shanghai')
ts_shanghaits_shanghai.tz_convert('UTC')ts_shanghai.tz_convert('Europe/Berlin')tz_localize 和 tz_convert 也是 DatetimeIndex 的实例方法:
ts.index.tz_localize('Asia/Shanghai')处理时区时,夏令时(Daylight Saving Time, DST)是一个主要的复杂性来源。tz_localize 在处理DST转换期间的模糊或不存在的时间时非常智能。例如,当时间“向前”跳跃时(比如从凌晨1:59跳到3:00),凌晨2:00到2:59这段时间实际上是不存在的。tz_localize 会识别出这一点并引发错误。同样,当时间“向后”跳跃时,某些时间会发生两次,tz_localize 可以通过 ambiguous 参数来处理这种情况。尽管中国目前不实行夏令时,但在处理国际数据时,务必注意这些边缘情况。
9.4.2 对感知时区的 Timestamp 对象进行操作
与时间序列和日期范围类似,单个的 Timestamp 对象也可以从不感知时区本地化为感知时区,并从一个时区转换到另一个时区:
stamp = pd.Timestamp('2011-03-12 04:00')
stamp_utc = stamp.tz_localize('utc')
stamp_utc.tz_convert('Asia/Shanghai')你也可以在创建 Timestamp 时传入一个时区:
stamp_moscow = pd.Timestamp('2011-03-12 04:00', tz='Europe/Moscow')
stamp_moscow感知时区的 Timestamp 对象内部存储一个UTC时间戳值,即自Unix纪元(1970年1月1日)以来的纳秒数,所以改变时区不会改变内部的UTC值:
stamp_utc.valuestamp_utc.tz_convert('Asia/Shanghai').value当使用 pandas 的 DateOffset 对象进行时间算术时,pandas 会在可能的情况下遵守夏令时转换。这里我们以美国时区为例,构建了恰好在DST转换(向前和向后)之前的 Timestamp 对象。
# 转换到夏令时前的30分钟
stamp = pd.Timestamp('2012-03-11 01:30', tz='US/Eastern')
stampstamp + Hour()# 退出夏令时前的90分钟
stamp = pd.Timestamp('2012-11-04 00:30', tz='US/Eastern')
stampstamp + 2 * Hour()9.4.3 不同时区之间的操作
如果将两个具有不同时区的时间序列组合起来,结果将是UTC。因为时间戳在底层是以UTC存储的,所以这是一个直接的操作,不需要转换。
dates = pd.date_range('2012-03-07 09:30', periods=10, freq='B')
ts = pd.Series(np.random.standard_normal(len(dates)), index=dates)
ts1 = ts[:7].tz_localize('Europe/London')
ts2 = ts1[2:].tz_convert('Europe/Moscow')
result = ts1 + ts2
result.index不感知时区和感知时区的数据之间的操作是不支持的,会引发异常。这是一个很好的安全特性,可以防止潜在的细微错误。
9.5 时期与时期算术
时期 (Periods) 代表时间跨度,如天、月、季度或年。pandas.Period 类代表这种数据类型,需要一个字符串或整数以及一个 表 tbl-freq-aliases 中支持的频率。
p = pd.Period('2011', freq='A-DEC')
p在这种情况下,Period 对象代表了从2011年1月1日到2011年12月31日(含)的整个时间跨度。这对于宏观经济数据特别有用,这些数据通常是针对特定时期报告的(例如,2021年第二季度的GDP)。方便的是,对时期进行整数加减运算的效果是按其频率移动它们:
p + 5p - 2如果两个时期具有相同的频率,它们的差就是它们之间的单位数:
pd.Period('2014', freq='A-DEC') - p可以使用 period_range 函数构建规则的时期范围:
periods = pd.period_range('2000-01-01', '2000-06-30', freq='M')
periodsPeriodIndex 类存储了一系列时期,并可以作为任何 pandas 数据结构中的轴索引:
pd.Series(np.random.standard_normal(6), index=periods)如果你有一个字符串数组,你也可以使用 PeriodIndex 类:
values = ['2001Q3', '2002Q2', '2003Q1']
index = pd.PeriodIndex(values, freq='Q-DEC')
index9.5.1 时期频率转换
Period 和 PeriodIndex 对象可以使用它们的 asfreq 方法转换为另一个频率。假设我们有一个年度时期,并希望将其转换为年初或年末的月度时期。
p = pd.Period('2011', freq='A-DEC')
p.asfreq('M', how='start')p.asfreq('M', how='end')你可以将 Period('2011', 'A-DEC') 想象成一个指向一个时间跨度的游标,这个时间跨度被月度时期所细分。对于财年结束月份不是12月的时期,相应的月度子时期是不同的:
p = pd.Period('2011', freq='A-JUN')
p.asfreq('M', 'start')p.asfreq('M', 'end')如 图 fig-period-conversion 所示,时期的转换逻辑取决于其定义。
当你从高频向低频转换时,pandas 会根据超时期“所属”的位置来确定超时期。例如,在 A-JUN 频率中,2011年8月这个月份实际上是2012财年的一部分:
p = pd.Period('Aug-2011', 'M')
p.asfreq('A-JUN')整个 PeriodIndex 对象或时间序列也可以用相同的语义进行类似的转换:
periods = pd.period_range('2006', '2009', freq='A-DEC')
ts = pd.Series(np.random.standard_normal(len(periods)), index=periods)
tsts.asfreq('M', how='start')如果我们想要每年的最后一个工作日,我们可以使用 'B' 频率并指明我们想要时期的结束:
ts.asfreq('B', how='end')9.5.2 季度时期频率
季度数据在会计、金融和其他领域是标准的。许多季度数据是相对于一个财年结束日 (fiscal year end) 报告的,通常是一年中12个月份之一的最后一个日历日或工作日。因此,时期 2012Q4 的含义根据财年结束日而不同。pandas 支持所有12种可能的季度频率,从 Q-JAN 到 Q-DEC。
对于一个财年在一月份结束的情况,2012Q4 从2011年11月持续到2012年1月。我们可以通过转换为每日频率来验证这一点:
p = pd.Period('2012Q4', freq='Q-JAN')
pp.asfreq('D', 'start')p.asfreq('D', 'end')从 图 fig-quarterly-freq 可以清晰地看到,财年结束日的不同约定,导致了同一个季度标签(例如’2012Q1’)对应着完全不同的日历时间范围。
进行时期算术是很方便的。例如,要获取季度倒数第二个工作日下午4点的时间戳,你可以这样做:
p = pd.Period('2012Q4', freq='Q-JAN')
p4pm = (p.asfreq('B', 'end') - 1).asfreq('T', 'start') + 16 * 60
p4pmp4pm.to_timestamp()to_timestamp 方法默认返回该时期的起始时间戳。
你可以使用 pandas.period_range 生成季度范围:
periods = pd.period_range('2011Q3', '2012Q4', freq='Q-JAN')
ts = pd.Series(np.arange(len(periods)), index=periods)
ts我们可以通过算术运算生成新的时间戳,例如,获取每个时期倒数第二个工作日的下午4点:
new_periods = (periods.asfreq('B', 'end') - 1).asfreq('H', 'start') + 16
ts.index = new_periods.to_timestamp()
ts9.5.3 时间戳与时期的转换(及逆转换)
由时间戳索引的 Series 和 DataFrame 对象可以使用 to_period 方法转换为时期:
dates = pd.date_range('2000-01-01', periods=3, freq='M')
ts = pd.Series(np.random.standard_normal(3), index=dates)
tspts = ts.to_period()
pts由于时期指的是不重叠的时间跨度,一个时间戳对于给定的频率只能属于一个时期。新的 PeriodIndex 的频率默认是从时间戳推断出来的,但你可以指定任何支持的频率。
dates = pd.date_range('2000-01-29', periods=6)
ts2 = pd.Series(np.random.standard_normal(6), index=dates)
ts2.to_period('M')要转换回时间戳,使用 to_timestamp 方法:
pts = ts2.to_period()
ptspts.to_timestamp(how='end')9.5.4 从数组创建 PeriodIndex
固定频率的数据集有时会将时间跨度信息分散在多个列中。例如,让我们看看 FRED 的一些宏观经济数据。我们将获取中国季度实际GDP。
# 中国实际GDP的序列ID是 RGDPNACNA666NRUG
data_raw = fred.get_series('RGDPNACNA666NRUG')
# FRED返回的是年度数据,我们需要将其转换为季度格式来进行演示
data = data_raw.to_frame(name='realgdp').resample('Q').ffill()
data['year'] = data.index.year
data['quarter'] = data.index.quarter
data.tail()通过将这些 year 和 quarter 数组传递给 PeriodIndex,我们可以将它们组合起来形成 DataFrame 的索引:
index = pd.PeriodIndex(year=data['year'], quarter=data['quarter'], freq='Q-DEC')
indexdata.index = index
data['realgdp'] = data_raw.resample('Q').ffill().values # 重新赋值以匹配PeriodIndex
data.head()9.6 重采样与频率转换
重采样 (Resampling) 是指将时间序列从一个频率转换为另一个频率的过程。 * 将高频数据聚合到低频称为降采样 (downsampling)。(例如,每日股价到每月股价) * 将低频数据转换为高频称为升采样 (upsampling)。(例如,年度GDP到季度GDP)
并非所有的重采样都属于这两类;例如,将 W-WED(周三)转换为 W-FRI(周五)既不是升采样也不是降采样。
pandas 对象配备了一个 resample 方法,这是所有频率转换的主力函数。resample 的 API 与 groupby 类似;你调用 resample 来对数据进行分组,然后调用一个聚合函数。
dates = pd.date_range('2020-01-01', periods=100)
ts = pd.Series(np.random.standard_normal(len(dates)), index=dates)
ts.head()让我们将其重采样到月度频率并取平均值。
ts.resample('M').mean()我们也可以将结果转换为 Period 对象。
ts.resample('M', kind='period').mean()resample 是一个灵活的方法,可以用来处理大型时间序列。表 tbl-resample-args 总结了它的一些选项。
resample 方法参数
| 参数 | 描述 |
|---|---|
rule |
字符串、DateOffset或timedelta,指示期望的重采样频率 (例如, ‘M’, ‘5min’) |
axis |
要重采样的轴;默认为 axis=0 |
closed |
在降采样中,每个区间的哪一端是闭合的(包含),‘right’ 或 ‘left’ |
label |
在降采样中,如何标记聚合结果,使用 ‘right’ 或 ‘left’ 的区间边缘 |
convention |
在重采样时期时,用于低频到高频转换的 ‘start’ 或 ‘end’ |
kind |
聚合到时期 (‘period’) 或时间戳 (‘timestamp’) |
on / level |
对于 DataFrame,用于重采样的列,而不是索引 |
9.6.1 降采样
降采样是把数据聚合到一个更规整、频率更低的时间序列。期望的频率定义了用于将时间序列切片成块以进行聚合的区间边缘 (bin edges)。在使用 resample 进行降采样时,有几件事需要考虑:
- 每个区间的哪一边是闭合的 (closed)(即包含在内的)。
- 如何标记 (label) 每个聚合后的区间,是用区间的开始还是结束。
为了说明这一点,让我们看一些一分钟频率的数据:
dates = pd.date_range('2020-01-01', periods=12, freq='T')
ts = pd.Series(np.arange(12), index=dates)
ts假设你想将这些数据聚合成五分钟的块或bars,通过取每组的总和:
ts.resample('5min').sum()频率 '5min' 定义了以五分钟为增量的区间边缘(00:00, 00:05 等)。默认情况下,左边的区间边缘是包含的(closed='left'),并且标签也取自左边缘。所以,00:00 到 00:04 的数据被加总到标记为 00:00 的区间中。
我们可以改变这些默认设置。closed='right' 使右边缘成为包含的:
ts.resample('5min', closed='right').sum()而 label='right' 用右边缘的时间戳来标记区间:
ts.resample('5min', closed='right', label='right').sum()图 fig-resampling-illustration 直观地展示了 closed 和 label 参数如何影响最终的聚合结果。
最后,你可能想对结果索引进行一些移位,比如从右边缘减去一秒,以更清楚地表明时间戳指的是哪个区间。为此,你可以使用 .loffset() 或直接向索引添加一个偏移量。
# 'loffset' 参数已被弃用。这是现代的方法。
from pandas.tseries.frequencies import to_offset
result = ts.resample('5min', closed='right', label='right').sum()
result.index = result.index + to_offset('-1s')
result9.6.1.1 开高低收 (OHLC) 重采样
在金融领域,聚合高频时间序列的一种流行方法是为每个桶计算四个值:第一个(开盘价, open)、最后一个(收盘价, close)、最大值(最高价, high)和最小值(最低价, low)。.ohlc() 聚合函数可以高效地完成这项工作。
# 使用我们之前的一分钟时间序列
ts.resample('5min').ohlc()9.6.2 升采样与插值
升采样是从低频转换到高频,此时不需要聚合。这个过程会引入缺失值。让我们考虑一个包含一些周度数据的 DataFrame,例如代表每周的经济报告。
frame = pd.DataFrame(np.random.standard_normal((2, 4)),
index=pd.date_range('2020-01-01', periods=2,
freq='W-WED'),
columns=['北京', '上海', '广东', '浙江'])
frame如果我们将这个重采样到每日频率,我们会得到带有缺失值的间隙。.asfreq() 方法用于在不进行任何聚合的情况下转换为更高频率。
df_daily = frame.resample('D').asfreq()
df_daily假设你想在非周三的日子里向前填充每个周度值。你可以使用像 ffill(“向前填充”)或 bfill(“向后填充”)这样的方法。
frame.resample('D').ffill()你也可以选择只向前填充一定数量的周期,以限制一个观测值可以被继续使用的距离:
frame.resample('D').ffill(limit=2)值得注意的是,新的日期索引完全不必与旧的重合:
frame.resample('W-THU').ffill()9.6.3 使用时期进行重采样
对由时期索引的数据进行重采样与时间戳类似。
frame = pd.DataFrame(np.random.standard_normal((24, 4)),
index=pd.period_range('1-2018', '12-2019', freq='M'),
columns=['北京', '上海', '广东', '浙江'])
frame.head()让我们通过取平均值将这个月度数据降采样为年度数据。
annual_frame = frame.resample('A-DEC').mean()
annual_frame升采样则更为微妙,因为你必须决定将值放在新频率时间跨度的哪一端。convention 参数默认为 'start',但也可以是 'end'。
# Q-DEC: 季度,年度在12月结束
# 使用向前填充来传播值
annual_frame.resample('Q-DEC').ffill()将 convention='end' 与 .asfreq 一起使用会将值放在新频率的最后一个时期。
annual_frame.resample('Q-DEC', convention='end').asfreq()由于时期指的是时间跨度,升采样和降采样的规则更为严格: * 在降采样中,目标频率必须是源频率的子时期 (subperiod)。 * 在升采样中,目标频率必须是源频率的超时期 (superperiod)。
如果不满足这些规则,将会引发异常。这主要影响季度、年度和周度频率。
9.7 移动窗口函数
时间序列的一类重要转换是在一个滑动窗口 (sliding window)上或使用指数衰减权重 (exponentially decaying weights)计算的统计量。这对于平滑噪声数据和识别趋势很有用。这些被称为移动窗口函数 (moving window functions)。
首先,让我们使用 yfinance 库获取一些真实的A股市场股价数据。
import yfinance as yf
# 定义股票代码和日期范围
# 600519.SS: 贵州茅台, 601318.SS: 中国平安, 300750.SZ: 宁德时代
# 000001.SS: 上证综合指数
tickers = ['600519.SS', '601318.SS', '300750.SZ', '000001.SS']
start_date = '2010-01-01'
end_date = '2020-01-01'
# 下载调整后的收盘价
close_px_all = yf.download(tickers, start=start_date, end=end_date)['Adj Close']
# 重命名上证综指列
close_px_all = close_px_all.rename(columns={'000001.SS': 'SSEC'})
# 重采样到工作日频率以填充任何缺失的节假日
close_px = close_px_all[['600519.SS', '601318.SS', '300750.SZ']]
close_px = close_px.resample('B').ffill()
close_px.info()rolling 算子的行为与 resample 和 groupby 类似。它可以在一个 Series 或 DataFrame 上调用,并附带一个窗口(表示为周期数)。让我们计算并绘制贵州茅台股价的250天移动平均线。在A股市场中,一个250天的窗口大致对应于一年的交易日。这通常用于识别股票的长期趋势。
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei'] # 用于显示中文标签
plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号
close_px['600519.SS'].plot(label='股价')
close_px['600519.SS'].rolling(250).mean().plot(label='250日移动平均线')
plt.title('贵州茅台股价 (2010-2019)')
plt.xlabel('日期')
plt.ylabel('价格 (元)')
plt.legend()
plt.grid(True)
plt.show()表达式 rolling(250) 创建了一个对象,该对象能够在250天的滑动窗口上进行分组。如 图 fig-moutai-ma250 所示,移动平均线平滑了每日的价格波动,揭示了潜在的趋势。
默认情况下,滚动函数要求窗口中的所有值都是非NA的。可以更改此行为以考虑缺失数据,特别是考虑到在时间序列开始时,你的数据周期会少于 window 个。min_periods 参数设置了窗口中产生一个值所需的最小观测数。
让我们计算贵州茅台每日回报率的滚动标准差。这是滚动波动率 (rolling volatility) 的一个度量,是金融风险管理中的一个关键概念。
moutai_returns = close_px['600519.SS'].pct_change()
# A股一年约245个交易日,这里用250近似。将日波动率年化,乘以sqrt(250)
std250 = moutai_returns.rolling(250, min_periods=10).std() * np.sqrt(250)
std250.plot()
plt.title('贵州茅台250日年化波动率')
plt.xlabel('日期')
plt.ylabel('波动率')
plt.grid(True)
plt.show()可以使用 expanding 算子计算扩展窗口 (expanding window) 均值。扩展均值从序列的开始处启动时间窗口,并增加窗口的大小,直到它包含整个序列。
expanding_mean = std250.expanding().mean()在 DataFrame 上调用移动窗口函数会将转换应用于每一列。图 fig-multi-ma 显示了我们三只A股的60天移动平均线。
close_px.plot(logy=True, title='三支A股股价(对数坐标)')
close_px.rolling(60).mean().plot(logy=True)
plt.title('60日移动平均线 (对数坐标)')
plt.xlabel('日期')
plt.ylabel('价格 (元)')
plt.legend(['贵州茅台', '中国平安', '宁德时代'])
plt.grid(True)
plt.show()rolling 函数也接受一个表示固定大小时间偏移的字符串,而不是一个固定的周期数。这对于不规则的时间序列可能很有用。例如,我们可以像这样计算一个20天的滚动均值:
close_px.rolling('20D').mean().tail()9.7.1 指数加权函数
除了使用具有同等权重观测值的固定窗口大小外,另一种方法是指定一个恒定的衰减因子 (decay factor),以给予更近的观测值更多的权重。指数加权移动平均 (EWMA) 是一种能更快适应近期变化的统计量。
pandas 有 ewm 算子。让我们比较一下贵州茅台股价的30天简单移动平均线(SMA)和一个span为30的EWMA。
moutai_px = close_px['600519.SS']['2012':'2013']
ma30 = moutai_px.rolling(30, min_periods=20).mean()
ewma30 = moutai_px.ewm(span=30).mean()
moutai_px.plot(style='k-', label='股价')
ma30.plot(style='k--', label='简单移动平均 (SMA)')
ewma30.plot(style='k-', lw=0.5, label='指数加权移动平均 (EWMA)')
plt.title('贵州茅台: SMA vs. EWMA (30天跨度)')
plt.legend()
plt.grid(True)
plt.show()正如你在 图 fig-sma-vs-ewma 中所看到的,EWMA 更“尖锐”,因为它给予近期价格更多的权重,使其反应更灵敏。
9.7.2 二元移动窗口函数
一些统计算子,如相关性和协方差,需要对两个时间序列进行操作。例如,金融分析师通常对一只股票与像上证综合指数这样的基准指数的相关性感兴趣。这与资本资产定价模型(CAPM)中的Beta概念有关。
让我们计算我们的A股和上证综指的每日回报率。
ssec_px = close_px_all['SSEC'].resample('B').ffill()
ssec_rets = ssec_px.pct_change()
returns = close_px.pct_change()在我们调用 rolling 之后,corr 聚合函数就可以计算与上证综指回报率的滚动相关性。让我们看看贵州茅台和上证综指之间125天(约6个月)的滚动相关性,如 图 fig-moutai-ssec-corr 所示。
corr = returns['600519.SS'].rolling(125, min_periods=100).corr(ssec_rets)
corr.plot()
plt.title('贵州茅台 vs. 上证综指 六个月滚动相关性')
plt.xlabel('日期')
plt.ylabel('相关性')
plt.grid(True)
plt.show()假设你想一次性计算上证综指与多只股票的滚动相关性。我们可以通过在 DataFrame 上调用 rolling 并传入 ssec_rets Series 来一次性计算所有的滚动相关性。结果见 图 fig-multi-corr。
corr = returns.rolling(125, min_periods=100).corr(ssec_rets)
corr.plot()
plt.title('A股组合 vs. 上证综指 六个月滚动相关性')
plt.xlabel('日期')
plt.ylabel('相关性')
plt.legend(['贵州茅台', '中国平安', '宁德时代'])
plt.grid(True)
plt.show()9.7.3 用户定义的移动窗口函数
rolling 上的 apply 方法提供了一种在你自己的移动窗口上应用数组函数的方法。唯一的要求是该函数从数组的每一块中产生一个单一的值(一个归约操作)。
例如,虽然我们可以使用 rolling(...).quantile(q) 计算样本分位数,但我们可能对样本中特定值的百分位排名感兴趣。scipy.stats.percentileofscore 函数正是这样做的。让我们找出贵州茅台股价2%日回报率的滚动百分位排名,如 图 fig-moutai-percentile 所示。
from scipy.stats import percentileofscore
def score_at_2percent(x):
"""计算数值 0.02 在序列 x 中的百分位排名。"""
return percentileofscore(x, 0.02)
result = returns['600519.SS'].rolling(250).apply(score_at_2percent, raw=True)
result.plot()
plt.title('贵州茅台 2%日回报率的百分位排名 (250日窗口)')
plt.xlabel('日期')
plt.ylabel('百分位排名')
plt.grid(True)
plt.show()raw=True 参数将底层的 NumPy 数组传递给我们的函数,这样效率更高。