前五篇我们一直在跟数字打交道:NumPy 算数组,Pandas 读表格。你可能已经发现了,df.head() 打出来一大屏数字,看半天也看不出这支股票到底是在涨还是在跌。人眼对数字不敏感,但对图形极其敏感——一条曲线往上翘还是往下弯,一眼就看出来了。这就是为什么所有量化平台的第一屏都是图,而不是表格。
上一篇我们学了 Pandas 的时间序列与滚动计算,会用 rolling 算滚动均值了。算出来的均线如果只是打印数字,很难直观感受它「贴合价格、滞后于价格」的特性。本篇就在这个基础上补上最后一块拼图:可视化。我们会学 Python 画图的标准库 matplotlib,把价格、成交量画出来,最后亲手画一张简化的 K 线图——K 线是全世界股民最熟悉的图形,能自己画出来,你对 OHLC 数据的理解会上一个台阶。
本篇目标
学完本篇,你能够做到:
- 理解 matplotlib 的两层结构:
figure(画布)与axes(子图),不再分不清plt.plot和ax.plot的区别。 - 解决 matplotlib 显示中文和负号的乱码问题,让图表标题、坐标轴正常显示中文。
- 把价格和成交量画在上下两个联动的子图里,形成专业的行情图布局。
- 用柱体颜色区分涨跌,画出一张「简化版 K 线图」,理解 K 线的 OHLC 本质。
- 知道
mplfinance这个进阶库的存在,以后画正经 K 线图可以偷懒用它。
核心概念
figure 与 axes:画布和画纸
matplotlib 有两种使用方式,新手很容易在这里晕。第一种是「 pyplot 交互式」:直接调 plt.plot()、plt.title(),matplotlib 帮你悄悄维护一个「当前图」,画完调 plt.show() 展示。这种方式写起来快,适合一两张简单图。
第二种是「面向对象式」:先显式创建画布(Figure,可以理解成一张画板),再在画板上摆好一张或多张画纸(Axes,每张画纸是一块独立的坐标系),之后所有绘制操作都指名道姓地画在某个 Axes 上。专业写法是这一句:
fig, axes = plt.subplots(2, 1) # 2 行 1 列的子图布局
它返回画布对象 fig 和一个装着两张画纸的数组 axes。axes[0] 是上面那张,axes[1] 是下面那张。建议一开始就养成面向对象的习惯,因为行情图几乎都是「价格一张图、成交量一张图」的多子图布局, pyplot 交互式在这种场景下会越写越乱。
划重点:
figure是画板,axes是画板上的每张画纸。记住这句话,matplotlib 的 API 有一半你就懂了。
常用绘图命令速览
ax.plot(x, y):画折线图,画价格曲线用它。ax.bar(x, height):画柱状图,画成交量用它。ax.set_title()/ax.set_xlabel()/ax.set_ylabel():设置标题和坐标轴标签。ax.legend():显示图例,多根线挤在一张图里时必须有。ax.grid(True, alpha=0.3):加浅色网格线,方便对齐读数。fig.autofmt_xdate():自动倾斜日期刻度,避免日期挤成一团。plt.tight_layout():自动调整子图间距,防止标题和标签互相重叠。
还有一个几乎人人都要踩的坑:中文乱码。matplotlib 默认字体不含中文字符,标题里的中文会显示成一个个小方框。解决办法是画图前改一下运行时配置:
plt.rcParams["font.sans-serif"] = ["SimHei"] # Windows 用黑体;Mac 可换 "Arial Unicode MS"
plt.rcParams["axes.unicode_minus"] = False # 让负号正常显示,否则负数会变方框
K 线的本质:一根柱子装下四个价格
K 线图(Candlestick Chart,蜡烛图)每根「蜡烛」由当日的开盘价(Open)、最高价(High)、最低价(Low)、收盘价(Close)四个价格画成。中间胖胖的部分叫实体,从收盘到开盘(阳线是低到高,阴线是高到低);上下伸出的细线叫影线,顶到最高价和最低价。收盘价高于开盘价画红色(A股习惯,涨为红),反之画绿色。
所以画 K 线的思路拆开特别朴素:
- 影线:从当日最低价到最高价画一条竖线,对应 matplotlib 的
ax.vlines(x, low, high)。 - 实体:画一个柱形,底是
min(open, close),高是abs(close - open),对应ax.bar()或者更底层的Rectangle补丁。 - 上色:收盘价不低于开盘价的日子用红色,否则用绿色。
我们自己手画一遍这个流程,比直接调库收获大得多——以后你看到任何 K 线形态(锤子线、十字星),脑子里都能立刻对应到这四个价格上。写完之后再介绍一句:实际项目里有个现成的库 mplfinance,一行 mpf.plot(df, type='candle', volume=True) 就能画出专业 K 线图,本篇先手写理解原理,进阶时再去用它。
价格与成交量为什么放一起
价格是「涨到哪了」,成交量是「有多少人认可这个价」。光看价格线,你分不清温和上涨和疯狂追高;配上成交量柱,放量上涨和缩量上涨一眼可辨。所以专业行情图的标准布局是:上子图画价格,下子图画成交量,两张图共用同一条时间轴。matplotlib 里用 sharex=True 参数就能实现时间轴联动——你拖动缩放任何一张图,另一张跟着动。
完整代码
本篇只用 matplotlib 和 pandas,安装命令:
uv pip install matplotlib pandas
为了让每位读者都能跑通,代码里准备了两份数据:优先用第 7 篇才会讲的 akshare 拉取真实 A 股行情;如果没装 akshare 或网络不通,就自动降级为本地随机造一段模拟行情(用固定随机种子,保证每次结果一致)。下面是第一部分:造数据 + 布局画价格和成交量。
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
# ---------- 中文显示设置(每次画图前都要执行) ----------
plt.rcParams["font.sans-serif"] = ["SimHei"] # Windows 黑体;Mac 可换成 "Arial Unicode MS"
plt.rcParams["axes.unicode_minus"] = False # 修复负号显示为方框的问题
def get_data():
"""优先拉真实数据,失败则本地造数据,保证代码永远可跑。"""
try:
import akshare as ak
df = ak.stock_zh_a_hist(symbol="000001", period="daily",
start_date="20200101", end_date="20201231",
adjust="qfq") # 前复权,避免除权跳空
df = df.rename(columns={"日期": "date", "开盘": "open", "收盘": "close",
"最高": "high", "最低": "low", "成交量": "volume"})
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date")
print("已使用 akshare 真实数据:平安银行 2020 年日线")
except Exception as e: # 网络不通、没装 akshare、接口变更,都走这里
print(f"akshare 不可用({e}),改用本地模拟数据。")
rng = np.random.default_rng(42) # 固定种子,保证每人跑出的图一样
n = 243 # 一年大约 243 个交易日
dates = pd.bdate_range("2020-01-02", periods=n) # 仅工作日
close = 16 * np.exp(np.cumsum(rng.normal(0.0003, 0.018, n))) # 几何随机游走
open_ = close * (1 + rng.normal(0, 0.004, n))
high = np.maximum(open_, close) * (1 + np.abs(rng.normal(0, 0.006, n)))
low = np.minimum(open_, close) * (1 - np.abs(rng.normal(0, 0.006, n)))
volume = rng.integers(50, 300, n) * 1e4
df = pd.DataFrame({"open": open_, "close": close, "high": high,
"low": low, "volume": volume}, index=dates)
df.index.name = "date"
print("已使用本地模拟数据(随机种子 42,模拟 2020 年日线)")
return df
df = get_data()
print(df.head())
# 本段只负责准备数据;下面一段接续本段,开始真正画图。
划重点:
plt.rcParams两行字体设置建议存进你自己的「画图模板」,以后每个画图脚本开头都复制一遍,一劳永逸。
接下来是本篇真正的主角——先画「价格 + 成交量」双子图,再画「简化 K 线」。把下面这段原样接在上一段的 df = get_data() 与 print(df.head()) 之后(同一个脚本里接着写即可):
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
# (承上一代码块)df 已就绪,且已设置中文字体
# ---------- 图一:价格与成交量双子图 ----------
fig, axes = plt.subplots(2, 1, figsize=(12, 8), sharex=True,
gridspec_kw={"height_ratios": [3, 1]}) # 价格区高、量区矮
axes[0].plot(df.index, df["close"], color="#1f77b4", linewidth=1.2, label="收盘价")
axes[0].set_title("平安银行 2020 年日线收盘价(数据不可用时为模拟数据)")
axes[0].set_ylabel("价格(元)")
axes[0].legend(loc="upper left")
axes[0].grid(True, alpha=0.3)
# 用涨跌颜色画成交量柱:当日收盘 >= 开盘记为红(涨),否则绿(跌)
colors = np.where(df["close"] >= df["open"], "#d62728", "#2ca02c")
axes[1].bar(df.index, df["volume"], color=colors, width=1.0)
axes[1].set_ylabel("成交量(股)")
axes[1].grid(True, alpha=0.3)
fig.autofmt_xdate() # 日期刻度自动倾斜
plt.tight_layout() # 自动排布,防止标题重叠
plt.show()
# ---------- 图二:手绘简化 K 线 ----------
fig2, ax = plt.subplots(figsize=(12, 6))
for date, row in df.iterrows(): # 逐日画一根蜡烛
up = row["close"] >= row["open"] # 判断当天是阳线还是阴线
color = "#d62728" if up else "#2ca02c"
ax.vlines(date, row["low"], row["high"], color=color, linewidth=0.8) # 影线
body_bottom = min(row["open"], row["close"])
body_height = max(abs(row["close"] - row["open"]), 0.001) # 避免十字星高度为 0
ax.bar(date, body_height, bottom=body_bottom, width=0.7,
color=color, edgecolor=color) # 实体
ax.set_title("简化 K 线图(影线 = 高低价,实体 = 开收盘价)")
ax.set_ylabel("价格(元)")
ax.grid(True, alpha=0.3)
fig2.autofmt_xdate()
plt.tight_layout()
plt.show()
代码逐段解释
第 1 段:字体与数据函数。 get_data() 用了 try/except:先尝试用 akshare 拉平安银行 2020 年真实日线,并把中文列名统一改成英文小写(open/close/high/low/volume),后面所有代码只认这套列名。任何异常——没装库、断网、接口改名——都会落入 except 分支,用固定随机种子的几何随机游走造出 243 天模拟行情。用 np.exp(np.cumsum(...)) 而不是直接累加,是为了保证价格恒为正,更接近真实股价的行为。无论走哪个分支,df 的结构和列名完全一致,后面的绘图代码不需要任何改动。
第 2 段:双子图布局。 plt.subplots(2, 1, sharex=True) 创建上下两张画纸并共享 X 轴;height_ratios=[3, 1] 让价格图占 3 份高度、成交量图占 1 份——这是行情图的经典比例。上图画收盘价折线;下图画成交量柱,柱子颜色由 np.where 按当日涨跌批量生成(当日收盘价不低于开盘价为红,否则为绿),这比写 for 循环逐根判断快得多。示例输出示意如下(数字为示意,实际以你运行为准):
已使用 akshare 真实数据:平安银行 2020 年日线
open close high low volume
date
2020-01-02 16.30 16.45 16.50 16.15 78341502
2020-01-03 16.52 16.36 16.60 16.28 66878254
...(弹出一个 12x8 的窗口:上面蓝色价格线,下面红绿成交量柱)
第 3 段:手绘 K 线循环。 遍历 DataFrame 的每一行,先用 close >= open 判断阴阳决定颜色;ax.vlines(date, low, high) 画出从最低价到最高价的影线;再用 ax.bar(date, 高度, bottom=底边) 画实体——注意实体是「从实体底边往上画指定高度」,所以要传 bottom=min(open, close),高度是 abs(close - open)。这里加了个教学性的小处理:给实体高度托底 0.001,防止开盘价等于收盘价的十字星因为高度为 0 而画不出来。真实十字星确实没有实体,我们托一个极小的高度只是为了让它在图上可见,这是显示层面的简化,不影响数据。
划重点:逐行 for 循环画 243 根蜡烛在 matplotlib 里完全没问题,但几万根就会明显卡顿。实际项目里请用
mplfinance,它对 K 线做了专门优化,还自带成交量、均线叠加等功能。
关于 mplfinance。 装好后(uv pip install mplfinance)三行就能出专业 K 线图:import mplfinance as mpf,把 DataFrame 转成含 Open/High/Low/Close/Volume 大写列名后执行 mpf.plot(df, type="candle", volume=True, style="cn")。它适合以后画正式图表;今天手写的版本价值在于让你看清 K 线的每一根线是怎么来的。
第 4 段:把图保存成文件。 弹窗看一眼就关了,但量化研究里的图要能存档、能贴进笔记和报告。用 fig.savefig("路径") 就能把画布存成 PNG,其中 dpi(Dots Per Inch,每英寸点数)控制清晰度:屏幕看 120 够用,贴文档建议 200 以上。下面顺带演示「不弹窗、只落盘」的静默画图模式,批量出图时很常用。本块按独立脚本编写,数据用本篇同款随机种子重建:
import matplotlib
matplotlib.use("Agg") # 静默模式:不弹窗,图直接存文件(必须放在画图前)
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
plt.rcParams["font.sans-serif"] = ["SimHei"] # 新进程里字体设置要重新执行
plt.rcParams["axes.unicode_minus"] = False
# ---- 重建数据(独立运行本块时用;同一脚本里紧跟前文可省略) ----
rng = np.random.default_rng(42)
n = 243
dates = pd.bdate_range("2020-01-02", periods=n)
close = 16 * np.exp(np.cumsum(rng.normal(0.0003, 0.018, n)))
df = pd.DataFrame({"open": close * (1 + rng.normal(0, 0.004, n)),
"close": close}, index=dates)
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(df.index, df["close"], color="#1f77b4", linewidth=1.2, label="收盘价")
ax.set_title("保存到文件的示例图")
ax.legend()
ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig("price_2020.png", dpi=200) # 保存为 PNG,dpi 越高越清晰、文件也越大
print("图片已保存:price_2020.png")
# 在同一画布上并排放两张子图,一次导出
fig2, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))
ax1.plot(df.index, df["close"], color="#1f77b4", linewidth=1.0)
ax1.set_title("收盘价")
ax2.bar(df.index, (df["close"] - df["open"]).abs(), color="#ff7f0e")
ax2.set_title("每日实体高度(开收盘价差)")
fig2.autofmt_xdate()
fig2.tight_layout()
fig2.savefig("price_panels.png", dpi=200)
print("图片已保存:price_panels.png")
注意两个细节:第一,matplotlib.use("Agg") 属于后端切换,必须在 pyplot 第一次被使用之前执行,放脚本开头最稳妥;第二,同一脚本里如果已经弹过窗再切后端,行为可能不符合预期,所以本块按独立脚本假设重新导入并重建数据。运行后你会在脚本目录下看到两张 PNG:一张单图,一张左右双图。示例输出示意:
图片已保存:price_2020.png
图片已保存:price_panels.png
常见坑与小练习
坑 1:中文标题全是方框。 现象:标题、图例处显示 □□□。原因:matplotlib 默认字体 DejaVu Sans 不含中文字形。避免方式:每次画图前先执行 plt.rcParams["font.sans-serif"] = ["SimHei"];如果仍无效,说明系统里没有黑体,Mac 用户可换成 Arial Unicode MS 或 PingFang SC,Linux 用户需要先安装中文字体。
坑 2:负号显示为方框。 现象:Y 轴负数刻度变成 □。原因:字体切换后 unicode 减号没了对应字形。避免方式:plt.rcParams["axes.unicode_minus"] = False,让 matplotlib 改用普通连字符。
坑 3:plt.plot() 画出的图不显示或顺序错乱。 现象:在脚本里画了两张图却只弹出一个窗口,或两个窗口内容混在一起。原因:pyplot 交互式共享「当前图」状态。避免方式:多图场景一律用 fig, axes = plt.subplots() 面向对象写法,每张图配 plt.show() 或保存为文件。
坑 4:X 轴日期挤成一团。 现象:横轴日期密密麻麻互相重叠。原因:默认刻度数量对长区间太多。避免方式:调用 fig.autofmt_xdate() 自动倾斜,或用 ax.xaxis.set_major_locator(mdates.MonthLocator()) 只显示每月刻度。
坑 5:实体高度为 0 的蜡烛消失。 现象:十字星在图上找不到。原因:bar 高度为 0 画不出东西。避免方式:给高度加一个极小托底值,或者接受「十字星就是看不见」并改用 vlines 全程加粗代替。
小练习 1: 把本篇的成交量柱改成「当日收盘价相对前一日上涨为红」,也就是和开盘价比改成和昨收比(提示:df["close"].diff() 求每日差分,正数为涨)。
思路提示:
colors = np.where(df["close"].diff() >= 0, "#d62728", "#2ca02c"),注意第一行 diff 结果是 NaN,先用fillna(0)处理。
小练习 2: 在价格子图上叠加你在上一篇算过的 20 日均线(提示:df["close"].rolling(20).mean()),让均线和价格画在同一张图里,并加 label 后调用 legend()。
思路提示:直接在
axes[0]上再plot一次即可;前 19 天均线是 NaN,matplotlib 会自动跳过,不用手动处理。
小练习 3: 用 mplfinance 重画一遍 K 线图,对比自己手写版和它的效果差异(提示:注意它要求列名为大写的 Open/High/Low/Close)。
思路提示:
df_mpf = df.rename(columns={"open": "Open", "close": "Close", "high": "High", "low": "Low", "volume": "Volume"}),然后mpf.plot(df_mpf, type="candle", volume=True)。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。