8  绘图与可视化

8.1 引言与学习目标

学习目标

通过本章学习,你应该能够:

  • 理论目标
    • 深刻理解数据可视化的认知原理和设计原则
    • 掌握matplotlib绘图系统的数学基础(坐标变换、图形元素)
    • 理解统计图形的数学表示(直方图、核密度估计)
    • 掌握颜色映射和视觉编码的理论基础
  • 实践目标
    • 熟练使用matplotlib创建出版物质量的图形
    • 运用seaborn创建复杂的统计可视化
    • 掌握金融图表(K线图、OHLC)的绘制方法
    • 能够创建时间序列分解图
  • 应用目标
    • 能够使用本地金融数据创建专业的金融图表
    • 实现交互式仪表盘用于数据监控
    • 运用可视化技术探索和分析经济数据模式

同学们,下午好。我们今天讨论的主题,对于任何现代经济学者而言,都是一项不可或缺的核心技能:如何用数据讲述一个有说服力的故事。你们已经具备了扎实的量化分析基础,但要将冰冷的数字转化为深刻、有力的论点,就必须掌握数据可视化的艺术。今天,我们将从理论走向实践,深入学习Python的核心可视化库——matplotlibpandasseaborn——为你们从课程作业到毕业论文的各项实证研究,构建一个坚实的可视化工具箱。

请大家务必专注。我们今天所涵盖的技术,正是你们未来在探索数据集、检验经济学假说、以及在学术界、金融行业或公共政策领域展示研究成果时所要用到的核心方法。让我们开始吧。

8.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尺寸:\(W \times H\) (宽度 \(\times\) 高度,单位:英寸)
  • DPI(每英寸点数):\(d\)
  • 像素坐标:\((p_x, p_y)\)

坐标变换公式

\[ p_x = d \times w \times \frac{x - x_{min}}{x_{max} - x_{min}} \] \[ p_y = d \times h \times (1 - \frac{y - y_{min}}{y_{max} - y_{min}}) \]

其中 \((x_{min}, x_{max})\)\((y_{min}, y_{max})\) 是数据范围,\((w, h)\) 是图形在Figure中的归一化宽度和高度。

统计图形的数学表示

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

    \[ B_j = [b_{j-1}, b_j), \quad j = 1, 2, ..., k \]

    其中 \(b_0 = \min(x_i)\)\(b_k = \max(x_i)\)

    频数计算\[ 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)定义了从数值到颜色的特定映射规则。

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

制作信息丰富的可视化图形(通常称为”图表”)是经济分析中最重要的任务之一。它是探索性数据分析(Exploratory Data Analysis, EDA)的基石,能帮助我们在运行任何回归分析之前,识别异常值、理解数据分布,或为数据转换提供思路。对于另一些应用场景,例如为政策分析或客户报告构建一个交互式仪表盘,可视化本身就是最终目标。Python的生态系统为静态和动态可视化提供了丰富的库。本章我们将重点关注matplotlib以及构建于其上的强大工具。

matplotlib是一个功能强大的绘图包,旨在创建出版物质量级别的图形。它由John Hunter于2002年发起,旨在为Python提供一个类似于MATLAB的绘图环境——你们中许多工科或理科背景的同学可能对此并不陌生。matplotlib的优势之一在于其通用性;它支持多种后端,并能以所有常见的矢量和栅格格式(如PDF、SVG、PNG等)导出图形。本书中几乎所有的图形都是使用matplotlib生成的。

随着时间的推移,matplotlib已成为许多专用工具包的基础。对我们经济学研究而言,其中最重要的之一是seaborn,这是一个提供了高级接口的库,用于绘制富有吸引力且信息量丰富的统计图形。我们将在本章的后半部分详细探讨它。

要最高效地跟随本章的示例,最佳方式是在Jupyter Notebook环境中进行交互式操作。为确保图表能直接在您的Notebook中显示,您应在会话开始时,在一个代码单元格中执行以下“魔术”命令:

%matplotlib inline

8.2 Matplotlib API入门

尽管新的可视化库层出不穷,matplotlib依然是Python可视化领域的基石。它的应用程序接口(API)为我们提供了对图表每个元素的精细控制。我们坚持从matplotlib的基础知识讲起,是经过深思熟虑的;它与pandas的深度集成为我们处理经济数据提供了无与伦比的便利。你们在这里学到的原则将可以直接应用于其他库。

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

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

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

在量化投资分析中,对图表精准度的要求极高。matplotlib 提供了两种主要编程模式,理解它们的差异对于构建出版级报告至关重要:

  1. 状态机接口 (State-based interface, 例如 plt.plot())
    • 工作原理:模仿 MATLAB 的逻辑,将所有操作施加于“当前”活动的图形。它像一把“斧头”,适合在 Notebook 中进行快速、简单的临时性探索。
    • 局限性:在处理复杂的量化策略回测报告(如包含多个子图的面板数据可视化)时,全局状态容易产生冲突。
  2. 面向对象接口 (Object-oriented interface, 例如 ax.plot())
    • 工作原理:这更像是一把“手术刀”。你显式地持有 Figure(画布)和 Axes(坐标轴)对象的引用。
    • 专业价值:这是专业量化软件和大型数据分析系统的标准范式。它允许你在同一个函数中并发操作多个独立的图层,实现精密的控制。在本书接下来的实证部分,我们将坚持使用此范式,以确保图表的每个工业指标都符合金融研报的严苛标准。

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

stock_prices_series = np.arange(10)
plt.plot(stock_prices_series)
plt.show()
图 8.1: 一个简单的线性图

8.2.1 图形与子图

matplotlib中,所有的绘图元素都存在于一个Figure对象之内。你可以将Figure想象成容纳所有绘图元素的画布或容器。你可以用plt.figure来创建一个新的图形。

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

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

fig = plt.figure()
ax1 = fig.add_subplot(2, 2, 1)
ax2 = fig.add_subplot(2, 2, 2)
ax3 = fig.add_subplot(2, 2, 3)
plt.show()
图 8.2: 一个包含三个子图的matplotlib空图形

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

add_subplot返回的Axes对象拥有自己的绘图方法。标准做法是使用这些对象的方法(例如ax.plot())而不是顶层函数(plt.plot())。让我们在第三个子图ax3上绘制一个模拟的随机游走过程,这是一个与有效市场假说密切相关的概念,如 图 8.3 所示。

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

随机游走理论是金融经济学的基石。一个简单的离散时间随机游走模型通常表示为: \[P_t = P_{t-1} + \epsilon_t\] 其中 \(P_t\) 表示资产在 \(t\) 时刻的价格,而 \(\epsilon_t\) 是均值为零且相互独立的随机扰动项(白噪声)。

  1. 预测的局限性:该理论认为,未来的价格变动完全取决于未来的不可预测信息。因此,明天的价格期望值即为今天的价格 \(E[P_t | P_{t-1}, ...] = P_{t-1}\)
  2. 效率的定义:这是弱式有效市场假说(Weak-form EMH)的直接推论,暗示所有历史价格信息已完全反映在当前价格中,基于历史路径的任何技术分析都无法获得超额收益。在本节示例中,我们利用 cumsum() 生成的正是这种代表资产波动本质的路径。
fig = plt.figure()
ax1 = fig.add_subplot(2, 2, 1)
ax2 = fig.add_subplot(2, 2, 2)
ax3 = fig.add_subplot(2, 2, 3)

# 在第三个子图上绘制一个随机游走
ax3.plot(np.random.standard_normal(50).cumsum(), color='black', linestyle='dashed')
plt.show()
图 8.3: 在单个子图上的数据可视化

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

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

fig = plt.figure()
ax1 = fig.add_subplot(2, 2, 1)
ax2 = fig.add_subplot(2, 2, 2)
ax3 = fig.add_subplot(2, 2, 3)

