量化交易中文教材

第 06 章 回测方法论

回测是对"如果过去按这套规则交易会怎样"的模拟。它不是证明,而是一次带偏差的实验:几乎所有常见错误都让结果偏乐观。本章讲怎样搭一个可信的回测——时间对齐、成本建模、样本外验证、过拟合校正、业绩指标的误差——并用可运行的代码把每一种偏差的大小量出来。多重检验与 Deflated Sharpe 的统计原理见第 03 册第 10b 章,时间序列交叉验证的原理见第 03 册第 22b 章 22b.7 节,交易成本的度量见第 07 册第 21a、21b 章,本章只讲把它们装进回测框架的工程做法。

学习目标

读完本章,你应当能够:

  1. 说明向量化回测和事件驱动回测各自的适用范围,能写出两种实现并核对它们的一致性。
  2. 识别前视偏差的常见形态(成交价假设、漏掉滞后、全样本标准化、未来数据修订、幸存者偏差),并能量出每种偏差把 Sharpe 抬高了多少。
  3. 在回测中加入价差、佣金和平方根冲击成本,画出容量曲线,估计策略能承载的资金规模。
  4. 在标签重叠、特征持续的数据上使用 purged/embargo 交叉验证和走步验证,理解随机 K 折为什么会严重高估预测力。
  5. 计算参数寻优后的 PSR、Deflated Sharpe 和回测过拟合概率(PBO),并知道它们各自回答什么问题、不能回答什么问题。
  6. 给 Sharpe、最大回撤、Calmar 比率配上置信区间,识别收益自相关造成的 Sharpe 高估。

读前导读

这一章在解决什么问题。 把回测想成投资委员会审查一份新策略提案。研究员拿来一条漂亮的净值曲线,委员会要问的其实是四个问题:这条曲线用的数据,当时真的拿得到吗(前视偏差)?成交价和成本是不是想得太便宜(成本与容量)?这是不是试了上百次之后挑出来的最好一次(过拟合与选择偏差)?就算都没问题,10 年的数据能把 Sharpe 估得多准(统计误差)?本章就是把这四个问题变成可执行的检查和可运行的代码。你在 CFA 里学过的 GIPS 业绩呈现、幸存者偏差、数据挖掘偏差、Sharpe 比率,这里都会出现,区别是本章会把每种偏差"量出来":漏掉一天滞后能让一个毫无预测力的信号显示出 5 以上的 Sharpe。

和 CPA 的审计思路也很像:审计不只看报表结果,还要看产生结果的流程是否可靠、有没有留痕。本章反复强调的"事件驱动回测的日志""记录所有试验""封存最终样本外",就是给研究流程留审计轨迹。

需要先想起来的数学。

  • 标准误与 \(t\) 值。 一个估计量的标准误是它在不同样本之间波动的标准差。\(t=\text{估计值}/\text{标准误}\),\(|t|>2\) 大致对应 5% 显著。本章的关键事实:年化 Sharpe 的标准误约为 \(1/\sqrt{\text{年数}}\)。例:10 年数据,标准误约 \(1/\sqrt{10}\approx0.32\);Sharpe 0.5 的策略 \(t\approx0.5\times\sqrt{10}\approx1.6\),还不显著。参见 第 00 册第 07 章 概率中的分析工具。
  • 正态分布的分位数函数 \(\Phi^{-1}\)。 \(\Phi(z)\) 是标准正态累积分布函数,\(\Phi^{-1}(p)\) 是它的反函数:给定概率 \(p\),求对应的 \(z\)。例:\(\Phi^{-1}(0.975)=1.96\),\(\Phi^{-1}(0.95)=1.645\)。Deflated Sharpe 公式里会用它估计"一堆噪声里最大值会有多大"。
  • 自相关 \(\rho_k\)。 收益序列与它自身滞后 \(k\) 期的相关系数。\(\rho_1>0\) 表示今天涨明天倾向于也涨。若日收益正自相关,\(n\) 日累计收益的方差大于 \(n\) 倍日方差,于是用 \(\sqrt{252}\) 年化会低估年波动。例:\(\rho_1=0.2\)、其余为 0 时,两日收益方差是 \(2\sigma^2(1+0.2)=2.4\sigma^2\),而不是 \(2\sigma^2\)。
  • AR(1) 过程与半衰期。 \(x_t=\varphi x_{t-1}+\varepsilon_t\),\(\varphi\) 越接近 1,信号越"慢"、越持久。冲击衰减到一半所需的期数是 \(\ln0.5/\ln\varphi\)。例:\(\varphi=0.9\) 时约 6.6 天,\(\varphi=0.2\) 时约 0.43 天。
  • 组合数 \(\binom{S}{S/2}\)。 从 \(S\) 个块里选一半的方法数。例:\(S=16\) 时为 12870,PBO 就是在这么多种切分上做统计。参见 第 00 册第 08 章 读懂数学证明与符号。

怎么读这一章。 6.1 节的四类偏差表是全章地图,先看。6.3 节(前视偏差)和 6.6 节(过拟合与 Deflated Sharpe)是核心必读,结论直接影响你怎么看任何一份回测报告。6.4 节的容量曲线对资产管理从业者很实用,建议细读输出表格。6.2 节如果你不写代码,可以只读 6.2.1 和代码之后的"设计要点"。6.5 节的 purged K 折偏机器学习,第一次可以只记住结论:"随机打乱的交叉验证在金融时间序列上会严重高估预测力"。6.7 节建议至少读完代码 6.6 之后的四条解读。代码可以先跳过,直接看"输出"和逐项解读,回头再对照代码。


6.1 问题与动机

先说结论:一个回测的可信度,取决于它在多大程度上复现了真实交易时的信息集、成交条件和研究过程。 信息集错了,就是前视偏差;成交条件错了,就是成本和容量被低估;研究过程没有记录,就无法校正多重检验。三者都让回测偏乐观,而且会叠加。

回测的偏差可以按来源分成四类:

来源 典型形式 后果 本章位置
信息集 用到决策时还不知道的数据 收益虚高,有时虚高数倍 6.3 节
成交 按收盘价或中间价成交、忽略冲击、忽略涨跌停与停牌 净收益和容量虚高 6.2、6.4 节
样本 幸存者偏差、只用一段特殊行情 结论不可推广 6.3 节、本册第 02 章
研究过程 反复调参、只报告最好的结果 选择偏差 6.5、6.6 节

本章的合成数据(代码 6.0)是一个单一资产的日线:有开盘价和收盘价,收益分成隔夜和日内两段;波动服从 GARCH(1,1),冲击是 \(t(5)\) 分布;一个在收盘后可见的 AR(1) 信号 \(x_t\) 对次日收益有很弱的预测力,可预测部分主要落在隔夜收益上。它和真实数据的差别:真实市场的可预测性会随时间消失和变化,有跳空、涨跌停、停牌、交易日历和分红送配;这里的数据生成过程是固定的,所以我们可以重复生成新路径,得到真正的"样本外"。模拟中的收益数字不代表任何真实策略。

# 代码 6.0  本章公共函数:带开盘价的合成日线(GARCH + t 厚尾 + 弱可预测性)
import numpy as np
import pandas as pd

def make_daily(T=2520, seed=0, beta_on=0.0003, beta_id=0.0001, phi=0.9):
    """单资产日线。信号 x_t 在 t 日收盘后可见,预测 t+1 日收益:
    隔夜收益(收盘->次日开盘)承担大部分可预测性 beta_on,日内收益(开盘->收盘)承担 beta_id。
    波动:GARCH(1,1),冲击为单位方差 t(5)。返回 DataFrame[open, close, x, ret_on, ret_id, ret]。"""
    rng = np.random.default_rng(seed)
    tz = lambda n=None: rng.standard_t(5, n) / np.sqrt(5 / 3)
    x = np.zeros(T)
    for t in range(1, T):
        x[t] = phi * x[t - 1] + np.sqrt(1 - phi**2) * rng.standard_normal()
    omega, a, b = 2e-6, 0.08, 0.90
    h = omega / (1 - a - b)
    r_on, r_id = np.zeros(T), np.zeros(T)
    for t in range(1, T):
        e_on = np.sqrt(0.16 * h) * tz()                  # 隔夜冲击:方差占 16%
        e_id = np.sqrt(0.84 * h) * tz()                  # 日内冲击:方差占 84%
        h = omega + a * (e_on + e_id)**2 + b * h
        r_on[t] = beta_on * x[t - 1] + e_on
        r_id[t] = beta_id * x[t - 1] + e_id
    close = 100 * np.exp(np.cumsum(r_on + r_id))
    open_ = close / np.exp(r_id)
    idx = pd.bdate_range("2014-01-01", periods=T)
    df = pd.DataFrame(dict(open=open_, close=close, x=x, ret_on=r_on, ret_id=r_id), index=idx)
    df["ret"] = np.log(df.close).diff().fillna(0.0)    # 收盘到收盘对数收益
    return df

