9  时间序列分析

9.1 引言与学习目标

学习目标

完成本章后,你应该能够:

  • 目标 9.1:区分时间戳、时期、时区和交易日索引,并诊断频率与时区错配;
  • 目标 9.2:用 shiftresamplerollingewm 构造不跨越信息边界的时间序列特征;
  • 目标 9.3:计算并解释后向移动统计、指数加权统计、滚动波动率与滚动相关性;
  • 目标 9.4:在本地长三角公司行情上估计原始收益市场模型,并区分它与超额收益 CAPM;
  • 目标 9.5:统一协方差与方差的自由度约定,核验证券代码、变量、图题和公式一致;
  • 目标 9.6:将样本内描述与预测、因果及投资决策分开,并列出进一步验证要求。

数据与推断边界

本章日期、短序列和随机路径若由代码直接构造,均属于“机制演示题设值”,只用于解释时间索引或随机过程性质。公司实证案例读取本地 HDF5,保留证券、复权口径、日期与基准。滚动统计和技术指标是历史样本的描述;预测必须采用时间外验证,因果结论必须有识别设计,均不能由图形共变或任意阈值直接推出。

目标—活动—核心练习—答案证据映射

表 9.1: 第九章教学闭环映射
正式目标 学习活动或示例 可观察产出 核心练习或选做项目 完整答案中的评分证据
目标 9.1 小节 9.2小节 9.5小节 9.6 的类型及 DST 对照 标注本地化、转换、UTC 瞬间和时期标签的差异 习题 9.1习题 9.8 9.1 输出 DatetimeIndex 与交易日选择;9.8 输出春季跳时、秋季重复时刻、UTC/上海转换及单调唯一断言
目标 9.2 小节 9.4小节 9.7小节 9.8 的真实行情计算 生成频率转换、滞后、滚动及指数加权序列,并说明可得信息边界 习题 9.2习题 9.3习题 9.4 答案分别给出周/月聚合、移动窗口和 EWMA 结果及解释
目标 9.3 小节 9.8 的后向窗口与指数加权示例 报告窗口长度、对齐方向、缺失边界和滚动风险统计 习题 9.3习题 9.4 移动平均、滚动波动率、EWMA 图表及窗口解释构成可评分产出
目标 9.4 小节 9.8.2 的市场基准与共同日期示例 估计滚动 Beta,并写出原始收益模型与 CAPM 的口径差异 习题 9.6 共同日期断言、滚动 Beta/相关图及口径辨析共同计分
目标 9.5 重采样、滚动协方差和多证券代码审计 核对自由度、证券代码、图题、轴标签和公式中的分析对象 习题 9.6;选做 习题 9.7 9.6 的同口径协方差/方差与 9.7 的指标审计表给出逐项证据
目标 9.6 各实证示例后的推断边界讨论 将描述、预测、因果和决策结论分栏陈述 核心题 9.3、9.6 的边界解释;选做 习题 9.7 核心答案说明信息边界,选做项目进一步分开描述、预测、因果和行动结论

表 9.1 汇总本节的计算或审计结果,解释时应遵循正文给出的口径与限制。

时间序列数据是许多领域中一种重要的结构化数据形式,例如金融、经济学、生态学、神经科学和物理学。任何在多个时间点上重复记录的数据都构成了时间序列。许多时间序列是固定频率 (fixed frequency)的,也就是说,数据点按照某种规则以固定的时间间隔出现,例如每15秒、每5分钟或每月一次。时间序列也可以是不规则 (irregular)的,没有固定的时间单位或单位之间的偏移。如何标记和引用时间序列数据取决于具体的应用场景,你可能会遇到以下几种情况:

  • 时间戳 (Timestamps): 时间中的特定瞬间。
  • 固定时期 (Fixed periods): 例如2017年整个一月份,或2020年全年。
  • 时间区间 (Intervals of time): 由一个开始时间戳和一个结束时间戳表示。时期可以被看作是区间的特例。
  • 实验或流逝时间 (Experiment or elapsed time): 每个时间戳都是相对于某个特定开始时间的度量(例如,一个饼干从放入烤箱开始,每秒钟测量其直径),从0开始。

本章我们主要关注前三类时间序列,不过许多技术也可以应用于实验性时间序列,其索引可能是一个表示从实验开始后流逝时间的整数或浮点数。最简单的时间序列类型是由时间戳索引的。

pandas 工程实践中,除了基于日期和时间的索引外,有时也会使用基于时间差(timedelta)的频率表示。这在衡量特定事件(如业绩公告或降息政策)发生后的窗口偏移时非常有用。本章侧重绝对时间索引;timedelta 只用于表达相对时间距离,不由此推断事件影响或策略效果。

9.1.1 本章核心路径:先建立时间语义

时间序列方法必须先回答三个基础问题:每个标签表示瞬间还是时期、记录采用什么时区、相邻行是否具有可解释的频率。为此,本章先学习日期解析、DatetimeIndex、频率、移位、时区、PeriodIndex 和重采样,再进入后向移动窗口与指数加权统计。只有在这些语义和数据质量条件明确后,平稳性、单位根、协整、频谱或滤波等模型才有可解释的输入。

核心考核限于时间索引和窗口计算。高级计量与分解方法集中放在基础内容之后的选修说明中,不作为本章练习或后续章节的默认先修。

pandas 提供了许多内置的时间序列工具和算法。你可以高效地处理大规模时间序列,并对不规则和固定频率的时间序列进行切片、聚合和重采样。这些工具中的一些对于金融和经济学应用特别有用,但你当然也可以用它们来分析服务器日志数据。

和之前的章节一样,我们首先导入 NumPy 和 pandas

import platform  # 识别运行平台以统一后续真实数据路径
from pathlib import Path  # 用basename审计前复权与后复权行情口径
import numpy as np  # 为滚动统计、数值断言和机制演示提供数组运算
import pandas as pd  # 为真实行情读取及带标签时间运算提供统一接口
DATA_ROOT = 'C:/qiufei/data' if platform.system() == 'Windows' else '/home/ubuntu/r2_data_mount/data'  # 建立全章唯一的跨平台数据入口
PRE_ADJUSTED_PRICE_PATH = Path(DATA_ROOT) / 'stock' / 'stock_price_pre_adjusted.h5'  # 技术分析与短窗口案例使用前复权行情
POST_ADJUSTED_PRICE_PATH = Path(DATA_ROOT) / 'stock' / 'stock_price_post_adjusted.h5'  # 综合长期收益链使用后复权行情
price_source_adjustments = {PRE_ADJUSTED_PRICE_PATH: '前复权', POST_ADJUSTED_PRICE_PATH: '后复权'}  # 将展示注记与实际文件路径成对保存
expected_adjustment_by_basename = {'stock_price_pre_adjusted.h5': '前复权', 'stock_price_post_adjusted.h5': '后复权'}  # 记录两种basename的教材数据字典口径
assert all(expected_adjustment_by_basename[path.name] == price_adjustment for path, price_adjustment in price_source_adjustments.items())  # 防止basename与price_adjustment注记反转

9.2 日期和时间数据类型及工具

在深入使用 pandas 解析复杂的金融序列之前,我们需要先熟悉 Python 处理时间的基础设施。Python 标准库中的 datetimetimecalendar 模块提供了最基础的日期和时间处理功能。

其中,datetime.datetime(通常简称为 datetime)是我们在日常编程中最常打交道的类型,它能精确表示一个具体的时间点:

from datetime import datetime                               # 构造、解析并格式化Python日期时间对象
from zoneinfo import ZoneInfo                               # 使用标准库时区数据库明确中国标准时间

china_timezone = ZoneInfo('Asia/Shanghai')                  # 绑定中国标准时间的IANA时区规则
reference_instant = datetime(2026, 8, 25, 11, 30, tzinfo=china_timezone)  # 固定教学参考时点,避免渲染结果随时间变化
reference_instant.isoformat(timespec='seconds')             # 以稳定的ISO 8601格式输出时点及+08:00偏移
'2026-08-25T11:30:00+08:00'

渲染稿使用固定参考时点,避免每次执行改变出版产物。在需要采集真实“此刻”的程序中,应显式调用 datetime.now(ZoneInfo('Asia/Shanghai')).isoformat(timespec='seconds');这里把该实时表达式作为说明而不执行,以维持确定性。你可以通过对象的属性轻松提取出年、月、日等组成部分:

reference_instant.year, reference_instant.month, reference_instant.day  # 提取固定参考时点的年、月、日分量
(2026, 8, 25)

一个 datetime 对象同时存储了日期和时间,精度可以达到微秒。而 datetime.timedelta,或简称 timedelta,则表示两个 datetime 对象之间的时间差:

from datetime import timedelta                              # 表示两个datetime之间的日、秒与微秒差

delta = datetime(2011, 1, 7) - datetime(2008, 6, 24, 8, 15)  # 计算两个日期之间的时间差
delta  # 输出2008-06-24 08:15到2011-01-07的完整时间差
datetime.timedelta(days=926, seconds=56700)
delta.days  # 获取时间差的天数分量
926
delta.seconds  # 获取时间差的秒数分量
56700

你可以将一个 timedelta(或其倍数)添加(或减去)到一个 datetime 对象上,从而得到一个新的、经过移位的 datetime 对象:

start = datetime(2011, 1, 7)  # 创建指定日期的datetime对象
start + timedelta(12)  # 向后偏移12天得到新日期
datetime.datetime(2011, 1, 19, 0, 0)
start - 2 * timedelta(12)  # 向前偏移24天得到新日期
datetime.datetime(2010, 12, 14, 0, 0)

底层架构辨析:datetime.datetimepandasTimestamp 的工程分野

在 Python 量化生态中,初学者常混淆标准库标量、pandas 标量与时间索引。三者在表层高度互操作,但职责不同:

  1. 标量时点与分辨率Timestamp 能表达纳秒分辨率,而标准库 datetime 的最高分辨率为微秒;NumPy 的 datetime64[ns] 等类型也能表达纳秒,因此 Timestamp 并非高频时间戳的唯一选择。选择类型还要考虑可表示范围、缺失值、时区和容器接口。
  2. 索引频率与序列运算:标量 Timestamp 不携带 freq 属性,也没有 resample 方法。工作日等频率信息属于 DatetimeIndexPeriodIndex 或其他序列索引语境;resample 作用于带时间索引的 Series/DataFrame,按逻辑步长移位也必须由索引或显式偏移量提供规则。
  3. 时区感知 (Timezone Awareness) 的现代封装Timestamp 提供本地化与时区转换方法,便于表示全球不同交易所的同一瞬间;批量对齐仍由时间索引和序列操作完成。

表 9.2 总结了 datetime 模块中的数据类型。虽然本章主要关注 pandas 中的数据类型和更高级别的时间序列操作,但你们在 Python 的其他应用场景中可能会遇到这些基于 datetime 的类型。

表 9.2: datetime 模块中的主要类型
类型 描述
date 使用公历存储日历日期(年、月、日)
time 存储一天中的时间(时、分、秒、微秒)
datetime 同时存储日期和时间
timedelta 两个 datetime 值之间的差异(以天、秒和微秒表示)
tzinfo 用于存储时区信息的基类

9.2.1 字符串与 datetime 对象的相互转换

你可以使用 str() 函数或 strftime 方法,并传入一个格式规范,将 datetime 对象和 pandasTimestamp 对象格式化为字符串。strftime 是 “string format time” 的缩写。

stamp = datetime(2011, 1, 3)  # 创建指定日期的datetime对象
str(stamp)  # 将datetime对象转换为字符串表示
'2011-01-03 00:00:00'
stamp.strftime('%Y-%m-%d')  # 将2011年1月3日格式化为四位年—两位月—两位日字符串
'2011-01-03'

反之,你可以使用 datetime.strptime (“string parse time”) 将字符串转换为日期,但这要求你必须确切地知道输入字符串的格式。表 9.3 提供本章常用的格式代码及可移植性提示;它不是完整清单,完整集合与平台差异见表后的官方文档。

表 9.3: 本章常用 datetime 格式码(含平台扩展)
代码 描述
%Y 四位数年份
%y 两位数年份
%m 两位数月份 [01, 12]
%d 两位数日期 [01, 31]
%H 小时 (24小时制) [00, 23]
%I 小时 (12小时制) [01, 12]
%M 两位数分钟 [00, 59]
%S 两位数秒 [00, 59]datetime 不支持闰秒
%f 微秒,作为零填充的整数 (从000000到999999)
%w 星期几,作为整数 [0 (周日), 6]
%u ISO 星期几 [1, 7],其中1是周一;不属于 C89 核心格式码
%U 一年中的周数 [00, 53]; 周日是一周的第一天
%W 一年中的周数 [00, 53]; 周一是一周的第一天
%z UTC 时区偏移,常见为 ±HHMM;完整形式可含秒与微秒,strptime 也接受冒号分隔及 Z
%Z 时区名称字符串
%F %Y-%m-%d 的常见平台扩展;跨平台代码宜写出完整格式
%D %m/%d/%y 的常见平台扩展;跨平台代码宜写出完整格式

Python 3.10 datetime 文档列出的 C89 核心格式码可在标准 C 实现的平台使用,额外格式码及不受支持格式的处理则可能随平台 C 库而变化。%f 是 Python datetime 始终提供的扩展;%u%F%D 不应被当作无条件可移植合同。需要跨平台解析时,优先采用表中明确的核心组合,例如用 %Y-%m-%d 代替 %F

value = '2011-01-03'                                        # 待解析的日期字符串
datetime.strptime(value, '%Y-%m-%d')  # 严格按年—月—日模板把输入字符串解析为datetime
datetime.datetime(2011, 1, 3, 0, 0)
datestrs = ['7/6/2011', '8/6/2011']                         # 美式日期格式的字符串列表
[datetime.strptime(x, '%m/%d/%Y') for x in datestrs]  # 逐项调用datetime的strptime方法
[datetime.datetime(2011, 7, 6, 0, 0), datetime.datetime(2011, 8, 6, 0, 0)]

datetime.strptime 用于按已知格式解析单个日期。在实际工作中,日期常以多种格式出现;pandas.to_datetime 面向数组解析,并提供格式、错误处理和时区相关参数。自动推断可能产生歧义,生产数据仍应尽量声明格式并核对失败记录。

datestrs = ['2011-07-06 12:00:00', '2011-08-06 00:00:00']   # ISO格式的日期时间字符串列表
pd.to_datetime(datestrs)  # 将两个ISO字符串解析为无时区的DatetimeIndex
DatetimeIndex(['2011-07-06 12:00:00', '2011-08-06 00:00:00'], dtype='datetime64[ns]', freq=None)

它还能处理应被视为缺失值的情况(如 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)  # 解析有效字符串,并把None保留为时间缺失值NaT
idx  # 输出两个有效Timestamp和一个NaT组成的DatetimeIndex
DatetimeIndex(['2011-07-06 12:00:00', '2011-08-06 00:00:00', 'NaT'], dtype='datetime64[ns]', freq=None)
pd.isna(idx)  # 返回三项布尔掩码,只有None解析成的NaT位置为真
array([False, False,  True])

解析风险提示:格式歧义与失败策略

pd.to_datetime 的公开合同是把标量或数组转换为 pandas 时间对象,并不保证使用某个内部解析器。项目使用的 pandas 2.3.3 默认 errors='raise',例如非法字符串 "42" 会直接抛出 ValueError;设置 errors='coerce' 才会把无法解析的输入变为 NaT。像 10/11/12 这样的短日期还可能因日、月、年顺序约定不同而产生歧义,不能依赖猜测格式作为数据合同。

工程准则:交易数据入库时应通过 format 显式声明预期格式(如 %Y%m%d),默认保留 errors='raise' 让异常立即暴露;若业务上选择 errors='coerce',应同时保留原字符串和“原值非空但解析结果为 NaT”的失败掩码,不能静默丢弃坏记录。

datetime 对象还有许多针对其他国家或语言系统的本地化格式选项。例如,在德语或法语系统中,月份的缩写会与英语系统不同。具体请参见 表 9.4

表 9.4: 本地化日期格式
代码 描述
%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.3 时间序列基础

pandas 中最基本的时间序列对象是由时间戳索引的 Series。下面使用本地上证综指的真实日度收盘点位。指数点位是市场变量,不是利率、GDP 或其他宏观指标;明确变量语义是时间序列分析的第一项数据审计。

index_data_all = pd.read_hdf(f'{DATA_ROOT}/index/indexes.h5')  # 对无法条件下推的 fixed 节点执行本章唯一一次全量读取
index_data = index_data_all.copy()  # 为首个时间索引示例复制章级只读基表,避免后续转换污染缓存
sse = index_data[index_data['symbol'] == '000001.XSHG'].copy()  # 隔离上证综指记录,避免后续索引变换污染全表
sse['datetime'] = pd.to_datetime(sse['datetime'].astype(str), format='%Y%m%d%H%M%S')  # 按源文件编码解析交易时间,避免依赖模糊推断
sse.set_index('datetime', inplace=True)  # 用交易时点建立可切片的时间索引

# 用短窗口让读者能逐行核对索引选择结果
sse_close_price_series = sse.sort_index().loc['2011-01-01':'2011-01-14']['close']  # 排序后截取两周真实收盘点位
sse_close_price_series  # 输出2011年前两周上证综指真实交易日及收盘点位
datetime
2011-01-04    2852.648
2011-01-05    2838.593
2011-01-06    2824.197
2011-01-07    2838.801
2011-01-10    2791.809
2011-01-11    2804.047
2011-01-12    2821.305
2011-01-13    2827.713
2011-01-14    2791.344
Name: close, dtype: float64

在底层,这些 datetime 对象被放入了一个 DatetimeIndex

sse_close_price_series.index  # 查看索引信息
DatetimeIndex(['2011-01-04', '2011-01-05', '2011-01-06', '2011-01-07',
               '2011-01-10', '2011-01-11', '2011-01-12', '2011-01-13',
               '2011-01-14'],
              dtype='datetime64[ns]', name='datetime', freq=None)

和其他 Series 一样,不同索引的时间序列之间的算术运算会自动按日期对齐:

sse_close_price_series + sse_close_price_series[::2]  # 不同索引的时间序列相加,自动按日期对齐
datetime
2011-01-04    5705.296
2011-01-05         NaN
2011-01-06    5648.394
2011-01-07         NaN
2011-01-10    5583.618
2011-01-11         NaN
2011-01-12    5642.610
2011-01-13         NaN
2011-01-14    5582.688
Name: close, dtype: float64

结果中包含了日期未对齐处的 NaN 值。

本例索引的输出 dtype 是 datetime64[ns],其中 ns 表示纳秒分辨率。pandas 2.x 也能保留 datetime64[s]datetime64[ms]datetime64[us];因此应读取具体对象的 dtype,而不能把所有 pandas 时间数组都概括为纳秒存储。

sse_close_price_series.index.dtype  # 查看DatetimeIndex的底层数据类型
dtype('<M8[ns]')

DatetimeIndex 中取出的标量值是 pandasTimestamp 对象:

stamp = sse_close_price_series.index[0]  # 取出索引中的第一个Timestamp对象
stamp  # 输出该短样本首个交易日的pandas Timestamp标量
Timestamp('2011-01-04 00:00:00')

一个 pandas.Timestamp 对象在许多标量接口中可以替代 datetime 对象使用,但二者的分辨率、可表示范围和方法并不完全相同。Timestamp 能表达纳秒分辨率并提供时区本地化与转换方法,datetime 的最高分辨率则为微秒;频率信息属于承载这些标量的时间索引或期间索引,而不属于单个 Timestamp

9.3.1 索引、选择与子集构造

当你基于标签进行索引和选择数据时,时间序列的行为与其他 Series 类似:

stamp = sse_close_price_series.index[2]  # 取出索引中的第三个Timestamp对象
sse_close_price_series[stamp]  # 用第三个Timestamp标签取出单个上证综指收盘点位
2824.197

为方便起见,你也可以传入一个可以被解释为日期的字符串:

sse_close_price_series['2011-01-10']  # 使用日期字符串标签取出当日上证综指收盘点位
2791.809

对于更长的时间序列,这种方式尤其有用。让我们获取一个更长的数据序列,例如宁波港的上市初期数据。宁波港于 2010 年在上海证券交易所上市,是中国重要的港口运营商。

import pandas as pd  # 读取宁波港HDF行情并构造按交易日排序的收盘价序列
from pathlib import Path                                    # 以跨平台路径对象表示本地行情文件位置

# 读取宁波港完整数据
ningbo_port_full = pd.read_hdf(  # 从本地前复权日行情表读取宁波港完整历史
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='601018.XSHG'"  # 在HDF查询层限定宁波港证券代码
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开交易日索引并统一成交量字段名

# 选择上市初期的一段数据用于演示
ningbo_early = ningbo_port_full[  # 筛选2010-2012年的上市初期数据
    (ningbo_port_full['trade_date'] >= '2010-01-01') &  # 起始日期条件
    (ningbo_port_full['trade_date'] <= '2012-12-31')  # 截止日期条件
].copy()  # 独立保存2010—2012年派生样本,避免后续赋值修改完整历史表

# 创建时间序列
ningbo_port_ipo_prices_series = ningbo_early.set_index('trade_date')['close']  # 构造以交易日为索引的宁波港收盘价序列
ningbo_port_ipo_prices_series.name = '宁波港收盘价'  # 为时间序列设置名称属性
ningbo_port_ipo_prices_series.head()  # 核验上市初期样本的交易日索引与收盘价名称
trade_date
2010-09-28    2.5505
2010-09-29    2.4790
2010-09-30    2.5362
2010-10-08    2.5648
2010-10-11    2.6576
Name: 宁波港收盘价, dtype: float64

你可以传入年份或年份加月份来轻松选择数据的切片:

# 年份字符串切片覆盖2011年首末实际交易日,并保留原时间索引
ningbo_port_ipo_prices_series['2011'].head()  # 展示2011年标签切片的前五个交易日
trade_date
2011-01-04    2.2504
2011-01-05    2.2218
2011-01-06    2.2076
2011-01-07    2.2290
2011-01-10    2.2076
Name: 宁波港收盘价, dtype: float64
# 选择 2011 年 5 月的数据
ningbo_port_ipo_prices_series['2011-05'].head()  # 展示2011年5月标签切片的前五个交易日
trade_date
2011-05-03    2.4219
2011-05-04    2.4004
2011-05-05    2.3933
2011-05-06    2.3933
2011-05-09    2.3933
Name: 宁波港收盘价, dtype: float64

使用 datetime 对象进行切片同样有效:

sse_close_price_series[datetime(2011, 1, 7):]  # 从2011-01-07起按时间标签取出其后全部上证综指观测
datetime
2011-01-07    2838.801
2011-01-10    2791.809
2011-01-11    2804.047
2011-01-12    2821.305
2011-01-13    2827.713
2011-01-14    2791.344
Name: close, dtype: float64

因为大多数时间序列数据是按时间顺序排列的,所以你可以使用不包含在时间序列中的时间戳进行切片,以执行范围查询:

sse_close_price_series['2011-01-06':'2011-01-11']  # 在闭区间内取出实际存在的上证综指交易日观测
datetime
2011-01-06    2824.197
2011-01-07    2838.801
2011-01-10    2791.809
2011-01-11    2804.047
Name: close, dtype: float64

请不要依赖时间切片一定返回视图。Pandas 的 copy/view 行为会受对象布局、索引方式和版本影响;启用 Copy-on-Write 后,对切片赋值也不会据此回写原对象。需要独立修改子样本时应显式调用 .copy(),需要修改原对象时应使用原对象上的 .loc[...] = ...,并避免链式赋值。

还有一个等效的实例方法 truncate,它可以在两个日期之间对 Series 进行切片:

sse_close_price_series.truncate(after='2011-01-09')  # 截取指定日期之前的时间序列数据
datetime
2011-01-04    2852.648
2011-01-05    2838.593
2011-01-06    2824.197
2011-01-07    2838.801
Name: close, dtype: float64

以上所有操作对于 DataFrame 也同样适用,在其行上进行索引。让我们获取几家有代表性的A股上市公司的股价数据,来构造一个 DataFrame

# 选择几家知名的A股公司
# 选择恒瑞医药、科大讯飞和上汽集团,三者在2010—2015年都有可用行情
companies = {                                               # 股票名称与代码的映射字典
    '恒瑞医药': '600276.XSHG',  # 恒瑞医药的交易所代码
    '科大讯飞': '002230.XSHE',  # 科大讯飞的深交所代码
    '上汽集团': '600104.XSHG'  # 沪市汽车股补足2010—2015三行业价格宽表
}  # 字典定义结束
start_date = '2010-01-01'                                   # 数据查询的起始日期
end_date = '2015-12-31'                                     # 数据查询的截止日期

# 从本地 HDF5 文件获取数据
price_list = []                                             # 初始化空列表用于存储各股价格序列
for name, order_book_id in companies.items():  # 逐只读取三家公司的同口径前复权行情
    df = pd.read_hdf(  # 当前循环仅载入一个证券的日行情
        PRE_ADJUSTED_PRICE_PATH,
        where=f'order_book_id={order_book_id!r}'  # 将当前循环的证券代码变量安全写入HDF查询条件
    ).reset_index()  # 将交易日从索引恢复为列以限定2010—2015样本
    
    df['trade_date'] = pd.to_datetime(df['date'], format='%Y%m%d')  # 按YYYYMMDD解析三家公司2010—2015年的交易日
    mask = (df['trade_date'] >= start_date) & (df['trade_date'] <= end_date)  # 构建日期范围的布尔筛选条件
    series = df.loc[mask].set_index('trade_date')['close']  # 形成当前公司的交易日—收盘价序列
    series.name = name  # 为时间序列设置股票名称作为列名
    price_list.append(series)  # 向price_list追加新元素

china_blue_chip_prices_df = pd.concat(price_list, axis=1).dropna()  # 仅保留各序列共同有观测的交易日,统一跨资产样本
china_blue_chip_prices_df.head()  # 核验三家公司按共同交易日对齐后的列名和缺失结构
恒瑞医药 科大讯飞 上汽集团
trade_date
2010-01-04 4.8450 4.0432 11.2713
2010-01-05 4.6778 4.1329 10.7236
2010-01-06 4.6550 4.1241 10.5522
2010-01-07 4.5999 3.9711 10.0840
2010-01-08 4.6835 4.1008 9.5823

我们现在可以使用日期字符串对这个 DataFrame 进行切片:

china_blue_chip_prices_df.loc['2012-05']                    # 按标签选取数据
恒瑞医药 科大讯飞 上汽集团
trade_date
2012-05-02 4.7568 5.9610 8.7898
2012-05-03 4.8169 6.0815 8.8283
2012-05-04 4.8204 6.0782 8.8669
2012-05-07 4.8753 6.0949 8.9220
2012-05-08 4.8582 6.0129 8.8173
2012-05-09 4.7723 6.0346 8.5583
2012-05-10 4.8753 5.9777 8.5363
2012-05-11 4.8616 5.8555 8.4260
2012-05-14 4.9406 5.8622 8.5142
2012-05-15 4.9097 5.9359 8.3875
2012-05-16 4.9303 5.9459 8.3379
2012-05-17 4.8959 6.0748 8.5252
2012-05-18 4.7912 5.9844 8.3654
2012-05-21 4.8084 5.9449 8.3324
2012-05-22 4.8427 5.9979 8.4260
2012-05-23 4.7809 6.0029 8.4205
2012-05-24 4.7551 5.9525 8.4205
2012-05-25 4.7431 5.6826 8.1395
2012-05-28 4.8015 5.8667 8.5638
2012-05-29 4.8476 5.8188 8.6244
2012-05-30 4.8514 5.7330 8.6850
2012-05-31 4.9500 5.9323 8.6630

9.3.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)  # 用五个整数配合三个重复的2000-01-02标签演示非唯一索引
dup_ts  # 输出五个观测,其中2000-01-02标签重复三次
2000-01-01    0
2000-01-02    1
2000-01-02    2
2000-01-02    3
2000-01-03    4
dtype: int64

