10  绘图与可视化探索

10.1 引言与学习目标

学习目标

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

  • 目标 10.1:根据变量类型和分析问题选择位置、长度、颜色与分面等视觉编码;
  • 目标 10.2:用 matplotlibpandasseaborn 创建并标注可复现图形;
  • 目标 10.3:在本地长三角公司行情上绘制价格、K 线、成交量、收益分布和时间序列探索图;
  • 目标 10.4:检查坐标尺度、缺失值、样本期、图例和证券标签是否与底层数据一致;
  • 目标 10.5:区分探索性图形、预测评估图与因果证据,避免由共变和注释推断因果;
  • 目标 10.6:导出适合论文或网页的图形,并说明格式、分辨率和可访问性选择。

数据与推断边界

本章用于解释画布、坐标和随机游走的短数组或随机数均属于“机制演示题设值”,只检验绘图 API,不代表市场实证。公司图形读取本地 HDF5 且使用长三角上市公司。可视化可以发现模式和异常,却不能替代预测的时间外评估或因果识别;图中事件注释也不等于事件造成了价格变化。

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

表 10.1: 第十章教学闭环映射
正式目标 学习活动或示例 可观察产出 核心练习或选做项目 完整答案中的评分证据
目标 10.1 小节 10.1.1小节 10.3.2小节 10.3.5 的编码比较 为变量指定位置、长度、颜色、大小或分面,并说明选择理由 习题 10.4、习题 10.6 分布图与静态多变量图同时给出编码、图例和文字解释
目标 10.2 小节 10.2小节 10.3 的对象接口示例 生成含标题、轴标签、图例与数据来源的可复现图形 习题 10.1;选做 10.7 完整代码、图形与统计摘要共同评分
目标 10.3 价格、K 线、成交量、收益分布和时间序列探索图 基于本地长三角公司真实行情形成五类图形 习题 10.2—10.5 每题答案均读取真实行情并输出相应图形及业务解释
目标 10.4 坐标、刻度、图例与样本筛选示例 提交图形审计清单,核对单位、样本期和证券代码 习题 10.1;选做 习题 10.6 轴单位、样本期说明、双轴图例及尺寸断言是评分证据
目标 10.5 事件注释、星期分组与仪表盘后的推断边界辨析 将探索发现与预测、因果结论分开书写 习题 10.5;选做 10.7 答案明确显著性、时间外验证、成本和因果识别边界
目标 10.6 小节 10.2.5小节 10.2.6 给出格式、像素尺寸、颜色冗余编码及替代文本方案 选做 习题 10.6 或 10.7(二选一) PNG/SVG 导出、像素断言、无红绿依赖及可访问性说明逐项计分

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

可视化把数据变量映射到位置、长度、颜色、形状和分面等视觉通道,使读者能够检查分布、比较组别并发现异常。图形是否有说服力取决于数据合同、尺度、标注和不确定性是否透明,而不是取决于装饰效果。本章依次介绍 matplotlib 的对象接口、Pandas/Seaborn 的统计图形、真实行情图例和发布审计,并始终区分探索性描述与预测或因果证据。

10.1.1 理论基础:数据可视化的数学原理

可视化的数学基础

数据可视化本质上是将抽象数据映射到视觉空间的过程。给定:

  • 数据空间 \(D = \{(x_1, y_1), (x_2, y_2), ..., (x_n, y_n)\}\)
  • 视觉空间 \(V = \mathbb{R}^2\) (二维图形平面)

可视化映射函数定义为:

\[ \text{Vis}: D \to V \] \[ (x_i, y_i) \mapsto (u_i, v_i) = (f_x(x_i), f_y(y_i)) \]

其中 \(f_x, f_y\) 是坐标变换函数。

matplotlib的坐标系统

matplotlib 不是把数据范围直接按整张 Figure 的宽高缩放成像素。一个数据点依次经过 data → axes → figure → display 变换:比例尺和坐标范围先由 ax.transData 处理,Axes 在 Figure 中的位置再由 ax.transAxes/fig.transFigure 参与组合,最后后端依据 Figure 尺寸和 DPI 得到显示坐标。显示坐标以画布左下角为原点,单位通常是显示像素(严格说是 display units);Axes 左下角一般有由边距产生的非零偏移,因此不能忽略 axes bbox。

对线性轴,可以把单轴变换理解为“先按数据范围归一化,再缩放到 Axes 的显示宽度并加上 bbox 左下角偏移”。但日期轴、对数轴、极坐标以及用户自定义 transform 都含非线性或额外映射,不能套用一个简单比例式。需要标注或核验位置时,应直接调用 ax.transData.transform((x, y));反向读取数据坐标则用 ax.transData.inverted()。本章稍后的可执行断言同时检查线性轴的边距偏移和对数轴的等比刻度。

统计图形的数学表示

  1. 直方图: 给定数据集 \(\{x_1, x_2, ..., x_n\}\),直方图将数据域划分为 \(k\) 个连续的区间(bins):

    \[ B_j = \begin{cases} [b_{j-1}, b_j), & j = 1, 2, ..., k-1,\\ [b_{k-1}, b_k], & j = k. \end{cases} \]

    其中 \(b_0 = \min(x_i)\)\(b_k = \max(x_i)\)。这里采用 NumPy/Matplotlib 的端点约定:前 \(k-1\) 个箱左闭右开,最后一个箱同时包含右端点,因此样本最大值仍计入最后一箱。

    频数计算\[ f_j = \sum_{i=1}^{n} \mathbb{I}[x_i \in B_j] \]

    其中 \(\mathbb{I}[\cdot]\) 是指示函数。

  2. 核密度估计

    \[ \hat{f}_h(x) = \frac{1}{nh} \sum_{i=1}^{n} K\left(\frac{x - x_i}{h}\right) \]

    其中 \(K(\cdot)\) 是核函数(如高斯核),\(h\) 是带宽参数。

颜色映射与视觉编码

颜色映射函数

\[ C: V \to \text{Color} \] \[ v \mapsto \text{RGB}(r(v), g(v), b(v)) \]

常见的colormap(如viridis、plasma)定义了从数值到颜色的特定映射规则。

视觉编码的原则: - 位置编码:表示数值大小和比较关系 - 颜色编码:表示分类或连续变量 - 形状编码:表示分类变量 - 大小编码:表示数值权重

可视化在经济分析中承担三类任务:检查异常值与数据分布、比较变量和分组、向读者传达已经完成的分析。图形可以为后续模型提出问题,但不能替代模型检验。Python 提供静态和动态绘图库;本章重点学习 matplotlib 及其上层接口。

matplotlib 提供 Figure、Axes、Artist 和变换系统等绘图对象,支持交互显示以及 PDF、SVG、PNG 等常见矢量或栅格导出格式。本章关注这些可直接核验的接口能力,不依赖未经本书保存来源的历史年份或贡献者数字。

seaborn 在 Matplotlib 之上提供面向统计变量和分组语义的高级接口,本章后半部分用同一批可追溯数据比较两类接口。

在 Jupyter Notebook 中,可以在会话开始时运行下列“魔术”命令,使静态图嵌入输出单元。Quarto 渲染由项目配置管理,不要求在每个代码块重复运行该命令。

# 将静态图嵌入当前 Notebook 输出单元,便于逐段核对代码与图形
%matplotlib inline

10.2 Matplotlib API入门

Matplotlib 的对象接口允许分别控制画布、坐标轴、图形元素和变换。先理解这些对象,再使用 Pandas 或 Seaborn 的高级封装,有助于在需要时检查自动生成图形的轴、图例和数据映射。

按照惯例,我们通常将包含核心绘图函数的matplotlib.pyplot导入并简写为plt

import platform  # 识别运行平台以统一后续真实数据路径
import tempfile  # 为跨代码块图形导出创建自动清理的临时工作区
from pathlib import Path  # 用跨平台路径对象连接临时目录与导出文件名
import matplotlib.pyplot as plt  # 提供对象式绘图、标注和出版导出接口
import numpy as np  # 为视觉编码和图形尺寸断言提供数值运算
import pandas as pd  # 读取真实行情并组织绘图所需的带标签数据
DATA_ROOT = 'C:/qiufei/data' if platform.system() == 'Windows' else '/home/ubuntu/r2_data_mount/data'  # 建立全章唯一的跨平台数据入口
plot_export_workspace = tempfile.TemporaryDirectory(prefix='web_book_ch10_')  # 持有目录对象直至本章内核结束,供后续代码块复用
PLOT_EXPORT_ROOT = Path(plot_export_workspace.name)  # 将所有教学导出限制在系统临时目录而非项目根目录
assert PLOT_EXPORT_ROOT.is_dir()  # 核验跨块导出工作区在首次写文件前已经存在

下面直接检查坐标变换,而不是手写易遗漏边距和比例尺的“像素公式”。在线性轴上,显示坐标随数据值增大而增大,但 (0, 0) 不会落在 Figure 原点;切换为对数横轴后,110100 三个十倍间隔应映射成近似相等的显示距离。

fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100)  # 固定画布尺寸,使显示坐标断言可复现
ax.set(xlim=(0, 10), ylim=(0, 10))  # 在线性数据范围内建立可解释的基准变换
lower_left_display = ax.transData.transform((0, 0))  # 读取数据原点在含边距 Axes 中的显示位置
upper_right_display = ax.transData.transform((10, 10))  # 读取数据上界对应的显示位置
assert np.all(lower_left_display > 0)  # Axes bbox 的左、下边距使数据原点偏离画布原点
assert np.all(upper_right_display > lower_left_display)  # 显示坐标以画布左下角为原点并向右、向上增加

ax.set_xscale('log')  # 将横轴改为非线性比例尺,验证简单线性比例式不再适用
ax.set_xlim(1, 1000)  # 三个十倍区间覆盖完整的对数坐标核验范围
decade_display = ax.transData.transform([(1, 1), (10, 1), (100, 1)])[:, 0]  # 提取三个十倍刻度的显示横坐标
assert np.allclose(np.diff(decade_display), np.diff(decade_display)[0])  # 对数轴把相同比例而非相同差值映射为等距
plt.close(fig)  # 关闭只用于数值核验的画布,避免产生无教学信息的空图

方法辨析:Matplotlib API 的“全局管理”与“对象控制”

matplotlib 提供两种主要编程模式。区别不在于哪一种更“专业”,而在于状态归属是否显式:

  1. 状态机接口 (State-based interface, 例如 plt.plot())
    • 工作原理:将操作施加于当前活动图形,适合单轴、短代码的临时探索。
    • 局限性:多个图和子图交替操作时,当前对象可能不易追踪。
  2. 面向对象接口 (Object-oriented interface, 例如 ax.plot())
    • 工作原理:显式持有 Figure(画布)和 Axes(坐标轴)对象的引用。
    • 适用条件:多子图、复用函数或需要逐轴核验标题、单位和图例时,对象接口更容易追踪每项设置。本章后续实证采用这一接口。

运行%matplotlib inline命令后,我们就可以创建一个简单的图表。下面的代码从一个数字数组生成一个简单的线性图。请注意,np.arange(10)会生成一个数组[0, 1, ..., 9]。当plt.plot只接收一个列表或数组时,它会假定这是y值的序列,并自动生成x值作为该数组的索引。结果如 图 10.1 所示。

stock_prices_series = np.arange(10)  # 构造只用于说明默认横轴的可核对等差题设
plt.plot(stock_prices_series)  # 将数组位置映射为横轴、数组值映射为纵轴
plt.show()  # 在当前教学单元呈现默认轴映射结果
图 10.1: 一个简单的线性图

这个简单的调用在幕后执行了多项工作:它自动创建了一个图形容器和一个坐标系,并将数据点连接成线。在实际研究中,我们往往需要更精细的控制。

10.2.1 图形与子图

matplotlib 中,层次结构是理解它的关键。最外层是 Figure(图形对象),你可以把它想象成一张白纸。而真正的绘图动作发生在 Axes(坐标轴/子图)上,一张 Figure 可以包含多个 Axes。

在Jupyter Notebook中,单独调用一个空的plt.figure()不会显示任何东西。一个Figure仅仅是一块空白的画布;要制作图表,你需要在上面添加一个或多个子图(subplots)。一个子图由一个Axes对象表示(注意Axes中的”e”;它不同于x轴或y轴的axis)。

你可以使用add_subplot方法来添加子图。例如,fig.add_subplot(2, 2, 1)会在一个2x2的网格上创建一个子图布局,并选中第一个子图(编号从1开始,逐行进行)。让我们创建一个包含三个子图的图形,如 图 10.2 所示。

fig = plt.figure()  # 建立 Figure 容器,随后用三个 Axes 观察 2×2 网格的编号顺序
ax1 = fig.add_subplot(2, 2, 1)  # 在图形与子图的2×2布局左上角建立独立Axes
ax2 = fig.add_subplot(2, 2, 2)  # 在图形与子图的2×2布局右上角建立独立Axes
ax3 = fig.add_subplot(2, 2, 3)  # 在图形与子图的2×2布局左下角建立独立Axes
plt.show()  # 核对前三个网格位置依次为左上、右上、左下,右下保持空白
图 10.2: 一个包含三个子图的matplotlib空图形

Jupyter Notebook的一个特点是,每个单元格执行完毕后,图表的状态会被重置。因此,用于生成单个图形的所有命令都必须包含在同一个单元格内。

add_subplot返回的Axes对象拥有自己的绘图方法。标准做法是使用这些对象的方法(例如ax.plot())而不是顶层函数(plt.plot())。下面在第三个子图 ax3 上绘制一条零漂移、单位冲击尺度、50 步的高斯随机游走,如 图 10.3 所示。它是按给定模型和固定种子生成的一条机制路径,只用于练习绘图 API,不是市场观测,也不是市场效率检验。

核心概念:随机游走 (Random Walk) 与有效市场假说

三个概念相关但不等价:

  1. 随机游走是一类具体数据生成模型。简单加性规格可写为 \(P_t=P_{t-1}+\mu+\epsilon_t\),通常还对增量的独立性、同分布或条件矩作出额外假设;零漂移 \(\mu=0\) 只是特例。
  2. 是条件期望限制:相对于信息集 \(\mathcal F_{t-1}\),有 \(E[P_t\mid\mathcal F_{t-1}]=P_{t-1}\)。鞅不要求增量独立同分布;若存在正的风险补偿,价格或财富过程还可能更接近次鞅。反过来,带零漂移且增量满足适当条件的随机游走可以构成鞅。
  3. 弱式有效市场是经济命题,关注仅使用历史价格与成交信息,能否在计入风险和交易成本后持续获得可利用的异常利润。它不要求价格零漂移,也不要求增量独立同分布。随机游走是比弱式效率更强的一种模型规格,不能由一条 cumsum() 路径证明或否定市场效率。
fig = plt.figure()  # 建立 2×2 Figure,隔离“向指定 Axes 绘图”这一机制
ax1 = fig.add_subplot(2, 2, 1)  # 左上 Axes 暂时留空,作为与 ax3 的对象归属对照
ax2 = fig.add_subplot(2, 2, 2)  # 右上 Axes 暂时留空,说明创建对象不会自动生成数据图层
ax3 = fig.add_subplot(2, 2, 3)  # 左下 Axes 接收下方随机游走线,验证方法调用只作用于指定对象

# 在第三个子图上绘制一条参数明确的高斯随机游走
simulation_step_count = 50  # 固定机制路径包含50个离散步
simulation_drift_per_step = 0.0  # 设定每步漂移为零,不把路径解释为预期收益
simulation_shock_scale = 1.0  # 设定每步高斯冲击标准差为一个题设单位
single_path_rng = np.random.default_rng(2026)  # 固定机制演示种子,使同一模型路径可重复核对
simulated_increments = simulation_drift_per_step + simulation_shock_scale * single_path_rng.standard_normal(simulation_step_count)  # 按已声明参数生成独立高斯增量
simulated_path = simulated_increments.cumsum()  # 累加增量得到一条模型路径,不对应任何证券价格
ax3.plot(simulated_path, color='black', linestyle='dashed')  # 左下图横轴为模拟步序、纵轴为截至该步的累计冲击
plt.show()  # 核对折线只出现在左下 ax3,其余已创建 Axes 仍为空
图 10.3: 在单个子图上的数据可视化

你可能会注意到,运行绘图命令时会出现类似<matplotlib.lines.Line2D at ...>的输出。这只是创建的matplotlib对象。你可以在该行末尾加上一个分号(;)来抑制这个输出。

现在我们可以通过调用相应Axes对象的实例方法来填充其他空的子图。让我们在第一个子图上添加一个直方图,在第二个子图上添加一个散点图,如 图 10.4 所示。

fig = plt.figure()  # 建立 2×2 Figure,前三个位置分别承载三种图形语法
ax1 = fig.add_subplot(2, 2, 1)  # 左上 Axes 用于直方图,横轴为题设样本值、纵轴为箱内频数
ax2 = fig.add_subplot(2, 2, 2)  # 右上 Axes 用于散点图,比较序号与确定性波动题设
ax3 = fig.add_subplot(2, 2, 3)  # 左下 Axes 用于确定性累计路径,横轴为步序、纵轴为路径水平

