一条净值曲线告诉你策略「赚没赚」,但回答不了「赚得是不是稳」「赚得是不是承担了与之匹配的风险」「赚得是不是能在新数据上重复」这三件真正决定生死的问题。绩效指标的存在,就是把净值曲线压缩成几个可比、可监控、可归因的数字,让策略之间能够横向比较,让风险预算能够纵向追踪,让真假 alpha 在统计意义上能够被分辨。这一篇要讲的不是哪个指标「最好」,而是每个指标在工程上回答的是哪个问题、适用的口径是什么、踩过的坑在哪里,以及一套可以直接搬到生产环境里跑的 Python 实现。
上一篇《Walk-forward 与交叉验证》解决的是「样本怎么切才能不污染」,本文解决的是「切好之后用什么尺子量」。两者合起来才构成完整的回测推断链路:先确保数据划分干净,再确保度量口径严谨。下一篇《执行算法》会回到「真实成交」这一侧,把绩效指标里的成本项进一步落到 VWAP、TWAP、Implementation Shortfall 等执行算法上。
代码示例使用 Python 3.11、numpy 1.26、pandas 2.2、scipy 1.11、statsmodels 0.14。文中所有数据为本地合成或可复现仿真,不涉及任何具体策略或基金的真实业绩。
风险提示:本文出现的所有指标、阈值、公式、Python 代码仅用于阐释绩效度量的方法论本身,不构成任何投资建议,也不能保证任何策略在真实市场中盈利。Sharpe、Sortino、PSR、DSR 等指标的「数字漂亮」不等于「策略可上线」,必须配合独立样本验证、纸面交易(paper trading)和小额实盘逐级放量。把示例代码搬到生产前,请重新核对收益频率(频率换算)、无风险利率口径(risk-free rate)、年化倍数(annualization factor)、自相关修正(autocorrelation adjustment)以及多重检验修正(multiple testing correction)。
一、绩效指标的目的
写绩效模块之前,先把「为什么需要绩效指标」想清楚。同一个净值序列,可以从四个完全不同的角度去用:评估、比较、监控、归因。每个角度需要的指标不同,对口径的容忍度也不同,混着用会出事。
一点一、评估:单个策略「值不值得上线」
评估场景关心的是「这条净值曲线本身够不够好」。这里要回答三件事:收益率是否显著大于 0、风险水平是否可以承受、结果是否在样本外可重复。对应的指标分别是 Sharpe / Sortino(收益相对于波动)、最大回撤 / VaR(风险水平)、Deflated Sharpe / PSR(统计显著性 + 多重检验修正)。评估口径要求最严:年化倍数、无风险利率、自相关、偏度峰度、试验次数全部要纳入。一个策略只有在这一层全部过关,才有资格进入纸面交易。
一点二、比较:多个策略「选哪个」
比较场景关心的是「在 A 和 B 两条净值之间怎么挑」。这里的关键不是绝对值,而是口径必须一致:同一段样本期、同一无风险利率、同一年化倍数、同一频率、同一手续费假设。横向比较时,常用的指标是 Sharpe(无基准)、信息比率(有基准,相对超额)、Calmar(收益 / 最大回撤)、Omega(阈值以上 / 阈值以下的概率加权)。机构内部做策略海选时,往往会同时报告四到六个指标,因为单一指标都能被构造出来「专门优化这一个数」的策略,多指标交叉看才不容易被骗。
一点三、监控:策略上线后「有没有衰减」
监控场景关心的是「实盘跑了 30 天 / 90 天,表现和回测有没有偏离」。这里需要的指标和评估、比较都不同:监控指标必须短窗口可计算、对样本外漂移敏感、有明确告警阈值。常见做法是滚动 Sharpe(rolling Sharpe,60 天 / 120 天)、滚动跟踪误差(rolling tracking error)、回撤深度与持续时长(current drawdown depth & duration)、命中率(hit rate)与盈亏比(payoff ratio)的组合。每一个都要有「正常区间」和「触发降仓 / 暂停」的硬阈值,写在策略上线书里。
一点四、归因:表现「来自哪里」
归因场景关心的是「这部分收益 / 风险是哪个因子 / 哪个品种 / 哪个时段贡献的」。归因不解决「好不好」的问题,它解决「为什么好(或不好)」的问题。归因报表通常分三层:多因子归因(market、size、value、momentum、quality 等系统性因子)、品种归因(按合约、行业、地区分桶)、时段归因(按交易时段、按宏观状态分桶)。归因不像评估那样有一个「最优指标」,而是一组按维度切片的 PnL 分解。
把这四件事分开,写绩效模块时才不会「一份代码服务所有场景」。评估模块要慢、要严、要带统计显著性;比较模块要把口径写成强约束;监控模块要快、要有告警;归因模块要能按任意维度 group-by。这一篇主要展开评估、比较、监控里的核心指标,归因放到第七节单独讲。
二、年化收益与几何均值
所有绩效指标的起点都是「收益率」。看起来简单,工程上却有四个反复踩坑的细节:算术 vs 几何、单期 vs 多期、年化倍数、对数收益 vs 简单收益。把这四件事先讲清楚,后面的 Sharpe / Sortino / Calmar 才不会在分子分母上算错。
二点一、简单收益与对数收益
简单收益(simple return)的定义是
r_t = P_t / P_{t-1} - 1,对数收益(log
return)的定义是
l_t = ln(P_t / P_{t-1})。两者的关系是
l_t = ln(1 + r_t),在 r 较小时近似相等(一阶
Taylor 展开
ln(1+r) ≈ r - r²/2)。在工程上,两者各有各的好处:
- 对数收益是时间可加的:多期对数收益可以直接相加,
ln(P_T / P_0) = Σ l_t。这意味着对数收益做一阶矩、二阶矩估计,可以直接套用独立同分布(i.i.d.)假设下的中心极限定理。 - 简单收益是横截面可加的:给定权重
w_i,组合的简单收益等于Σ w_i · r_{i},对数收益没有这个性质。
实际工程里,纵向(时间维度)用 log
return、横截面(组合加权)用 simple return。Python
实现上,pandas 的 pct_change()
给的是简单收益,转 log 用 np.log1p(returns) 或
np.log(prices).diff()。需要警惕的是:连续 log
return 累加之后再 expm1
才能还原成简单收益的累计;如果直接把 log return 误当成
simple return 累加,长期下来会产生几个百分点的偏差。
二点二、年化收益的两种口径
把日频或月频收益换算成年化收益,工程上有两个完全不同的口径,用错口径会让两个数差一倍:
几何年化(CAGR,compound annual growth
rate):CAGR = (P_T / P_0)^(252/N) - 1,反映的是「按这个速度复利走一年后的实际收益」。它对路径不敏感,只取决于起点和终点的净值与样本期长度。
算术年化(arithmetic
annualized):r_arith = mean(r_t) × 252,反映的是「平均每天的收益乘以一年的交易日数」。它在统计学上对应「期望收益的年化估计」,在
Sharpe 公式的分子里用的就是这一个。
两者关系:r_arith ≈ CAGR + 0.5 × σ²_annual(Itô
修正,annualized variance
drag)。当波动率足够大时,算术年化会显著高于几何年化。一个年化算术收益
20%、年化波动 30% 的策略,其几何年化大约只有
0.20 - 0.5 × 0.30² = 0.155,差出 4.5
个百分点。汇报业绩时一定要写清楚是哪一个,否则横向比较时会出现「我的
CAGR 比你的 arith 还高」这种错位讨论。
二点三、年化倍数怎么取
把日频指标乘以 √252
是行业惯例,但「252」这个数字本身有口径分歧:
- 美股:每年大约 252 个交易日(NYSE 一年扣除周末和节假日后的实际值在 250–253 之间浮动)。
- A 股:每年大约 244–245 个交易日(清明、五一、端午、中秋、国庆、春节调休后比美股少一些)。
- 加密:7×24 全年无休,年化倍数取
365(日频)或8760(小时频)。 - 小时频股票:一天约 6.5 小时(美股)或 4
小时(A 股),年化倍数
√(252 × 6.5)或√(244 × 4)。 - 分钟频:相应再乘以分钟数。
做横向比较时,年化倍数必须统一:不能 A 股策略用 245、美股策略用 252,然后直接比 Sharpe。另外要注意:连续交易(如加密 24h)的波动率相对显著低于离散交易市场,把日频年化倍数换成小时频年化倍数时,要根据实际交易时段调整,否则会高估波动。
二点四、几何均值与算术均值的工程实现
下面这段代码是后续所有指标的基础,里面给出了简单收益、对数收益、几何年化、算术年化、annualization factor 的统一接口。后文所有的 Sharpe / Sortino / Calmar 都基于它。
import numpy as np
import pandas as pd
from dataclasses import dataclass
@dataclass
class FreqSpec:
"""收益序列的频率规格。
periods_per_year: 一年的期数。日频股票 252 / 244;日频加密 365;
小时频美股 252*6.5;分钟频按市场调整。
is_log: 输入序列是否已经是 log return;False 表示 simple return。
"""
periods_per_year: float = 252.0
is_log: bool = False
def to_log_returns(returns: pd.Series, spec: FreqSpec) -> pd.Series:
if spec.is_log:
return returns.astype(float)
return np.log1p(returns.astype(float))
def to_simple_returns(returns: pd.Series, spec: FreqSpec) -> pd.Series:
if spec.is_log:
return np.expm1(returns.astype(float))
return returns.astype(float)
def cagr(returns: pd.Series, spec: FreqSpec) -> float:
"""几何年化收益(CAGR)。"""
log_r = to_log_returns(returns.dropna(), spec)
n = len(log_r)
if n == 0:
return float('nan')
total_log = log_r.sum()
years = n / spec.periods_per_year
if years <= 0:
return float('nan')
return float(np.expm1(total_log / years))
def arithmetic_annual_return(returns: pd.Series, spec: FreqSpec) -> float:
"""算术年化收益(mean × periods_per_year)。
注意:这是 Sharpe 分子使用的口径。"""
simple = to_simple_returns(returns.dropna(), spec)
return float(simple.mean() * spec.periods_per_year)
def annual_volatility(returns: pd.Series, spec: FreqSpec, ddof: int = 1) -> float:
simple = to_simple_returns(returns.dropna(), spec)
return float(simple.std(ddof=ddof) * np.sqrt(spec.periods_per_year))工程上一个常见错误是:把
returns.std() * np.sqrt(252) 当成年化波动,但
returns 是 log
return;这会让波动率在数值上略小于按 simple return
算的版本(差异约为 σ²/2
量级,在日频上可忽略,在月频上不可忽略)。统一接口的好处是把这种细节封装在一个地方,外部只问「这是什么频率」「是不是
log」,避免在主流程里反复判断。
二点五、累计净值与 NAV
净值(net asset value,NAV)序列是后面回撤分析的输入。从收益序列还原净值有两种方式:
def returns_to_nav(returns: pd.Series, spec: FreqSpec, init_nav: float = 1.0) -> pd.Series:
if spec.is_log:
log_r = returns.astype(float)
else:
log_r = np.log1p(returns.astype(float))
return init_nav * np.exp(log_r.cumsum())避免使用 (1 + simple_returns).cumprod()
的写法处理超长序列(数十年日频以上),因为浮点累乘会引入累积误差;改用
log 累加 + exp 还原,数值稳定性更好。
三、Sharpe、Sortino、Calmar、Omega
这一节讲四个最常用的「单值绩效指标」。它们都把净值曲线压缩成一个数字,但每个数字回答的问题不一样,对应的口径也不一样。
三点一、Sharpe 比率
Sharpe 比率(Sharpe ratio,SR)的定义是:
SR = (E[R_p] - R_f) / σ_p
其中 R_p 是策略收益、R_f
是无风险利率、σ_p
是策略收益的标准差。年化形式是
SR_annual = SR_period × √(periods_per_year),但这个换算只在收益i.i.d.
且零自相关的假设下成立——这是后面第六节要修正的地方。
工程上写 Sharpe,至少要把以下五件事写在文档里,否则报出来的数字没法和别人比:
- 无风险利率口径:用什么作为
R_f?常见选项是国债收益率(3-month T-bill)、回购利率(如 SHIBOR、SOFR)、或简单取 0。机构内部做横向比较时,最干脆的做法是统一取 0;做对外披露时按 GIPS 推荐使用市场无风险利率。 - 频率匹配:
R_f必须和策略收益同频率。日频策略减去年化无风险利率是错的,要先把R_f转成日频。 - 样本统计量:用
mean()还是nanmean()?std()的ddof取 0 还是 1?工程上推荐ddof=1(无偏样本方差)。 - 超额收益的定义:是先减
R_f再算 mean/std,还是 mean 减R_f之后除以 std?两种写法在 std 的分母上是等价的,但在数值上前者更稳。 - 年化倍数:见上一节。
def sharpe_ratio(returns: pd.Series,
spec: FreqSpec,
risk_free: float = 0.0,
ddof: int = 1) -> float:
"""年化 Sharpe 比率。
risk_free: 年化无风险利率(百分数,如 0.03 表示 3%)。
内部按 periods_per_year 转成单期 rf 后做差。"""
simple = to_simple_returns(returns.dropna(), spec)
if len(simple) < 2:
return float('nan')
rf_period = risk_free / spec.periods_per_year
excess = simple - rf_period
sigma = excess.std(ddof=ddof)
if sigma == 0:
return float('nan')
return float(excess.mean() / sigma * np.sqrt(spec.periods_per_year))Sharpe 的常见误用包括:用月频 Sharpe 直接乘 √12 得到「年化」并和别人的日频年化 Sharpe 比;忽略无风险利率用差的样本期;只报 Sharpe 不报样本期长度(短样本 Sharpe 的标准误极大);在收益自相关(如均线策略、趋势策略)严重的情况下使用裸 Sharpe,这一点在第六节会展开。
三点二、Sortino 比率
Sortino 比率把分母从「全部波动」换成「下行波动」,背后的直觉是「上行波动也是波动,但投资者并不讨厌」。定义:
Sortino = (E[R_p] - MAR) / σ_down
σ_down = sqrt( E[ min(R_p - MAR, 0)² ] )
MAR(minimum acceptable
return)是最低可接受收益,常取 0
或无风险利率。下行偏差(downside
deviation)的两个工程口径分歧点:
- 分母只对低于 MAR
的样本求方差:
np.sqrt(np.mean(np.minimum(r - mar, 0)**2))。这是 Sortino 原始论文的口径。 - 分母对全部样本求 min(·, 0)² 的均值:和上面一致,但更明确包含「正收益样本贡献 0」。
工程上要小心:有些库(包括早期 empyrical)默认把 MAR 设为
0
而不是无风险利率,要提前在文档里说清楚。还有一个常见错误:把分母只对负收益样本求标准差(即
r[r<MAR].std()),这会让分母偏小、Sortino
虚高。下面给出严谨实现:
def sortino_ratio(returns: pd.Series,
spec: FreqSpec,
mar: float = 0.0) -> float:
"""年化 Sortino 比率。mar 为年化最低可接受收益。"""
simple = to_simple_returns(returns.dropna(), spec)
if len(simple) < 2:
return float('nan')
mar_period = mar / spec.periods_per_year
excess = simple - mar_period
downside = np.minimum(excess, 0.0)
dd = np.sqrt(np.mean(downside ** 2)) # 对全部样本求方差,不只是负样本
if dd == 0:
return float('nan')
return float(excess.mean() / dd * np.sqrt(spec.periods_per_year))Sortino 的实际意义:当策略有强烈的「上行尖峰」(比如尾部押对了一次大事件),Sharpe 会因为分母里的上行波动而被压低,Sortino 不会。这意味着 Sortino 对正偏(positive skew)策略友好、对负偏(negative skew,如卖期权)策略严苛。一个卖深虚值期权的策略,长期看可能 Sharpe 看起来很好,但 Sortino 会暴露「平时收一点点 premium、偶尔大亏一次」的真实形态。
三点三、Calmar 比率
Calmar 的定义最简单:
Calmar = CAGR / |Max Drawdown|
它把「绝对收益」和「最坏跌幅」放到同一个比值里,回答的是「我每承担 1 单位最大回撤,能换到多少年化收益」。Calmar 在 CTA / 趋势策略里非常常用,因为这类策略的回撤往往是长期的、深的,传统 Sharpe 不够刻画。
工程实现要注意 max drawdown 的口径(见第四节):用净值序列还是用 log NAV,会有细微差别;用全样本最大回撤还是用滚动 36 个月最大回撤,结果会差很多。机构里看 Calmar,常用的是「过去 36 个月的 CAGR / 过去 36 个月的最大回撤」,避免一个十年前的回撤永远把分母拖在那里。
def max_drawdown(nav: pd.Series) -> tuple[float, pd.Timestamp, pd.Timestamp]:
"""返回 (最大回撤幅度, 峰值时间, 谷值时间)。回撤为负数。"""
nav = nav.dropna().astype(float)
if len(nav) == 0:
return float('nan'), None, None
running_max = nav.cummax()
dd = nav / running_max - 1.0
trough_idx = dd.idxmin()
peak_idx = nav.loc[:trough_idx].idxmax()
return float(dd.loc[trough_idx]), peak_idx, trough_idx
def calmar_ratio(returns: pd.Series, spec: FreqSpec) -> float:
nav = returns_to_nav(returns, spec)
mdd, _, _ = max_drawdown(nav)
cag = cagr(returns, spec)
if mdd == 0 or np.isnan(mdd):
return float('nan')
return float(cag / abs(mdd))三点四、Omega 比率
Omega 比率是一个被低估的指标。它不依赖均值方差假设,把「阈值 τ 以上的概率加权累积」除以「阈值以下的概率加权累积」:
Ω(τ) = ∫_τ^∞ (1 - F(r)) dr / ∫_{-∞}^τ F(r) dr
直观解释:如果你设定了一个阈值(比如年化 5%),Omega 告诉你「超过阈值的好结果总量」是「不及阈值的坏结果总量」的多少倍。τ = 0 时 Omega 等价于「正收益样本之和 / 负收益样本绝对值之和」(即 Gain-Loss Ratio)。Omega 的好处是:
- 不假设收益服从正态分布;
- 自动包含全部高阶矩信息(skew、kurt 都已经隐含在 CDF 里);
- 阈值 τ 可以根据投资者的最低收益要求灵活设置。
经验实现:
def omega_ratio(returns: pd.Series,
spec: FreqSpec,
threshold: float = 0.0) -> float:
"""Omega 比率。threshold 为年化阈值,内部转成单期。"""
simple = to_simple_returns(returns.dropna(), spec)
tau = threshold / spec.periods_per_year
excess = simple - tau
gain = excess[excess > 0].sum()
loss = -excess[excess < 0].sum()
if loss == 0:
return float('inf') if gain > 0 else float('nan')
return float(gain / loss)Omega 在「Sharpe = 2、但有一根 -8% 黑色 Friday」和「Sharpe = 2、但每根都是 ±0.2%」之间,会给出明显不同的数。这正是它存在的价值——单纯的 Sharpe 在这两种形态上看起来一样。
三点五、四指标的取舍
在评估场景下,Sharpe 是「默认基线」,因为它是在所有学术文献和机构报表里出现频率最高的指标,可比性最强。但 Sharpe 不能单独支撑结论,至少要再看以下三个:
- Sortino:判断收益分布是否对称,正偏还是负偏。
- Calmar:判断这个 Sharpe 是不是用「极深回撤」换来的。
- Omega(τ = 0)或最大回撤:作为分布形态的最后一道检查。
对外披露时,至少同时报告 Sharpe、Sortino、最大回撤、Calmar 这四个,再加上样本期长度和年化口径。少于这四个的报告基本上是「营销 deck」而不是「研究报告」。
四、最大回撤与回撤分布
回撤(drawdown,DD)是绩效指标里最贴近「亏钱体验」的那一组。Sharpe 只看波动,不看路径;最大回撤直接告诉你「持有这个策略,最坏的时候曾经亏了多少」。从工程角度,回撤分析至少要包含三件事:最大回撤幅度(max drawdown depth)、最大回撤时长(max drawdown duration)、水下曲线(underwater curve)。
四点一、最大回撤的定义
最大回撤的标准定义是:
DD_t = NAV_t / max(NAV_{0..t}) - 1 ≤ 0
MaxDD = min_t DD_t
注意三点:
- 要用累计净值,不能用区间收益率。直接对收益率序列取
min(cumsum)是错的——回撤是相对于历史峰值的相对跌幅,不是相对于起点。 - 峰值是「至今为止」的累计最大值,不是全样本最大值。所以
cummax()是正确的实现,max()是错的。 - DD ≤ 0:永远是非正数,等于 0 表示「现在就是历史新高」。
四点二、回撤时长与恢复时间
回撤幅度只是一半的故事,另一半是「这个回撤持续了多久」。三个时间点要拆开:
- 峰值时间(peak):开始下跌前的历史高点。
- 谷值时间(trough):跌到最深的时刻。
- 恢复时间(recovery):净值重新创下历史新高的时刻。
回撤的总时长 =
recovery - peak,回撤的下行时长 =
trough - peak,恢复时长 =
recovery - trough。一个 -8% 的回撤如果在 30
天内恢复,体感和「-8%
但拖了两年才恢复」完全不同。后者会击穿大多数人的持有意愿,即使回撤数字一样。
def drawdown_episodes(nav: pd.Series) -> pd.DataFrame:
"""枚举所有回撤段,返回 DataFrame,列为
[peak, trough, recovery, depth, drawdown_days, recovery_days]。
未恢复的最后一段,recovery 为 NaT,recovery_days 为 NaN。"""
nav = nav.dropna().astype(float)
running_max = nav.cummax()
in_dd = nav < running_max
episodes = []
i, n = 0, len(nav)
while i < n:
if not in_dd.iloc[i]:
i += 1
continue
# 找回撤段起点:i 之前的最近一个新高
peak_val = running_max.iloc[i]
peak_idx = nav.index[(nav == peak_val) & (nav.index <= nav.index[i])].max()
# 找段终点(恢复)或样本末尾
j = i
while j < n and nav.iloc[j] < peak_val:
j += 1
segment = nav.iloc[i:j]
trough_idx = segment.idxmin()
depth = float(segment.min() / peak_val - 1.0)
if j < n:
recovery_idx = nav.index[j]
recovery_days = (recovery_idx - peak_idx).days
else:
recovery_idx = pd.NaT
recovery_days = float('nan')
drawdown_days = (trough_idx - peak_idx).days
episodes.append({
'peak': peak_idx,
'trough': trough_idx,
'recovery': recovery_idx,
'depth': depth,
'drawdown_days': drawdown_days,
'recovery_days': recovery_days,
})
i = j
return pd.DataFrame(episodes)把这张表按 depth 升序排,前 5
个回撤段就是这条净值最痛的 5 段经历。机构尽调(due
diligence)报表里通常会单独列出 Top 5
drawdowns,标明每段的起止时间和恢复天数。
四点三、水下曲线
水下曲线(underwater curve)是最直观的回撤可视化:横轴时间,纵轴回撤百分比,整条曲线永远在 0 以下。每次回到 0 就是一次「重新创下新高」。
工程上画水下曲线只需两行:
nav = returns_to_nav(returns, spec)
underwater = nav / nav.cummax() - 1.0水下曲线比净值曲线更适合监控:当净值在历史高点附近反复震荡时,净值曲线看不出什么,水下曲线却能精确显示「现在距离新高有多远」「在水下待了多久」。实盘监控仪表盘里建议把水下曲线作为默认视图,而不是净值曲线。
四点四、回撤分布
只看 max drawdown 一个数字不够,因为它对样本期长度极度敏感:样本越长、最大回撤越深,这是简单的极值统计性质。为了让回撤分析在不同样本期之间可比,要看回撤分布:
- 回撤段数量:
drawdown_episodes()返回的行数。 - 回撤深度的分位数:
depth.quantile([0.5, 0.9, 0.95, 0.99])。 - 回撤时长的分位数:
drawdown_days.quantile([...])。 - 当前是否在水下、水下了多久:实盘监控用。
报表写法上,「过去 5 年最大回撤 -8%」要配「同期共 12 段回撤,深度中位数 -1.2%、95 分位 -5.5%」,否则「-8%」这个数无法被解读为是常态还是黑天鹅。
四点五、Pain Index 与 Ulcer Index
Pain Index 是水下曲线的算术平均值(取绝对值):
PI = mean(|DD_t|)
Ulcer Index 是水下曲线的均方根:
UI = sqrt(mean(DD_t²))
两者都比 max drawdown 平滑、对样本期长度敏感性更低。Ulcer
Performance Index(UPI)=
(CAGR - R_f) / UI,相当于「Calmar
的均方根版本」。在长期跟踪策略时,UPI 比 Calmar 更稳健,因为
Calmar 容易被一次极端深回撤主导整张报表。
def pain_index(nav: pd.Series) -> float:
underwater = nav / nav.cummax() - 1.0
return float(underwater.abs().mean())
def ulcer_index(nav: pd.Series) -> float:
underwater = nav / nav.cummax() - 1.0
return float(np.sqrt((underwater ** 2).mean()))五、信息比率与跟踪误差
Sharpe 衡量的是「绝对收益除以绝对波动」,但对有基准的策略(指数增强、行业中性、smart beta、long-short equity),更合适的指标是信息比率(information ratio,IR)。
五点一、定义与口径
信息比率定义为「相对基准的超额收益除以超额收益的波动」:
IR = E[R_p - R_b] / σ(R_p - R_b)
TE = σ(R_p - R_b) # tracking error,跟踪误差
α = E[R_p - R_b] # active return
IR = α / TE
注意三件事:
- 基准必须是可投资的。如果基准是「中证 500 全收益指数」,那策略的对标对象就应当是中证 500 ETF + 现金管理;用一个不可投资的指数当基准会让 IR 系统性偏高。
- 超额收益要 PIT 计算。每天用 PIT 的指数权重和 PIT 的成分股价格计算指数收益,不能用今天能拉到的「最终修订版」指数权重去回算历史。
- 跟踪误差年化:
TE_annual = TE_period × √(periods_per_year),和 Sharpe 分母一样的年化倍数。
def information_ratio(strategy_returns: pd.Series,
benchmark_returns: pd.Series,
spec: FreqSpec,
ddof: int = 1) -> tuple[float, float, float]:
"""返回 (IR_annual, alpha_annual, te_annual)。"""
df = pd.concat([strategy_returns, benchmark_returns], axis=1).dropna()
df.columns = ['p', 'b']
sp = to_simple_returns(df['p'], spec)
sb = to_simple_returns(df['b'], spec)
active = sp - sb
if len(active) < 2:
return float('nan'), float('nan'), float('nan')
te = active.std(ddof=ddof)
alpha = active.mean()
if te == 0:
return float('nan'), float(alpha * spec.periods_per_year), 0.0
ir = alpha / te * np.sqrt(spec.periods_per_year)
return float(ir), float(alpha * spec.periods_per_year), float(te * np.sqrt(spec.periods_per_year))五点二、IR 的经验区间
学术文献和机构尽调里,对长期 IR 有一个粗略的「合格线」:
- IR ≥ 0.50:可接受
- IR ≥ 0.75:好
- IR ≥ 1.00:优秀
- IR ≥ 1.50:罕见,需要重点核对样本期、口径、是否过拟合
这个区间来自 Grinold-Kahn 在《Active Portfolio Management》里的经验讨论。任何号称长期 IR > 2 的「公开可投」策略都应当被严重质疑:要么样本期太短(不到 3 年),要么基准选得太弱,要么有数据问题或 lookahead。
五点三、Beta、Alpha 与 Active Risk
把策略收益相对于基准做线性回归:
R_p,t - R_f = α + β · (R_b,t - R_f) + ε_t
得到的 α 是「与基准无关的超额」,β 是「对基准的暴露」,残差 σ(ε) 是「特异波动(idiosyncratic risk)」。这一组数和 IR / TE 的关系是:
- 当 β = 1 时,active return 等于回归 α,TE 等于残差标准差,三者口径一致。
- 当 β ≠ 1 时,active return = α + (β-1) · E[R_b],意味着「主动收益」里混入了「基准杠杆」。
机构内部常常同时报告「Total Active IR」(基于差值 R_p - R_b)和「Residual IR」(基于回归残差)。两者的差异本身就是信号:差异大说明策略有显著的 β 偏离,差异小说明策略接近 β = 1 的「纯主动管理」。
import statsmodels.api as sm
def alpha_beta(strategy_returns: pd.Series,
benchmark_returns: pd.Series,
spec: FreqSpec,
risk_free: float = 0.0) -> dict:
df = pd.concat([strategy_returns, benchmark_returns], axis=1).dropna()
df.columns = ['p', 'b']
rf = risk_free / spec.periods_per_year
y = to_simple_returns(df['p'], spec) - rf
x = sm.add_constant(to_simple_returns(df['b'], spec) - rf)
model = sm.OLS(y, x).fit()
alpha_period = model.params['const']
beta = model.params['b']
resid_std = model.resid.std(ddof=1)
return {
'alpha_annual': float(alpha_period * spec.periods_per_year),
'beta': float(beta),
'residual_std_annual': float(resid_std * np.sqrt(spec.periods_per_year)),
'residual_ir': float(alpha_period / resid_std * np.sqrt(spec.periods_per_year)) if resid_std > 0 else float('nan'),
't_alpha': float(model.tvalues['const']),
'r_squared': float(model.rsquared),
}五点四、跟踪误差的工程边界
跟踪误差听起来像是「越低越好」,但它有边界。指数增强产品里,跟踪误差通常在 3% 到 8% 年化之间;TE < 1% 的「指数增强」基本上就是「指数 + 极小 alpha 努力」;TE > 10% 已经接近主动多头的形态,应当重新分类。机构尽调时会把 TE 作为分类工具:低 TE 看 IR,高 TE 看 Sharpe + α。
六、稳健 Sharpe
第三节的 Sharpe 公式建立在两个强假设上:收益独立同分布、收益服从正态分布。这两个假设在真实策略里几乎都不成立。这一节给出三个修正方向:自相关修正(Newey-West)、bootstrap 区间、考虑高阶矩与多重检验的 PSR / DSR。下图把整个修正树的结构画了出来:
六点一、为什么裸 Sharpe 会被高估
- 收益自相关(autocorrelation):均线策略、趋势跟踪、慢动量,收益序列存在正自相关,会让标准差被低估、Sharpe 被高估。Lo (2002) 给出过一个经典例子:日频 SR = 1.4 的策略,如果存在 0.3 的一阶自相关,年化 Sharpe 真值可能只有 1.0 左右。
- 高阶矩(skew / kurt):负偏 + 厚尾分布(卖期权、卖保险类策略)的标准差并不能描述真实风险,Sharpe 会显著高估。
- 多重检验(multiple testing):一组策略里挑出 Sharpe 最高的那一个,期望本身就被向上拉动,Bailey-López de Prado 的 Deflated Sharpe 给出了这个偏差的解析形式。
六点二、Newey-West 修正
Newey-West (1987) 给出了带自相关的标准误估计。对 Sharpe 的修正可以用「调整后的方差估计」直接代入分母:
σ²_NW = σ² · (1 + 2 Σ_{k=1}^{q} (1 - k/(q+1)) ρ_k)
SR_NW = SR_naive × σ / σ_NW
其中 ρ_k 是 lag-k 自相关、q
是带宽(一般取 floor(4·(N/100)^(2/9)) 或经验上
q = √N)。当 ρ > 0 时,σ_NW > σ,Sharpe
会被压低。
def sharpe_newey_west(returns: pd.Series,
spec: FreqSpec,
risk_free: float = 0.0,
q: int | None = None) -> float:
simple = to_simple_returns(returns.dropna(), spec)
n = len(simple)
if n < 5:
return float('nan')
rf = risk_free / spec.periods_per_year
excess = (simple - rf).values
mean = excess.mean()
var = excess.var(ddof=1)
if q is None:
q = max(1, int(np.floor(4 * (n / 100) ** (2 / 9))))
# Newey-West variance with Bartlett kernel
nw_var = var
for k in range(1, q + 1):
cov_k = np.mean((excess[k:] - mean) * (excess[:-k] - mean))
weight = 1.0 - k / (q + 1)
nw_var += 2 * weight * cov_k
nw_var = max(nw_var, 1e-12) # 数值兜底
nw_std = np.sqrt(nw_var)
return float(mean / nw_std * np.sqrt(spec.periods_per_year))六点三、Bootstrap 置信区间
Sharpe 是一个估计量,不是一个真值。用 bootstrap 给出区间,是评估「这个 Sharpe 在统计上显著大于 0 吗」的最直接办法。两种常用的 bootstrap:
- Stationary bootstrap(Politis-Romano 1994):处理弱平稳序列的自相关,块长度服从指数分布。
- Block bootstrap:固定块长度,最简单也最常用。
def sharpe_bootstrap_ci(returns: pd.Series,
spec: FreqSpec,
risk_free: float = 0.0,
n_boot: int = 5000,
block_size: int | None = None,
alpha: float = 0.05,
seed: int = 0) -> tuple[float, float, float]:
"""返回 (point_estimate, ci_low, ci_high)。
block bootstrap with fixed block size, default block_size = ceil(n^(1/3))."""
rng = np.random.default_rng(seed)
simple = to_simple_returns(returns.dropna(), spec).values
n = len(simple)
if n < 10:
return float('nan'), float('nan'), float('nan')
if block_size is None:
block_size = max(2, int(np.ceil(n ** (1 / 3))))
rf = risk_free / spec.periods_per_year
excess = simple - rf
point = excess.mean() / excess.std(ddof=1) * np.sqrt(spec.periods_per_year)
n_blocks = int(np.ceil(n / block_size))
sr_samples = np.empty(n_boot)
for b in range(n_boot):
starts = rng.integers(0, n - block_size + 1, size=n_blocks)
idx = (starts[:, None] + np.arange(block_size)[None, :]).reshape(-1)[:n]
sample = excess[idx]
std = sample.std(ddof=1)
if std == 0:
sr_samples[b] = 0.0
else:
sr_samples[b] = sample.mean() / std * np.sqrt(spec.periods_per_year)
lo = float(np.quantile(sr_samples, alpha / 2))
hi = float(np.quantile(sr_samples, 1 - alpha / 2))
return float(point), lo, hi实务里看到「Sharpe 1.6,95% CI [0.4, 2.6]」这样的输出,应当判定为「样本不足以说服我」,而不是「策略 Sharpe 1.6」。CI 不跨过 0 才有底气向上汇报。
六点四、Probabilistic Sharpe Ratio(PSR)
Bailey-López de Prado (2012) 给出 PSR:在已知样本 Sharpe
SR_hat 与样本偏度 γ_3、超额峰度
γ_4 的情况下,真实 Sharpe 大于阈值 SR*
的概率:
PSR(SR*) = Φ( (SR_hat - SR*) · sqrt(N - 1) / sqrt(1 - γ_3 · SR_hat + (γ_4 - 1)/4 · SR_hat²) )
其中 Φ 是标准正态 CDF,γ_4 是「超额峰度」(excess kurtosis = kurtosis - 3),N 是样本数。PSR 把高阶矩对 Sharpe 估计方差的影响显式纳入。负偏(γ_3 < 0)和厚尾(γ_4 > 0)都会让 PSR 下降。
from scipy import stats
def probabilistic_sharpe_ratio(returns: pd.Series,
spec: FreqSpec,
sr_benchmark: float = 0.0,
risk_free: float = 0.0) -> float:
"""PSR(SR*) = P(true SR > sr_benchmark | observed sample)。
sr_benchmark 为年化 Sharpe 阈值,例如 0、1.0 等。"""
simple = to_simple_returns(returns.dropna(), spec)
n = len(simple)
if n < 4:
return float('nan')
rf = risk_free / spec.periods_per_year
excess = simple - rf
sr_period = excess.mean() / excess.std(ddof=1)
# 把年化阈值换成单期
sr_star_period = sr_benchmark / np.sqrt(spec.periods_per_year)
skew = float(stats.skew(excess, bias=False))
excess_kurt = float(stats.kurtosis(excess, fisher=True, bias=False))
denom = np.sqrt(1 - skew * sr_period + (excess_kurt) / 4 * sr_period ** 2)
if denom <= 0 or np.isnan(denom):
return float('nan')
z = (sr_period - sr_star_period) * np.sqrt(n - 1) / denom
return float(stats.norm.cdf(z))实务上,机构内部上线门槛常常是「PSR(0) ≥ 0.95」(真实 Sharpe 大于 0 的概率不低于 95%)或「PSR(0.5) ≥ 0.95」(真实年化 Sharpe 大于 0.5 的概率)。把 PSR 作为强约束,能挡住「Sharpe 看起来很好但样本太短」的伪策略。
六点五、Deflated Sharpe Ratio(DSR)
DSR 在 PSR 的基础上再修一层「多重检验」。如果你试了 N 组策略,最终留下了 Sharpe 最高的那一个,期望最高 Sharpe 本身就比单次试验的期望大。Bailey-López de Prado 给出的修正阈值是:
SR*_DSR ≈ E[max{SR_i}] over N trials
≈ √V × ((1 - γ) Φ⁻¹(1 - 1/N) + γ Φ⁻¹(1 - 1/(N·e)))
其中 V 是各次试验 Sharpe 的样本方差,γ 是
Euler-Mascheroni 常数(≈ 0.5772),Φ⁻¹ 是标准正态 inverse
CDF。DSR 就是把这个 SR* 代回 PSR 公式:
DSR = PSR(SR*_DSR)
直觉是:如果你试了 1 个策略得到 Sharpe = 1,可信;试了 1000 个挑出最好的那一个 Sharpe = 1,根本不可信。DSR 把「试验次数 N」「试验 Sharpe 方差」直接量化成阈值。
def deflated_sharpe_ratio(returns: pd.Series,
spec: FreqSpec,
n_trials: int,
sr_trials_var: float,
risk_free: float = 0.0) -> float:
"""Deflated Sharpe。
n_trials: 总试验次数 N(同一研究框架下尝试过的所有策略 / 参数组合数)。
sr_trials_var: 各次试验 Sharpe(年化)的方差。"""
if n_trials < 2 or sr_trials_var <= 0:
return float('nan')
euler = 0.5772156649
z1 = stats.norm.ppf(1 - 1.0 / n_trials)
z2 = stats.norm.ppf(1 - 1.0 / (n_trials * np.e))
sr_star_annual = np.sqrt(sr_trials_var) * ((1 - euler) * z1 + euler * z2)
return probabilistic_sharpe_ratio(returns, spec,
sr_benchmark=sr_star_annual,
risk_free=risk_free)DSR 对 N 极其敏感:N 从 1 到 100 时阈值快速上升,从 100
到 10000 上升变慢(因为 Φ⁻¹
的尾部增长很慢)。实务上挑战是「n_trials
怎么定」——研究员往往低估自己实际试过的参数组合数。一个折中做法:把
n_trials 取「同一框架下该研究员近 12
个月跑过的所有 backtest 数」,从研究平台的日志里直接抽。
六点六、汇总:稳健 Sharpe 报表
把前面五个子节合起来,机构内部的「上线 Sharpe 报表」通常长这样:
def sharpe_robust_report(returns: pd.Series,
spec: FreqSpec,
risk_free: float = 0.0,
n_trials: int = 1,
sr_trials_var: float = 0.0) -> dict:
sr = sharpe_ratio(returns, spec, risk_free=risk_free)
sr_nw = sharpe_newey_west(returns, spec, risk_free=risk_free)
point, lo, hi = sharpe_bootstrap_ci(returns, spec, risk_free=risk_free)
psr0 = probabilistic_sharpe_ratio(returns, spec, sr_benchmark=0.0,
risk_free=risk_free)
dsr = deflated_sharpe_ratio(returns, spec,
n_trials=n_trials,
sr_trials_var=sr_trials_var,
risk_free=risk_free) if n_trials >= 2 else float('nan')
return {
'sharpe_naive': sr,
'sharpe_newey_west': sr_nw,
'sharpe_bootstrap_point': point,
'sharpe_bootstrap_ci_low': lo,
'sharpe_bootstrap_ci_high': hi,
'psr_zero': psr0,
'dsr': dsr,
'n_trials': n_trials,
}把这一组数都写到上线书里,比孤立一个 Sharpe 数字有说服力得多。
七、归因
绩效指标解决的是「好不好」,归因解决的是「为什么好」。归因不是单值指标,而是一组按维度切片的 PnL 分解。这一节从工程上展开三种最常用的归因:多因子归因、品种归因、时段归因。
七点一、多因子归因
多因子归因(factor attribution)的目标是把策略的超额收益分解成已知系统性因子的暴露贡献加上残差。最简单的形式是把 Fama-French 三因子或五因子作为右侧变量,对策略超额收益做时序回归:
R_p,t - R_f = α + β_MKT · MKT_t + β_SMB · SMB_t + β_HML · HML_t
+ β_MOM · MOM_t + β_QMJ · QMJ_t + ε_t
回归得到的:α 是无法被这些因子解释的「真
alpha」;β_i × E[F_i]
是各个因子贡献的年化收益;残差 ε
的方差是策略的「特异风险」。
def factor_attribution(strategy_excess_returns: pd.Series,
factor_returns: pd.DataFrame,
spec: FreqSpec) -> dict:
"""factor_returns 的列是各因子日收益(已减去 R_f,例如 Fama-French CSV 直接读取)。"""
df = pd.concat([strategy_excess_returns, factor_returns], axis=1).dropna()
y = df.iloc[:, 0]
X = sm.add_constant(df.iloc[:, 1:])
model = sm.OLS(y, X).fit()
contrib = {col: float(model.params[col] * df[col].mean() * spec.periods_per_year)
for col in factor_returns.columns}
contrib['alpha'] = float(model.params['const'] * spec.periods_per_year)
contrib['residual_std_annual'] = float(model.resid.std(ddof=1) * np.sqrt(spec.periods_per_year))
contrib['t_alpha'] = float(model.tvalues['const'])
contrib['r_squared'] = float(model.rsquared)
return contrib关键解读:
- 如果
t_alpha不显著(|t| < 2),策略就是几个已知因子的线性组合,没有真 alpha,机构不会付主动管理费。 - 如果 R² 很高(>0.9)但 α 显著,策略在已知因子之外还有边际增量。
- 如果 R² 很低(<0.3),策略和这些因子相关性弱,要么是真正独立的 alpha,要么是因子模型选得不对。
七点二、品种归因
品种归因把组合 PnL 按合约 / 行业 / 地区 / 资产类别拆开,看每一桶贡献了多少收益和多少风险。基本恒等式:
PnL_total = Σ_i PnL_i = Σ_i (w_i × R_i)
risk_total² ≈ Σ_i Σ_j w_i w_j Cov(R_i, R_j)
工程实现:
def bucket_attribution(positions: pd.DataFrame,
returns: pd.DataFrame,
bucket_map: dict[str, str]) -> pd.DataFrame:
"""positions: 行索引 date,列为标的,值为权重;
returns: 同形状的标的日收益;
bucket_map: 标的到桶的映射(如行业、品种类别)。
返回每个桶的累计 PnL、年化收益、波动、Sharpe。"""
pnl_per_asset = positions.shift(1) * returns # T-1 持仓获取 T 收益
asset_to_bucket = pd.Series(bucket_map)
pnl_long = pnl_per_asset.stack().rename('pnl').reset_index()
pnl_long.columns = ['date', 'asset', 'pnl']
pnl_long['bucket'] = pnl_long['asset'].map(asset_to_bucket)
grouped = pnl_long.groupby(['date', 'bucket'])['pnl'].sum().unstack().fillna(0.0)
summary = pd.DataFrame({
'cum_pnl': grouped.sum(),
'annual_return': grouped.mean() * 252,
'annual_vol': grouped.std(ddof=1) * np.sqrt(252),
})
summary['sharpe'] = summary['annual_return'] / summary['annual_vol']
return summary.sort_values('cum_pnl', ascending=False)实务上品种归因要回答:
- 是不是少数品种贡献了大部分 PnL:如果前 3 个品种贡献了 80% 的 PnL,组合是「集中赌注」,不是「分散 alpha」。
- 是不是某些品种长期亏钱:尾部桶的 Sharpe 应当被拿出来 review,长期负贡献的桶要重新检查策略逻辑或剔除。
七点三、时段归因
时段归因把 PnL 按时间维度切片:交易时段(开盘 30 分钟、午盘、收盘 30 分钟)、星期几、月份、宏观状态(牛市 / 熊市 / 震荡)等。这是发现「策略只在某些时段赚钱」的最直接工具。
def time_bucket_attribution(returns: pd.Series, bucketer) -> pd.DataFrame:
"""bucketer: callable(pd.Timestamp) -> str,例如按月份、按星期。"""
df = pd.DataFrame({'r': returns.dropna()})
df['bucket'] = df.index.map(bucketer)
summary = df.groupby('bucket')['r'].agg(['count', 'mean', 'std']).rename(
columns={'mean': 'mean_per_period', 'std': 'std_per_period'})
summary['annual_return'] = summary['mean_per_period'] * 252
summary['annual_vol'] = summary['std_per_period'] * np.sqrt(252)
summary['sharpe'] = summary['annual_return'] / summary['annual_vol']
return summary把 bucketer 写成
lambda t: t.month_name() 就是月度归因,写成
lambda t: 'bull' if regime[t] == 1 else 'bear'
就是宏观状态归因。两个常见信号:
- 某一两个月份贡献远超其他:策略可能踩中了一个季节性事件(财报季、月底再平衡等),需要核实是否过拟合于这个事件。
- 熊市 Sharpe 显著低于牛市 Sharpe:策略对市场状态敏感,要么纳入 regime 切换、要么标注「非全天候策略」。
七点四、Brinson 归因
对带基准的组合(指数增强、行业中性 long-short),Brinson 归因(Brinson-Hood-Beebower 1986、Brinson-Fachler 1985)是行业标准。它把超额收益分解成三块:
- 资产配置(allocation):策略相对基准的桶权重偏离 × 基准桶收益。
- 个股选择(selection):策略和基准相同桶的内部,选股带来的超额收益 × 桶权重。
- 交互项(interaction):选股和配置的交叉项。
公式:
Allocation_i = (w_p,i - w_b,i) × (R_b,i - R_b)
Selection_i = w_b,i × (R_p,i - R_b,i)
Interaction_i = (w_p,i - w_b,i) × (R_p,i - R_b,i)
Total_i = Allocation_i + Selection_i + Interaction_i
Brinson 归因的工程实现需要 PIT 的指数权重
w_b,i 和指数桶收益
R_b,i,因此对数据基础设施有较高要求。机构内部一般直接用
BARRA、Axioma、Bloomberg PORT
等平台的归因模块,自研的成本不低。
八、机构常用报表与口径
绩效指标做完,最后一步是「按什么口径对外汇报」。机构里这一层的规范叫 GIPS(Global Investment Performance Standards),由 CFA Institute 维护。把这一节作为收尾,是因为前七节解决的是「自己看的指标」,这一节解决的是「对客户、监管、托管行汇报的指标」。
八点一、GIPS 的核心要求
GIPS 是一份对资产管理人「业绩计算与披露」的全球标准,最新版本是 GIPS 2020。它要求:
- 全部可比组合纳入 composite:管理人不能只挑漂亮组合对外披露,必须把所有可比策略组合并到同一个 composite 里取平均。
- TWR 为基础:业绩计算口径采用时间加权收益率(time-weighted return,TWR),消除资金流入流出的影响。
- 披露至少 5 年(或自成立起)业绩,每年年报和年度回顾都要披露。
- 公允估值:底层资产按市值、可观察输入估值,估值层级(Level 1/2/3)要披露。
- 第三方核验:合规(GIPS-compliant)需要独立第三方机构核查 composite 构建和计算过程。
GIPS 不是法规,是行业自律规则;但绝大多数机构投资者尽调时会要求合规。
八点二、TWR 与 MWR
时间加权收益率(TWR)和资金加权收益率(money-weighted return,MWR)是两个不同的口径,回答的不是同一个问题。
TWR:把整个样本期切成若干个「无外部资金流」的子区间,每个子区间算一个简单收益率,再几何串联起来。公式:
TWR = ∏_i (1 + r_i) - 1, 其中 r_i = (V_end_i - V_begin_i) / V_begin_i
TWR 的精神是「把基金经理的能力和投资人申赎的时机拆开」,因为外部资金流入流出不是基金经理决定的,不应该被算在他的业绩里。GIPS、晨星(Morningstar)、Lipper 用的都是 TWR。
MWR(也叫 IRR,internal rate of return):把所有现金流(外部申购、赎回)和最终净值放在一起,求让 NPV = 0 的折现率。MWR 反映的是「投资人实际拿到的收益率」,包含了申赎时机的影响。私募股权(PE)、风险投资(VC)、长期持有产品常用 MWR。
工程上:
def twr(nav_series_by_subperiod: list[pd.Series]) -> float:
"""nav_series_by_subperiod: 每段无现金流的 NAV 序列。"""
rs = []
for nav in nav_series_by_subperiod:
nav = nav.dropna()
if len(nav) < 2:
continue
rs.append(nav.iloc[-1] / nav.iloc[0] - 1.0)
return float(np.prod([1 + r for r in rs]) - 1)
def mwr(cashflows: pd.Series, ending_value: float) -> float:
"""cashflows: 时间序列,正值=投资人净流入(基金视角),负值=赎回。
ending_value: 期末 NAV。返回年化 IRR。"""
from scipy.optimize import brentq
flows = cashflows.copy()
flows = flows.append(pd.Series([ending_value], index=[flows.index.max() + pd.Timedelta(days=0)]))
times = (flows.index - flows.index[0]).days.values / 365.0
def npv(rate):
return np.sum(flows.values * (1 + rate) ** -times)
return float(brentq(npv, -0.999, 10.0))实际报表中要写清楚是 TWR 还是 MWR,否则一个混合报表会让前后口径不可比。
八点三、Composite 与样本扩展
GIPS 要求 composite 按「策略类似」聚合所有可投资组合。工程上要做的事情:
- 定义 composite 边界:策略名 + 投资范围 + 杠杆约束 + 客户类型,写清楚什么样的组合属于这个 composite。
- 入池规则:新组合从第几个完整月起纳入 composite。
- 离池规则:组合关闭后从哪个月起从 composite 移出。
- 加权方式:等权 vs AUM 加权。GIPS 推荐按月初 AUM 加权。
- 离散度(dispersion):composite 内各组合年度收益的标准差,反映 composite 内组合表现的一致性。
def composite_aum_weighted(monthly_returns: pd.DataFrame,
monthly_aum: pd.DataFrame) -> pd.Series:
"""monthly_returns 与 monthly_aum 同形状(行 month、列 portfolio)。"""
weights = monthly_aum.shift(1).div(monthly_aum.shift(1).sum(axis=1), axis=0)
composite = (monthly_returns * weights).sum(axis=1, min_count=1)
return composite八点四、披露最小集合
合规的 GIPS 报表至少要包含以下字段,按年度披露:
| 字段 | 含义 |
|---|---|
| Year | 年度 |
| Composite Gross Return | 扣手续费前年度 TWR |
| Composite Net Return | 扣手续费后年度 TWR |
| Benchmark Return | 基准年度收益 |
| Composite 3-Yr Std Dev | 复合体过去 36 个月年化波动 |
| Benchmark 3-Yr Std Dev | 基准过去 36 个月年化波动 |
| Number of Portfolios | composite 内组合数 |
| Composite Assets | 复合体期末 AUM |
| Total Firm Assets | 公司期末总 AUM |
| Dispersion | 离散度 |
国内监管(中基协)对私募的业绩披露规则与 GIPS 不完全一致,但基本精神相通:禁止挑库(cherry-picking)、禁止预测收益、所有展示业绩需经过独立估值。具体细节查阅《私募投资基金信息披露管理办法》。
八点五、对外报表口径与对内研究口径的差异
最后强调一件事:对外报表口径和对内研究口径不要混用。
- 对内研究:评估策略时用算术年化、ddof=1、不扣管理费、零无风险利率、Sharpe + Sortino + Calmar + PSR + DSR 全套。
- 对外披露:用 GIPS 规定的 TWR、扣管理费 net、披露无风险利率口径、Composite 加权、3-Yr Std Dev。
混用的常见错误:研究团队报「年化 Sharpe 2.4」,营销团队当成「净值数字」对外宣传,结果客户拿到的实盘业绩远低于这个数(因为没扣费、没 GIPS 合规、没考虑申赎影响)。一个稳健做法是:研究、风控、营销三方共用一份 metrics 口径表,每个指标都有「研究 / 披露 / 营销」三列,规定能用在哪个场景。
九、把工具箱串起来:一个完整示例
下面这段代码把前八节的核心函数串起来,作为完整工具箱的展示。输入是任意一条日频策略收益序列(可加基准),输出是一份覆盖评估、比较、监控、归因、合规口径的报表。
def full_performance_report(strategy_returns: pd.Series,
spec: FreqSpec,
benchmark_returns: pd.Series | None = None,
risk_free: float = 0.0,
n_trials: int = 1,
sr_trials_var: float = 0.0) -> dict:
nav = returns_to_nav(strategy_returns, spec)
mdd, peak_t, trough_t = max_drawdown(nav)
report = {
# 收益
'cagr': cagr(strategy_returns, spec),
'arith_annual_return': arithmetic_annual_return(strategy_returns, spec),
'annual_vol': annual_volatility(strategy_returns, spec),
# Sharpe 家族
'sharpe': sharpe_ratio(strategy_returns, spec, risk_free=risk_free),
'sortino': sortino_ratio(strategy_returns, spec, mar=risk_free),
'calmar': calmar_ratio(strategy_returns, spec),
'omega_zero': omega_ratio(strategy_returns, spec, threshold=0.0),
# 回撤
'max_drawdown': mdd,
'max_dd_peak': peak_t,
'max_dd_trough': trough_t,
'pain_index': pain_index(nav),
'ulcer_index': ulcer_index(nav),
# 稳健 Sharpe
'sharpe_newey_west': sharpe_newey_west(strategy_returns, spec, risk_free=risk_free),
'psr_zero': probabilistic_sharpe_ratio(strategy_returns, spec,
sr_benchmark=0.0, risk_free=risk_free),
}
if n_trials >= 2 and sr_trials_var > 0:
report['dsr'] = deflated_sharpe_ratio(strategy_returns, spec,
n_trials=n_trials,
sr_trials_var=sr_trials_var,
risk_free=risk_free)
point, lo, hi = sharpe_bootstrap_ci(strategy_returns, spec, risk_free=risk_free)
report['sharpe_bootstrap_point'] = point
report['sharpe_bootstrap_ci_low'] = lo
report['sharpe_bootstrap_ci_high'] = hi
if benchmark_returns is not None:
ir, alpha, te = information_ratio(strategy_returns, benchmark_returns, spec)
report['information_ratio'] = ir
report['alpha_annual'] = alpha
report['tracking_error_annual'] = te
ab = alpha_beta(strategy_returns, benchmark_returns, spec, risk_free=risk_free)
report.update({f'reg_{k}': v for k, v in ab.items()})
return report调用方式:
spec = FreqSpec(periods_per_year=252.0, is_log=False)
report = full_performance_report(strategy_returns=daily_returns,
spec=spec,
benchmark_returns=benchmark_daily,
risk_free=0.02,
n_trials=200,
sr_trials_var=0.6 ** 2)
for k, v in report.items():
print(f'{k:32s}: {v}')实际生产里这个函数会被包装成 service:每天 EOD(end-of-day)跑一次,结果写进数据库;前端仪表盘按需查询;告警系统订阅其中关键字段(current drawdown、rolling Sharpe、PSR)做阈值告警。
十、常见错误与自检清单
最后一节是工程「上线前」自检清单。每条错误后面给出自检方法,落到代码里要么是单元测试、要么是断言(assertion)。
十点一、口径错误
- 频率不匹配:日频收益用 √12
年化,月频收益用 √252 年化。自检:把
periods_per_year写成 dataclass 字段,每次调用都强制传入。 - 无风险利率没换频:年化 R_f
直接减日频收益。自检:在
sharpe_ratio()内部除以periods_per_year,外部调用方只关心年化值。 - Sortino 分母只对负样本求 std:导致 Sortino 虚高。自检:单元测试用合成分布(已知 skew)核对值。
- Calmar 用全样本 max drawdown:让分母被很久以前的事件主导。自检:约定使用过去 36 个月或 60 个月窗口。
十点二、统计推断错误
- 裸 Sharpe 没做 NW 修正:均线策略 /
趋势策略 Sharpe 高估 30% 以上。自检:默认输出
sharpe_naive和sharpe_newey_west两列,差异超过 20% 时标红。 - PSR 没考虑高阶矩:负偏厚尾策略 Sharpe 虚高。自检:PSR 计算时打印 skew、excess kurt,配合 Sharpe 一起看。
- DSR 没传
n_trials:忽略多重检验。自检:研究平台日志记录每个研究员近
12 个月的回测次数,自动注入
n_trials。 - Bootstrap 块长度太短:自相关被 i.i.d.
bootstrap 破坏。自检:默认
block_size = ceil(n^(1/3)),并允许覆盖。
十点三、回撤分析错误
- 回撤定义用区间收益:回撤是相对于历史峰值,不是相对起点。自检:用合成数据(已知峰谷)测试
max_drawdown()输出。 - 未恢复的回撤段没标注:报告里漏掉「现在还在水下」的事实。自检:
drawdown_episodes()中将未恢复段的recovery设为 NaT,在汇总报表中显式列出。 - Pain / Ulcer Index
用错样本:只对水下样本求均值,而不是全样本。自检:分母用
len(nav)而不是(underwater < 0).sum()。
十点四、归因错误
- 多因子归因 R² 解释为 alpha 显著:R² 高不等于策略好,可能只是把因子复制了一遍。自检:α 的 t 值是判断标准,R² 仅作辅助。
- 品种归因用今日权重回算历史:lookahead。自检:使用 PIT 的权重 + lag 1 的持仓。
- 时段归因把多个时段合并:丢失信息。自检:把月份、星期、宏观状态分别报一份,不合并。
十点五、合规口径错误
- TWR 与 MWR 混用:对外披露使用
MWR、对内研究使用
TWR。自检:两个函数命名严格区分(
twr()、mwr()),不允许互相替代。 - Composite cherry-picking:只挑表现好的组合纳入。自检:自动化 composite 构建,研究员不能手动剔除。
- 业绩数据 lookahead:TWR 计算时使用了 t+1 的估值。自检:估值时间戳必须 ≤ 业绩日。
把这些点写成 pytest 套件,绑在 CI 上,每次回测代码改动都跑一遍。绩效指标的工程化,本质上就是把「能想到的所有口径分歧、所有偏差源」全部写成可执行的检查项,任何一条不合格都不允许进入对外报表。
十一、写在最后
绩效指标这件事的核心冲突是:简单一个数 vs 复杂的真实。客户要的是「这个策略 Sharpe 多少」,研究员知道单一 Sharpe 撑不起结论;监管要的是 GIPS 标准化披露,研究员知道标准化口径会丢掉 80% 的信息;营销要的是漂亮数字,风控要的是稳健下界。每一个角色都在拉绩效指标往一个方向走,谁都不算错,但合到一起就是「同一份净值,五个数字、五个故事」。
这一篇没法解决这个冲突,但能给出一个工程化的折中:口径规范化 + 多指标并列 + 显式标注假设。任何对外汇报的 Sharpe 都要标注「频率、年化倍数、无风险利率、是否 NW 修正、N_trials」;任何对内评估都要至少同时跑 Sharpe、Sortino、Calmar、PSR、最大回撤五个;任何监控仪表盘都要把 rolling 版本和 underwater curve 摆在最显眼位置。把这些写到 metrics 服务里,沉淀到 CI 里,绩效指标这一层才算真正可用。
下一篇《执行算法》会回到执行成本这一块,把 implementation shortfall、VWAP slippage、market impact 等真实成本项落到 PnL 上,让前面这套绩效指标在「净值已经扣过执行成本」这个起点上是可信的。否则,再漂亮的 Sharpe 也只是「在不存在的市场里」的 Sharpe。
参考文献
- Sharpe, W. F. (1966). Mutual Fund Performance. Journal of Business, 39(1), 119-138.
- Sharpe, W. F. (1994). The Sharpe Ratio. Journal of Portfolio Management, 21(1), 49-58.
- Sortino, F. A., & Price, L. N. (1994). Performance Measurement in a Downside Risk Framework. Journal of Investing, 3(3), 59-64.
- Young, T. W. (1991). Calmar Ratio: A Smoother Tool. Futures, 20(1), 40.
- Keating, C., & Shadwick, W. F. (2002). A Universal Performance Measure. Journal of Performance Measurement, 6(3), 59-84.
- Lo, A. W. (2002). The Statistics of Sharpe Ratios. Financial Analysts Journal, 58(4), 36-52.
- Bailey, D. H., & López de Prado, M. (2012). The Sharpe Ratio Efficient Frontier. Journal of Risk, 15(2), 3-44.
- Bailey, D. H., & López de Prado, M. (2014). The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting, and Non-Normality. Journal of Portfolio Management, 40(5), 94-107.
- Newey, W. K., & West, K. D. (1987). A Simple, Positive Semi-Definite, Heteroskedasticity and Autocorrelation Consistent Covariance Matrix. Econometrica, 55(3), 703-708.
- Politis, D. N., & Romano, J. P. (1994). The Stationary Bootstrap. Journal of the American Statistical Association, 89(428), 1303-1313.
- Brinson, G. P., Hood, L. R., & Beebower, G. L. (1986). Determinants of Portfolio Performance. Financial Analysts Journal, 42(4), 39-44.
- Brinson, G. P., & Fachler, N. (1985). Measuring Non-US Equity Portfolio Performance. Journal of Portfolio Management, 11(3), 73-76.
- Grinold, R. C., & Kahn, R. N. (1999). Active Portfolio Management (2nd ed.). McGraw-Hill.
- CFA Institute. (2020). Global Investment Performance Standards (GIPS) 2020. https://www.cfainstitute.org/en/ethics-standards/codes/gips-standards
- Bacon, C. R. (2008). Practical Portfolio Performance Measurement and Attribution (2nd ed.). Wiley.
- López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley.
- Harvey, C. R., & Liu, Y. (2015). Backtesting. Journal of Portfolio Management, 42(1), 13-28.
- Statsmodels Documentation. OLS and Newey-West HAC. https://www.statsmodels.org/stable/generated/statsmodels.regression.linear_model.OLS.html
系列导航
- 上一篇:Walk-forward 与交叉验证
- 下一篇:执行算法
- 系列首页:量化交易工程
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【量化交易】头寸管理:Kelly、波动率目标、风险预算
信号有了、组合权重也求出来了,最后一公里是头寸管理:到底下多大、用多少杠杆、回撤多深时降仓。本文从 Kelly 公式、波动率目标、风险预算、回撤管理、资金与杠杆约束、心理偏差、再平衡工程七个方向展开,给出可运行的 Python 实现与上线 checklist。
【量化交易】量化交易全景:从信号到订单的工程链路
量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。
【量化交易】市场结构:交易所、做市商、暗池、ECN
系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。
【量化交易】市场微结构:订单簿、价差、流动性、冲击
系统讲解市场微结构的核心概念与可计算工具:限价订单簿的数据模型、报价/有效/已实现价差、Roll 模型、四维流动性度量、Kyle's lambda、订单流不平衡(OFI)、Almgren-Chriss 框架下的临时与永久冲击、PIN 与 VPIN、Hawkes 过程,并给出基于 polars 的 L2 增量处理与系数估计代码。