我们可以通过检查其 is_unique 属性来判断索引是否唯一:

dup_ts.index.is_unique  # 检查索引是否唯一(无重复时间戳)
False

现在对这个时间序列进行索引,将根据时间戳是否重复而产生标量值或切片:

# 不重复
dup_ts['2000-01-03']  # 通过索引访问dup_ts中的元素
4
# 重复
dup_ts['2000-01-02']  # 通过索引访问dup_ts中的元素
2000-01-02    1
2000-01-02    2
2000-01-02    3
dtype: int64

假设你想对具有非唯一时间戳的数据进行聚合。一种方法是使用 groupby 并传入 level=0(第一个也是唯一的索引层级):

grouped = dup_ts.groupby(level=0)                           # 按 level=0 分层汇总,组均值只作历史描述
grouped.mean()  # 按日期标签归约数值,重复日输出三个观测的算术均值
2000-01-01    0.0
2000-01-02    2.0
2000-01-03    4.0
dtype: float64
grouped.count()  # 按日期报告非缺失观测数,重复日计数为3
2000-01-01    1
2000-01-02    3
2000-01-03    1
dtype: int64

9.4 日期范围、频率和移位

pandas 中,通用的时间序列被假定为不规则的;也就是说,它们没有固定的频率。对于许多应用来说,这已经足够了。然而,通常我们希望相对于一个固定的频率工作,比如每日、每月或每15分钟,即使这意味着要在时间序列中引入缺失值。幸运的是,pandas 拥有一整套标准的时间序列频率和用于重采样、推断频率以及生成固定频率日期范围的工具。

例如,我们最初的上证指数序列 sse_close_price_series 是不规则的,因为它省略了周末和节假日。我们可以通过调用 resample 将其转换为固定的每日频率。

sse_close_price_series  # 重列不规则交易日序列,周末与休市日未出现在索引中
datetime
2011-01-04    2852.648
2011-01-05    2838.593
2011-01-06    2824.197
2011-01-07    2838.801
2011-01-10    2791.809
2011-01-11    2804.047
2011-01-12    2821.305
2011-01-13    2827.713
2011-01-14    2791.344
Name: close, dtype: float64
# 重采样到每日频率
resampler = sse_close_price_series.resample('D')  # 建立自然日重采样器;周末和休市日会进入空桶,尚未选择填充或聚合规则
resampler  # 显示按自然日切桶但尚未执行聚合的DatetimeIndexResampler
<pandas.core.resample.DatetimeIndexResampler object at 0x7e4d1578fb50>

字符串 'D' 被解释为每日频率。频率之间的转换或重采样 (resampling) 是一个足够大的主题,我们稍后会有一个专门的章节来讨论。在这里,我们将向你展示如何使用基本频率及其倍数。

9.4.1 生成日期范围

pandas.date_range 负责根据特定的频率生成一个指定长度的 DatetimeIndex

index = pd.date_range('2012-04-01', '2012-06-01')  # 生成含首尾日期的每日索引,覆盖2012年4月1日至6月1日
index  # 输出62个连续自然日组成的DatetimeIndex
DatetimeIndex(['2012-04-01', '2012-04-02', '2012-04-03', '2012-04-04',
               '2012-04-05', '2012-04-06', '2012-04-07', '2012-04-08',
               '2012-04-09', '2012-04-10', '2012-04-11', '2012-04-12',
               '2012-04-13', '2012-04-14', '2012-04-15', '2012-04-16',
               '2012-04-17', '2012-04-18', '2012-04-19', '2012-04-20',
               '2012-04-21', '2012-04-22', '2012-04-23', '2012-04-24',
               '2012-04-25', '2012-04-26', '2012-04-27', '2012-04-28',
               '2012-04-29', '2012-04-30', '2012-05-01', '2012-05-02',
               '2012-05-03', '2012-05-04', '2012-05-05', '2012-05-06',
               '2012-05-07', '2012-05-08', '2012-05-09', '2012-05-10',
               '2012-05-11', '2012-05-12', '2012-05-13', '2012-05-14',
               '2012-05-15', '2012-05-16', '2012-05-17', '2012-05-18',
               '2012-05-19', '2012-05-20', '2012-05-21', '2012-05-22',
               '2012-05-23', '2012-05-24', '2012-05-25', '2012-05-26',
               '2012-05-27', '2012-05-28', '2012-05-29', '2012-05-30',
               '2012-05-31', '2012-06-01'],
              dtype='datetime64[ns]', freq='D')

默认情况下,pandas.date_range 生成每日的时间戳。如果你只传入开始日期或结束日期,你必须传入一个周期数来生成:

pd.date_range(start='2012-04-01', periods=20)  # 从起始日期生成20个日历日时间戳
DatetimeIndex(['2012-04-01', '2012-04-02', '2012-04-03', '2012-04-04',
               '2012-04-05', '2012-04-06', '2012-04-07', '2012-04-08',
               '2012-04-09', '2012-04-10', '2012-04-11', '2012-04-12',
               '2012-04-13', '2012-04-14', '2012-04-15', '2012-04-16',
               '2012-04-17', '2012-04-18', '2012-04-19', '2012-04-20'],
              dtype='datetime64[ns]', freq='D')
pd.date_range(end='2012-06-01', periods=20)  # 从截止日期向前生成20个日历日时间戳
DatetimeIndex(['2012-05-13', '2012-05-14', '2012-05-15', '2012-05-16',
               '2012-05-17', '2012-05-18', '2012-05-19', '2012-05-20',
               '2012-05-21', '2012-05-22', '2012-05-23', '2012-05-24',
               '2012-05-25', '2012-05-26', '2012-05-27', '2012-05-28',
               '2012-05-29', '2012-05-30', '2012-05-31', '2012-06-01'],
              dtype='datetime64[ns]', freq='D')

开始和结束日期为生成的日期索引定义了严格的边界。例如,如果你想要一个包含每个月最后一个工作日的日期索引,可以传入 'BME' 频率(business month end;更完整的频率列表见 表 9.5),并且只有落在日期区间内或边界上的日期才会被包含:

pd.date_range('2000-01-01', '2000-12-01', freq='BME')  # 生成每月最后一个工作日的日期序列
DatetimeIndex(['2000-01-31', '2000-02-29', '2000-03-31', '2000-04-28',
               '2000-05-31', '2000-06-30', '2000-07-31', '2000-08-31',
               '2000-09-29', '2000-10-31', '2000-11-30'],
              dtype='datetime64[ns]', freq='BME')

pandas.date_range 默认会保留开始或结束时间戳的时间信息(如果有的话):

pd.date_range('2012-05-02 12:56:31', periods=5)  # 生成保留时间信息的日期范围
DatetimeIndex(['2012-05-02 12:56:31', '2012-05-03 12:56:31',
               '2012-05-04 12:56:31', '2012-05-05 12:56:31',
               '2012-05-06 12:56:31'],
              dtype='datetime64[ns]', freq='D')

有时你的开始或结束日期带有时间信息,但你希望生成一组规范化到午夜的时间戳。为此,有一个 normalize 选项:

pd.date_range('2012-05-02 12:56:31', periods=5, normalize=True)  # 生成并将时间规范化到午夜零点
DatetimeIndex(['2012-05-02', '2012-05-03', '2012-05-04', '2012-05-05',
               '2012-05-06'],
              dtype='datetime64[ns]', freq='D')

9.4.2 频率和日期偏移量

pandas 中的频率由一个基本频率和一个乘数组成。生成或重采样DatetimeIndex时,基本频率通常由字符串别名引用,例如'ME'代表日历月末,'h'代表每小时。每个基本频率都对应一个日期偏移量 (date offset) 对象。表 9.5 列出当前版本用于时间戳偏移量的主要别名;Period表示时间跨度,仍使用MQ-DECY-DEC等时期频率,二者不要混用。

表 9.5: 基本时间序列频率(非详尽列表)
别名 偏移类型 描述
D Day 日历日
B BusinessDay 工作日
h Hour 小时
min Minute 分钟
s Second
ms Milli 毫秒
us Micro 微秒
ME MonthEnd 月末(日历日)
BME BusinessMonthEnd 月末(工作日)
MS MonthBegin 月初(日历日)
BMS BusinessMonthBegin 月初(工作日)
W-MON, W-TUE,… Week 每周的指定星期几
WOM-1MON,… WeekOfMonth 生成月中第n周的周度日期
QE-JAN, QE-FEB,… QuarterEnd 季度末,锚定在指定月份的最后一个日历日
BQE-JAN, BQE-FEB,… BusinessQuarterEnd 季度末,锚定在指定月份的最后一个工作日
QS-JAN, QS-FEB,… QuarterBegin 季度初,锚定在指定月份的第一个日历日
BQS-JAN, BQS-FEB,… BusinessQuarterBegin 季度初,锚定在指定月份的第一个工作日
YE-JAN, YE-FEB,… YearEnd 年末,锚定在指定月份的最后一个日历日
BYE-JAN, BYE-FEB,… BusinessYearEnd 年末,锚定在指定月份的最后一个工作日
YS-JAN, YS-FEB,… YearBegin 年初,锚定在指定月份的第一个日历日
BYS-JAN, BYS-FEB,… BusinessYearBegin 年初,锚定在指定月份的第一个工作日

例如,小时频率可以用 Hour 类来表示:

from pandas.tseries.offsets import Hour, Minute             # 构造整小时及分钟级日期偏移量
hour = Hour()  # 创建小时频率偏移量对象
hour  # 输出单小时DateOffset对象及其频率表示
<Hour>

你可以通过传入一个整数来定义一个偏移量的倍数:

four_hours = Hour(4)  # 创加4小时频率偏移量对象
four_hours  # 输出跨度为四小时的偏移量对象
<4 * Hours>

在大多数应用中,你不需要显式创建这些对象;可以直接使用'h''4h'这样的字符串别名。

pd.date_range('2000-01-01', '2000-01-03 23:59', freq='4h')  # 生成每4小时一个时间戳的日期范围
DatetimeIndex(['2000-01-01 00:00:00', '2000-01-01 04:00:00',
               '2000-01-01 08:00:00', '2000-01-01 12:00:00',
               '2000-01-01 16:00:00', '2000-01-01 20:00:00',
               '2000-01-02 00:00:00', '2000-01-02 04:00:00',
               '2000-01-02 08:00:00', '2000-01-02 12:00:00',
               '2000-01-02 16:00:00', '2000-01-02 20:00:00',
               '2000-01-03 00:00:00', '2000-01-03 04:00:00',
               '2000-01-03 08:00:00', '2000-01-03 12:00:00',
               '2000-01-03 16:00:00', '2000-01-03 20:00:00'],
              dtype='datetime64[ns]', freq='4h')

许多偏移量可以通过加法组合。这对于构建复杂的自定义频率特别有用。

Hour(2) + Minute(30)  # 组合2小时和30分钟偏移量为2.5小时频率
<150 * Minutes>

同样,你也可以传入像 '1h30min' 这样的频率字符串,它们实际上会被解析为相同的表达式:

pd.date_range('2000-01-01', periods=10, freq='1h30min')  # 生成每1小时30分钟一个时间戳的日期范围
DatetimeIndex(['2000-01-01 00:00:00', '2000-01-01 01:30:00',
               '2000-01-01 03:00:00', '2000-01-01 04:30:00',
               '2000-01-01 06:00:00', '2000-01-01 07:30:00',
               '2000-01-01 09:00:00', '2000-01-01 10:30:00',
               '2000-01-01 12:00:00', '2000-01-01 13:30:00'],
              dtype='datetime64[ns]', freq='90min')

有些频率描述的时间点不是均匀分布的。例如,'ME'(日历月末)和'BME'(业务月末/工作日)取决于月份的天数。我们称之为锚定偏移量 (anchored offsets)

9.4.2.1 月中周日期

一个有用的频率类是“月中周”,以 WOM 开头。这使你能够获得像每个月第三个星期五这样的日期:

monthly_dates = pd.date_range('2012-01-01', '2012-09-01', freq='WOM-3FRI')  # 枚举2012年1月至8月每月第三个星期五
list(monthly_dates)                                         # 转换为列表
[Timestamp('2012-01-20 00:00:00'),
 Timestamp('2012-02-17 00:00:00'),
 Timestamp('2012-03-16 00:00:00'),
 Timestamp('2012-04-20 00:00:00'),
 Timestamp('2012-05-18 00:00:00'),
 Timestamp('2012-06-15 00:00:00'),
 Timestamp('2012-07-20 00:00:00'),
 Timestamp('2012-08-17 00:00:00')]

9.4.3 数据移位(领先和滞后)

移位 (Shifting) 是指在时间上向前或向后移动数据。这在计量经济学中是一个至关重要的操作,用于为自回归模型创建滞后变量或为预测创建领先变量。SeriesDataFrame 都有一个 shift 方法,用于进行简单的向前或向后移位,而不修改索引。

下面把本地上证综指日收盘点位降采样为月末点位,再用同一个真实序列说明领先与滞后。这里的“滞后”只改变观测值的对齐关系;它不会把指数点位解释为宏观变量,也不会自动消除非交易日问题。

# 从本地指数表构造 2018 年月末点位,作为移位示例的真实输入
sse_monthly_close_series = (
    index_data.loc[index_data['symbol'].eq('000001.XSHG')]
    .assign(datetime=lambda x: pd.to_datetime(x['datetime'].astype(str), format='%Y%m%d%H%M%S'))
    .set_index('datetime')['close']
    .sort_index()
    .loc['2018']
    .resample('ME')
    .last()
)
sse_monthly_close_series  # 核对月末标签、样本长度与点位含义
datetime
2018-01-31    3480.8334
2018-02-28    3259.4080
2018-03-31    3168.8966
2018-04-30    3082.2316
2018-05-31    3095.4737
2018-06-30    2847.4181
2018-07-31    2876.4009
2018-08-31    2725.2499
2018-09-30    2821.3501
2018-10-31    2602.7832
2018-11-30    2588.1875
2018-12-31    2493.8962
Freq: ME, Name: close, dtype: float64

.shift() 传递一个正数参数会将数据在时间上向前移动。这会创建一个理论上称之为滞后 (lagged) 变量。注意序列的开头是如何引入缺失数据的。

sse_monthly_close_series.shift(2)  # 将数值下移两期,形成可用于模型的二阶滞后项
datetime
2018-01-31          NaN
2018-02-28          NaN
2018-03-31    3480.8334
2018-04-30    3259.4080
2018-05-31    3168.8966
2018-06-30    3082.2316
2018-07-31    3095.4737
2018-08-31    2847.4181
2018-09-30    2876.4009
2018-10-31    2725.2499
2018-11-30    2821.3501
2018-12-31    2602.7832
Freq: ME, Name: close, dtype: float64

一个负数参数会将数据在时间上向后移动,创建一个领先 (leading) 变量。缺失数据会在序列的末尾引入。

sse_monthly_close_series.shift(-2)  # 将未来数值对齐到当前行,仅用于理解领先变量而非回测信号
datetime
2018-01-31    3168.8966
2018-02-28    3082.2316
2018-03-31    3095.4737
2018-04-30    2847.4181
2018-05-31    2876.4009
2018-06-30    2725.2499
2018-07-31    2821.3501
2018-08-31    2602.7832
2018-09-30    2588.1875
2018-10-31    2493.8962
2018-11-30          NaN
2018-12-31          NaN
Freq: ME, Name: close, dtype: float64

移位操作在计量经济学中的实证范式

在构建滞后变量或检查动态效应时,.shift() 用来改变值与时间标签的对齐关系:

  1. 资产收益率的递归定义:对于价格序列 \(P_t\),简单收益率定义为 \(R_t = \frac{P_t}{P_{t-1}} - 1\)。在 pandas 中,这对应于 price_series / price_series.shift(1) - 1。这为后续平稳性检验提供了基础。
  2. 自回归 (AR) 特征工程:在研究市场记忆效应时,我们常用滞后变量作为自变量:\(Y_t = \alpha + \beta_1 Y_{t-1} + \beta_2 Y_{t-2} + \epsilon_t\)。通过 shift(1)shift(2),我们可以轻松构建模型的滞后矩阵。
  3. 前视泄漏预警:回测信号必须在信息实际可得之后执行;shift(1) 常用于把当期形成的信号延迟到下一期,但是否需要延迟以及延迟多久仍取决于信号时间戳和成交制度。这与只保留存续证券造成的幸存者偏差不是同一问题。

月末点位的简单收益率可以直接由当前点位除以前一期点位得到:

# 这等价于 sse_monthly_close_series.pct_change(fill_method=None)
sse_monthly_close_series / sse_monthly_close_series.shift(1) - 1
datetime
2018-01-31         NaN
2018-02-28   -0.063613
2018-03-31   -0.027769
2018-04-30   -0.027349
2018-05-31    0.004296
2018-06-30   -0.080135
2018-07-31    0.010179
2018-08-31   -0.052549
2018-09-30    0.035263
2018-10-31   -0.077469
2018-11-30   -0.005608
2018-12-31   -0.036431
Freq: ME, Name: close, dtype: float64

因为简单的移位不修改索引,所以一些数据会被丢弃。如果频率是已知的,可以将其传递给 shift 来移动时间戳,而不是仅仅移动数据:

sse_monthly_close_series.shift(2, freq='ME')  # 只移动月末时间标签,保留原观测值顺序
datetime
2018-03-31    3480.8334
2018-04-30    3259.4080
2018-05-31    3168.8966
2018-06-30    3082.2316
2018-07-31    3095.4737
2018-08-31    2847.4181
2018-09-30    2876.4009
2018-10-31    2725.2499
2018-11-30    2821.3501
2018-12-31    2602.7832
2019-01-31    2588.1875
2019-02-28    2493.8962
Freq: ME, Name: close, dtype: float64

也可以传递其他频率,这让你在如何领先和滞后数据方面有更大的灵活性:

sse_monthly_close_series.shift(3, freq='D')  # 将标签平移三个日历日以对比数值移位语义
datetime
2018-02-03    3480.8334
2018-03-03    3259.4080
2018-04-03    3168.8966
2018-05-03    3082.2316
2018-06-03    3095.4737
2018-07-03    2847.4181
2018-08-03    2876.4009
2018-09-03    2725.2499
2018-10-03    2821.3501
2018-11-03    2602.7832
2018-12-03    2588.1875
2019-01-03    2493.8962
Name: close, dtype: float64
# '90min' 表示90分钟
sse_monthly_close_series.shift(1, freq='90min')
datetime
2018-01-31 01:30:00    3480.8334
2018-02-28 01:30:00    3259.4080
2018-03-31 01:30:00    3168.8966
2018-04-30 01:30:00    3082.2316
2018-05-31 01:30:00    3095.4737
2018-06-30 01:30:00    2847.4181
2018-07-31 01:30:00    2876.4009
2018-08-31 01:30:00    2725.2499
2018-09-30 01:30:00    2821.3501
2018-10-31 01:30:00    2602.7832
2018-11-30 01:30:00    2588.1875
2018-12-31 01:30:00    2493.8962
Name: close, dtype: float64

9.4.3.1 使用偏移量移动日期

pandas 的日期偏移量也可以用于 datetimeTimestamp 对象:

from pandas.tseries.offsets import Day, MonthEnd            # 演示自然日偏移与锚定月末滚动规则
now = datetime(2011, 11, 17)  # 创建指定的日期时间对象
now + 3 * Day()  # 当前日期加3个日历日
Timestamp('2011-11-20 00:00:00')

如果你添加一个像 MonthEnd 这样的锚定偏移量,第一次增量会根据频率规则将日期“滚动”到下一个日期:

now + MonthEnd()  # 当前日期滚动到最近的月末
Timestamp('2011-11-30 00:00:00')
now + MonthEnd(2)  # 当前日期滚动到第二个月末
Timestamp('2011-12-31 00:00:00')

锚定偏移量可以通过分别使用它们的 rollforwardrollback 方法来显式地向前或向后“滚动”日期:

offset = MonthEnd()  # 创建月末偏移量对象
offset.rollforward(now)  # 将当前时刻推进到不早于它的最近自然月末
Timestamp('2011-11-30 00:00:00')
offset.rollback(now)  # 将当前时刻回退到不晚于它的最近自然月末
Timestamp('2011-10-31 00:00:00')

日期偏移量的一个创造性用法是将这些方法与 groupby 结合使用。假设我们有一个每日观测的时间序列,我们想要计算每个月的平均值。

# 使用本地指数真实日度数据
sse_daily_prices_series = index_data[index_data['symbol'] == '000001.XSHG'].copy()  # 筛选上证综指数据并创建副本
sse_daily_prices_series['datetime'] = pd.to_datetime(sse_daily_prices_series['datetime'].astype(str), format='%Y%m%d%H%M%S')  # 按14位源编码解析上证综指交易时点
sse_daily_prices_series = sse_daily_prices_series.set_index('datetime')['close'].loc['2020-01-01':'2020-03-31']  # 提取2020年一季度交易日—收盘点位序列
sse_daily_prices_series.groupby(MonthEnd().rollforward).mean()  # 按滚动后的月末标签分组,均值对应各自然月日度样本
datetime
2020-01-31    3078.654681
2020-02-29    2927.513035
2020-03-31    2852.062891
Name: close, dtype: float64

当然,有一种更简单、更快捷的方法是使用 resample,我们稍后会更深入地讨论它。

sse_daily_prices_series.resample('ME').mean()  # 以日历月末为标签汇总2020年一季度日收盘点位均值
datetime
2020-01-31    3078.654681
2020-02-29    2927.513035
2020-03-31    2852.062891
Freq: ME, Name: close, dtype: float64

9.5 时区处理

处理时区可能是时间序列操作中最令人头疼的部分之一。因此,许多量化分析师,尤其是在金融领域,选择在协调世界时(UTC)中处理时间序列,这是一个与地理位置无关的国际标准。时区表示为与UTC的偏移量;例如,纽约在夏令时(DST)期间比UTC晚四个小时,在一年中的其余时间晚五个小时。我们中国的标准时间,即北京时间,比UTC早8个小时(UTC+8)。

在Python中,时区信息来自第三方库 pytz,它公开了奥尔森数据库(Olson database),这是一个世界时区信息的汇编。这对于历史数据尤其重要,因为DST的转换日期(甚至UTC偏移量)根据地区法律已经改变了无数次。

由于 pandaspytz 有硬性依赖,因此无需单独安装。时区名称可以在交互式环境中查找,也可以在文档中找到:

import pytz                                                 # 导入pytz模块
pytz.common_timezones[-5:]  # 对pytz.common_timezones进行切片操作
['US/Eastern', 'US/Hawaii', 'US/Mountain', 'US/Pacific', 'UTC']

