Python在金融中的应用 · 第二部分

第七节:金融图表表达

图表不是把代码换成颜色,而是把问题、数据和证据组织给读者看。本节沿用模拟股票 CSV,逐步完成数据准备、图形选择、颜色和标注、实际结果展示、图片保存与文字解释,最后形成一张可以放入报告的金融图表。

开始前:一张图应该回答什么

画图前先写一句问题:价格怎样变化?哪只股票成交额更高?成交量与日内收益是否同向?哪只股票收益分布更宽?不同问题需要不同图形。图表的第一步不是调用 plt.plot,而是确定比较对象、时间范围、指标和单位。

问题明确要比较什么
数据选择字段和样本
图形匹配视觉结构
标注写清单位和含义
结论说明证据边界
折线图日期顺序、价格、累计收益
柱状图股票或行业的汇总比较
散点图两个数值变量的关系
箱线与热力分布和相关系数

颜色、标题、图例和坐标轴都是数据说明的一部分。读者应该不用打开代码,也能知道图上每条线、每根柱子和每种颜色代表什么。

1. 读取数据并为作图准备长表与宽表

Matplotlib 可以直接接受列表、NumPy 数组和 Pandas Series。金融数据通常先用 Pandas 整理,再把合适的 Series 或宽表交给绘图函数。先排序并构造收益率,避免把不同股票的前后日期错接在一起。

from pathlib import Path
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

df = pd.read_csv(
    Path("data/stock_demo.csv"),
    parse_dates=["date"],
    dtype={"ticker": "string"}
)
df = df.sort_values(["ticker", "date"]).reset_index(drop=True)
df["return"] = df.groupby("ticker")["close"].pct_change()
df["intraday_return"] = df["close"] / df["open"] - 1

price_wide = df.pivot_table(
    index="date", columns="name", values="close", aggfunc="last"
)
return_wide = df.pivot_table(
    index="date", columns="name", values="return", aggfunc="first"
)
print("长表:", df.shape)
print("价格宽表:", price_wide.shape)
print("收益宽表:", return_wide.shape)
长表: (60, 13) 价格宽表: (15, 4) 收益宽表: (15, 4)

长表保留每一行的完整字段,适合 Seaborn 按类别分组;宽表把股票放在列上,适合多条时间序列一起画。两种形状都要掌握,不能认为某一种永远更好。

2. Matplotlib 的图、轴和绘图对象

fig 是整张画布,ax 是其中一个坐标轴。推荐显式使用 fig, ax = plt.subplots(),这样后面添加标题、图例和保存图片时更清楚,也方便扩展成多个子图。

fig, ax = plt.subplots(figsize=(8, 4.5))
ax.plot([1, 2, 3, 4], [10, 12, 11, 14],
        marker="o", linewidth=2, label="示例序列")
ax.set_title("一个最小的金融折线图")
ax.set_xlabel("观测顺序")
ax.set_ylabel("数值")
ax.legend()
ax.grid(alpha=0.25)
fig.tight_layout()
plt.show()
最小折线图的实际运行结果运行结果:显示一张带有 4 个圆点、标题、坐标轴名称、图例和浅色网格的折线图。

figsize=(宽, 高) 的单位是英寸。图太小会导致中文和图例重叠,图太大则不适合网页或报告。先保证信息完整,再调整尺寸。

标题

说明图展示的对象、时间范围或指标。

坐标轴

写明变量名、单位和是否百分比。

图例

解释颜色、线型或点形,不要让读者猜。

3. 折线图:比较标准化价格走势

四只股票的绝对价格差异很大,直接画会让高价股票占据视觉主导。标准化是把每只股票第一天的价格都除以自己的第一天价格,让纵轴表示“相对起点的变化”。

normalized = price_wide / price_wide.iloc[0]

fig, ax = plt.subplots(figsize=(10, 5))
normalized.plot(ax=ax, linewidth=2)
ax.set_title("模拟股票:标准化收盘价走势")
ax.set_xlabel("日期")
ax.set_ylabel("第一天 = 1")
ax.legend(title="股票", ncol=2)
ax.grid(alpha=0.25)
fig.autofmt_xdate()
fig.tight_layout()
plt.show()
标准化收盘价折线图的实际运行结果图中四条线都从 1 开始。纵轴 1.004 表示相对第一天上涨约 0.4%,不是价格 1.004 元。