def sharpe(r, k=252):
    r = np.asarray(r); return r.mean() / r.std(ddof=1) * np.sqrt(k)

def max_dd(r):
    nav = np.exp(np.cumsum(r)); return (1 - nav / np.maximum.accumulate(nav)).max()

白话解释:代码 6.0 在"造一个假市场"。为什么不用真实数据?因为真实历史只有一条路径,你永远不知道策略的"真实" Sharpe 是多少;而在假市场里,规则是自己定的,可以无限次重新生成新的历史,拿来当作真正没见过的样本外。几个设计各有用意:GARCH(1,1)(条件方差 \(h_t=\omega+a\,\varepsilon_{t-1}^2+b\,h_{t-1}\),昨天冲击越大今天波动越大)让波动成团出现,像真实市场;\(t(5)\) 分布让尾部比正态更厚,除以 \(\sqrt{5/3}\) 是把它的方差缩放成 1(\(t(\nu)\) 的方差是 \(\nu/(\nu-2)\));信号 \(x\) 是 AR(1),np.sqrt(1 - phi**2) 保证它的长期方差恒为 1。收益拆成隔夜(占 16% 方差)和日内两段,是为了在 6.3 节检验"收盘成交还是次日开盘成交"这类假设到底差多少。max_dd 先把对数收益累加再取指数得到净值,再与历史最高点比。


6.2 向量化回测与事件驱动回测

6.2.1 两种架构

向量化回测把整个历史放进数组,一次算出所有日期的仓位和收益。核心就是一行:pos.shift(1) * ret。它快,适合信号研究阶段扫描大量参数和股票池;它的弱点是难以表达路径依赖的规则:订单部分成交、限价单、止损、整手、资金不足、保证金追加、涨跌停无法成交。

事件驱动回测按时间顺序逐个处理事件(行情、信号、订单、成交),维护现金、持仓和订单状态。它慢,但结构上与实盘系统一致:同一套策略代码可以接回测数据源,也可以接实盘行情和券商接口。这是它最大的价值——回测和实盘用同一套代码,就少了一类"回测和实盘对不上"的问题。

工程上的推荐做法是两者都要:用向量化做研究,用事件驱动做上线前的验证,并且核对两者的结果。核对不一致时,几乎总能找到一个 bug 或一个隐含假设。

6.2.2 代码:同一策略的两种实现

策略:每天收盘后看信号 \(x_t\),目标权重 \(w_t=\mathrm{clip}(x_t,-1,1)\),次日开盘按市价成交;半价差 5bp 以滑点形式体现,佣金 1bp。事件驱动版本额外处理了整手(100 股)和"按收盘价计算目标股数、按开盘价成交"带来的权重偏差。

# 代码 6.1  向量化回测与事件驱动回测:同一策略、两种实现(需先运行代码 6.0)
from dataclasses import dataclass

df = make_daily(seed=1)
w_tgt = np.clip(df.x.values, -1, 1)          # t 日收盘后算出的目标权重
half_spread, commission = 5e-4, 1e-4         # 半价差 5bp(以滑点形式体现),佣金 1bp

# ---------- 向量化:t 日信号在 t+1 日开盘成交 ----------
r_on = (df.open / df.close.shift(1) - 1).fillna(0).values   # 隔夜简单收益
r_id = (df.close / df.open - 1).values                       # 日内简单收益
pos = np.r_[0, w_tgt[:-1]]                   # 第 t 日开盘后持有的权重 = 第 t-1 日的目标
pos_prev = np.r_[0, pos[:-1]]                # 隔夜持有的是前一日开盘后的仓位
cost = (half_spread + commission) * np.abs(pos - pos_prev)
r_vec = pos_prev * r_on + pos * r_id - cost
nav_vec = np.cumprod(1 + r_vec)

# ---------- 事件驱动:订单、成交、现金、持股逐日记账 ----------
@dataclass
class Order:
    qty: int                                 # 正为买,负为卖

class Broker:
    """开盘撮合上一交易日收盘后提交的市价单:成交价 = 开盘价 ± 半价差,另收佣金。"""
    def fill(self, order, open_px):
        px = open_px * (1 + np.sign(order.qty) * half_spread)
        fee = abs(order.qty) * px * commission
        return px, fee

class Portfolio:
    def __init__(self, cash=1e7):
        self.cash, self.shares, self.history = cash, 0, []
    def nav(self, px):
        return self.cash + self.shares * px

broker, pf, pending = Broker(), Portfolio(), None
for t, (day, row) in enumerate(df.iterrows()):
    if pending is not None and pending.qty != 0:          # 1) 开盘:撮合昨晚的订单
        px, fee = broker.fill(pending, row.open)
        pf.cash -= pending.qty * px + fee
        pf.shares += pending.qty
    pending = None
    nav = pf.nav(row.close)                                # 2) 收盘:盯市
    pf.history.append(nav)
    target_sh = int(round(w_tgt[t] * nav / row.close / 100)) * 100   # 3) 收盘后:按整手生成订单
    pending = Order(target_sh - pf.shares)
nav_evt = np.array(pf.history) / 1e7

print(f"向量化   年化收益 {np.log(nav_vec[-1]) / len(df) * 252:.4f}  Sharpe {sharpe(r_vec):.3f}")
r_evt = np.diff(nav_evt, prepend=1.0) / np.r_[1.0, nav_evt[:-1]]
print(f"事件驱动 年化收益 {np.log(nav_evt[-1]) / len(df) * 252:.4f}  Sharpe {sharpe(r_evt):.3f}")
print(f"两者日收益相关系数 {np.corrcoef(r_vec[1:], r_evt[1:])[0, 1]:.5f},"
      f"日收益差的标准差 {np.std(r_vec - r_evt) * 1e4:.2f}bp")

输出:

向量化   年化收益 0.0281  Sharpe 0.291
事件驱动 年化收益 0.0277  Sharpe 0.287
两者日收益相关系数 0.99994,日收益差的标准差 0.81bp

两种实现的日收益相关系数是 0.99994,日收益差的标准差不到 1bp,年化收益差 4bp。剩下的差别有明确来源:事件驱动版本按收盘时的净值和收盘价算目标股数,隔夜价格变动后开盘成交时的实际权重会偏离 \(w_t\);整手取整也带来微小误差。如果两种实现的差别远大于这个量级,就说明某一边有错。

推导拆解:向量化版本里最容易看糊涂的是 pos 和 pos_prev 两个数组,把一天拆成两段就清楚了。 第一步,时间线:\(t-1\) 日收盘后算出目标 \(w_{t-1}\) → \(t\) 日开盘按市价成交 → \(t\) 日盘中持有新仓位 → \(t\) 日收盘再算 \(w_t\)。 第二步,所以 \(t\) 日"开盘后"持有的是 \(w_{t-1}\),这就是 pos[t] = w_tgt[t-1](np.r_[0, w_tgt[:-1]] 就是整体后移一格)。 第三步,\(t\) 日的隔夜段(\(t-1\) 日收盘到 \(t\) 日开盘)发生在成交之前,持有的还是上一个开盘建立的仓位,即 pos[t-1],这就是 pos_prev。 第四步,当日收益 \(=\) 隔夜仓位 × 隔夜收益 \(+\) 日内仓位 × 日内收益 \(-\) 成本,成本按开盘那一刻的换手 \(\lvert pos_t-pos_{t-1}\rvert\) 计。 这样拆的好处是:信号用到的信息(\(t-1\) 日收盘)严格早于它第一次赚钱的时刻(\(t\) 日开盘之后),时间对齐一目了然。事件驱动版本用"先撮合、再盯市、后下单"的循环顺序实现同一件事,两者结果一致,等于互相做了交叉核对,类似会计里用两种独立方法算同一个科目余额再对账。

事件驱动框架里的几个设计要点:

  • 严格的事件顺序。 每个交易日先撮合(开盘),再盯市(收盘),最后生成新订单。任何先生成订单、再用同一时刻价格撮合的写法都是前视。
  • 撮合器和策略分离。 策略只能提交订单,不能决定成交价。成交价、成交量、费用由撮合器按规则决定,这样换成本模型时不用改策略。
  • 用整数记账。 持股用整数,现金用定点数或至少检查舍入误差的累计。
  • 日志即审计。 每笔订单、成交、拒单都要记录。回测与实盘对账时,靠的就是这些日志。
  • 市场规则。 A 股的 T+1、涨跌停时无法买入(涨停)或卖出(跌停)、停牌、最小申报单位,期货的保证金和展期(本册第 09 章),都要在撮合器里实现。忽略涨跌停的 A 股回测,在小盘股和事件驱动策略上可能严重高估收益。

