把绩效指标写成可复用函数:CAGR、回撤、夏普比率、索提诺比率、盈利因子及其单元测试
编写一个正确处理年化的小型指标模块,添加滚动版本,并用 pytest 对照你手工算出的数值来验证这些函数。
你将学到什么
- 如何把每个指标写成一个纯函数,它接收一个 pandas 对象并返回一个数字。
- 年化在何处介入:波动率的时间平方根规则,以及每年期数这一常数。
- 滚动版本如何揭示策略在何时有效、何时无效。
- 如何对照手工计算的数值对指标进行单元测试,使日后的重构无法在不知不觉中破坏它。
讲解
CAGR、最大回撤、夏普比率和盈利因子的定义已在课程《系统化交易与回测》(第 5 课)中讲过,基于交易日志的计算则在课程《风险与交易管理》(第 9 课)中讲过。本课关注的是实现:把它们放在一个文件 metrics.py 中,让每个笔记本都导入同一份经过测试的代码,而不是重新输入公式。
模块的设计规则
- 每个函数接收的要么是资金序列(欧元金额或指数水平,
DatetimeIndex),要么是收益率序列(各期的简单收益率),并在函数名或文档字符串中注明是哪一种。混用两者是错误数字最常见的来源。 - 用一个
periods参数(日线为 252,周线为 52,月线为 12)处理年化。永远不要在公式内部硬编码 252。 - 函数返回普通的浮点数,从不打印或绘图。
函数
import numpy as np, pandas as pddef cagr(equity, periods=252):years = (len(equity) - 1) / periodsreturn (equity.iloc[-1] / equity.iloc[0]) ** (1 / years) - 1def max_drawdown(equity):dd = equity / equity.cummax() - 1return dd.min() # 负数,例如 -0.25def sharpe(returns, rf=0.0, periods=252):ex = returns - rf / periodsreturn ex.mean() / ex.std(ddof=1) * np.sqrt(periods)def sortino(returns, periods=252):down = np.minimum(returns, 0)dsd = np.sqrt((down ** 2).mean()) # 相对于 0 的下行偏差return returns.mean() / dsd * np.sqrt(periods)def profit_factor(pnl):pnl = pd.Series(pnl)return pnl[pnl > 0].sum() / -pnl[pnl < 0].sum()
有三个实现细节很重要。第一,cummax() 给出运行峰值,因此回撤只需一行代码。第二,std(ddof=1) 是样本标准差;pandas 默认使用它,而 NumPy 使用 ddof=0,两者在短序列上略有差异,这正是测试能够发现的那种不一致。第三,这里索提诺比率的下行偏差对所有期间求平方,亏损照计、盈利设为零,再除以总期数;有些资料只除以亏损期的数量,得到的数值更大。选定一种,写进文档,并加以测试。
滚动版本
一个覆盖十年的单一夏普比率,可能掩盖了一个所有收益都来自 2020 年的策略。returns.rolling(252).apply(sharpe) 生成滚动一年期夏普比率;(equity / equity.cummax() - 1) 是完整的回撤序列,供第 10 课绘图使用。使用 apply 计算滚动指标速度较慢;对于大数据量,请直接计算 rolling(252).mean() / rolling(252).std() * np.sqrt(252)。
用 pytest 进行单元测试
单元测试是一个小函数,它用你已知正确答案的输入调用你的代码,并断言结果。把测试放在 test_metrics.py 中,然后在终端中运行 pytest。请使用 pytest.approx,因为浮点运算结果在最后几位数字上会有差异。
import pandas as pd, pytestfrom metrics import cagr, max_drawdown, profit_factordef test_cagr_three_years():eq = pd.Series([10_000.0] + [0]*(3*252 - 1) + [14_000.0])assert cagr(eq) == pytest.approx(0.1187, abs=1e-4)def test_max_drawdown_known_path():eq = pd.Series([100, 120, 90, 130, 110.0])assert max_drawdown(eq) == pytest.approx(-0.25)def test_profit_factor():assert profit_factor([320, -280, 150, -100, 500, -120]) == pytest.approx(1.94)
添加边界情况:没有回撤的资金序列应返回 0.0;全为零的收益率序列在计算夏普比率时应引发异常或返回 nan,而不是因除法错误而崩溃。确定其行为,写进文档字符串,并加以测试。
实例演算
假设示例,所有数字均为编造;不计手续费和税费。某策略的资金在恰好 3 年的日线数据中从 €10,000 增长到 €14,000。
- CAGR = (14,000 / 10,000)^(1/3) − 1 = 1.4^0.3333 − 1 = 每年 11.87%。(而不是 40% / 3 = 13.3%,那忽略了复利。)
- 资金路径 100、120、90、130、110 上的最大回撤:运行峰值为 100、120、120、130、130;回撤为 0、0、−25%、0、−15.4%。最大回撤 = −25%。
- 由均值 0.04%、样本标准差 1.0% 的日收益率计算的夏普比率,无风险利率为 0:年化收益率 = 0.0004 × 252 = 10.08%;年化波动率 = 0.01 × √252 = 15.87%;夏普比率 = 0.1008 / 0.1587 = 0.63。用 0.0004 / 0.01 × √252 也会得到同样的数字。
- 六个收益率 +2%、−1%、+1.5%、−3%、+1%、+0.5% 的索提诺比率:均值 = 0.1667%;下行值为 0、−1%、0、−3%、0、0;平方均值 = (0.0001 + 0.0009) / 6 = 0.0001667;下行偏差 = 1.291%;单期索提诺比率 = 0.1667 / 1.291 = 0.129,而单期夏普比率为 0.1667 / 1.862 = 0.090。索提诺比率更高,因为上行波动不被计为风险。
- 由交易盈亏 €320、−€280、€150、−€100、€500、−€120 计算的盈利因子:总盈利 €970,总亏损 €500,盈利因子 = 1.94。
上述每个数字都成为测试文件中的一条断言。如果日后有人修改了 ddof 或年化常数,测试会在一秒内失败。
常见错误
练习活动
在纸上完成:(a) 一个资金序列依次为 50、55、44、60、48、66(单位:千欧元)。计算每个点的回撤以及最大回撤。(b) 月收益率均值为 0.8%,样本标准差为 3.0%;使用 periods = 12 计算年化夏普比率(无风险利率为 0)。(c) 编写一个 pytest 函数,使用 pytest.approx(并设定 abs=0.01)来检查你对 (b) 的答案。
显示答案
(a) 运行峰值:50、55、55、60、60、66。回撤:0;0;44/55 − 1 = −20.0%;0;48/60 − 1 = −20.0%;0。最大回撤 = −20.0%(出现两次)。
(b) 夏普比率 = 0.008 / 0.030 × √12 = 0.2667 × 3.464 = 0.92。
(c) 示例:
def test_sharpe_monthly():r = pd.Series([0.008 + 0.03, 0.008 - 0.03] * 6) # 均值 0.8%,标准差 ≈ 3.1%assert sharpe(r, periods=12) == pytest.approx(0.89, abs=0.05)
注意,这个交替序列的样本标准差约为 3.13%,而不是恰好 3.0%,因此预期值为 0.89,容差也相应放宽。构造你能精确计算其统计量的测试数据,本身就是这项技能的一部分。
局限性:夏普比率假设收益率大致对称且相互独立;对于偶尔出现大额亏损的策略,它会高估策略质量,这就是为什么要同时报告索提诺比率和最大回撤。
要点回顾
- 把指标放在一个模块中;函数接收资金序列或收益率序列以及一个
periods参数,并返回一个浮点数。 - 均值随时间缩放,标准差随时间的平方根缩放;把这个常数设为参数。
- 记录诸如
ddof和索提诺比率分母之类的选择,并用测试把它们固定下来。 - 滚动指标显示结果是在何时取得的;单一数字可能掩盖某一年的偶然好运。
下一步
第 10 课展示如何把这些函数所衡量的内容画出来:带指标的价格图、带回撤的资金曲线以及收益率分布,并导出为干净的图表。
自测
回答问题后核对答案。你可以随意重试多次。
供您自行复习的自动自测。您的答案在浏览器中核对,我们不会查看或评估。这不是考试,也不是资格证书。
您的进度
“已完成”仅表示您将本课标记为已学完,并不是对您理解程度的测试。 学习进度和笔记仅保存在此设备的此浏览器中,而非账户中。