要从 pytz 获取一个时区对象,请使用 pytz.timezone

tz = pytz.timezone('Asia/Shanghai')                         # 创建上海/北京时区对象
tz  # 输出IANA名称为Asia/Shanghai的时区对象
<DstTzInfo 'Asia/Shanghai' LMT+8:06:00 STD>

pandas 中的方法既可以接受时区名称,也可以接受这些对象。

9.5.1 时区本地化与转换

默认情况下,pandas 中的时间序列是不感知时区 (time zone naive) 的。例如,考虑以下时间序列:

dates = pd.date_range('2012-03-09 09:30', periods=6)  # 从2012-03-09 09:30起生成六个无时区的日频时间戳
timezone_localization_rng = np.random.default_rng(20260901)  # 固定本地化示例的随机数流,使时区变换前后可逐值复核
random_asset_returns_series = pd.Series(timezone_localization_rng.standard_normal(len(dates)), index=dates)  # 将六个固定教学收益绑定到无时区索引,供localize前后比较
random_asset_returns_series.name = 'random_asset_returns'  # 为序列命名以标识资产收益率
random_asset_returns_series  # 输出六个模拟收益及无tz属性的日期索引
2012-03-09 09:30:00   -0.036307
2012-03-10 09:30:00    0.435650
2012-03-11 09:30:00    0.780780
2012-03-12 09:30:00    0.658048
2012-03-13 09:30:00   -0.245916
2012-03-14 09:30:00    0.064726
Freq: D, Name: random_asset_returns, dtype: float64

索引的 tz 字段是 None

print(random_asset_returns_series.index.tz)  # 输出None以确认原索引尚未绑定任何时区
None

可以设置时区来生成日期范围:

pd.date_range('2012-03-09 09:30', periods=10, tz='UTC')  # 生成10个带UTC时区信息的时间戳
DatetimeIndex(['2012-03-09 09:30:00+00:00', '2012-03-10 09:30:00+00:00',
               '2012-03-11 09:30:00+00:00', '2012-03-12 09:30:00+00:00',
               '2012-03-13 09:30:00+00:00', '2012-03-14 09:30:00+00:00',
               '2012-03-15 09:30:00+00:00', '2012-03-16 09:30:00+00:00',
               '2012-03-17 09:30:00+00:00', '2012-03-18 09:30:00+00:00'],
              dtype='datetime64[ns, UTC]', freq='D')

从不感知时区到本地化 (localized)(即被重新解释为在特定时区观测到的)的转换由 tz_localize 方法处理:

random_asset_returns_series_utc = random_asset_returns_series.tz_localize('UTC')  # 将无时区序列本地化为UTC时区
random_asset_returns_series_utc  # 输出壁钟时间未移动、但索引已解释为UTC的收益序列
2012-03-09 09:30:00+00:00   -0.036307
2012-03-10 09:30:00+00:00    0.435650
2012-03-11 09:30:00+00:00    0.780780
2012-03-12 09:30:00+00:00    0.658048
2012-03-13 09:30:00+00:00   -0.245916
2012-03-14 09:30:00+00:00    0.064726
Freq: D, Name: random_asset_returns, dtype: float64
random_asset_returns_series_utc.index  # 核验本地化后索引携带UTC时区且时点值未移动
DatetimeIndex(['2012-03-09 09:30:00+00:00', '2012-03-10 09:30:00+00:00',
               '2012-03-11 09:30:00+00:00', '2012-03-12 09:30:00+00:00',
               '2012-03-13 09:30:00+00:00', '2012-03-14 09:30:00+00:00'],
              dtype='datetime64[ns, UTC]', freq='D')

一旦时间序列被本地化到特定时区,就可以使用 tz_convert 将其转换为另一个时区:

random_asset_returns_series_utc.tz_convert('Asia/Shanghai')  # 保持UTC瞬间不变,将索引显示时间换为北京时间
2012-03-09 17:30:00+08:00   -0.036307
2012-03-10 17:30:00+08:00    0.435650
2012-03-11 17:30:00+08:00    0.780780
2012-03-12 17:30:00+08:00    0.658048
2012-03-13 17:30:00+08:00   -0.245916
2012-03-14 17:30:00+08:00    0.064726
Freq: D, Name: random_asset_returns, dtype: float64

对于前面的时间序列,我们可以将其本地化为北京时间,然后转换为,比如说,UTC或柏林时间:

random_asset_returns_series_shanghai = random_asset_returns_series.tz_localize('Asia/Shanghai')  # 将无时区序列本地化为北京时间
random_asset_returns_series_shanghai  # 输出把原无时区壁钟读数解释为北京时间后的收益序列
2012-03-09 09:30:00+08:00   -0.036307
2012-03-10 09:30:00+08:00    0.435650
2012-03-11 09:30:00+08:00    0.780780
2012-03-12 09:30:00+08:00    0.658048
2012-03-13 09:30:00+08:00   -0.245916
2012-03-14 09:30:00+08:00    0.064726
Name: random_asset_returns, dtype: float64
random_asset_returns_series_shanghai.tz_convert('UTC')  # 将同一绝对时点改用UTC显示,数值与顺序不变
2012-03-09 01:30:00+00:00   -0.036307
2012-03-10 01:30:00+00:00    0.435650
2012-03-11 01:30:00+00:00    0.780780
2012-03-12 01:30:00+00:00    0.658048
2012-03-13 01:30:00+00:00   -0.245916
2012-03-14 01:30:00+00:00    0.064726
Name: random_asset_returns, dtype: float64
random_asset_returns_series_shanghai.tz_convert('Europe/Berlin')  # 将北京时间索引转换为柏林当地显示时间
2012-03-09 02:30:00+01:00   -0.036307
2012-03-10 02:30:00+01:00    0.435650
2012-03-11 02:30:00+01:00    0.780780
2012-03-12 02:30:00+01:00    0.658048
2012-03-13 02:30:00+01:00   -0.245916
2012-03-14 02:30:00+01:00    0.064726
Name: random_asset_returns, dtype: float64

tz_localizetz_convert 也是 DatetimeIndex 的实例方法:

random_asset_returns_series.index.tz_localize('Asia/Shanghai')  # 直接给六个无时区壁钟标签绑定上海时区
DatetimeIndex(['2012-03-09 09:30:00+08:00', '2012-03-10 09:30:00+08:00',
               '2012-03-11 09:30:00+08:00', '2012-03-12 09:30:00+08:00',
               '2012-03-13 09:30:00+08:00', '2012-03-14 09:30:00+08:00'],
              dtype='datetime64[ns, Asia/Shanghai]', freq=None)

国际金融市场交易时间处理的技术性总结

处理跨境资产(如港股、美股、欧股)时,夏令时(DST)是导致时间序列错位的首要原因:

  • 逻辑跳变:当时间“向前”跳跃时(如 1:59 直接跳到 3:00),凌晨 2:00 属于非法时间戳。tz_localize 默认会抛出 NonExistentTimeError
  • 歧义解析:当时间“向后”跳跃时,1:30 可能会出现两次。工程上通常通过 ambiguous='infer' 或指定布尔数组来消除歧义。
  • 最佳实践:在金融中后台数据中心,推荐全程使用 UTC 存储和计算,仅在最前端面向交易员的终端界面(Dashboard)展示时才转换为当地时间(如 Asia/ShanghaiAmerica/New_York)。

9.5.2 对感知时区的 Timestamp 对象进行操作

与时间序列和日期范围类似,单个的 Timestamp 对象也可以从不感知时区本地化为感知时区,并从一个时区转换到另一个时区:

stamp = pd.Timestamp('2011-03-12 04:00')  # 构造无时区壁钟时刻,作为本地化运算起点
stamp_utc = stamp.tz_localize('utc')                        # 将时间戳本地化为UTC时区
stamp_utc.tz_convert('Asia/Shanghai')  # 把2011-03-12 04:00 UTC显示为同一瞬间的北京时间
Timestamp('2011-03-12 12:00:00+0800', tz='Asia/Shanghai')

你也可以在创建 Timestamp 时传入一个时区:

stamp_moscow = pd.Timestamp('2011-03-12 04:00', tz='Europe/Moscow')  # 构造莫斯科感知时点以比较同一壁钟读数的UTC瞬间
stamp_moscow  # 输出带Europe/Moscow偏移信息的感知时区Timestamp
Timestamp('2011-03-12 04:00:00+0300', tz='Europe/Moscow')

对本例中可用纳秒纪元表示的感知时区 Timestamp.value 返回自 Unix 纪元(1970 年 1 月 1 日)以来的 UTC 纳秒整数。时区转换只改变同一瞬间的本地显示,因此不会改变这个 .value

stamp_utc.value  # 获取UTC时间戳的内部纳秒值
1299902400000000000
stamp_utc.tz_convert('Asia/Shanghai').value  # 验证转换显示时区后底层UTC纳秒整数保持不变
1299902400000000000

当使用 pandasDateOffset 对象进行时间算术时,pandas 会在可能的情况下遵守夏令时转换。这里我们以美国时区为例,构建了恰好在DST转换(向前和向后)之前的 Timestamp 对象。

# 转换到夏令时前的30分钟
stamp = pd.Timestamp('2012-03-11 01:30', tz='US/Eastern')  # 选择美国东部春季跳时前仍存在的01:30
stamp  # 显示跳时前时间戳及其UTC偏移
Timestamp('2012-03-11 01:30:00-0500', tz='US/Eastern')
stamp + Hour()  # 在夏令时转换前加1小时(观察DST跳变效果)
Timestamp('2012-03-11 03:30:00-0400', tz='US/Eastern')
# 退出夏令时前的90分钟
stamp = pd.Timestamp('2012-11-04 00:30', tz='US/Eastern')  # 选择秋季回拨前的00:30以观察随后重复小时
stamp  # 显示回拨前时间戳及其夏令时偏移
Timestamp('2012-11-04 00:30:00-0400', tz='US/Eastern')
stamp + 2 * Hour()  # 在夏令时回退前加2小时(观察DST回退效果)
Timestamp('2012-11-04 01:30:00-0500', tz='US/Eastern')

9.5.3 不同时区之间的操作

如果将两个具有不同时区的时间序列组合起来,结果将是UTC。因为时间戳在底层是以UTC存储的,所以这是一个直接的操作,不需要转换。

dates = pd.date_range('2012-03-07 09:30', periods=10, freq='B')  # 从3月7日开盘时刻起生成十个工作日标签,排除周末
timezone_alignment_rng = np.random.default_rng(20260902)  # 固定跨时区对齐示例的随机数流,避免重复渲染改变数值
global_asset_returns_series = pd.Series(timezone_alignment_rng.standard_normal(len(dates)), index=dates)  # 将固定教学收益绑定到工作日索引,随后分别解释为伦敦与莫斯科时间
london_asset_returns_series = global_asset_returns_series[:7].tz_localize('Europe/London')  # 前7个数据点本地化为伦敦时区
moscow_asset_returns_series = london_asset_returns_series[2:].tz_convert('Europe/Moscow')  # 从第3个开始转换为莫斯科时区
result = london_asset_returns_series + moscow_asset_returns_series  # 两个不同时区序列相加(自动对齐为UTC)
result.index  # 核验跨时区相加后索引统一为UTC瞬间
DatetimeIndex(['2012-03-07 09:30:00+00:00', '2012-03-08 09:30:00+00:00',
               '2012-03-09 09:30:00+00:00', '2012-03-12 09:30:00+00:00',
               '2012-03-13 09:30:00+00:00', '2012-03-14 09:30:00+00:00',
               '2012-03-15 09:30:00+00:00'],
              dtype='datetime64[ns, UTC]', freq=None)

不感知时区和感知时区的数据之间的操作是不支持的,会引发异常。这是一个很好的安全特性,可以防止潜在的细微错误。

9.6 时期与时期算术

时期 (Periods) 代表时间跨度,如天、月、季度或年。pandas.Period 类代表这种数据类型,需要一个字符串或整数以及一个 表 9.5 中支持的频率。

p = pd.Period('2011', freq='Y-DEC')                         # 创建以12月为年末的年度时期对象
p  # 输出覆盖2011完整自然年的A-DEC时期标签
Period('2011', 'Y-DEC')

在这种情况下,Period 对象代表了从2011年1月1日到2011年12月31日(含)的整个时间跨度。这对于宏观经济数据特别有用,这些数据通常是针对特定时期报告的(例如,2021年第二季度的GDP)。方便的是,对时期进行整数加减运算的效果是按其频率移动它们:

p + 5  # 当前时期向前移动5个年度单位
Period('2016', 'Y-DEC')
p - 2  # 当前时期向后移动2个年度单位
Period('2009', 'Y-DEC')

如果两个时期具有相同的频率,它们的差就是它们之间的单位数:

pd.Period('2014', freq='Y-DEC') - p  # 计算2014年与2011年之间的年度差值
<3 * YearEnds: month=12>

可以使用 period_range 函数构建规则的时期范围:

periods = pd.period_range('2000-01-01', '2000-06-30', freq='M')  # 创建2000年上半年六个月度时期标签
periods  # 输出2000年1月至6月的六项月度PeriodIndex
PeriodIndex(['2000-01', '2000-02', '2000-03', '2000-04', '2000-05', '2000-06'], dtype='period[M]')

PeriodIndex 类存储了一系列时期,并可以作为任何 pandas 数据结构中的轴索引:

period_index_rng = np.random.default_rng(20260903)  # 固定PeriodIndex示例的随机数流,使索引展示与数值输出均可重复
pd.Series(period_index_rng.standard_normal(6), index=periods)  # 创建以时期索引的固定教学收益率序列
2000-01   -0.027735
2000-02   -0.411547
2000-03    1.222584
2000-04   -0.944822
2000-05    0.250430
2000-06   -0.971720
Freq: M, dtype: float64

如果你有一个字符串数组,你也可以使用 PeriodIndex 类:

values = ['2001Q3', '2002Q2', '2003Q1']                     # 季度时期字符串列表
index = pd.PeriodIndex(values, freq='Q-DEC')                # 创建季度时期索引对象
index  # 输出2001Q3、2002Q2、2003Q1三个季度时期标签
PeriodIndex(['2001Q3', '2002Q2', '2003Q1'], dtype='period[Q-DEC]')

9.6.1 时期频率转换

PeriodPeriodIndex 对象可以使用它们的 asfreq 方法转换为另一个频率。假设我们有一个年度时期,并希望将其转换为年初或年末的月度时期。

p = pd.Period('2011', freq='Y-DEC')                         # 创建2011年度时期(12月年末)
p.asfreq('M', how='start')  # 将年度时期转换为起始月份
Period('2011-01', 'M')
p.asfreq('M', how='end')  # 将年度时期转换为结束月份
Period('2011-12', 'M')

你可以将Period('2011', 'Y-DEC')想象成一个指向时间跨度的游标,这个时间跨度被月度时期所细分。对于财年结束月份不是12月的时期,相应的月度子时期不同:

p = pd.Period('2011', freq='Y-JUN')                         # 创建2011年度时期(6月年末)
p.asfreq('M', 'start')  # 返回该年度时期包含的首个月份
Period('2010-07', 'M')
p.asfreq('M', 'end')  # 返回该年度时期包含的末个月份
Period('2011-06', 'M')
import matplotlib.pyplot as plt  # 绘制两种财年约定下年度Period到月份边界的映射
import matplotlib.patches as patches  # 导入matplotlib库用于数据可视化
import pandas as pd  # 构造A-DEC、A-JUN及其月度Period边界

def plot_period_conversion(ax, period_str, freq, title):    # 把一个年度Period展开为十二个月框并标出首末月映射
    p = pd.Period(period_str, freq=freq)                    # 根据传入参数创建时期对象
    
    # 获取年度的起始和结束月份
    year_start_month = pd.Period(p.start_time, freq='M').month  # 获取时期起始时间对应的月份
    year_end_month = pd.Period(p.end_time, freq='M').month  # 获取时期结束时间对应的月份
    
    # 构造月份标签
    months_in_year = pd.period_range(p.start_time, p.end_time, freq='M')  # 创建期间范围序列
    month_labels = [m.strftime('%b') for m in months_in_year]  # 将月份格式化为缩写标签列表

    ax.set_ylim(0, 5)  # 将构造月份标签面板纵轴限定为0, 5
    ax.set_xlim(-1, 12)                                     # 设置x轴范围
    ax.set_xticks(range(12))                                # 设置x轴刻度
    ax.set_xticklabels(month_labels)                        # 设置x轴刻度标签
    ax.set_yticks([])  # 隐藏构造月份标签面板无数值含义的纵轴刻度
    ax.set_title(title)                                     # 使用调用方给定题名区分年、季度与月份层级

    # 绘制年度时期
    rect_year = patches.Rectangle((-0.5, 3.5), 12, 1, linewidth=1, edgecolor='k', facecolor='skyblue', alpha=0.6)  # 绘制年度时期的蓝色矩形区域
    ax.add_patch(rect_year)                                 # 添加图形元素
    ax.text(5.5, 4, str(p), ha='center', va='center', fontweight='bold')  # 在绘制年度时期图层标注center

    # 绘制月度时期
    for i in range(12):  # 遍历range(12)中的每个元素
        rect_month = patches.Rectangle((i - 0.4, 2), 0.8, 0.8, linewidth=1, edgecolor='grey', facecolor='lightgreen', alpha=0.7)  # 绘制各月份的绿色矩形区域
        ax.add_patch(rect_month)                            # 添加图形元素
    # 标记 asfreq('M', 'start')
    start_p = p.asfreq('M', how='start')                    # 获取时期开始对应的月份
    start_idx = (start_p.month - year_start_month + 12) % 12  # 计算起始月在图表中的位置索引
    ax.annotate('asfreq(\'M\', \'start\')',  # 箭头指向年度时期按start规则映射到的首个月份
                xy=(start_idx, 2.4), xycoords='data',       # 箭头指向起始月份矩形位置
                xytext=(start_idx, 1), textcoords='data',   # 文本标签放置在下方
                arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0.1', color='red'),  # 红色箭头指示起始位置
                ha='center', va='top', color='red')         # 居中对齐红色文本

    # 标记 asfreq('M', 'end')
    end_p = p.asfreq('M', how='end')                        # 获取时期结束对应的月份
    end_idx = (end_p.month - year_start_month + 12) % 12    # 计算结束月在图表中的位置索引
    ax.annotate('asfreq(\'M\', \'end\')',  # 箭头指向年度时期按end规则映射到的末月份
                xy=(end_idx, 2.4), xycoords='data',         # 箭头指向结束月份矩形位置
                xytext=(end_idx, 1), textcoords='data',     # 文本标签放置在下方
                arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=-0.1', color='blue'),  # 蓝色箭头指示结束位置
                ha='center', va='top', color='blue')        # 居中对齐蓝色文本

fig, axes = plt.subplots(2, 1, figsize=(10, 6), sharex=False)  # 上下两轴分别展示十二月与六月财年末,月份标签各自独立
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 为年度—月度Period转换图启用中文财年标签
plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号

plot_period_conversion(axes[0], '2011', 'Y-DEC', "财年以12月结束 (freq='Y-DEC')")  # 绘制以十二月为财年末的转换示意图
plot_period_conversion(axes[1], '2012', 'Y-JUN', "财年以6月结束 (freq='Y-JUN')")  # 绘制以六月为财年末的转换示意图

fig.tight_layout()  # 调整上下财年面板,避免月份标签与箭头文字重叠
plt.show()  # 显示年度矩形、十二个月份及start/end月份箭头
图 9.1: 时期频率转换示意图

图 9.1 所示,时期的转换逻辑取决于其定义。

当你从高频向低频转换时,pandas会根据超时期“所属”的位置来确定超时期。例如,在Y-JUN频率中,2011年8月这个月份实际上是2012财年的一部分:

p = pd.Period('Aug-2011', 'M')                              # 创建2011年8月的月度时期对象
p.asfreq('Y-JUN')  # 将月度时期映射到六月结束的财年标签
Period('2012', 'Y-JUN')

整个 PeriodIndex 对象或时间序列也可以用相同的语义进行类似的转换:

periods = pd.period_range('2006', '2009', freq='Y-DEC')  # 创建四个十二月结束的年度时期
annual_period_rng = np.random.default_rng(20260904)  # 固定年度时期转换示例的随机数流,便于核对转换前后数值不变
quarterly_economic_series = pd.Series(annual_period_rng.standard_normal(len(periods)), index=periods)  # 将四个固定教学值绑定到2006—2009年度PeriodIndex
quarterly_economic_series  # 输出2006—2009四个年度时期及对应模拟值
2006    1.517924
2007   -1.273252
2008    0.796872
2009   -0.194975
Freq: Y-DEC, dtype: float64
quarterly_economic_series.asfreq('M', how='start')  # 将年度数据转换为各年起始月份的时期
2006-01    1.517924
2007-01   -1.273252
2008-01    0.796872
2009-01   -0.194975
Freq: M, dtype: float64

工作日不再建模为Period频率。若要取得每年的最后一个工作日,可以先把年度时期转换为日历年末时间戳,再用BusinessDay.rollback回退到不晚于该日的工作日:

calendar_year_ends = quarterly_economic_series.index.to_timestamp(how='end').normalize()  # 把年度PeriodIndex落到各年最后一个日历日
last_business_days = pd.DatetimeIndex([pd.offsets.BusinessDay().rollback(day) for day in calendar_year_ends])  # 周末年末回退到最近工作日
pd.Series(quarterly_economic_series.to_numpy(), index=last_business_days)  # 数值不变,仅把索引改为各年最后一个工作日Timestamp
2006-12-29    1.517924
2007-12-31   -1.273252
2008-12-31    0.796872
2009-12-31   -0.194975
dtype: float64

9.6.2 季度时期频率

季度数据在会计、金融和其他领域是标准的。许多季度数据是相对于一个财年结束日 (fiscal year end) 报告的,通常是一年中12个月份之一的最后一个日历日或工作日。因此,时期 2012Q4 的含义根据财年结束日而不同。pandas 支持所有12种可能的季度频率,从 Q-JANQ-DEC

对于一个财年在一月份结束的情况,2012Q4 从2011年11月持续到2012年1月。我们可以通过转换为每日频率来验证这一点:

p = pd.Period('2012Q4', freq='Q-JAN')                       # 创建以一月为财年末的2012Q4时期对象
p  # 输出Q-JAN约定下跨2011年11月至2012年1月的2012Q4
Period('2012Q4', 'Q-JAN')
p.asfreq('D', 'start')  # 转换为季度起始日的日度时期
Period('2011-11-01', 'D')
p.asfreq('D', 'end')  # 转换为季度结束日的日度时期
Period('2012-01-31', 'D')
import matplotlib.pyplot as plt  # 绘制标准季度与六月财年季度的起止端点
import matplotlib.dates as mdates  # 导入matplotlib库用于数据可视化
import pandas as pd  # 解析Q-DEC和Q-JUN时期并取得各自日期边界

def plot_quarterly_convention(ax, period_str, freq, title):  # 定义季度时期约定可视化函数
    p = pd.Period(period_str, freq=freq)                    # 根据参数创建季度时期对象
    start_date = p.start_time  # 获取时期的起始时间
    end_date = p.end_time  # 获取时期的结束时间
    
    ax.set_ylim(0, 1)  # 将季度时期频率面板纵轴限定为0, 1
    ax.set_yticks([])  # 隐藏季度时期频率面板无数值含义的纵轴刻度
    ax.set_title(title)                                     # 使用调用方题名注明季度定义及其起止边界

    # 绘制时间条
    ax.plot([start_date, end_date], [0.5, 0.5], lw=10, color='royalblue', solid_capstyle='butt')  # 用线段编码该季度从起点到终点的完整时间区间
    
    # 标记开始和结束
    ax.plot(start_date, 0.5, 'o', color='green', markersize=10)  # 以端点标记季度开始,避免把时期标签当作单一时刻
    ax.text(start_date, 0.6, f'开始:\n{start_date:%Y-%m-%d}', ha='center', va='bottom')  # 标注该季度按当前频率约定得到的实际起始日
    
    ax.plot(end_date, 0.5, 'o', color='red', markersize=10)  # 以端点标记季度结束,显示两种季度约定的边界差异
    ax.text(end_date, 0.6, f'结束:\n{end_date:%Y-%m-%d}', ha='center', va='bottom')  # 标注该季度按当前频率约定得到的实际结束日

    # 格式化x轴
    ax.xaxis.set_major_locator(mdates.MonthLocator())  # 在季度区间内每个自然月放置一个主刻度
    ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%b'))  # 将月刻度显示为四位年与英文月缩写
    ax.figure.autofmt_xdate()  # 旋转日期标签以避免相邻月份文字重叠
    ax.grid(axis='x', linestyle=':')  # 月度竖网格帮助读取季度端点所在月份,纵轴无数值含义

fig, axes = plt.subplots(2, 1, figsize=(12, 5), constrained_layout=True)  # 两行面板共享季度概念,但分别使用Q-DEC与Q-JUN日期范围
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 为两种季度约定图启用中文起止日期标签
plt.rcParams['axes.unicode_minus'] = False  # 保持日期标签中的连字符与中文字体兼容

plot_quarterly_convention(axes[0], '2012Q1', 'Q-DEC', "标准季度 (freq='Q-DEC'): 2012Q1")  # 绘制标准季度约定示意图
plot_quarterly_convention(axes[1], '2012Q1', 'Q-JUN', "财年6月结束 (freq='Q-JUN'): 2012Q1")  # 绘制财年季度约定示意图