histogram_demo_values = np.sin(np.arange(100) * 0.31) + 0.4 * np.cos(np.arange(100) * 0.73)  # 用确定序列形成可重复的直方图题设
scatter_demo_x = np.arange(30)  # 用0至29的确定序号建立散点横轴
scatter_demo_y = scatter_demo_x + 3 * np.sin(scatter_demo_x * 0.6)  # 用确定正弦偏离展示非完全共线的散点层
line_demo_increments = np.where(np.arange(50) % 3 == 0, 1.0, -0.35)  # 用周期性增减题设展示累计折线
ax1.hist(histogram_demo_values, bins=20, color='black', alpha=0.3)  # 左上图把100个确定题设值分入20箱,只演示频数分布API
ax2.scatter(scatter_demo_x, scatter_demo_y)  # 右上图比较确定序号与带周期偏离的题设值
ax3.plot(line_demo_increments.cumsum(), color='black', linestyle='dashed')  # 左下图累计确定增量,只演示折线图层

plt.show()  # 核对三类图层分别绑定到左上、右上、左下 Axes
图 10.4: 包含直方图、散点图和线图的可视化

用这种方式创建图形和子图可能有些繁琐。一个更便捷的方法是plt.subplots,它会创建一个新的图形,并返回一个包含所创建的Axes对象的NumPy数组。

fig, axes = plt.subplots(2, 3)  # 一次创建 Figure 和 2×3 Axes 数组,供二维下标定位子图
# axes 的 shape 应为 (2, 3),每个单元都是独立 Axes 对象
print(axes)  # 核对返回对象按两行三列组织,后续可用 axes[row, column] 精确寻址
[[<Axes: > <Axes: > <Axes: >]
 [<Axes: > <Axes: > <Axes: >]]

axes数组可以像其他NumPy数组一样进行索引,例如axes[0, 1]表示顶部中间的子图。sharexsharey选项非常有用;将它们设置为True可以确保所有子图共享相同的x轴或y轴,这对于在相同尺度上比较分布或时间序列至关重要。我们在 表 10.2 中总结了plt.subplots的关键选项。

表 10.2: matplotlib.pyplot.subplots 选项
参数 描述
nrows 子图的行数。
ncols 子图的列数。
sharex 所有子图应使用相同的x轴刻度。
sharey 所有子图应使用相同的y轴刻度。
subplot_kw 一个字典,包含传递给每个子图的add_subplot调用的关键字参数。
**fig_kw 传递给plt.figure调用的其他关键字参数,例如figsize=(8, 6)

10.2.1.1 调整子图周围的间距

默认情况下,matplotlib会在子图之间以及图形的外部添加填充。这个间距是相对于绘图尺寸的,并且在调整大小时会动态调整。你可以使用Figure对象上的subplots_adjust方法来精确控制这个间距: subplots_adjust(left=None, bottom=None, right=None, top=None, wspace=None, hspace=None)

wspacehspace参数控制子图之间的宽度和高度间距,指定为平均轴宽度和高度的一小部分。让我们创建一个2x2的直方图网格,并移除所有子图间的间距,如 图 10.5 所示。

fig, axes = plt.subplots(2, 2, sharex=True, sharey=True)  # 四幅直方图共享横纵尺度,使箱位置与频数高度可直接比较
histogram_grid_index = np.arange(500)  # 建立四幅确定性教学分布共用的位置索引

for i in range(2):  # 外层索引定位上下两行
    for j in range(2):  # 内层索引定位左右两列,使四个Axes都获得一幅直方图
        panel_phase = i * 2 + j  # 用面板编号改变确定序列的相位而不引入随机抽样
        panel_values = np.sin(histogram_grid_index * 0.17 + panel_phase) + 0.35 * np.cos(histogram_grid_index * 0.43)  # 构造500个可逐次复现的题设值
        axes[i, j].hist(panel_values, bins=50, color='black', alpha=0.5)  # 各面板仅用相位不同的确定序列演示共享尺度

fig.subplots_adjust(wspace=0, hspace=0)  # 将子图之间的水平和垂直间距设为零
plt.show()  # 检查四个没有子图间距的直方图的面板占位与排列
图 10.5: 四个没有子图间距的直方图

你可能会注意到轴标签重叠了。matplotlib不会自动检查这个问题,所以在这种情况下,你需要手动设置刻度位置和标签,这是我们稍后会讨论的主题。

10.2.2 颜色、标记和线型

ax.plot方法接受x和y坐标以及可选的样式参数。例如,要用绿色虚线绘制x对y的图,你可以写成:ax.plot(x, y, linestyle='--', color='g')

常见的颜色有单字母代码(例如,’g’代表绿色,’k’代表黑色),但你也可以使用十六进制代码(例如'#CECECE')来指定任何颜色。有多种线型可选,例如'-'代表实线,'--'代表虚线,':'代表点线。

线图也可以用标记(markers)突出实际输入点。matplotlib会在点之间绘制连接线,连接线可能掩盖离散观测位置。图 10.6 使用确定序列隔离比较标记与连接线。

fig, ax = plt.subplots()  # 单轴隔离“折线插值”与“原始观测标记”的视觉差别
marker_demo_index = np.arange(30)  # 建立30个可明确计数的离散位置
marker_demo_values = np.sin(marker_demo_index * 0.45) + marker_demo_index * 0.03  # 用确定的波动加缓慢斜率构造折线题设
ax.plot(marker_demo_values, color='black', linestyle='dashed', marker='o');  # 圆点标出30个实际输入位置,虚线只表示连接规则
plt.show()  # 核对圆点恰落在30个观测位置,虚线只是点间连接规则
图 10.6: 带有标记以指示数据点的线图

默认情况下,点是线性插值的。你可以用drawstyle选项来改变这种行为。在经济学中,一个常见的用例是创建阶梯图,例如在可视化税收等级或离散模拟结果时。drawstyle='steps-post'选项创建的图表中,线条会保持其水平直到下一个数据点。让我们在 图 10.7 中比较默认插值和阶梯图。

fig, ax = plt.subplots()  # 在同一 Axes 叠加两种连接规则,保证比较使用完全相同的坐标尺度
drawstyle_index = np.arange(30)  # 建立30个确定的离散横轴位置
data = np.floor(drawstyle_index / 5) + 0.2 * np.sin(drawstyle_index)  # 构造同时含台阶和小幅变化的确定序列

ax.plot(data, color='black', linestyle='dashed', label='Default')  # 默认规则在相邻观测间线性连接
ax.plot(data, color='black', linestyle='-', drawstyle='steps-post', label='steps-post')  # 后置阶梯在下一观测到来前保持上一水平
ax.legend()  # 图例列出使用默认和 `steps-post` 绘制样式的线图实际绘制的颜色与线型对应关系
plt.show()  # 对照默认插值与后置阶梯在相邻观测之间的路径差异
图 10.7: 使用默认和 steps-post 绘制样式的线图

10.2.3 刻度、标签和图例

大多数图表的装饰元素都可以通过Axes对象的方法来访问。这包括set_xlimset_xticksset_xticklabels,它们分别控制绘图范围、刻度位置和刻度标签。

这些方法有两种模式: * 不带参数调用时,它们返回当前的参数值(例如ax.get_xlim())。 * 带参数调用时,它们设置参数值(例如ax.set_xlim([0, 10]))。

10.2.3.1 设置标题、轴标签、刻度和刻度标签

让我们通过本地上证综指的真实日度收盘点位说明轴的定制。图 10.8 显示默认刻度下的图表;这个例子只讨论绘图方法,不把市场指数改称宏观经济指标。

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 = sse[sse['datetime'] >= '2000-01-01'].set_index('datetime')['close']  # 限定样本期并建立日期—点位序列

fig, ax = plt.subplots()  # 创建单轴画布以观察默认日期刻度的局限
ax.plot(sse.index, sse.values)  # 将真实交易日映射到横轴、指数点位映射到纵轴
plt.show()  # 呈现默认刻度,供下一示例作前后对照
图 10.8: 上证综合指数历史走势,使用默认刻度

默认的x轴标签过于拥挤,信息量也不大。我们可以使用set_xticks来指定刻度的位置,并使用set_xticklabels来提供自定义标签,从而改善这一点。在 图 10.9 中,我们将在特定的年份设置刻度并相应地标记它们,同时为图表添加标题和轴标签以提高清晰度。

fig, ax = plt.subplots()  # 单轴复现默认图,并只改变日期刻度与文字元素以便前后比较
ax.plot(sse.index, sse.values)                              # 绘制上证综指收盘价走势曲线

# 自定义图表
ticks_loc = pd.to_datetime(['2000-01-01', '2005-01-01', '2010-01-01', '2015-01-01', '2020-01-01', '2025-01-01'])  # 按显式格式解析ticks_loc,避免日期推断歧义
ax.set_xticks(ticks_loc)  # 把六个五年节点绑定到真实日期坐标,防止标签与数据位置分离
ax.set_xticklabels(['2000', '2005', '2010', '2015', '2020', '2025'], rotation=30, fontsize='small')  # 将六个五年节点显示为年份,并旋转30度避免相邻标签重叠

ax.set_title('2000年以来上证综合指数趋势')  # 题名注明证券指数与样本起点,读图重点是长期点位范围与刻度间隔
ax.set_xlabel('年份')                                         # 横轴注明“年份”,按时间索引顺序解释相邻观测
ax.set_ylabel('指数点位')  # 纵轴使用点位水平,不能据此比较跨指数投资回报

# 一种更便捷的设置属性的方法
# 等价写法:一次调用同时传入日期刻度位置、
# 六个五年节点的年份文本、上证综指题名、
# 年份横轴和指数点位纵轴;
# 也可单独旋转横轴标签并缩小字号避免重叠

plt.show()  # 核对六个年份标签落在对应日期,并与默认刻度图使用同一数据序列
图 10.9: 上证综合指数,带有自定义刻度、标签和标题

10.2.3.2 标识各数据系列,避免读者混淆比较对象

图例对于识别图表中的不同元素至关重要。创建图例最简单的方法是在绘制每个序列时传递label参数,然后调用ax.legend()来显示图例。

为了隔离“图例”这一绘图机制,下面使用三个确定函数生成教学路径。它们只用于比较线型与标签,不是市场观测。结果见 图 10.10

fig, ax = plt.subplots()  # 单轴叠加三条教学路径,用相同坐标范围检验图例辨识度
legend_index = np.linspace(0, 8 * np.pi, 1000)  # 建立覆盖四个周期的确定横轴
legend_path_a = np.sin(legend_index)  # 用单位振幅正弦路径演示实线图例
legend_path_b = 1.5 * np.sin(legend_index + np.pi / 4)  # 用相位和振幅变化区分虚线路径
legend_path_c = 2.0 * np.cos(legend_index * 0.5)  # 用较低频率和较大振幅区分点线路径
ax.plot(legend_path_a, color='black', linestyle='-', label='路径A')  # 把实线与路径A标签绑定
ax.plot(legend_path_b, color='black', linestyle='--', label='路径B')  # 把虚线与路径B标签绑定
ax.plot(legend_path_c, color='black', linestyle=':', label='路径C')  # 把点线与路径C标签绑定

ax.legend(loc='best')  # 图例列出带有图例的三条教学路径实际绘制的颜色与线型对应关系
plt.show()  # 核对实线、虚线、点线与路径A/B/C标签一一对应且未遮挡主要路径
图 10.10: 带有图例的三条教学路径

ax.legend()中的loc参数告诉matplotlib将图例放在哪里。默认值'best'会尝试找到对数据遮挡最少的位置。

10.2.4 注释与在子图上绘图

除了标准的图表类型,你可能还需要添加自定义的注释——文本、箭头或形状——来突出特定的特征。ax.textax.annotate函数就是用于此目的。

下面在本地沪深300指数序列上标注三个中国股票市场的重要时点,以演示文本、箭头和坐标的组合。日期标签是解释性注释,不等于对转折原因作因果识别。参见 图 10.11

# 本例只用本地沪深300收盘价,事件标签是时间定位提示,不代表因果识别
csi300 = index_data[index_data['symbol'] == '000300.XSHG'].copy()  # 筛选沪深300指数数据并保留副本
csi300['datetime'] = pd.to_datetime(csi300['datetime'].astype(str), format='%Y%m%d%H%M%S')  # 严格解析沪深300的14位交易时点,使三处事件箭头落在正确日期
csi300 = csi300[csi300['datetime'] >= '2005-01-01'].set_index('datetime')['close']  # 建立沪深300收盘价时间轴供事件箭头按日期定位

fig, ax = plt.subplots(figsize=(12, 6))  # 单轴绘制沪深300点位,并为日期—点位注释预留纵向空间
csi300.plot(ax=ax, color='black')                           # 绘制沪深300指数收盘价走势曲线

crisis_data = [  # 三个日期用于演示事件标注的定位方法
    (pd.Timestamp('2007-10-16'), '2007年牛市顶点'),  # 日期常量无须依赖模糊格式推断
    (pd.Timestamp('2015-06-12'), '2015年杠杆牛顶点'),  # Timestamp与指数的DatetimeIndex可直接比较
    (pd.Timestamp('2020-03-19'), '新冠疫情市场底部')  # 标签仅说明市场时点,不据此声称事件导致价格变化
]

for date, label in crisis_data:  # 为每个事件日期寻找可绘制的交易观测
    price_at_date = csi300.asof(date)  # 若日期不是交易日,取不晚于该日的最近有效收盘价
    ax.annotate(label,  # 文本和箭头共同显示事件日期与对应指数点位
                xy=(date, price_at_date),  # 箭头落点使用数据坐标而非画布像素
                xytext=(date, price_at_date + 1500),  # 标签上移1500指数点,避免遮住价格曲线
                arrowprops=dict(facecolor='black', headwidth=4, width=2, headlength=4),  # 箭头样式:黑色填充、宽4、箭头长4
                horizontalalignment='left',                 # 文字水平对齐方式为左对齐
                verticalalignment='top')                    # 文字垂直对齐方式为顶部对齐

ax.set_xlim(['2005-01-01', '2024-01-01'])                   # 横轴从沪深300基期延伸到2024年,覆盖三处事件注释
ax.set_title('沪深300指数与重大金融事件')  # 题名提示读者核对事件日期与指数位置,不把共时标注解释为因果效应
ax.set_ylabel('指数点位')  # 沪深300指数及重大金融事件注释的纵轴按指数点位计量,避免与收益率或频数混读
ax.set_xlabel('日期')  # 沪深300指数及重大金融事件注释把交易日期放在横轴,所有相邻点按时间先后解释
plt.show()  # 核对三个箭头落在不晚于题设日期的实际交易点位,文字不遮挡主曲线
图 10.11: 沪深300指数及重大金融事件注释

绘制形状需要创建patch对象,并使用ax.add_patch将其添加到子图中。让我们绘制几个基本形状,如 图 10.12 所示。这对于阐释理论概念非常有用,比如在供需图中说明消费者剩余。

fig, ax = plt.subplots()  # 单轴承载三类 patch,比较其锚点、尺寸和填充属性

rect = plt.Rectangle((0.2, 0.75), 0.4, 0.15, color='black', alpha=0.3)  # 创建黑色半透明矩形补丁
circ = plt.Circle((0.7, 0.2), 0.15, color='blue', alpha=0.3)  # 创建蓝色半透明圆形补丁
pgon = plt.Polygon([[0.15, 0.15], [0.35, 0.4], [0.2, 0.6]], color='green', alpha=0.5)  # 创建绿色半透明三角形补丁

ax.add_patch(rect)  # 将黑色半透明矩形加入当前Axes
ax.add_patch(circ)  # 将蓝色半透明圆形加入同一Axes
ax.add_patch(pgon)  # 将绿色半透明三角形加入同一Axes
plt.show()  # 核对矩形、圆形与多边形均进入同一 Axes,位置和透明度彼此可辨
图 10.12: 一个由三种不同形状构成的图

10.2.5 将图表保存到文件

你可以使用fig.savefig保存当前活动的图形,文件格式会根据扩展名自动推断。论文若需要无损缩放或后续编辑,可以选择 PDF 或 SVG;网页、演示文稿或只接受栅格图的投稿系统可以选择高分辨率 PNG。最终格式还应服从发布平台的字体、透明度、文件大小和浏览器兼容要求。dpi选项控制栅格输出的每英寸点数。

# 将两种格式写入本章跨块临时工作区,冷渲染不会改写项目根目录
png_example_path = PLOT_EXPORT_ROOT / 'my_figure.png'  # 为网页示例建立临时PNG路径
pdf_example_path = PLOT_EXPORT_ROOT / 'my_figure.pdf'  # 为出版示例建立临时PDF路径
fig.savefig(png_example_path, dpi=400)  # 把当前图形导出为高分辨率PNG
fig.savefig(pdf_example_path)  # 把同一图形导出为矢量PDF
assert png_example_path.read_bytes().startswith(b'\x89PNG')  # 回读文件签名以核验PNG导出器实际写入
assert pdf_example_path.read_bytes().startswith(b'%PDF')  # 回读文件签名以核验PDF导出器实际写入

表 10.3 列出了一些关键的savefig选项。

表 10.3: fig.savefig 的一些选项
参数 描述
fname 包含文件路径的字符串或Python文件类对象。
dpi 图形分辨率,单位为每英寸点数。
facecolor, edgecolor 子图外图形背景的颜色。
format 显式文件格式(‘png’, ‘pdf’, ’svg’等)。
bbox_inches 要保存的图形部分。’tight’常用于裁剪空白。