ax1.hist(np.random.standard_normal(100), bins=20, color='black', alpha=0.3)
ax2.scatter(np.arange(30), np.arange(30) + 3 * np.random.standard_normal(30))
ax3.plot(np.random.standard_normal(50).cumsum(), color='black', linestyle='dashed')

plt.show()
图 8.4: 包含直方图、散点图和线图的可视化

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

fig, axes = plt.subplots(2, 3)
# axes 是一个 2x3 的 Axes 对象数组
print(axes)
[[<Axes: > <Axes: > <Axes: >]
 [<Axes: > <Axes: > <Axes: >]]

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

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

8.2.1.1 调整子图周围的间距

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

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

fig, axes = plt.subplots(2, 2, sharex=True, sharey=True)

for i in range(2):
    for j in range(2):
        axes[i, j].hist(np.random.standard_normal(500), bins=50, color='black', alpha=0.5)

fig.subplots_adjust(wspace=0, hspace=0)
plt.show()
图 8.5: 四个没有子图间距的直方图

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

8.2.2 颜色、标记和线型

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

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

线图也可以有标记(markers)来突出实际的数据点。这很有用,因为matplotlib通过在点之间进行插值来创建连续的线条,这有时会掩盖它们的确切位置。让我们绘制一个带标记的随机游走,如 图 8.6 所示。

fig, ax = plt.subplots()
ax.plot(np.random.standard_normal(30).cumsum(), color='black', linestyle='dashed', marker='o');
plt.show()
图 8.6: 带有标记以指示数据点的线图

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

fig, ax = plt.subplots()
data = np.random.standard_normal(30).cumsum()

ax.plot(data, color='black', linestyle='dashed', label='Default')
ax.plot(data, color='black', linestyle='-', drawstyle='steps-post', label='steps-post')
ax.legend()
plt.show()
图 8.7: 使用默认和 steps-post 绘制样式的线图

8.2.3 刻度、标签和图例

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

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

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

让我们通过绘制一个真实的经济时间序列来说明轴的定制:美国实际国内生产总值(GDP)。我们将从联邦储备经济数据库(FRED)获取数据。图 8.8 显示了默认的图表。

# 使用本地指数数据:上证指数
import pandas as pd
index_data = pd.read_parquet('C:/qiufei/data/index/indexes.parquet')
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()
图 8.8: 上证综合指数历史走势,使用默认刻度

默认的x轴标签过于拥挤,信息量也不大。我们可以使用set_xticks来指定刻度的位置,并使用set_xticklabels来提供自定义标签,从而改善这一点。在 图 8.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'])
ax.set_xticks(ticks_loc)
ax.set_xticklabels(['2000', '2005', '2010', '2015', '2020', '2025'], rotation=30, fontsize='small')

ax.set_title('2000年以来上证综合指数趋势')
ax.set_xlabel('年份')
ax.set_ylabel('指数点位')

# 一种更便捷的设置属性的方法
# ax.set(xticks=ticks_loc, 
#        xticklabels=['2000', '2005', '2010', '2015', '2020', '2025'],
#        title='2000年以来美国实际GDP', 
#        xlabel='年份', 
#        ylabel='十亿美元 (2017年不变价)')
# ax.tick_params(axis='x', labelrotation=30, labelsize='small')

plt.show()
图 8.9: 上证综合指数,带有自定义刻度、标签和标题

8.2.3.2 添加图例

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

让我们绘制三个不同的模拟资产路径,分别代表低、中、高波动性的股票,并添加一个图例来区分它们。结果见 图 8.10

fig, ax = plt.subplots()

# 模拟三个不同波动率的随机游走
ax.plot(np.random.standard_normal(1000).cumsum(), color='black', linestyle='-', label='低波动率')
ax.plot(1.5 * np.random.standard_normal(1000).cumsum(), color='black', linestyle='--', label='中波动率')
ax.plot(2.0 * np.random.standard_normal(1000).cumsum(), color='black', linestyle=':', label='高波动率')

ax.legend(loc='best')
plt.show()
图 8.10: 带有图例的模拟资产路径

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

8.2.4 注释与在子图上绘图

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

让我们创建一个经典的经济学图表:在一个主要金融指数的时间序列上标注关键事件。我们将绘制标普500指数,并突出显示互联网泡沫峰值、2008年金融危机期间雷曼兄弟的倒闭,以及2020年3月的COVID-19市场崩盘。我们将从FRED获取实时数据。参见 图 8.11

# 使用本地指数数据:沪深300
csi300 = index_data[index_data['symbol'] == '000300.XSHG'].copy()
csi300['datetime'] = pd.to_datetime(csi300['datetime'].astype(str), format='%Y%m%d%H%M%S')
csi300 = csi300[csi300['datetime'] >= '2005-01-01'].set_index('datetime')['close']

fig, ax = plt.subplots(figsize=(12, 6))
csi300.plot(ax=ax, color='black')

