同样是「我的策略赚钱了」,为什么有人听了祝贺,有人听了泼冷水?因为一句「赚了钱」里缺了三样东西:时间刻度(多久赚的)、风险刻度(中间疼过多少次)、以及跟谁比。没有这三样,收益数字就只是孤零零的一个数,既无法比较,也无法复制。
上一篇我们手写完成了双均线策略的向量化回测,示例输出里策略期末净值 1.423、买入持有 1.187,看起来策略赢了。但「五年赚 42%」和「两年赚 42%」完全不是一回事;中途账户最深亏过多少,直接决定你拿不拿得住。本篇就把那坨净值曲线变成一排可比的数字:年化收益、年化波动、夏普比率、最大回撤、胜率、盈亏比和卡玛比率,每个指标都讲清「它量什么」和「多少算好」。
全部指标只需要二十来行自写的函数,不需要任何第三方回测库。学完本篇,你不仅会算,还会知道每个数字的适用边界——这比会算更重要。
本篇目标
- 能用
(1 + 总收益)^(252/n) − 1把任意区间的总收益折算成年化收益,并说出 252 与 n 各是什么。 - 能用「日收益标准差 × √252」计算年化波动率,并按系列统一的年化 2% 无风险利率计算夏普比率。
- 能用
cummax两行核心代码算出最大回撤及其发生的日期。 - 能从交易记录里按「建仓—清仓」配对出每笔收益,算出胜率与盈亏比。
- 能用卡玛比率把收益与最惨时刻拼成单一数字,并对「多少算好」给出有前提的回答。
核心概念
年化收益:先拉到同一时间刻度
总收益率没有时间长度就没法比。年化收益(Annualized Return)把一段历史按复利折算成「每年赚这么多、连续赚 n/252 年」的等速度:
年化收益 = (1 + 总收益)^(252/n) − 1
其中 252 是 A 股一年的平均交易日数,n 是回测覆盖的交易日数。用第 18 篇的示例口径算一遍:五年约 1215 个交易日,总收益 42.3%,那么 1215/252 ≈ 4.82 年,年化就是 1.423^(252/1215) − 1 ≈ 7.6%。注意两点:一是指数上的 252/n 是把复利「摊」到每年,必须用几何方式折算,用「日平均收益 × 252」这种算术平均会系统性偏差;二是年化假设历史节奏能延续,它是对历史的折算,不是对未来的预测。
年化波动与夏普比率:给风险装上刻度
年化波动率(Annualized Volatility)衡量净值的颠簸程度:
年化波动率 = 日收益率标准差 × √252
乘 √252 的直觉是:日收益近似独立时,方差按时间线性累积,波动(标准差)就按时间的平方根累积。这是统计上的教学近似,但对日线策略足够用。
有了收益和波动,就能算本篇的主角——夏普比率(Sharpe Ratio):
夏普比率 = (年化收益 − 无风险利率) ÷ 年化波动率
无风险利率(Risk-free Rate)是「什么都不干、买国债也能拿到的收益」,系列统一按年化 2%。夏普回答的问题是:你承担的每一份颠簸,换来了多少超出躺平的报酬? 它是「每单位风险的超额收益」,所以比裸收益率更能区分「凭本事赚的」和「凭胆子赚的」。
划重点:夏普的分母是「日常颠簸」(波动率),不是「最大亏损」。一个夏普很好看的策略仍可能埋着很深的极端亏损——所以夏普必须和下节的回撤搭配着读,缺一不可。
最大回撤:账户最疼的那一刻
最大回撤(Maximum Drawdown)衡量「从历史最高点跌到最低点的最大幅度」:
某日回撤 = 1 − 当日净值 ÷ 截至当日的最高净值;最大回撤 = 所有日子回撤的最大值
用 pandas 实现只要两行核心:cummax() 滚动记录历史最高净值,再用净值除以它。它回答的问题是:如果我恰好在最高点买入,账户最难看时会亏百分之几? 这是普通人最容易破防的数字——很多策略不是死于收益不够,而是死于回撤太深、持有者中途割肉。年化 7.6% 听着平淡,但如果最大回撤只有第 18 篇买入持有的一半,它的实际持有体验可能好得多。
胜率与盈亏比:赢几次 × 赢多大
胜率(Win Rate)= 盈利交易笔数 ÷ 总交易笔数;盈亏比(Profit/Loss Ratio)= 平均每笔盈利 ÷ 平均每笔亏损的绝对值。两者要一起看:胜率 40% 但盈亏比 3 的策略仍然能赚(赢一次顶亏三次),胜率 80% 但盈亏比 0.2 的策略可能在一次大亏里吐光全部利润。趋势类策略(比如双均线)的典型画像是低胜率、高盈亏比——大部分小亏试错,少数大赚兜底。算这两个指标需要把交易「配对」:每次建仓到对应清仓算一笔。
卡玛比率与「多少算好」:先问跟谁比
卡玛比率(Calmar Ratio)= 年化收益 ÷ |最大回撤|,用「最惨时刻」做分母,和夏普互补:夏普管日常颠簸,卡玛管极端亏损。
至于「多少算好」,必须分情况给结论,脱离前提的绝对数字都是自欺:
- 跟买入持有比:同一区间、同一标的上,策略的夏普高于基准、最大回撤浅于基准,才说明折腾有价值;否则不如躺平。
- 跟自己比:换参数、换区间后夏普与卡玛是否稳定,比单点数字重要——单点好看可能是运气(第 17 篇的过拟合)。
- 绝对值参考(A 股日线策略的教学经验值):夏普长期小于 0 说明跑不赢无风险利率;0.5–1 值得继续研究;大于 1 相当不错;大于 2 先别高兴,优先排查未来函数与过拟合。
- 口径纪律:所有指标必须算在扣除手续费后的净值上,且对比双方使用同一区间、同一标的;口径不齐的对比没有意义。
完整代码
依赖安装:uv pip install pandas numpy akshare matplotlib。四个代码块按顺序放在同一个脚本里执行;块 1、块 2 与第 18 篇完全同款,保证两篇数字可比。
代码块 1:加载数据(与第 18 篇逐字相同):
import numpy as np
import pandas as pd
def load_data():
"""优先 akshare 真实行情,失败则退回固定种子的模拟数据。"""
try:
import akshare as ak
raw = ak.stock_zh_a_hist(symbol="000001", period="daily",
start_date="20200101", end_date="20241231",
adjust="qfq") # 前复权,保证价格连续
raw = raw.rename(columns={"日期": "date", "收盘": "close"})
df = raw[["date", "close"]].copy()
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date")
print(f"已加载 akshare 真实数据:000001,共 {len(df)} 个交易日")
except Exception as e:
print(f"akshare 不可用({e}),改用本地模拟数据")
rng = np.random.default_rng(7)
idx = pd.bdate_range("2020-01-01", "2024-12-31")
df = pd.DataFrame(
{"close": 10 * np.cumprod(1 + rng.normal(0.0002, 0.015, len(idx)))},
index=idx)
df.index.name = "date"
print(f"已生成模拟数据,共 {len(df)} 个交易日")
return df
df = load_data()
代码块 2:复刻第 18 篇的双均线回测流水线:
import numpy as np
import pandas as pd
# —— 信号 -> 持仓 -> 日收益 -> 净值,四步与第 18 篇完全一致 ——
df["ma_fast"] = df["close"].rolling(5).mean() # MA5 快线
df["ma_slow"] = df["close"].rolling(20).mean() # MA20 慢线
df["signal"] = (df["ma_fast"] > df["ma_slow"]).astype(int)
df["position"] = df["signal"].shift(1).fillna(0).astype(int) # 信号次日执行,防未来函数
df["asset_ret"] = df["close"].pct_change().fillna(0) # 标的日收益
df["trade"] = df["position"].diff() # +1 建仓,-1 清仓
df["cost"] = df["trade"].abs().fillna(0) * 0.001 # 双边 0.1% 手续费
df["strategy_ret"] = df["position"] * df["asset_ret"] - df["cost"]
df["nav"] = (1 + df["strategy_ret"]).cumprod() # 策略净值
df["nav_bnh"] = df["close"] / df["close"].iloc[0] # 买入持有基准净值
代码块 3:五个指标函数:
import numpy as np
import pandas as pd
TRADING_DAYS = 252 # A 股年均交易日数
RF = 0.02 # 系列统一:无风险利率年化 2%
def ann_return(nav):
"""年化收益:(1 + 总收益)^(252/n) - 1,n 为交易日数。"""
total = nav.iloc[-1] / nav.iloc[0] # 区间总收益(复利口径)
n = len(nav)
return total ** (TRADING_DAYS / n) - 1
def ann_vol(ret):
"""年化波动率:日收益标准差 × sqrt(252)。"""
return ret.std() * np.sqrt(TRADING_DAYS)
def sharpe(ret):
"""夏普比率:(年化收益 - 无风险利率) ÷ 年化波动率。"""
nav = (1 + ret).cumprod()
return (ann_return(nav) - RF) / ann_vol(ret)
def max_drawdown(nav):
"""最大回撤:1 - 净值 ÷ 历史最高净值,返回(深度, 最深日)。"""
peak = nav.cummax() # 截至每天为止的历史最高净值
dd = 1 - nav / peak # 每一天的回撤深度
return dd.max(), dd.idxmax()
def calmar(nav):
"""卡玛比率:年化收益 ÷ |最大回撤|。"""
mdd, _ = max_drawdown(nav)
return ann_return(nav) / abs(mdd)
代码块 4:逐笔统计与体检表:
import numpy as np
import pandas as pd
def trade_stats(df):
"""按「建仓日 → 清仓日」配对每笔交易,返回(胜率, 盈亏比, 单笔收益序列)。"""
entries = df.index[df["trade"] == 1] # 每次建仓日
exits = df.index[df["trade"] == -1] # 每次清仓日
nav_before = df["nav"].shift(1).fillna(df["nav"].iloc[0]) # 建仓前一日净值
rets = [df.loc[x, "nav"] / nav_before[e] - 1 # 单笔净收益,已含双边手续费
for e, x in zip(entries, exits)] # 教学简化:一对一顺序配对
rets = pd.Series(rets)
win, loss = rets[rets > 0], rets[rets <= 0]
win_rate = len(win) / len(rets) if len(rets) else float("nan")
plr = win.mean() / abs(loss.mean()) if len(win) and len(loss) else float("nan")
return win_rate, plr, rets
# —— 汇总打印:策略 vs 买入持有,一张体检表 ——
mdd, mdd_date = max_drawdown(df["nav"])
bnh_mdd, _ = max_drawdown(df["nav_bnh"])
win_rate, plr, rets = trade_stats(df)
print(f"期末净值:策略 {df['nav'].iloc[-1]:.3f} | 买入持有 {df['nav_bnh'].iloc[-1]:.3f}")
print(f"年化收益:策略 {ann_return(df['nav']):.2%} | 买入持有 {ann_return(df['nav_bnh']):.2%}")
print(f"年化波动:策略 {ann_vol(df['strategy_ret']):.2%} | 买入持有 {ann_vol(df['asset_ret']):.2%}")
print(f"夏普比率:策略 {sharpe(df['strategy_ret']):.2f}")
print(f"最大回撤:策略 {mdd:.2%}(最深日 {mdd_date.strftime('%Y-%m-%d')})"
f" | 买入持有 {bnh_mdd:.2%}")
print(f"卡玛比率:策略 {calmar(df['nav']):.2f}")
print(f"完成 {len(rets)} 笔交易:胜率 {win_rate:.1%},盈亏比 {plr:.2f}")
代码逐段解释
1. 块 1 与块 2(取数与复刻):与第 18 篇逐字同款,一句都不改。这不是偷懒,而是刻意为之:本篇的所有指标都算在同一条净值曲线上,你才能把本篇的读数和第 18 篇的示例输出对上号。trade 列在这里从「统计金叉死叉次数」升级为「交易配对的依据」。
2. 块 3(指标函数):ann_return 里总收益写成 nav.iloc[-1] / nav.iloc[0],这样即使净值不是从 1 起步也能正确折算。ann_vol 的 √252 年化建立在「日收益近似独立同分布」的假设上,真实收益有自相关性,这是公认的教学近似。sharpe 内部先用收益流重建净值,保证它度量的是这条收益流本身;无风险利率 2% 是系列统一口径,别各写各的。max_drawdown 用 cummax() 滚动记录历史峰值,idxmax() 顺便给出最深回撤发生的日期,方便你去图上找那一天发生了什么。calmar 直接复用 max_drawdown。
3. 块 4(逐笔统计与体检表):配对的关键是分母用建仓前一日的净值——因为建仓当天的手续费已经在当日净值里扣掉了,从建仓前一日算起,买卖双边的手续费才都落在这一笔的收益里。zip 一对一顺序配对,若回测结束时仍持仓(有建仓没清仓),这一笔会被丢弃,这是本篇声明的教学简化。体检表把策略与买入持有并排打印,读法按「先比夏普和回撤,再看年化收益」的顺序:风险调整后赢,才是真赢。
示例输出(示意,与第 18 篇示例输出同一演示口径;实际数值随你拉到的数据变化,但形态如此):
已加载 akshare 真实数据:000001,共 1215 个交易日
期末净值:策略 1.423 | 买入持有 1.187
年化收益:策略 7.59% | 买入持有 3.62%
年化波动:策略 17.63% | 买入持有 21.05%
夏普比率:策略 0.32
最大回撤:策略 15.84%(最深日 2022-04-27) | 买入持有 32.17%
卡玛比率:策略 0.48
完成 14 笔交易:胜率 50.0%,盈亏比 1.64
怎么读这张体检表:策略年化 7.59% 绝对值平淡,但对比买入持有的 3.62%,超额明显;年化波动 17.63% 低于基准的 21.05%,说明空仓期确实躲过了一部分颠簸;最大回撤 15.84% 只有基准 32.17% 的一半,持有体验好得多;胜率 50% 配盈亏比 1.64,正是上一节说的趋势策略典型画像。夏普 0.32 落在「值得继续研究但要改进」的区间——这个演示策略离「好用」还有距离,这正是教学示例该有的诚实样子。
划重点:本篇所有数字都算在扣除双边 0.1% 手续费后的净值上。忘了扣成本的指标是「纸面体检」,成本一加,胜率和夏普常常集体跳水——这在第 18 篇的成本一节已经预演过一次。
常见坑与小练习
坑 1:用算术平均日收益 × 252 当年化。 现象是年化数字系统性偏高或偏低,复利越久偏得越多。原因:收益的复利是几何累积,算术平均忽略了波动拖累。避免方式:固定使用 (1 + 总收益)^(252/n) − 1 这一个公式,全系列不换口径。
坑 2:夏普的分母没年化,或无风险利率口径漂移。 现象是不同文章的夏普没法对比,甚至自己两次算的数字对不上。原因:有人用日收益除日标准差不乘 √252,有人减的是日化无风险利率、有人干脆不减。避免方式:统一「年化对年化」:(年化收益 − 2%) ÷ 年化波动,2% 是系列铁律。
坑 3:只用收盘价净值算回撤,忽视盘中更深的坑。 现象是回测回撤 15%,实盘体验却有 20%。原因:盘中价格可以击穿收盘价口径的最低点,收盘价序列会低估真实回撤。避免方式:教学阶段承认这个低估即可,正式回测用最高最低价计算盘中回撤,第 22 篇会涉及。
坑 4:只看胜率就下结论。 现象是胜率 75% 的策略让人兴奋,一次回撤却亏掉半年利润。原因:胜率没带盈亏比,小赢大亏的高胜率策略期望可能为负。避免方式:胜率和盈亏比永远成对打印、成对看,再配合总收益交叉验证。
坑 5:口径不齐就横向比较。 现象是「我的策略夏普 1.2,比某书上的 0.8 强」。原因:两边区间不同、标的不同、成本假设不同,数字不可比。避免方式:只跟自己同口径的基准比;引用别人的数字前先核对它的区间与成本假设。
小练习 1:把 RF 改成 0 和 0.03 各跑一遍,观察夏普的变化幅度,并解释为什么无风险利率越低夏普越高。
提示:夏普 = (年化 − RF) ÷ 波动,分子整体平移;波动大的策略对 RF 更不敏感,对比一下两只策略的变化量。
小练习 2:给买入持有净值 df["nav_bnh"] 算一遍全套指标(夏普记得用 df["asset_ret"]),和策略并排成表。
提示:一张表七个指标两列数字,读完你就知道本篇示例策略到底赢在哪、输在哪。
小练习 3:把均线参数改成 MA10/MA60 重跑全部代码,对比夏普与卡玛的变化。
提示:参数变慢后交易笔数会减少,重点观察胜率与盈亏比怎么重新分配——它们俩此消彼长是常态。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。