plt.show()  # 显示两种2012Q1定义的绿色起点、红色终点与完整区间
图 9.2: 不同季度频率约定示意图

图 9.2 可以清晰地看到,财年结束日的不同约定,导致了同一个季度标签(例如’2012Q1’)对应着完全不同的日历时间范围。

进行时期算术是很方便的。例如,要获取季度倒数第二个工作日下午4点的时间戳,你可以这样做:

p = pd.Period('2012Q4', freq='Q-JAN')                       # 创建以1月为季末的2012Q4时期
p_end_day = p.end_time.normalize()  # 先把季度时期落到其最后一个日历日Timestamp
p_last_business_day = pd.offsets.BusinessDay().rollback(p_end_day)  # 若季末在周末则回退到最近工作日
p4pm = p_last_business_day - pd.offsets.BusinessDay(1) + pd.Timedelta(hours=16)  # 再回退一个工作日并定位到16:00
p4pm  # 输出季度末倒数第二个工作日16:00的Timestamp
Timestamp('2012-01-30 16:00:00')
p4pm  # 结果已经是纳秒精度Timestamp,无需再从工作日Period转换
Timestamp('2012-01-30 16:00:00')

这里显式使用时间戳与工作日偏移量,避免把工作日误建模成等长时期。

你可以使用 pandas.period_range 生成季度范围:

periods = pd.period_range('2011Q3', '2012Q4', freq='Q-JAN')  # 创建以一月为财年末的六个季度时期
quarterly_profit_series = pd.Series(np.arange(len(periods)), index=periods)  # 用0—5标记六个Q-JAN季度,便于跟踪索引换算后值不变
quarterly_profit_series  # 输出六个Q-JAN季度与0—5的值对应关系
2011Q3    0
2011Q4    1
2012Q1    2
2012Q2    3
2012Q3    4
2012Q4    5
Freq: Q-JAN, dtype: int64

我们可以通过算术运算生成新的时间戳,例如,获取每个时期倒数第二个工作日的下午4点:

quarter_end_days = periods.to_timestamp(how='end').normalize()  # 把六个季度时期分别落到季度末日历日
last_business_days = pd.DatetimeIndex([pd.offsets.BusinessDay().rollback(day) for day in quarter_end_days])  # 对周末季末逐项回退
new_timestamps = last_business_days - pd.offsets.BusinessDay(1) + pd.Timedelta(hours=16)  # 定位到各季度倒数第二个工作日16:00
quarterly_profit_series.index = new_timestamps  # 数值不变,只把季度标签替换为目标业务时间戳
quarterly_profit_series  # 验证改成季度倒数第二个工作日16:00索引后六个值未改变
2010-10-28 16:00:00    0
2011-01-28 16:00:00    1
2011-04-28 16:00:00    2
2011-07-28 16:00:00    3
2011-10-28 16:00:00    4
2012-01-30 16:00:00    5
dtype: int64

9.6.3 时间戳与时期的转换(及逆转换)

由时间戳索引的 SeriesDataFrame 对象可以使用 to_period 方法转换为时期:

dates = pd.date_range('2000-01-01', periods=3, freq='ME')  # 生成2000年1—3月的三个月末时间戳
timestamp_period_rng = np.random.default_rng(20260905)  # 固定时间戳转时期示例的随机数流,使转换前后可逐值核对
monthly_returns_series = pd.Series(timestamp_period_rng.standard_normal(3), index=dates)  # 将三个固定教学收益绑定到月末Timestamp索引
monthly_returns_series  # 输出三个模拟收益及2000年一至三月月末Timestamp索引
2000-01-31    1.355427
2000-02-29   -1.082545
2000-03-31    0.145787
Freq: ME, dtype: float64
monthly_returns_period_series = monthly_returns_series.to_period()  # 将时间戳索引转换为时期索引
monthly_returns_period_series  # 输出值不变、索引改为2000-01至2000-03月度Period的Series
2000-01    1.355427
2000-02   -1.082545
2000-03    0.145787
Freq: M, dtype: float64

由于时期指的是不重叠的时间跨度,一个时间戳对于给定的频率只能属于一个时期。新的 PeriodIndex 的频率默认是从时间戳推断出来的,但你可以指定任何支持的频率。

dates = pd.date_range('2000-01-29', periods=6)  # 生成跨越一月末的六个连续自然日
daily_period_rng = np.random.default_rng(20260906)  # 固定日频转月度时期示例的随机数流,避免索引压缩演示发生数值漂移
six_month_returns_series = pd.Series(daily_period_rng.standard_normal(6), index=dates)  # 绑定六个固定教学收益,展示多个日期压缩到同一月度Period
six_month_returns_series.to_period('M')  # 将日期索引转换为月度时期索引
2000-01    0.502498
2000-01   -0.383793
2000-01    0.812029
2000-02   -0.915050
2000-02   -0.043620
2000-02    0.862705
Freq: M, dtype: float64

要转换回时间戳,使用 to_timestamp 方法:

six_month_returns_period_series = six_month_returns_series.to_period()  # 将日期索引转换为默认频率的时期索引
six_month_returns_period_series  # 输出六个日频值转换后的日度PeriodIndex结果
2000-01-29    0.502498
2000-01-30   -0.383793
2000-01-31    0.812029
2000-02-01   -0.915050
2000-02-02   -0.043620
2000-02-03    0.862705
Freq: D, dtype: float64
six_month_returns_period_series.to_timestamp(how='end')  # 将时期索引转换为各时期末尾的时间戳
2000-01-29 23:59:59.999999999    0.502498
2000-01-30 23:59:59.999999999   -0.383793
2000-01-31 23:59:59.999999999    0.812029
2000-02-01 23:59:59.999999999   -0.915050
2000-02-02 23:59:59.999999999   -0.043620
2000-02-03 23:59:59.999999999    0.862705
Freq: D, dtype: float64

9.6.4 从数组创建 PeriodIndex

固定频率的数据集有时会将时间跨度信息分散在多个列中。下面把本地上证综指的季度末点位拆成 yearquarter,再用这两列构造 PeriodIndex。这个例子只演示时期索引,不把市场指数改称 GDP。

# 从全指数表筛出上证综指,按季度末点位构造year与quarter两列
index_data = index_data_all.copy()  # 从章级只读基表复制指数记录,避免再次扫描约 1.1GB 的 fixed 节点
sse = index_data[index_data['symbol'] == '000001.XSHG'].copy()  # 复制上证综指记录,避免日期转换改写全指数表
sse['datetime'] = pd.to_datetime(sse['datetime'].astype(str), format='%Y%m%d%H%M%S')  # 按14位编码解析指数交易时间
sse.set_index('datetime', inplace=True)  # 以交易时间为索引后才能转换为月度PeriodIndex

# 转换为季度数据以进行演示
data_raw = sse['close'].sort_index()                        # 按索引排序
data = data_raw.to_frame(name='quarter_end_close').resample('QE').last()  # 取每季最后真实观测构造季度时期输入
data['year'] = data.index.year  # 从日期索引中提取年份列
data['quarter'] = data.index.quarter  # 从日期索引中提取季度列
data.tail()                                                 # 查看后5行数据
quarter_end_close year quarter
datetime
2025-03-31 3335.7462 2025 1
2025-06-30 3444.4256 2025 2
2025-09-30 3882.7774 2025 3
2025-12-31 3968.8401 2025 4
2026-03-31 4117.9476 2026 1

通过将这些 yearquarter 数组传递给 PeriodIndex,我们可以将它们组合起来形成 DataFrame 的索引:

# 使用PeriodIndex
index = pd.PeriodIndex(year=data['year'], quarter=data['quarter'], freq='Q-DEC')  # 用年份和季度列构建季度时期索引
data.index = index  # 将DataFrame的索引替换为新建的时期索引
data.head()  # 核验季度时期索引与季末点位、年份、季度三列
quarter_end_close year quarter
2005Q1 1181.236 2005 1
2005Q2 1080.938 2005 2
2005Q3 1155.614 2005 3
2005Q4 1161.057 2005 4
2006Q1 1298.295 2006 1

9.7 重采样与频率转换

重采样 (Resampling) 是指将时间序列从一个频率转换为另一个频率的过程。 * 将高频数据聚合到低频称为降采样 (downsampling)。(例如,每日股价到每月股价) * 将低频数据转换为高频称为升采样 (upsampling)。(例如,年度GDP到季度GDP)

并非所有的重采样都属于这两类;例如,将 W-WED(周三)转换为 W-FRI(周五)既不是升采样也不是降采样。

pandas 对象配备了一个 resample 方法,这是所有频率转换的主力函数。resample 的 API 与 groupby 类似;你调用 resample 来对数据进行分组,然后调用一个聚合函数。

trade_dates = pd.date_range('2020-01-01', periods=100)  # 构造连续100个日历日作为重采样机制索引
resample_example_rng = np.random.default_rng(20260907)  # 固定重采样机制示例的随机数流,使月度聚合结果可重复核对
stock_daily_returns_series = pd.Series(resample_example_rng.standard_normal(len(trade_dates)), index=trade_dates)  # 将固定题设收益绑定到日历日,不作真实资产解释
stock_daily_returns_series.head()  # 核验题设值与每日DatetimeIndex一一对应
2020-01-01    0.616239
2020-01-02   -0.753662
2020-01-03   -2.476746
2020-01-04   -0.925183
2020-01-05    0.083098
Freq: D, dtype: float64

让我们将其重采样到月度频率并取平均值。

stock_daily_returns_series.resample('ME').mean()  # 以日历月末为标签汇总题设日收益均值
2020-01-31   -0.345356
2020-02-29   -0.334119
2020-03-31    0.100509
2020-04-30   -0.246607
Freq: ME, dtype: float64

我们也可以将结果转换为 Period 对象。

stock_daily_returns_series.resample('ME').mean().to_period('M')  # 先按月末聚合,再显式转换为月度PeriodIndex
2020-01   -0.345356
2020-02   -0.334119
2020-03    0.100509
2020-04   -0.246607
Freq: M, dtype: float64

resample 是一个灵活的方法,可以用来处理大型时间序列。表 9.6 总结了它的一些选项。