6.3 时间对齐与前视偏差

6.3.1 每个数据点都要有两个时间戳

前视偏差的根源是把"数据描述的时间"和"数据可以被知道的时间"混为一谈。一季度财报描述的是 3 月 31 日,但可能到 4 月底才公布,之后还可能被修订;指数成分在调整日生效,但调整名单提前公布;收盘价在收盘时刻才确定,基于收盘价的信号不可能在同一收盘价成交。所以每条数据都应当带两个时间戳:事件时间和可得时间(point-in-time,见本册第 02 章)。回测引擎只能读取可得时间早于决策时刻的数据。

6.3.2 代码:把偏差量出来

代码 6.2 演示四种常见形态:

  • A/B 成交价假设:信号用 \(t\) 日收盘的数据计算,回测却假设在 \(t\) 日收盘价成交(B),而现实中最早只能在 \(t+1\) 日开盘成交(A)。
  • C 忘记滞后:价格类信号与同期收益相乘,等于用今天的收益决定今天的仓位。
  • D 全样本标准化:用全样本的均值和标准差标准化价格,等于知道了价格将来会回到哪里。
# 代码 6.2  前视偏差的四种常见形态(需先运行代码 6.0)
def legs(df):
    r_on = (df.open / df.close.shift(1) - 1).fillna(0)
    return r_on, df.close / df.open - 1, df.close.pct_change().fillna(0)

def next_open(w, df):
    """t 日收盘后的目标权重 w_t,在 t+1 日开盘成交:正确的时间对齐。"""
    r_on, r_id, _ = legs(df)
    pos = w.shift(1).fillna(0)                     # t 日开盘后持仓
    return pos.shift(1).fillna(0) * r_on + pos * r_id

rows = {}
for phi in [0.9, 0.2]:                             # 慢信号与快信号
    d = make_daily(seed=1, phi=phi)
    w = d.x.clip(-1, 1)
    rows[f"A 信号持续性 φ={phi}:t+1 开盘成交(正确)"] = next_open(w, d)
    rows[f"B 信号持续性 φ={phi}:t 收盘成交(乐观)"] = w.shift(1).fillna(0) * legs(d)[2]
df = make_daily(seed=1)
r_cc = legs(df)[2]
# 价格类信号:过去 5 日收益的符号(在本模型中没有真实预测力)
mom = np.sign(np.log(df.close).diff(5)).fillna(0)
rows["C1 5日动量,正确滞后"] = next_open(mom, df)
rows["C2 5日动量,忘记 shift(用当日收益)"] = mom * r_cc
# 全样本标准化:价格相对"全样本均值"的偏离,做均值回复
z_full = (df.close - df.close.mean()) / df.close.std()
z_exp = (df.close - df.close.expanding(60).mean()) / df.close.expanding(60).std()
rows["D1 价格 z 分数(全样本均值,前视)"] = next_open((-z_full).clip(-1, 1), df)
rows["D2 价格 z 分数(扩展窗口,无前视)"] = next_open((-z_exp).clip(-1, 1).fillna(0), df)

res = pd.DataFrame({k: dict(年化收益=v.mean() * 252, Sharpe=sharpe(v)) for k, v in rows.items()}).T
print(res.round(3))

# D 的差距是不是运气?在 20 条完全没有可预测性的路径上重复
d1, d2 = [], []
for s in range(20):
    d = make_daily(seed=100 + s, beta_on=0, beta_id=0)
    zf = (d.close - d.close.mean()) / d.close.std()
    ze = (d.close - d.close.expanding(60).mean()) / d.close.expanding(60).std()
    d1.append(sharpe(next_open((-zf).clip(-1, 1), d)))
    d2.append(sharpe(next_open((-ze).clip(-1, 1).fillna(0), d)))
print(f"无信号世界,20 条路径平均 Sharpe:全样本 z {np.mean(d1):.2f}(20 条中 {np.sum(np.array(d1) > 0)} 条为正),"
      f"扩展窗口 z {np.mean(d2):.2f}")

输出:

                             年化收益  Sharpe
A 信号持续性 φ=0.9:t+1 开盘成交(正确)  0.074   0.600
B 信号持续性 φ=0.9:t 收盘成交(乐观)    0.073   0.600
A 信号持续性 φ=0.2:t+1 开盘成交(正确)  0.034   0.279
B 信号持续性 φ=0.2:t 收盘成交(乐观)    0.084   0.699
C1 5日动量,正确滞后               -0.028  -0.163
C2 5日动量,忘记 shift(用当日收益)     0.869   5.384
D1 价格 z 分数(全样本均值,前视)        0.071   0.553
D2 价格 z 分数(扩展窗口,无前视)        0.042   0.313
无信号世界,20 条路径平均 Sharpe:全样本 z 0.51(20 条中 20 条为正),扩展窗口 z 0.07

逐项解读:

  • 成交价假设的偏差取决于信号的持续性。 慢信号(\(\varphi=0.9\))错过一个隔夜几乎没有损失,A 和 B 的 Sharpe 都是 0.60;快信号(\(\varphi=0.2\))的预测力主要在第一个隔夜,B 的 Sharpe 0.70 是 A 的 0.28 的两倍多。也就是说,同一种回测简化,对低频策略无害,对高频策略是致命的。所以不能笼统地说"收盘成交可以接受",要用代码量一下自己的策略对成交时点有多敏感。
  • 忘记滞后造成的偏差最大:一个在本模型中毫无预测力的 5 日动量信号(正确滞后时 Sharpe −0.16),忘记 shift 后 Sharpe 变成 5.4。经验法则:日频策略回测的 Sharpe 超过 3,先假设有前视偏差,查完再说。
  • 全样本标准化的偏差隐蔽而稳定。单条路径上 0.55 对 0.31 的差别还可以说是运气;在 20 条完全没有可预测性的路径上,全样本版本 20 条全部为正、平均 Sharpe 0.51,扩展窗口版本平均 0.07。凡是用到全样本统计量的预处理——标准化、去极值的分位数、PCA 的载荷、协整回归的系数、模型的超参数——都要改成只用决策时刻之前的数据(扩展窗口或滚动窗口)。

金融直觉:三种前视各有一个熟悉的对应物。B(按信号当天收盘价成交)相当于看完今天的收盘报价再按这个价格下单,交易台做不到;它对慢信号无害,是因为 \(\varphi=0.9\) 时信号半衰期约 6.6 天,晚一个隔夜只损失很小一部分预测力,而 \(\varphi=0.2\) 的信号半衰期不到半天,预测力大部分在第一个隔夜就兑现了。C(忘记滞后)相当于"用今天的涨跌决定今天持仓":5 日动量的符号里包含了今天的收益,与今天收益相乘必然大概率为正,Sharpe 5.4 是机械性结果,不是信号。D(全样本标准化)最隐蔽:价格的全样本均值是整段历史的"终点信息",用它判断价格偏高偏低,等于提前知道价格最后会回到哪个区间,就像审计时用年末才确认的减值去判断年初的估值是否合理。 代码中的 expanding(60) 是扩展窗口:第 \(t\) 天只用第 1 天到第 \(t\) 天的数据算均值和标准差,且至少要 60 个观测才开始输出,这样每一天用到的统计量都是当天确实能算出来的。

6.3.3 其他常见形态

  • 幸存者偏差:只用今天还存在的股票做历史回测,等于事先剔除了退市股票。对小盘、价值、反转类策略影响尤大(本册第 02 章)。
  • 数据修订:宏观数据和财务数据会被修订,回测应使用当时首次公布的版本。
  • 复权方式:前复权价格用到了未来的分红送转信息,价格水平类信号(如"价格低于 10 元")会因此带前视;收益计算应使用后复权或直接用分红调整后的收益率。
  • 时区与收盘时刻:跨市场策略用"同一日期"的收盘价,可能是不同时刻的价格。例如用美股收盘后的信息交易同一日期的亚洲市场,就是前视。
  • 指数成分与股票池:用当前成分股回测历史指数增强,既有幸存者偏差也有前视。

6.4 交易成本与滑点建模

6.4.1 成本模型的层次

回测中的成本模型可以按精细程度分为三层:

  1. 固定比例:每单位换手扣固定 bp(佣金 + 印花税 + 半价差)。适合研究初期和低换手策略。
  2. 随市况变化的价差:半价差随波动率、股价、市值变化,可以用历史报价数据或 Roll 估计(本册第 07 章)。
  3. 含冲击:单位成本 \(=\) 固定费用 \(+\) 半价差 \(+Y\sigma_d\sqrt{Q/\text{ADV}}\),其中 \(Q\) 是成交额,ADV 是日均成交额,\(\sigma_d\) 是日波动率,\(Y\) 为量级在 1 附近的系数(第 07 册第 21b 章 21.7.2 节的"平方根冲击律",本册第 07 章详细讨论其估计)。