10.2.6 格式、分辨率与可访问性

导出决策应从读者和媒介出发。线条、文字和标记构成的论文图优先用 SVG 或 PDF:它们是矢量格式,缩放时不会因像素不足而模糊。网页缩略图、像素型热图或必须兼容办公软件的场景可用 PNG。dpi 只决定栅格输出的像素密度;若画布宽 \(w\) 英寸、高 \(h\) 英寸,以 \(d\) DPI 导出,则像素尺寸为 \(wd\times hd\)。因此,提高 DPI 会增加文件体积,却不能修复字号过小、标签错误或低对比度。

可访问性不是导出后的装饰,而是编码合同:

  • 不仅用红—绿或明暗区分类别,还要提供线型、标记大小、直接标签或分面等冗余编码;
  • 轴标题必须包含变量与单位,图题或图注必须说明证券、样本期、数据来源和缺失处理;
  • 正文或发布系统中的替代文本应描述图形目的、主要比较和重要限制,而不是机械重复标题;
  • 检查文字—背景对比度、灰度打印辨识度以及缩小到最终版面后的字号;
  • 双 Y 轴必须让轴色、序列色和单位一一对应,并说明它展示的是共时变化,不是因果或尺度可比性。

习题 10.6 要求学生同时交付静态图、格式选择、像素证据和替代文本摘要,从而直接检验上述合同。

10.2.7 matplotlib配置

matplotlib有一个广泛的配置系统,用于自定义默认设置,如图形大小、字体样式、颜色等。你可以使用plt.rc以编程方式修改这些设置。第一个参数是要自定义的组件(例如'figure', 'axes'),后面跟着新参数的关键字参数。

# 将默认图形尺寸设置得更大
plt.rc('figure', figsize=(10, 6))

# 为所有绘图元素设置字体属性
plt.rc('font', family='serif', weight='normal', size=12)

你可以随时通过调用plt.rcdefaults()恢复默认设置。对于持久性的更改,你可以修改matplotlibrc配置文件。

10.3 使用pandas和seaborn绘图

matplotlib 提供细粒度对象控制,但标准统计图往往需要较多设置。pandas 的绘图方法封装常见 DataFrame 映射;seaborn 则提供面向统计变量、分组和分面的接口。选择上层接口后仍应核对其生成的轴、聚合和不确定性语义。

10.3.1 线图

pandas中的SeriesDataFrame对象都有一个.plot属性,为创建各种图表提供了便捷的接口。默认情况下,它会创建一个线图。对象的索引被用作x轴。图 10.13 显示了一个由Series对象生成的简单图表。

sse.loc['2023'].head(60).plot()  # 截取前六十个真实交易日展示索引驱动的默认横轴
plt.ylabel('指数点位')  # 明确纵轴是市场点位而非收益率或金额
plt.show()  # 核对 Series 的 DatetimeIndex 自动成为横轴,纵轴保持指数点位单位
图 10.13: 上证综指收盘点位的Series线图

表 10.4 列出了Series.plot方法的一些参数。

表 10.4: Series.plot 方法参数
参数 描述
label 图例的标签。
ax 用于绘图的matplotlib子图对象。
style 样式字符串,例如 'k--'
kind 图表类型:'line', 'bar', 'barh', 'hist', 'kde'等。
logy 在y轴上使用对数刻度。
use_index 使用对象的索引作为刻度标签。
rot 刻度标签的旋转角度(0到360)。
xticks, yticks 用于轴刻度的值。
xlim, ylim 轴的限制范围。
grid 显示轴网格(默认为关闭)。

当绘制一个DataFrame时,每一列都会在同一个子图上被绘制为一条独立的线,并且会自动创建一个图例。下面比较本地数据中的七个中国主要市场指数。由于指数基日和点位尺度不同,这张图适合演示多列绘图;若要比较投资表现,应先归一化或改画收益率。结果如 图 10.14 所示。

# 七个指数共用本地行情表;点位未归一化,因此这里只演示多列绘图
import pandas as pd  # pandas在线图中负责时间索引对齐、列标签与缺失值传播

# 七个指数用于演示 DataFrame 多列绘图;输入合同只接受本地指数表中的 .XSHG/.XSHE 代码
index_name_by_symbol = {'000001.XSHG': '上证综指', '399001.XSHE': '深证成指', '000300.XSHG': '沪深300',  # 指数代码到中文名称的映射字典
                        '000688.XSHG': '科创50', '399006.XSHE': '创业板指', '000016.XSHG': '上证50', '000905.XSHG': '中证500'}  # 字典续行覆盖科创50、创业板指、上证50和中证500

index_data = index_data_all.copy()  # 复用章级只读指数基表,避免多列绘图再次扫描约 1.1GB 文件
assert all(symbol.endswith(('.XSHG', '.XSHE')) for symbol in index_name_by_symbol)  # 核验映射表符合指数数据输入合同
index_long_frames = []  # 收集七个date—Close Price—index_name长表片段
for index_symbol, index_name in index_name_by_symbol.items():  # 逐个指数建立同schema序列,随后按交易日横向对齐
    current_index_df = index_data.loc[index_data['symbol'].eq(index_symbol), ['datetime', 'close']].copy()  # 只从指数表筛选当前合同代码
    current_index_df['datetime'] = pd.to_datetime(current_index_df['datetime'].astype(str), format='%Y%m%d%H%M%S')  # 按来源14位格式解析交易时点
    current_index_df['index_name'] = index_name  # 保留中文指数名作为宽表列标签和图例来源
    current_index_df = current_index_df.rename(columns={'close': 'Close Price', 'datetime': 'date'})  # 统一为日期、点位和指数名三列合同
    index_long_frames.append(current_index_df)  # 收集当前指数片段供循环后纵向拼接
market_indices = pd.concat(index_long_frames).dropna()       # 删除各指数自身缺失记录;共同交易日由后续 unstack 对齐
market_indices = market_indices[market_indices['date'] >= '2020-01-01']  # 筛选2020年以后的数据进行对比
market_indices = market_indices.set_index(['date', 'index_name'])['Close Price'].unstack('index_name')  # 展开为日期×市场宽表,供多序列折线共享横轴

# 将日期×指数宽表绘成共享交易日横轴的多序列折线
market_indices.plot(figsize=(10, 6))                        # 绘制多个市场指数的走势对比图
plt.title('中国主要市场指数点位 (2020年至今)')  # 题名明确比较的是原始点位,不能直接据此排序投资回报
plt.ylabel('指数点位')  # 纵轴单位为各指数自身点位,基日不同会影响水平高低
plt.xlabel('日期')  # 横轴按真实交易日对齐,个别指数缺日会形成折线断点
plt.legend(title='指数')  # 图例把宽表七列映射回指数名称
plt.grid(True)  # 弱网格用于估读时间与点位,不编码额外变量
plt.show()  # 核对七列均进入图例,并检查2020年后的日期覆盖与缺失断点
图 10.14: 中国主要市场指数走势对比

DataFrame.plot方法有处理列的额外选项。例如,你可以在不同的子图中绘制每一列。这些选项总结在 表 10.5 中。

表 10.5: DataFrame特有的plot参数
参数 描述
subplots 在单独的子图中绘制DataFrame的每一列。
sharex 如果subplots=True,则共享相同的x轴。
sharey 如果subplots=True,则共享相同的y轴。
figsize 要创建的图形尺寸,以元组形式表示。
title 图表的标题。
legend 添加子图图例(默认为True)。

10.3.2 条形图

plot.bar()plot.barh()方法分别创建垂直和水平的条形图。SeriesDataFrame的索引被用作刻度标签。下面读取本地上市公司基本信息,统计公司数量最多的六个行业,并用垂直和水平条形图在 图 10.15 中展示同一组真实频数。

stock_basic_df = pd.read_hdf(f'{DATA_ROOT}/stock/stock_basic_data.h5')  # 复用首块数据入口读取真实公司分类
# 只统计在册公司并选取频数最高的六个行业,控制图面拥挤
industry_company_counts = (
    stock_basic_df.loc[stock_basic_df['status'].eq('Active'), 'industry_name']
    .dropna()
    .value_counts()
    .head(6)
    .sort_values()
)

fig, axes = plt.subplots(2, 1, figsize=(8, 8))  # 上下两轴展示同一组六行业频数,仅比较条形方向对标签空间的影响

industry_company_counts.plot.bar(ax=axes[0], color='black', alpha=0.7)  # 用垂直柱比较六个行业的公司计数
axes[0].set_ylabel('公司数量')  # 标明柱高对应在册公司频数
axes[0].set_title('上市公司行业频数(垂直)')  # 标示垂直条形图的分类口径
axes[0].tick_params(axis='x', rotation=45)                  # 设置刻度参数


industry_company_counts.plot.barh(ax=axes[1], color='black', alpha=0.7)  # 用相同频数演示水平条形图标签空间
axes[1].set_xlabel('公司数量')  # 将计数单位放在水平数值轴
axes[1].set_title('上市公司行业频数(水平)')  # 强调与上图仅方向不同

plt.tight_layout()  # 为长行业标签与两个轴题留出边距,避免文字被裁切
plt.show()  # 核对上下图的公司频数完全一致,仅视觉方向不同
图 10.15: 本地上市公司数量最多的六个行业

对于一个DataFrame,条形图会将每一行中的值并排分组。下面对本地上市公司按交易所与板块类型交叉计数,比较分组条形图与堆叠条形图的读法。

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

active_basic_df = stock_basic_df.loc[stock_basic_df['status'].eq('Active')]  # 排除非在册公司以统一统计时点口径
exchange_board_counts_df = pd.crosstab(active_basic_df['board_type'], active_basic_df['exchange'])  # 交叉统计板块与交易所公司数
exchange_board_counts_df.plot.bar()  # 并排展示各板块在不同交易所的频数
plt.ylabel('公司数量')  # 明确纵轴为频数而非板块占比
plt.title('上市公司板块与交易所分布')  # 标明两维分类口径
plt.xticks(rotation=0)  # 板块标签较短,保持水平便于在各交易所柱组间比较
plt.show()  # 核对每个板块的分组柱总数与交叉频数表对应
图 10.16: 上市公司板块与交易所的分组频数

通过传递stacked=True,我们可以创建堆叠条形图,其中每一行的值会堆叠在一起。这对于同时观察总量与内部构成很有效。图 10.17 展示同一张真实交叉表的堆叠格式。

exchange_board_counts_df.plot.barh(stacked=True, alpha=0.7)  # 堆叠同一交叉表以同时观察总量与构成
plt.xlabel('公司数量')  # 将总频数放在水平数值轴
plt.title('上市公司板块与交易所分布')  # 保持与并排图一致的比较对象
plt.legend(title='交易所')  # 用颜色图例区分交易所构成
plt.show()  # 核对每根横条总长等于板块公司总数,色段表示交易所构成
图 10.17: 上市公司板块与交易所的堆叠频数

分类数据可以先用value_countscrosstab汇总,再可视化频率。下面使用本地上市公司注册省份与板块类型,先构造交叉表,再按省份归一化。为避免小样本比例误导,只展示在册公司数量最多的十个省份。

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

province_board_df = active_basic_df[['province', 'board_type']].dropna()  # 仅保留省份和板块均可识别的公司
top_provinces = province_board_df['province'].value_counts().head(10).index  # 选择公司数最多的十省降低小样本噪声
# 在选定省份内交叉统计各板块公司数
province_board_counts = pd.crosstab(
    province_board_df.loc[province_board_df['province'].isin(top_provinces), 'province'],
    province_board_df.loc[province_board_df['province'].isin(top_provinces), 'board_type'],
)
province_board_shares = province_board_counts.div(province_board_counts.sum(axis=1), axis=0)  # 按省内总数归一化为构成比例
province_board_shares  # 核对各省份行比例之和为一
表 10.6: 主要注册省份内各板块上市公司比例
board_type GEM KSH MainBoard
province
上海市 0.184932 0.210046 0.605023
北京市 0.287611 0.170354 0.542035
四川省 0.275449 0.125749 0.598802
安徽省 0.235632 0.137931 0.626437
山东省 0.235294 0.076125 0.688581
广东省 0.378063 0.107351 0.514586
江苏省 0.307229 0.171687 0.521084
浙江省 0.265043 0.074499 0.660458
湖北省 0.283688 0.120567 0.595745
福建省 0.267442 0.058140 0.674419

现在用堆叠条形图可视化这些样本内比例(图 10.18)。每根柱子的总高度为1,色块表示该省份在册上市公司分布于不同板块的比例;它不代表省域经济总量。

province_board_shares.plot.bar(stacked=True, figsize=(10, 7))  # 用百分比堆叠柱比较省内板块构成
plt.title('主要注册省份的上市公司板块构成')  # 标明样本限定为主要注册省份
plt.ylabel('省内比例')  # 避免将归一化结果误读为公司总数
plt.xlabel('注册省份')  # 说明横轴采用公司注册地口径
plt.xticks(rotation=45, ha='right')  # 旋转省份标签并右对齐到各自柱中心,避免相邻文字重叠
plt.legend(title='板块类型')  # 用图例标识各色块对应的板块类别
plt.show()  # 核对每省堆叠柱高为1,色段只表示省内板块比例
图 10.18: 主要注册省份内各板块上市公司比例

对于涉及此类聚合的分析,seaborn提供了更高层的统计绘图接口。下面从本地季度估值因子表读取2021年pe_ratio_ttm,与上市公司行业信息合并,剔除非正值和极端高值后计算行业季度中位数。这个筛选口径必须随图表一同报告,因为它会影响横向比较。

import seaborn as sns  # 用统计绘图接口编码行业、季度与市盈率中位数

# 读取季度估值因子并只投影市盈率字段,保留公司—日期索引血缘
valuation_df = (
    pd.read_hdf(f'{DATA_ROOT}/stock/valuation_factors_quarterly_15_years.h5', 'valuation_factors')
    [['pe_ratio_ttm']]
    .reset_index()
)
# 合并行业维度并应用正市盈率与上限筛选,固定 2021 年比较样本
valuation_2021_df = (
    valuation_df.loc[valuation_df['date'].dt.year.eq(2021)]
    .merge(stock_basic_df[['order_book_id', 'industry_name']], on='order_book_id', how='left', validate='many_to_one')
    .loc[lambda x: x['pe_ratio_ttm'].between(0, 200) & x['industry_name'].notna()]
)
top_valuation_industries = valuation_2021_df['industry_name'].value_counts().head(7).index  # 选择有效观测最多的七个行业
# 按季度和行业取中位数,降低单家公司极端估值的影响
china_industry_valuation_df = (
    valuation_2021_df.loc[valuation_2021_df['industry_name'].isin(top_valuation_industries)]
    .assign(quarter=lambda x: x['date'].dt.to_period('Q').astype(str))
    .groupby(['quarter', 'industry_name'], as_index=False)['pe_ratio_ttm']
    .median()
)

plt.figure(figsize=(10, 6))  # 采用横向条形所需宽高比,为七个行业名称保留纵向空间
sns.barplot(x='pe_ratio_ttm', y='industry_name', data=china_industry_valuation_df, color='0.45', seed=20261001)  # 固定自助法种子并保留默认区间,比较行业季度中位数的跨季度均值
plt.title('2021年A股各行业市盈率中位数')  # 题名锁定2021年和筛选后行业样本,条长比较季度中位数的均值
plt.show()  # 核对横轴为0—200筛选后的市盈率倍数,纵轴为七个有效观测最多行业
图 10.19: 2021年A股各行业季度市盈率中位数(真实数据)

图 10.19 中,每个行业有四个季度中位数,条形表示这些季度值的均值,误差线是seaborn基于固定随机种子重复抽样得到的默认自助法区间;它描述季度间变动,并不是个股总体参数的严格置信区间。hue参数可以引入季度维度,直接显示行业内的时间差异。

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

plt.figure(figsize=(12, 8))  # 扩大横向空间以容纳四季度分组柱和图外图例
sns.barplot(x='pe_ratio_ttm', y='industry_name', hue='quarter', data=china_industry_valuation_df)  # 用色相拆分季度以保留时间异质性
plt.title('2021年各季度行业市盈率中位数')  # 明确每根柱对应行业—季度中位数
plt.legend(title='季度', bbox_to_anchor=(1.05, 1), loc=2)  # 将季度图例移至图外避免遮挡柱体
plt.tight_layout()  # 为行业标签与移至图外的季度图例预留边距
plt.show()  # 核对同一行业的四季度柱均可见,避免均值条掩盖时间异质性
图 10.20: 按季度区分的行业市盈率中位数

10.3.3 直方图和密度图

直方图是一种条形图,它以离散化的方式显示数值的频率。下面考察本地上证综指的日对数收益率分布。金融收益常呈现尖峰厚尾,但是否偏离正态仍应通过统计检验和样本外验证判断。直方图先提供直观诊断,如 图 10.21 所示。

# 相邻收盘价之比取对数形成上证综指日收益,首期因没有前值而剔除
sse_ret = np.log(sse / sse.shift(1)).dropna()  # 结果以小数表示,不是百分数

