⚠ 交易存在重大亏损风险。本网站的所有内容仅供教育用途,不构成财务建议。
第 9 课,共 13 课 · 免费

把绩效指标写成可复用函数:CAGR、回撤、夏普比率、索提诺比率、盈利因子及其单元测试

编写一个正确处理年化的小型指标模块,添加滚动版本,并用 pytest 对照你手工算出的数值来验证这些函数。

作者: Moustafa Hijazi内容审核日期: 2026-10-30阅读约 9 分钟自学课程

你将学到什么

  • 如何把每个指标写成一个纯函数,它接收一个 pandas 对象并返回一个数字。
  • 年化在何处介入:波动率的时间平方根规则,以及每年期数这一常数。
  • 滚动版本如何揭示策略在何时有效、何时无效。
  • 如何对照手工计算的数值对指标进行单元测试,使日后的重构无法在不知不觉中破坏它。

讲解

CAGR、最大回撤、夏普比率和盈利因子的定义已在课程《系统化交易与回测》(第 5 课)中讲过,基于交易日志的计算则在课程《风险与交易管理》(第 9 课)中讲过。本课关注的是实现:把它们放在一个文件 metrics.py 中,让每个笔记本都导入同一份经过测试的代码,而不是重新输入公式。

模块的设计规则

  • 每个函数接收的要么是资金序列(欧元金额或指数水平,DatetimeIndex),要么是收益率序列(各期的简单收益率),并在函数名或文档字符串中注明是哪一种。混用两者是错误数字最常见的来源。
  • 用一个 periods 参数(日线为 252,周线为 52,月线为 12)处理年化。永远不要在公式内部硬编码 252。
  • 函数返回普通的浮点数,从不打印或绘图。

函数

  • import numpy as np, pandas as pd
  • def cagr(equity, periods=252):
  • years = (len(equity) - 1) / periods
  • return (equity.iloc[-1] / equity.iloc[0]) ** (1 / years) - 1
  • def max_drawdown(equity):
  • dd = equity / equity.cummax() - 1
  • return dd.min() # 负数,例如 -0.25
  • def sharpe(returns, rf=0.0, periods=252):
  • ex = returns - rf / periods
  • return 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, pytest
  • from metrics import cagr, max_drawdown, profit_factor
  • def 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。

  1. CAGR = (14,000 / 10,000)^(1/3) − 1 = 1.4^0.3333 − 1 = 每年 11.87%。(而不是 40% / 3 = 13.3%,那忽略了复利。)
  2. 资金路径 100、120、90、130、110 上的最大回撤:运行峰值为 100、120、120、130、130;回撤为 0、0、−25%、0、−15.4%。最大回撤 = −25%。
  3. 由均值 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 也会得到同样的数字。
  4. 六个收益率 +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。索提诺比率更高,因为上行波动不被计为风险。
  5. 由交易盈亏 €320、−€280、€150、−€100、€500、−€120 计算的盈利因子:总盈利 €970,总亏损 €500,盈利因子 = 1.94。

上述每个数字都成为测试文件中的一条断言。如果日后有人修改了 ddof 或年化常数,测试会在一秒内失败。

常见错误

把年化方式弄反收益率随时间缩放(均值乘以 252);波动率随时间的平方根缩放(乘以 √252)。把日标准差乘以 252 会得出数百个百分点的波动率和接近零的夏普比率;把日夏普比率乘以 252 则会得出 10 甚至更高的夏普比率。这两种错误都曾出现在真实的笔记本中。使用已知日度输入的测试能立即发现其中任何一种错误。

练习活动

在纸上完成:(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 课展示如何把这些函数所衡量的内容画出来:带指标的价格图、带回撤的资金曲线以及收益率分布,并导出为干净的图表。

自测

回答问题后核对答案。你可以随意重试多次。

1. 日收益率均值为 0.05%,样本标准差为 1.2%。按 252 个交易日、无风险利率为零计算,年化夏普比率是多少(保留两位小数)?

2. 在一个你手工算出 −0.25 的测试序列上,你的 max_drawdown 函数返回 −0.18。哪种程序错误与此最吻合?

3. 为什么指标函数应当接收一个 periods 参数,而不是在公式内部使用 252?

供您自行复习的自动自测。您的答案在浏览器中核对,我们不会查看或评估。这不是考试,也不是资格证书。

您的进度

“已完成”仅表示您将本课标记为已学完,并不是对您理解程度的测试。 学习进度和笔记仅保存在此设备的此浏览器中,而非账户中。