这张图可以回答“样本期间谁的相对价格变化更高”,但不能回答“谁未来一定上涨”。标准化改变了纵轴尺度,不能改变原始价格和真实交易结果。

4. 日期轴、刻度和可读性

日期标签很容易重叠。Matplotlib 的日期格式工具可以控制主刻度和显示格式;即使数据只有 15 天,也要养成明确日期轴的习惯。

import matplotlib.dates as mdates

fig, ax = plt.subplots(figsize=(10, 4.5))
ax.plot(price_wide.index, normalized["平安银行"],
        marker="o", color="#1f6b65")
ax.xaxis.set_major_locator(mdates.DayLocator(interval=2))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
ax.set_title("平安银行标准化价格:日期刻度示例")
ax.set_xlabel("日期(月-日)")
ax.set_ylabel("相对价格")
fig.autofmt_xdate()
fig.tight_layout()
plt.show()
带日期刻度的实际运行结果运行结果:横轴每隔两天显示一个日期刻度,日期格式为月-日,标签经过自动旋转后不再相互遮挡。

时间轴的刻度间隔要与样本长度匹配。15 天可以显示每 2 天一个刻度,几年数据则应改成按月或按季度显示。

5. 柱状图:比较平均成交额

柱状图适合横向比较。先按股票计算平均成交额,再排序,让读者从长到短迅速识别差异。柱子上方的数值要和纵轴单位一致。

avg_turnover = (
    df.groupby("name")["turnover_million"]
      .mean()
      .sort_values(ascending=False)
)

fig, ax = plt.subplots(figsize=(8, 4.5))
bars = ax.bar(
    avg_turnover.index, avg_turnover.values,
    color=["#9b6c42", "#1f6b65", "#6f8795", "#c9a77b"]
)
ax.set_title("模拟期间各股票平均成交额")
ax.set_xlabel("股票")
ax.set_ylabel("成交额(百万元)")
ax.tick_params(axis="x", rotation=12)
for bar in bars:
    ax.text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height(),
        f"{bar.get_height():.1f}",
        ha="center", va="bottom", fontsize=9
    )
fig.tight_layout()
plt.show()
平均成交额柱状图的实际运行结果运行结果:右上角柱状图显示贵州茅台的平均成交额最高;柱顶数值便于读者核对。

柱状图的零点通常应该从 0 开始,否则视觉长度可能夸大差异。若指标本身有正负值,可以明确说明为何需要不同的坐标范围。

sector_turnover = (
    df.groupby("sector")["turnover_million"]
      .mean()
      .sort_values()
)
fig, ax = plt.subplots(figsize=(8, 4))
sector_turnover.plot.barh(ax=ax, color="#9b6c42")
ax.set_title("各行业平均成交额")
ax.set_xlabel("成交额(百万元)")
ax.set_ylabel("行业")
fig.tight_layout()
plt.show()
行业平均成交额横向柱状图的实际运行结果运行结果:横向柱状图把行业名称放在纵轴,适合中文标签较长的情况。实际输出:非银金融 20.8653,银行 25.8980,电力设备 121.3173,食品饮料 1714.3720(单位:百万元)。

6. 散点图:观察成交量与日内收益

散点图中的每个点对应一条观测。颜色可以区分股票,横轴放成交量,纵轴放日内收益率。先观察点云形状,再计算相关系数,不要直接把一条上升趋势写成因果关系。

fig, ax = plt.subplots(figsize=(8, 5))
for name, group in df.groupby("name"):
    ax.scatter(
        group["volume"],
        group["intraday_return"] * 100,
        alpha=0.7, s=42, label=name
    )
ax.axhline(0, color="#72818a", linewidth=1)
ax.set_title("成交量与日内收益的关系")
ax.set_xlabel("成交股数")
ax.set_ylabel("日内收益率(%)")
ax.legend(title="股票")
ax.grid(alpha=0.2)
fig.tight_layout()
plt.show()
成交量与日内收益散点图的实际运行结果运行结果:出现按股票着色的散点;零线把上涨和下跌分开,点的密集程度可以帮助观察样本分布。