plt.figure(figsize=(10, 6))  # 单轴保留足够横向空间观察收益分布两端
sse_ret.plot.hist(bins=100)                                 # 以100个等宽箱显示上证综指日对数收益的样本频数
plt.title('上证综指日对数收益率分布')  # 读图任务是检查中心、偏度与尾部,不能由形状直接断言分布模型
plt.xlabel('对数收益率')                                         # 横轴注明“对数收益率”,界定比较变量及其度量口径
plt.show()  # 观察上证综指日对数收益率的直方图的集中位置、离散程度与尾部
图 10.21: 上证综指日对数收益率的直方图

一个相关的图是密度图,或称核密度估计(Kernel Density Estimate, KDE),它从数据中计算连续概率分布的平滑估计。图 10.22 显示同一上证综指收益样本的KDE。

plt.figure(figsize=(10, 6))  # 使用与直方图相同宽高比,便于比较离散分箱和平滑估计
sse_ret.plot.density()  # 在连续横轴上平滑估计同一收益样本的概率密度
plt.title('上证综指日对数收益率的密度估计')  # 题名注明这是带宽依赖的KDE,不是已知总体密度
plt.xlabel('对数收益率')                                         # 横轴注明“对数收益率”,界定比较变量及其度量口径
plt.show()  # 观察上证综指日对数收益率的密度图的集中位置、离散程度与尾部
图 10.22: 上证综指日对数收益率的密度图

seaborn.histplot 可以同时绘制直方图和 KDE。为了单独说明“双峰”形状,下面从成分 A \(N(0,1^2)\) 和成分 B \(N(5,3^2)\) 各生成 200 个观测,再以等样本权重合并。固定随机种子只保证示例可重复;这 400 个数是模型题设,不是市场数据,不能用于推断任何资产类别的收益分布。结果如 图 10.23 所示。

mixture_rng = np.random.default_rng(2026)  # 固定种子使双峰机制图可重复渲染
component_a = mixture_rng.normal(0, 1, size=200)  # 构造低均值、低波动的第一教学成分
component_b = mixture_rng.normal(5, 3, size=200)  # 构造高均值、高波动的第二教学成分
mixed_returns = pd.Series(np.concatenate([component_a, component_b]))  # 合并两成分以形成可辨识双峰形状

sns.histplot(mixed_returns, bins=100, color='black', kde=True)  # 绘制两成分模型题设的直方图并叠加核密度曲线
plt.title('两个正态成分的混合分布')  # 标明图形来自机制题设而非真实资产收益
plt.show()  # 观察教学用双峰分布的直方图和KDE的集中位置、离散程度与尾部
图 10.23: 教学用双峰分布的直方图和KDE

10.3.4 散点图

散点图适合考察两个连续变量的共同变化。下面使用本地四个中国股票指数的真实季度收益率研究市场联动。指数收益可以反映不同市场板块的共同波动,但不能被改称 CPI、货币供应量、利率或失业率。

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

import pandas as pd  # 将四个指数按季度末日期对齐并保留可读列标签
import numpy as np  # 对季度末点位取对数差分并检查有限值

# 建立四个指数代码到中文名称的可追溯映射
index_names = {
    '000001.XSHG': '上证综指',
    '399001.XSHE': '深证成指',
    '000300.XSHG': '沪深300',
    '000016.XSHG': '上证50'
}

# 本地指数表提供可复核的季度末点位,不以随机数替代金融样本
index_data = index_data_all.copy()  # 从章级只读基表复制四指数数据,避免季度收益示例重复全量 I/O
index_data['datetime'] = pd.to_datetime(index_data['datetime'].astype(str), format='%Y%m%d%H%M%S')  # 把指数库14位时点恢复为日期,保证季度末收益按真实时间重采样
# 筛选需要的指数并重塑
index_subset = index_data[index_data['symbol'].isin(index_names)].copy()
index_levels_df = index_subset.pivot(index='datetime', columns='symbol', values='close')  # 按日期对齐四个指数点位
index_levels_df = index_levels_df.rename(columns=index_names).sort_index()  # 替换可读列名并建立单调时间顺序
quarter_end_levels_df = index_levels_df.resample('QE').last()  # 取每季最后真实指数观测
quarterly_index_returns_df = np.log(quarter_end_levels_df).diff().dropna()  # 计算相邻季末点位的对数收益
quarterly_index_returns_df.tail()  # 核对输出为季度行、四指数收益列,且最近五季均为共同有效样本
表 10.7: 中国主要股票指数季度对数收益率(最近5个季度)
symbol 上证综指 上证50 沪深300 深证成指
datetime
2025-03-31 -0.004790 -0.007153 -0.012172 0.008578
2025-06-30 0.032061 0.017239 0.012469 -0.003740
2025-09-30 0.119794 0.097237 0.164679 0.256604
2025-12-31 0.021923 0.014016 -0.002320 -0.000110
2026-03-31 0.036881 0.011602 0.016367 0.049115

seaborn.regplot 可以绘制散点、线性拟合和置信区间。图 10.24 比较上证综指与沪深 300 的季度收益,并固定自助法随机种子以使置信区间可重复。拟合线用于描述样本相关性,不代表因果关系。

sns.regplot(x='上证综指', y='沪深300', data=quarterly_index_returns_df, seed=20261002)  # 固定自助法种子并保留默认区间,描述季度收益的样本线性关系
plt.title('上证综指与沪深300季度收益率的样本关系')  # 避免将拟合线表述为因果效应
plt.show()  # 检查散点方向、离群季度与拟合不确定区间,不作因果解释
图 10.24: 上证综指与沪深300季度对数收益率

在探索性分析中,查看一组变量的成对散点图通常很有用。seaborn.pairplot 在非对角单元显示两两关系,在对角线上显示各变量的边际分布。图 10.25 展示四个真实市场指数的季度收益分布与联动。

sns.pairplot(quarterly_index_returns_df, diag_kind='kde', plot_kws={'alpha': 0.4})  # 同时查看边际分布与全部两两关系
plt.show()  # 对角线检查各指数边际分布,非对角面板检查两两联动与离群季度
图 10.25: 中国主要股票指数季度收益率配对图矩阵

10.3.5 分面网格与分类数据

对于具有多个分类分组变量的数据集,分面网格(facet grid)把行和列映射到分类变量水平,使各面板共享同一比较规则。seaborn.catplot 提供了相应接口。

继续使用上一节的真实指数收益。我们把宽表转成长表,再按年份划分市场阶段。图 10.26 用列分面比较四个指数在不同阶段的季度收益分布;“市场阶段”只是一种展示分组,并不暗示制度变化或因果关系。

# 将季度收益宽表转换为指数—季度长表以支持分面绘图
index_return_long_df = (
    quarterly_index_returns_df
    .rename_axis('quarter')
    .reset_index()
    .melt(id_vars='quarter', var_name='index_name', value_name='quarterly_return')
)
# 按预先给定年份边界分组,仅用于展示不同时段的样本分布
index_return_long_df['period'] = pd.cut(
    index_return_long_df['quarter'].dt.year,
    bins=[2004, 2014, 2025],
    labels=['2005—2014', '2015—2025']
)

# 按时期分面,用箱线图比较四个指数季度收益分布
sns.catplot(
    data=index_return_long_df.dropna(),
    x='quarterly_return', y='index_name', col='period',
    kind='box', height=4.5, aspect=1.1
)
plt.show()  # 在统一收益横轴上比较两个时期内四指数的中位数、离散度与异常季度
图 10.26: 按市场阶段分面的中国主要指数季度收益率

分面也可以按指数展开。图 10.27 以每个指数为一个面板,比较两个阶段的中位数、四分位距和异常季度。箱线图概括分布,不应被解释为未来收益预测。

# 按指数分面,直接比较两个展示时期的中位数与四分位距
sns.catplot(
    data=index_return_long_df.dropna(),
    x='period', y='quarterly_return', col='index_name', col_wrap=2,
    kind='box', height=4, aspect=1.3
)
plt.xticks(rotation=45, ha='right')  # 旋转两个时期标签,避免在窄分面内重叠
plt.tight_layout()  # 为四个指数分面题与时期标签留出边距
plt.show()  # 逐指数比较两个时期的季度收益分布,不将时期差异解释为制度因果效应
图 10.27: 按指数分面的不同阶段季度收益率箱线图

10.4 其他Python可视化工具

Python的可视化生态系统是广阔且不断发展的。虽然我们本章的重点是使用matplotlibpandasseaborn进行用于分析和出版的静态图形,但大量的开发工作都集中在为Web创建交互式图形上。

PlotlyBokehAltair这样的库,允许你构建动态的、可交互的可视化,这对于基于Web的仪表盘、论文的在线附录或面向客户的应用来说是理想的选择。学习这些工具可以是有价值的下一步,特别是如果你期望在数据科学、咨询或政策分析等领域发展,因为在这些领域,交互式数据探索是关键。

10.5 习题

请先按题面完成设计和代码,再展开完整解答。网页默认折叠答案,打印时自动展开。每题标注核心或选修身份、先修、预计工作量和评分产出,使长综合任务可以独立安排为课后项目。

10.5.1 习题 10.1: 基础折线图与价格走势

定位与产出:核心基础题;先修为对象式折线图、轴标签和图例;预计 30—40 分钟;提交价格区间图、均值线、单位完整的轴和样本统计核验。

问题描述

使用宁波港 (601018.SH) 的股票数据,创建轴、单位、图例和样本范围完整的价格走势图:

  1. 绘制收盘价折线图
  2. 添加开盘价、最高价、最低价
  3. 添加标题、标签、图例和网格
  4. 使用颜色与线型的冗余编码,并保证灰度阅读时仍能辨识
展开习题 10.1 完整解答

10.5.1.1 习题 10.1 完整解答

图 10.28 给出本题的可复核解答。

import pandas as pd  # 读取宁波港日行情并以交易日索引对齐四条价格图层
import matplotlib.pyplot as plt  # 创建单一价格 Axes 并管理线、区间、均值与文字标注

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 使用项目指定字体,避免证券名与人民币单位缺字
plt.rcParams['axes.unicode_minus'] = False  # 保留坐标轴标准负号字形

stock_data_full = pd.read_hdf(  # 读取宁波港前复权日线,供一月开盘、收盘及高低区间作图
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',
    where="order_book_id='601018.XSHG'"  # 将601018.XSHG条件下推到stock_data_full的HDF查询
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开宁波港交易日并统一成交量字段,得到一月价格图所需 schema
stock_data_full['datetime'] = pd.to_datetime(stock_data_full['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析宁波港一月价格走势的交易日

# 筛选宁波港的数据和日期范围
port_stock_data = stock_data_full[(stock_data_full['datetime'] >= '2023-01-01') &  # 筛选2023年1月的宁波港股票数据
                      (stock_data_full['datetime'] <= '2023-01-31')].copy()  # 复制筛选结果避免修改原始数据
port_stock_data.set_index('datetime', inplace=True)  # 以datetime索引绘制宁波港一月收盘价时序

# 用单一价格面板叠加开盘、收盘和日内高低区间
fig, ax = plt.subplots(figsize=(14, 6))  # 单轴叠加开盘、收盘与日内高低区间,三者共用人民币价格尺度

# 绘制价格线
ax.plot(port_stock_data.index, port_stock_data['close'], label='收盘价', color='#2C3E50', linewidth=2)  # 绘制收盘价时序曲线(深蓝灰实线)
ax.plot(port_stock_data.index, port_stock_data['open'], label='开盘价', color='#008080', alpha=0.6, linewidth=1.5, linestyle='--')  # 绘制开盘价时序曲线(青色虚线)
ax.fill_between(port_stock_data.index, port_stock_data['low'], port_stock_data['high'], alpha=0.2, color='#E3120B', label='价格区间')  # 用浅红面积编码每日最低价至最高价区间

# 题名锁定宁波港一月样本,横轴为交易日,纵轴以人民币元计价
ax.set_title('宁波港 (601018.SH) 股票价格走势 - 2023年1月',  # 题名固定证券与样本月,读图比较开收盘及日内区间
             fontsize=16, fontweight='bold', pad=20)  # 加大题名并留出上边距,避免与图层相碰
ax.set_xlabel('日期', fontsize=12, labelpad=10)  # 宁波港股票价格走势图(2023年1月)把交易日期放在横轴,所有相邻点按时间先后解释
ax.set_ylabel('价格(元)', fontsize=12, labelpad=10)  # 纵轴使用前复权价格的人民币元口径

# 设置网格和图例
ax.grid(True, alpha=0.3, linestyle=':')  # 宁波港股票价格走势图(2023年1月)使用低视觉权重网格辅助估读纵轴,不把网格编码为数据
ax.legend(loc='upper left', fontsize=11, framealpha=0.9)  # 图例区分开盘、收盘和高低区间三种编码

# 格式化x轴日期
import matplotlib.dates as mdates  # 提供交易日刻度的月—日格式化器
ax.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))  # 把一月交易日刻度格式化为月-日,保留底层时间位置
plt.xticks(rotation=45)  # 旋转月—日标签,保持刻度仍绑定真实交易日位置
(array([19362., 19366., 19370., 19374., 19378., 19382., 19386., 19389.]),
 [Text(19362.0, 0, '01-05'),
  Text(19366.0, 0, '01-09'),
  Text(19370.0, 0, '01-13'),
  Text(19374.0, 0, '01-17'),
  Text(19378.0, 0, '01-21'),
  Text(19382.0, 0, '01-25'),
  Text(19386.0, 0, '01-29'),
  Text(19389.0, 0, '02-01')])
(a) 宁波港股票价格走势图(2023年1月)
(b)
图 10.28
# 三项样本统计量用于给价格图提供位置和范围参照
mean_price = port_stock_data['close'].mean()  # 一月收盘价算术均值对应图中的水平基准
max_price = port_stock_data['close'].max()  # 一月样本内最高收盘价,不等同于日内最高价
min_price = port_stock_data['close'].min()  # 一月样本内最低收盘价,不等同于日内最低价

ax.axhline(y=mean_price, color='gray', linestyle=':', alpha=0.7, label=f'均价: {mean_price:.2f}')  # 水平线把一月平均收盘价映射回同一人民币价格轴
ax.text(port_stock_data.index[-1], mean_price, f' 均价 {mean_price:.2f}',  # 在最后交易日位置标注一月平均收盘价
        verticalalignment='center', fontsize=10)            # 文本垂直居中对齐,字号为10

plt.tight_layout()  # 为旋转日期、人民币轴题和图例保留边距
plt.show()  # 核对均值线位置与文本统计一致,并检查日内区间包围开收盘线

# 文本输出复核图中均值线与样本极值,并补充相对离散度
print('=== 价格统计 ===')                                       # 将图中均值线与样本收盘价范围集中核验
print(f'最高价: {max_price:.2f}')  # 核对样本最高收盘价,不与日内 high 混读
print(f'最低价: {min_price:.2f}')  # 核对样本最低收盘价,不与日内 low 混读
print(f'平均价: {mean_price:.2f}')  # 核对图中水平参考线的精确数值
print(f'价格波动率: {(port_stock_data["close"].std() / mean_price * 100):.2f}%')  # 以变异系数描述价格水平离散度,不是收益波动率
<Figure size 672x480 with 0 Axes>
=== 价格统计 ===
最高价: 3.37
最低价: 3.23
平均价: 3.28
价格波动率: 1.15%

关键要点: - 使用 figsize 控制图表尺寸 - linewidthalpha 控制线条粗细和透明度 - fill_between 创建填充区域 - 使用颜色、线型和直接标注建立冗余编码 - 添加可复算统计信息,帮助核对图中位置与范围 - 格式化日期标签提高可读性


10.5.2 习题 10.2: K线图(蜡烛图)

定位与产出:核心应用题;先修为 Matplotlib 图元、共享横轴和 OHLCV 字段;预计 50—70 分钟;提交 K 线、成交量面板、均线、图例及价格—成交量单位审计。

问题描述

K 线图把单期 OHLC 编码为实体与影线。请使用宁波港数据:

  1. 绘制字段、单位和日期范围可核验的 K 线图
  2. 使用红色表示上涨,绿色表示下跌
  3. 添加成交量柱状图
  4. 标注重要价格点
展开习题 10.2 完整解答

10.5.2.1 习题 10.2 完整解答

图 10.29 给出本题的可复核解答。

import pandas as pd  # 读取并按交易日索引宁波港 OHLCV 行情
import matplotlib.pyplot as plt  # 创建共享交易日的价格与成交量上下双轴
from matplotlib.patches import Rectangle                    # 用矩形实体编码K线开盘—收盘区间
from matplotlib.lines import Line2D                         # 构造涨跌颜色与均线样式的图例代理
import matplotlib.dates as mdates  # 为K线交易日横轴提供日期定位器与格式化器

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 保证证券名、价格单位与图例在导出时可读
plt.rcParams['axes.unicode_minus'] = False  # 保留价格轴标准负号字形

stock_data = pd.read_hdf(  # 读取宁波港一月行情以绘制OHLC与成交量
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',
    where="order_book_id='601018.XSHG'"  # 仅载入K线练习所需的港口证券
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 恢复交易日列并对齐行情字段合同,保证图层共享同一横轴

stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析一月K线与成交量的交易日
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-01-31')]  # K线和成交量共享2023年1月的交易日边界

port_stock_data = stock_data.copy()                         # 复制数据避免修改原始数据集
port_stock_data.set_index('datetime', inplace=True)  # 以datetime索引对齐一月K线与成交量柱

# 创建子图:价格和成交量
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10),  # 创建上下两个子图(价格+成交量)
                               gridspec_kw={'height_ratios': [3, 1]}, sharex=True)  # 价格图占纵向空间的3/4,共享x轴