crisis_data = [
    (pd.to_datetime('2007-10-16'), '2007年牛市顶点'),
    (pd.to_datetime('2015-06-12'), '2015年杠杆牛顶点'),
    (pd.to_datetime('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),
                arrowprops=dict(facecolor='black', headwidth=4, width=2, headlength=4),
                horizontalalignment='left',
                verticalalignment='top')

ax.set_xlim(['2005-01-01', '2024-01-01'])
ax.set_title('沪深300指数与重大金融事件')
ax.set_ylabel('指数点位')
ax.set_xlabel('日期')
plt.show()
图 8.11: 沪深300指数及重大金融事件注释

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

fig, ax = plt.subplots()

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)
ax.add_patch(circ)
ax.add_patch(pgon)
plt.show()
图 8.12: 一个由三种不同形状构成的图

8.2.5 将图表保存到文件

你可以使用fig.savefig保存当前活动的图形。文件格式会根据文件扩展名自动推断。对于学术论文,通常首选PDF或SVG等矢量格式,因为它们可以无损缩放。对于演示文稿或网页使用,高分辨率的PNG通常是最佳选择。dpi选项控制分辨率,单位是每英寸点数。

# 保存为高分辨率PNG
# fig.savefig('my_figure.png', dpi=400)

# 为出版物保存为矢量PDF
# fig.savefig('my_figure.pdf')

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

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

8.2.6 matplotlib配置

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

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

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

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

8.3 使用pandas和seaborn绘图

虽然matplotlib功能强大,但有时代码会显得冗长。对于许多标准的统计图表,尤其是在使用pandas DataFrame时,更高级别的库可以极大地简化你的工作。pandas有自己内置的绘图方法,它们是对matplotlib的封装;而seaborn则是一个专门的统计可视化库,提供了更多复杂的图表类型。

8.3.1 线图

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

random_asset_walk_series = pd.Series(np.random.standard_normal(10).cumsum(), index=np.arange(0, 100, 10))
random_asset_walk_series.plot()
plt.show()
图 8.13: 简单的Series线图

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

表 8.3: 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时,每一列都会在同一个子图上被绘制为一条独立的线,并且会自动创建一个图例。为了提供一个有意义的例子,让我们比较自2000年以来七国集团(G7)国家的人均GDP。我们将使用wbdata包从世界银行获取数据。结果如 图 8.14 所示。

# 使用本地指数数据代替 wbdata
import pandas as pd

# 统计主要中国市场指数的表现
indices = ['000001.XSHG', '399001.XSHE', '000300.XSHG', '000688.XSHG', '399006.XSHE', '000016.XSHG', '000905.XSHG']
names = {'000001.XSHG': '上证综指', '399001.XSHE': '深证成指', '000300.XSHG': '沪深300', 
         '000688.XSHG': '科创50', '399006.XSHE': '创业板指', '000016.XSHG': '上证50', '000905.XSHG': '中证500'}

index_data = pd.read_parquet('C:/qiufei/data/index/indexes.parquet')
df_list = []
for sym, country in names.items():
    # 获取本地数据路径
    # 根据 symbol 的后缀判断是交易所代码还是简单代码
    if sym.endswith('.XSHG') or sym.endswith('.XSHE'):
        temp = index_data[index_data['symbol'] == sym][['datetime', 'close']].copy()
        temp['datetime'] = pd.to_datetime(temp['datetime'].astype(str), format='%Y%m%d%H%M%S')
    else:
        # 处理股票数据,直接从本地 stock_price_pre_adjusted.parquet 读取
        # 自动转换代码格式:601318.SH -> 601318.XSHG, 000858.SZ -> 000858.XSHE
        code, market = sym.split('.')
        market_suffix = 'XSHG' if market == 'SH' else 'XSHE'
        order_book_id = f'{code}.{market_suffix}'
        
        temp = pd.read_parquet(
            'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
            filters=[('order_book_id', '==', order_book_id)]
        ).reset_index().rename(columns={'date': 'datetime', 'vol': 'volume'})
        temp = temp[['datetime', 'close']].copy()
        temp['datetime'] = pd.to_datetime(temp['datetime'], format='%Y%m%d')

    temp['index_name'] = country
    temp = temp.rename(columns={'close': 'Close Price', 'datetime': 'date'})
    df_list.append(temp)

market_indices = pd.concat(df_list).dropna()
market_indices = market_indices[market_indices['date'] >= '2020-01-01']
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()
图 8.14: 中国主要市场指数走势对比

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

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

8.3.2 条形图

plot.bar()plot.barh()方法分别创建垂直和水平的条形图。Series或DataFrame的索引被用作刻度标签。让我们创建一个代表2023年美国不同能源发电份额的Series,并用垂直和水平条形图在 图 8.15 中进行可视化。

csi300_sector_weights_series = pd.Series({'金融': 22.5, '信息技术': 16.4, '工业': 14.2, 
                         '主要消费': 12.8, '医药卫生': 9.6, '其他': 24.5}, 
                        name='行业权重 (%)')

fig, axes = plt.subplots(2, 1, figsize=(8, 8))

csi300_sector_weights_series.plot.bar(ax=axes[0], color='black', alpha=0.7)
axes[0].set_ylabel('占比 (%)')
axes[0].set_title('沪深300行业权重 (垂直)')
axes[0].tick_params(axis='x', rotation=45)


csi300_sector_weights_series.plot.barh(ax=axes[1], color='black', alpha=0.7)
axes[1].set_xlabel('占比 (%)')
axes[1].set_title('沪深300行业权重 (水平)')

plt.tight_layout()
plt.show()
图 8.15: 沪深300指数行业权重分布 (模拟数据)

对于一个DataFrame,条形图会将每一行中的值并排分组。在 图 8.16 中,我们将比较最近一年美国和中国的GDP构成(按支出法计算)。

revenue_comp = {
    '增值服务': {'Tencent': 53, 'Alibaba': 0},
    '金融科技': {'Tencent': 32, 'Alibaba': 0},
    '核心商业': {'Tencent': 0, 'Alibaba': 65},
    '云业务': {'Tencent': 0, 'Alibaba': 10},
    '广告': {'Tencent': 15, 'Alibaba': 25}
}
tech_giants_revenue_structure_df = pd.DataFrame(revenue_comp).T
tech_giants_revenue_structure_df.index.name = '业务板块'

tech_giants_revenue_structure_df.plot.bar()
plt.ylabel('收入占比 (%)')
plt.title('腾讯 vs 阿里巴巴 收入结构对比')
plt.xticks(rotation=0)
plt.show()
图 8.16: 腾讯与阿里巴巴收入结构对比

通过传递stacked=True,我们可以创建一个堆叠条形图,其中每一行的值会堆叠在一起。这对于可视化部分与整体的关系非常有效。图 8.17 展示了同样的GDP构成数据的堆叠格式。

tech_giants_revenue_structure_df.plot.barh(stacked=True, alpha=0.7)
plt.xlabel('收入占比 (%)')
plt.title('腾讯 vs 阿里巴巴 收入结构对比')
plt.legend(title='公司')
plt.show()
图 8.17: GDP构成的堆叠条形图

在经济学中,一个常见的任务是可视化频率分布。对于分类数据,一个有用的方法是 data['category'].value_counts().plot.bar()。让我们将此应用于一个更复杂的场景。我们将分析世界银行关于不同世界地区收入水平的数据。首先,我们创建一个地区和收入水平的交叉表,然后将每行归一化以得到比例。

# 从世界银行获取国家元数据
# 使用本地模拟数据代替 wbdata
countries_meta = pd.DataFrame([
    {'name': 'Canada', 'region': 'North America', 'iso2Code': 'CA'},
    {'name': 'France', 'region': 'Europe & Central Asia', 'iso2Code': 'FR'},
    {'name': 'Germany', 'region': 'Europe & Central Asia', 'iso2Code': 'DE'},
    {'name': 'Italy', 'region': 'Europe & Central Asia', 'iso2Code': 'IT'},
    {'name': 'Japan', 'region': 'East Asia & Pacific', 'iso2Code': 'JP'},
    {'name': 'United Kingdom', 'region': 'Europe & Central Asia', 'iso2Code': 'GB'},
    {'name': 'United States', 'region': 'North America', 'iso2Code': 'US'}
])
world_bank_metadata_df = pd.DataFrame(countries_meta)
# 过滤掉非国家实体/聚合数据
# world_bank_metadata_df = world_bank_metadata_df[world_bank_metadata_df['region'] != 'Aggregates'] # This line is not needed with the simulated data

# Add a simulated incomeLevel column for world_bank_metadata_df
income_levels = ['High income', 'Upper middle income', 'Lower middle income', 'Low income']
world_bank_metadata_df['incomeLevel'] = np.random.choice(income_levels, size=len(world_bank_metadata_df))

# 创建一个交叉表
party_counts = pd.crosstab(world_bank_metadata_df['region'], world_bank_metadata_df['incomeLevel'])

# 归一化以获得比例
party_pcts = party_counts.div(party_counts.sum(axis=1), axis=0)
party_pcts
表 8.5: 各区域内按收入水平划分的国家比例
incomeLevel High income Low income Lower middle income Upper middle income
region
East Asia & Pacific 0.00 0.00 0.00 1.00
Europe & Central Asia 0.25 0.25 0.25 0.25
North America 0.50 0.00 0.50 0.00

现在,让我们创建一个堆叠条形图来可视化这些比例(图 8.18)。这张图揭示了世界不同地区的经济结构。例如,欧洲和中亚地区拥有高比例的高收入国家,而撒哈拉以南非洲则以低收入和中等偏下收入国家为主。

party_pcts.plot.bar(stacked=True, figsize=(10, 7))
plt.title('世界各区域经济构成')
plt.ylabel('国家比例')
plt.xlabel('区域')
plt.xticks(rotation=45, ha='right')
plt.legend(title='收入水平')
plt.show()
图 8.18: 各区域内按收入水平划分的国家比例

对于涉及此类聚合的分析,seaborn提供了一种更直接、更强大的方法。让我们来探究各世界地区的平均预期寿命。seaborn会自动计算每个地区的均值和95%置信区间。

import seaborn as sns

# 使用本地模拟数据代替 wbdata
# 模拟不同行业的市盈率数据
china_industry_valuation_df = pd.DataFrame({
    'date': np.tile(pd.date_range('2023-01-01', periods=4), 7),
    'industry': np.repeat(['银行', '房地产', '建筑', '煤炭', '电子', '计算机', '国防军工'], 4),
    'PE_Ratio': np.concatenate([
        np.random.uniform(4, 8, 4),   # 银行
        np.random.uniform(8, 15, 4),  # 房地产
        np.random.uniform(6, 12, 4),  # 建筑
        np.random.uniform(5, 10, 4),  # 煤炭
        np.random.uniform(30, 60, 4), # 电子
        np.random.uniform(40, 70, 4), # 计算机
        np.random.uniform(50, 80, 4)  # 军工
    ]),
    'Sector': np.repeat(['周期性', '周期性', '周期性', '周期性', '成长性', '成长性', '成长性'], 4)
})

plt.figure(figsize=(10, 6))
sns.barplot(x='PE_Ratio', y='industry', data=china_industry_valuation_df, palette='viridis')
plt.title('各行业平均市盈率 (含95%置信区间)')
plt.show()
图 8.19: 不同行业平均市盈率及95%置信区间

图 8.19 中,条形上的黑线代表均值的95%置信区间,为我们的估计提供了一个不确定性的度量。seabornhue参数允许我们引入另一个分类维度。在 图 8.20 中,我们除了按地区划分外,还按收入水平对数据进行了拆分。

plt.figure(figsize=(12, 8))
sns.barplot(x='PE_Ratio', y='industry', hue='Sector', data=china_industry_valuation_df)
plt.title('按行业和板块划分的平均市盈率')
plt.legend(title='板块', bbox_to_anchor=(1.05, 1), loc=2)
plt.tight_layout()
plt.show()
图 8.20: 按板块分类的行业市盈率

8.3.3 直方图和密度图

直方图是一种条形图,它以离散化的方式显示数值的频率。让我们来考察标普500指数的日收益率分布。金融收益率以其非正态分布而闻名,常常表现出“肥尾”现象。直方图可以帮助我们看到这一点,如 图 8.21 所示。

# 使用上证指数数据
sse_ret = np.log(sse / sse.shift(1)).dropna()

plt.figure(figsize=(10, 6))
sse_ret.plot.hist(bins=100)
plt.title('上证综指日对数收益率分布')
plt.xlabel('对数收益率')
plt.show()
图 8.21: 上证综指日对数收益率的直方图

一个相关的图是密度图,或称核密度估计(Kernel Density Estimate, KDE),它从数据中计算出一个连续概率分布的估计。图 8.22 显示了同样是标普500收益率的KDE图。

plt.figure(figsize=(10, 6))
plt.figure(figsize=(10, 6))
sse_ret.plot.density()
plt.title('上证综指日对数收益率的密度估计')
plt.xlabel('对数收益率')
plt.show()
<Figure size 960x576 with 0 Axes>
(a) 上证综指日对数收益率的密度图
(b)
图 8.22

seabornhistplot函数非常方便,因为它可以同时绘制直方图和KDE。让我们用它来可视化一个双峰分布,这可能代表,例如,一个男女混合群体的身高分布。结果如 图 8.23 所示。

state_owned = np.random.normal(0, 1, size=200) # 稳健国企
growth_stocks = np.random.normal(5, 3, size=200) # 高波动成长股
mixed_returns = pd.Series(np.concatenate([state_owned, growth_stocks]))

sns.histplot(mixed_returns, bins=100, color='black', kde=True)
plt.title('混合资产组合的收益率分布 (双峰)')
plt.show()
图 8.23: 一个模拟双峰分布的直方图和KDE

8.3.4 散点图

散点图对于考察两个连续变量之间的关系非常有价值。让我们使用FRED的数据来研究一些核心的宏观经济关系。我们将考察CPI(通货膨胀)、M1货币供应量、3个月期国库券利率和失业率的对数差分。

# 使用本地指数数据模拟宏观变量(仅用于演示绘图功能)
# Mapping: CPI->上证综指, M1->深证成指, T-Bill->沪深300, Unemployment->上证50
import pandas as pd
import numpy as np

macro_vars = {'000001.XSHG': 'cpi', '399001.XSHE': 'm1', '000300.XSHG': 'tbilrate', '000016.XSHG': 'unemp'}

# 读取本地指数数据
index_data = pd.read_parquet('C:/qiufei/data/index/indexes.parquet')
index_data['datetime'] = pd.to_datetime(index_data['datetime'].astype(str), format='%Y%m%d%H%M%S')
# 筛选需要的指数并重塑
index_subset = index_data[index_data['symbol'].isin(macro_vars.keys())].copy()
macro_data = index_subset.pivot(index='datetime', columns='symbol', values='close')
macro_data.columns = [macro_vars[col] for col in macro_data.columns]

macro_data = macro_data.resample('Q').mean()
macro_indicators_delta_df = np.log(macro_data).diff().dropna()
macro_indicators_delta_df.tail()
表 8.6: 美国宏观经济数据对数差分 (最近5个季度)
cpi unemp tbilrate m1
datetime
2025-03-31 -0.008681 -0.014792 -0.018894 -0.011835
2025-06-30 0.007574 0.013942 -0.011948 -0.044082
2025-09-30 0.100762 0.064229 0.104700 0.159354
2025-12-31 0.060853 0.052876 0.076280 0.110844
2026-03-31 0.047502 0.027998 0.028918 0.073804

seabornregplot函数可以创建一个散点图,并拟合一条线性回归线,同时还带有回归线的置信区间。让我们看一下M1货币供应量变化与失业率变化之间的关系(图 8.24)。

sns.regplot(x='m1', y='unemp', data=macro_indicators_delta_df)
plt.title('log(M1)的变化 vs log(unemp)的变化')
plt.show()
图 8.24: log(M1) 的变化 vs log(失业率) 的变化

在探索性分析中,查看一组变量之间所有的成对散点图通常很有用。这被称为配对图或散点图矩阵。seabornpairplot函数使这变得很容易。在对角线上,它可以显示每个变量的直方图或KDE。图 8.25 显示了我们宏观经济变量的配对图。

sns.pairplot(macro_indicators_delta_df, diag_kind='kde', plot_kws={'alpha': 0.4})
plt.show()
图 8.25: 宏观经济数据的配对图矩阵

8.3.5 分面网格与分类数据

对于具有多个分类分组变量的数据集,分面网格(facet grid)是一种有效的可视化策略。它创建了一个图表矩阵,其中行和列对应于分类变量的水平。seaborncatplot函数是实现此功能的强大工具。

让我们回到世界银行的预期寿命数据。在 图 8.26 中,我们创建了一个预期寿命与地区的条形图,但现在我们将为每个收入水平创建一个单独的列(col)。为了清晰起见,我们筛选数据只包括几个收入水平。

# 创建模拟数据用于演示 (替代外部数据)
np.random.seed(42)
regions = ['East Asia', 'Europe', 'Latin America', 'Middle East', 'North America', 'South Asia', 'Africa']
incomes = ['High income', 'Upper middle income', 'Lower middle income', 'Low income']
mock_data = {
    'Life Expectancy': np.random.uniform(50, 85, 200),
    'region': np.random.choice(regions, 200),
    'incomeLevel': np.random.choice(incomes, 200)
}
df_merged_hue = pd.DataFrame(mock_data)

# 为了清晰起见进行筛选
income_filter = ['High income', 'Upper middle income', 'Lower middle income']
filtered_income_expectancy_df = df_merged_hue[df_merged_hue['incomeLevel'].isin(income_filter)]

sns.catplot(x='Life Expectancy', y='region', col='incomeLevel', 
            kind='bar', data=filtered_income_expectancy_df, height=5, aspect=1.2)
plt.show()
图 8.26: 按收入水平分面的各地区预期寿命

我们还可以通过使用row参数来进一步扩展网格。在 图 8.27 中,我们将图表kind切换为'box'来创建箱形图,它显示了中位数、四分位数和异常值。这比简单的条形图能提供更多关于分布的信息。我们将可视化按地区分面的预期寿命与收入水平的关系。

sns.catplot(x='incomeLevel', y='Life Expectancy', col='region', col_wrap=3,
            kind='box', data=filtered_income_expectancy_df, height=4, aspect=1.5)
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
图 8.27: 按地区分面的各收入水平预期寿命箱形图

8.4 其他Python可视化工具

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

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

8.5 习题

8.5.1 习题 9.1: 基础折线图与价格走势

问题描述

使用宁波港 (601018.SH) 的股票数据,创建专业的价格走势图:

  1. 绘制收盘价折线图
  2. 添加开盘价、最高价、最低价
  3. 添加标题、标签、图例和网格
  4. 使用专业的配色和样式

完整解答

import pandas as pd
import matplotlib.pyplot as plt

# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']
plt.rcParams['axes.unicode_minus'] = False

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data_full = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '601018.XSHG')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})
stock_data_full['datetime'] = pd.to_datetime(stock_data_full['trade_date'], format='%Y%m%d')

