单元 06:评价策略表现
单元 05 得到了一条扣除交易成本后的策略净值。末端净值告诉我们最终赚亏,却没有说明中间经历了多大波动、最深亏损是多少、结果是否依赖少数上涨日,也无法单独判断策略是否优于基准。
本单元将同一份逐日回测结果整理成指标表和回撤图。重点不只是记住公式,而是明确每个指标回答什么问题、依赖什么假设,以及它没有证明什么。
本单元目标
完成本单元后,你将能够:
- 计算总收益和复合年化收益;
- 使用日收益的样本标准差计算年化波动率;
- 从历史最高净值计算回撤和最大回撤;
- 在明确年化期数和无风险利率后计算夏普比率;
- 计算非零收益日胜率与平均盈亏比;
- 报告样本累计换手和机械年化换手;
- 使用完全相同的收益区间比较策略与基准;
- 解释为什么 9 个收益区间产生的年化指标不应被当作长期结论。
本单元依次运行:
uv run python examples/unit05/run_backtest.py
uv run python examples/unit06/evaluate_performance.py第一条命令生成逐日回测输入,第二条命令生成绩效报告。
前置条件
- 已完成单元 05;
- 能解释策略净收益、策略净值、基准净值和单边换手;
- 已执行
uv sync --locked; - 后续命令均在项目根目录运行。
场景:一个末端数字不够评价策略
本样例的策略净收益为 -0.0115%,买入持有收益为 4.6627%。只看这两个数字,可以说策略在这段样例中落后,但还回答不了:
- 策略每天的波动是否更小;
- 最大一次从高点回落了多少;
- 正收益日有多少,单次盈利能否覆盖单次亏损;
- 频繁调仓带来了多大换手;
- 把短期结果年化后为什么会出现夸张数字。
所以绩效评价不是寻找一个“最终得分”,而是从收益、风险、路径和交易行为几个角度观察同一份结果:
| 角度 | 本单元指标 |
|---|---|
| 最终结果 | 总收益、年化收益 |
| 日常波动 | 年化波动率、夏普比率 |
| 亏损路径 | 回撤、最大回撤 |
| 收益分布 | 非零收益日胜率、平均盈亏比 |
| 交易行为 | 样本累计换手、机械年化换手 |
先统一评价口径
程序读取单元 05 生成的 outputs/unit05/backtest.csv:
backtest = pd.read_csv(path, parse_dates=["date"])策略使用扣费后的 net_return 和 net_wealth,基准使用同一张表中的 asset_return 和 benchmark_wealth。两者都排除第一行无法计算的收益,因此各有 9 个收益区间。
本单元还明确两项年化假设:
PERIODS_PER_YEAR = 252
ANNUAL_RISK_FREE_RATE = 0.0252是日频研究常见的近似年交易期数,不是市场永远不变的常数;- 年化无风险利率设为 0%,只是为了让第一次计算更容易核对;
- 所有策略与基准指标使用相同假设。
同口径比较比指标数量更重要
不能拿扣费后的策略与包含分红但不扣成本的基准直接下结论,也不能让策略和基准使用不同日期。名称相同的指标,只有输入数据、时间区间和年化假设一致时才可比较。
本教程当前基准仍是不扣成本、不含分红的价格型买入持有,这项限制要与结果一起保留。
计算总收益和年化收益
设一共有
代码继续使用复利:
total_return = (1 + valid_returns).prod() - 1如果假设一年有
其中:
:样本总收益,无单位; :按样本复合速度外推的一年收益,无单位; :样本有效收益区间数,本例为 9; :假设的一年收益区间数,本例为 252。
本样例结果为:
| 序列 | 总收益 | 年化收益 |
|---|---|---|
| 策略净收益 | -0.0115% | -0.3218% |
| 买入持有基准 | 4.6627% | 258.2399% |
基准在约两周的样例中上涨 4.6627%,公式把这个短期速度重复约 28 次,于是得到 258.2399%。计算没有出错,解释却很危险:市场不太可能全年重复这 9 个区间。
年化不会自动增加样本信息
年化只是尺度转换,不会把 9 个观测变成 252 个观测。短样本的年化收益对起止日期和少数涨跌日极其敏感,只能用于理解公式,不能作为长期收益预期。
计算年化波动率
波动率(Volatility)在这里指日收益围绕其平均值变化的程度。程序使用样本标准差:
periodic_volatility = valid_returns.std(ddof=1)
annualized_volatility = periodic_volatility * math.sqrt(periods_per_year)设日收益样本标准差为
其中
本样例策略年化波动率为 6.5726%,低于基准的 12.7243%。原因之一是策略有多天处于现金,日收益为 0。
这些 0 是策略真实规则产生的结果,计算波动率时应该保留。若只挑持仓日计算,回答的会变成“持仓期间资产有多波动”,不再是整条策略净收益有多波动。
波动率同时把向上和向下偏离视为波动。低波动不等于不会亏损,也不等于最大回撤一定更小。
从净值高点计算回撤
回撤(Drawdown)衡量净值相对此前历史最高点下降了多少。先计算截至交易日
再计算:
其中:
:交易日 的净值; :截至当日出现过的最高净值; :当日回撤,处于高点时为 0,低于高点时为负数。
代码为:
running_peak = wealth.cummax()
drawdown = wealth / running_peak - 1最大回撤(Maximum Drawdown,MDD)是整段样本中最负的回撤:
max_drawdown = drawdown.min()本样例策略最大回撤为 -0.6719%,基准为 -0.5725%。虽然策略年化波动率更低,最大回撤反而略深,因为回撤取决于涨跌发生的顺序和此前高点,这种性质称为路径依赖(Path Dependence)。
最大回撤只保留最深的一次下降,没有告诉我们回撤持续多久、恢复是否完成,也不会描述第二深和第三深的回撤。
计算夏普比率
夏普比率(Sharpe Ratio)用单位波动对应的超额收益描述风险调整后的表现。本单元使用:
其中:
:日收益率; :与日收益同频率的无风险收益率; :日超额收益平均值; :日收益的样本标准差; :年化期数,本例为 252。
年化无风险利率需要先转换为单期利率:
periodic_risk_free_rate = (1 + annual_risk_free_rate) ** (1 / periods_per_year) - 1本例年化无风险利率为 0%,策略夏普比率为 -0.0199,基准为 10.1101。基准的数字非常高,并不表示我们发现了近乎完美的投资:它来自仅 9 个收益区间、较高短期平均收益和有限的样本波动。
夏普比率还会:
- 把向上和向下波动都放进分母;
- 对极端收益、样本区间和频率敏感;
- 在收益波动为 0 时无法定义;
- 用一个平均数隐藏收益随时间的变化。
因此夏普比率适合与其他指标一起使用,不适合作为策略的唯一排名。
计算胜率和平均盈亏比
“胜率”必须先说明统计对象。本单元统计每日策略净收益,并排除缺失和恰好为 0 的日期:
策略共有 6 个非零收益日,其中 2 天为正,胜率为 33.3333%。处于现金且没有成本的 0 收益日既不算赢,也不算输。
平均盈亏比定义为:
策略的平均盈亏比为 1.9926,表示样本中正收益日的平均涨幅约为负收益日平均跌幅绝对值的 1.99 倍。
高盈亏比不能弥补任意低的胜率,反过来也一样。两者必须结合收益发生频率和复利结果阅读。
这是按日统计,不是按交易统计
一次完整交易可能跨越多个持仓日,还包含买入和卖出成本。本单元的胜率与盈亏比基于每日净收益,不能解释为“6 笔交易中赢了 2 笔”。若要评价每笔交易,需要先定义如何把进出场配对并汇总持有期收益。
报告换手
单元 05 已经得到逐日单边换手。本样例的样本累计单边换手为:
为了展示年化方式,程序还计算:
这里的 112 表示按这 9 个区间的平均换手速度机械外推,一年累计换手约为初始资产规模的 112 倍。它不是 112%,也不是 112 笔交易。
与年化收益一样,这个数字只展示尺度转换。短样本恰好包含几次频繁切换,不足以代表全年交易频率。
买入持有在本报告中是价格型基准,没有模拟初始买入交易,因此换手项显示为 --,而不是武断地填 0 与策略比较。
运行绩效评价
执行:
uv run python examples/unit05/run_backtest.py
uv run python examples/unit06/evaluate_performance.py预期指标表为:
metric strategy_net benchmark
总收益 -0.0115% 4.6627%
年化收益 -0.3218% 258.2399%
年化波动率 6.5726% 12.7243%
最大回撤 -0.6719% -0.5725%
夏普比率 -0.0199 10.1101
非零收益日胜率 33.3333% 66.6667%
平均盈亏比(日) 1.9926 1.9829
样本累计单边换手 4.00 --
机械年化换手 112.00 --
收益区间: 9 个
年化期数假设: 252
年化无风险利率: 0.00%
绩效报告: outputs/unit06/performance_report.csv
逐日回撤: outputs/unit06/performance_daily.csv
净值与回撤图: outputs/unit06/wealth_and_drawdown.png三个结果文件分别承担不同任务:
| 文件 | 用途 |
|---|---|
performance_report.csv | 保存策略与基准的汇总指标 |
performance_daily.csv | 保存两条净值、逐日回撤和策略换手 |
wealth_and_drawdown.png | 对照观察净值路径和回撤发生时间 |
把指标写成结论
打开 outputs/unit06/wealth_and_drawdown.png。上图比较策略净值与基准净值,下图显示它们相对各自历史高点的回撤。
对当前样例,可以写出下面这段有边界的结论:
在 2024-01-02 至 2024-01-15 的 9 个收益区间内,3 日均线策略扣除每次单边换手 0.1% 的成本后,总收益为 -0.0115%,低于价格型买入持有基准的 4.6627%。策略日收益年化波动率较低,但最大回撤略深;4.0 的累计单边换手使成本明显侵蚀了本就有限的毛收益。样本过短,年化收益、夏普比率和机械年化换手不具备长期推断能力。
这段文字同时交代了日期、收益口径、成本、基准、风险和样本限制。它没有把“基准夏普 10.1101”单独拿出来宣传,也没有把一次短样本落后解释成策略永远无效。
常见陷阱
策略与基准使用不同区间
即使只相差一天,也可能让总收益、波动率和回撤无法直接比较。先对齐日期和收益定义,再计算指标。
用毛收益计算部分指标、净收益计算其他指标
混合口径会得到一份不存在于任何真实净值路径上的报告。本单元策略指标全部使用扣费后的净收益和净值。
把短样本年化当成预测
年化公式假设样本复合速度可以延续。9 个收益区间无法支持这种长期推断。
最大回撤写成正数却不说明
有些系统把最大回撤报告为正的损失幅度,有些保留负号。本教程保留负号;比较不同工具时要先核对符号约定。
胜率没有说明统计对象
按日胜率、按周胜率和按交易胜率可能完全不同。还要说明 0 收益如何处理。
夏普比率没有说明无风险利率
不同无风险利率、收益频率和年化期数会产生不同结果。报告夏普时应把这些参数一起写出。
只保留一个最好看的指标
高收益可能伴随深回撤,高胜率可能伴随少数巨大亏损,低波动也可能来自长期空仓。指标必须与净值和回撤路径一起检查。
验证结果
完成标准:
- 策略和基准都使用相同的 9 个收益区间;
- 策略总收益与单元 05 的末端净值一致;
- 基准总收益与
105.50 / 100.80 - 1一致; - 策略最大回撤等于逐日策略回撤的最小值;
- 年化期数为 252,年化无风险利率为 0%;
- 非零收益日胜率排除现金状态下的 0 收益日;
- 样本累计换手为 4.0,机械年化换手为 112;
- 能说明为什么基准 258.2399% 的年化收益和 10.1101 的夏普比率不能当作长期结论。
小结
本单元把逐日回测整理成了一份基础绩效报告:
策略净收益 / 基准收益
↓
总收益 ─ 年化收益 ─ 年化波动率 ─ 夏普比率
↓
策略净值 / 基准净值 → 逐日回撤 → 最大回撤
↓
非零收益日 → 胜率 / 平均盈亏比
↓
逐日换手 → 样本累计换手 / 机械年化换手
↓
指标表 + 净值回撤图 + 有边界的文字结论下一单元会故意制造未来函数、忽略成本和其他回测错误,对比错误结果与当前结果,训练识别“漂亮但不可信”的回测。
练习
基础练习:手工核对最大回撤
打开 performance_daily.csv:
- 找到策略历史最高净值;
- 找到此后样例中的最低净值;
- 用
最低净值 / 此前最高净值 - 1手工计算; - 与报告中的 -0.6719% 对照。
进阶练习:改变无风险利率
将 annual_risk_free_rate 改为 2%,重新计算:
- 总收益和最大回撤是否变化;
- 年化波动率是否变化;
- 策略与基准夏普比率如何变化。
先写出哪些指标应该不变,再运行程序。
延伸练习:设计按交易统计
不必实现代码,先定义一笔交易:
- 买入和卖出如何配对;
- 买入日、持有日和卖出日的成本如何归属;
- 样本结束时仍未平仓如何处理;
- 交易胜率和盈亏比应使用金额收益还是收益率。
如果这些口径没有确定,“交易胜率”就还不是一个可重复计算的指标。