# 定义K线颜色
def get_color(open_price, close_price):                     # 根据涨跌返回颜色的函数
    """按题设用红色编码上涨、绿色编码下跌。"""
    return '#E3120B' if close_price >= open_price else '#228B22'  # 收盘不低于开盘为红色,否则为绿色
图 10.29: 宁波港K线图与成交量
# 绘制K线
for idx, row in port_stock_data.iterrows():                 # 每次迭代用同一交易日OHLC构造一根完整K线
    date = idx  # 索引日期决定实体矩形和上下影线的共同横坐标
    open_price = row['open']  # 开盘价与收盘价共同确定实体底边和高度
    high = row['high']  # 最高价给出上影线端点,必须不低于实体上沿
    low = row['low']  # 最低价给出下影线端点,必须不高于实体下沿
    close = row['close']  # 收盘相对开盘的方向决定红涨或绿跌编码

    color = get_color(open_price, close)  # 根据涨跌确定当日K线颜色

    # 影线连接当日最低价与最高价,实体仅覆盖开盘—收盘区间
    ax1.plot([mdates.date2num(date)] * 2, [low, high],  # 同一日期坐标重复两次形成竖直高低价线
             color=color, linewidth=1)  # 使用涨跌颜色,线宽为1

    # 绘制实体(开盘收盘价矩形)
    height = abs(close - open_price)  # 实体高度=开盘价与收盘价之差的绝对值
    bottom = min(open_price, close)  # 实体底部取开盘价与收盘价中的较小值
    width = 0.6                                             # K线实体宽度设为0.6个日期单位

    rect = Rectangle((mdates.date2num(date) - width/2, bottom),  # 以日期中心偏移半宽为左下角创建矩形
                     width, height, facecolor=color, edgecolor=color)  # 宽高及填充色、边框色均由涨跌决定
    ax1.add_patch(rect)                                     # 将K线实体矩形添加到价格子图

# 设置价格轴
ax1.set_title('宁波港 (601018.SH) K线图 - 2023年1月',  # 题名固定证券与样本月,主面板判读开高低收和均线
              fontsize=16, fontweight='bold', pad=20)  # 为题名和上方面板留出可读间距
ax1.set_ylabel('价格(元)', fontsize=12, labelpad=10)  # 将`ax1`纵轴标为价格(元),避免把水平值误读为收益率
ax1.grid(True, alpha=0.3, linestyle=':')  # 价格面板使用淡网格辅助估读OHLC与均线水平

# 添加移动平均线
port_stock_data['MA5'] = port_stock_data['close'].rolling(window=5).mean()  # 在一月K线图上叠加5观测收盘均线
port_stock_data['MA10'] = port_stock_data['close'].rolling(window=10).mean()  # 收盘价十日均线只使用截至当日的10个交易观测,窗口未满保留缺失

ax1.plot(port_stock_data.index, port_stock_data['MA5'], label='MA5', color='#F0A700', linewidth=1.5)  # 绘制5日移动平均线
ax1.plot(port_stock_data.index, port_stock_data['MA10'], label='MA10', color='#8E9EAB', linewidth=1.5)  # 绘制10日移动平均线
ax1.legend(loc='upper left', fontsize=11)  # 图例区分5日、10日均线;K线涨跌方向由红蓝实体表示
# 绘制成交量
colors = [get_color(row['open'], row['close']) for _, row in port_stock_data.iterrows()]  # 根据每日涨跌生成对应颜色列表
ax2.bar(port_stock_data.index, port_stock_data['volume'], color=colors, alpha=0.6, width=0.6)  # 绘制成交量柱状图,颜色随涨跌变化

ax2.set_ylabel('成交量', fontsize=12, labelpad=10)  # 下方面板纵轴为每日成交股数,与上方人民币价格分轴
ax2.set_xlabel('日期', fontsize=12, labelpad=10)  # 共享横轴确保成交量柱与同日K线垂直对齐
ax2.grid(True, alpha=0.3, linestyle=':')  # 成交量面板淡网格用于估读柱高

# 格式化x轴
ax2.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))  # 设置x轴日期格式为月-日
plt.xticks(rotation=45)  # 旋转成交量面板的月—日标签,价格面板因共享横轴保持同一日期位置

plt.tight_layout()  # 为K线题名、均线图例和旋转日期标签保留边距
plt.show()  # 核对每根实体位于当日高低影线内,成交量柱与同日涨跌颜色一致

关键要点: - K线图由实体和影线组成 - 实体高度表示开盘价和收盘价的差 - 影线表示最高价和最低价 - 使用子图展示价格和成交量 - 移动平均线展示所选窗口下的历史价格平滑结果 - 颜色编码:红色上涨,绿色下跌


10.5.3 习题 10.3: 多股票对比图

定位与产出:核心应用题;先修为多子图、标准化价格、收益和相关矩阵;预计 55—75 分钟;提交三只证券的统一样本期、标准化路径、收益分布和相关热力图。

问题描述

对比宁波港、宁波银行和恒瑞医药的股票表现:

  1. 创建标准化价格对比图
  2. 绘制累计财富曲线
  3. 使用子图展示各股票
  4. 添加相关性热力图
展开习题 10.3 完整解答

10.5.3.1 习题 10.3 完整解答

图 10.30 给出本题的可复核解答。

import pandas as pd  # 按共同交易日对齐三只证券的前复权收盘价
import numpy as np  # 支持收益复合与相关矩阵的数值核验
import matplotlib.pyplot as plt  # 创建组合比较、单股路径与相关热力图画布
import seaborn as sns  # 将相关系数编码为带数值标注的色块

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 保证三只证券中文简称和轴题完整显示
plt.rcParams['axes.unicode_minus'] = False  # 使负收益与负相关系数使用标准负号