第三层模型让成本依赖于资金规模,这是回测必须回答的第二个问题——"能赚多少"之后的"能做多大"。

6.4.2 代码:容量曲线

代码 6.3 对一个用部分调整降低了换手(本册第 05 章 5.5 节)的版本,计算不同资金规模下的成本和净收益。注意代码里的日波动只用前一日及以前的数据估计,成本模型同样不能有前视。

# 代码 6.3  成本模型与容量曲线:平方根冲击下,规模越大净收益越低(需先运行代码 6.0)
df = make_daily(seed=1)
r_on = (df.open / df.close.shift(1) - 1).fillna(0).values
r_id = (df.close / df.open - 1).values
w_raw = np.clip(df.x.values, -1, 1)
w = np.zeros_like(w_raw)                                    # 部分调整(本册第 05 章 5.5 节)降低换手
for t in range(1, len(w)):
    w[t] = w[t - 1] + 0.3 * (w_raw[t] - w[t - 1])
pos = np.r_[0, w[:-1]]; pos_prev = np.r_[0, pos[:-1]]
gross = pos_prev * r_on + pos * r_id
dw = np.abs(pos - pos_prev)                                 # 每日换手(权重)
sig_d = pd.Series(df.ret).rolling(20, min_periods=5).std().shift(1).bfill().values  # 只用前一日及以前
ADV, Y, fee, hs = 2e8, 0.8, 1e-4, 2e-4                      # 日均成交额 2 亿;冲击系数;佣金;半价差 2bp

print("资金规模      日均成交占ADV  成本(年化)  净收益(年化)  净Sharpe  年净利润(万)")
for aum in [1e6, 1e7, 3e7, 1e8, 3e8, 1e9, 3e9]:
    q = aum * dw                                            # 每日成交额
    unit_cost = fee + hs + Y * sig_d * np.sqrt(q / ADV)     # 单位成交额成本(平方根律)
    cost = dw * unit_cost
    net = gross - cost
    print(f"{aum/1e6:8.0f}M   {np.mean(q) / ADV:12.2%}   {cost.mean()*252:9.4f}   {net.mean()*252:10.4f}"
          f"   {sharpe(net):7.3f}   {net.mean()*252*aum/1e4:10.1f}")
print(f"\n毛收益(无成本): 年化 {gross.mean()*252:.4f}, Sharpe {sharpe(gross):.3f};日均换手 {dw.mean():.3f}")

输出:

资金规模      日均成交占ADV  成本(年化)  净收益(年化)  净Sharpe  年净利润(万)
       1M          0.05%      0.0127       0.0351     0.335          3.5
      10M          0.49%      0.0240       0.0238     0.226         23.8
      30M          1.47%      0.0361       0.0116     0.111         34.9
     100M          4.91%      0.0598      -0.0121    -0.115       -120.7
     300M         14.74%      0.0982      -0.0504    -0.479      -1513.1
    1000M         49.14%      0.1732      -0.1254    -1.181     -12540.0
    3000M        147.41%      0.2945      -0.2467    -2.278     -74021.0

毛收益(无成本): 年化 0.0478, Sharpe 0.455;日均换手 0.098
  • 100 万规模时,冲击几乎可以忽略,净 Sharpe 0.34 主要被固定费用拉低(毛 Sharpe 0.46)。
  • 规模每扩大 10 倍,冲击成本按 \(\sqrt{10}\approx3.2\) 倍增长,净收益率单调下降,1 亿规模时已经为负。
  • 年净利润(规模 × 净收益率)在 3000 万附近达到最大,约 35 万。这就是这个策略在给定流动性下的容量。超过这个规模,多投的钱会减少总利润。实务中通常把容量定在利润最大点以下,因为冲击系数 \(Y\) 本身有很大的估计误差。
  • 日均成交占 ADV 的比例是另一个约束:超过 5%–10% 时,平方根律的外推本身已不可靠,而且一天内可能根本成交不完,需要在回测里加入参与率上限和未成交部分的处理。

推导拆解:为什么利润有一个最大点?把容量曲线写成一个可以求导的式子就明白了(这是简化推导,忽略了换手和波动随时间变化)。 第一步,设资金规模为 \(A\),年化毛收益率为 \(g\),年化换手为 \(\tau\),固定费率(佣金加半价差)为 \(f\)。冲击的单位成本是 \(Y\sigma\sqrt{A\tau_d/\text{ADV}}\),乘以换手后,冲击造成的年化成本率可以写成 \(k\sqrt{A}\),\(k\) 是把 \(Y\)、\(\sigma\)、换手、ADV 合在一起的常数。 第二步,净收益率 \(=g-f\tau-k\sqrt A\),年利润 \(P(A)=A\,(g-f\tau)-kA^{3/2}\)。 第三步,对 \(A\) 求导(幂函数求导:\(A^{3/2}\) 的导数是 \(\tfrac32A^{1/2}\))并令其为零:\(g-f\tau-\tfrac32k\sqrt{A^*}=0\),得 \(\sqrt{A^*}=\dfrac{2(g-f\tau)}{3k}\)。 第四步,代回净收益率:在利润最大点,净收益率恰好是 \((g-f\tau)/3\),即扣固定费用后的收益有三分之二被冲击吃掉。 对照输出:毛收益 4.78%,固定费用约 \(3\text{bp}\times0.098\times252\approx0.74\%\),扣完约 4.04%,三分之一约 1.35%;表中 3000 万规模的净收益 1.16%,正好落在利润最大点附近。这个"三分之一法则"可以当作快速估算容量的经验工具。数学背景见 第 00 册第 02 章 导数与泰勒展开。

6.4.3 让成本模型接受实盘校准

回测成本模型的参数(半价差、冲击系数 \(Y\)、指数 \(\delta\))最初只能来自文献或经纪商的估计。策略上线后,每一笔母单都会留下执行记录,应当定期用这些记录回填和校准成本模型:按第 07 册第 21a 章的执行差额口径计算每笔母单的实际成本,按参与率、波动率、价差分组,与模型预测比较。校准时要注意两种选择偏差:一是只有成交了的订单才有成本记录,没成交的部分以机会成本的形式存在(第 07 册第 21b 章 21.6 节);二是交易员会在流动性差的时候放慢节奏,实际成交样本偏向流动性好的时段。校准后的成本模型要同时回写到回测引擎和组合优化器(本册第 05 章 5.5 节)中,三者使用同一套参数,否则研究、优化和执行会在不同的成本世界里各自"最优"。

一个实用的诊断是"实现差额分解":把回测收益与实盘收益的差距按时间对齐差异、成交价差异、费用差异、未成交差异逐项分开。差距主要来自哪一项,就优先改进哪一部分的建模。


6.5 样本外验证:走步与 purged/embargo 交叉验证

6.5.1 为什么随机 K 折会失效

第 03 册第 22b 章 22b.7 节已经说明:金融数据的特征高度持续,标签常常重叠(例如"未来 20 日收益"),随机打乱的 K 折交叉验证会让测试样本的"邻居"出现在训练集中,灵活的模型只要记住邻居就能在测试集上得分。解决办法有两个:

  • 走步验证(walk-forward):只用测试期之前的数据训练,滚动或扩展训练窗口。它最接近实盘,但每次训练用的数据少,早期测试折的模型偏弱,而且整个历史只被测试一次。
  • purged K 折 + embargo(López de Prado,2018):测试折取连续区间;从训练集中删去标签区间与测试区间重叠的样本(purging);再在测试折之后空出一段禁区(embargo),因为测试折之后的训练样本的特征与测试期末尾高度相关。它允许用测试期之后的数据训练,所以不模拟实盘,但能更充分地利用数据,适合模型选择和超参数调优。

设样本 \(i\) 的标签用到 \([i+1,i+H]\) 的收益,测试折为 \([a,b]\),禁区长度为 \(h\),则训练集只保留满足 \(i+H<a\) 或 \(i>b+h\) 的样本。