如果点云没有明显方向,可能说明线性关系弱,也可能是样本太短。还要注意成交量的数量级差异,必要时可以使用对数坐标。

fig, ax = plt.subplots(figsize=(8, 5))
for name, group in df.groupby("name"):
    ax.scatter(
        group["volume"], group["intraday_return"] * 100,
        alpha=0.65, s=42, label=name
    )
ax.set_xscale("log")
ax.set_title("对数横轴:观察不同数量级的成交量")
ax.set_xlabel("成交股数(对数刻度)")
ax.set_ylabel("日内收益率(%)")
ax.legend()
fig.tight_layout()
plt.show()
对数横轴散点图的实际运行结果运行结果:横轴改为对数刻度后,小成交量股票与大成交量股票都能在同一张图中获得更合理的视觉空间。

7. 箱线图:比较收益率分布

平均收益可能掩盖极端值。箱线图显示中位数、四分位数、须和离群点,适合比较不同股票的收益分布宽度。

plot_df = df.dropna(subset=["return"]).copy()
fig, ax = plt.subplots(figsize=(9, 4.8))
sns.boxplot(
    data=plot_df, x="name", y="return",
    color="#d6b88e", ax=ax
)
ax.axhline(0, color="#72818a", linewidth=1)
ax.set_title("模拟股票日收益率分布")
ax.set_xlabel("股票")
ax.set_ylabel("日收益率(%)")
ax.tick_params(axis="x", rotation=12)
fig.tight_layout()
plt.show()
收益率箱线图的实际运行结果运行结果:左下角箱线图显示四只股票的收益分布;箱体越高表示中间 50% 的样本范围越宽。

箱体宽度表示四分位距,横线是中位数,单独的点可能是离群值。离群值需要回到原始日期和价格检查,不能一看到极端点就删除。

适合回答

哪只股票的收益分布更分散?哪只股票的中位数更高?是否存在极端观测?

不能直接回答

哪只股票未来更赚钱?是否存在因果关系?是否应该买入?

8. 热力图:相关系数的颜色表达

把收益率宽表交给 corr() 可以得到相关系数矩阵。热力图用颜色把矩阵变成视觉结构:暖色代表正相关,冷色代表负相关,颜色接近中间表示线性关系较弱。

returns = (
    df.pivot(index="date", columns="name", values="return")
      .dropna()
)
corr = returns.corr()

fig, ax = plt.subplots(figsize=(7, 5))
sns.heatmap(
    corr, annot=True, fmt=".2f",
    cmap="vlag", center=0, vmin=-1, vmax=1,
    cbar_kws={"label": "相关系数"},
    ax=ax
)
ax.set_title("模拟股票日收益率相关系数")
ax.set_xlabel("")
ax.set_ylabel("")
fig.tight_layout()
plt.show()
收益率相关系数热力图的实际运行结果运行结果:右下角热力图的对角线为 1;例如图中宁德时代与中国平安的相关系数约为 0.76,平安银行与中国平安约为 -0.49。

相关系数描述共同变化,不说明谁导致谁。样本只有 14 个收益率观测,数值对单个日期非常敏感,不能把它当成稳定的长期关系。

9. 组合图:四个视角放在一张结果图中

研究报告常需要一张“总览图”,让读者先看到趋势、规模、分布和相关性,再决定是否阅读细节。下面是完整可复制脚本,运行后会生成课程页面中展示的结果图。

from pathlib import Path
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

df = pd.read_csv(Path("data/stock_demo.csv"), parse_dates=["date"])
df = df.sort_values(["ticker", "date"])
df["return"] = df.groupby("ticker")["close"].pct_change() * 100

price = df.pivot(index="date", columns="name", values="close")
normalized = price / price.iloc[0]
returns = df.pivot(index="date", columns="name", values="return").dropna()

fig, axes = plt.subplots(2, 2, figsize=(13, 9))
normalized.plot(ax=axes[0, 0], linewidth=2)
axes[0, 0].set_title("标准化收盘价:第一天 = 1")
axes[0, 0].set_xlabel("日期")
axes[0, 0].set_ylabel("相对价格")
axes[0, 0].legend(title="股票", fontsize=8)