stock_data_port = pd.read_hdf(  # 载入宁波港收盘价,作为三股归一化比较的港口样本
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',
    where="order_book_id='601018.XSHG'"  # 将601018.XSHG条件下推到stock_data_port的HDF查询
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 将宁波港索引日期展开为 trade_date,并统一成交量列名
stock_data_nby = pd.read_hdf(  # 载入宁波银行收盘价,代表区域银行证券路径
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',
    where="order_book_id='002142.XSHE'"  # 只载入证券代码002142.XSHE的记录
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 恢复宁波银行交易日列,保持与另外两股相同字段合同
stock_data_hengrui = pd.read_hdf(  # 载入恒瑞医药收盘价,补充医药行业对照样本
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',
    where="order_book_id='600276.XSHG'"  # 只载入证券代码600276.XSHG的记录
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开恒瑞医药日期索引,使三股可按共同交易日内连接

stock_data_port['symbol'] = '601018.SH'  # 标注宁波港股票代码
stock_data_nby['symbol'] = '002142.SZ'  # 标注宁波银行股票代码
stock_data_hengrui['symbol'] = '600276.SH'  # 标注恒瑞医药股票代码
stock_data = pd.concat([stock_data_port, stock_data_nby, stock_data_hengrui])  # 纵向合并三只股票数据
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析三只股票全年比较的共同日期字段
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]  # 宁波港、宁波银行、恒瑞医药价格对比仅保留2023年交易日,使价格、成交量与分组统计共享样本期
图 10.30
# 名称和颜色映射固定三只证券在价格、收益与相关性图中的身份
symbols_map = {                                             # 股票代码到名称和颜色的映射字典
    '601018.SH': ('宁波港', '#E3120B'),  # 宁波港:红色
    '002142.SZ': ('宁波银行', '#00A4E6'),  # 宁波银行:蓝色
    '600276.SH': ('恒瑞医药', '#F0A700')  # 恒瑞医药:金色
}  # 三个键覆盖本题全部证券,名称与颜色在各面板保持一致

filtered = stock_data[stock_data['symbol'].isin(symbols_map.keys())].copy()  # 筛选三只目标股票并复制

# 数据准备
price_data = []                                             # 收集三只证券按中文简称命名的单列价格表
for symbol, (name, color) in symbols_map.items():  # 逐只把证券日收盘价整理为中文命名单列表
    data = stock_data[stock_data['symbol'] == symbol][['datetime', 'close']].copy()  # 按股票代码筛选日期和收盘价
    data.set_index('datetime', inplace=True)  # 以交易日索引对齐当前股票与另外两只股票的价格路径
    data.columns = [name]  # 将列名改为股票中文简称
    price_data.append(data)  # 将该股票价格序列加入列表

price_df = pd.concat(price_data, axis=1).dropna()           # 仅保留各序列共同有观测的交易日,统一跨资产样本

1. 标准化价格对比

fig, axes = plt.subplots(3, 2, figsize=(16, 18))  # 第一行比较归一价格与累计财富,后三个位置分别展示单股价格,末格留空

# 标准化价格(初始价格=100)
normalized = (price_df / price_df.iloc[0] * 100)  # 每列除以自身首个交易日价格,将三只证券共同归一到100

ax = axes[0, 0]  # 左上面板比较三条共同以100起步的标准化价格路径
for (symbol, (name, color)) in symbols_map.items():  # 按证券既定颜色绘制起点归一为100的价格路径
    ax.plot(normalized.index, normalized[name],             # 绘制各股票标准化价格曲线
            label=name, color=color, linewidth=2)  # 证券简称进入图例,颜色与后续单股面板一致

ax.set_title('标准化价格走势(初始=100)', fontsize=14, fontweight='bold')  # 题名写明共同基准100,路径高低才可跨证券比较
ax.set_ylabel('标准化价格', fontsize=11)                         # 纵轴注明“标准化价格”,避免把金额水平误读为收益率
ax.legend(loc='best', fontsize=11)  # 图例按固定颜色区分三只证券的标准化价格路径
ax.grid(True, alpha=0.3, linestyle=':')  # 淡网格辅助比较各路径相对共同基准100的距离

2. 累计财富

returns = price_df.pct_change(fill_method=None).fillna(0)  # 禁止隐式价格填补;共同首日结构性缺失设为0使财富路径从1起步
cumulative_wealth = (1 + returns).cumprod()                 # 按时间复合日简单收益,得到一元初始财富路径

ax = axes[0, 1]  # 右上面板比较三只证券的一元初始财富路径
for (symbol, (name, color)) in symbols_map.items():  # 按同一证券颜色绘制从1开始的累计财富路径
    ax.plot(cumulative_wealth.index, cumulative_wealth[name],  # 绘制各股票累计财富曲线
            label=name, color=color, linewidth=2)  # 图例与标准化价格面板复用同一证券颜色

ax.set_title('累计财富曲线(初始=1)', fontsize=14, fontweight='bold')  # 纵值是财富倍数而非单期收益率,基准线1表示不盈不亏
ax.set_ylabel('累计财富(初始=1)', fontsize=11)  # 纵轴为复合后的财富倍数,不是单期收益率
ax.legend(loc='best', fontsize=11)  # 图例按同一颜色映射区分三只证券的累计财富路径
ax.grid(True, alpha=0.3, linestyle=':')  # 淡网格辅助读取财富倍数相对基准线1的距离
ax.axhline(y=1, color='gray', linestyle='--', alpha=0.5)  # 财富倍数1表示相对共同起点不盈不亏

3. 各股票子图

for i, (symbol, (name, color)) in enumerate(symbols_map.items()):  # 依证券映射同时取得展示名与固定颜色,保证子图和总图编码一致
    # 三股价格面板从第二行开始排入3×2网格,第一行保留组合比较图
    r = 1 + i // 2  # 整除结果决定证券面板位于第二行还是第三行
    c = i % 2  # 奇偶位置把相邻证券分配到左右两列
    ax = axes[r, c]  # 将当前证券价格层绑定到上面算出的唯一面板
    ax.plot(price_df.index, price_df[name],                 # 绘制该股票收盘价走势
            color=color, linewidth=2, label=name)  # 延续组合图的证券颜色,并以简称进入图例
    ax.fill_between(price_df.index, price_df[name],         # 填充价格曲线下方区域
                    alpha=0.2, color=color)  # 同色半透明面积帮助追踪价格路径而不遮蔽网格
    ax.set_title(f'{name} 2023年收盘价', fontsize=12, fontweight='bold')  # 每幅题名绑定当前证券,便于按独立价格尺度判读年内路径
    ax.set_ylabel('价格(元)', fontsize=10)  # 单股面板纵轴保留人民币元,不与归一化总图混读
    ax.grid(True, alpha=0.3, linestyle=':')  # 淡网格辅助估读当前证券价格,不编码额外变量
    ax.legend(loc='best', fontsize=10)  # 图例再次标明当前颜色对应的证券简称

plt.tight_layout()  # 为五个已用面板的题名、图例和轴标签留出边距
plt.show()  # 核对三股颜色在总图与单股图中一致,且共同样本从同一交易日起步
<Figure size 672x480 with 0 Axes>

4. 相关性热力图

fig, ax = plt.subplots(figsize=(10, 8))  # 单轴展示三证券日收益的3×3相关矩阵

# 计算日收益率相关性
returns_corr = returns.corr()                               # 计算相关系数

sns.heatmap(returns_corr, annot=True, fmt='.3f', cmap='RdYlGn_r',  # 绘制相关系数热力图并标注数值
            center=0, square=True, linewidths=1,  # 以0为色阶中心,方形单元格,边框宽度1
            cbar_kws={'label': '相关系数'}, ax=ax)              # 设置色条标签并指定绑图坐标轴

ax.set_title('三只股票2023年日收益率相关性矩阵', fontsize=14, fontweight='bold', pad=20)  # 题名固定证券数量、样本年和日频口径
plt.tight_layout()  # 为矩阵标签、色条和题名留出边距
plt.show()  # 核对对角线为1、矩阵对称,并用色条读取相关方向与强度

# 数值表为图中的收益分布和相关性色阶提供精确复核值
print('=== 收益率统计 ===')                                      # 精确核验图中三列日收益的有效样本数、中心与离散度
print(returns.describe().round(4))  # 每列对应一只证券,首日填0口径与累计财富计算一致

print('\n=== 相关性矩阵 ===')                                    # 以数值表复核热力图颜色,避免只凭色差估读
print(returns_corr.round(4))  # 核对证券行列顺序、对称性与对角线单位值

=== 收益率统计 ===
            宁波港      宁波银行      恒瑞医药
count  242.0000  242.0000  242.0000
mean     0.0001   -0.0017    0.0009
std      0.0076    0.0179    0.0190
min     -0.0261   -0.0557   -0.0911
25%     -0.0054   -0.0114   -0.0097
50%      0.0000   -0.0035   -0.0006
75%      0.0056    0.0073    0.0087
max      0.0221    0.0879    0.0983

=== 相关性矩阵 ===
         宁波港    宁波银行    恒瑞医药
宁波港   1.0000  0.2646  0.0930
宁波银行  0.2646  1.0000  0.1476
恒瑞医药  0.0930  0.1476  1.0000

关键要点: - 标准化使不同价格水平的资产可比 - 累计财富展示一元初始投资按日简单收益复合后的路径 - 子图布局使用 gridspec_kwsubplots - 热力图直观展示相关性 - 配色保持一致性便于识别


10.5.4 习题 10.4: 分布可视化

定位与产出:核心进阶题;先修为收益率、直方图、KDE、Q-Q 图和经验 CDF;预计 55—75 分钟;提交四类分布诊断、数值分位数、Jarque–Bera 结果和不能由图形推出的结论。

问题描述

使用宁波港股票收益率数据,创建多种分布图:

  1. 直方图与KDE
  2. Q-Q图检验正态性
  3. 箱形图和小提琴图
  4. 累积分布函数图

统计诊断:Q-Q 图与金融收益率的“肥尾”判定

Q-Q 图(Quantile-Quantile Plot) 是量化分析中验证分布假设的关键策略。其核心逻辑是将样本的分位数与理论分布(如标准正态分布)的分位数进行一一映射。

  • 判读基准:若点群接近参考线,说明当前样本分位数与所选理论分布较为接近;这不是接受总体分布假设的充分证据。
  • 尾部诊断:若两端系统偏离参考线,说明当前样本尾部分位数与同参数理论基准不一致。是否可以称为厚尾、偏斜或异常点影响,还需要结合统计量、检验规格、样本期和数据质量判断。
展开习题 10.4 完整解答

10.5.4.1 习题 10.4 完整解答

import pandas as pd  # 读取宁波港日行情并沿交易日计算简单收益
import numpy as np  # 构造密度求值网格、经验分位数与累计概率
import matplotlib.pyplot as plt                             # 创建四幅互补的收益分布诊断图
import seaborn as sns  # 保留统计图形接口供分布可视化扩展
from scipy import stats                                     # 用正态概率图检验收益分位数与理论分位数的偏离
from IPython.display import display  # 在四个面板全部完成后显式呈现同一个Figure对象

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 保证证券名、分位数与统计检验中文标签完整显示
plt.rcParams['axes.unicode_minus'] = False  # 使负收益刻度保留标准负号

stock_data = pd.read_hdf(  # 读取宁波港全年行情以刻画日收益分布
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',
    where="order_book_id='601018.XSHG'"  # 将收益分布样本限定为601018.XSHG
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开日期并保留close字段供pct_change

stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析宁波港日收益分布的交易日
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]  # 宁波港收益率分布分析仅保留2023年交易日,使价格、成交量与分组统计共享样本期

# 筛选宁波港的数据并计算收益率
port_stock_data = stock_data.copy()                         # 复制数据以避免修改原始数据集
port_stock_data.set_index('datetime', inplace=True)  # 以datetime索引计算全年宁波港日收益分布
port_stock_data['returns'] = port_stock_data['close'].pct_change(fill_method=None)  # 不填补缺口并保留首日结构性缺失

returns = port_stock_data['returns'].dropna()               # 剔除收益变换产生的首期缺失,样本从第二个交易日开始

# 建立2×2面板分别检查密度、Q-Q、箱形—小提琴叠加与经验CDF
fig, axes = plt.subplots(2, 2, figsize=(14, 10))  # 四个Axes分别承载密度、Q-Q、两种分布摘要与CDF诊断

1. 直方图与KDE

ax = axes[0, 0]  # 左上面板把经验直方图和KDE放在同一密度尺度比较
ax.hist(returns, bins=50, density=True, alpha=0.6,          # density=True使柱面积之和为1,纵轴不是样本频数
        color='#2C3E50', edgecolor='white', linewidth=1)    # 深蓝灰色填充,白色边框

# KDE提供不依赖箱宽边界的平滑形状参照
from scipy.stats import gaussian_kde                        # 估计宁波港日收益的连续核密度曲线
kde = gaussian_kde(returns)  # 基于收益率数据构建核密度估计模型
x_range = np.linspace(returns.min(), returns.max(), 200)  # 在经验收益最小值至最大值间建立共同求值网格
ax.plot(x_range, kde(x_range), color='#E3120B',             # 绘制核密度估计曲线(红色)
        linewidth=2, label='KDE')  # 红线表示带宽平滑后的经验密度,与直方柱共享收益横轴

# 用样本均值和标准差构造描述性正态基准;曲线接近本身不构成正态性检验
mu, std = returns.mean(), returns.std()  # 参数与当前宁波港收益样本使用同一估计口径
normal_pdf = stats.norm.pdf(x_range, mu, std)  # 在KDE的同一横轴网格上计算基准密度,便于逐点比较
ax.plot(x_range, normal_pdf, color='#00A4E6',               # 绘制理论正态分布曲线(蓝色虚线)
        linewidth=2, linestyle='--', label='正态分布')  # 蓝色虚线标出同均值、标准差的正态密度基准

ax.set_title('宁波港2023年日收益:直方图与KDE', fontsize=12, fontweight='bold')  # 题名固定证券、样本年与日频,重点比较经验箱频与平滑形状
ax.set_xlabel('日收益率', fontsize=10)  # 横轴以小数口径表示宁波港日简单收益
ax.set_ylabel('密度', fontsize=10)  # 柱面积与两条曲线积分口径为1,纵轴不是交易日频数
ax.legend(loc='upper right', fontsize=10)  # 图例区分经验KDE与同均值、标准差的正态基准
ax.grid(True, alpha=0.3, linestyle=':')  # 淡网格辅助比较经验密度与正态基准的峰部和尾部

2. Q-Q图

ax = axes[0, 1]  # 右上面板比较经验分位数与理论正态分位数
stats.probplot(returns, dist='norm', plot=ax)  # 生成收益率的正态Q-Q图以检验正态性
ax.set_title('宁波港日收益正态Q-Q图', fontsize=12, fontweight='bold')  # 读图检查中部贴线程度与两端系统偏离
ax.grid(True, alpha=0.3, linestyle=':')  # 淡网格辅助识别中部贴线与两端偏离

3. 箱形图与小提琴图

# 箱形图与小提琴图复用同一returns样本并叠加在同一纵轴
ax = axes[1, 0]  # 左下面板叠加核密度轮廓与箱形摘要,为CDF保留右下面板
violin_plot = sns.violinplot(y=returns, ax=ax, inner=None, color='#8E9EAA', linewidth=1, bw_adjust=0.7, cut=0)  # 以0.7带宽因子平滑同一收益样本且不把密度延伸到样本范围外
for violin_collection in violin_plot.collections:  # 遍历小提琴图的密度图层以降低遮挡
    violin_collection.set_alpha(0.45)  # 半透明密度轮廓保留箱体、须和离群点的可辨识度

box_data = [returns.values]                                 # 将收益率数组包装为箱线图数据列表
bp = ax.boxplot(box_data, vert=True, patch_artist=True,     # 绘制纵向箱线图,启用填充色
                positions=[0], tick_labels=['宁波港'], widths=0.16)  # 把窄箱体叠在小提琴中心,横轴只标识同一证券

for box in bp['boxes']:  # 单组收益样本只有一个箱体,循环写法仍兼容后续增加资产
    box.set_facecolor('#E3120B')  # 红色窄箱体与灰色核密度轮廓形成冗余编码
    box.set_alpha(0.75)  # 保留中位数线同时让下层小提琴宽度仍可读取

for median in bp['medians']:  # 遍历所有中位数线图形对象
    median.set_color('white')  # 设置中位数线为白色以增强对比
    median.set_linewidth(2)  # 设置中位数线宽度为2

ax.set_title('同一样本的小提琴密度与箱形摘要', fontsize=12, fontweight='bold')  # 题名明确两图共享宁波港日收益样本,而非两组独立数据
ax.set_ylabel('日收益率', fontsize=10)  # 纵轴同时支持读取密度宽度、四分位、须和极端观测
ax.grid(True, alpha=0.3, axis='y', linestyle=':')  # 仅用横网格辅助读取密度形状与分位数所在收益水平

# 将四分位数直接写回箱体位置,避免只凭图形估读
quartiles = np.percentile(returns, [25, 50, 75])  # 25%、50%和75%分位与箱体下缘、中线、上缘一致
ax.text(0.08, quartiles[0], f' Q1: {quartiles[0]:.4f}',  # 在叠加图25%分位位置标注Q1数值
        verticalalignment='center', fontsize=9)  # 将Q1文字与25%分位水平线垂直对齐
ax.text(0.08, quartiles[1], f' 中位数: {quartiles[1]:.4f}',  # 在叠加图50%分位位置标注中位数
        verticalalignment='center', fontsize=9)  # 将中位数文字对齐箱体中央统计位置
ax.text(0.08, quartiles[2], f' Q3: {quartiles[2]:.4f}',  # 在叠加图75%分位位置标注Q3数值
        verticalalignment='center', fontsize=9)  # 将Q3文字与75%分位水平线垂直对齐
Text(0.08, 0.005565917636261464, ' Q3: 0.0056')

4. 累积分布函数

ax = axes[1, 1]  # 右下面板用经验CDF显示任意收益阈值以下的样本比例
sorted_returns = np.sort(returns)  # 横轴必须按收益从小到大排列
cumulative = np.arange(1, len(sorted_returns) + 1) / len(sorted_returns)  # 第i个次序统计量对应累计概率i/n

ax.plot(sorted_returns, cumulative, color='#E3120B',        # 绘制实际收益率的经验CDF曲线(红色)
        linewidth=2, label='实际CDF')  # 红线按次序统计量给出样本累计比例

# 理论正态CDF
theoretical_cdf = stats.norm.cdf(sorted_returns, mu, std)   # 计算同参数正态分布的理论CDF值
ax.plot(sorted_returns, theoretical_cdf, color='#00A4E6',   # 绘制理论正态CDF曲线(蓝色虚线)
        linewidth=2, linestyle='--', label='正态CDF')  # 蓝色虚线表示同均值、标准差的正态累计概率

ax.set_title('宁波港日收益经验CDF与正态基准', fontsize=12, fontweight='bold')  # 比较任一收益阈值下的样本累计比例与正态基准
ax.set_xlabel('日收益率', fontsize=10)  # 横轴按从小到大排列的日简单收益阈值
ax.set_ylabel('累积概率', fontsize=10)  # 纵轴范围0—1,表示不高于横轴收益阈值的样本比例
ax.legend(loc='lower right', fontsize=10)  # 图例区分经验CDF与同参数正态CDF
ax.grid(True, alpha=0.3, linestyle=':')  # 淡网格辅助读取给定收益阈值下的累计概率差

plt.tight_layout()  # 为四幅诊断图的不同轴题、图例与分位数标注保留边距
display(fig)  # 显式呈现完成后的四面板Figure,避免分块执行只捕获初始化空画布
(a) 宁波港收益率的四面板分布分析
<Figure size 672x480 with 0 Axes>
(b)
图 10.31
# 正态性检验
print('=== 正态性检验 ===')                                      # 用数值检验补充Q-Q图的视觉诊断
statistic, p_value = stats.jarque_bera(returns)  # 执行Jarque-Bera正态性检验
print(f'Jarque-Bera检验: 统计量={statistic:.4f}, p值={p_value:.4f}')  # 输出JB检验统计量和p值
print(f'结论: {"拒绝正态假设" if p_value < 0.05 else "不能拒绝正态假设"} (α=0.05)')  # 根据p值判断是否拒绝正态假设

print('\n=== 描述性统计 ===')                                    # 核对有效日数、四分位数与箱形图位置
print(returns.describe())  # 输出与四幅图完全相同的去首期缺失收益样本
=== 正态性检验 ===
Jarque-Bera检验: 统计量=0.9669, p值=0.6166
结论: 不能拒绝正态假设 (α=0.05)

=== 描述性统计 ===
count    241.000000
mean       0.000121
std        0.007604
min       -0.026119
25%       -0.005434
50%        0.000000
75%        0.005566
max        0.022126
Name: returns, dtype: float64

图 10.31 与数值检验支持以下样本内诊断:

  1. 分布形状:直方图与KDE描述当前宁波港日收益样本的中心和尾部形状;是否拒绝正态性以同一代码块输出的Jarque–Bera检验为准。
  2. 尾部偏离:Q-Q图两端若系统偏离参考线,说明样本尾部分位数与同均值、标准差的正态基准不一致,但图形本身不估计样本外极端事件概率。
  3. 密度与稳健摘要互补:小提琴图以bw_adjust=0.7控制核密度带宽,较小带宽会显示更多局部起伏,较大带宽会进一步平滑;箱形图则稳定地给出中位数、四分位距、须与离群标记。两者复用同一收益样本,前者补充形状信息,后者提供不依赖核带宽的摘要;异常观测仍需回查原始交易日和行情记录。

10.5.5 习题 10.5: 趋势平滑、星期分组与价格偏离探索图

定位与产出:核心边界题;先修为移动平均、分类聚合和多面板图;预计 60—80 分钟;提交平滑路径、星期分组、偏离序列以及对预测、因果和方差解释边界的说明。

问题描述

使用宁波港股票行情构建一组探索性时间序列图:

  1. 比较5日、20日和60日移动平均的平滑程度
  2. 按星期分组比较同一样本中的日收益均值
  3. 绘制收盘价相对20日移动平均的价格偏离
  4. 用四面板图和描述性统计说明图形能支持什么、不能支持什么结论

理论研讨:探索性平滑与正式时间序列分解的边界

本题并不估计满足重构恒等式的加法或乘法分解。四个面板分别回答三个描述性问题,量纲也不完全相同:移动平均仍以价格(元)计量,星期分组使用日收益率,价格偏离再次以元计量。因此不能把三者称为同一模型中的趋势、季节项与残差,也不能将它们相加或相乘以重构收盘价。

  1. 移动平均平滑\(MA_t=k^{-1}\sum_{i=0}^{k-1}Y_{t-i}\)是截至时点\(t\)的右对齐局部均值。窗口\(k\)越大,曲线通常越平滑且起始缺失越多;它是分析者选择的平滑器,不自动等于结构模型中的趋势成分。
  2. 星期分组:把日收益率按星期一至星期五分组并计算样本均值,只是检查当前样本是否出现分组差异。若要声称稳定的周内效应,还需显著性检验、多重检验修正和时间外复核。
  3. 价格偏离:定义\(D_t=Y_t-MA_{20,t}\)后,可以描述收盘价相对20日平滑线的方向和金额距离。\(D_t\)不是由已估计统计模型产生的正交残差,其平稳性、均值回归和可预测性都需要另行检验。
展开习题 10.5 完整解答

10.5.5.1 习题 10.5 完整解答

import pandas as pd  # 读取宁波港日行情并按交易日计算均线与星期分组
import numpy as np  # 支持价格偏离与样本统计的数值运算
import matplotlib.pyplot as plt  # 创建价格、均线、星期均值和价格偏离四个纵向面板
from IPython.display import display  # 在四个探索面板完成后显式呈现同一个Figure对象

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 保证星期标签、均线图例与人民币单位完整显示
plt.rcParams['axes.unicode_minus'] = False  # 使负收益和负偏离刻度使用标准负号

stock_data = pd.read_hdf(  # 读取宁波港全年行情以开展趋势平滑与分组探索
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',
    where="order_book_id='601018.XSHG'"  # 只读取趋势平滑与星期分组所需证券
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开交易日以构造5/20/60观测窗口

stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析探索图所需的宁波港交易日
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]  # 仅保留2023年交易日,使价格、收益与分组统计共享样本期

port_stock_data = stock_data.copy()  # 从全年宁波港样本派生探索变量,不回写原始读取结果
port_stock_data.set_index('datetime', inplace=True)  # 以datetime索引计算5/20/60日平滑值及星期分组

# 计算不同周期的移动平均
port_stock_data['MA5'] = port_stock_data['close'].rolling(window=5).mean()  # 以5观测均线表示短期价格平滑层
port_stock_data['MA20'] = port_stock_data['close'].rolling(window=20).mean()  # 收盘价二十日趋势只使用截至当日的20个交易观测,窗口未满保留缺失
port_stock_data['MA60'] = port_stock_data['close'].rolling(window=60).mean()  # 收盘价六十日趋势只使用截至当日的60个交易观测,窗口未满保留缺失

# 计算星期分组所需的相邻交易日收益率
port_stock_data['returns'] = port_stock_data['close'].pct_change(fill_method=None)  # 不填补缺口后计算相邻交易日收益

# 形成探索性星期分组:这里只计算样本均值,不执行显著性检验
port_stock_data['day_of_week'] = port_stock_data.index.dayofweek  # 提取交易日对应的星期几(0=周一)
weekly_pattern = port_stock_data.groupby('day_of_week')['returns'].mean()  # 按 'day_of_week' 分层汇总,组均值只作历史描述

# 创建探索性四面板图
fig, axes = plt.subplots(4, 1, figsize=(14, 12))  # 四个纵向 Axes 按价格元、价格元、平均收益率、偏离元分别设定尺度

1. 原始价格

ax = axes[0]  # 第一行把原始收盘价与20日均线放在同一人民币元尺度
ax.plot(port_stock_data.index, port_stock_data['close'], color='#2C3E50',  # 深蓝灰线表示宁波港每日收盘价
        linewidth=1, alpha=0.7, label='收盘价')  # 较细半透明线保留日度波动,并由图例标识原序列
ax.plot(port_stock_data.index, port_stock_data['MA20'], color='#E3120B',  # 绘制20日均线(红色)
        linewidth=2, label='20日均线')  # 红线为20个交易观测的右对齐平滑值,前19日为空
ax.fill_between(port_stock_data.index, port_stock_data['close'], port_stock_data['MA20'],  # 填充收盘价不低于20日均线的红色上涨区间
                where=(port_stock_data['close'] >= port_stock_data['MA20']),  # 条件:价格高于均线时填充
                alpha=0.2, color='#E3120B', label='上涨区间')  # 红色半透明填充表示上涨区间
ax.fill_between(port_stock_data.index, port_stock_data['close'], port_stock_data['MA20'],  # 填充收盘价低于20日均线的蓝色下跌区间
                where=(port_stock_data['close'] < port_stock_data['MA20']),  # 条件:价格低于均线时填充
                alpha=0.2, color='#00A4E6', label='下跌区间')  # 蓝色半透明填充表示下跌区间

ax.set_title('宁波港收盘价与20日移动平均', fontsize=12, fontweight='bold')  # 题名写明证券与窗口,填色仅表示价格位于均线上下
ax.set_ylabel('价格(元)', fontsize=10)  # 原价与20日均线共享人民币元尺度
ax.legend(loc='upper left', fontsize=10)  # 图例区分原收盘价、20日均线及均线上下填色
ax.grid(True, alpha=0.3, linestyle=':')  # 淡网格辅助读取收盘价及其与20日均线的距离

2. 多窗口趋势平滑

ax = axes[1]  # 第二行比较5、20、60观测窗口的平滑路径
ax.plot(port_stock_data.index, port_stock_data['MA5'], color='#008080', linewidth=1, label='MA5', alpha=0.7)  # 绘制5日短期均线(青色)
ax.plot(port_stock_data.index, port_stock_data['MA20'], color='#F0A700', linewidth=1.5, label='MA20')  # 绘制20日中期均线(金色)
ax.plot(port_stock_data.index, port_stock_data['MA60'], color='#E3120B', linewidth=2, label='MA60')  # 绘制60日长期均线(红色)

ax.set_title('5、20、60日移动平均比较', fontsize=12, fontweight='bold')  # 读图比较窗口越长时平滑增强与起始缺失增多
ax.set_ylabel('价格(元)', fontsize=10)  # 三条移动平均共享人民币元尺度
ax.legend(loc='upper left', fontsize=10)  # 图例把三种线色对应到5、20、60个交易观测窗口
ax.grid(True, alpha=0.3, linestyle=':')  # 淡网格辅助比较三种窗口的平滑程度和水平差异

3. 探索性星期分组均值

ax = axes[2]  # 第三行以星期为类别展示样本日收益均值
days = ['周一', '周二', '周三', '周四', '周五']                       # 定义星期几的中文标签列表
bars = ax.bar(range(len(weekly_pattern)), weekly_pattern.values,  # 绘制各交易日平均收益率柱状图
               color=['#E3120B' if v > 0 else '#00A4E6' for v in weekly_pattern.values],  # 正收益红色、负收益蓝色
               alpha=0.7, edgecolor='white', linewidth=1.5)  # 设置透明度和白色边框

ax.set_xticks(range(len(weekly_pattern)))  # 把0—4五个分组位置绑定到周一至周五标签
ax.set_xticklabels(days)                                    # 将0—4的星期编号显示为周一至周五中文标签
ax.set_title('按星期分组的样本日均收益率(探索性)', fontsize=12, fontweight='bold')  # 明确柱状图不构成显著性检验
ax.set_ylabel('平均日收益率', fontsize=10)  # 纵轴为各星期组内简单收益均值,小数口径
ax.axhline(y=0, color='black', linestyle='-', linewidth=0.8)  # 零线区分正负样本均值,不代表显著性阈值
ax.grid(True, alpha=0.3, axis='y', linestyle=':')  # 横网格辅助比较五个星期样本均值与零线距离

# 添加数值标签
for i, bar in enumerate(bars):  # 逐柱读取实际高度,使数值标签与五个星期均值一致
    height = bar.get_height()                               # 获取当前柱子的高度值
    ax.text(bar.get_x() + bar.get_width()/2., height,  # 在各星期收益柱顶标注样本均值
            f'{height:.4f}', ha='center', va='bottom' if height > 0 else 'top',  # 在柱顶标注收益率数值
            fontsize=9)  # 设置字号为9

4. 价格相对移动平均的偏离

ax = axes[3]  # 第四行展示收盘价相对20日均线的金额偏离
price_deviation = port_stock_data['close'] - port_stock_data['MA20']  # 计算同为人民币元口径的收盘价—MA20描述性偏离
ax.plot(port_stock_data.index, price_deviation, color='#8E9EAA', linewidth=1, alpha=0.8)  # 绘制价格偏离曲线(灰色)
ax.fill_between(port_stock_data.index, price_deviation, 0,  # 用红色面积编码价格相对20日均线的正偏离
                where=(price_deviation >= 0), alpha=0.3, color='#E3120B')  # 正偏离用红色填充
ax.fill_between(port_stock_data.index, price_deviation, 0,  # 用蓝色面积编码价格相对20日均线的负偏离
                where=(price_deviation < 0), alpha=0.3, color='#00A4E6')  # 负偏离用蓝色填充

ax.set_title('价格相对20日移动平均的偏离(探索性)', fontsize=12, fontweight='bold')  # 避免把平滑偏离直接称为统计残差
ax.set_ylabel('偏离度(元)', fontsize=10)                        # 纵轴注明“偏离度(元)”,避免把金额水平误读为收益率
ax.set_xlabel('日期', fontsize=10)  # 横轴沿真实交易日展示20日平滑偏离的时间顺序
ax.axhline(y=0, color='black', linestyle='-', linewidth=1)  # 零线表示收盘价恰等于20日均线的参考水平
ax.grid(True, alpha=0.3, linestyle=':')  # 淡网格辅助读取正负价格偏离的人民币元幅度

plt.tight_layout()  # 为四个纵向面板的题名、单位与日期刻度保留间距
display(fig)  # 显式呈现完成后的四面板Figure,避免分块执行只捕获初始化空画布
(a) 宁波港趋势平滑、星期分组与价格偏离探索图
<Figure size 672x480 with 0 Axes>
(b)
图 10.32
# 描述性结果
print('=== 时间序列探索分析 ===')  # 用数值摘要复核20日平滑线与价格偏离图
smoothed_variance_ratio = port_stock_data['MA20'].var() / port_stock_data['close'].var()  # 计算不具方差分解含义的描述性方差比
print(f'平滑序列方差/原序列方差: {smoothed_variance_ratio:.4f}')  # 报告不限定在0至1的平滑序列方差与原序列方差之比
print(f'价格偏离标准差: {price_deviation.std():.2f}元')  # 报告收盘价减MA20所得金额偏离的样本标准差
price_variation_coefficient_percent = port_stock_data['close'].std() / port_stock_data['close'].mean() * 100  # 按价格标准差除以均值计算百分比口径变异系数
print(f'价格变异系数: {price_variation_coefficient_percent:.2f}%')  # 输出与原收盘价序列对应的描述性变异系数

print('\n=== 按星期分组的样本日均收益率 ===')  # 输出探索性分组均值而非显著性检验结论
for day, ret in zip(days, weekly_pattern.values):  # 按柱状图的周一至周五顺序配对标签与样本均值
    print(f'{day}: {ret:.4f}')  # 精确复核每根柱高;未提供显著性或样本外结论
=== 时间序列探索分析 ===
平滑序列方差/原序列方差: 0.7578
价格偏离标准差: 0.06元
价格变异系数: 2.73%

=== 按星期分组的样本日均收益率 ===
周一: 0.0014
周二: -0.0007
周三: -0.0013
周四: 0.0009
周五: 0.0003

通过 图 10.32 对宁波港价格与收益序列作图,我们可以提出待检验的样本问题:

  1. 平滑趋势:MA20/MA60 展示不同窗口下的价格平滑路径;窗口由分析者选择,图形不能识别动量机制或因果来源。
  2. 星期差异:柱状图只报告 2023 年当前样本的星期均值。判断“周内效应”需要预先定义假设、稳健标准误、多重检验修正及其他样本期复核。
  3. 移动平均偏离:偏离曲线只是价格与所选平滑线之差。声称均值回归还需检验平稳性和回归速度;转化为交易证据还需加入信号时点、成本、风险控制与样本外评价。

这三个对象不构成正式的加法或乘法分解:它们没有共享统一量纲,也没有满足价格重构恒等式。此处报告的“平滑序列方差/原序列方差”只是两个序列样本方差的描述性比值;由于移动平均不是产生正交残差的投影,该比值不能解释为趋势所解释的变异比例,也不要求落在0到1之间。


10.5.6 习题 10.6:静态多变量图与发布审计(选做项目 A)

定位与产出:选做发布项目 A,与习题 10.7 二选一,不重复计入核心必做工作量;先修为双轴风险、色觉可访问性、静态导出和文件回读;预计 70—90 分钟;提交静态多变量图、替代文本、PNG/SVG 往返证据和双轴限制说明。

问题描述

使用 Matplotlib 为宁波港 2023 年第一季度行情创建一幅静态多变量图。本题不要求悬停、缩放控件或事件回调;可操作的交互图属于 Plotly、Bokeh 或 Altair 等后续主题。提交物必须满足:

  1. 左轴绘制收盘价,右轴绘制成交量,并通过轴色、变量色、单位和合并图例降低双轴误读风险;
  2. 标注最高、最低收盘价,以散点颜色和大小冗余编码日收益绝对值,不得只依赖红—绿辨识;
  3. 在图题、图注或图内说明证券、样本期和数据来源,并给出一段可作为替代文本的摘要;
  4. 导出 300 DPI PNG 与可缩放 SVG,核验 PNG 像素尺寸,并说明两种格式的适用媒介;
  5. 明确该图只展示共时变化,不能据此断言成交量造成价格变化。

评分点(100 分):真实数据与样本合同 15 分;双轴及单位/图例 20 分;第三变量的冗余编码 15 分;极值标注 10 分;PNG/SVG 与像素核验 15 分;可访问性及替代文本 15 分;推断边界 10 分。

展开习题 10.6 完整解答

10.5.6.1 习题 10.6 完整解答

import pandas as pd  # 读取真实行情并保持日期标签
import numpy as np  # 计算冗余标记大小与导出像素尺寸
import matplotlib.pyplot as plt  # 创建静态双轴图并导出出版文件
from PIL import Image  # 回读PNG以验证实际像素尺寸而非只相信参数
from IPython.display import display  # 在所有图层完成后一次性呈现最终静态图

# 统一中文字体和负号,避免发布环境替换字形
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 采用项目指定的中文字体
plt.rcParams['axes.unicode_minus'] = False  # 保证负收益与坐标刻度正常显示

stock_data = pd.read_hdf(  # 仅选择宁波港记录以固定图形对象
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',
    where="order_book_id='601018.XSHG'"  # 在HDF层筛选证券,避免载入全市场行情
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 将存储索引显式恢复为业务字段

stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 按源文件日期编码解析交易日
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-03-31')]  # 冻结2023年第一季度样本

port_stock_data = stock_data.copy()  # 让绘图特征不回写读取结果
port_stock_data.set_index('datetime', inplace=True)  # 以真实交易日驱动所有视觉图层

# 计算涨跌幅
port_stock_data['change'] = port_stock_data['close'].pct_change(fill_method=None)  # 不填补缺失价格,计算相邻真实观测收益
port_stock_data['change_abs'] = port_stock_data['change'].abs()  # 用绝对收益表示波动幅度而非涨跌方向

# 创建双Y轴图表
fig, ax1 = plt.subplots(figsize=(16, 8))  # 固定英寸尺寸,使300 DPI对应可核验像素尺寸

# 左轴:价格
price_color = '#2C3E50'  # 用深蓝灰把价格轴与价格线绑定
ax1.set_xlabel('交易日', fontsize=12, labelpad=10)  # 明确横轴是不连续的真实交易日
ax1.set_ylabel('收盘价(元)', color=price_color, fontsize=12, labelpad=10)  # 同时标出变量和人民币单位
price_lines = ax1.plot(port_stock_data.index, port_stock_data['close'], color=price_color, linewidth=2, label='收盘价(左轴)')  # 用左轴呈现价格水平
ax1.tick_params(axis='y', labelcolor=price_color)  # 让刻度颜色与价格序列一致
ax1.grid(True, alpha=0.3, linestyle=':')  # 提供弱网格以辅助读取位置而不压过数据
plt.close(fig)  # 暂停Notebook自动显示,避免把未完成的第一层误当成最终答案
# 右轴:成交量
ax2 = ax1.twinx()  # 共享交易日但保留成交量独立量纲
volume_color = '#F0A700'  # 用金色避免红绿方向暗示并区别于价格轴
ax2.set_ylabel('成交量(股)', color=volume_color, fontsize=12, labelpad=10)  # 明确右轴的数量单位
volume_lines = ax2.plot(port_stock_data.index, port_stock_data['volume'], color=volume_color, linewidth=1, alpha=0.6, label='成交量(右轴)')  # 用右轴呈现不同量纲的成交活跃度
ax2.tick_params(axis='y', labelcolor=volume_color)  # 让右轴刻度与成交量序列一致

# 标题
plt.title('宁波港价格、成交量与日收益波动幅度', fontsize=16, fontweight='bold', pad=20)  # 标题只陈述变量,不暗示因果关系

# 合并图例
legend_lines = price_lines + volume_lines  # 合并两个量纲的曲线句柄
legend_labels = [legend_line.get_label() for legend_line in legend_lines]  # 从句柄读取与轴一致的标签
ax1.legend(legend_lines, legend_labels, loc='upper left', fontsize=11)  # 在同一图例中声明左右轴归属

# 用文字与箭头直接标出最高价,避免仅靠颜色传递极值信息
maximum_price_date = port_stock_data['close'].idxmax()  # 定位样本最高收盘价的真实交易日
maximum_close_price = port_stock_data['close'].max()  # 提取注释中需要报告的价格数值
ax1.annotate(f'最高价\n{maximum_close_price:.2f}元', xy=(maximum_price_date, maximum_close_price), xytext=(10, 20), textcoords='offset points', bbox={'boxstyle': 'round,pad=0.5', 'facecolor': 'white', 'edgecolor': price_color}, arrowprops={'arrowstyle': '->', 'color': price_color}, fontsize=10, ha='center')  # 用同轴颜色连接文字和最高价点

图 10.33 给出本题的可复核解答。

# 用文字与箭头直接标出最低价,形成与最高价相同的阅读规则
minimum_price_date = port_stock_data['close'].idxmin()  # 定位样本最低收盘价的真实交易日
minimum_close_price = port_stock_data['close'].min()  # 提取注释中需要报告的价格数值
ax1.annotate(f'最低价\n{minimum_close_price:.2f}元', xy=(minimum_price_date, minimum_close_price), xytext=(-10, -20), textcoords='offset points', bbox={'boxstyle': 'round,pad=0.5', 'facecolor': 'white', 'edgecolor': price_color}, arrowprops={'arrowstyle': '->', 'color': price_color}, fontsize=10, ha='center')  # 用同轴颜色连接文字和最低价点

# 均价用虚线形成非颜色冗余,并与价格、成交量一并进入图例
mean_close_price = port_stock_data['close'].mean()  # 计算固定样本期内的描述性平均价格
mean_line = ax1.axhline(y=mean_close_price, color='#8E9EAA', linestyle='--', linewidth=1.5, label=f'样本均价 {mean_close_price:.2f}元')  # 用虚线编码均价参考而非预测阈值
legend_lines = price_lines + volume_lines + [mean_line]  # 将左右轴变量和参考线纳入同一图例
legend_labels = [legend_line.get_label() for legend_line in legend_lines]  # 保持图例标签与实际句柄同步
ax1.legend(legend_lines, legend_labels, loc='upper left', fontsize=10)  # 重新绘制包含全部曲线含义的图例

# 第三变量同时使用大小和顺序色图,保证灰度或色觉受限场景仍可辨认
maximum_absolute_return = port_stock_data['change_abs'].max()  # 取得标记尺度的样本上界
assert maximum_absolute_return > 0  # 防止全零样本导致标记缩放除零
return_marker_sizes = 30 + 220 * port_stock_data['change_abs'].fillna(0) / maximum_absolute_return  # 把绝对收益线性映射为30到250点面积
return_scatter = ax1.scatter(port_stock_data.index, port_stock_data['close'], c=port_stock_data['change_abs'].fillna(0), s=return_marker_sizes, cmap='viridis', alpha=0.75, edgecolors=price_color, linewidths=0.3)  # 用色觉友好顺序色与大小冗余呈现波动幅度
return_colorbar = fig.colorbar(return_scatter, ax=ax1, pad=0.02)  # 为连续颜色编码提供数值刻度
return_colorbar.set_label('日收益绝对值', fontsize=10)  # 明确颜色和大小均不表示涨跌方向

source_note = '数据:本地前复权行情;证券:宁波港 601018.SH;样本:2023Q1'  # 固定图形的数据血缘与样本范围
ax1.text(0.02, 0.88, source_note, transform=ax1.transAxes, fontsize=9, verticalalignment='top', bbox={'boxstyle': 'round', 'facecolor': 'white', 'alpha': 0.8})  # 在图内保留随导出文件传播的来源说明
fig.tight_layout()  # 在固定画布内为双轴、图例和颜色条保留可读空间
display(fig)  # 仅在所有编码、图例和注释完成后输出带图题的最终图
图 10.33: 宁波港 2023 年第一季度静态多变量图:左轴为收盘价,右轴为成交量,点的大小与颜色共同表示日收益绝对值

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

png_output_path = PLOT_EXPORT_ROOT / 'port_multivariate_static.png'  # 在临时工作区准备高分辨率栅格版本
svg_output_path = PLOT_EXPORT_ROOT / 'port_multivariate_static.svg'  # 在同一临时工作区准备矢量版本
fig.savefig(png_output_path, dpi=300, facecolor='white', edgecolor='none')  # 按固定16乘8英寸画布导出300 DPI PNG
fig.savefig(svg_output_path, format='svg', facecolor='white', edgecolor='none')  # 导出保留线条与文字矢量信息的SVG
with Image.open(png_output_path) as saved_png:  # 回读实际文件以检查导出器输出
    png_pixel_size = saved_png.size  # 取得真实宽高像素而不是根据参数猜测
assert png_pixel_size == (4800, 2400)  # 核验16乘8英寸在300 DPI下的像素合同
svg_document_text = svg_output_path.read_text(encoding='utf-8')  # 回读矢量文件内容以审计实际导出格式
assert '<svg' in svg_document_text  # 核验SVG导出包含可缩放矢量根元素
export_audit = pd.DataFrame({'file': [png_output_path.name, svg_output_path.name], 'format': ['PNG', 'SVG'], 'intended_medium': ['网页预览或办公软件', '论文、网页矢量图与缩放'], 'pixel_or_scaling_evidence': [f'{png_pixel_size[0]}×{png_pixel_size[1]} px', '矢量路径与文字,可无损缩放']})  # 只展示确定性文件名并汇总回读证据
export_audit  # 输出格式、媒介与分辨率审计表
表 10.8: 练习10.6静态图导出与像素审计
file format intended_medium pixel_or_scaling_evidence
0 port_multivariate_static.png PNG 网页预览或办公软件 4800×2400 px
1 port_multivariate_static.svg SVG 论文、网页矢量图与缩放 矢量路径与文字,可无损缩放

替代文本示例:宁波港 2023 年第一季度收盘价与成交量静态双轴图。深蓝线对应左轴价格,金色线对应右轴成交量;价格点越大且在 viridis 色阶上越亮,日收益绝对值越高。图中直接标注样本最高价与最低价。该图仅显示同一时期的共时变化,不能证明成交量变化导致价格变化。

PNG 适合需要固定像素的网页预览或办公软件,300 DPI 在本题固定画布下得到 4800×2400 像素;SVG 更适合论文线图和响应式网页,因为缩放不损失线条与文字清晰度。双 Y 轴只解决量纲并置,不表示两个轴的数值可直接比较,更不构成因果证据。


10.5.7 习题 10.7: 综合金融仪表盘(选做项目 B)

定位与产出:选做综合项目 B,与习题 10.6 二选一,不重复计入核心必做工作量;先修为本章全部核心内容及第 9 章移动窗口;预计 90—120 分钟;提交统一样本期的四面板图、导出审计、末日机械状态和“不构成预测或行动建议”的解释。

问题描述

创建一个综合性的金融分析仪表盘,整合多种图表:

  1. 价格走势与均线
  2. 成交量分析
  3. 收益率分布
  4. 技术指标(RSI、MACD)
展开习题 10.7 完整解答

10.5.7.1 习题 10.7 完整解答

图 10.34 给出本题的可复核解答。

import pandas as pd  # 读取宁波港日行情并按交易日对齐价格、成交量与技术指标
import numpy as np  # 支持收益与技术指标的数值运算和缺失处理
import matplotlib.pyplot as plt  # pyplot创建并管理仪表盘画布与四个Axes
from matplotlib.gridspec import GridSpec                    # 将仪表盘划成跨列价格区和三个下方面板

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 保证证券名、技术指标与阈值标签完整显示
plt.rcParams['axes.unicode_minus'] = False  # 使负收益和MACD刻度使用标准负号

stock_data = pd.read_hdf(  # 读取宁波港全年行情以构建四面板仪表盘
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',
    where="order_book_id='601018.XSHG'"  # 仪表盘只展示601018.XSHG
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开日期并统一成交量字段供四面板共用

stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析四面板仪表盘的宁波港交易日
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]  # 宁波港综合分析仪表盘仅保留2023年交易日,使价格、成交量与分组统计共享样本期

port_stock_data = stock_data.copy()                         # 为仪表盘建立独立派生表,保留上游原始行情供复核
port_stock_data.set_index('datetime', inplace=True)  # 以datetime索引对齐仪表盘价格、成交量、RSI与MACD

# 所有技术指标均按日期右对齐,只使用当日及此前信息
# 5日与20日均线分别表示短、较长观察窗口
port_stock_data['MA5'] = port_stock_data['close'].rolling(5).mean()  # 计算仪表盘短期5观测价格均线
port_stock_data['MA20'] = port_stock_data['close'].rolling(20).mean()  # 收盘价二十日趋势只使用截至当日的20个交易观测,窗口未满保留缺失

# RSI将最近14个交易观测中的平均涨幅与平均跌幅映射到0—100
def calculate_rsi(prices, period=14):  # period控制右对齐滚动窗口长度
    """按简单滚动均值计算不使用未来数据的相对强弱指标。"""
    delta = prices.diff()  # 一阶价差的正负分别进入涨幅与跌幅序列
    gain = (delta.where(delta > 0, 0)).rolling(period).mean()  # 每点使用截至当日最近 period(默认14)个交易观测估计平均涨幅
    loss = (-delta.where(delta < 0, 0)).rolling(period).mean()  # 使用同一右对齐窗口估计平均跌幅,窗口未满时不输出 RSI
    rs = gain / loss  # 计算相对强度(平均涨幅/平均跌幅)
    rsi = 100 - (100 / (1 + rs))  # 将相对强度转换为0-100的RSI值
    return rsi                                              # 返回结果

port_stock_data['RSI'] = calculate_rsi(port_stock_data['close'])  # 计算宁波港收盘价的RSI指标
图 10.34
# MACD用12期与26期指数均线之差衡量近期相对长期趋势,再以9期信号线平滑
exp12 = port_stock_data['close'].ewm(span=12, adjust=False).mean()  # 用截至当日的信息计算`exp12`的指数加权统计,不读取未来观测
exp26 = port_stock_data['close'].ewm(span=26, adjust=False).mean()  # 用截至当日的信息计算`exp26`的指数加权统计,不读取未来观测
port_stock_data['MACD'] = exp12 - exp26  # 计算MACD线(12日与26日EMA之差)
port_stock_data['MACD_smooth'] = port_stock_data['MACD'].ewm(span=9, adjust=False).mean()  # 用截至当日的MACD计算9期指数平滑参照线,不读取未来观测
port_stock_data['Histogram'] = port_stock_data['MACD'] - port_stock_data['MACD_smooth']  # 计算MACD线与其9期平滑参照线之差

# 日收益面板使用未填补的相邻收盘价百分比变化
port_stock_data['returns'] = port_stock_data['close'].pct_change(fill_method=None)  # 仪表盘不填补价格缺口,首日收益自然缺失

# 创建仪表盘
fig = plt.figure(figsize=(18, 12))  # 以18×12英寸画布容纳价格、成交量、收益和RSI四个诊断区
gs = GridSpec(3, 3, figure=fig, hspace=0.3, wspace=0.3)     # 创建3×3网格布局
<Figure size 1728x1152 with 0 Axes>

1. 主图:价格与均线(占上面两行)

ax1 = fig.add_subplot(gs[0:2, :])                           # 选取上方两行作为主图区域

ax1.plot(port_stock_data.index, port_stock_data['close'], label='收盘价',  # 绘制收盘价时序曲线
         color='#2C3E50', linewidth=1.5, alpha=0.8)         # 深蓝灰色、线宽1.5、透明度0.8
ax1.plot(port_stock_data.index, port_stock_data['MA5'], label='MA5',  # 绘制5日短期均线
         color='#E3120B', linewidth=1.2)                    # 红色、线宽1.2
ax1.plot(port_stock_data.index, port_stock_data['MA20'], label='MA20',  # 绘制20日中期均线
         color='#00A4E6', linewidth=1.2)                    # 蓝色、线宽1.2

# 填充均线间区域
ax1.fill_between(port_stock_data.index, port_stock_data['MA5'], port_stock_data['MA20'],  # 填充MA5不低于MA20时两条均线之间的相对位置区间
                 where=(port_stock_data['MA5'] >= port_stock_data['MA20']),  # 只编码MA5不低于MA20的历史机械状态
                 alpha=0.2, color='#E3120B')  # 红色半透明填充

ax1.set_title('宁波港 (601018.SH) 2023年价格与均线', fontsize=16, fontweight='bold', pad=15)  # 主图固定证券、样本年与5/20日窗口,填色只表示均线相对位置
ax1.set_ylabel('价格(元)', fontsize=11, labelpad=10)  # 将`ax1`纵轴标为价格(元),避免把水平值误读为收益率
ax1.legend(loc='upper left', fontsize=10)  # 左上图例区分收盘价、MA5与MA20,便于核对短中期均线交叉
ax1.grid(True, alpha=0.3, linestyle=':')  # 主图淡网格辅助估读人民币价格和均线交叉日期

2. 成交量(左下)

ax2 = fig.add_subplot(gs[2, 0])                             # 左下格使用股数尺度承载日成交量柱

colors = ['#E3120B' if port_stock_data['close'].iloc[i] >= port_stock_data['open'].iloc[i]  # iloc按同一行位置比较当日收盘与开盘,避免把整数误作日期标签
          else '#00A4E6' for i in range(len(port_stock_data))]  # 下跌日为蓝色,遍历所有交易日
ax2.bar(port_stock_data.index, port_stock_data['volume'], color=colors, alpha=0.6, width=0.8)  # 绘制成交量柱状图(涨红跌蓝)

ax2.set_title('2023年日成交量', fontsize=12, fontweight='bold')  # 左下面板以股数比较日度交易活跃度,颜色表示当日开收方向
ax2.set_ylabel('成交量(股)', fontsize=10)  # 左下面板使用每日成交股数,不与上方价格共用尺度
ax2.grid(True, alpha=0.3, axis='y', linestyle=':')  # 横网格辅助比较成交量柱高
plt.setp(ax2.xaxis.get_majorticklabels(), rotation=45)  # 旋转x轴日期标签45度以避免重叠
[None, None, None, None, None, None, None]

3. 收益率分布(中下)

ax3 = fig.add_subplot(gs[2, 1])                             # 中下格用频数尺度展示有效日收益分布

ax3.hist(port_stock_data['returns'].dropna(), bins=50, color='#F0A700',  # 剔除收益变换产生的首期缺失,样本从第二个交易日开始
         alpha=0.7, edgecolor='white', linewidth=1)  # 设置透明度和白色边框
ax3.axvline(port_stock_data['returns'].mean(), color='#E3120B',  # 添加垂直参考线
           linestyle='--', linewidth=2, label=f'均值: {port_stock_data["returns"].mean():.4f}')  # 设置虚线样式、线宽和图例标签
ax3.set_title('2023年日收益率频数分布', fontsize=12, fontweight='bold')  # 中下面板比较样本中心与尾部,红虚线标出样本均值
ax3.set_xlabel('日收益率', fontsize=10)  # 将3. 收益率分布(中下)当前分布面板横轴标为日收益率小数
ax3.set_ylabel('交易日频数', fontsize=10)  # 中下面板柱高为落入收益箱的有效交易日数
ax3.legend(fontsize=9)  # 收益分布图例标出样本均值虚线,使中心位置可与直方柱对照
ax3.grid(True, alpha=0.3, axis='y', linestyle=':')  # 横网格辅助比较各收益区间的交易日频数

4. RSI指标(右下)

ax4 = fig.add_subplot(gs[2, 2])                             # 右下格用0—100无量纲尺度展示14日RSI

ax4.plot(port_stock_data.index, port_stock_data['RSI'], color='#8E9EAB', linewidth=1.5)  # 绘制RSI指标曲线(灰色)
ax4.axhline(y=70, color='#E3120B', linestyle='--',  # 70线标记RSI高位参考边界,不构成卖出规则
           linewidth=1, alpha=0.7, label='高位参考线 (70)')     # 红色虚线与RSI曲线保持足够对比并进入图例
ax4.axhline(y=30, color='#00A4E6', linestyle='--',  # 30线辅助识别低位区间,仍需时间外策略验证
           linewidth=1, alpha=0.7, label='低位参考线 (30)')     # 蓝色虚线和文字标签共同区分下阈值
ax4.fill_between(port_stock_data.index, 70, 100, alpha=0.1, color='#E3120B')  # 淡红填充RSI的70—100高值区间
ax4.fill_between(port_stock_data.index, 0, 30, alpha=0.1, color='#00A4E6')  # 淡蓝填充RSI的0—30低值区间

ax4.set_title('14日RSI与70/30参考线', fontsize=12, fontweight='bold')  # 右下面板明确窗口和传统阈值,阈值本身不是交易规则
ax4.set_ylabel('RSI(0—100)', fontsize=10)  # 右下面板使用无量纲0—100尺度
ax4.set_ylim([0, 100])                                      # 设置y轴范围
ax4.legend(loc='upper right', fontsize=9)  # RSI图例说明指标曲线及70/30高低位参考线
ax4.grid(True, alpha=0.3, linestyle=':')  # 网格辅助读取RSI与30、70参考线的相对位置
plt.setp(ax4.xaxis.get_majorticklabels(), rotation=45)  # 旋转x轴日期标签45度以避免重叠

# 顶部摘要统一报告样本最后一个交易日,而不是各指标各取不同日期
fig.text(0.5, 0.92,  # 在仪表盘顶部居中汇总最后交易日的四项指标
         f'当前价格: {port_stock_data["close"].iloc[-1]:.2f}  |  '  # 摘要首项报告样本末日收盘价,单位为元
         f'涨跌幅: {port_stock_data["returns"].iloc[-1]*100:.2f}%  |  '  # 摘要第二项把末日日收益换算为百分数
         f'RSI: {port_stock_data["RSI"].iloc[-1]:.2f}  |  '  # 摘要第三项展示末日14期相对强弱指标
         f'成交量: {port_stock_data["volume"].iloc[-1]:,.0f}',  # 摘要末项给出末日成交量原始股数
         ha='center', fontsize=12,                          # 水平居中对齐、字号12
         bbox=dict(boxstyle='round,pad=0.5', facecolor='wheat', alpha=0.5))  # 设置圆角麦色背景框

dashboard_output_path = PLOT_EXPORT_ROOT / 'port_dashboard.png'  # 把仪表盘导出限制在跨块临时工作区
plt.savefig(dashboard_output_path, dpi=300, bbox_inches='tight',  # 以300 dpi导出完整仪表盘并裁去画布外空白
            facecolor='white', edgecolor='none')  # 白底保证印刷可读,图形外框不编码数据
with Image.open(dashboard_output_path) as saved_dashboard_png:  # 回读仪表盘文件而不是只检查内存画布
    dashboard_file_format = saved_dashboard_png.format  # 取得实际编码格式供合同断言
    dashboard_pixel_size = saved_dashboard_png.size  # 取得裁边后的实际像素尺寸供审计
assert dashboard_file_format == 'PNG'  # 核验扩展名与实际编码格式一致
assert all(pixel_dimension > 0 for pixel_dimension in dashboard_pixel_size)  # 核验导出器生成非空二维图像
dashboard_export_audit = pd.Series({'file': dashboard_output_path.name, 'format': dashboard_file_format, 'pixel_size': f'{dashboard_pixel_size[0]}×{dashboard_pixel_size[1]} px'})  # 只记录确定性文件名与回读元数据
dashboard_export_audit  # 输出仪表盘文件格式与像素审计证据
plt.show()  # 联合核对价格、成交量、收益分布与RSI使用同一2023年证券样本
<Figure size 672x480 with 0 Axes>
# 输出样本末日的描述性摘要
print('=== 样本末日描述性摘要 ===')                            # 精确复核仪表盘最后交易日的价格、均线、RSI与MACD
print(f'样本末日收盘价: {port_stock_data["close"].iloc[-1]:.2f} 元')  # 以元报告样本末日收盘价
print(f'MA5: {port_stock_data["MA5"].iloc[-1]:.2f}')  # 输出末日五观测均线以比较历史窗口位置
print(f'MA20: {port_stock_data["MA20"].iloc[-1]:.2f}')  # 输出同一末日二十观测均线
print(f'RSI: {port_stock_data["RSI"].iloc[-1]:.2f}')  # 报告末日RSI并按70/30给出机械区间状态
print(f'MACD: {port_stock_data["MACD"].iloc[-1]:.4f}')  # 保留四位小数展示末日快慢EMA差值

# 只报告均线相对位置,不命名为趋势或交易信号
latest_ma5 = port_stock_data['MA5'].iloc[-1]  # 固定末日5观测均线,供互斥分支使用
latest_ma20 = port_stock_data['MA20'].iloc[-1]  # 固定同一末日20观测均线,避免比较错期
if latest_ma5 > latest_ma20:  # 短均线严格高于长均线时报告该机械关系
    print('均线状态: MA5 > MA20')  # 输出可由末日数值直接复算的相对位置
elif latest_ma5 < latest_ma20:  # 短均线严格低于长均线时报告该机械关系
    print('均线状态: MA5 < MA20')  # 输出可由末日数值直接复算的相对位置
else:  # 两条末日均线恰好相等时单独处理边界
    print('均线状态: MA5 = MA20')  # 不把相等情形误报为严格大于或小于

latest_rsi = port_stock_data['RSI'].iloc[-1]  # 固定同一末日RSI供互斥区间比较
if latest_rsi > 70:  # 读取末日RSI并比较高位参考线
    print('RSI区间状态: RSI > 70')  # 只输出高于参考线的机械事实
elif latest_rsi < 30:  # 比较末日RSI与低位参考线
    print('RSI区间状态: RSI < 30')  # 只输出低于参考线的机械事实
else:  # 当RSI位于30—70区间时报告闭区间状态
    print('RSI区间状态: 30 ≤ RSI ≤ 70')  # 输出包含边界的机械区间
=== 样本末日描述性摘要 ===
样本末日收盘价: 3.35 元
MA5: 3.38
MA20: 3.42
RSI: 26.07
MACD: 0.0086
均线状态: MA5 < MA20
RSI区间状态: RSI < 30

上述状态只复述样本末日指标之间的数值关系。阈值和窗口没有经过时间外验证,也没有纳入成交时点、成本、滑点或多重尝试,因此不证明未来价格方向,不构成投资行动建议。

关键要点: - GridSpec 提供灵活的布局 - 整合多种图表类型 - 技术指标图只描述样本内数值与参考线的关系 - 配色和布局应支持变量辨识、单位核对和灰度阅读 - 添加可复算统计信息,为图形判断提供数值证据 - 保存为高分辨率图片

10.6 总结

本章建立了从 Matplotlib 对象接口到 Seaborn 和 Pandas 高级绘图接口的基本路径,并把坐标、图例、样本期、导出格式和可访问性纳入同一发布审计。

图形的任务是让数据、尺度和比较关系可检查。清晰图形仍然只是证据表达的一部分:预测需要时间外评估,因果判断需要识别设计,投资规则还需要执行和成本模型。保持这些边界,才能使可视化服务于分析而不替代分析。

章节导航:上一章为时间序列分析,下一章为Pandas 的边界与内存管理