白话解释:先说"重叠标签"为什么是问题。标签是"未来 20 日累计收益",那么第 100 天的标签用的是第 101–120 天的收益,第 101 天的标签用的是第 102–121 天,两者共享 19 天。如果第 100 天在测试集、第 101 天在训练集,模型等于在训练时已经看过了测试答案的 95%。特征又高度持续(\(\varphi=0.98\)),第 100 天和第 101 天的特征几乎一样,随机森林很容易"认出"这对邻居。 purging(清洗)就是把标签区间 \([i+1,i+H]\) 碰到测试区间 \([a,b]\) 的训练样本删掉:测试折之前的样本要求标签在 \(a\) 之前结束,即 \(i+H<a\)。embargo(禁区)处理测试折之后的一侧:紧跟在测试折后面的样本,特征和测试折末尾高度相似,所以再多空出 \(h\) 天,只保留 \(i>b+h\)。 用审计的话说:抽样检查的样本不能和被审计单位已经"准备好"的样本重叠,否则检查结果没有独立性。

6.5.2 代码:三种验证方法的对比

代码 6.4 生成 5 个持续性极强(\(\varphi=0.98\))的特征和"未来 20 日累计收益"标签,用随机森林预测,比较三种验证方法。分两种情况:特征完全没有预测力,以及第 1 个特征有弱预测力。

# 代码 6.4  重叠标签下的交叉验证:随机 K 折、purged + embargo K 折、走步验证(需先运行代码 6.0)
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import KFold

def make_xy(seed, H=20, T=2000, signal=0.0):
    """特征:5 个持续性很强的 AR(1)(φ=0.98);标签:未来 H 日累计收益(相邻标签重叠 H-1 天)。
    signal>0 时,第 1 个特征对下一日收益有真实预测力。"""
    rng = np.random.default_rng(seed)
    F = np.zeros((T + H, 5))
    for t in range(1, T + H):
        F[t] = 0.98 * F[t - 1] + np.sqrt(1 - 0.98**2) * rng.standard_normal(5)
    r = signal * 0.01 * np.r_[0, F[:-1, 0]] + 0.01 * rng.standard_t(5, T + H) / np.sqrt(5 / 3)
    y = np.array([r[t + 1:t + 1 + H].sum() for t in range(T)])   # t 时刻标签用 t+1..t+H 的收益
    return F[:T], y

def purged_kfold(T, H, k=5, embargo=0.01):
    """测试折为连续区间;训练集中删去标签区间 [i+1, i+H] 与测试区间重叠的样本,并在测试折后再禁区 embargo*T 个样本。"""
    bounds = np.array_split(np.arange(T), k)
    emb = int(embargo * T)
    for te in bounds:
        a, b = te[0], te[-1]
        idx = np.arange(T)
        keep = (idx + H < a) | (idx > b + emb)       # 标签结束早于测试开始,或位于禁区之后
        yield idx[keep], te

def walk_forward(T, H, k=5, min_train=0.3):
    start = int(min_train * T)
    for te in np.array_split(np.arange(start, T), k):
        yield np.arange(0, te[0] - H), te            # 训练标签必须在测试开始前完全实现

def oos_r2(X, y, splits):
    pred, true = [], []
    for tr, te in splits:
        m = RandomForestRegressor(n_estimators=100, min_samples_leaf=5, n_jobs=-1, random_state=0).fit(X[tr], y[tr])
        pred.append(m.predict(X[te])); true.append(y[te])
    p, t = np.concatenate(pred), np.concatenate(true)
    return 1 - np.sum((t - p) ** 2) / np.sum((t - t.mean()) ** 2), np.corrcoef(p, t)[0, 1]

H = 20
for sig in [0.0, 0.1]:
    X, y = make_xy(seed=3, H=H, signal=sig)
    T = len(y)
    print(f"--- 真实预测力 signal={sig} ---")
    for name, sp in [("随机打乱 K 折", KFold(5, shuffle=True, random_state=0).split(X)),
                     ("Purged+Embargo K 折", purged_kfold(T, H)),
                     ("走步验证", walk_forward(T, H))]:
        r2, ic = oos_r2(X, y, sp)
        print(f"{name:20s} 样本外 R² = {r2:7.3f}   预测与实现的相关 = {ic:6.3f}")

输出:

--- 真实预测力 signal=0.0 ---
随机打乱 K 折             样本外 R² =   0.448   预测与实现的相关 =  0.689
Purged+Embargo K 折   样本外 R² =  -0.308   预测与实现的相关 = -0.024
走步验证                 样本外 R² =  -0.302   预测与实现的相关 =  0.083
--- 真实预测力 signal=0.1 ---
随机打乱 K 折             样本外 R² =   0.534   预测与实现的相关 =  0.739
Purged+Embargo K 折   样本外 R² =  -0.028   预测与实现的相关 =  0.231
走步验证                 样本外 R² =  -0.203   预测与实现的相关 =  0.174
  • 特征完全没有预测力时,随机 K 折报告样本外 \(R^2=0.45\)、预测相关 0.69。这完全是泄漏:模型记住了训练集中时间相邻的样本,而这些样本的标签与测试样本共享 19 天的收益。purged K 折和走步验证给出负的 \(R^2\) 和接近 0 的相关,这才是真相。
  • 特征有弱预测力时,随机 K 折报告的相关 0.74 几乎和无信号时一样高,它无法区分有信号和没信号。purged K 折(0.23)和走步验证(0.17)都识别出了信号,走步验证略低,因为它的训练样本更少。
  • 两种正确方法的 \(R^2\) 都是负的,而相关为正:随机森林在噪声上过拟合,预测的幅度太大。对交易而言,排序和方向往往比幅度重要,所以要同时看相关(IC)和 \(R^2\),并考虑对预测做收缩。
  • 走步验证在无信号时给出 0.083 的相关,这仍是噪声:20 日重叠标签使有效独立样本只有约 \(1400/20=70\) 个,相关的标准误约 \(1/\sqrt{70}\approx0.12\)。重叠标签下的任何统计量,其有效样本量都要按重叠程度折算。

6.5.3 走步验证的工程细节

走步验证有几个需要事先确定、并写进研究日志的选择:

  • 扩展窗口还是滚动窗口。 扩展窗口用全部历史,估计更稳定,但对结构变化反应慢;滚动窗口适应变化,但估计噪声大。可以把窗口长度当作超参数,但要记住它也是一次试验,计入 6.6 节的 \(N\)。
  • 重训练频率。 每个测试折重训练一次是最常见的做法。重训练太频繁会增加计算量,也会让模型的参数在相邻两期之间跳动,带来额外换手。
  • 训练集末端的处理。 训练样本的标签必须在测试期开始前完全实现,所以训练集末端要留出 \(H\) 期空白(代码 6.4 中的 te[0] - H)。忘记这一步是走步验证里最常见的泄漏。
  • 超参数调优放在哪一层。 如果在每个训练窗口内部还要调超参数,就需要嵌套验证:内层在训练窗口内做 purged K 折,外层做走步。只用一层验证、又用它的结果选超参数,等于在测试集上调参。
  • 最终样本外。 无论用哪种验证方法,都应在研究开始时封存最后一段数据(例如最近 2 年),只在全部设计确定后运行一次。运行之后无论结果如何都不再修改设计,否则它就变成了又一个验证集。

6.6 过拟合与 Deflated Sharpe

6.6.1 选择偏差

从 \(N\) 个策略里挑出样本内最好的一个,它的 Sharpe 是 \(N\) 个估计值的最大值,即使所有策略的真实 Sharpe 都是 0,这个最大值的期望也随 \(\sqrt{2\ln N}\) 增长。第 03 册第 10b 章 10b.6 节给出了完整的推导和两个工具:

  • 概率 Sharpe 比率 \(\widehat{PSR}(SR^*)\):考虑样本长度、偏度和峰度后,真实 Sharpe 超过基准 \(SR^*\) 的置信度。
  • Deflated Sharpe 比率 \(DSR=\widehat{PSR}(SR_0)\):基准取"\(N\) 个零效应试验的预期最大 Sharpe"
    \[SR_0=\sqrt{\mathbb V[\{\widehat{SR}_n\}]}\Big((1-\gamma)\Phi^{-1}(1-\tfrac1N)+\gamma\Phi^{-1}(1-\tfrac1{Ne})\Big).\]

另一个工具是 Bailey、Borwein、López de Prado 和 Zhu 提出的回测过拟合概率(PBO),用组合对称交叉验证(CSCV)估计:把样本切成 \(S\) 块,取所有 \(\binom{S}{S/2}\) 种"一半做样本内、一半做样本外"的组合,每次记录样本内最优策略在样本外的排名,PBO 是它排在中位数以下的比例。