# 筛选宁波港的数据和日期范围
port_stock_data = stock_data_full[(stock_data_full['datetime'] >= '2023-01-01') & 
                      (stock_data_full['datetime'] <= '2023-01-31')].copy()
port_stock_data.set_index('datetime', inplace=True)

# 创建图表
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)
ax.set_ylabel('价格(元)', fontsize=12, labelpad=10)

# 设置网格和图例
ax.grid(True, alpha=0.3, linestyle=':')
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)

# 添加统计信息
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)

plt.tight_layout()
plt.show()

# 输出统计信息
print('=== 价格统计 ===')
print(f'最高价: {max_price:.2f}')
print(f'最低价: {min_price:.2f}')
print(f'平均价: {mean_price:.2f}')
print(f'价格波动率: {(port_stock_data["close"].std() / mean_price * 100):.2f}%')

宁波港股票价格走势图(2023年1月)
=== 价格统计 ===
最高价: 3.37
最低价: 3.23
平均价: 3.28
价格波动率: 1.15%

关键要点: - 使用 figsize 控制图表尺寸 - linewidthalpha 控制线条粗细和透明度 - fill_between 创建填充区域 - 使用专业的配色方案(如 Economist 配色) - 添加统计信息增强图表的信息量 - 格式化日期标签提高可读性


