先说一个尴尬的场景:你跑完一次回测,兴冲冲把净值曲线发给朋友看,对方连问三个问题——夏普多少?最深那次回撤亏了多少?哪几个月赚钱、哪几个月亏钱?你翻了半个小时的临时 print 输出才凑齐答案,心想:下次回测还得再来一遍。
上一篇我们用参数优化和 Walk-Forward 给策略做了「防过拟合体检」;体检做完,结论总要落到一份稳定、可对比的报告上,本篇在此基础上回到工程效率的主题:把「算指标、画图、排版」交给工具,你只负责看结论。我们将用 quantstats 这个开源库,把第 18 篇双均线策略的回测结果一键变成专业级 HTML 报告,逐项读懂里面的关键数字;最后准备一个降级方案——当 quantstats 装不上或版本不兼容时,用手写的几十行函数算出核心指标,工作不至于停摆。
本篇目标
- 能安装 quantstats,并用
qs.reports.html()把一条策略收益率序列一键变成 HTML 报告文件。 - 能把第 18 篇双均线策略的回测结果喂给 quantstats,生成报告并导出核心指标表。
- 能逐项读懂报告里的夏普比率、索提诺比率、最大回撤、胜率与月度收益热力图。
- 能在 quantstats 装不上或版本不兼容时,用手写的核心指标函数顶上,不耽误交付。
- 能记住一条铁律:报告自动化只负责「翻译」回测结果,不改变回测本身的质量。
核心概念
quantstats 是什么,三个最常用入口
quantstats 是 Python 生态里最常用的回测绩效报告库(Performance Reporting Library),作者是 Ran Aroussi(也是 yfinance 的作者)。它的定位很纯粹:你给它一条收益率序列,它还你一份包含几十个指标和图表的报告。安装只要一行:uv pip install quantstats。
它有三个最常用的入口,按「想看多细」来选:
qs.reports.html():一行生成完整 HTML 报告文件,浏览器打开就能看,适合存档、发给别人;qs.reports.metrics()(notebook 里也可以用qs.reports.full()):在终端或 Jupyter 里输出文本版指标表,适合快速看数;qs.extend_pandas():把几十个指标函数挂到 pandas 的 Series 上,之后可以像returns.sharpe()这样链式调用。
只想要某一个指标时,从 qs.stats 里单独取即可,例如 qs.stats.sharpe(returns)、qs.stats.max_drawdown(returns)。
划重点:quantstats 吃的是收益率序列(每天的涨跌幅,例如 0.012 表示涨 1.2%),不是净值(Net Asset Value,NAV,即 1.05、1.10 这样的累计水平值)。把净值当成收益率喂进去,报告照样能生成,但里面每个数字都是错的——这是本篇第一大坑,后面「常见坑」还会展开。
报告里最值得看的五个数字
完整报告有三十多项指标,新手先盯住五个。
夏普比率(Sharpe Ratio):每承担一单位波动,换回多少超额收益,计算式为 (年化收益 − 无风险利率)÷ 年化波动率(Annualized Volatility)。白话:你多赚的钱,是不是靠坐过山车换来的。本系列统一无风险利率(Risk-free Rate)按年化 2% 计。经验参考:长期大于 1 算不错,大于 2 相当好,小于 0 意味着承担了波动却没跑赢无风险收益。
索提诺比率(Sortino Ratio):夏普的改良版,把分母里的「总波动」换成「下跌波动」。涨得猛不算风险,跌才叫风险。对「大赚大亏」性格的策略,它比夏普更公允。
最大回撤(Maximum Drawdown):净值从历史最高点跌下来最深的那一刀。它回答的问题最朴素:拿着这个策略,最难熬的时候要亏多少?很多人离场不是因为策略逻辑错了,而是回撤超过了心理承受力。
胜率(Win Rate):赚钱样本的占比(日频输入时默认是赚钱交易日的比例)。注意高胜率不等于赚钱:胜率 90%,但亏的那 10% 一次亏掉前面所有,照样白干。胜率要和盈亏比(平均盈利 ÷ 平均亏损)放在一起看。
月度收益热力图(Monthly Returns Heatmap):按「年 × 月」铺开的收益矩阵。它一眼能回答两个问题:策略是稳定小赚,还是靠某一两个月撑起全场?亏损月份有没有扎堆出现?
经验提醒:报告是给「已经跑干净的回测」做翻译的,不是给「有未来函数的回测」做美颜的。第 17 篇讲过的回测陷阱、第 25 篇讲过的过拟合,都不会因为报告漂亮就消失。先保证回测干净,再谈报告。
自动化报告的正确姿势
为什么值得为报告专门写一篇?因为回测实验是批量进行的。第 25 篇做参数优化动辄跑几十组参数,如果每组都靠人肉整理指标:一容易抄错数;二口径会漂——这次用了 2% 无风险利率,下次忘了;三没法横向比较。把报告做成固定脚本,好处有三个:每次实验口径完全一致;报告文件随实验一起存档,半年后还能复查;省下来的时间可以花在检查回测逻辑上——那才是真正值钱的环节。
完整代码
本篇依赖安装:uv pip install quantstats akshare pandas numpy。quantstats 装不上也没关系,第三块代码是完整的手写降级方案,只依赖 numpy 和 pandas。
代码分三块:第一块生成第 18 篇双均线策略的收益率序列(含断网降级);第二块用 quantstats 出报告;第三块是手写核心指标的降级方案。
# 块 1:数据获取 + 双均线策略收益率(第 18 篇同款逻辑)
# uv pip install akshare pandas numpy
import numpy as np
import pandas as pd
FEE = 0.0005 # 单边手续费 0.05%,双边合计 0.1%(万 10)
def load_close() -> pd.Series:
"""获取沪深300指数日收盘价;akshare 不可用时本地造数据。"""
try:
import akshare as ak
df = ak.index_zh_a_hist(symbol="000300", period="daily",
start_date="20200101", end_date="20241231")
s = pd.Series(df["收盘"].values, index=pd.to_datetime(df["日期"]))
return s.astype(float)
except Exception:
# fallback:断网也能跑——固定种子造一条几何随机游走(Geometric Random Walk)
rng = np.random.default_rng(42)
idx = pd.bdate_range("2020-01-01", "2024-12-31")
ret = rng.normal(0.0002, 0.012, len(idx))
return pd.Series(100 * np.exp(np.cumsum(ret)), index=idx)
def make_strategy_returns(close: pd.Series) -> pd.Series:
"""双均线策略:5 日均线上穿 20 日均线做多,否则空仓。
信号 shift(1) 防未来函数;每次仓位变动按单边扣手续费。"""
fast = close.rolling(5).mean()
slow = close.rolling(20).mean()
pos = (fast > slow).astype(int).shift(1).fillna(0) # 今天用昨天的信号
ret = close.pct_change().fillna(0)
trade = pos.diff().abs().fillna(0) # 仓位变动 → 交易次数
return pos * ret - trade * FEE # 收益扣手续费
close = load_close()
strat_ret = make_strategy_returns(close)
print(f"交易日数量:{len(strat_ret)}")
print(strat_ret.tail(3))
块 1 跑通后,strat_ret 就是喂给报告的标准输入。接下来是本篇主角:
# 块 2:quantstats 生成报告(需要 uv pip install quantstats)
try:
import quantstats as qs
except ImportError:
raise SystemExit("quantstats 未安装:先 uv pip install quantstats,"
"或直接改用块 3 的手写降级方案")
# 入口一:一行生成完整 HTML 报告文件,浏览器打开 report.html 即可
qs.reports.html(strat_ret, rf=0.02, title="双均线策略回测报告",
output="report.html")
# 入口二:终端 / Jupyter 里直接打印核心指标表
qs.reports.metrics(strat_ret, mode="full", rf=0.02)
# 入口三:把指标函数挂到 pandas 上,之后可以 strat_ret.sharpe() 这样调用
qs.extend_pandas()
# 只取单个指标时,从 qs.stats 里单独调
print("夏普比率:", round(qs.stats.sharpe(strat_ret, rf=0.02, periods=252), 3))
print("月度收益表(示意,只看前 6 个月列):")
print(qs.stats.monthly_returns(strat_ret).iloc[:, :6])
quantstats 装不上、或版本与你的 numpy / pandas 冲突时,下面这块就是完整的替代品:
# 块 3:降级方案——手写核心指标函数,只依赖 numpy 与 pandas
import numpy as np
import pandas as pd
def core_metrics(returns: pd.Series, rf: float = 0.02,
periods: int = 252) -> dict:
"""几十行代码复刻 quantstats 报告里最常用的核心指标。"""
r = returns.dropna()
nav = (1 + r).cumprod() # 净值曲线,一切的底座
total = nav.iloc[-1] - 1 # 累计收益
years = len(r) / periods # 折算年数
ann = nav.iloc[-1] ** (1 / years) - 1 # 年化收益(几何口径)
vol = r.std() * np.sqrt(periods) # 年化波动率
downside = r[r < 0].std() * np.sqrt(periods) # 只统计下跌日波动
sharpe = (ann - rf) / vol if vol > 0 else np.nan
sortino = (ann - rf) / downside if downside > 0 else np.nan
mdd = (nav / nav.cummax() - 1).min() # 最大回撤
win_rate = (r > 0).mean() # 日胜率
return {"累计收益": total, "年化收益": ann, "年化波动": vol,
"夏普比率": sharpe, "索提诺比率": sortino,
"最大回撤": mdd, "日胜率": win_rate}
def monthly_heatmap(returns: pd.Series) -> pd.DataFrame:
"""降级版月度热力图:按「年 × 月」汇总月度收益率(%)。"""
r = returns.dropna()
monthly = (1 + r).groupby([r.index.year, r.index.month]).prod() - 1
return (monthly.unstack() * 100).round(2)
for k, v in core_metrics(strat_ret).items():
print(f"{k}: {v:.2%}" if abs(v) <= 10 else f"{k}: {v:.2f}")
print(monthly_heatmap(strat_ret).fillna(""))
代码逐段解释
1. 数据与策略收益率(块 1)。load_close() 先尝试用 akshare 拉沪深300(代码 000300)2020-01-01 至 2024-12-31 的日收盘价——这是本系列的统一回测区间;拉不到(断网、接口变更)就用固定随机种子造一条几何随机游走,保证读者复制就能跑。make_strategy_returns() 复刻第 18 篇的双均线逻辑:5 日均线上穿 20 日均线时持有;信号整体 shift(1),即「今天的仓位用昨天的信号」,杜绝未来函数;pos.diff() 找出仓位变动的日子,每次变动扣一次单边手续费。终端输出形如:
交易日数量:1217
2024-12-27 0.000000
2024-12-30 0.000812
2024-12-31 -0.003450
dtype: float64 # 数值仅为示意
2. quantstats 出报告(块 2)。qs.reports.html() 跑完后,工作目录会多出一个 report.html:顶部是累计收益曲线与回撤阴影,中部是几十行指标表,底部依次是月度热力图、收益分布等图块。参数 rf=0.02 把无风险利率对齐到本系列的 2% 口径;quantstats 默认按日频、一年 252 个交易日年化,与本系列一致,所以不用额外传频率参数。qs.reports.metrics(mode="full") 打印同一套指标的文本版;qs.extend_pandas() 之后,strat_ret.sharpe() 这样的写法就生效了。终端输出形如:
夏普比率: 0.22 # 数值仅为示意
月度收益表(示意,只看前 6 个月列):
Jan Feb Mar Apr May Jun
2020 1.24 -2.31 5.62 1.02 -0.87 3.45
2021 2.10 0.55 -1.44 3.21 1.98 -0.63
3. 降级方案(块 3)。core_metrics() 用十几行复刻了报告里最关键的七个指标:净值曲线 (1 + r).cumprod() 是一切的底座;年化收益用几何口径 期末净值^(1/年数) − 1;索提诺的分母只取下跌日的标准差;nav / nav.cummax() − 1 是回撤序列,其最小值即最大回撤。monthly_heatmap() 用 groupby 按「年 × 月」把日收益复利合成月收益,输出热力图的文字版。打印形如:
累计收益: 18.34%
年化收益: 3.42%
年化波动: 6.50%
夏普比率: 0.22
索提诺比率: 0.31
最大回撤: -6.20%
日胜率: 52.40%
1 2 3 4 ... # 月度表数值仅为示意
2020 1.2 -2.3 5.6 1.0 ...
2021 2.1 0.6 -1.4 3.2 ...
示意数值是自洽的:18.34% 的累计收益折算 5 年年化约 3.42%;年化波动 6.50%,夏普 =(3.42% − 2%)÷ 6.50% ≈ 0.22,与块 2 的示意一致。要说明的是,手写版与 quantstats 的算法细节(例如夏普比率用算术还是几何口径)略有差异,教学足够,正式对数时以库的输出为准。
常见坑与小练习
常见坑:
- 把净值当收益率喂给 quantstats。现象:报告能生成,但夏普高到几十、回撤显示 100%。原因:quantstats 假定输入是每日涨跌幅,净值 1.02、1.05 会被理解成「每天涨 2%、5%」。避免:永远传
pct_change()之后的序列,喂之前先head()看一眼数值是不是小数。 - 忘了对齐无风险利率口径。现象:同一份回测,你的夏普总比别人算的高一截。原因:quantstats 默认
rf=0,而本系列统一按 2%。避免:显式传rf=0.02,全系列口径统一,报告之间才可比。 - 版本不兼容直接报错。现象:
import quantstats或生成报告时报AttributeError: module 'numpy' has no attribute 'float'之类的错误。原因:老版本 quantstats 用了新版 numpy 已删除的别名。避免:要么把 numpy 固定到旧版本,要么直接用块 3 的手写方案——核心指标十行就够,不阻塞工作。 - 报告只看收益那一行。现象:见到累计收益高就下结论。原因:报告的价值在结构——回撤集中在什么时候、盈利是否靠一两个月、亏损月是否扎堆。避免:固定按「夏普 → 最大回撤 → 月度热力图」的顺序看,再下结论。
- 找不到生成的报告文件。现象:代码跑完没看到
report.html。原因:output=用的是相对路径,文件生成在当前工作目录。避免:传绝对路径,或先os.getcwd()确认目录再跑。
小练习:
- 把双均线参数改成 10/60 重跑一遍,对比新旧两份
report.html的夏普与最大回撤。思路提示:两份报告的指标表可以并排对比;差异巨大说明策略对该参数敏感,回看第 25 篇的过拟合检查。
- 给
core_metrics()增加卡玛比率(Calmar Ratio)= 年化收益 ÷ |最大回撤|。思路提示:一行公式;注意最大回撤是负数,要取绝对值。
- 用
qs.reports.html()的benchmark=参数,把沪深300 指数自身的日收益作为基准传入,观察报告中的超额收益部分。思路提示:基准同样必须是收益率序列;对比策略与基准的差距,判断收益来自择时还是来自市场本身(第 19 篇的口径)。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。