推导拆解:\(SR_0\) 公式逐项看。 第一项 \(\sqrt{\mathbb V[\{\widehat{SR}_n\}]}\)(\(\mathbb V\) 表示方差):\(N\) 个试验的 Sharpe 估计值之间的标准差,代表"噪声 Sharpe"的散布宽度。 第二项括号里是"\(N\) 个标准正态变量的最大值的期望"的近似。直观上,\(\Phi^{-1}(1-\tfrac1N)\) 是"\(N\) 次抽样里大约只有一次会超过的水平",例如 \(N=58\) 时 \(\Phi^{-1}(1-1/58)\approx2.11\);\(\gamma\approx0.5772\) 是欧拉常数,第二个分位数做了一点修正,把近似调得更准。它随 \(N\) 的增长大约是 \(\sqrt{2\ln N}\),增长很慢但不会停:试 10 次约 2.1,试 1000 次约 3.7(按 \(\sqrt{2\ln N}\) 粗算)。 两项相乘:\(SR_0\) 就是"如果所有试验都是纯噪声,你挑出来的最好那个大约会有多大的 Sharpe"。DSR 再用 PSR 问:观测到的最优 Sharpe 超过这个噪声上限的概率有多大。 金融直觉:这和投资委员会面对一家"过去 5 年排名第一的基金"时的问题完全一样。如果同类有 500 只基金,即使大家都没有选股能力,也必然有一只排第一;该问的不是"它的业绩显著吗",而是"它比 500 只纯运气基金里的最好者还好吗"。

6.6.2 代码:均线参数寻优

代码 6.5 在 58 组快慢均线参数上做网格搜索,分两种市场:完全没有可预测性;有一个慢速变化的趋势成分。因为是模拟,我们还可以在同一数据生成过程的 20 条新路径上检验所选参数,得到真正的样本外表现。

# 代码 6.5  参数寻优的过拟合:Deflated Sharpe 与 CSCV 回测过拟合概率(需先运行代码 6.0)
from itertools import combinations
from scipy.stats import norm, skew, kurtosis

def ma_grid(df):
    """均线交叉策略族:快线 < 慢线;t 日收盘信号,t+1 日收盘到收盘计收益(简化),扣 2bp 单边成本。"""
    logp = np.log(df.close)
    out = {}
    for f in [3, 5, 10, 15, 20, 30, 40, 50]:
        for s in [20, 40, 60, 80, 100, 150, 200, 250]:
            if f >= s: continue
            pos = np.sign(logp.rolling(f).mean() - logp.rolling(s).mean()).fillna(0)
            r = pos.shift(1).fillna(0) * df.ret - 2e-4 * pos.diff().abs().fillna(0)
            out[f"{f}/{s}"] = r.values[250:]                 # 去掉均线预热期
    return pd.DataFrame(out)

def dsr(R):
    """R: T x N 的试验收益矩阵。返回最优试验的 SR(年化)、PSR(0)、SR0(年化)、DSR。见第 03 册 10b.6.2 节。"""
    T, N = R.shape
    sr = R.mean() / R.std(ddof=1)                        # 每期(未年化)Sharpe
    best = sr.idxmax(); r = R[best].values; s = sr[best]
    g3, g4 = skew(r), kurtosis(r, fisher=False)
    denom = np.sqrt(1 - g3 * s + (g4 - 1) / 4 * s**2)
    psr = lambda s0: norm.cdf((s - s0) * np.sqrt(T - 1) / denom)
    gamma = 0.5772156649
    emax = (1 - gamma) * norm.ppf(1 - 1 / N) + gamma * norm.ppf(1 - 1 / (N * np.e))
    sr0 = np.sqrt(sr.var(ddof=1)) * emax
    return best, s * np.sqrt(252), psr(0.0), sr0 * np.sqrt(252), psr(sr0)