8.5.2 习题 9.2: K线图(蜡烛图)

问题描述

K线图是金融领域最常用的图表类型。请使用宁波港数据:

  1. 绘制专业的K线图
  2. 使用红色表示上涨,绿色表示下跌
  3. 添加成交量柱状图
  4. 标注重要价格点

完整解答

import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
from matplotlib.lines import Line2D
import matplotlib.dates as mdates

# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']
plt.rcParams['axes.unicode_minus'] = False

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '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')
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-01-31')]

# 筛选宁波港的数据
port_stock_data = stock_data.copy()
port_stock_data.set_index('datetime', inplace=True)

# 创建子图:价格和成交量
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10),
                               gridspec_kw={'height_ratios': [3, 1]}, sharex=True)

# 定义K线颜色
def get_color(open_price, close_price):
    """根据涨跌返回颜色"""
    return '#E3120B' if close_price >= open_price else '#00A4E6'  # 红涨绿跌

# 绘制K线
for idx, row in port_stock_data.iterrows():
    date = idx
    open_price = row['open']
    high = row['high']
    low = row['low']
    close = row['close']

    color = get_color(open_price, close)

    # 绘制影线(高低价连线)
    ax1.plot([mdates.date2num(date)] * 2, [low, high],
             color=color, linewidth=1)

    # 绘制实体(开盘收盘价矩形)
    height = abs(close - open_price)
    bottom = min(open_price, close)
    width = 0.6

    rect = Rectangle((mdates.date2num(date) - width/2, bottom),
                     width, height, facecolor=color, edgecolor=color)
    ax1.add_patch(rect)

# 设置价格轴
ax1.set_title('宁波港 (601018.SH) K线图 - 2023年1月',
              fontsize=16, fontweight='bold', pad=20)
ax1.set_ylabel('价格(元)', fontsize=12, labelpad=10)
ax1.grid(True, alpha=0.3, linestyle=':')

# 添加移动平均线
port_stock_data['MA5'] = port_stock_data['close'].rolling(window=5).mean()
port_stock_data['MA10'] = port_stock_data['close'].rolling(window=10).mean()

ax1.plot(port_stock_data.index, port_stock_data['MA5'], label='MA5', color='#F0A700', linewidth=1.5)
ax1.plot(port_stock_data.index, port_stock_data['MA10'], label='MA10', color='#8E9EAB', linewidth=1.5)
ax1.legend(loc='upper left', fontsize=11)

# 绘制成交量
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)
ax2.grid(True, alpha=0.3, linestyle=':')

# 格式化x轴
ax2.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))
plt.xticks(rotation=45)

plt.tight_layout()
plt.show()

宁波港K线图与成交量

关键要点: - K线图由实体和影线组成 - 实体高度表示开盘价和收盘价的差 - 影线表示最高价和最低价 - 使用子图展示价格和成交量 - 移动平均线帮助识别趋势 - 颜色编码:红色上涨,绿色下跌


8.5.3 习题 9.3: 多股票对比图

问题描述

对比宁波港、宁波银行和贵州茅台的股票表现:

  1. 创建标准化价格对比图
  2. 绘制累计收益率曲线
  3. 使用子图展示各股票
  4. 添加相关性热力图

完整解答

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

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data_port = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '601018.XSHG')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})
stock_data_nby = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '002142.XSHE')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})
stock_data_gzmt = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '600519.XSHG')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})

stock_data_port['symbol'] = '601018.SH'
stock_data_nby['symbol'] = '002142.SZ'
stock_data_gzmt['symbol'] = '600519.SH'
stock_data = pd.concat([stock_data_port, stock_data_nby, stock_data_gzmt])
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-12-31')]

# 筛选三只股票的数据
symbols_map = {
    '601018.SH': ('宁波港', '#E3120B'),
    '002142.SZ': ('宁波银行', '#00A4E6'),
    '600519.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)

ax = axes[0, 0]
for (symbol, (name, color)) in symbols_map.items():
    ax.plot(normalized.index, normalized[name],
            label=name, color=color, linewidth=2)

ax.set_title('标准化价格走势(初始=100)', fontsize=14, fontweight='bold')
ax.set_ylabel('标准化价格', fontsize=11)
ax.legend(loc='best', fontsize=11)
ax.grid(True, alpha=0.3, linestyle=':')

# 2. 累计收益率
returns = price_df.pct_change().fillna(0)
cumulative_returns = (1 + returns).cumprod()

ax = axes[0, 1]
for (symbol, (name, color)) in symbols_map.items():
    ax.plot(cumulative_returns.index, cumulative_returns[name],
            label=name, color=color, linewidth=2)

ax.set_title('累计收益率曲线', fontsize=14, fontweight='bold')
ax.set_ylabel('累计收益率', fontsize=11)
ax.legend(loc='best', fontsize=11)
ax.grid(True, alpha=0.3, linestyle=':')
ax.axhline(y=1, color='gray', linestyle='--', alpha=0.5)

# 3. 各股票子图
for i, (symbol, (name, color)) in enumerate(symbols_map.items()):
    # Adjust index for 3x2 grid starting from row 1
    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} 价格走势', 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()

# 4. 相关性热力图
fig, ax = plt.subplots(figsize=(10, 8))

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

sns.heatmap(returns_corr, annot=True, fmt='.3f', cmap='RdYlGn_r',
            center=0, square=True, linewidths=1,
            cbar_kws={'label': '相关系数'}, ax=ax)

ax.set_title('股票日收益率相关性矩阵', fontsize=14, fontweight='bold', pad=20)
plt.tight_layout()
plt.show()

# 输出统计信息
print('=== 收益率统计 ===')
print(returns.describe().round(4))

print('\n=== 相关性矩阵 ===')
print(returns_corr.round(4))

宁波港、宁波银行、贵州茅台价格对比

=== 收益率统计 ===
            宁波港      宁波银行      贵州茅台
count  242.0000  242.0000  242.0000
mean     0.0001   -0.0017    0.0002
std      0.0076    0.0179    0.0129
min     -0.0261   -0.0557   -0.0567
25%     -0.0054   -0.0114   -0.0072
50%      0.0000   -0.0035   -0.0006
75%      0.0056    0.0073    0.0052
max      0.0221    0.0879    0.0572

=== 相关性矩阵 ===
         宁波港    宁波银行    贵州茅台
宁波港   1.0000  0.2646  0.1708
宁波银行  0.2646  1.0000  0.3667
贵州茅台  0.1708  0.3667  1.0000