表 9.6: resample 方法参数
参数 描述
rule 字符串、DateOffset或timedelta,指示期望的重采样频率(例如'ME''5min'
closed 在降采样中,每个区间的哪一端是闭合的(包含),‘right’ 或 ‘left’
label 在降采样中,如何标记聚合结果,使用 ‘right’ 或 ‘left’ 的区间边缘
on / level 对于 DataFrame,用于重采样的列,而不是索引

9.7.1 降采样

降采样是把数据聚合到一个更规整、频率更低的时间序列。期望的频率定义了用于将时间序列切片成块以进行聚合的区间边缘 (bin edges)。在使用 resample 进行降采样时,有几件事需要考虑:

  • 每个区间的哪一边是闭合的 (closed)(即包含在内的)。
  • 如何标记 (label) 每个聚合后的区间,是用区间的开始还是结束。

为了说明这一点,让我们看一些一分钟频率的数据:

intraday_dates = pd.date_range('2020-01-01', periods=12, freq='min')  # 构造00:00至00:11的一分钟时间戳
minbar_trading_volume_series = pd.Series(np.arange(12), index=intraday_dates)  # 用0—11题设成交量逐桶核验边界归属
minbar_trading_volume_series  # 输出00:00—00:11的12条一分钟题设成交量
2020-01-01 00:00:00     0
2020-01-01 00:01:00     1
2020-01-01 00:02:00     2
2020-01-01 00:03:00     3
2020-01-01 00:04:00     4
2020-01-01 00:05:00     5
2020-01-01 00:06:00     6
2020-01-01 00:07:00     7
2020-01-01 00:08:00     8
2020-01-01 00:09:00     9
2020-01-01 00:10:00    10
2020-01-01 00:11:00    11
Freq: min, dtype: int64

假设你想将这些数据聚合成五分钟的块或bars,通过取每组的总和:

minbar_trading_volume_series.resample('5min').sum()  # 按默认左闭左标签汇总每个五分钟桶的成交量
2020-01-01 00:00:00    10
2020-01-01 00:05:00    35
2020-01-01 00:10:00    21
Freq: 5min, dtype: int64

频率 '5min' 定义了以五分钟为增量的区间边缘(00:00, 00:05 等)。默认情况下,左边的区间边缘是包含的(closed='left'),并且标签也取自左边缘。所以,00:0000:04 的数据被加总到标记为 00:00 的区间中。

我们可以改变这些默认设置。closed='right' 使右边缘成为包含的:

minbar_trading_volume_series.resample('5min', closed='right').sum()  # 改为右闭区间并观察边界分钟归属变化
2019-12-31 23:55:00     0
2020-01-01 00:00:00    15
2020-01-01 00:05:00    40
2020-01-01 00:10:00    11
Freq: 5min, dtype: int64

label='right' 用右边缘的时间戳来标记区间:

minbar_trading_volume_series.resample('5min', closed='right', label='right').sum()  # 用右边缘标记右闭五分钟成交量桶
2020-01-01 00:00:00     0
2020-01-01 00:05:00    15
2020-01-01 00:10:00    40
2020-01-01 00:15:00    11
Freq: 5min, dtype: int64
import matplotlib.pyplot as plt  # 绘制五分钟桶边界、原始分钟点和标签箭头
import numpy as np  # 生成0—11分钟观测位置与0、5、10、15分钟桶边界

def plot_resample_concept(ax, closed, label, title):        # 根据闭合端和标签端绘制三只五分钟桶的包含关系
    # 时间点
    time_points = np.arange(12)                             # 创建等差数组
    ax.plot(time_points, np.zeros_like(time_points), 'o', color='black', label='原始数据点 (0-11分钟)')  # 按分钟位置展示原始点,以核对五分钟桶的左右边界
    
    # 区间
    bins = np.arange(0, 16, 5) # 桶边界依次为0、5、10、15分钟
    ax.vlines(bins, -0.5, 0.5, colors='red', linestyles='--', label='5分钟区间边界')  # 绘制各个5分钟分箱的时间边界
    
    for i in range(len(bins) - 1):  # 遍历range(len(bins) - 1)中的每个元素
        left_edge = bins[i]  # 获取当前区间的左边界
        right_edge = bins[i+1]  # 获取当前区间的右边界
        
        # 定义区间包含关系
        if closed == 'left':  # 根据条件进行分支判断
            interval_str = f'[{left_edge}, {right_edge})'   # 左闭右开区间表示
            range_mask = (time_points >= left_edge) & (time_points < right_edge)  # 筛选属于当前左闭右开区间的数据点
        else: # 右闭分支包含右端点但排除左端点
            interval_str = f'({left_edge}, {right_edge}]'   # 左开右闭区间表示
            range_mask = (time_points > left_edge) & (time_points <= right_edge)  # 筛选属于当前左开右闭区间的数据点
            
        # 确定标签位置
        if label == 'left':  # 根据条件进行分支判断
            label_pos = left_edge  # 用左边界作为区间标签位置
        else: # 右标签分支使用桶的结束分钟标记聚合值
            label_pos = right_edge  # 用右边界作为区间标签位置

        ax.text((left_edge + right_edge) / 2, 0.2, interval_str, ha='center', color='blue')  # 在每个桶中央写明左闭右开或左开右闭区间
        
        # 绘制聚合结果标签
        ax.annotate(f'标签: {label_pos}', xy=(label_pos, -0.1), xytext=(label_pos, -0.4),  # 箭头指向由closed与label规则共同决定的时间标签
                    arrowprops=dict(facecolor='green', shrink=0.05),  # 设置绿色箭头样式
                    ha='center', va='top', color='green')   # 设置文字居中对齐和绿色显示
    ax.set_xlim(-1, 15)                                     # 设置x轴范围
    ax.set_ylim(-0.6, 0.6)  # 将绘制聚合结果标签面板纵轴限定为-0.6, 0.6
    ax.set_title(title)                                     # 使用调用方题名明确 closed 与 label 的边界组合
    ax.set_xlabel('分钟')                                     # 横轴注明“分钟”,界定比较变量及其度量口径
    ax.set_yticks([])  # 隐藏绘制聚合结果标签面板无数值含义的纵轴刻度
    ax.grid(True, axis='x')  # 仅沿分钟横轴画网格,帮助核对0、5、10、15边界


fig, axes = plt.subplots(2, 1, figsize=(12, 6), constrained_layout=True)  # 上轴演示左闭左标,下轴演示右闭右标的五分钟桶
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 为五分钟桶边界与标签示意图启用中文字体
plt.rcParams['axes.unicode_minus'] = False  # 正确显示标签箭头所在的负纵坐标刻度

plot_resample_concept(axes[0], 'left', 'left', "默认: closed='left', label='left'")  # 绘制默认左闭合左标签的重采样示意图
plot_resample_concept(axes[1], 'right', 'right', "修改: closed='right', label='right'")  # 绘制右闭合右标签的重采样示意图

handles, labels = axes[0].get_legend_handles_labels()  # 获取第一个子图的图例句柄和标签
fig.legend(handles, labels, loc='upper center', ncol=2)  # 共用图例区分黑色原始分钟点与红色桶边界

plt.show()  # 输出两种closed/label约定的上下对照图
图 9.3: 五分钟重采样中 closed 和 label 约定的示意图

图 9.3 直观地展示了 closedlabel 参数如何影响最终的聚合结果。

最后,你可能想对结果索引进行一些移位,比如从右边缘减去一秒,以更清楚地表明时间戳指的是哪个区间。为此,你可以使用 .loffset() 或直接向索引添加一个偏移量。

# 'loffset' 参数已被弃用。这是现代的方法。
from pandas.tseries.frequencies import to_offset            # 把负一秒频率字符串解析为可加到DatetimeIndex的偏移量
result = minbar_trading_volume_series.resample('5min', closed='right', label='right').sum()  # 先生成右闭右标签的五分钟成交量和
result.index = result.index + to_offset('-1s')  # 将索引向前偏移1秒以标明区间归属
result  # 输出右闭五分钟成交量和,并把每个右端标签前移一秒
2019-12-31 23:59:59     0
2020-01-01 00:04:59    15
2020-01-01 00:09:59    40
2020-01-01 00:14:59    11
Freq: 5min, dtype: int64

9.7.1.1 开高低收 (OHLC) 重采样

在金融领域,聚合高频时间序列的一种流行方法是为每个桶计算四个值:第一个(开盘价, open)、最后一个(收盘价, close)、最大值(最高价, high)和最小值(最低价, low)。.ohlc() 聚合函数可以高效地完成这项工作。

# 使用我们之前的一分钟时间序列
minbar_trading_volume_series.resample('5min').ohlc()  # 每个五分钟桶返回题设序列的首值、最大、最小与末值
open high low close
2020-01-01 00:00:00 0 4 0 4
2020-01-01 00:05:00 5 9 5 9
2020-01-01 00:10:00 10 11 10 11

9.7.2 升采样与插值

升采样是从低频转换到高频,此时不需要聚合。这个过程会引入缺失值。下面从本地 HDF5 行情按周三标签提取四个指数的周末前最新点位;它们是可执行链生成的指数点位,不是地区 GDP 或手工常量。

# 从本地行情形成可追溯的四指数日度宽表
upsampling_index_names = {                                 # 保留代码到指数名称的变量语义
    '000001.XSHG': '上证综指', '399001.XSHE': '深证成指',
    '000300.XSHG': '沪深300', '000016.XSHG': '上证50',
}
index_daily_levels_df = (                                  # 解析日期并将指数代码旋转为列
    index_data.loc[index_data['symbol'].isin(upsampling_index_names)]
    .assign(datetime=lambda x: pd.to_datetime(x['datetime'].astype(str), format='%Y%m%d%H%M%S'))
    .pivot(index='datetime', columns='symbol', values='close')
    .rename(columns=upsampling_index_names)
    .sort_index()
)
index_weekly_levels_df = index_daily_levels_df.loc['2020-01'].resample('W-WED').last().head(2)  # 取两个周三桶内的最后真实点位
index_weekly_levels_df  # 核对周标签与四个指数列
symbol 上证综指 上证50 沪深300 深证成指
datetime
2020-01-08 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-15 3090.0379 3058.0078 4166.7344 10972.3163

如果我们将这个重采样到每日频率,我们会得到带有缺失值的间隙。.asfreq() 方法用于在不进行任何聚合的情况下转换为更高频率。

index_calendar_daily_df = index_weekly_levels_df.resample('D').asfreq()  # 扩展为日历日仅用于观察升采样空位
index_calendar_daily_df  # 检查两个周标签之间新增的缺失行
symbol 上证综指 上证50 沪深300 深证成指
datetime
2020-01-08 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-09 NaN NaN NaN NaN
2020-01-10 NaN NaN NaN NaN
2020-01-11 NaN NaN NaN NaN
2020-01-12 NaN NaN NaN NaN
2020-01-13 NaN NaN NaN NaN
2020-01-14 NaN NaN NaN NaN
2020-01-15 3090.0379 3058.0078 4166.7344 10972.3163

假设你想在非周三的日子里向前传播每个周度值。你可以使用像 ffill(“向前填充”)或 bfill(“向后填充”)这样的方法。这里生成的是日历日机制示例,不应把填充值当成交易所真实观测。

index_weekly_levels_df.resample('D').ffill()  # 将已知周度点位传播到日历日以演示填充规则
symbol 上证综指 上证50 沪深300 深证成指
datetime
2020-01-08 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-09 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-10 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-11 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-12 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-13 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-14 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-15 3090.0379 3058.0078 4166.7344 10972.3163

你也可以选择只向前填充一定数量的周期,以限制一个观测值可以被继续使用的距离:

index_weekly_levels_df.resample('D').ffill(limit=2)  # 限制点位最多传播两个日历日,控制信息陈旧程度
symbol 上证综指 上证50 沪深300 深证成指
datetime
2020-01-08 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-09 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-10 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-11 NaN NaN NaN NaN
2020-01-12 NaN NaN NaN NaN
2020-01-13 NaN NaN NaN NaN
2020-01-14 NaN NaN NaN NaN
2020-01-15 3090.0379 3058.0078 4166.7344 10972.3163

值得注意的是,新的日期索引完全不必与旧的重合:

index_weekly_levels_df.resample('W-THU').ffill()  # 改用周四标签说明目标索引可与源标签错开
symbol 上证综指 上证50 沪深300 深证成指
datetime
2020-01-09 3066.8925 3037.8525 4112.3172 10706.8685
2020-01-16 3090.0379 3058.0078 4166.7344 10972.3163

9.7.3 使用时期进行重采样

对由时期索引的数据进行重采样与时间戳类似。

# 将同一真实日度宽表聚合为月末点位,再转换为 PeriodIndex
index_monthly_levels_df = index_daily_levels_df.loc['2018':'2019'].resample('ME').last()  # 每月取最后真实交易观测
index_monthly_levels_df.index = index_monthly_levels_df.index.to_period('M')  # 将月末时间戳转换为月度时期
index_monthly_levels_df.head()  # 核对时期索引与指数语义列名
symbol 上证综指 上证50 沪深300 深证成指
datetime
2018-01 3480.8334 3116.8331 4275.8986 11159.6760
2018-02 3259.4080 2878.6666 4023.6415 10828.7302
2018-03 3168.8966 2722.1530 3898.4977 10868.6549
2018-04 3082.2316 2653.5436 3756.8765 10324.4679
2018-05 3095.4737 2658.2917 3802.3759 10295.7290

让我们通过取平均值将这个月度数据降采样为年度数据。

index_annual_levels_df = index_monthly_levels_df.resample('Y-DEC').mean()  # 将月末点位均值汇总至年度超时期
index_annual_levels_df  # 检查每年四个指数的月末点位均值
symbol 上证综指 上证50 沪深300 深证成指
datetime
2018 2920.177433 2608.994300 3574.705583 9275.536758
2019 2928.940875 2855.514092 3781.117517 9331.694075

升采样则更为微妙,因为你必须决定将值放在新频率时间跨度的哪一端。convention 参数默认为 'start',但也可以是 'end'

# Q-DEC: 季度,年度在12月结束
# 使用向前填充来传播值
index_annual_levels_df.resample('Q-DEC').ffill()  # 将年度值放入季度子时期并向后传播
symbol 上证综指 上证50 沪深300 深证成指
datetime
2018Q1 2920.177433 2608.994300 3574.705583 9275.536758
2018Q2 2920.177433 2608.994300 3574.705583 9275.536758
2018Q3 2920.177433 2608.994300 3574.705583 9275.536758
2018Q4 2920.177433 2608.994300 3574.705583 9275.536758
2019Q1 2928.940875 2855.514092 3781.117517 9331.694075
2019Q2 2928.940875 2855.514092 3781.117517 9331.694075
2019Q3 2928.940875 2855.514092 3781.117517 9331.694075
2019Q4 2928.940875 2855.514092 3781.117517 9331.694075

convention='end'.asfreq 一起使用会将值放在新频率的最后一个时期。

index_annual_levels_df.resample('Q-DEC', convention='end').asfreq()  # 仅把年度值放到年末季度
symbol 上证综指 上证50 沪深300 深证成指
datetime
2018Q4 2920.177433 2608.994300 3574.705583 9275.536758
2019Q1 NaN NaN NaN NaN
2019Q2 NaN NaN NaN NaN
2019Q3 NaN NaN NaN NaN
2019Q4 2928.940875 2855.514092 3781.117517 9331.694075

由于时期指的是时间跨度,升采样和降采样的规则更为严格: * 在降采样中,目标频率必须是源频率的超时期 (superperiod),例如月度时期归入年度时期。 * 在升采样中,目标频率必须是源频率的子时期 (subperiod),例如年度时期展开为季度时期。

如果不满足这些规则,将会引发异常。这主要影响季度、年度和周度频率。

9.8 移动窗口函数

时间序列的一类重要转换是在一个滑动窗口 (sliding window)上或使用指数衰减权重 (exponentially decaying weights)计算的统计量。这对于平滑噪声数据和识别趋势很有用。这些被称为移动窗口函数 (moving window functions)

首先,让我们从本地 HDF5 行情读取真实 A 股数据。我们将重点分析长三角地区的重要企业,包括宁波港 (601018)宁波银行 (002142),以及其他代表性公司。

import pandas as pd  # 读取四只A股日行情并按共同交易日合并收盘价
import numpy as np  # 为后续收益、滚动波动率与有限值计算提供数组运算
from pathlib import Path                                    # 表示股票与指数HDF5文件所在的数据根目录

# 从同一前复权日行情表读取四只证券,确保价格口径和字段一致
ningbo_port_stock_df = pd.read_hdf(  # 读取宁波港完整日行情
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='601018.XSHG'"  # 港口价格形成共同交易日宽表的运输行业列
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开交易日并统一成交量字段
ningbo_bank_stock_df = pd.read_hdf(  # 读取宁波银行同口径日行情
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='002142.XSHE'"  # 银行价格提供深市金融行业对照列
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 保留交易日、收盘价和成交量schema
hengrui_stock_df = pd.read_hdf(  # 读取恒瑞医药同口径日行情
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='600276.XSHG'"  # 医药价格补充沪市跨行业比较列
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开交易日索引供多资产对齐
iflytek_stock_df = pd.read_hdf(  # 读取科大讯飞同口径日行情
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='002230.XSHE'"  # 科技价格作为第四列检验多资产窗口输出schema
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 形成与前三表相同的列结构

# 准备时间序列数据的函数
def prepare_timeseries_data(df, stock_name):                # 将单只证券表整理为日期索引、中文列名的收盘价Series
    """准备时间序列数据"""  # 输出schema为trade_date索引与一列中文证券名收盘价
    temp = df[['trade_date', 'close']].copy()  # 只复制对齐所需的交易日与收盘价
    temp.rename(columns={'close': stock_name}, inplace=True)  # 用中文证券名作为宽表列名
    temp.set_index('trade_date', inplace=True)  # 建立交易日索引供外连接匹配共同日期
    return temp  # 返回单列交易日—收盘价表
# 合并所有股票的收盘价数据
raw_closing_prices_df = prepare_timeseries_data(ningbo_port_stock_df, '宁波港')  # 以宁波港收盘价初始化合并数据框
raw_closing_prices_df = raw_closing_prices_df.join(prepare_timeseries_data(ningbo_bank_stock_df, '宁波银行'), how='outer')  # 加入宁波银行并保留任一证券存在的日期
raw_closing_prices_df = raw_closing_prices_df.join(prepare_timeseries_data(hengrui_stock_df, '恒瑞医药'), how='outer')  # 加入恒瑞医药并暴露日期错位缺失
raw_closing_prices_df = raw_closing_prices_df.join(prepare_timeseries_data(iflytek_stock_df, '科大讯飞'), how='outer')  # 加入科大讯飞完成四资产面板

# 删除包含缺失值的行以确保连续性
raw_closing_prices_df = raw_closing_prices_df.dropna()      # 排除上游对齐或变换产生的缺失,固定后续统计样本

# 只保留四只证券共同存在的真实交易日期,不用通用工作日补造中国休市日
portfolio_closing_prices_df = raw_closing_prices_df.sort_index().dropna()  # 以共同真实观测形成可比较价格面板

portfolio_closing_prices_df.info()                          # 查看数据基本信息
print('\n数据预览:')  # 标识四资产共同交易日面板的前五行
print(portfolio_closing_prices_df.head())  # 显示前5行合并后的股价数据
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 3708 entries, 2010-09-28 to 2025-12-31
Data columns (total 4 columns):
 #   Column  Non-Null Count  Dtype  
---  ------  --------------  -----  
 0   宁波港     3708 non-null   float64
 1   宁波银行    3708 non-null   float64
 2   恒瑞医药    3708 non-null   float64
 3   科大讯飞    3708 non-null   float64
dtypes: float64(4)
memory usage: 144.8 KB

数据预览:
               宁波港    宁波银行    恒瑞医药    科大讯飞
trade_date                                
2010-09-28  2.5505  4.9686  5.6668  6.0083
2010-09-29  2.4790  4.9515  5.4634  5.9715
2010-09-30  2.5362  5.1224  5.5743  5.8101
2010-10-08  2.5648  5.2463  5.7137  5.6198
2010-10-11  2.6576  5.4769  5.3595  5.4317

rolling 算子的行为与 resamplegroupby 类似。它可以在一个 SeriesDataFrame 上调用,并附带一个窗口(表示为周期数)。

移动平均线的统计学本质与趋势识别

简单移动平均 (Simple Moving Average, SMA) 是时间序列平滑最直观的数学表达。其定义如下:

\[ \text{SMA}_t = \frac{1}{k} \sum_{i=0}^{k-1} P_{t-i} \]

其中 \(k\) 是滑动窗口的大小。对等间隔、无缺失的数值序列,SMA 可视为有限长度移动平均滤波器:它会衰减部分高频成分,同时引入相位滞后和端点缺失。这里的“高频成分”是频率分解术语,不能自动解释为无信息噪声;平滑后剩余的低频形状也不自动等于可预测趋势。

窗口长度只决定纳入多少个历史交易观测,不自动携带经济解释:

  • 较短窗口(5 或 10 个交易观测):对近期变化响应较快,但样本均值也更易受单日波动影响;
  • 中等窗口(20 或 60 个交易观测):响应更平滑、滞后更长,具体长度必须由研究设计预先声明;
  • 较长窗口(250 个交易观测):大致覆盖一年的交易日。价格与 250 日均线的偏离只描述当前价格相对历史窗口均值的位置,不定义牛熊状态或未来方向。

让我们计算并绘制宁波港股价的 250 个交易观测移动平均线。宁波港是长三角地区重要的港口物流企业;下图只描述其价格的历史低频走势,不据此断言外贸景气度对股价的因果影响。

import matplotlib.pyplot as plt  # 叠加宁波港收盘价与250个交易观测移动均线

# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 使用项目中文字体显示证券简称、价格单位和均线标签
plt.rcParams['axes.unicode_minus'] = False  # 让价格坐标中的负号使用字体可识别的字符

# 创建图表
plt.figure(figsize=(12, 6))                                 # 创建新画布

# 绘制股价和移动平均线
portfolio_closing_prices_df['宁波港'].plot(label='前复权收盘价', alpha=0.7)  # 绘制共同交易日样本中的宁波港前复权收盘价,单位为元
portfolio_closing_prices_df['宁波港'].rolling(250).mean().plot(label='250个交易观测移动平均线', linewidth=2)  # 仅在满250个历史收盘观测后输出长期均值,不使用未来价格

plt.title('宁波港 (601018) 股价走势与长期趋势', fontsize=14, fontweight='bold')  # 标题指出证券代码并把250观测均线限定为历史趋势描述
plt.xlabel('日期', fontsize=12)  # 横轴采用实际共同交易日,不插入周末与休市日
plt.ylabel('前复权价格 (元)', fontsize=12)  # 纵轴记录前复权收盘价水平,单位为人民币元
plt.legend(fontsize=11)  # 区分宁波港原始收盘价与250观测移动均线
plt.grid(True, alpha=0.3)  # 辅助读取价格纵轴及均线交叉时点
plt.tight_layout()  # 调整单轴边距,避免日期刻度与价格单位被画布裁切
plt.show()  # 显示元价格与250交易观测均线;图形只描述历史低频走势
图 9.4: 宁波港前复权收盘价与 250 个交易观测移动平均线

表达式 rolling(250) 创建了一个按最近 250 个交易观测滑动的窗口,而不是固定 250 个日历日。如 图 9.4 所示,移动平均线平滑了每日价格波动,只能描述样本中的低频走势;若要把它用于预测,还需时间外检验。对于宁波港这样的港口企业,图形上的长期共变不能单独证明宏观经济或贸易形势造成了价格变化。

默认情况下,滚动函数要求窗口中的所有值都是非NA的。可以更改此行为以考虑缺失数据,特别是考虑到在时间序列开始时,你的数据周期会少于 window 个。min_periods 参数设置了窗口中产生一个值所需的最小观测数。

让我们计算恒瑞医药每日回报率的滚动标准差。这是滚动波动率 (rolling volatility) 的一个度量,是金融风险管理中的一个关键概念。

图 9.5 展示本节讨论对象的可视化结果,读图时应结合正文给出的口径与限制。

hengrui_daily_returns_series = portfolio_closing_prices_df['恒瑞医药'].pct_change(fill_method=None)  # 不填补缺口后计算恒瑞医药相邻共同交易日收益
# A股一年约245个交易日,这里用250近似。将日波动率年化,乘以sqrt(250)
rolling_annual_volatility_series = hengrui_daily_returns_series.rolling(250, min_periods=10).std() * np.sqrt(250)  # 最多取250个历史日收益,早期满10个后输出,并按平方根250年化
rolling_annual_volatility_series.plot()                     # 绘制恒瑞日收益250观测年化标准差路径
plt.title('恒瑞医药250日年化波动率')                                  # 标题明确证券、滚动上限及平方根时间年化口径
plt.xlabel('日期')  # 每个横坐标表示该窗口的信息截止交易日
plt.ylabel('波动率')                                           # 纵轴为年化收益标准差,以比例而非价格计量
plt.grid(True)  # 辅助读取年化波动率水平与变化时点
plt.show()  # 输出恒瑞医药滚动风险曲线
图 9.5: 恒瑞医药250日每日回报率标准差(年化波动率)

可以使用 expanding 算子计算扩展窗口 (expanding window) 均值。扩展均值从序列的开始处启动时间窗口,并增加窗口的大小,直到它包含整个序列。

expanding_mean = rolling_annual_volatility_series.expanding().mean()  # 扩展窗口计算

DataFrame 上调用移动窗口函数会将转换应用于每一列。图 9.6 显示了四只 A 股的 60 天移动平均线。

ax = portfolio_closing_prices_df.rolling(60).mean().plot(logy=True)  # 四只证券各用截至当日最近60个共同交易日平滑价格,并画在同一Axes
ax.set_title('四只A股价格的60日移动平均线(对数坐标)')  # 对数轴垂直距离表示价格倍数而非绝对价差
ax.set_xlabel('日期')  # 横轴保留四只证券都有收盘价的共同交易日
ax.set_ylabel('价格(元)')  # 刻度值仍是人民币价格,轴变换只改变视觉尺度
ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')  # 图例逐列标识四只证券的60日均线
ax.grid(True)  # 对数价格轴网格辅助比较相对倍数
plt.show()  # 输出四资产60日均线对数图
图 9.6: 四只A股价格的60日移动平均线(对数y轴)

rolling 函数也接受一个表示固定大小时间偏移的字符串,而不是一个固定的周期数。这对于不规则的时间序列可能很有用。例如,我们可以像这样计算一个20天的滚动均值:

portfolio_closing_prices_df.rolling('20D').mean().tail()    # 查看后5行数据
宁波港 宁波银行 恒瑞医药 科大讯飞
trade_date
2025-12-25 3.697143 28.122500 61.617143 49.007143
2025-12-26 3.695333 28.114333 61.580000 48.996000
2025-12-29 3.692143 28.143814 61.333571 49.010714
2025-12-30 3.685000 28.178614 61.105000 48.995714
2025-12-31 3.680000 28.221121 60.831429 49.097143

9.8.1 指数加权函数

除了使用具有同等权重观测值的固定窗口大小外,另一种方法是指定一个恒定的衰减因子 (decay factor),以给予更近的观测值更多的权重。指数加权移动平均 (EWMA) 是一种能更快适应近期变化的统计量。

pandasewm 算子。让我们比较一下恒瑞医药股价的30天简单移动平均线(SMA)和一个span为30的EWMA。

hengrui_px_sample = portfolio_closing_prices_df['恒瑞医药']['2012':'2013']  # 截取2012至2013年恒瑞医药股价数据
ma30_sma = hengrui_px_sample.rolling(30, min_periods=20).mean()  # 取截至当日最多30个恒瑞价格,早期至少20个有效值才计算SMA
ewma30_ema = hengrui_px_sample.ewm(span=30).mean()  # 以span=30赋予近期恒瑞价格更高指数权重

hengrui_px_sample.plot(style='k-', label='前复权股价')               # 黑色实线给出2012—2013年恒瑞医药前复权收盘价基准
ma30_sma.plot(style='k--', label='简单移动平均 (SMA)')            # 虚线显示等权历史窗口,窗口上限为30个交易观测
ewma30_ema.plot(style='k-', lw=0.5, label='指数加权移动平均 (EWMA)')  # 细实线显示span=30的指数权重结果,近期价格权重更大
plt.title('恒瑞医药: SMA vs. EWMA (30天跨度)')                     # 标题限定恒瑞2012—2013样本及两种30跨度平滑方法
plt.legend()  # 区分原始价格、30观测SMA和span=30 EWMA
plt.grid(True)  # 辅助比较两种平滑线对价格转折的响应速度
plt.show()  # 输出恒瑞医药SMA与EWMA对照图
图 9.7: 简单移动平均线与指数加权移动平均线的比较

正如你在 图 9.7 中所看到的,EWMA 更“尖锐”,因为它给予近期价格更多的权重,使其反应更灵敏。

9.8.2 二元移动窗口函数

一些统计算子,如相关性和协方差,需要对两个时间序列进行操作。例如,金融分析师通常对一只股票与像上证综合指数这样的基准指数的相关性感兴趣。这与资本资产定价模型(CAPM)中的Beta概念有关。

让我们计算我们的A股和上证综指的每日回报率。

# 使用本地上证综指构造真实基准收益
ssec_index_series = index_data[index_data['symbol'] == '000001.XSHG'].copy()  # 复制上证综指记录,后续设索引不会污染含其他指数的原表
ssec_index_series['datetime'] = pd.to_datetime(ssec_index_series['datetime'].astype(str), format='%Y%m%d%H%M%S')  # 按14位源编码解析上证综指日期
ssec_index_series.set_index('datetime', inplace=True)  # 以指数交易日对齐个股收益窗口
ssec_index_series = ssec_index_series['close'].sort_index()  # 保留指数原始交易日,不补造法定休市日期

ssec_market_returns_series = ssec_index_series.pct_change(fill_method=None)  # 不填补指数缺口后计算相邻交易日市场收益
asset_returns_df = portfolio_closing_prices_df.pct_change(fill_method=None)  # 各证券分别计算且不跨共同日期表中的价格缺口填补

在我们调用 rolling 之后,corr 聚合函数就可以计算与上证综指回报率的滚动相关性。让我们看看恒瑞医药和上证综指之间125天(约6个月)的滚动相关性,如 图 9.8 所示。

rolling_corr_hengrui_ssec = asset_returns_df['恒瑞医药'].rolling(125, min_periods=100).corr(ssec_market_returns_series)  # 截至当日最多使用125对共同收益,至少100对时估计相关系数
rolling_corr_hengrui_ssec.plot()                             # 绘制恒瑞—上证综指125观测收益相关路径
plt.title('恒瑞医药 vs. 上证综指 六个月滚动相关性')                         # 标题将约六个月明确为125个共同收益观测,而非日历半年
plt.xlabel('日期')  # 横坐标是每个后向相关窗口的截止交易日
plt.ylabel('相关性')                                           # 纵轴为无量纲Pearson样本相关系数,范围为-1至1
plt.grid(True)  # 辅助读取相关系数的正负与接近零时点
plt.show()  # 输出单资产滚动相关图
图 9.8: 恒瑞医药与上证综指六个月回报率滚动相关性

假设你想一次性计算上证综指与多只股票的滚动相关性。我们可以通过在 DataFrame 上调用 rolling 并传入 ssec_rets Series 来一次性计算所有的滚动相关性。结果见 图 9.9

rolling_corr_multi_assets = asset_returns_df.rolling(125, min_periods=100).corr(ssec_market_returns_series)  # 对四列分别取至多125对市场共同收益,100对以下保持缺失
rolling_corr_multi_assets.plot()                            # 同图绘制四只证券各自相对上证综指的滚动相关
plt.title('A股组合 vs. 上证综指 六个月滚动相关性')                         # 标题说明每条线均以同一上证综指收益为基准
plt.xlabel('日期')  # 日期标记四个后向125观测窗口的共同截止时点
plt.ylabel('相关性')                                           # 纵轴统一使用无量纲相关系数,便于跨证券比较
plt.legend(title='证券')  # 直接使用四列真实证券名,避免手写图例与数据对象错配
plt.grid(True)  # 辅助比较各证券滚动相关系数的相对水平
plt.show()  # 输出多资产滚动相关对照图
图 9.9: 多支A股与上证综指的六个月回报率滚动相关性

9.8.3 用户定义的移动窗口函数

rolling 上的 apply 方法提供了一种在你自己的移动窗口上应用数组函数的方法。唯一的要求是该函数从数组的每一块中产生一个单一的值(一个归约操作)。

例如,虽然我们可以使用 rolling(...).quantile(q) 计算样本分位数,但我们可能对样本中特定值的百分位排名感兴趣。scipy.stats.percentileofscore 函数正是这样做的。让我们找出恒瑞医药股价2%日回报率的滚动百分位排名,如 图 9.10 所示。

from scipy.stats import percentileofscore                   # 计算固定2%收益在滚动样本中的经验百分位

def score_at_2percent(x):                                   # 把每个250观测窗口归约为2%阈值的0—100排名
    """计算数值 0.02 在序列 x 中的百分位排名。"""  # 函数文档字符串
    return percentileofscore(x, 0.02)  # 返回2%收益在当前窗口中的经验百分位,输出范围为0—100

percentile_rank_series = asset_returns_df['恒瑞医药'].rolling(250).apply(score_at_2percent, raw=True)  # 在每个250观测窗口定位2%日收益的经验百分位
percentile_rank_series.plot()                               # 绘制该百分位随样本窗口移动的路径
plt.title('恒瑞医药 2%日回报率的百分位排名 (250日窗口)')                     # 标题明确固定阈值为2%日收益,经验分布来自250个历史观测
plt.xlabel('日期')  # 每个日期对应一个截至当日的完整250收益窗口
plt.ylabel('百分位排名')                                         # 纵轴为0—100的经验百分位,不是收益率本身
plt.grid(True)  # 辅助读取2%收益在窗口分布中的百分位变化
plt.show()  # 输出恒瑞医药滚动经验百分位图
图 9.10: 恒瑞医药2%日回报率在一年窗口期内的百分位排名

raw=True 参数将底层的 NumPy 数组传递给我们的函数,这样效率更高。

9.9 可选进阶:从索引运算到时间序列模型

本节只建立方法地图,不纳入本章核心考核。使用下列方法前,读者应先掌握概率统计、线性回归和统计推断,并继续学习专门的时间序列课程。

平稳性与单位根。 弱平稳要求有限二阶矩、恒定均值,并要求自协方差只依赖滞后而不依赖日历时点。ADF 检验的原假设是指定模型中存在单位根;确定性项、滞后阶数、结构突变和有限样本功效都会影响结论。“未拒绝”不等于证明存在单位根,“拒绝”也只针对已声明的检验规格。

自相关与白噪声。 在弱平稳条件下,\(\rho_h=\gamma(h)/\gamma(0)\) 描述滞后 \(h\) 的线性相关。样本 ACF 是估计量;Ljung–Box 检验联合检查一组预先选定的滞后。弱白噪声只排除线性自相关,不排除条件异方差;边际正态性检验也不能代替序列相关诊断。

协整与误差修正。 多个非平稳水平序列的高 \(R^2\) 可能来自共同趋势。只有在变量单整阶数、协整秩、确定性项和滞后结构得到支持时,才讨论协整回归、误差修正模型或 VECM;不能因为单个 ADF 结果就机械差分全部变量。

频谱与滤波。 FFT 的频率解释要求清楚的采样间隔,并受有限窗口、缺失记录和频谱泄漏影响;样本最大谱峰不是预测周期的证据。HP 滤波具有参数敏感性和端点偏差,不同频率不能机械套用同一个 \(\lambda\)。这些方法需要独立的 worked example、敏感性分析和时间外检验,因此本章不再把它们塞入核心综合练习。

9.10 习题

先按题号完成题面,再展开对应解答。网页默认折叠完整答案;打印时自动展开,以便教师核对评分证据。每题的“定位—先修—工作量—产出”用于安排课程,不代表所有学生必须在同一课时完成全部任务。

9.10.1 习题 9.1: 时间序列索引与选择

定位与产出:核心基础题;先修为 DatetimeIndex、日期切片和 asof;预计 25—35 分钟;提交索引类型、季度/月末/星期筛选结果与最近有效交易日核验。

问题描述: 从本地 HDF5 文件中读取宁波港 (601018.SH) 和宁波银行 (002142.SZ) 在 2023 年的日度收盘价数据,完成以下任务:

  1. 将日期设置为索引,并确保索引为 DatetimeIndex 类型
  2. 选择 2023 年第一季度的所有数据
  3. 选择每个月的最后一个交易日
  4. 选择所有周三 (Wednesday) 的数据
  5. 使用 asof 方法找到 2023-06-15 最接近的交易日数据
展开习题 9.1 完整解答

9.10.1.1 习题 9.1 完整解答

import pandas as pd  # 用DatetimeIndex完成季度切片、星期筛选和asof回溯查询
import numpy as np  # 保留NumPy数值类型支持;本题不直接调用数组函数
import tables  # 注册HDF5查询后端,使证券代码条件在读取阶段生效

ningbo_bank_stock_df = pd.read_hdf(  # 读取宁波银行行情以演示DatetimeIndex选择
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='002142.XSHE'"  # 仅载入索引练习所需的宁波银行记录
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开日期供季度、星期和asof选择

ningbo_bank_stock_df['date'] = pd.to_datetime(ningbo_bank_stock_df['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析索引选择练习的宁波银行交易日
ningbo_bank_stock_df = ningbo_bank_stock_df[ningbo_bank_stock_df['date'] >= pd.Timestamp('2023-01-01')]  # 筛选2023年及以后的数据
ningbo_bank_stock_df.set_index('date', inplace=True)  # 建立DatetimeIndex供季度切片、星期筛选与asof查询

print('数据类型检查:')  # 输出数据类型检查标题
print(f'索引类型: {type(ningbo_bank_stock_df.index)}')  # 输出索引元数据,核验时间轴类型与名称
print(f'索引名称: {ningbo_bank_stock_df.index.name}')  # 输出索引元数据,核验时间轴类型与名称
数据类型检查:
索引类型: <class 'pandas.core.indexes.datetimes.DatetimeIndex'>
索引名称: date

1. 选择2023年第一季度数据

q1_trade_data = ningbo_bank_stock_df['2023-01':'2023-03']  # 切片2023年第一季度数据
print('\n2023年第一季度数据:')  # 标识随后五行为2023年1月至3月的宁波银行日行情
print(q1_trade_data.head())  # 显示前5行第一季度数据

2023年第一季度数据:
           order_book_id trade_date     open     high      low    close  \
date                                                                      
2023-01-03   002142.XSHE 2023-01-03  29.3540  29.5547  28.7338  29.3814   
2023-01-04   002142.XSHE 2023-01-04  29.6003  30.8774  29.4088  30.5399   
2023-01-05   002142.XSHE 2023-01-05  30.5764  30.9321  30.1020  30.1933   
2023-01-06   002142.XSHE 2023-01-06  30.3392  30.3392  29.2355  29.7645   
2023-01-09   002142.XSHE 2023-01-09  29.8922  30.1020  29.5547  29.9470   

                volume  total_turnover  
date                                    
2023-01-03  27552512.0    8.832415e+08  
2023-01-04  37930714.0    1.265668e+09  
2023-01-05  25570577.0    8.521616e+08  
2023-01-06  39761873.0    1.288696e+09  
2023-01-09  26419619.0    8.642679e+08  

2. 选择每个月的最后一个交易日

last_trading_day_rows = ningbo_bank_stock_df.groupby(ningbo_bank_stock_df.index.to_period('M'), sort=True).tail(1)  # 每个自然月保留最后一条实际交易记录,并维持原交易日索引
assert last_trading_day_rows.index.to_period('M').is_unique  # 核验每个月恰好输出一个真实交易日,而非日历月末伪标签
print('\n每个月的最后一个交易日:')  # 标识随后结果为各自然月最后实际交易日的收盘价
print(last_trading_day_rows[['close']])  # 显示每月最后实际交易日及其收盘价

每个月的最后一个交易日:
              close
date               
2023-01-31  29.9196
2023-02-28  26.9459
2023-03-31  24.9117
2023-04-28  24.9756
2023-05-31  22.4397
2023-06-30  23.0782
2023-07-31  27.1109
2023-08-31  24.3644
2023-09-28  25.0161
2023-10-31  23.2658
2023-11-30  21.3386
2023-12-29  18.7225
2024-01-31  20.0259
2024-02-29  20.7428
2024-03-29  19.2066
2024-04-30  21.3479
2024-05-31  23.0610
2024-06-28  20.5380
2024-07-31  20.5762
2024-08-30  19.3889
2024-09-30  24.6071
2024-10-31  24.4539
2024-11-29  23.6209
2024-12-31  23.2763
2025-01-27  25.2582
2025-02-28  23.2475
2025-03-31  24.7220
2025-04-30  22.8837
2025-05-30  25.5742
2025-06-30  26.1966
2025-07-31  27.5741
2025-08-29  27.9204
2025-09-30  26.1494
2025-10-31  28.0391
2025-11-28  28.0688
2025-12-31  28.0900

3. 选择所有周三的数据

wednesday_trade_data = ningbo_bank_stock_df[ningbo_bank_stock_df.index.dayofweek == 2]  # 仅保留星期三的真实交易观测,不补造休市日期
print('\n所有周三的数据:')  # 标识随后十行为dayofweek等于2的真实交易观测
print(wednesday_trade_data.head(10))  # 显示前10行周三交易数据

所有周三的数据:
           order_book_id trade_date     open     high      low    close  \
date                                                                      
2023-01-04   002142.XSHE 2023-01-04  29.6003  30.8774  29.4088  30.5399   
2023-01-11   002142.XSHE 2023-01-11  29.6459  30.9048  29.5091  30.7406   
2023-01-18   002142.XSHE 2023-01-18  30.8865  31.1875  30.5125  30.7588   
2023-02-01   002142.XSHE 2023-02-01  29.9378  30.2115  29.2811  29.6733   
2023-02-08   002142.XSHE 2023-02-08  28.8980  29.4179  28.6973  28.8250   
2023-02-15   002142.XSHE 2023-02-15  28.3050  28.3780  27.5662  27.7577   
2023-02-22   002142.XSHE 2023-02-22  28.1135  28.2321  27.7760  27.9037   
2023-03-01   002142.XSHE 2023-03-01  26.9459  27.7304  26.7817  27.7121   
2023-03-08   002142.XSHE 2023-03-08  26.0884  26.1888  25.6597  25.9607   
2023-03-15   002142.XSHE 2023-03-15  25.6779  26.1523  25.4043  25.4590   

                volume  total_turnover  
date                                    
2023-01-04  37930714.0    1.265668e+09  
2023-01-11  35349805.0    1.182864e+09  
2023-01-18  18387040.0    6.204899e+08  
2023-02-01  24785890.0    8.053785e+08  
2023-02-08  21922299.0    6.975908e+08  
2023-02-15  27617395.0    8.430439e+08  
2023-02-22  26666729.0    8.171578e+08  
2023-03-01  35993012.0    1.083632e+09  
2023-03-08  47177318.0    1.341394e+09  
2023-03-15  35291211.0    9.957978e+08  

4. 使用asof找到2023-06-15最接近的交易日

target_query_date = pd.Timestamp('2023-06-15')  # 构造“习题 9.1: 时间序列索引与选择”的单一时点,以演示精确标签定位
closest_closing_price = ningbo_bank_stock_df['close'].asof(target_query_date)  # 查找目标日期当天或之前最近的收盘价
closest_actual_trade_date = ningbo_bank_stock_df.index.asof(target_query_date)  # 查找目标日期当天或之前最近的实际交易日

print(f'\n2023-06-15 最接近的交易日: {closest_actual_trade_date}')  # 输出sof查询到的最近交易日
print(f'该交易日收盘价: {closest_closing_price:.2f} 元')  # 报告asof命中的实际交易日收盘价,单位为元

2023-06-15 最接近的交易日: 2023-06-15 00:00:00
该交易日收盘价: 24.20 元

关键要点: - df['2023-01':'2023-03'] 利用 DatetimeIndex 的切片功能选择日期范围 - dayofweek 属性返回星期几 (0=Monday, 6=Sunday) - asof 方法用于查找指定时间点之前的最后一个值

9.10.2 习题 9.2: 时间序列重采样与频率转换

定位与产出:核心基础题;先修为频率标签与降采样聚合;预计 25—35 分钟;提交周/月频结果、OHLC 表和聚合口径说明。

问题描述: 使用宁波银行的股价数据,演示不同的重采样方法:

  1. 将日度数据重采样为周度数据,使用每周最后一个交易日的收盘价
  2. 将日度数据重采样为月度数据,计算每月的 OHLC (开高低收)
  3. 将日度成交量重采样为月度,计算总成交量和平均日成交量
  4. 使用 ohlc 方法获取周度 OHLC 数据
展开习题 9.2 完整解答

9.10.2.1 习题 9.2 完整解答

import pandas as pd  # 按周、月重采样宁波银行收盘价与成交量
import numpy as np  # 保留统一数值环境;本题聚合由pandas完成
import tables  # 启用按宁波银行代码筛选前复权HDF日行情

ningbo_bank_stock_df = pd.read_hdf(  # 银行OHLCV同时支持周末标签的last与月末OHLC聚合
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='002142.XSHE'"  # 只读取002142.XSHE的OHLCV日记录
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开交易日并保留重采样所需close与volume

ningbo_bank_stock_df['date'] = pd.to_datetime(ningbo_bank_stock_df['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析重采样练习的宁波银行交易日
ningbo_bank_stock_df = ningbo_bank_stock_df[ningbo_bank_stock_df['date'] >= pd.Timestamp('2023-01-01')]  # 筛选2023年及以后的数据
ningbo_bank_stock_df.set_index('date', inplace=True)  # 建立DatetimeIndex供周度与月度重采样

# 1. 重采样为周度,使用每周最后交易日收盘价
weekly_closing_prices = ningbo_bank_stock_df['close'].resample('W').last()  # 每个周日标签的桶取该周最后一个实际收盘价
print('周度收盘价 (最后交易日):')  # 标识每周最后交易日收盘价预览
print(weekly_closing_prices.head())  # 抽查周五收盘序列前五行,核验字段、索引与日期顺序

# 2. 重采样为月度OHLC
monthly_ohlc_bars = ningbo_bank_stock_df['close'].resample('ME').ohlc()  # 以日历月末为标签,由月内日收盘序列生成四项OHLC
print('\n月度OHLC:')  # 标识月度收盘价开高低收结果
print(monthly_ohlc_bars.head())  # 显示月度开高低收前5行

# 3. 月度成交量统计
monthly_volume_stats = ningbo_bank_stock_df['volume'].resample('ME').agg(['sum', 'mean'])  # 以日历月末为标签汇总月总量与月内日均量
monthly_volume_stats.columns = ['总成交量', '平均日成交量']  # 重命名列为中文名称
print('\n月度成交量统计:')  # 标识月总成交量与日均成交量结果
print(monthly_volume_stats.head())  # 显示月度成交量汇总统计

# 4. 周度OHLC
weekly_ohlc_bars = ningbo_bank_stock_df['close'].resample('W').ohlc()  # 由每周日收盘序列生成周度open、high、low、close结构
print('\n周度OHLC:')  # 标识周度收盘价开高低收结果
print(weekly_ohlc_bars.head(10))  # 显示前10行周度开高低收数据
周度收盘价 (最后交易日):
date
2023-01-08    29.7645
2023-01-15    30.9868
2023-01-22    30.2936
2023-01-29        NaN
2023-02-05    29.0348
Freq: W-SUN, Name: close, dtype: float64

月度OHLC:
               open     high      low    close
date                                          
2023-01-31  29.3814  31.1967  29.3814  29.9196
2023-02-28  29.6733  29.6733  26.9459  26.9459
2023-03-31  27.7121  28.0770  24.8570  24.9117
2023-04-30  24.8296  26.2891  23.5434  24.9756
2023-05-31  25.4681  26.1158  22.4397  22.4397

月度成交量统计:
                   总成交量        平均日成交量
date                                 
2023-01-31  447477998.0  2.796737e+07
2023-02-28  588636725.0  2.943184e+07
2023-03-31  736430037.0  3.201870e+07
2023-04-30  842491477.0  4.434166e+07
2023-05-31  603914997.0  3.019575e+07

周度OHLC:
               open     high      low    close
date                                          
2023-01-08  29.3814  30.5399  29.3814  29.7645
2023-01-15  29.9470  30.9868  29.6551  30.9868
2023-01-22  31.1967  31.1967  30.2936  30.2936
2023-01-29      NaN      NaN      NaN      NaN
2023-02-05  30.6676  30.6676  29.0348  29.0348
2023-02-12  28.3780  28.8250  28.3780  28.6517
2023-02-19  27.8854  28.3689  27.4020  27.4020
2023-02-26  28.7246  28.7246  27.3290  27.3290
2023-03-05  27.0006  28.0770  26.9459  28.0770
2023-03-12  26.9824  26.9824  25.3040  25.3040

关键要点: - resample('W') 按周重采样,默认周日为一周结束 - ohlc() 方法一次性计算开高低收四个价格 - agg() 方法可以应用多个聚合函数

9.10.3 习题 9.3: 移动窗口函数

定位与产出:核心进阶题;先修为后向窗口、缺失边界和收益率;预计 40—55 分钟;提交均线、滚动波动率、价格区间位置图及窗口解释。

问题描述: 对宁波港股价数据计算各种滚动统计:

  1. 计算 5日、20日、60日移动平均线
  2. 计算描述性布林带 (Bollinger Bands): 20日滚动均值 ± 2倍滚动样本标准差
  3. 计算 20日滚动波动率 (年化)
  4. 计算价格相对位置: (收盘价 - 20日最低价) / (20日最高价 - 20日最低价)
展开习题 9.3 完整解答

9.10.3.1 习题 9.3 完整解答

import pandas as pd  # 计算宁波港5/20/60观测均线与20观测滚动区间
import numpy as np  # 用平方根252把日收益标准差转换为年化波动率
import tables  # 在HDF读取时仅提取宁波港前复权日行情
import matplotlib.pyplot as plt  # 分三轴展示价格均线、波动率和区间位置

# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 显示宁波港证券名、价格与波动率中文标签
plt.rcParams['axes.unicode_minus'] = False  # 让收益与波动率刻度中的负号正常显示

nbz_df = pd.read_hdf(  # 读取宁波港前复权日线,供均线、波动率和区间位置三轴共用
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='601018.XSHG'"  # HDF层只读取宁波港移动窗口练习所需记录
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 恢复交易日列并把成交量字段统一为volume
nbz_df['date'] = pd.to_datetime(nbz_df['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析宁波港移动窗口练习的交易日
nbz_df = nbz_df[(nbz_df['date'] >= pd.Timestamp('2023-01-01')) &  # 移动窗口示例从2023年首个自然日开始截取宁波港样本
                (nbz_df['date'] <= pd.Timestamp('2023-12-31'))]  # 取宁波港2023自然年完整样本,末日上界不跨入2024年
nbz_df.set_index('date', inplace=True)  # 以date为索引,保证5/20/60观测窗口按宁波港交易日滚动

1. 计算移动平均线

nbz_df['MA5'] = nbz_df['close'].rolling(5).mean()           # 用当日及此前四个收盘价计算短期均线,前四行保持缺失
nbz_df['MA20'] = nbz_df['close'].rolling(20).mean()  # 用最近20个交易观测计算nbz_df['MA20']的均值,窗口未满时保留缺失
nbz_df['MA60'] = nbz_df['close'].rolling(60).mean()  # 用截至当日的60个交易观测刻画较慢趋势,样本初段不输出

2. 计算布林带

nbz_df['BB_middle'] = nbz_df['close'].rolling(20).mean()  # 用最近20个交易观测计算nbz_df['BB_middle']的均值,窗口未满时保留缺失
nbz_df['BB_std'] = nbz_df['close'].rolling(20).std()  # 用最近20个交易观测计算nbz_df['BB_std']的样本标准差,窗口未满时保留缺失
nbz_df['BB_upper'] = nbz_df['BB_middle'] + 2 * nbz_df['BB_std']  # 计算布林带上轨(均线+2倍标准差)
nbz_df['BB_lower'] = nbz_df['BB_middle'] - 2 * nbz_df['BB_std']  # 计算布林带下轨(均线-2倍标准差)
valid_bollinger_df = nbz_df[['BB_middle', 'BB_upper', 'BB_lower']].dropna()  # 仅在满20个交易观测后核验包络的代数对称性
np.testing.assert_allclose(valid_bollinger_df['BB_upper'] - valid_bollinger_df['BB_middle'], valid_bollinger_df['BB_middle'] - valid_bollinger_df['BB_lower'])  # 上下轨对同一中轨按构造完全对称

3. 计算20日滚动波动率 (年化)

nbz_df['returns'] = nbz_df['close'].pct_change(fill_method=None)  # 不填补缺口后计算宁波港相邻交易日收益,首期无前值
nbz_df['volatility_20d'] = nbz_df['returns'].rolling(20).std() * np.sqrt(252)  # 用最近20个交易观测计算nbz_df['volatility_20d']的样本标准差,窗口未满时保留缺失

4. 计算价格相对位置 (0-100之间)

nbz_df['rolling_max'] = nbz_df['close'].rolling(20).max()  # 用最近20个交易观测计算nbz_df['rolling_max']的最高价,窗口未满时保留缺失
nbz_df['rolling_min'] = nbz_df['close'].rolling(20).min()  # 用最近20个交易观测计算nbz_df['rolling_min']的最低价,窗口未满时保留缺失
# 将当日收盘映射到最近20个交易观测的最低价—最高价区间,并换算为百分数
nbz_df['price_position'] = (nbz_df['close'] - nbz_df['rolling_min']) / \
                           (nbz_df['rolling_max'] - nbz_df['rolling_min']) * 100  # 计算价格在区间中的位置百分比

# 打印统计信息
print('技术指标统计:')  # 标识价格、均线、年化波动率与区间位置的描述统计
print(nbz_df[['close', 'MA5', 'MA20', 'MA60', 'volatility_20d', 'price_position']].describe())  # 显示技术指标的描述性统计

# 为宁波港价格、年化波动率和区间位置分别设置元、比例和百分位轴
fig, axes = plt.subplots(3, 1, figsize=(14, 10))  # 三轴从上到下分别承载元价格、年化波动率和0—100区间位置

# 子图1: 价格与移动平均线
nbz_df['close'].plot(ax=axes[0], label='收盘价', alpha=0.7)  # 宁波港前复权收盘价是同轴三条移动均线的原始价格基准
nbz_df['MA5'].plot(ax=axes[0], label='MA5', alpha=0.7)      # 最快均线从第五个交易观测起反映短期价格水平
nbz_df['MA20'].plot(ax=axes[0], label='MA20', alpha=0.7)    # 中速均线用约一个交易月的历史收盘价
nbz_df['MA60'].plot(ax=axes[0], label='MA60', alpha=0.7)    # 慢速均线用约一个交易季度的历史收盘价
axes[0].fill_between(nbz_df.index, nbz_df['BB_lower'], nbz_df['BB_upper'], alpha=0.2, label='20日滚动均值±2倍样本标准差')  # 阴影只表示历史价格的描述性滚动包络
axes[0].set_title('宁波港 (601018.SH) - 价格与移动平均线', fontsize=12, fontweight='bold')  # 标题明确证券及5/20/60观测平滑线与原始价格同轴
axes[0].set_ylabel('价格 (元)')  # 将子图1: 价格与移动平均线的首面板纵轴标为价格(元)
axes[0].legend()  # 图例区分收盘价与三条不同信息长度的均线
axes[0].grid(True, alpha=0.3)  # 价格网格辅助读取均线交叉对应的人民币价位

# 子图2: 波动率
nbz_df['volatility_20d'].plot(ax=axes[1], color='darkred')  # 绘制20收益观测标准差乘平方根252后的历史路径
axes[1].set_title('20日滚动波动率 (年化)', fontsize=12, fontweight='bold')  # 标题说明窗口按20个收益观测而非20个自然日推进
axes[1].set_ylabel('波动率')                                   # 纵轴为无量纲年化收益标准差
axes[1].grid(True, alpha=0.3)  # 横向参照便于比较不同时点的年化风险水平
技术指标统计:
            close         MA5        MA20        MA60  volatility_20d  \
count  242.000000  238.000000  223.000000  183.000000      222.000000   
mean     3.304329    3.303999    3.301285    3.293293        0.120278   
std      0.090314    0.087952    0.078619    0.059291        0.024511   
min      3.057800    3.095400    3.183375    3.215515        0.074273   
25%      3.237225    3.236520    3.230722    3.243350        0.099884   
50%      3.285200    3.279790    3.287810    3.276002        0.122823   
75%      3.367800    3.364140    3.361627    3.350618        0.135859   
max      3.551400    3.516500    3.466480    3.394745        0.165753   

       price_position  
count      223.000000  
mean        53.216255  
std         36.998510  
min          0.000000  
25%         14.274867  
50%         58.281665  
75%         87.465940  
max        100.000000  

# 子图3: 价格相对位置
nbz_df['price_position'].plot(ax=axes[2], color='darkgreen')  # 绘制收盘价在最近20观测最低—最高区间中的百分比位置
axes[2].axhline(y=80, color='red', linestyle='--', alpha=0.5, label='高位参考线 (80)')  # 80线标识价格位于窗口振幅上部五分之一
axes[2].axhline(y=20, color='green', linestyle='--', alpha=0.5, label='低位参考线 (20)')  # 20线标识价格落入窗口振幅下部五分之一
axes[2].set_title('价格相对位置 (20日窗口)', fontsize=12, fontweight='bold')  # 标题限定比较基准为当前20观测最高价和最低价
axes[2].set_ylabel('相对位置 (%)')                              # 纵轴范围解释为0—100百分位位置,不是收益百分比
axes[2].set_ylim(0, 100)  # 将子图3: 价格相对位置面板纵轴限定为0, 100
axes[2].legend()  # 图例说明两条虚线分别代表20与80位置阈值
axes[2].grid(True, alpha=0.3)  # 百分位网格便于判断价格靠近窗口上沿还是下沿

plt.tight_layout()  # 调整三层面板间距,保留各自标题和最下方日期刻度
plt.show()  # 显示宁波港2023年价格、风险与区间位置的三层诊断图
<Figure size 672x480 with 0 Axes>

描述性滚动价格包络及其边界

本题的布林带对每个时点 \(t\) 使用同一组 20 个历史收盘价,以滚动均值 \(\bar P_{t,20}\) 为中轨,以 pandas 默认 ddof=1 的滚动样本标准差 \(s_{t,20}\) 刻画窗口内历史价格的离散程度:

\[ L_t=\bar P_{t,20}-2s_{t,20},\qquad M_t=\bar P_{t,20},\qquad U_t=\bar P_{t,20}+2s_{t,20}. \tag{9.1}\]

这是描述性滚动价格包络,不是总体均值的置信区间,也不是下一期价格的预测区间。重叠窗口、参数由当期样本估计以及价格水平通常非平稳,都使得固定正态分布的概率无法直接解释为历史或未来覆盖率。中轨仅是历史滚动均值,不是“均衡价值”的估计。由 式 9.1 可见,\(U_t-M_t=M_t-L_t=2s_{t,20}\),上下轨的带宽按构造对称。

关键要点:

  • 带宽 \(U_t-L_t=4s_{t,20}\) 只描述最近20个价格观测的样本离散程度。
  • 价格在20观测最低—最高区间中的位置是历史描述,不自动构成交易决策。
  • 若要声称带宽收窄或价格穿轨可预测突破,必须事先定义信号、未来窗口、基准和交易成本,并在时间外样本中验证;本题不作该类预测主张。

9.10.4 习题 9.4: 指数加权移动平均 (EWMA)

定位与产出:核心进阶题;先修为 SMA、ewm 参数和右对齐信息边界;预计 35—45 分钟;提交 SMA/EWMA 对比、冲击日响应和参数敏感性解释。

问题描述: 比较宁波银行股价的简单移动平均 (SMA) 和指数加权移动平均 (EWMA):

  1. 计算 30日 SMA 和 span=30 的 EWMA
  2. 计算 EWMA 的权重分布,展示其指数衰减特性
  3. 在突发价格变化时,比较 SMA 和 EWMA 的反应速度
  4. 使用不同的 span 参数 (10, 30, 60) 观察 EWMA 的平滑效果
展开习题 9.4 完整解答

9.10.4.1 习题 9.4 完整解答

import pandas as pd  # 计算宁波银行SMA及不同span的指数加权均值
import numpy as np  # 支持价格收益数值运算与极值日期识别
import tables  # 从HDF表只读取宁波银行前复权收盘价
import matplotlib.pyplot as plt  # 对照等权窗口和三种指数衰减路径

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 显示宁波银行与平滑方法的中文图例
plt.rcParams['axes.unicode_minus'] = False  # 保留价格变化为负时的标准负号字形

nbz_df = pd.read_hdf(  # 读取宁波银行前复权收盘价,比较等权与指数衰减平滑
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='002142.XSHE'"  # 在存储层限定宁波银行EWMA练习的证券样本
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开日期索引;尽管只用close,仍保持行情字段合同一致
nbz_df['date'] = pd.to_datetime(nbz_df['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析宁波银行EWMA练习的交易日
nbz_df = nbz_df[['date', 'close']]  # 只保留日期和收盘价两列
nbz_df = nbz_df[(nbz_df['date'] >= pd.Timestamp('2023-01-01')) &  # 筛选2023年上半年数据
                (nbz_df['date'] <= pd.Timestamp('2023-06-30'))]  # 将宁波银行样本限定为2023年上半年,后续平滑不读取七月信息
nbz_df.set_index('date', inplace=True)  # 以date为索引,对齐宁波银行SMA与EWMA的2023年上半年路径

1. 计算SMA和EWMA

nbz_df['SMA_30'] = nbz_df['close'].rolling(30).mean()       # 等权平均当日及此前29个收盘价,前29个观测保持缺失
nbz_df['EWMA_30'] = nbz_df['close'].ewm(span=30).mean()  # span=30对应alpha=2/31,历史价格权重按期数指数衰减

2. 计算EWMA权重分布

span = 30                                                   # 设定EWMA窗口跨度为30期
alpha = 2 / (span + 1)  # 计算EWMA衰减因子alpha
weights = [(1 - alpha) ** i for i in range(30)]             # 第i个历史观测权重按(1-alpha)^i递减,最新一期i为0
weights_normalized = [w * alpha for w in weights]           # 归一化权重使总和趋近1

print('EWMA 权重分布 (span=30):')  # 输出权重分布标题
print(f'衰减因子 alpha: {alpha:.4f}')  # 报告span=30对应的单期指数衰减因子
print(f'最近1期权重: {weights_normalized[0]:.4f}')  # 报告最新价格观测在EWMA中的权重
print(f'最近5期权重和: {sum(weights_normalized[:5]):.4f}')  # 报告span=30下最新一周左右观测的累计权重
print(f'最近10期权重和: {sum(weights_normalized[:10]):.4f}')  # 报告最新十个交易观测吸收的权重份额
print(f'最近20期权重和: {sum(weights_normalized[:20]):.4f}')  # 报告约一个交易月历史价格的累计权重
EWMA 权重分布 (span=30):
衰减因子 alpha: 0.0645
最近1期权重: 0.0645
最近5期权重和: 0.2836
最近10期权重和: 0.4867
最近20期权重和: 0.7365

3. 在价格突变时比较反应速度

# 以绝对日收益最大值定位样本内冲击日,再比较两种平滑器的响应
returns = nbz_df['close'].pct_change(fill_method=None)  # 不填补缺口后计算相邻交易观测收益,首期无前值
large_change_date = returns.abs().idxmax()                  # 获取最大值的索引
print(f'\n最大单日波动日期: {large_change_date}')  # 标识样本内绝对日收益最大的交易日
print(f'当日涨跌幅: {returns.loc[large_change_date]:.2%}')       # 按标签选取数据

# 计算突变前后的SMA和EWMA变化
window_data = nbz_df.loc[large_change_date - pd.Timedelta(days=10):  # 选取突变日前10天至
                         large_change_date + pd.Timedelta(days=10)]  # 突变日后10天的斜察窗口
print('\n价格突变前后的SMA和EWMA对比:')  # 标识极端波动日前后10个自然日的平滑结果对比
print(window_data[['close', 'SMA_30', 'EWMA_30']].tail())  # 显示突变前后SMA与EWMA的对比数据

最大单日波动日期: 2023-06-02 00:00:00
当日涨跌幅: 4.87%

价格突变前后的SMA和EWMA对比:
              close     SMA_30    EWMA_30
date                                     
2023-06-06  23.8992  24.210857  24.117074
2023-06-07  24.1272  24.192307  24.117728
2023-06-08  24.4921  24.223930  24.141905
2023-06-09  24.4100  24.228187  24.159217
2023-06-12  24.3553  24.241567  24.171878

4. 不同span参数的EWMA比较

nbz_df['EWMA_10'] = nbz_df['close'].ewm(span=10).mean()  # 较大的alpha使该曲线最快响应近期宁波银行价格
nbz_df['EWMA_60'] = nbz_df['close'].ewm(span=60).mean()  # 较小的alpha保留更长历史记忆并产生最平滑路径

# 为宁波银行价格平滑与EWMA差值分别建立元单位面板
fig, axes = plt.subplots(2, 1, figsize=(14, 10))  # 上轴比较30观测等权与指数权重,下轴比较三种指数衰减速度

# 子图1: SMA vs EWMA
nbz_df['close'].plot(ax=axes[0], label='收盘价', alpha=0.6, linewidth=1)  # 用宁波银行前复权收盘价比较SMA与EWMA的滞后差异
nbz_df['SMA_30'].plot(ax=axes[0], label='SMA(30)', linewidth=2)  # 实线给出最近30个收盘价的等权平均,起始段因窗口不足为空
nbz_df['EWMA_30'].plot(ax=axes[0], label='EWMA(span=30)', linewidth=2, linestyle='--')  # 虚线使用同一价格但给予近期观测更高权重
axes[0].set_title('宁波银行 (002142.SZ) - SMA vs. EWMA', fontsize=12, fontweight='bold')  # 标题限定宁波银行上半年样本及两种30跨度平滑定义
axes[0].set_ylabel('价格 (元)')  # 将子图1: SMA vs EWMA的首面板纵轴标为价格(元)
axes[0].legend()  # 图例用SMA与EWMA区分等权和指数衰减估计
axes[0].grid(True, alpha=0.3)  # 价格网格帮助读取两种平滑器在转折点的滞后差异

# 标记最大波动日
axes[0].axvline(x=large_change_date, color='red', linestyle=':', alpha=0.7)  # 添加垂直参考线
axes[0].text(large_change_date, nbz_df['close'].max() * 0.95,  # 在标记最大波动日图层标注close
             f'最大波动日\n{large_change_date.strftime("%Y-%m-%d")}',  # 标注最大波动日的日期文本
             fontsize=9, ha='center')  # 设置字体大小和水平居中对齐

# 子图2: 不同span的EWMA
nbz_df['close'].plot(ax=axes[1], label='收盘价', alpha=0.5, linewidth=1, color='gray')  # 宁波银行收盘价作为span=10/30/60三条EWMA的共同对照
nbz_df['EWMA_10'].plot(ax=axes[1], label='EWMA(span=10)', linewidth=1.5)  # span=10曲线主要反映最近价格,响应速度最快
nbz_df['EWMA_30'].plot(ax=axes[1], label='EWMA(span=30)', linewidth=1.5)  # span=30曲线提供居中的衰减速度参照
nbz_df['EWMA_60'].plot(ax=axes[1], label='EWMA(span=60)', linewidth=1.5)  # span=60曲线保留更久历史信息,响应最慢
axes[1].set_title('不同 span 参数的 EWMA 比较', fontsize=12, fontweight='bold')  # 标题强调比较对象是权重衰减速度而非三种证券
axes[1].set_ylabel('价格 (元)')  # 三条曲线均为平滑后的宁波银行收盘价,单位为元
axes[1].legend()  # 图例将10、30、60三个span与相应曲线配对
axes[1].grid(True, alpha=0.3)  # 同一价格网格便于观察span增大带来的平滑与滞后

plt.tight_layout()  # 分隔两组平滑比较,避免上轴日期标签遮住下轴标题
plt.show()  # 显示宁波银行SMA/EWMA及不同span响应速度对照

指数加权模型的记忆效应与动态平滑因子

EWMA 相对于 SMA 的核心优势在于其递归记忆性时滞缓解能力。

递推公式的深度解读\[ \text{EWMA}_t = \alpha \cdot P_t + (1-\alpha) \cdot \text{EWMA}_{t-1} \]

  1. 记忆性质:虽然 \(\alpha(1-\alpha)^i\) 随滞后阶数 \(i\) 增加而衰减,但理论上 EWMA 包含了从初始时刻起的所有历史信息,只是历史信息的权重极低。
  2. 平滑因子 \(\alpha\) 的权衡
    • \(\alpha\) 越大 (或 \(\text{span}\) 越小):模型对最新价格更敏感,但容易受到“市场噪音”干扰(即所谓信号的高频波动)。
    • \(\alpha\) 越小:平滑度更高,但趋势确认的延迟时间较长。
  3. 与风险模型的区别:RiskMetrics 类方法把指数衰减权重用于平方收益或协方差更新;本题对价格水平做 EWMA,只比较平滑器的历史响应,不能直接称为波动率模型,也不能由平滑价格图证明波动聚集。

关键要点: - EWMA 对近期价格变化反应更快 - span 越小,反应越灵敏但波动越大 - 将 EWMA 用于波动率时,输入、衰减参数、估计目标和时间外评价都需要重新定义

9.10.5 习题 9.5:高级方法的适用条件(选修,不纳入核心考核)

定位与产出:选修设计题;先修为 小节 9.9 和统计检验基本概念;预计 20—30 分钟;提交三类方法的适用条件、不能推出的结论和所需补充验证,不运行模型。

本题采用方法设计而非混合执行:阅读 小节 9.9 后,列出频谱分析所需的采样与窗口条件;解释“未拒绝白噪声原假设”为何不是模型正确的证明;说明 HP 参数敏感性和端点偏差为何阻止其趋势项直接成为交易规则。这样可以把每种方法的识别条件与结论边界作为评价对象,避免把样本内分解误读为预测证据。本题不要求运行模型或提交经验结论。

展开习题 9.5 完整解答

9.10.5.1 习题 9.5 完整解答

  1. 频谱分析条件:必须声明等间隔采样频率、缺失和停牌处理、去均值或去趋势规则、窗口长度以及窗函数。有限窗口会产生谱泄漏,最高样本峰只表示该规格下的周期成分候选,不能直接解释为稳定周期或预测信号。
  2. 白噪声检验边界:Ljung–Box 等检验针对预先声明的一组滞后和线性自相关原假设。“未拒绝”可能来自样本功效不足、滞后选择或相关形式不在检验范围内;它既不证明模型正确,也不排除条件异方差、非线性依赖或结构变化。
  3. HP 滤波边界:平滑参数决定趋势曲率,频率改变时不能机械复用同一参数;样本末端缺少未来观测,趋势估计存在端点偏差且会随新增数据修订。把样本内趋势或缺口直接转成交易规则以前,至少要冻结参数和信息集,说明实时可得版本,并执行含成本的时间外评价。

9.10.6 习题 9.6: 滚动相关性分析与 Beta 计算

定位与产出:核心应用题;先修为共同日期对齐、滚动协方差和原始收益市场模型;预计 55—75 分钟;提交两条滚动 Beta、一条滚动相关、共同样本断言和模型口径辨析。

问题描述: 分析宁波港、宁波银行与市场基准 (上证综指) 的关系:

  1. 读取两只股票和上证综指的日度数据
  2. 计算日收益率
  3. 计算每只股票相对上证综指的滚动 Beta (125天窗口)
  4. 计算股票之间的滚动相关性
  5. 分析 Beta 的时变特征
展开习题 9.6 完整解答

9.10.6.1 习题 9.6 完整解答

import pandas as pd  # 对齐两只股票与上证综指的真实共同收益日期
import numpy as np  # 支持滚动协方差、方差结果的数值检查
import tables  # 分别按证券代码读取宁波港与宁波银行HDF行情
import matplotlib.pyplot as plt  # 展示两条Beta、一条股票相关和收益散点

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 显示两只证券名、市场Beta和相关系数中文标签
plt.rcParams['axes.unicode_minus'] = False  # 允许负Beta、负相关和负收益使用标准负号

ningbo_port_stock_df = pd.read_hdf(  # 载入宁波港个股价格,作为滚动Beta与双股相关分析的第一条序列
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='601018.XSHG'"  # 市场模型的第一条个股序列限定为宁波港
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 恢复宁波港日期列,随后只保留date与close

ningbo_port_stock_df['date'] = pd.to_datetime(ningbo_port_stock_df['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析宁波港日期,以便与市场基准取共同交易日
ningbo_port_ready_df = ningbo_port_stock_df[['date', 'close']].rename(columns={'close': '601018.SH'})  # 将宁波港收盘列命名为展示代码,合并后可直接选择其收益

# 读取宁波银行
ningbo_bank_stock_df = pd.read_hdf(  # 读取宁波银行行情以估计相对市场的滚动Beta
    PRE_ADJUSTED_PRICE_PATH,
    where="order_book_id='002142.XSHE'"  # 将银行证券条件下推到HDF读取层
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开日期供上证综指共同样本对齐

ningbo_bank_stock_df['date'] = pd.to_datetime(ningbo_bank_stock_df['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析宁波银行日期,以便与市场基准取共同交易日
ningbo_bank_ready_df = ningbo_bank_stock_df[['date', 'close']].rename(columns={'close': '002142.SZ'})  # 将宁波银行价格列标为深交所展示代码,避免与宁波港价格列重名

# 市场基准使用上证综指close列,并与两只股票取真实共同交易日
index_data = index_data_all.copy()  # 复用章级只读指数基表,避免综合题再次全盘读取 fixed HDF5
ssec_df = index_data[index_data['symbol'] == '000001.XSHG'][['datetime', 'close']].copy()  # 复制`ssec_df`作为“ex-ts-9-6-solution”的派生样本,避免修改上游原始表
ssec_df = ssec_df.rename(columns={'datetime': 'date', 'close': '000001.SH'})  # 统一指数日期键并用000001.SH标识市场基准点位
ssec_df['date'] = pd.to_datetime(ssec_df['date'], format='%Y%m%d%H%M%S')  # 按%Y%m%d%H%M%S解析ssec_df['date'],避免日期推断歧义
# 分别按各资产真实相邻观测计算收益,避免填充价格制造人为零收益
price_frames_list = [ningbo_port_ready_df, ningbo_bank_ready_df, ssec_df]  # 汇总两只股票与指数的原始价格表
return_frames_list = []                                    # 收集各序列独立计算的收益率
for price_frame_df in price_frames_list:                   # 逐资产保留其自身真实交易时点
    ordered_price_df = (                                   # 去重并建立单调日期索引
        price_frame_df.drop_duplicates('date')
        .set_index('date')
        .sort_index()
    )
    individual_return_df = ordered_price_df.pct_change(fill_method=None)  # 只连接该资产相邻真实价格
    return_frames_list.append(individual_return_df)        # 保存独立收益序列等待共同日期对齐

# 内连接仅保留三条收益序列都实际观察到的日期
returns = pd.concat(return_frames_list, axis='columns', join='inner').dropna().loc['2023']  # 形成市场模型共同有效样本
print('共同有效收益日期预览:')  # 展示对齐后的真实观测日期和三列收益
print(returns.head())  # 核对没有由前向填充产生的零收益行
共同有效收益日期预览:
            601018.SH  002142.SZ  000001.SH
date                                       
2023-01-03  -0.002770  -0.007395   0.008822
2023-01-04   0.005586   0.039430   0.002248
2023-01-05  -0.002793  -0.011349   0.010149
2023-01-06  -0.005570  -0.014202   0.000767
2023-01-09   0.000000   0.006131   0.005842

2. 计算日收益率

print('\n收益率统计:')  # 汇总共同日期样本的分布与有效观测数
print(returns.describe())  # 检查每列样本量一致且收益口径可比较

收益率统计:
        601018.SH   002142.SZ   000001.SH
count  242.000000  242.000000  242.000000
mean     0.000109   -0.001732   -0.000129
std      0.007591    0.017942    0.007294
min     -0.026119   -0.055661   -0.020068
25%     -0.005434   -0.011401   -0.004798
50%      0.000000   -0.003671   -0.000181
75%      0.005566    0.007257    0.004870
max      0.022126    0.087864    0.021289

3. 计算滚动 Beta

def calculate_beta(stock_returns, market_returns, window=125):  # 用同一后向窗口的股票—市场协方差除以市场方差
    """计算滚动 Beta"""  # 函数说明: 基于滚动窗口估计Beta系数
    covariance = stock_returns.rolling(window).cov(market_returns)  # 滚动计算个股与市场收益的协方差
    market_variance = market_returns.rolling(window).var()  # 滚动计算市场收益的方差
    beta = covariance / market_variance  # 用同一滚动窗口的协方差与市场方差估计样本 Beta
    return beta  # 返回与股票收益索引对齐的滚动协方差/市场方差比值序列

# 计算每只股票的Beta
market_returns = returns['000001.SH']  # 提取上证综指收益率作为市场基准
betas = pd.DataFrame()                                      # 创建空数据框存放滚动Beta结果
betas['宁波港 (601018)'] = calculate_beta(returns['601018.SH'], market_returns)  # 计算宁波港相对市场的滚动Beta
betas['宁波银行 (002142)'] = calculate_beta(returns['002142.SZ'], market_returns)  # 计算宁波银行相对市场的滚动Beta

print('\nBeta 统计:')  # 标识两只股票滚动Beta的描述统计表
print(betas.describe())  # 输出两列Beta的有效窗口数、均值、分位数和极值

Beta 统计:
       宁波港 (601018)  宁波银行 (002142)
count    118.000000     118.000000
mean       0.552946       1.294034
std        0.031655       0.111813
min        0.477085       1.104905
25%        0.535518       1.175595
50%        0.561501       1.321608
75%        0.575011       1.399497
max        0.616612       1.458082

4. 计算股票间滚动相关性

correlation = returns['002142.SZ'].rolling(125).corr(returns['601018.SH'])  # 满125对共同日收益后计算两只股票的后向相关系数

5. 分析Beta的时变特征

print('\nBeta 时变特征分析:')  # 标识逐证券Beta均值、离散度和范围
for stock in betas.columns:  # 遍历betas.columns中的每个元素
    beta_mean = betas[stock].mean()                         # 计算该股票Beta的时间序列均值
    beta_std = betas[stock].std()                           # 计算Beta时间序列的标准差(衡量波动)
    beta_min = betas[stock].min()                           # 获取Beta最小值(最防御时刻)
    beta_max = betas[stock].max()                           # 获取Beta最大值(最激进时刻)
    print(f'\n{stock}:')  # 标识当前证券的滚动Beta时间序列摘要
    print(f'  平均Beta: {beta_mean:.2f}')  # 报告该证券滚动Beta的时间均值
    print(f'  Beta标准差: {beta_std:.2f}')  # 报告该证券滚动Beta的时间标准差
    print(f'  Beta范围: [{beta_min:.2f}, {beta_max:.2f}]')  # 报告该证券滚动Beta的样本最小值与最大值

# 为滚动相关系数和市场模型Beta分别建立无量纲纵轴
fig, axes = plt.subplots(3, 1, figsize=(14, 12))  # 三轴依次展示无量纲Beta、股票间相关及宁波银行—市场收益散点

# 子图1: 滚动Beta
betas['宁波港 (601018)'].plot(ax=axes[0], label='宁波港', linewidth=2)  # 绘制宁波港相对上证综指的125共同收益窗口Beta
betas['宁波银行 (002142)'].plot(ax=axes[0], label='宁波银行', linewidth=2)  # 绘制宁波银行使用同一市场序列和窗口定义的Beta
axes[0].axhline(y=1, color='red', linestyle='--', alpha=0.5, label='市场Beta (=1)')  # Beta=1虚线代表与市场收益协方差等于市场方差
axes[0].set_title('滚动Beta (125天窗口)', fontsize=12, fontweight='bold')  # 标题中的125指共同交易收益观测,不是125个自然日
axes[0].set_ylabel('Beta值')                                 # 纵轴为无量纲市场敏感度,未做年化
axes[0].legend()  # 图例将两条证券Beta与市场基准线分别标识
axes[0].grid(True, alpha=0.3)  # 网格便于读取Beta高于、低于或穿越1的时段

# 子图2: 滚动相关性
correlation.plot(ax=axes[1], color='darkgreen', linewidth=2)  # 展示两只股票125对共同日收益的后向Pearson相关
axes[1].axhline(y=0, color='black', linestyle='-', linewidth=0.5)  # 零线区分同向与反向线性共变窗口
axes[1].set_title('宁波港与宁波银行滚动相关性 (125天窗口)', fontsize=12, fontweight='bold')  # 标题明确相关对象是两只股票而非各自与指数
axes[1].set_ylabel('相关系数')                                  # 纵轴是-1至1的无量纲样本相关系数
axes[1].grid(True, alpha=0.3)  # 参照网格辅助比较相关强度及过零时点

Beta 时变特征分析:

宁波港 (601018):
  平均Beta: 0.55
  Beta标准差: 0.03
  Beta范围: [0.48, 0.62]

宁波银行 (002142):
  平均Beta: 1.29
  Beta标准差: 0.11
  Beta范围: [1.10, 1.46]

# 子图3展示宁波银行原始收益的市场模型回归线
market_model_sample_df = returns[['002142.SZ', '000001.SH']].dropna()  # 用同一组完整日期对齐个股与市场收益
stock_return_series = market_model_sample_df['002142.SZ']  # 取宁波银行原始日收益率
market_return_series = market_model_sample_df['000001.SH']  # 取上证综指原始日收益率
market_beta = stock_return_series.cov(market_return_series) / market_return_series.var(ddof=1)  # 协方差与方差统一采用样本自由度
market_model_intercept = stock_return_series.mean() - market_beta * market_return_series.mean()  # 计算原始收益市场模型截距

axes[2].scatter(market_return_series, stock_return_series, alpha=0.5, label='日收益率')  # 绘制对齐后的样本散点
x_line = np.linspace(market_return_series.min(), market_return_series.max(), 100)  # 覆盖市场收益的样本取值范围
y_line = market_model_intercept + market_beta * x_line  # 根据原始收益市场模型计算拟合值
axes[2].plot(x_line, y_line, 'r-', linewidth=2,             # 绘制宁波银行原始收益对市场收益的拟合线
             label=f'市场模型: 截距={market_model_intercept:.4f}, β={market_beta:.2f}')  # 图例明确截距不是Jensen alpha
axes[2].set_title('原始收益市场模型 - 宁波银行', fontsize=12, fontweight='bold')  # 使图题与实际证券对象一致
axes[2].set_xlabel('市场收益率 (上证综指)')                          # 横轴注明“市场收益率 (上证综指)”,界定比较变量及其度量口径
axes[2].set_ylabel('股票收益率 (宁波银行)')                         # 使纵轴标签与数据列一致
axes[2].legend()  # 图例同时报告原始日收益散点和含截距市场模型拟合线
axes[2].grid(True, alpha=0.3)  # 收益率网格便于按横纵轴读取市场与股票的同期变动

plt.tight_layout()  # 分隔时间序列面板与底部散点轴,避免图例遮挡轴标签
plt.show()  # 显示2023共同收益样本的滚动敏感度、共变和市场模型
<Figure size 672x480 with 0 Axes>

证券特征线与系统性风险的动态演变

Beta 系数衡量股票收益率对市场收益率的样本敏感度。它也出现在 CAPM 中,但本节没有扣除可追溯的无风险收益,因此代码估计的是原始收益市场模型,不是超额收益 CAPM。

实证经济含义: 1. Beta 的时变性 (Time-varying Beta):滚动估计值可能随样本窗口变化。公司基本面和宏观环境是可能机制,但仅凭滚动曲线不能识别因果来源。 2. 证券特征线 (Security Characteristic Line, SCL)\[ R_{i,t} = a_i + \beta_i R_{m,t} + \epsilon_{i,t} \] 这里 \(a_i\) 是原始收益市场模型截距,不能称为 Jensen alpha。只有引入同频、同口径且有数据血缘的 \(R_f\),对个股和市场都扣除无风险收益后,才可估计超额收益规格。 3. 机制假说与样本证据的边界:港口业务稳定性、银行信用周期敏感度都可以作为解释 Beta 差异的待检验假说,但不能由行业标签直接推出 Beta 大小。应先报告本题共同日期样本中的估计路径与不确定性,再另行引入公司基本面或宏观变量检验机制。

关键要点: - 滚动 Beta 是随窗口更新的样本估计量,其变化不自动证明真实 Beta 或公司机制发生改变 - 比较 Beta 必须使用共同有效日期,并报告窗口、样本量与估计不确定性 - 股票间相关性描述共同样本中的线性共变;用于组合决策前仍需检验稳定性、估计误差和样本外表现

9.10.7 习题 9.7: 综合时间序列分析报告(选做项目)

定位与产出:选做综合项目,不与本章核心题重复计入必做工作量;先修为本章全部核心内容及第 8 章分组聚合;预计 90—120 分钟;提交可复算指标表、四面板图、数据与代码审计表,以及把描述、预测、因果和行动边界分开的结论段。

问题描述: 对宁波港和宁波银行进行同一时期的描述性对比,并生成可审计报告:

  1. 收益率分析: 计算并比较两只股票的累积收益率、年化收益率、年化波动率
  2. 风险描述指标: 计算收益波动比、年化目标下行偏差(MAR=0)、最大回撤与 Calmar 比率
  3. 技术指标: 计算 RSI、MACD、移动平均线
  4. 相关性分析: 计算两只股票的后向滚动相关性
  5. 指标审计: 生成描述性对比表,并解释为何历史指标不直接构成预测或投资建议
展开习题 9.7 完整解答

9.10.7.1 习题 9.7 完整解答

本题按每只证券实际得到的有效相邻收益区间数做252交易日几何年化。pct_change(fill_method=None)不会填补缺失价格;由首期变换或价格缺失产生的非有效收益不进入复合收益与年化指数。Calmar 比率的分子复用这一相同口径的年化收益率,因而不会把价格观测行数误当成收益区间数。

风险表把最低可接受收益率设为日目标 \(MAR_d=0\),并报告与第 8 章同口径的年化目标下行偏差(MAR=0)

\[ DD_{MAR,d}=\sqrt{\frac{1}{m}\sum_{t=1}^{m}\min(r_t-MAR_d,0)^2},\qquad DD_{MAR,a}=\sqrt{252}\,DD_{MAR,d}. \tag{9.2}\]

式 9.2 给出本节后续实现与解释采用的数学关系。

这里 \(m\) 包含全部有效日收益区间;收益不低于日目标时,平方短缺为零但区间仍进入分母。若给定年目标 \(MAR_a\),先用 \(MAR_d=(1+MAR_a)^{1/252}-1\) 转成日目标再计算。它既不是负收益子样本标准差,也不是 Sortino 比率。

import pandas as pd  # 组织两只股票的收益、回撤、技术指标和滚动相关结果
import numpy as np  # 年化收益波动率并计算回撤极值等数组统计
import tables  # 从后复权HDF表读取2023年两只目标证券的OHLCV
import matplotlib.pyplot as plt  # 构建累计收益、相关、RSI与成交量四面板报告
from scipy import stats                                     # 计算两只证券收益的偏度、峰度等分布描述

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 显示综合报告中的证券简称和风险指标中文标签
plt.rcParams['axes.unicode_minus'] = False  # 正确显示负收益、负回撤与负相关系数

# 长期收益分析统一采用后复权数据
target_stocks_dict = {}                                     # 初始化字典存储各股票数据框
for stock_code_id, stock_labeled_name in [('601018.SH', '宁波港'), ('002142.SZ', '宁波银行')]:  # 逐只读取两家宁波上市公司的后复权OHLCV
    stock_prefix = stock_code_id.split('.')[0]              # 按分隔符拆分字符串
    stock_suffix = stock_code_id.split('.')[1]              # 按分隔符拆分字符串
    order_book_id_str = f"{stock_prefix}.XSHG" if stock_suffix == "SH" else f"{stock_prefix}.XSHE"  # 将A股代码转换为米筐格式的order_book_id
    individual_stock_df = pd.read_hdf(  # 循环读取当前证券后复权行情,使累计收益含分红送转影响
        POST_ADJUSTED_PRICE_PATH,  # 从全章唯一入口读取长期收益所需后复权行情
        where=f'order_book_id={order_book_id_str!r}'  # 将当前展示代码转换出的米筐证券变量安全写入HDF查询条件
    ).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 每次循环恢复交易日并输出统一OHLCV列供指标函数复用

    individual_stock_df['date'] = pd.to_datetime(individual_stock_df['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析当前证券综合报告的交易日
    individual_stock_df = individual_stock_df[['date', 'close', 'open', 'high', 'low', 'volume']]  # 只保留日期和OHLCV列
    individual_stock_df = individual_stock_df[(individual_stock_df['date'] >= pd.Timestamp('2023-01-01')) &  # 每只证券从同一2023年起点计算累计收益与回撤
                        (individual_stock_df['date'] <= pd.Timestamp('2023-12-31'))]  # 将长期收益分析统一采用后复权数据样本截止日固定为2023-12-31
    individual_stock_df.set_index('date', inplace=True)  # 以交易日索引计算当前证券收益、回撤与风险指标
    target_stocks_dict[stock_labeled_name] = individual_stock_df  # 将处理好的数据框存入字典
# ==================== 1. 收益率分析 ====================
print('='*60)  # 打开收益率分析区段
print('一、收益率分析')  # 开始逐证券报告期间收益、252日年化收益与年化波动率
print('='*60)  # 分隔线之后逐证券输出2023年期间收益、252日年化收益和年化波动率

stock_returns_history_dict = {}                             # 初始化字典存储各股票收益率序列
annualized_returns_by_stock = {}                            # 按证券保存年化收益以供同一证券的Calmar计算
effective_return_counts_by_stock = {}  # 按证券保存实际参与复合的相邻收益区间数,供年化和指标审计共用
for stock_name, individual_stock_df in target_stocks_dict.items():  # 逐只计算2023年日收益、累积收益及年化描述量
    # 日收益率
    individual_stock_df['returns'] = individual_stock_df['close'].pct_change(fill_method=None)  # 当前证券内不填补缺口后计算相邻交易日收益
    stock_returns_history_dict[stock_name] = individual_stock_df['returns']  # 将日收益率序列存入字典

    # 累积收益率
    individual_stock_df['cumulative_returns'] = (1 + individual_stock_df['returns']).cumprod() - 1  # 计算累积乘积

    # 年化收益率
    valid_return_series = individual_stock_df['returns'].dropna()  # 仅保留确有前后价格支持的相邻收益区间,不跨缺口填补
    total_period_return = (1 + valid_return_series).prod() - 1  # 复合全部有效相邻区间,得到与年化分母一致的期间收益
    effective_return_count = len(valid_return_series)  # 统计参与复合的收益区间数,而不是价格观测行数
    assert effective_return_count > 0  # 防止空收益样本进入几何年化并产生无意义指数
    annualized_return_rate = (1 + total_period_return) ** (252 / effective_return_count) - 1  # 按有效收益区间数做252交易日几何年化
    annualized_returns_by_stock[stock_name] = annualized_return_rate  # 防止下一循环误用其他证券的年化收益
    effective_return_counts_by_stock[stock_name] = effective_return_count  # 保存当前证券的年化分母以供后续审计

    # 年化波动率
    annualized_volatility_rate = individual_stock_df['returns'].std() * np.sqrt(252)  # 计算平方根

    # 获取对应的证券代码用于显示
    current_stock_code_id = '601018.SH' if stock_name == '宁波港' else '002142.SZ'  # 把中文名与报告使用的交易所展示代码对齐

    print(f'\n{stock_name} ({current_stock_code_id}):')  # 将随后三项收益统计绑定到当前循环的证券名称与交易所代码
    print(f'  期间收益率: {total_period_return:.2%}')  # 报告所有有效相邻收益区间复合得到的样本期间收益
    print(f'  有效收益区间数: {effective_return_count}')  # 明示几何年化指数使用的实际相邻收益区间数
    print(f'  年化收益率: {annualized_return_rate:.2%}')  # 按252个交易日和有效收益区间数将期间收益几何年化
    print(f'  年化波动率: {annualized_volatility_rate:.2%}')  # 按平方根时间规则年化日收益标准差
============================================================
一、收益率分析
============================================================

宁波港 (601018.SH):
  期间收益率: 2.24%
  有效收益区间数: 241
  年化收益率: 2.34%
  年化波动率: 12.07%

宁波银行 (002142.SZ):
  期间收益率: -36.28%
  有效收益区间数: 241
  年化收益率: -37.57%
  年化波动率: 28.54%
# ==================== 2. 风险描述指标 ====================
print('\n' + '='*60)  # 用分隔线结束收益率区段并进入历史风险描述
print('二、风险描述指标')  # 明示未引入无风险序列时不计算夏普或索提诺比率
print('='*60)  # 结束风险指标区段标题

def calculate_annualized_target_downside_deviation(valid_returns, daily_mar=0.0):  # 按全部有效区间计算目标下行偏差
    '''计算与日目标同频、再按252个交易区间年化的目标下行偏差。'''  # 分母不只包含负收益日
    if len(valid_returns) == 0:  # 空样本没有可定义的目标下行偏差
        return np.nan  # 用缺失值显式表示没有有效收益区间
    downside_shortfalls = np.minimum(valid_returns - daily_mar, 0.0)  # 未低于MAR的区间贡献零平方短缺
    daily_target_downside_deviation = np.sqrt(np.mean(np.square(downside_shortfalls)))  # 均方分母包含全部有效区间
    return daily_target_downside_deviation * np.sqrt(252)  # 用平方根时间规则年化日目标下行偏差

two_interval_returns = pd.Series([-0.02, 0.01])  # 反例一含一次-2%和一个非负区间
four_interval_returns = pd.Series([-0.02, 0.01, 0.01, 0.01])  # 反例二保留同幅负收益并增加两个零短缺区间
two_interval_downside_deviation = calculate_annualized_target_downside_deviation(two_interval_returns)  # 计算两区间目标下行偏差
four_interval_downside_deviation = calculate_annualized_target_downside_deviation(four_interval_returns)  # 计算四区间目标下行偏差
assert np.isclose(two_interval_downside_deviation / four_interval_downside_deviation, np.sqrt(2))  # 同幅负收益仍因全部区间分母不同而产生不同结果

============================================================
二、风险描述指标
============================================================
def calculate_risk_metrics(stock_name, individual_stock_df):  # 为单只证券计算同口径的历史风险描述
    '''返回不依赖无风险利率的风险指标。'''  # 明确函数不生成夏普或索提诺比率
    stock_daily_returns_series = individual_stock_df['returns'].dropna()  # 剔除收益变换产生的首期缺失,样本从第二个交易日开始
    annualized_volatility_value = stock_daily_returns_series.std(ddof=1) * np.sqrt(252)  # 使用样本标准差年化总波动
    return_volatility_ratio_value = annualized_returns_by_stock[stock_name] / annualized_volatility_value  # 年化收益除以总波动,未扣无风险收益,不能称为夏普比率
    annualized_target_downside_deviation = calculate_annualized_target_downside_deviation(stock_daily_returns_series, daily_mar=0.0)  # 按全部有效区间计算MAR为0的标准目标下行偏差
    cumulative_max_prices = individual_stock_df['close'].cummax()  # 计算累积最大值
    price_drawdown_series = (individual_stock_df['close'] - cumulative_max_prices) / cumulative_max_prices  # 计算每日回撤幅度序列
    max_drawdown_value = price_drawdown_series.min()        # 取最大回撤(即最大负值)
    current_annualized_return_rate = annualized_returns_by_stock[stock_name]  # 按证券键取回当前对象年化收益
    calmar_ratio_value = current_annualized_return_rate / abs(max_drawdown_value) if max_drawdown_value != 0 else np.nan  # 以当前证券年化收益除以其最大回撤绝对值
    return {  # 字典schema固定为收益波动比、目标下行偏差、最大回撤、Calmar与有效收益区间数
        '收益波动比': return_volatility_ratio_value,  # 保存未扣无风险收益的描述性比率
        '年化目标下行偏差(MAR=0)': annualized_target_downside_deviation,  # 保存日目标为0且按252区间年化的目标下行偏差
        '最大回撤': max_drawdown_value,  # 存储最大回撤值
        'Calmar比率': calmar_ratio_value,  # 分子沿用按有效收益区间数计算的年化收益
        '有效收益区间数': effective_return_counts_by_stock[stock_name]  # 暴露年化分母,便于核查证券间样本差异
    }  # 字典构建完成
risk_metric_summary_dict = {  # 分证券调用函数,避免循环变量跨对象复用
    stock_name: calculate_risk_metrics(stock_name, individual_stock_df)  # 传入同一证券的名称、数据与年化收益
    for stock_name, individual_stock_df in target_stocks_dict.items()  # 遍历两只目标证券
}  # 完成风险指标汇总
risk_metric_output_df = pd.DataFrame.from_dict(risk_metric_summary_dict, orient='index')  # 转为可审计的证券乘指标表
print(risk_metric_output_df.round(4))  # 输出结果并核对每个证券拥有独立Calmar分子

# ==================== 3. 技术指标 ====================
print('\n' + '='*60)  # 分隔风险表与RSI、MACD和移动均线计算
print('三、技术指标分析')  # 标识下一部分只报告样本末日技术统计,不作预测评分
print('='*60)  # 结束技术指标区段标题
       收益波动比  年化目标下行偏差(MAR=0)    最大回撤  Calmar比率  有效收益区间数
宁波港   0.1941           0.0840 -0.1390    0.1686      241
宁波银行 -1.3168           0.1949 -0.4198   -0.8950      241

============================================================
三、技术指标分析
============================================================
def calculate_rsi(prices, period=14):                       # 由14观测平均涨幅与平均跌幅生成0—100振荡指标
    """计算RSI指标"""  # 函数说明: 基于动量振荡器原理计算相对强弱指数
    delta = prices.diff()                                   # 计算逐日价格变动
    gain = (delta.where(delta > 0, 0)).rolling(period).mean()  # 十四观测窗口把非上涨日记为零后求平均涨幅
    loss = (-delta.where(delta < 0, 0)).rolling(period).mean()  # 同一窗口把非下跌日记为零后求平均跌幅绝对值
    rs = gain / loss  # 计算相对强度RS = 平均涨幅/平均跌幅
    rsi = 100 - (100 / (1 + rs))  # 将RS转换为0-100的RSI指数
    return rsi  # 返回与价格日期同索引的0—100相对强弱序列,前期窗口可能缺失

def calculate_macd(prices, fast=12, slow=26, smooth=9):     # 计算12与26跨度EMA差及其9跨度平滑线和柱差
    """计算MACD指标"""  # 函数说明: 基于双EMA差值计算趋势动量指标
    exp_fast = prices.ewm(span=fast).mean()                 # 计算快线EMA(12日)
    exp_slow = prices.ewm(span=slow).mean()                 # 计算慢线EMA(26日)
    macd = exp_fast - exp_slow  # MACD线 = 快线EMA - 慢线EMA
    smooth_line = macd.ewm(span=smooth).mean()              # 平滑参照线 = MACD的9期EMA
    histogram = macd - smooth_line  # 柱状图 = MACD线 - 平滑参照线
    return macd, smooth_line, histogram  # 按日期返回快慢EMA差、九期平滑线及两者差值三列
for stock_name, individual_stock_df in target_stocks_dict.items():  # 逐只计算14观测RSI、MACD差值与移动均线
    # 计算十四观测相对强弱指数
    individual_stock_df['RSI'] = calculate_rsi(individual_stock_df['close'])  # 计算14日RSI指标

    # 计算快慢指数均线差及其平滑参照线
    individual_stock_df['MACD'], individual_stock_df['MACD_Smooth'], individual_stock_df['MACD_Hist'] = calculate_macd(individual_stock_df['close'])  # 计算快慢EMA差、平滑参照线及两者差值

    # 移动平均线
    individual_stock_df['MA20'] = individual_stock_df['close'].rolling(20).mean()  # 计算20日移动平均线
    individual_stock_df['MA60'] = individual_stock_df['close'].rolling(60).mean()  # 计算60日移动平均线

    # 最新技术指标
    print(f'\n{stock_name} 最新技术指标:')  # 将末交易日RSI、MACD和相对MA20值归属到当前证券
    print(f'  RSI(14): {individual_stock_df["RSI"].iloc[-1]:.2f}')  # 输出末日14观测相对强弱指数,尺度为0—100
    print(f'  MACD: {individual_stock_df["MACD"].iloc[-1]:.4f}')  # 输出末日12跨度EMA减26跨度EMA的价格差
    print(f'  MACD平滑线: {individual_stock_df["MACD_Smooth"].iloc[-1]:.4f}')  # 输出末日MACD九跨度指数均线,单位仍为元
    print(f'  价格相对MA20: {(individual_stock_df["close"].iloc[-1] / individual_stock_df["MA20"].iloc[-1] - 1):.2%}')  # 报告最后一个交易日的close、MA20值

宁波港 最新技术指标:
  RSI(14): 26.08
  MACD: 0.0120
  MACD平滑线: 0.0325
  价格相对MA20: -1.94%

宁波银行 最新技术指标:
  RSI(14): 40.55
  MACD: -2.1935
  MACD平滑线: -2.4915
  价格相对MA20: -1.28%
# ==================== 4. 相关性分析 ====================
print('\n' + '='*60)  # 分隔技术指标与两只股票的共同日期收益相关分析
print('四、相关性分析')  # 标识随后输出全期相关与60观测滚动相关摘要
print('='*60)  # 结束相关性区段标题

# 合并收益率
returns_combined_df = pd.DataFrame({                        # 按交易日对齐两只股票的日收益列
    '宁波港': target_stocks_dict['宁波港']['returns'],  # 取宁波港日收益率序列
    '宁波银行': target_stocks_dict['宁波银行']['returns']  # 取宁波银行日收益率序列
}).dropna()                                                 # 仅保留各序列共同有观测的交易日,统一跨资产样本

# 全期相关性
overall_correlation_value = returns_combined_df['宁波港'].corr(returns_combined_df['宁波银行'])  # 计算相关系数
print(f'\n全期相关系数: {overall_correlation_value:.3f}')  # 报告两只股票在共同交易日上的全样本收益相关系数

============================================================
四、相关性分析
============================================================

全期相关系数: 0.265
# 滚动相关性 (60天窗口)
rolling_correlation_series = returns_combined_df['宁波港'].rolling(60).corr(returns_combined_df['宁波银行'])  # 满60对共同交易日收益后输出后向Pearson相关系数
print(f'滚动相关性 (60天窗口):')  # 明示窗口单位为交易观测,便于复核滚动口径
print(f'  均值: {rolling_correlation_series.mean():.3f}')  # 报告60观测滚动相关系数的时间均值
print(f'  标准差: {rolling_correlation_series.std():.3f}')  # 报告60观测滚动相关系数的时间标准差
print(f'  最小值: {rolling_correlation_series.min():.3f}')  # 报告60观测滚动相关系数的样本最小值
print(f'  最大值: {rolling_correlation_series.max():.3f}')  # 报告60观测滚动相关系数的样本最大值

# ==================== 5. 指标审计 ====================
descriptive_metric_df = pd.DataFrame.from_dict(risk_metric_summary_dict, orient='index')  # 汇总两只股票的历史风险描述
descriptive_metric_df['年化收益率'] = pd.Series(annualized_returns_by_stock)  # 按证券名称对齐各自年化收益
print('\n五、指标审计')  # 输出本节标题
print(descriptive_metric_df.round(4))  # 展示指标而不压缩成任意权重的单一分数
print('以上为2023年样本描述,不构成预测、因果结论或投资建议。')  # 明确结论边界
滚动相关性 (60天窗口):
  均值: 0.328
  标准差: 0.130
  最小值: -0.041
  最大值: 0.516

五、指标审计
       收益波动比  年化目标下行偏差(MAR=0)    最大回撤  Calmar比率  有效收益区间数   年化收益率
宁波港   0.1941           0.0840 -0.1390    0.1686      241  0.0234
宁波银行 -1.3168           0.1949 -0.4198   -0.8950      241 -0.3757
以上为2023年样本描述,不构成预测、因果结论或投资建议。

图 9.11 展示本节讨论对象的可视化结果,读图时应结合正文给出的口径与限制。

# ==================== 可视化 ====================
fig, axes = plt.subplots(4, 1, figsize=(14, 14))  # 四轴分别采用累计收益、相关系数、RSI和最大回撤的独立量纲
# 子图1: 累积收益率对比
for stock_name, individual_stock_df in target_stocks_dict.items():  # 把两只证券的2023年累积收益路径绘制在同一坐标轴上
    individual_stock_df['cumulative_returns'].plot(ax=axes[0], label=stock_name, linewidth=2)  # 绘制各证券由首个有效日收益起复利累乘的历史路径
axes[0].set_title('累积收益率对比', fontsize=12, fontweight='bold')  # 标题将两条曲线限定为2023后复权价格的样本内累计收益
axes[0].set_ylabel('累积收益率')                                 # 纵轴为相对初始财富的比例变化,无价格单位
axes[0].legend()  # 图例把宁波港与宁波银行累计收益曲线分开标识
axes[0].grid(True, alpha=0.3)  # 收益网格便于读取两条财富路径的差距及过零时点

# 子图2: 滚动相关性
rolling_correlation_series.plot(ax=axes[1], color='darkblue', linewidth=2)  # 绘制两只股票60对共同收益窗口的相关路径
axes[1].axhline(y=overall_correlation_value, color='red', linestyle='--',  # 红色水平线给出2023全样本相关作为窗口路径参照
                label=f'全期相关系数 = {overall_correlation_value:.3f}')  # 图例显示全期相关系数数值
axes[1].set_title('滚动相关性 (60天窗口)', fontsize=12, fontweight='bold')  # 标题中的窗口单位是共同收益观测而非自然日
axes[1].set_ylabel('相关系数')                                  # 纵轴为-1至1的Pearson系数,不进行年化
axes[1].legend()  # 图例只解释红色全期相关参照线
axes[1].grid(True, alpha=0.3)  # 网格用于比较局部相关相对全期水平的偏离

# 第三面板在0—100同轴上比较两只证券的十四观测RSI
for stock_name, individual_stock_df in target_stocks_dict.items():  # 每次循环加入一条证券RSI曲线,日期来自各自2023年交易样本
    individual_stock_df['RSI'].plot(ax=axes[2], label=stock_name, linewidth=1.5)  # 用相同14观测定义绘制两只证券的相对强弱指数
axes[2].axhline(y=70, color='red', linestyle='--', alpha=0.5, label='高位参考线 (70)')  # 70线标记RSI的高位参考,不等同卖出规则
axes[2].axhline(y=30, color='green', linestyle='--', alpha=0.5, label='低位参考线 (30)')  # 30线标记RSI的低位参考,不等同买入规则
axes[2].set_title('RSI指标对比', fontsize=12, fontweight='bold')  # 标题明确同轴比较两只证券的14观测RSI
axes[2].set_ylabel('RSI')                                   # 纵轴固定为0—100的无量纲振荡指标
axes[2].set_ylim(0, 100)  # 将子图3: RSI对比面板纵轴限定为0, 100
axes[2].legend()  # 图例标识两只证券曲线及30、70两条参考阈值
axes[2].grid(True, alpha=0.3)  # 百分尺度网格便于读取RSI距阈值的距离
# 子图4比较历史最大回撤,不输出任意综合评级
descriptive_metric_df['最大回撤'].plot(kind='bar', ax=axes[3], color=['#008080', '#F0A700'], legend=False)  # 绘制两只股票的样本最大回撤
axes[3].set_title('2023年样本最大回撤对比', fontsize=12, fontweight='bold')  # 明确图形的样本期与描述性质
axes[3].set_ylabel('最大回撤')                                # 标明纵轴是回撤比例而非综合得分
axes[3].grid(True, alpha=0.3, axis='y')                     # 仅保留横向淡网格,辅助比较各组纵轴数值

plt.tight_layout()  # 为四种不同量纲保留独立标题、刻度与图例空间
plt.show()  # 显示两只股票2023年收益、共变、RSI与最大回撤的描述图集

print('\n' + '='*60)  # 四面板执行完毕后开启报告完成提示,不承载统计数据
print('报告完成!')  # 确认五个文本区段与四个图形面板均已执行
print('='*60)  # 闭合综合报告完成提示
图 9.11: 宁波港与宁波银行综合量化对比分析图集

============================================================
报告完成!
============================================================

关键要点: - 风险指标口径: 未引入有血缘的无风险利率时,不把原始收益比率称为夏普或索提诺比率 - 技术指标: RSI 报告涨跌幅的历史相对位置,MACD 报告两条指数加权均线的历史差值;二者均不直接给出未来方向 - 指标审计: 年化收益、波动与回撤应分别呈现,避免任意权重掩盖取舍

注意事项: - 本分析基于历史数据,不构成投资建议 - 若用于预测,必须预先固定规则、执行时间外验证并计入交易成本 - 技术指标存在滞后性,不能由样本内图形直接推导未来表现

9.10.8 习题 9.8:跨市场时区与 DST 审计

定位与产出:核心边界题;先修为 tz_localizetz_convert、UTC 瞬间和 Period;预计 30—40 分钟;提交春季不存在时刻、秋季重复时刻和北京自然日归属的断言与审计表。

问题描述:某跨境审计表把纽约本地时钟写成不含时区的字符串。春季样本包含 2024-03-10 02:30,秋季样本包含两条 2024-11-03 01:30。这些值是用于检验时间语义的题设记录,不是市场行情。请完成以下任务:

  1. 说明为什么不能直接把这些字符串当作 UTC,也不能先 tz_convert
  2. 春季跳时用 nonexistent='shift_forward' 显式处置不存在的时刻,秋季回拨用布尔数组区分第一次与第二次 01:30
  3. 将两组结果转换为 UTC 和 Asia/Shanghai,验证 UTC 瞬间唯一,并报告两条秋季重复墙上时刻对应的 UTC 与北京时间;
  4. 将北京时间转换为日频 PeriodIndex,解释这一标签与带时区 Timestamp 的信息差异。

评分点(100 分):本地化与转换语义 20 分;春季不存在时刻处置 20 分;秋季歧义处置 25 分;UTC/北京时间断言与可观察输出 25 分;Timestamp/Period 辨析 10 分。

展开习题 9.8 完整解答

9.10.8.1 习题 9.8 完整解答

表 9.7 汇总本节的计算或审计结果,解释时应遵循正文给出的口径与限制。

spring_wall_times = pd.DatetimeIndex(['2024-03-10 01:30', '2024-03-10 02:30', '2024-03-10 03:30'])  # 构造含春季不存在时刻的纽约墙上时间题设
spring_new_york = spring_wall_times.tz_localize('America/New_York', nonexistent='shift_forward')  # 显式把02:30推进到合法的03:00
fall_wall_times = pd.DatetimeIndex(['2024-11-03 00:30', '2024-11-03 01:30', '2024-11-03 01:30', '2024-11-03 02:30'])  # 构造含两次01:30的秋季回拨题设
fall_new_york = fall_wall_times.tz_localize('America/New_York', ambiguous=[True, True, False, False])  # 将两条01:30分别指定为夏令时与标准时
all_new_york = spring_new_york.append(fall_new_york)  # 合并两个边界案例以执行统一审计
all_utc = all_new_york.tz_convert('UTC')  # 转换显示时区而不改变每条记录代表的绝对瞬间
all_shanghai = all_utc.tz_convert('Asia/Shanghai')  # 按项目时区生成面向中国团队的审计时间
dst_audit = pd.DataFrame({'new_york_time': all_new_york.astype(str), 'utc_time': all_utc.astype(str), 'shanghai_time': all_shanghai.astype(str)})  # 并列保存三个时区的可评分证据
assert all_utc.is_unique  # 确认歧义消除后每条记录对应唯一UTC瞬间
assert all_utc[4] == pd.Timestamp('2024-11-03 05:30', tz='UTC')  # 核验第一次01:30属于UTC-4夏令时
assert all_utc[5] == pd.Timestamp('2024-11-03 06:30', tz='UTC')  # 核验第二次01:30属于UTC-5标准时
shanghai_days = all_shanghai.tz_localize(None).to_period('D')  # 去除显示时区后形成北京时间自然日标签
dst_audit.assign(shanghai_period=shanghai_days.astype(str))  # 输出瞬间转换与时期归属的完整审计表
表 9.7: 纽约 DST 边界转换为 UTC 与北京时间的审计结果
new_york_time utc_time shanghai_time shanghai_period
0 2024-03-10 01:30:00-05:00 2024-03-10 06:30:00+00:00 2024-03-10 14:30:00+08:00 2024-03-10
1 2024-03-10 03:00:00-04:00 2024-03-10 07:00:00+00:00 2024-03-10 15:00:00+08:00 2024-03-10
2 2024-03-10 03:30:00-04:00 2024-03-10 07:30:00+00:00 2024-03-10 15:30:00+08:00 2024-03-10
3 2024-11-03 00:30:00-04:00 2024-11-03 04:30:00+00:00 2024-11-03 12:30:00+08:00 2024-11-03
4 2024-11-03 01:30:00-04:00 2024-11-03 05:30:00+00:00 2024-11-03 13:30:00+08:00 2024-11-03
5 2024-11-03 01:30:00-05:00 2024-11-03 06:30:00+00:00 2024-11-03 14:30:00+08:00 2024-11-03
6 2024-11-03 02:30:00-05:00 2024-11-03 07:30:00+00:00 2024-11-03 15:30:00+08:00 2024-11-03

tz_localize 为不含时区的墙上时间指定解释规则;tz_convert 只能用于已经感知时区的时间,并保持 UTC 瞬间不变。春季的 02:30 在纽约不存在,答案把它推进到 03:00-04:00;秋季两条 01:30 分别对应 05:30 UTC06:30 UTC,在北京时间是 13:30+08:0014:30+08:00Timestamp 仍表示可跨时区换算的具体瞬间,而日频 Period 只表示“北京时间哪一天”这一时间段标签,不再保留日内时刻和时区偏移,不能用于还原原始成交瞬间。

9.11 结论

本章建立了使用 pandas 处理时间序列的基础路径。时间标签的语义先于统计计算;只有索引、频率、时区和信息边界明确,重采样与窗口结果才可解释。本章还把市场模型中以市场收益解释个股收益的经验回归,与 CAPM 关于期望收益和系统性风险的均衡命题分开;滚动估计必须报告有效样本量、参数个数及残差自由度,不能只展示系数曲线。任何结果还应先声明它属于样本描述、样本外预测、因果识别还是行动规则:前一类证据不会自动升级为后一类结论。本章学习了:

  1. 时间序列基础: 如何处理时间戳、时期和时间增量
  2. 频率转换: 使用 resample 进行降采样和升采样
  3. 移动窗口函数: 计算滚动统计量和指数加权移动平均
  4. 时区处理: 正确处理不同时区的时间数据
  5. 推断边界: 区分市场模型与 CAPM,审计自由度,并隔离描述、预测、因果与行动主张

这些技能为后续的金融分析和时间序列建模打下了坚实的基础。

章节导航:上一章为数据聚合与分组操作,下一章为绘图与可视化探索