df.groupby("name")["turnover_million"].mean().sort_values().plot.barh(
    ax=axes[0, 1], color="#9b6c42"
)
axes[0, 1].set_title("平均成交额")
axes[0, 1].set_xlabel("百万元")
axes[0, 1].set_ylabel("")

sns.boxplot(
    data=df.dropna(subset=["return"]),
    x="name", y="return", color="#d6b88e", ax=axes[1, 0]
)
axes[1, 0].set_title("日收益率分布")
axes[1, 0].set_ylabel("收益率(%)")
axes[1, 0].tick_params(axis="x", rotation=12)

sns.heatmap(
    returns.corr(), annot=True, fmt=".2f",
    cmap="vlag", center=0, vmin=-1, vmax=1, ax=axes[1, 1]
)
axes[1, 1].set_title("收益率相关系数")
axes[1, 1].set_xlabel("")
axes[1, 1].set_ylabel("")

fig.suptitle("模拟股票数据:四种图表视角",
             fontsize=18, weight="bold")
fig.tight_layout()
Path("outputs").mkdir(exist_ok=True)
fig.savefig("outputs/stock_dashboard.png", dpi=160,
            bbox_inches="tight")
plt.show()
四联图组合结果输出文件: outputs/stock_dashboard.png 图表数量: 4 输入股票数量: 4 收益率观测数: 56

10. 金融图表的颜色、标注和比例

颜色要有含义。分类变量适合使用彼此可区分的颜色,连续变量适合使用渐变色,正负相关适合使用以 0 为中心的发散色带。不要只因为颜色好看就使用彩虹色。

同一对象保持同色

同一份报告中,贵州茅台不要在不同图里不断更换颜色。

百分比写单位

收益率乘以 100 后,坐标轴写“%”,不要让读者猜 0.01 的含义。

差异不可夸大

柱状图注意零点,折线图注意纵轴范围和基准。

fig, ax = plt.subplots(figsize=(8, 4.5))
mean_returns = (
    df.groupby("name")["return"]
      .mean()
      .mul(100)
      .sort_values()
)
colors = ["#9b6c42" if value >= 0 else "#497a87"
          for value in mean_returns]
ax.barh(mean_returns.index, mean_returns.values, color=colors)
ax.axvline(0, color="#72818a", linewidth=1)
ax.set_title("平均日收益率:正负方向使用不同颜色")
ax.set_xlabel("平均日收益率(%)")
fig.tight_layout()
plt.show()
正负收益率颜色区分图的实际运行结果运行结果:正收益和负收益分别使用两种颜色,零线帮助读者判断方向;颜色本身不改变数据。

11. 标注重点:让读者知道你想表达什么

图上可以标注最高点、最后一点或政策日期,但标注必须有理由。标注太多会遮挡数据,标注太少又会让读者找不到重点。

series = normalized["贵州茅台"]
last_date = series.index[-1]
last_value = series.iloc[-1]

fig, ax = plt.subplots(figsize=(9, 4.5))
ax.plot(series.index, series, marker="o", color="#9b6c42")
ax.annotate(
    f"最后值 {last_value:.4f}",
    xy=(last_date, last_value),
    xytext=(-80, 28),
    textcoords="offset points",
    arrowprops={"arrowstyle": "->", "color": "#9b6c42"}
)
ax.set_title("对最后一个观测点进行解释性标注")
ax.set_ylabel("第一天 = 1")
fig.autofmt_xdate()
fig.tight_layout()
plt.show()
带重点标注的折线图实际运行结果运行结果:折线末端出现箭头和最后值标签;本案例最后一个贵州茅台标准化收盘价为 1.0051。标注不改变原始数据,只帮助读者定位重点观测。

12. 图片保存:网页、报告和打印的不同需求

屏幕显示和论文插图的需求不同。网页通常使用 PNG,矢量排版可以考虑 SVG 或 PDF;保存前要设置 DPI、边界和背景,避免文字被裁剪。