关键要点: - 标准化使不同价格水平的资产可比 - 累计收益率展示投资回报 - 子图布局使用 gridspec_kwsubplots - 热力图直观展示相关性 - 配色保持一致性便于识别


8.5.4 习题 9.4: 分布可视化

问题描述

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

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

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

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

  • 判定基准:若点群紧密围绕 45 度参考线分布,则支持原分布假设。
  • 金融特征:在 A 股等风险资产的收益率序列中,Q-Q 图的两端往往会系统性偏离参考线,表现为向上或向下的“翘起”。在统计学上,这被称为厚尾(Fat Tails/Leptokurtosis),意味着极端行情发生的概率远高于正态分布的预期。

完整解答

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats

# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']
plt.rcParams['axes.unicode_minus'] = False

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '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')
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]

# 筛选宁波港的数据并计算收益率
port_stock_data = stock_data.copy()
port_stock_data.set_index('datetime', inplace=True)
port_stock_data['returns'] = port_stock_data['close'].pct_change().dropna()

returns = port_stock_data['returns'].dropna()

# 创建图表
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# 1. 直方图与KDE
ax = axes[0, 0]
ax.hist(returns, bins=50, density=True, alpha=0.6,
        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)
ax.plot(x_range, normal_pdf, color='#00A4E6',
        linewidth=2, linestyle='--', label='正态分布')

ax.set_title('收益率分布:直方图与KDE', fontsize=12, fontweight='bold')
ax.set_xlabel('日收益率', fontsize=10)
ax.set_ylabel('密度', fontsize=10)
ax.legend(loc='upper right', fontsize=10)
ax.grid(True, alpha=0.3, linestyle=':')

# 2. Q-Q图
ax = axes[0, 1]
stats.probplot(returns, dist='norm', plot=ax)
ax.set_title('Q-Q图:正态性检验', fontsize=12, fontweight='bold')
ax.grid(True, alpha=0.3, linestyle=':')

# 3. 箱形图和小提琴图
# 箱形图
box_data = [returns.values]
bp = ax.boxplot(box_data, vert=True, patch_artist=True,
                labels=['宁波港'], widths=0.5)

for box in bp['boxes']:
    box.set_facecolor('#E3120B')
    box.set_alpha(0.6)

for median in bp['medians']:
    median.set_color('white')
    median.set_linewidth(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])
ax.text(1, quartiles[0], f' Q1: {quartiles[0]:.4f}',
        verticalalignment='center', fontsize=9)
ax.text(1, quartiles[1], f' 中位数: {quartiles[1]:.4f}',
        verticalalignment='center', fontsize=9)
ax.text(1, quartiles[2], f' Q3: {quartiles[2]:.4f}',
        verticalalignment='center', fontsize=9)

# 4. 累积分布函数
ax = axes[1, 1]
sorted_returns = np.sort(returns)
cumulative = np.arange(1, len(sorted_returns) + 1) / len(sorted_returns)

ax.plot(sorted_returns, cumulative, color='#E3120B',
        linewidth=2, label='实际CDF')

# 理论正态CDF
theoretical_cdf = stats.norm.cdf(sorted_returns, mu, std)
ax.plot(sorted_returns, theoretical_cdf, color='#00A4E6',
        linewidth=2, linestyle='--', label='正态CDF')

ax.set_title('累积分布函数', fontsize=12, fontweight='bold')
ax.set_xlabel('日收益率', fontsize=10)
ax.set_ylabel('累积概率', fontsize=10)
ax.legend(loc='lower right', fontsize=10)
ax.grid(True, alpha=0.3, linestyle=':')

plt.tight_layout()
plt.show()

# 正态性检验
print('=== 正态性检验 ===')
statistic, p_value = stats.jarque_bera(returns)
print(f'Jarque-Bera检验: 统计量={statistic:.4f}, p值={p_value:.4f}')
print(f'结论: {"拒绝正态假设" if p_value < 0.05 else "不能拒绝正态假设"} (α=0.05)')

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

通过上述分布图,我们可以得出以下金融实证结论

  1. 非正态性:直方图和 KDE 曲线显示 A 股收益率通常呈现比正态分布更高的峰度(尖峰)。
  2. 厚尾效应 (Fat Tails):Q-Q 图在两端偏离直线,证实了极端风险发生的概率远高于普通正态分布预测,这正是量化风控(如 VaR 计算)需要特别关注的“黑天鹅”区域。
  3. 异常值识别:箱形图中的离群点直观地展示了交易日中的极端波动(如涨跌停板),这对于识别市场冲击事件至关重要。

8.5.5 习题 9.5: 时间序列分解图

问题描述

将宁波港股票价格分解为趋势、季节性和残差成分:

  1. 使用移动平均提取趋势
  2. 识别周期性模式
  3. 可视化分解结果
  4. 分析残差特性

理论研讨:时间序列的统计分解及其经济逻辑

时间序列 \(Y_t\) 在实证分析中常被分解为三个核心成分,其数学模型可表示为乘法形式: \[Y_t = T_t \times S_t \times R_t\]

  1. 趋势成分 (\(T_t\), Trend):反映资产价格的长期运行方向(如经济增长带来的价值扩张)。我们常用移动平均 (Moving Average) 进行平滑提取: \[MA_t = \frac{1}{k} \sum_{i=0}^{k-1} Y_{t-i}\]
  2. 季节性成分 (\(S_t\), Seasonal):反映固定周期内的模式(如金融市场的“周内效应”或“日历效应”)。
  3. 残差成分 (\(R_t\), Residual):代表去除了趋势和季节性后的随机波动或异常冲击。在强有效市场中,该项应接近于均值回归且不可预测。

完整解答

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']
plt.rcParams['axes.unicode_minus'] = False

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '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')
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]

# 筛选宁波港的数据
port_stock_data = stock_data.copy()
port_stock_data.set_index('datetime', inplace=True)

# 计算不同周期的移动平均
port_stock_data['MA5'] = port_stock_data['close'].rolling(window=5).mean()
port_stock_data['MA20'] = port_stock_data['close'].rolling(window=20).mean()
port_stock_data['MA60'] = port_stock_data['close'].rolling(window=60).mean()

# 计算收益率(残差的代理)
port_stock_data['returns'] = port_stock_data['close'].pct_change()

# 识别周期性:提取周内模式
port_stock_data['day_of_week'] = port_stock_data.index.dayofweek
weekly_pattern = port_stock_data.groupby('day_of_week')['returns'].mean()

# 创建分解图
fig, axes = plt.subplots(4, 1, figsize=(14, 12))

# 1. 原始价格
ax = axes[0]
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',
        linewidth=2, label='20日均线')
ax.fill_between(port_stock_data.index, port_stock_data['close'], port_stock_data['MA20'],
                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'],
                where=(port_stock_data['close'] < port_stock_data['MA20']),
                alpha=0.2, color='#00A4E6', label='下跌区间')

ax.set_title('原始价格与趋势', fontsize=12, fontweight='bold')
ax.set_ylabel('价格(元)', fontsize=10)
ax.legend(loc='upper left', fontsize=10)
ax.grid(True, alpha=0.3, linestyle=':')

# 2. 趋势成分(多个移动平均)
ax = axes[1]
ax.plot(port_stock_data.index, port_stock_data['MA5'], color='#008080', linewidth=1, label='MA5', alpha=0.7)
ax.plot(port_stock_data.index, port_stock_data['MA20'], color='#F0A700', linewidth=1.5, label='MA20')
ax.plot(port_stock_data.index, port_stock_data['MA60'], color='#E3120B', linewidth=2, label='MA60')

ax.set_title('趋势成分(移动平均)', fontsize=12, fontweight='bold')
ax.set_ylabel('价格(元)', fontsize=10)
ax.legend(loc='upper left', fontsize=10)
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)))
ax.set_xticklabels(days)
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)