def pbo(R, S=16):
    """组合对称交叉验证(CSCV):样本内最优者在样本外排名低于中位数的概率。"""
    T, N = R.shape
    blocks = np.array_split(np.arange(T), S)
    s1 = np.array([R.values[b].sum(0) for b in blocks]); s2 = np.array([(R.values[b]**2).sum(0) for b in blocks])
    n = np.array([len(b) for b in blocks])
    def sr_of(idx):
        m = s1[idx].sum(0) / n[idx].sum(); v = s2[idx].sum(0) / n[idx].sum() - m**2
        return m / np.sqrt(v)
    logits = []
    for is_idx in combinations(range(S), S // 2):
        is_idx = list(is_idx); oos_idx = [i for i in range(S) if i not in is_idx]
        best = np.argmax(sr_of(is_idx))
        rank = (sr_of(oos_idx) < sr_of(oos_idx)[best]).sum() + 1   # 1..N,越大越好
        w = rank / (N + 1); logits.append(np.log(w / (1 - w)))
    return np.mean(np.array(logits) <= 0)

for name, kw in [("无可预测性", dict(beta_on=0, beta_id=0)), ("有慢速趋势成分", dict(beta_on=0.0006, beta_id=0.0006, phi=0.99))]:
    R = ma_grid(make_daily(seed=5, **kw))
    best, sr_best, p0, sr0, d = dsr(R)
    print(f"[{name}] 试验数 N={R.shape[1]},最优参数 {best},样本内年化 SR={sr_best:.2f}")
    print(f"    PSR(0)={p0:.3f}(单检验 p≈{1-p0:.4f})  预期最大噪声 SR0={sr0:.2f}  DSR={d:.3f}  PBO={pbo(R):.2f}")
    fam = (R.mean() / R.std() * np.sqrt(252))
    news = []                                              # 同一数据生成过程的 20 条新路径 = 真正的样本外
    for k in range(20):
        Rn = ma_grid(make_daily(seed=100 + k, **kw)); news.append(Rn.mean() / Rn.std() * np.sqrt(252))
    news = pd.DataFrame(news)
    print(f"    样本内全族 SR 中位数 {fam.median():.2f};20 条新路径平均:所选参数 SR={news[best].mean():.2f},"
          f"全族中位数 {news.median(axis=1).mean():.2f},全族最好的参数 SR={news.mean().max():.2f}")

输出:

[无可预测性] 试验数 N=58,最优参数 3/20,样本内年化 SR=0.62
    PSR(0)=0.968(单检验 p≈0.0322)  预期最大噪声 SR0=0.41  DSR=0.731  PBO=0.75
    样本内全族 SR 中位数 0.33;20 条新路径平均:所选参数 SR=-0.01,全族中位数 0.05,全族最好的参数 SR=0.16
[有慢速趋势成分] 试验数 N=58,最优参数 20/80,样本内年化 SR=0.72
    PSR(0)=0.984(单检验 p≈0.0158)  预期最大噪声 SR0=0.34  DSR=0.875  PBO=0.78
    样本内全族 SR 中位数 0.49;20 条新路径平均:所选参数 SR=0.79,全族中位数 0.81,全族最好的参数 SR=0.93

这组结果需要细读:

  • 无可预测性时:最优参数的样本内 Sharpe 0.62,单检验 p 值 0.03,按传统标准"显著"。但 58 个试验的预期最大噪声 Sharpe 就有 0.41,DSR 只有 0.73,远低于通常要求的 0.95,正确地拒绝了这个策略。新路径上它的平均 Sharpe 是 −0.01。样本内整个参数族的 Sharpe 中位数 0.33 也是假象:这条路径恰好有一段趋势,所有均线策略都"赚了钱",在新路径上中位数只剩 0.05。
  • 有趋势成分时:最优参数的 DSR 是 0.875,也没有达到 0.95,但它在新路径上的平均 Sharpe 是 0.79,是真实有效的。DSR 在这里偏保守,原因是 58 个均线策略高度相关,有效独立试验数远小于 58(第 03 册 10b.6.3 节),用原始 \(N\) 会高估运气成分。所以 DSR 未通过不等于策略无效,而是说"证据还不够";估计有效试验数之后再算一次,结论可能不同。
  • PBO 在两种情况下都约为 0.75。这并不矛盾:PBO 回答的是"在这个参数族内部,样本内的排名在样本外是否保持"。有趋势时,几乎所有参数都有效(新路径上全族中位数 0.81,所选参数 0.79),参数之间的差别是噪声,样本内挑出的"最优"在样本外自然只是中等水平。高 PBO 说明调参没有价值,不说明策略族没有价值;要判断后者,得看全族的表现和 DSR。

白话解释:代码里 pbo 函数的几个技巧值得一看。第一,它先把每块的收益和与平方和预先算好(s1、s2),任意组合块的均值和方差都可以由这些和拼出来(方差 \(=\) 平方的均值 \(-\) 均值的平方),避免对 12870 种组合逐一重算,这是典型的"用代数换计算量"。第二,每次切分后,它找样本内 Sharpe 最高的策略,看它在样本外排第几,转成相对排名 \(w\in(0,1)\),再取对数几率 \(\ln\frac{w}{1-w}\):排名在中位数以上为正,以下为负或零。PBO 就是对数几率不大于 0 的比例。第三,dsr 函数里的 denom 就是含偏度 \(\gamma_3\) 和峰度 \(\gamma_4\) 修正的 Sharpe 标准误分子:负偏(\(\gamma_3<0\))和厚尾(\(\gamma_4>3\))都会让分母变大、PSR 变小,即同样的 Sharpe,在卖期权这类负偏策略上更不可信。

  • 实践建议:报告参数族整体的表现分布("参数高原"),而不只是最优点;如果最优点和中位数差不多,就选一个居中、稳健的参数,而不是样本内最优的那个。

研究流程上的防线(记录所有试验、封存最终样本外、先有经济逻辑再看数据)在第 03 册第 10b 章 10b.6.5 节有详细讨论,这里不重复,但要强调一点:DSR 和 PBO 只能校正你记录下来的试验。没被记录的尝试——换了几次股票池、改了几次调仓频率、删掉了"表现不好的年份"——都是看不见的 \(N\)。


6.7 业绩指标及其统计误差

6.7.1 常用指标

指标 定义 注意
年化收益 日均对数收益 × 252 简单收益和对数收益不要混用
年化波动 日收益标准差 × \(\sqrt{252}\) 收益自相关时需调整
Sharpe 年化超额收益 / 年化波动 标准误约 \(\sqrt{(1+SR^2/2)/\text{年数}}\)
最大回撤 \(\max_t(1-\text{NAV}_t/\max_{s\le t}\text{NAV}_s)\) 路径依赖,样本越长越大
Calmar 年化收益 / 最大回撤 分母估计误差极大
换手 \(\sum_i\lvert\Delta w_i\rvert\) 的年平均 与成本假设一起报告
信息比率 主动收益 / 跟踪误差 有基准时用

Sharpe 标准误的推导(Delta 方法,含偏度、峰度修正)见第 03 册第 01 章 1.6 节;不依赖公式的 bootstrap 见第 03 册第 08 章。金融收益有波动聚集,普通的 IID bootstrap 会破坏时间依赖,应当用块 bootstrap 或平稳 bootstrap(Politis–Romano)。

最大回撤有两个容易忽略的性质:它随样本长度增加而增加(同一个策略,回测 20 年的最大回撤一般比回测 5 年的大),所以不同长度的回测不能直接比较最大回撤;它的抽样分布很宽,单条路径上的最大回撤几乎不能用来推断"未来最大回撤"。

6.7.2 收益自相关与 Sharpe 年化

用 \(\sqrt{252}\) 把日 Sharpe 年化,隐含假设日收益不相关。估值滞后(非流动资产、按模型估值的头寸)或者策略本身的平滑会让收益正自相关,日波动被低估,年化 Sharpe 被高估。Lo(2002)给出调整因子:

\[ SR_{\text{年}}=\eta(q)\,SR_{\text{日}},\qquad \eta(q)=\frac{q}{\sqrt{q+2\sum_{k=1}^{q-1}(q-k)\rho_k}},\quad q=252 , \]

\(\rho_k\) 是日收益的 \(k\) 阶自相关。不相关时 \(\eta=\sqrt q\)。

推导拆解:\(\eta(q)\) 来自"\(q\) 日累计收益的方差"。 第一步,年收益是 \(q\) 个日收益之和 \(R=\sum_{t=1}^q r_t\)。年均值是 \(q\mu\),这一步不受自相关影响(期望是线性的)。 第二步,和的方差等于协方差矩阵所有元素之和:\(\mathrm{Var}(R)=\sum_{s}\sum_{t}\mathrm{Cov}(r_s,r_t)\)。对角线有 \(q\) 个 \(\sigma^2\);相隔 \(k\) 天的配对有 \(q-k\) 对,上下三角各一份,每对协方差为 \(\rho_k\sigma^2\)。所以 \(\mathrm{Var}(R)=\sigma^2\big(q+2\sum_{k=1}^{q-1}(q-k)\rho_k\big)\)。 第三步,年 Sharpe \(=\dfrac{q\mu}{\sigma\sqrt{q+2\sum(q-k)\rho_k}}=\eta(q)\cdot\dfrac{\mu}{\sigma}\)。 这和 CFA 里两资产组合方差 \(w_1^2\sigma_1^2+w_2^2\sigma_2^2+2w_1w_2\rho\sigma_1\sigma_2\) 是同一个道理,只是把"不同资产"换成了"同一资产的不同日子":正相关让总风险大于各部分风险的简单相加。私募股权、房地产基金按评估值报告的收益之所以 Sharpe 好看,正是因为估值平滑制造了正的 \(\rho_k\)。

6.7.3 代码:给指标配上置信区间

# 代码 6.6  业绩指标及其统计误差:平稳 bootstrap 与自相关调整(需先运行代码 6.0)
from scipy.stats import skew, kurtosis

df = make_daily(seed=1)
r_on = (df.open / df.close.shift(1) - 1).fillna(0).values
r_id = (df.close / df.open - 1).values
w = np.clip(df.x.values, -1, 1)
pos = np.r_[0, w[:-1]]; pos_prev = np.r_[0, pos[:-1]]
r = pos_prev * r_on + pos * r_id - 6e-4 * np.abs(pos - pos_prev)
r = np.log1p(r[1:])

def metrics(r):
    ann_ret, ann_vol = r.mean() * 252, r.std(ddof=1) * np.sqrt(252)
    mdd = max_dd(r)
    return dict(年化收益=ann_ret, 年化波动=ann_vol, Sharpe=ann_ret / ann_vol, 最大回撤=mdd, Calmar=ann_ret / mdd)

def stationary_bootstrap(r, n_boot=1000, mean_block=20, seed=0):
    """Politis–Romano 平稳 bootstrap:块长服从几何分布,保留短程依赖与波动聚集。"""
    rng = np.random.default_rng(seed); T = len(r); out = []
    for _ in range(n_boot):
        idx = np.empty(T, dtype=int); i = rng.integers(T)
        for t in range(T):
            idx[t] = i
            i = rng.integers(T) if rng.random() < 1 / mean_block else (i + 1) % T
        out.append(metrics(r[idx]))
    return pd.DataFrame(out)

m = metrics(r); bs = stationary_bootstrap(r)
tab = pd.DataFrame({"点估计": m, "2.5%": bs.quantile(0.025), "97.5%": bs.quantile(0.975)})
print(tab.round(3))
sr_d = r.mean() / r.std(ddof=1); g3, g4 = skew(r), kurtosis(r, fisher=False); n = len(r)
print(f"Sharpe 标准误(年化):正态公式 {np.sqrt((1 + sr_d**2 / 2) / n) * np.sqrt(252):.3f},"
      f"含偏峰修正 {np.sqrt((1 - g3 * sr_d + (g4 - 1) / 4 * sr_d**2) / n) * np.sqrt(252):.3f},"
      f"bootstrap {bs.Sharpe.std():.3f};偏度 {g3:.2f},峰度 {g4:.1f}")

# 自相关的收益:用 √252 年化会高估 Sharpe(Lo, 2002)
rng = np.random.default_rng(2)
e = 0.01 * rng.standard_normal(252 * 40) + 0.0004
r_s = 0.5 * e + 0.3 * np.r_[0, e[:-1]] + 0.2 * np.r_[0, 0, e[:-2]]   # 估值滞后造成的平滑收益
rho = [np.corrcoef(r_s[k:], r_s[:-k])[0, 1] for k in range(1, 252)]
q = 252
eta = q / np.sqrt(q + 2 * sum((q - k) * rho[k - 1] for k in range(1, q)))
sr_naive = r_s.mean() / r_s.std() * np.sqrt(252)
yearly = r_s.reshape(-1, 252).sum(1)
print(f"\n平滑收益:日度 √252 年化 SR={sr_naive:.2f},Lo 调整后 SR={r_s.mean() / r_s.std() * eta:.2f},"
      f"直接用年度收益算 SR={yearly.mean() / yearly.std():.2f}(真实未平滑序列 SR={e.mean() / e.std() * np.sqrt(252):.2f})")

输出:

          点估计   2.5%  97.5%
年化收益    0.028 -0.050  0.098
年化波动    0.122  0.108  0.136
Sharpe  0.230 -0.394  0.786
最大回撤    0.341  0.150  0.516
Calmar  0.083 -0.101  0.560
Sharpe 标准误(年化):正态公式 0.316,含偏峰修正 0.315,bootstrap 0.308;偏度 0.50,峰度 12.5

平滑收益:日度 √252 年化 SR=1.35,Lo 调整后 SR=0.80,直接用年度收益算 SR=0.77(真实未平滑序列 SR=0.83)
  • 10 年的回测,Sharpe 点估计 0.23,95% 置信区间是 \([-0.39,0.79]\);三种标准误(正态公式、偏峰修正、平稳 bootstrap)都在 0.31 左右。也就是说,10 年的数据不足以确定这个策略的 Sharpe 是不是正的。这个结论对真实策略同样成立:Sharpe 0.5 的策略需要约 16 年数据才能让 \(t\) 值达到 2。
  • 最大回撤的置信区间是 15% 到 52%,Calmar 的区间跨过 0。用这两个指标对策略排序,基本是在比运气。
  • 这里的偏度峰度修正几乎不改变标准误,因为 Sharpe 很小时修正项 \(-\gamma_3SR+\frac{\gamma_4-1}{4}SR^2\) 也很小;对高 Sharpe、强负偏的策略(卖期权、套息),修正会很重要(第 03 册 1.6.2 节)。
  • 平滑收益的例子:日收益的 \(\sqrt{252}\) 年化 Sharpe 是 1.35,Lo 调整后是 0.80,与用年度收益直接计算的 0.77 和真实未平滑序列的 0.83 一致。平滑让 Sharpe 虚高了 60%。看到一个波动异常低、Sharpe 异常高、日收益自相关显著为正的策略或基金,首先要怀疑估值平滑。

白话解释:平稳 bootstrap 在做什么?普通 bootstrap 是把 2520 个日收益放进袋子里有放回地抽,抽出来的序列把"波动大的日子扎堆出现"这种结构打散了,对最大回撤这种依赖连续亏损的指标尤其失真。平稳 bootstrap 改为"抽整段":随机选一个起点,然后顺着往下取,每一步以 \(1/20\) 的概率跳到新的随机起点,于是平均块长 20 天,一段段连续的历史被保留下来。代码里 (i + 1) % T 的取模让序列走到末尾时绕回开头,它和几何分布的块长一起,保证重抽出来的序列是平稳的(统计性质不随位置变化),"平稳 bootstrap"因此得名。对每条重新拼出来的历史都算一遍指标,1000 次的 2.5% 和 97.5% 分位数就是置信区间。


6.8 常见陷阱与检查清单

陷阱

  1. 信号与成交使用同一价格,或在同一根 K 线上既计算信号又成交。
  2. 全样本预处理:标准化、去极值、PCA、协整系数、超参数用了全样本。
  3. 忽略市场规则:涨跌停、停牌、T+1、最小交易单位、融券可得性。
  4. 成本模型不随规模变化,从而没有容量概念。
  5. 成本模型自己有前视:用当日的波动、成交量计算当日开盘交易的冲击(代码 6.3 特意用了滞后的波动)。
  6. 随机 K 折做时间序列验证,或者走步验证时训练标签与测试期重叠。
  7. 只报告最好的参数,不报告试验数和参数族的整体表现。
  8. 把样本外数据看了很多遍,样本外变成了样本内。
  9. 用最大回撤和 Calmar 比较策略而不给出误差。
  10. 收益自相关时用 \(\sqrt{252}\) 年化。

检查清单

  • [ ] 每个数据字段都有可得时间戳,引擎只读取可得时间早于决策时刻的数据。
  • [ ] 向量化与事件驱动两种实现核对过,差异可以解释。
  • [ ] 把信号人为滞后一天重跑:结果应当变差但不应崩溃;如果滞后一天后收益反而大幅上升或完全消失,查时间对齐。
  • [ ] 把收益序列随机打乱重跑:Sharpe 应当回到 0 附近,否则有泄漏。
  • [ ] 成本模型包含价差、费用和冲击,冲击依赖规模;画出容量曲线。
  • [ ] 模型选择用 purged/embargo K 折或走步验证;最终样本外只用一次。
  • [ ] 记录试验数,报告 DSR;报告参数族的表现分布。
  • [ ] 所有业绩指标附带置信区间;检查收益自相关。

本章小结

  • 回测的偏差几乎都偏乐观,来源是信息集、成交条件、样本和研究过程。
  • 向量化回测用于研究,事件驱动回测用于上线前验证,两者要核对。事件驱动引擎要严格按"撮合—盯市—下单"的顺序处理事件,并实现市场规则。
  • 前视偏差的大小因策略而异:成交时点假设对慢信号无害、对快信号致命;漏掉滞后会让无效信号显示出 5 以上的 Sharpe;全样本标准化在无信号的世界里稳定地制造出 0.5 的 Sharpe。
  • 成本模型要包含平方根冲击,从而得到容量曲线;容量取在利润最大点之下。
  • 重叠标签和持续特征下,随机 K 折无法区分有无信号;purged/embargo K 折和走步验证可以。
  • DSR 校正选择偏差,在试验高度相关时偏保守;PBO 衡量参数族内部的排名稳定性,不衡量策略族本身是否有效。
  • 10 年的日度回测,Sharpe 的标准误约 0.3;最大回撤和 Calmar 的误差更大;收益自相关会让年化 Sharpe 虚高。

练习

  1. 向量化与事件驱动的差异来源。 在代码 6.1 的事件驱动版本中,改为按次日开盘价计算目标股数(实盘做不到,只用于诊断)并去掉整手限制,两种实现的差异缩小了多少?再加入"涨跌幅超过 9.5% 时不能成交"的规则,两者差多少?

  2. 成交时点敏感度曲线。 对代码 6.2,把信号持续性 \(\varphi\) 从 0.1 扫到 0.95,画出 A、B 两种成交假设下的 Sharpe 差。给出一个经验判断:信号半衰期短于多少天时,必须精确建模成交时点?(提示:AR(1) 半衰期为 \(\ln0.5/\ln\varphi\)。)

  3. 前视自检。 写一个函数,输入任意信号序列和收益序列,自动比较"信号滞后 0、1、2 天"的 Sharpe,当滞后 0 天的 Sharpe 显著高于滞后 1 天时发出警告。用代码 6.2 的 C1、C2 测试。

  4. 容量与换手。 在代码 6.3 中,把部分调整的系数从 0.3 改为 1.0(每天调到目标)和 0.1,分别画容量曲线。利润最大点怎样移动?这与本册第 05 章 5.5 节的结论有什么关系?

  5. 禁区长度。 在代码 6.4 中把 embargo 从 0 改到 5%,观察无信号情况下 purged K 折的相关。再把特征持续性 \(\varphi\) 改成 0.999,禁区需要多长才能消除泄漏?

  6. 有效试验数。 对代码 6.5 中 58 个均线策略的收益矩阵做主成分分析,用"解释 95% 方差所需的主成分数"作为有效试验数 \(N_{\text{eff}}\),重新计算 DSR。趋势情形下 DSR 是否超过 0.95?(参考第 03 册 10b.6.3 节。)

  7. 最大回撤的长度依赖。 用代码 6.0 生成 5 年、10 年、20 年的路径各 100 条,计算同一策略的最大回撤分布,验证最大回撤随样本长度增长。

  8. 块长的选择。 在代码 6.6 中把平稳 bootstrap 的平均块长从 20 改为 1(即 IID bootstrap)和 60,比较最大回撤置信区间的宽度。为什么块长对最大回撤的影响比对 Sharpe 大?


延伸阅读

  • 第 03 册第 10b 章(多重检验与策略过拟合,PSR、DSR、PBO、有效试验数)、第 22b 章 22b.7 节(时间序列中的交叉验证)、第 01 章 1.6 节(Sharpe 比率的标准误)、第 08 章(Bootstrap)。
  • 第 05 册第 16 章 16.4 节(HAC 标准误,重叠标签回归的推断)。
  • 第 07 册第 21a 章(价格基准法与执行差额)、第 21b 章(计量估计与成本预测,平方根冲击律)、第 22a 章(绩效评估的度量与预测问题)。
  • 本册第 02 章(时点数据与幸存者偏差)、第 05 章(成本惩罚与调仓规则)、第 07 章(交易成本模型)、第 10 章(机器学习的样本外验证)、第 11 章(风险与绩效评估)。
  • Lo, A. (2002). The Statistics of Sharpe Ratios. Financial Analysts Journal.
  • Politis, D., & Romano, J. (1994). The Stationary Bootstrap. Journal of the American Statistical Association.
  • White, H. (2000). A Reality Check for Data Snooping. Econometrica.
  • Bailey, D., & López de Prado, M. (2014). The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting and Non-Normality. Journal of Portfolio Management.
  • Bailey, D., Borwein, J., López de Prado, M., & Zhu, Q. (2017). The Probability of Backtest Overfitting. Journal of Computational Finance.
  • Harvey, C., Liu, Y., & Zhu, H. (2016). ... and the Cross-Section of Expected Returns. Review of Financial Studies.
  • López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley.(第 7 章交叉验证,第 11–14 章回测)