from pathlib import Path
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(5, 3))
ax.plot([1, 2, 3], [1.0, 1.01, 1.005])
ax.set_title("用于保存示例图")

output_dir = Path("outputs")
output_dir.mkdir(exist_ok=True)

fig.savefig(output_dir / "stock_dashboard.png",
            dpi=160, bbox_inches="tight",
            facecolor="#fbfaf6")
fig.savefig(output_dir / "stock_dashboard.svg",
            bbox_inches="tight")
print("PNG 和 SVG 已保存到 outputs 文件夹")
图片保存示例的实际运行结果PNG 和 SVG 已保存到 outputs 文件夹 PNG 适合网页预览,SVG 适合放大后仍保持清晰。

保存后要打开文件检查:标题是否被裁掉,中文是否乱码,图例是否遮挡曲线,坐标轴单位是否完整。自动保存成功不等于图表表达成功。

13. 从“看起来好看”到“可以复核”

不够好的说明

“从图中可以看出,股票 A 比较好,股票 B 波动很大。”没有时间范围、指标、单位和判断依据。

更可复核的说明

“在 2024-01-02 至 2024-01-16 的模拟样本中,按第一天标准化收盘价比较,四只股票的相对变化幅度均较小;箱线图显示中国平安的收益分布较宽。该结论只适用于本样本。”

  1. 说明数据。写出来源、日期范围、股票数量和是否为合成数据。
  2. 说明指标。写出收盘价、简单收益率、成交额或相关系数的定义。
  3. 报告比较。指出最高、最低、最宽或最集中,并给出图中证据。
  4. 说明边界。明确样本短、数据为模拟数据,不能推出未来收益或因果关系。

14. 常见图表错误与排查

曲线突然跳跃

检查日期排序、股票分组、复权口径和是否把价格列读成字符串。

图例遮住数据

缩小图例字体、移动图例位置、增加画布尺寸,或减少同时展示的序列。

中文显示方框

检查绘图环境是否有中文字体;在本机明确设置字体,不要把字体文件强行放进公开项目。

颜色无法区分

避免只用红绿表示方向,同时使用位置、标记和文字;考虑色觉差异。

print("数据日期范围:", df["date"].min(), "至", df["date"].max())
print("图表序列:", list(price_wide.columns))
print("收益率缺失数:", df["return"].isna().sum())
print("输出前检查: 标题、单位、图例、边界")
先检查数据,再检查图形。图表错误可能来自数据排序,也可能来自坐标轴和标签设置。

15. 综合练习:完成一页金融图表报告

练习 A:趋势图

分别绘制绝对价格和标准化价格,解释为什么两张图的视觉结论不同。

练习 B:分布图

比较四只股票的收益率箱线图,报告中位数、四分位距和一个需要检查的极端值。

练习 C:关系图

绘制成交量与日内收益散点图,计算相关系数,并写出“不代表因果”的说明。

练习 D:设计规范

统一四张图的字体、颜色和单位,保存 PNG 与 SVG,并检查文字是否被裁剪。

练习 E:写图注

为四联图写一段 150 字图注,包含数据来源、时间范围、变量定义和解释边界。

练习 F:替换变量

将收盘价换成成交额或市值,思考哪种图形最适合新的问题。

完成本节后,应能把一份表格变成可读、可复核、可保存的金融图表,而不是只会调用一个绘图函数。

附:案例数据与图表结果

本节使用 stock_demo.csv。页面中的四联图是使用该文件在本地 Python 环境实际生成的结果,文件位于 images/screen-28-stock-dashboard.png。

图表输入字段主要回答不要直接推出
标准化折线图date、close样本期相对起点的价格变化。未来收益、投资建议。
平均成交额柱状图name、turnover_million样本期交易金额的横向比较。流动性一定更好或收益一定更高。
收益率箱线图ticker、close 计算出的 return收益分布、中位数和极端值。风险偏好和未来波动。
相关系数热力图各股票日收益率共同变化的线性程度。因果关系和稳定的长期相关性。

图表中的数字、颜色和趋势都只描述这份 15 日合成样本。替换为真实数据时,需要补充数据来源、授权、复权口径、交易日处理、异常值规则和图表复核记录。