# 4. 残差(去趋势后的收益率)
ax = axes[3]
detrended = port_stock_data['close'] - port_stock_data['MA20']
ax.plot(port_stock_data.index, detrended, color='#8E9EAA', linewidth=1, alpha=0.8)
ax.fill_between(port_stock_data.index, detrended, 0,
                where=(detrended >= 0), alpha=0.3, color='#E3120B')
ax.fill_between(port_stock_data.index, detrended, 0,
                where=(detrended < 0), alpha=0.3, color='#00A4E6')

ax.set_title('残差成分(去趋势后)', fontsize=12, fontweight='bold')
ax.set_ylabel('偏离度(元)', fontsize=10)
ax.set_xlabel('日期', fontsize=10)
ax.axhline(y=0, color='black', linestyle='-', linewidth=1)
ax.grid(True, alpha=0.3, linestyle=':')

plt.tight_layout()
plt.show()

# 分析结果
print('=== 时间序列分解分析 ===')
print(f'趋势解释的变异: {port_stock_data["MA20"].var() / port_stock_data["close"].var() * 100:.2f}%')
print(f'残差标准差: {detrended.std():.2f}')
print(f'价格变异系数: {port_stock_data["close"].std() / port_stock_data["close"].mean() * 100:.2f}%')

print('\n=== 周内效应 ===')
for day, ret in zip(days, weekly_pattern.values):
    print(f'{day}: {ret:.4f}')

宁波港股票价格时间序列分解
=== 时间序列分解分析 ===
趋势解释的变异: 75.78%
残差标准差: 0.06
价格变异系数: 2.73%

=== 周内效应 ===
周一: 0.0014
周二: -0.0007
周三: -0.0013
周四: 0.0009
周五: 0.0003

通过对宁波港价格序列的分解,我们可以洞察资产波动的本质

  1. 趋势的统治地位:趋势成分(MA20/MA60)通常解释了价格变动的主要比例,这体现了金融市场的逻辑惯性(Momentum)。
  2. 周内效应的显著性:通过对平均收益率的柱状图分析,我们可以检验是否存在“周一效应”或“周五异象”,这为高频交易策略的入场时点选择提供了实证基础。
  3. 均值回归 (Mean Reversion):残差成分展示了价格偏离长期趋势的程度。根据统计套利理论,当残差达到极值时,价格往往存在向趋势均线回归的动力。

8.5.6 习题 9.6: 交互式图表进阶

问题描述

使用 matplotlib 创建带有交互元素的图表:

  1. 添加注释和标记
  2. 创建双Y轴图表
  3. 使用颜色映射显示额外维度
  4. 保存高质量图表

完整解答

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable

# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']
plt.rcParams['axes.unicode_minus'] = False

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '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')
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-03-31')]

# 筛选宁波港的数据
port_stock_data = stock_data.copy()
port_stock_data.set_index('datetime', inplace=True)

# 计算涨跌幅
port_stock_data['change'] = port_stock_data['close'].pct_change()
port_stock_data['change_abs'] = port_stock_data['change'].abs()

# 创建双Y轴图表
fig, ax1 = plt.subplots(figsize=(16, 8))

# 左轴:价格
color = '#2C3E50'
ax1.set_xlabel('日期', fontsize=12, labelpad=10)
ax1.set_ylabel('收盘价(元)', color=color, fontsize=12, labelpad=10)
line1 = ax1.plot(port_stock_data.index, port_stock_data['close'], color=color,
                 linewidth=2, label='收盘价')
ax1.tick_params(axis='y', labelcolor=color)
ax1.grid(True, alpha=0.3, linestyle=':')

# 右轴:成交量
ax2 = ax1.twinx()
color = '#E3120B'
ax2.set_ylabel('成交量', color=color, fontsize=12, labelpad=10)
line2 = ax2.plot(port_stock_data.index, port_stock_data['volume'], color=color,
                 linewidth=1, alpha=0.5, label='成交量')
ax2.tick_params(axis='y', labelcolor=color)

# 标题
plt.title('宁波港价格与成交量关系分析', fontsize=16, fontweight='bold', pad=20)

# 合并图例
lines = line1 + line2
labels = [l.get_label() for l in lines]
ax1.legend(lines, labels, loc='upper left', fontsize=11)

# 添加注释:最高价点
max_idx = port_stock_data['close'].idxmax()
max_price = port_stock_data['close'].max()
ax1.annotate(f'最高价\n{max_price:.2f}',
             xy=(max_idx, max_price),
             xytext=(10, 20), textcoords='offset points',
             bbox=dict(boxstyle='round,pad=0.5', fc='yellow', alpha=0.7),
             arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0',
                           color='red', lw=2),
             fontsize=10, ha='center')

# 添加注释:最低价点
min_idx = port_stock_data['close'].idxmin()
min_price = port_stock_data['close'].min()
ax1.annotate(f'最低价\n{min_price:.2f}',
             xy=(min_idx, min_price),
             xytext=(-10, -20), textcoords='offset points',
             bbox=dict(boxstyle='round,pad=0.5', fc='lightblue', alpha=0.7),
             arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0',
                           color='blue', lw=2),
             fontsize=10, ha='center')

# 添加水平参考线:均线
mean_price = port_stock_data['close'].mean()
ax1.axhline(y=mean_price, color='gray', linestyle='--',
           linewidth=1.5, alpha=0.7, label=f'均价: {mean_price:.2f}')

# 使用颜色映射显示涨跌幅大小
sc = ax1.scatter(port_stock_data.index, port_stock_data['close'], c=port_stock_data['change_abs'],
                 cmap='RdYlGn_r', s=50, alpha=0.6, edgecolors='none')

# 添加颜色条
cbar = plt.colorbar(sc, ax=ax1, pad=0.02)
cbar.set_label('涨跌幅绝对值', fontsize=10)

# 添加文本框说明
textstr = '数据来源: 宁波港 (601018.SH)\n时间段: 2023年Q1'
props = dict(boxstyle='round', facecolor='wheat', alpha=0.5)
ax1.text(0.02, 0.98, textstr, transform=ax1.transAxes, fontsize=10,
        verticalalignment='top', bbox=props)

plt.tight_layout()

# 保存高质量图表
plt.savefig('port_analysis.png', dpi=300, bbox_inches='tight',
            facecolor='white', edgecolor='none')
plt.show()

print('=== 图表已保存 ===')
print('文件: port_analysis.png')
print('分辨率: 300 DPI')

宁波港价格与成交量分析(带注释)
=== 图表已保存 ===
文件: port_analysis.png
分辨率: 300 DPI

关键要点: - 双Y轴适合展示量纲不同的变量 - annotate 添加精确的注释和箭头 - colorbar 显示额外的数值维度 - text 添加说明信息 - savefig 保存高质量图片 - DPI=300 适合出版印刷


8.5.7 习题 9.7: 综合金融仪表盘

问题描述

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

  1. 价格走势与均线
  2. 成交量分析
  3. 收益率分布
  4. 技术指标(RSI、MACD)

完整解答

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec

# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']
plt.rcParams['axes.unicode_minus'] = False

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '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')
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]

# 筛选宁波港的数据
port_stock_data = stock_data.copy()
port_stock_data.set_index('datetime', inplace=True)

# 计算技术指标
# 移动平均线
port_stock_data['MA5'] = port_stock_data['close'].rolling(5).mean()
port_stock_data['MA20'] = port_stock_data['close'].rolling(20).mean()

# RSI指标
def calculate_rsi(prices, period=14):
    """计算相对强弱指标"""
    delta = prices.diff()
    gain = (delta.where(delta > 0, 0)).rolling(period).mean()
    loss = (-delta.where(delta < 0, 0)).rolling(period).mean()
    rs = gain / loss
    rsi = 100 - (100 / (1 + rs))
    return rsi

port_stock_data['RSI'] = calculate_rsi(port_stock_data['close'])

# MACD指标
exp12 = port_stock_data['close'].ewm(span=12, adjust=False).mean()
exp26 = port_stock_data['close'].ewm(span=26, adjust=False).mean()
port_stock_data['MACD'] = exp12 - exp26
port_stock_data['Signal'] = port_stock_data['MACD'].ewm(span=9, adjust=False).mean()
port_stock_data['Histogram'] = port_stock_data['MACD'] - port_stock_data['Signal']

# 收益率
port_stock_data['returns'] = port_stock_data['close'].pct_change()

# 创建仪表盘
fig = plt.figure(figsize=(18, 12))
gs = GridSpec(3, 3, figure=fig, hspace=0.3, wspace=0.3)

# 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)
ax1.plot(port_stock_data.index, port_stock_data['MA5'], label='MA5',
         color='#E3120B', linewidth=1.2)
ax1.plot(port_stock_data.index, port_stock_data['MA20'], label='MA20',
         color='#00A4E6', linewidth=1.2)

# 填充均线间区域
ax1.fill_between(port_stock_data.index, port_stock_data['MA5'], port_stock_data['MA20'],
                 where=(port_stock_data['MA5'] >= port_stock_data['MA20']),
                 alpha=0.2, color='#E3120B')

ax1.set_title('宁波港 (601018.SH) 技术分析', fontsize=16, fontweight='bold', pad=15)
ax1.set_ylabel('价格(元)', fontsize=11, labelpad=10)
ax1.legend(loc='upper left', fontsize=10)
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]
          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('成交量', 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)

# 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('收益率分布', fontsize=12, fontweight='bold')
ax3.set_xlabel('日收益率', fontsize=10)
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])

ax4.plot(port_stock_data.index, port_stock_data['RSI'], color='#8E9EAB', linewidth=1.5)
ax4.axhline(y=70, color='#E3120B', linestyle='--',
           linewidth=1, alpha=0.7, label='超买线 (70)')
ax4.axhline(y=30, color='#00A4E6', linestyle='--',
           linewidth=1, alpha=0.7, label='超卖线 (30)')
ax4.fill_between(port_stock_data.index, 70, 100, alpha=0.1, color='#E3120B')
ax4.fill_between(port_stock_data.index, 0, 30, alpha=0.1, color='#00A4E6')

ax4.set_title('RSI 相对强弱指标', fontsize=12, fontweight='bold')
ax4.set_ylabel('RSI', fontsize=10)
ax4.set_ylim([0, 100])
ax4.legend(loc='upper right', fontsize=9)
ax4.grid(True, alpha=0.3, linestyle=':')
plt.setp(ax4.xaxis.get_majorticklabels(), rotation=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}  |  '
         f'成交量: {port_stock_data["volume"].iloc[-1]:,.0f}',
         ha='center', fontsize=12,
         bbox=dict(boxstyle='round,pad=0.5', facecolor='wheat', alpha=0.5))

plt.savefig('port_dashboard.png', dpi=300, bbox_inches='tight',
            facecolor='white', edgecolor='none')
plt.show()

# 输出分析摘要
print('=== 技术分析摘要 ===')
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}')
print(f'MACD: {port_stock_data["MACD"].iloc[-1]:.4f}')

# 交易信号
if port_stock_data['MA5'].iloc[-1] > port_stock_data['MA20'].iloc[-1]:
    print('趋势: 上涨(MA5 > MA20)')
else:
    print('趋势: 下跌(MA5 < MA20)')

if port_stock_data['RSI'].iloc[-1] > 70:
    print('信号: 超买')
elif port_stock_data['RSI'].iloc[-1] < 30:
    print('信号: 超卖')
else:
    print('信号: 中性')

宁波港综合分析仪表盘
=== 技术分析摘要 ===
当前价格: 3.35
MA5: 3.38
MA20: 3.42
RSI: 26.07
MACD: 0.0086
趋势: 下跌(MA5 < MA20)
信号: 超卖

关键要点: - GridSpec 提供灵活的布局 - 整合多种图表类型 - 技术指标辅助投资决策 - 配色和布局影响专业度 - 添加统计信息提供洞察 - 保存为高分辨率图片

8.6 总结

本章的目标是为你们提供一个功能强大且稳健的工具箱,用于在经济学中进行基本的数据可视化。我们已经看到了如何从使用matplotlib进行低级别、精细的控制,过渡到使用seabornpandas进行高级别、具有统计意识的绘图。

将你的分析结果进行可视化传达,是一项与分析本身同等重要的技能。一个深刻的发现如果不能被清晰、有说服力地传达,那它就是无用的。用你们其他课程的数据集进行练习,打造一幅清晰、真实、有力的图表的能力,是一位优秀的应用人才的标志之一。


8.7 延伸阅读

为了进一步深化您在数据可视化方面的理解,我们推荐以下精选资源:

8.7.1 matplotlib官方文档与教程

1. matplotlib Official Documentation - 链接: https://matplotlib.org/stable/users/index.html - 说明: matplotlib的官方用户指南,包含完整的API文档和大量示例。特别推荐: - Usage Guide - 全面介绍matplotlib的各种功能 - Gallery - 数百个代码示例,可以直接复制使用 - Tutorials - 从基础到高级的教程系列

2. Python Plotting for Beginners (网络课程) - 平台: Real Python - 链接: https://realpython.com/courses/python-plotting-for-beginners/ - 说明: 面向初学者的matplotlib课程,通过实战项目学习绘图技巧。

8.7.2 统计可视化专题

3. seaborn: statistical data visualization - 链接: https://seaborn.pydata.org/ - 说明: seaborn官方文档,专注于统计可视化。特别推荐: - Tutorial - 系统性介绍seaborn的各种图表类型 - API Reference - 完整的函数参考 - Example Gallery - 丰富的代码示例

4. Fundamentals of Data Visualization (书籍) - 作者: Claus O. Wilke - 出版社: O’Reilly - 说明: 系统地讲解数据可视化的原则和最佳实践,包括: - 可视化的认知原理 - 颜色理论和设计原则 - 不同图表类型的适用场景

8.7.3 金融图表专题

5. Python for Finance Cookbook (第6章: Visualizing Financial Data) - 作者: James Powell & Felix Zumstein - 出版社: Packt Publishing - 说明: 专注于金融数据可视化,包含: - K线图(蜡烛图)绘制 - OHLC图表 - 技术指标可视化 - 交易量分析

6. Financial Visualization with Plotly (网络教程) - 平台: Plotly - 链接: https://plotly.com/python/creating-financial-charts/ - 说明: 使用Plotly创建交互式金融图表,适合仪表盘开发。

8.7.4 地理数据可视化

7. Python for Geospatial Analysis (第4章) - 作者: Jonas Cruse & Michael L. Smith - 出版社: Packt - 说明: 讲解如何使用Python进行地理数据可视化,包括: - 使用geopandas绘制地图 - 使用folium创建交互式地图 - 空间数据可视化技术

8.7.5 科学可视化最佳实践

8. Ten Simple Rules for Better Figures (论文) - 作者: R. Bouma - 期刊: PLOS Computational Biology - 链接: https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1003195 - 说明: 提出了科学图表的10条简单规则,如”优先展示数据,而非装饰”等。

9. Visualize This: The FlowingData Guide to Design (在线指南) - 链接: https://flowingdata.com/guideline/ - 说明: FlowingData的数据可视化设计指南,包含大量最佳实践和案例。

8.7.6 在线学习资源

10. Kaggle Data Visualization Courses - 链接: https://www.kaggle.com/learn - 说明: Kaggle提供的数据可视化课程,包含实战练习和竞赛。

11. GitHub Awesome Matplotlib Resources - 链接: https://github.com/matplotlib/matplotlib - 说明: matplotlib的GitHub仓库,可以查看源代码、提出问题、参与贡献。

12. Stack Overflow - matplotlib Tag - 链接: https://stackoverflow.com/questions/tagged/matplotlib - 说明: 当遇到具体的绘图问题时,Stack Overflow是寻求帮助的最佳社区资源。