第 01 章 量化研究全流程与工程环境
前 10 册分别讲了矩阵、概率、统计、优化、计量、时间序列、微观结构、衍生品、算法和神经网络。真正做一个量化策略时,这些知识是按一条固定的流水线被调用的:想法 → 数据 → 信号 → 组合 → 回测 → 执行 → 监控。本章先把这条流水线画清楚,标出每一环用到前面哪一册的哪一章;再用一段 100 行左右的代码把它从头跑到尾;最后讲把研究做成"可复现、可审查"的工程习惯。结论先说在前面:多数失败的量化策略不是数学不够,而是流水线某一环出了工程错误——时间错位、幸存者偏差、忽略成本、试验次数没记账。这些错误都能用固定的检查和测试拦住。
学习目标
- 能说出研究循环七个环节各自的输入、输出和最常见的失败方式,并知道每一环的理论出处在前面哪一册。
- 能写出一个最小的端到端研究循环:合成数据、构造信号、形成组合、带成本回测、监控 IC。
- 熟悉 numpy / pandas / scipy / statsmodels / scikit-learn / arch 在量化研究中的分工,知道 pandas 中最容易造成前视偏差的几种写法。
- 会组织一个量化研究项目:数据分层、纯函数、配置驱动、试验日志。
- 会用随机种子派生、环境记录、数据指纹保证结果可复现,理解浮点误差对排序类信号的影响。
- 会写"未来扰动测试",自动检查信号函数是否偷看了未来数据。
读前导读
这一章在解决什么问题。 第 11 册是"综合实战":前 10 册是零件,这一册把零件装成一条能每天运行的生产线。本章是这条生产线的总图和施工规范。把它对应到你熟悉的工作场景会更好理解:
- "想法"环节相当于买方/卖方研究员写投资逻辑:为什么这只股票(这类股票)会跑赢?谁是对手方?逻辑不清的"规律",你在做尽调时也不会信。
- "数据"环节相当于审计里的"截止性测试(cut-off test)":一笔收入是不是记在了正确的期间。量化里的问题是"这条信息在决策当天是否已经公开"。财报按报告期而不是公告日对齐,就是量化版的"跨期确认收入"。
- **"信号—组合—回测"**相当于基金经理把研究观点变成持仓,再由绩效部门做事后评估。
- **"执行"**对应交易台,关心的是执行差额(implementation shortfall)。
- **"监控"**对应风控与绩效归因部门的日报:风险是否超限、收益是否符合预期。
- 试验日志与可复现性对应内控与审计底稿:任何一个报告里的数字都要能追溯到"哪份数据、哪版代码、哪组参数"。你作为 CPA 对"审计轨迹"的要求,在这里原样适用。
本章的核心结论是:量化策略失败,多数不是数学不够,而是流程上的"会计差错"——时间错位、样本不完整、费用漏计、试验次数没披露。这些都能用固定的程序和测试拦住。
需要先想起来的数学。 本章数学很少,主要是几个统计量的直观含义。
- 截面 z-score(标准化):对同一天的 300 只股票,减去当天均值、除以当天标准差,\(z_i=(x_i-\bar x)/s_x\)。直观上就是把"原始分数"变成"比同行高几个标准差"。例:当天 PE 均值 20、标准差 5,某股 PE 30,则 \(z=2\)。只用当天的数据,所以不会偷看未来。
- AR(1) 过程:\(s_t=\phi s_{t-1}+\sqrt{1-\phi^2}\,\varepsilon_t\)。\(\phi\) 越接近 1,序列变化越慢、越"有记忆"。乘 \(\sqrt{1-\phi^2}\) 是为了让 \(s_t\) 的长期方差保持为 1(因为 \(\phi^2\cdot 1+(1-\phi^2)=1\))。例:\(\phi=0.98\) 时,冲击的半衰期约 \(\ln 0.5/\ln 0.98\approx 34\) 天。见 第 00 册第 04 章 级数与收敛 中几何级数的部分。
- GARCH(1,1):今天的方差 \(h_t=\omega+\alpha r_{t-1}^2+\beta h_{t-1}\),即"长期水平 + 昨天的意外 + 昨天的方差"。长期平均方差为 \(\omega/(1-\alpha-\beta)\)。你可以把它理解成风控里 EWMA 波动率的"带均值回归"版本。
- 秩相关(Spearman / RankIC):先把两列数都换成名次,再算普通相关系数。好处是不受极端值影响。例:信号排第 1 的股票次日收益也排第 1,以此类推,相关为 1。
- 大 O 记号 \(O(1)\):表示"每一步的计算量不随数据长度增长"。见 第 00 册第 07 章 概率中的分析工具。
怎么读这一章。 1.2 节的表格是全册地图,先通读,以后回头查。1.3 节的端到端代码是本章核心,建议对照讲解框逐段读,并真的运行一遍。1.4.1 节的六个 pandas 陷阱和 1.7 节的未来扰动测试是日后写代码时最常用的两样东西,必读。1.5 节(目录结构)和 1.6 节(浮点误差)第一次可以只看结论;1.5.2 节最后那个"期望最大 Sharpe"公式只需记住含义(试得越多,纯靠运气能得到的最好 Sharpe 越高),推导留到第 06 章。
1.1 问题与动机
设想你读到一篇论文,说某个因子年化 Sharpe 有 1.5。你在自己的数据上复现,回测曲线漂亮,于是上线。半年后发现实盘收益接近零。事后排查,原因往往是下面几类之一:
- 时间错位:用收盘后才知道的信息按当天收盘价成交;财报按报告期而非公告日对齐。
- 样本偏差:股票池只包含今天还活着的公司,退市的输家被悄悄剔除。
- 成本缺失:换手太高,扣掉价差与冲击后为负。
- 过拟合:试了几百个参数组合,只报告最好的那个,却没有按试验次数做校正。
- 不可复现:三个月后跑不出同样的数字,分不清差异来自数据、代码还是随机性。
这些问题都不需要高深数学,却都足以让策略失效。前 10 册提供的是"零件":第 05 册的 Fama–MacBeth 回归告诉你怎么检验一个因子,第 06 册的因子模型告诉你怎么估计协方差,第 04 册的二次规划告诉你怎么求最优组合。本册要做的是把零件装成一台机器,并在每个接口处装上"保险丝"。
1.2 研究循环:七个环节
量化研究是一个循环:监控发现的问题(IC 衰减、成本超预期)会回到想法或数据环节,开始下一轮。下表是本书的"地图"。风险模型是信号与组合之间的支撑环节,单列一行。
| 环节 | 要回答的问题 | 交付物 | 本册 | 理论出处(前 10 册) |
|---|---|---|---|---|
| 想法 | 为什么会有超额收益?谁在付钱给我? | 带经济逻辑的假设、先验强弱 | 本章 | 第 07 册第 08、10 章(交易动机、知情交易);第 03 册第 11 章(先验);第 03 册第 16 章(因果) |
| 数据 | 数据在当时是否可得?是否完整、干净? | 时点正确的面板数据 | 第 02 章 | 第 06 册第 01 章(收益率);第 03 册第 07、21 章(经验分布、MAD) |
| 信号 | 信号能否预测收益?是否只是已知风险的变形? | 因子值、IC、Fama–MacBeth 结果 | 第 03、08、10 章 | 第 05 册第 06、10 章(FWL、面板与聚类标准误);第 06 册第 09 章(因子模型);第 03 册第 10b 章(多重检验) |
| 风险 | 组合未来的波动和相关是多少? | 协方差矩阵 / 因子风险模型 | 第 04 章 | 第 06 册第 09、10 章(PCA、EWMA、DCC);第 01 册第 00 章(Woodbury)、第 04a 章(特征值不等式) |
| 组合 | 在约束和成本下,该持有多少? | 目标权重 | 第 05 章 | 第 04 册第 12、16a、16b 章(KKT、二次规划) |
| 回测 | 历史上这样做会怎样?结果有多可信? | 净值、指标及其误差、过拟合诊断 | 第 06 章 | 第 03 册第 08 章(Bootstrap)、第 10b 章、第 22b 章(时间序列交叉验证) |
| 执行 | 怎样把目标权重变成成交,花多少钱? | 订单、成交、执行差额 | 第 07 章 | 第 07 册第 14、21a、21b 章(价差、成本度量) |
| 监控 | 线上表现是否符合预期?风险是否超限? | 风险报告、归因、告警 | 第 11 章 | 第 06 册第 07a、07b 章(VaR、极值理论);第 08 册第 22 章(VaR) |
本册第 08–10 章(统计套利、衍生品、机器学习)只是换了"信号"和"组合"的具体内容,流程不变。
1.2.1 想法:先问"谁在付钱"
一个可持续的超额收益,背后要么是风险补偿(你承担了别人不愿承担的风险,例如流动性风险、崩盘风险),要么是行为或制度约束(有人因为情绪、指数调整、监管限制而被迫在不利价格交易),要么是信息或速度优势。第 07 册第 08 章把交易者分为功利型、利润驱动型和徒劳型;你的利润来自哪一类对手方,决定了它能持续多久。
经济逻辑在统计上的作用是先验。第 03 册第 11 章说明:先验成立概率低时,即使 p 值很小,后验概率也可能不高。一个纯数据挖掘出来的复杂公式,先验概率很低;一个有清晰机制、在多个市场独立被发现的效应,先验概率高。这一环的交付物应当写成一句可检验的假设,例如"盈利公告后的价格漂移在小盘股中更强,因为小盘股的信息处理成本更高",并在研究日志里登记。
1.2.2 数据:时点正确比数据多更重要
数据环节要回答一个问题:在决策时刻 \(t\),这条信息真的已经可得吗? 价格要复权,财报要按公告日对齐,股票池要包含后来退市的公司,停牌和异常值要有明确规则。这些都在本册第 02 章展开。经验上,数据环节占研究时间的一半以上,也是前视偏差的主要来源。
1.2.3 信号:预测力、增量与稳健性
信号环节把原始数据变成对未来收益的排序或预测。本册第 03 章的核心工具是 IC、分组回测与 Fama–MacBeth 回归(推导见第 05 册第 10 章 10.8 节)。要回答三件事:信号有没有预测力(IC 和 t 值);预测力是不是已知风险(行业、市值、市场 β)的变形(中性化后是否还在);结论是否经得起多重检验(第 03 册第 10b 章)。
1.2.4 风险、组合、回测、执行与监控
这五环在本册第 04–07 章和第 11 章展开,这里只点出难点。风险:组合优化要用协方差矩阵的逆,样本协方差在股票数接近样本长度时严重病态(本册第 04 章)。组合:在风险、约束和成本之间权衡,KKT 条件见第 04 册第 12 章 12.10 节(本册第 05 章)。回测:它是一次统计推断而不是"画一条净值曲线",要回答这条曲线有多少是运气(本册第 06 章)。执行:决定研究里的成本参数是否属实(本册第 07 章)。监控:比较实盘与回测、计算 VaR 和归因,发现漂移就回到上游(本册第 11 章)。
1.2.5 环节之间的接口
把研究循环落到代码上,最重要的是接口约定,而不是某个具体算法:
- 数据 → 信号:一张以(日期,资产)为索引的面板,每个值都带"何时可得"的语义。约定"\(t\) 行的数据在 \(t\) 日收盘后可得"。
- 信号 → 组合:\(t\) 行的信号只用 \(t\) 及之前的数据。
- 组合 → 回测:\(t\) 行的目标权重在 \(t\) 日收盘(或 \(t+1\) 日开盘)成交,赚取 \(t+1\) 的收益。回测里唯一的
shift(1)应当出现在这里,而且只出现一次。 - 回测 → 监控:回测与实盘使用同一份信号和组合代码,只替换数据源和成交模块。
接口一旦写清楚,大部分前视错误都能用 1.7 节的自动测试拦住。
金融直觉:这四条接口约定,相当于会计里的"权责发生制 + 截止日"规则。"\(t\) 行数据在 \(t\) 日收盘后可得"就是规定每笔信息的"入账日";"\(t\) 日权重赚 \(t+1\) 日收益"就是规定"今天做的决策,只能从明天起产生损益"。会计上把下一期收入提前确认是舞弊,回测里把明天的收益算给今天的决策就是前视偏差,性质一样。规定
shift(1)只出现在回测一处,就像规定只有一个部门有权做期末调整分录:集中在一处,才容易审查。
1.3 一个最小可运行的研究循环
下面的代码把七个环节压缩到一个脚本里。它生成一个有真实市场关键特征的合成面板:市场因子服从 GARCH(1,1) 波动、新息为 \(t(5)\) 分布(厚尾与波动聚集,见第 06 册第 03a 章);每只股票有不同的 β 与特质波动;有一个缓慢变化的"真信号"决定下一期期望收益,但研究员只能看到带持续性噪声的观测值。然后构造截面标准化信号、美元中性组合、带 10bp 成本的回测和滚动 IC 监控,最后故意制造一个前视错误作对照。
本章代码按顺序在同一个 Python 会话(例如一个 Jupyter notebook)中运行,后面的代码块会用到前面生成的 R、S_obs 等变量;各章之间互不依赖。
白话解释:先说代码在做什么、为什么这样设计。整段代码分 6 块,对应流水线的 6 个环节。 第 1 块"造数据":先造一个"大盘"日收益
mkt,它的波动会自己聚集(GARCH),偶尔有极端值(\(t(5)\) 分布);每只股票的收益 = β × 大盘 + 自己的特质波动 + 一小部分由"真信号"\(s\) 决定的超额收益。关键设计是:真信号 \(s_{t-1}\) 决定的是第 \(t\) 天的收益(s[t-1]),所以它是真正的"预测"。研究员拿不到 \(s\),只能拿到加了噪声的S_obs,就像卖方分析师的盈利预测 = 真实基本面 + 分析师误差。 第 2 块"信号":每天把 300 个观测值做截面 z-score。只用当天的横截面,不用历史均值,因此天然不偷看未来。 第 3 块"组合":减去均值使多空金额相等(美元中性,即不赌大盘方向),再除以绝对值之和使"多头 + 空头"总金额为 1(总杠杆 1)。 第 4 块"回测":w.shift(1) * R就是"昨天收盘定的仓位 × 今天的收益";w.diff().abs().sum()是每天调仓的金额,乘以 10bp 就是交易成本。 第 5 块"监控":每天算一次"昨天信号排名"与"今天收益排名"的相关(RankIC),再看 60 日滚动均值有没有掉下去。 第 6 块"反面教材":故意去掉shift(1),看前视错误长什么样。 用合成数据而不用真实数据,是因为这里"标准答案"已知:信号确实有效,所以能检验整条流水线有没有把它找回来。
推导拆解:为什么
t5要除以 \(\sqrt{5/3}\)?\(t\) 分布自由度为 \(\nu\) 时方差是 \(\nu/(\nu-2)\),\(\nu=5\) 时方差为 \(5/3\)。除以它的平方根,方差就变成 1,这样np.sqrt(h[t])才真正是当天的标准差。同理,GARCH 的初值h[0] = w0/(1-a1-b1)\(=2\times10^{-6}/0.02=10^{-4}\),就是长期平均方差,对应日波动 1%、年化约 16%,和真实股指量级相当。
import numpy as np
import pandas as pd
rng = np.random.default_rng(20261005)
N, T = 300, 1500 # 300 只股票,1500 个交易日(约 6 年)
# ---------- 1. 数据:合成一个"像市场"的日收益面板 ----------
# 市场因子:GARCH(1,1) 波动 + 标准化 t(5) 新息 -> 厚尾与波动聚集
def t5(size):
return rng.standard_t(5, size) / np.sqrt(5 / 3) # 方差归一为 1
w0, a1, b1 = 2e-6, 0.08, 0.90
h = np.empty(T); mkt = np.empty(T)
h[0] = w0 / (1 - a1 - b1)
for t in range(T):
if t > 0:
h[t] = w0 + a1 * mkt[t-1]**2 + b1 * h[t-1]
mkt[t] = 0.0003 + np.sqrt(h[t]) * t5(1)[0]
beta = rng.normal(1.0, 0.3, N)
idio_vol = rng.uniform(0.012, 0.03, N) # 特质日波动 1.2%–3%
# 一个缓慢变化的"真信号" s(AR(1),phi=0.98),它决定下一期的期望超额收益
s = np.empty((T, N)); s[0] = rng.standard_normal(N)
for t in range(1, T):
s[t] = 0.98 * s[t-1] + np.sqrt(1 - 0.98**2) * rng.standard_normal(N)
alpha_scale = 0.0003 # 1 个标准差信号 ≈ 3bp/日
ret = np.empty((T, N))
ret[0] = beta * mkt[0] + idio_vol * t5(N)
for t in range(1, T):
ret[t] = alpha_scale * s[t-1] + beta * mkt[t] + idio_vol * t5(N)
dates = pd.bdate_range('2019-01-01', periods=T)
R = pd.DataFrame(ret, index=dates) # 宽表:行=日期,列=股票
# 研究员看到的是带测量噪声的信号;噪声本身也有持续性(AR(1),phi=0.9)
e = np.empty((T, N)); e[0] = rng.standard_normal(N)
for t in range(1, T):
e[t] = 0.9 * e[t-1] + np.sqrt(1 - 0.9**2) * rng.standard_normal(N)
S_obs = pd.DataFrame(s + 1.0 * e, index=dates)
# ---------- 2. 信号:截面标准化(只用当天截面,不用未来) ----------
def cs_zscore(df):
return df.sub(df.mean(axis=1), axis=0).div(df.std(axis=1), axis=0)
signal = cs_zscore(S_obs)
# ---------- 3. 组合:美元中性、总杠杆为 1 ----------
w = signal.sub(signal.mean(axis=1), axis=0)
w = w.div(w.abs().sum(axis=1), axis=0)
# ---------- 4. 回测:t 日收盘形成的权重,赚 t+1 日的收益 ----------
gross = (w.shift(1) * R).sum(axis=1).iloc[1:]
turnover = w.diff().abs().sum(axis=1).iloc[1:] # 单边换手(权重变化绝对值之和)
cost_bps = 10 # 每单位成交额 10bp(价差的一半 + 冲击)
net = gross - turnover * cost_bps / 1e4
def perf(x, name):
ann_ret = x.mean() * 252; ann_vol = x.std() * np.sqrt(252)
print(f'{name:<10s} 年化收益 {ann_ret:7.2%} 年化波动 {ann_vol:6.2%} Sharpe {ann_ret/ann_vol:5.2f}')
perf(gross, '毛收益')
perf(net, '扣成本后')
print(f'日均单边换手 {turnover.mean():.3f}(总杠杆 1)')
# 组合层面的一个简单改进:对信号做 10 日指数平滑,用少量预测力换大幅降低换手
sig_s = cs_zscore(signal.ewm(halflife=10).mean())
w2 = sig_s.div(sig_s.abs().sum(axis=1), axis=0)
gross2 = (w2.shift(1) * R).sum(axis=1).iloc[1:]
to2 = w2.diff().abs().sum(axis=1).iloc[1:]
perf(gross2, '平滑-毛')
perf(gross2 - to2 * cost_bps / 1e4, '平滑-净')
print(f'平滑后日均单边换手 {to2.mean():.3f}')
# ---------- 5. 监控:滚动 60 日 IC(信号与次日收益的截面相关) ----------
ic = signal.shift(1).corrwith(R, axis=1, method='spearman').iloc[1:]
roll = ic.rolling(60).mean()
print(f'日 RankIC 均值 {ic.mean():.4f},t 值 {ic.mean()/ic.std()*np.sqrt(len(ic)):.2f};'
f'滚动 60 日 IC 最小 {roll.min():.4f}、最大 {roll.max():.4f}')
# ---------- 6. 一个前视错误:用 t 日收盘才知道的信息赚 t 日收益 ----------
rev = cs_zscore(-R) # "短期反转"信号:昨天跌得多的买
w_rev = rev.div(rev.abs().sum(axis=1), axis=0)
right = (w_rev.shift(1) * R).sum(axis=1).iloc[1:]
wrong = (w_rev * R).sum(axis=1).iloc[1:] # 少了 shift(1)
perf(right, '反转-正确')
perf(wrong, '反转-前视')
输出:
毛收益 年化收益 6.20% 年化波动 2.41% Sharpe 2.58
扣成本后 年化收益 -2.57% 年化波动 2.41% Sharpe -1.07
日均单边换手 0.348(总杠杆 1)
平滑-毛 年化收益 5.79% 年化波动 2.43% Sharpe 2.38
平滑-净 年化收益 4.29% 年化波动 2.43% Sharpe 1.76
平滑后日均单边换手 0.059
日 RankIC 均值 0.0109,t 值 7.48;滚动 60 日 IC 最小 -0.0102、最大 0.0347
反转-正确 年化收益 0.81% 年化波动 3.46% Sharpe 0.24
反转-前视 年化收益 -764.71% 年化波动 6.29% Sharpe -121.60
几点解读:
- 成本决定生死。 原始信号的毛 Sharpe 为 2.58,但日均单边换手 0.348,按 10bp 计每年成本约 \(0.348\times 0.001\times 252\approx 8.8\%\),比毛收益 6.2% 还多,净 Sharpe 变成 −1.07。对信号做 10 日指数平滑后,毛收益只少了 0.4 个百分点,换手降到 0.059,净 Sharpe 1.76。这就是本册第 05 章要系统处理的"预测力与换手的权衡",也是本册第 03 章要测量因子衰减的原因。
- IC 很小但显著。 日 RankIC 只有 0.011,但 1500 天的 t 值达到 7.5。真实市场中稳健因子的日 IC 往往也只有百分之一左右;IC 的意义在于"小而稳定",滚动 60 日 IC 在 −0.010 到 0.035 之间波动,监控时不应对单月 IC 为负过度反应。
推导拆解:第 1 点的成本估算逐步来。日均单边换手 0.348,意思是每天平均要买卖相当于组合总金额 34.8% 的股票。每成交 1 元付 10bp \(=0.001\) 元,于是每天成本 \(0.348\times0.001=0.000348\);一年 252 个交易日,年化成本 \(\approx 0.000348\times252\approx 8.8\%\)。毛收益只有 6.2%,扣完就是负的。注意成本只影响收益、几乎不影响波动(成本每天差不多是个常数),所以年化波动两行都是 2.41%,Sharpe 的下降全部来自分子。
金融直觉:第 2 点的"IC 只有 0.011 但 t 值 7.5",和你熟悉的"均值的 t 检验"是一回事:\(t=\bar{IC}/(s_{IC}/\sqrt{n})\)。样本有 1500 天,\(\sqrt{1500}\approx 38.7\),所以一个很小但很稳定的均值也能高度显著。这也是 Grinold 的"主动管理基本定律"的直觉:\(IR\approx IC\times\sqrt{\text{独立下注次数}}\)。每次只比抛硬币好一点点,但下注 300 只股票 × 每天,累积起来就很可观——类似保险公司每张保单的利润很薄,但靠大数定律稳定盈利。
- 前视错误一眼就能看出来,但不是每次都这么明显。 少写一个
shift(1),反转信号的年化"收益"变成 −765%、Sharpe −122。这种数字不会有人相信;危险的是只泄露一点点未来信息的错误(例如财报公告日错位几天),它让 Sharpe 从 0.5 变成 1.2,看起来完全合理。1.7 节的测试专门针对这一类。
这里的信号是"种"进去的,Sharpe 2.58 只说明代码能把它找出来,不代表真实市场能获得这样的收益(见 1.8 节)。
白话解释:为什么"反转-前视"会是 −765% 这样的离谱负数?反转信号是 \(-R_t\) 的截面标准化,即"今天跌得多的买、涨得多的卖"。不加
shift(1)时,权重和收益都是同一天的 \(R_t\),组合收益约等于 \(-\sum_i z(R_{t})_i R_{t,i}\),也就是"今天收益与今天收益的负相关",几乎恒为一个大的负数。这相当于每天都在"追跌杀涨后立刻按当天结果结算",结果必然被自己打脸。要点是:前视错误不一定让结果变好,也可能变坏;它的本质是结果不再依赖于预测能力,而依赖于一个恒等式。另外注意平滑那一步:
signal.ewm(halflife=10)是指数加权平均,越近的观测权重越大,10 天前的权重只剩一半。它只用过去和当天的数据,所以不引入前视;代价是信号对新信息反应变慢(预测力略降),收益是每天的持仓变化变小(换手大降)。这就是"用少量预测力换成本"的权衡。
1.4 Python 工具栈
本册所有代码只用以下几个库,均可离线运行:
| 库 | 在量化研究中的主要用途 | 本册主要用到的章 |
|---|---|---|
numpy |
向量化计算、随机数(Generator)、线性代数(特征分解、Cholesky、solve) |
全部 |
pandas |
面板数据:时间索引、对齐、groupby、rolling/ewm、merge_asof |
第 01–03、06、11 章 |
scipy |
分布函数与分位数(stats)、优化(optimize)、层次聚类(cluster)、稀疏矩阵 |
第 03–05、07 章 |
statsmodels |
OLS/WLS、聚类与 HAC 标准误、多重检验校正(multipletests)、协整与 ADF |
第 03、06、08 章 |
scikit-learn |
收缩协方差(LedoitWolf、OAS)、交叉验证框架、树模型与正则化回归 |
第 04、10 章 |
arch |
单变量 GARCH 族估计、波动率预测 | 第 04、09、11 章 |
几条工具层面的经验:
- 能向量化就向量化,但路径依赖的部分老老实实写循环。 GARCH 递推、带换手约束的逐日调仓、订单簿撮合本质上是顺序的,强行向量化反而容易写错。循环慢时再考虑
numba之类的编译工具;第 09 册第 17 章的摊还分析说明了环形缓冲区等增量数据结构为什么能把滚动计算降到每步 \(O(1)\)。 - 宽表与长表各有用处。 宽表(行 = 日期、列 = 资产)适合截面运算和矩阵乘法;长表适合合并基本面、做面板回归(本册第 02 章)。
- 统一使用
float64。 存储可用float32,计算前转回float64。
1.4.1 pandas 中六个与时间有关的陷阱
pandas 的默认行为是为一般数据分析设计的,不是为"不能偷看未来"设计的。下面六个陷阱在实际项目中反复出现:
import numpy as np
import pandas as pd
# 一个 2 只股票 × 6 天的长表(long format),故意让 B 在第 3 天停牌(缺一行)
df = pd.DataFrame({
'date': pd.to_datetime(['2024-01-02','2024-01-03','2024-01-04','2024-01-05','2024-01-08','2024-01-09',
'2024-01-02','2024-01-03','2024-01-05','2024-01-08','2024-01-09']),
'asset': ['A']*6 + ['B']*5,
'close': [10, 10.5, 10.2, 10.8, 11.0, 10.9, 50, 49, 52, 51, 53],
}).sort_values(['asset', 'date']).reset_index(drop=True)
# 陷阱 1:长表里直接 shift,会把 A 的最后一天和 B 的第一天接起来
df['ret_bad'] = df['close'].pct_change()
df['ret_good'] = df.groupby('asset')['close'].pct_change()
print(df.loc[df['asset'].eq('B')].head(2)[['date', 'asset', 'close', 'ret_bad', 'ret_good']])
# 陷阱 2:按"行"shift 与按"日历"shift 不同。B 在 01-04 停牌,
# 宽表 reindex 到完整交易日后再 shift,才知道 01-05 的收益跨了两天
wide = df.pivot(index='date', columns='asset', values='close')
print('\n宽表(停牌处为 NaN):\n', wide)
print('B 在 01-05 的收益(跨停牌):', round(wide['B'].pct_change(fill_method=None).loc['2024-01-05'], 4),
'<- NaN,提醒你这里需要专门处理')
# 陷阱 3:rolling 窗口包含当前行;做信号时要么先 shift,要么明确"t 日收盘后可用"
x = pd.Series([1., 2., 3., 4., 5.])
print('\nrolling(2).mean() :', x.rolling(2).mean().tolist())
print('shift(1).rolling(2).mean():', x.shift(1).rolling(2).mean().tolist())
# 陷阱 4:未来收益的方向。shift(-1) 是"把明天的值挪到今天",只能作为标签,绝不能进入特征
fwd = wide.pct_change(fill_method=None).shift(-1)
print('\n01-02 这一行的"未来 1 日收益"标签:', fwd.loc['2024-01-02'].round(4).to_dict())
# 陷阱 5:整列 fillna(method='ffill') 之前没有 groupby,会把一只股票的值填给另一只
df2 = pd.DataFrame({'asset': ['A', 'A', 'B', 'B'], 'pe': [15.0, np.nan, np.nan, 30.0]})
print('\n直接 ffill:', df2['pe'].ffill().tolist(), ' <- B 的第一行拿到了 A 的 PE')
print('分组 ffill:', df2.groupby('asset')['pe'].ffill().tolist())
# 陷阱 6:时点对齐。财报 3 月 31 日截止、4 月 25 日才公布,用 merge_asof 按"公告日"对齐
px = pd.DataFrame({'date': pd.bdate_range('2024-04-22', '2024-04-30')})
fund = pd.DataFrame({'period_end': pd.to_datetime(['2023-12-31', '2024-03-31']),
'ann_date': pd.to_datetime(['2024-03-28', '2024-04-25']),
'eps': [1.00, 1.30]})
naive = pd.merge_asof(px, fund.rename(columns={'period_end': 'key'}), left_on='date', right_on='key')
pit = pd.merge_asof(px, fund.rename(columns={'ann_date': 'key'}), left_on='date', right_on='key')
print('\n', pd.DataFrame({'date': px['date'].dt.strftime('%m-%d'),
'eps_按报告期': naive['eps'], 'eps_按公告日': pit['eps']}).to_string(index=False))
输出:
date asset close ret_bad ret_good
6 2024-01-02 B 50.0 3.587156 NaN
7 2024-01-03 B 49.0 -0.020000 -0.02
宽表(停牌处为 NaN):
asset A B
date
2024-01-02 10.0 50.0
2024-01-03 10.5 49.0
2024-01-04 10.2 NaN
2024-01-05 10.8 52.0
2024-01-08 11.0 51.0
2024-01-09 10.9 53.0
B 在 01-05 的收益(跨停牌): nan <- NaN,提醒你这里需要专门处理
rolling(2).mean() : [nan, 1.5, 2.5, 3.5, 4.5]
shift(1).rolling(2).mean(): [nan, nan, 1.5, 2.5, 3.5]
01-02 这一行的"未来 1 日收益"标签: {'A': 0.05, 'B': -0.02}
直接 ffill: [15.0, 15.0, 15.0, 30.0] <- B 的第一行拿到了 A 的 PE
分组 ffill: [15.0, 15.0, nan, 30.0]
date eps_按报告期 eps_按公告日
04-22 1.3 1.0
04-23 1.3 1.0
04-24 1.3 1.0
04-25 1.3 1.3
04-26 1.3 1.3
04-29 1.3 1.3
04-30 1.3 1.3
逐条说明:
- 长表上不分组的
pct_change,把 A 的最后一个价格和 B 的第一个价格连起来,得到 358.7% 的"收益"。这种错误在截面去极值之后可能被压下去,从而躲过肉眼检查。长表上的一切时间运算都必须先groupby('asset')。 - 停牌造成的缺口在长表里是"少一行",在宽表里才显示为 NaN。按行
shift会把停牌前后的两个价格当作相邻交易日。对日频研究,应先把宽表reindex到完整交易日历,再决定停牌期间的收益如何处理(本册第 02 章)。 rolling(k)的窗口包含当前行。如果约定"\(t\) 行数据在 \(t\) 日收盘后可得",那么 \(t\) 日收盘形成的信号可以包含 \(t\) 行;但若信号要在 \(t\) 日开盘前使用,就必须先shift(1)。关键是在接口文档里写清楚"什么时候可得"。shift(-1)把未来值挪到当前行,只能用来构造标签(未来收益)。在机器学习管线里,特征和标签放在同一张表时,最好用列名前缀(如y_)区分,并在特征选择时显式排除。- 不分组的
ffill会让一只股票继承另一只股票的值。 - 财报按报告期对齐,04-22 就"知道"了 04-25 才公布的 EPS。正确做法是以公告日为键做
merge_asof(本册第 02 章 2.5 节展开)。
白话解释:
merge_asof是"向后看最近一条"的合并:对左表每个日期,在右表里找键值不晚于该日期的最后一条记录。键用period_end(报告期末 3 月 31 日)时,4 月 22 日已经"找得到" 3 月 31 日那条,于是拿到 1.30;键用ann_date(公告日 4 月 25 日)时,4 月 22–24 日只能找到上一条(去年年报,3 月 28 日公告),拿到 1.00。对 CPA 读者,这就是"资产负债表日"与"财务报告批准报出日"的区别:投资者在报出日之前根本看不到报表。A 股一季报最晚 4 月 30 日、年报最晚 4 月 30 日披露,滞后可达一个月以上,错位造成的偏差并不小。
1.5 项目代码组织
1.5.1 目录结构
一个能长期维护的研究项目,大致按"数据分层 + 纯函数库 + 配置驱动的实验 + 测试"组织:
project/
├── data/
│ ├── raw/ # 原始下载,只读,不修改;文件名带下载日期
│ ├── interim/ # 清洗后的中间表(复权、对齐交易日历、PIT 财报)
│ └── features/ # 因子值、风险模型输出;可由代码完全重建
├── src/
│ ├── data/ # 读取、清洗、复权、股票池
│ ├── signals/ # 因子函数:输入面板,输出面板,无副作用
│ ├── risk/ # 协方差与风险模型
│ ├── portfolio/ # 优化器与约束
│ ├── backtest/ # 回测引擎、成本模型、业绩指标
│ └── utils/ # 日历、日志、指纹
├── configs/ # 每个实验一份 YAML/JSON 配置
├── experiments/ # 试验日志(jsonl)与结果;只追加,不删除
├── notebooks/ # 探索性分析;结论稳定后把代码移到 src/
└── tests/ # 单元测试、未来扰动测试、回归测试
几条原则:
- 原始数据只读。 任何清洗都写成代码,从
raw/生成interim/。这样数据供应商修订历史数据时,可以重新生成并比较差异。 - 信号函数是纯函数。 输入是面板,输出是同形状的面板,不读全局变量、不写文件、不依赖调用顺序。纯函数可以单独测试,也可以被回测和实盘共用。
- 配置与代码分离。 参数(窗口长度、分位数、成本假设)写在配置里,代码里不出现"魔法数字"。每次实验由"配置 + 数据版本 + 代码版本"唯一确定。
- notebook 只用于探索。 单元格可乱序执行,是不可复现的主要来源;结论进入报告前,代码要移到
src/并由脚本从头跑一遍。
1.5.2 试验日志:给多重检验记账
第 03 册第 10b 章 10b.6.5 节把"记录所有试验"列为防止过拟合的第一道防线:不知道试了多少次,就无法做多重检验校正。下面实现一个最小的试验日志:每次运行把配置、数据指纹、代码版本、库版本和结果追加写入一个 jsonl 文件,无论结果好坏。
import hashlib, json, tempfile, os, platform
import numpy as np
import pandas as pd
from scipy.stats import norm
def fingerprint(obj) -> str:
"""对配置(dict)或数据(DataFrame/ndarray)生成短哈希,用来判断"是不是同一个东西"。"""
if isinstance(obj, pd.DataFrame):
b = pd.util.hash_pandas_object(obj, index=True).values.tobytes()
elif isinstance(obj, np.ndarray):
b = np.ascontiguousarray(obj).tobytes()
else:
b = json.dumps(obj, sort_keys=True, ensure_ascii=False).encode()
return hashlib.sha256(b).hexdigest()[:12]
def run_experiment(cfg, R, S_obs, log_path):
"""一次试验 = 配置 + 数据 + 代码版本 -> 结果;无论好坏都写进日志。"""
sig = S_obs.ewm(halflife=cfg['halflife']).mean()
sig = sig.sub(sig.mean(axis=1), axis=0).div(sig.std(axis=1), axis=0)
q = sig.rank(axis=1, pct=True)
w = (q > 1 - cfg['tail']).astype(float) - (q <= cfg['tail']).astype(float)
w = w.div(w.abs().sum(axis=1), axis=0)
pnl = (w.shift(1) * R).sum(axis=1).iloc[1:]
pnl = pnl - w.diff().abs().sum(axis=1).iloc[1:] * cfg['cost_bps'] / 1e4
sr = pnl.mean() / pnl.std() * np.sqrt(252)
rec = {'cfg': cfg, 'cfg_id': fingerprint(cfg), 'data_id': fingerprint(R),
'code_ver': 'factor_ls@v0.3', 'python': platform.python_version(),
'numpy': np.__version__, 'pandas': pd.__version__, 'sharpe': round(float(sr), 4)}
with open(log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(rec, ensure_ascii=False) + '\n')
return rec
log_path = os.path.join(tempfile.mkdtemp(), 'trials.jsonl')
grid = [{'halflife': hl, 'tail': tl, 'cost_bps': 10} for hl in (1, 3, 5, 10, 20, 40) for tl in (0.1, 0.2, 0.3)]
for cfg in grid:
run_experiment(cfg, R, S_obs, log_path)
# 同一配置再跑一次:结果和哈希都应完全一致
again = run_experiment(grid[7], R, S_obs, log_path)
log = pd.read_json(log_path, lines=True)
first = log.iloc[7]
print('重跑一致:', first['sharpe'] == again['sharpe'], first['cfg_id'] == again['cfg_id'],
' data_id =', again['data_id'])
trials = log.drop_duplicates('cfg_id')
print(f'日志中独立配置数 {len(trials)},最优 Sharpe {trials.sharpe.max():.2f},'
f'最差 {trials.sharpe.min():.2f},中位数 {trials.sharpe.median():.2f}')
best = trials.loc[trials.sharpe.idxmax(), 'cfg']
print('最优配置:', best)
# 记录试验次数的意义:N 个零效应试验中"最好的那个"预期能有多高的 Sharpe(第 03 册第 10b 章)
years = (len(R) - 1) / 252
def expected_max_sr(n, years):
g = 0.5772156649
z = (1 - g) * norm.ppf(1 - 1 / n) + g * norm.ppf(1 - 1 / (n * np.e))
return z / np.sqrt(years)
print(f'若 {len(trials)} 个配置全无预测力,样本内最优年化 Sharpe 的预期约 {expected_max_sr(len(trials), years):.2f}')
输出:
重跑一致: True True data_id = dc7f78253a7b
日志中独立配置数 18,最优 Sharpe 1.57,最差 0.32,中位数 1.19
最优配置: {'halflife': 5, 'tail': 0.2, 'cost_bps': 10}
若 18 个配置全无预测力,样本内最优年化 Sharpe 的预期约 0.76
这段代码说明了三件事。第一,同一配置、同一数据、同一代码,结果必须逐位相同,这是可复现的最低标准;数据指纹 data_id 让你在数据更新后立刻知道"结果变了是因为数据变了"。第二,18 个配置的 Sharpe 从 0.32 到 1.57,最优者比中位数高 0.38;如果只报告最优者,就把这部分选择偏差当成了信号。第三,即使 18 个配置全部无效,样本内最优者的年化 Sharpe 预期也有约 0.76(Bailey 与 López de Prado 的近似,推导见第 03 册第 10b 章 10b.6.1 节)。本例信号真实存在,所以 1.57 明显高于 0.76;但参数网格内的配置高度相关,"有效试验数"小于 18,这些细节在本册第 06 章用 Deflated Sharpe 处理。
推导拆解:
expected_max_sr这个函数在算什么?分三步看。 第一步,假设 \(N\) 个策略都毫无预测力,每个策略的年化 Sharpe 估计值只是噪声。年化 Sharpe 的估计误差(标准误)约为 \(1/\sqrt{\text{年数}}\):样本约 6 年,标准误约 \(1/\sqrt{6}\approx0.41\)。这是 CFA 里"均值的标准误 \(=\sigma/\sqrt n\)"的直接应用。 第二步,\(N\) 个标准正态变量中最大那个的期望值,近似为 \((1-\gamma)\Phi^{-1}(1-1/N)+\gamma\,\Phi^{-1}(1-1/(Ne))\),其中 \(\gamma\approx0.5772\) 是欧拉常数,\(\Phi^{-1}\) 是标准正态分位数函数(Excel 里的NORM.S.INV)。\(N=18\) 时这个值约为 1.85。 第三步,两者相乘:\(1.85\times0.41\approx0.76\)。 含义:在 6 年数据上试 18 个毫无用处的配置,"冠军"平均也能有 0.76 的 Sharpe。这就像基金业绩排名:100 只纯靠运气的基金里,排第一的那只业绩看起来也会很亮眼。所以必须披露"一共试了多少次",这正是试验日志的作用。
工程上,试验日志应当是只追加的:不删除失败的试验,不覆盖旧结果。研究结束时,日志的行数就是做多重检验时的 \(N\) 的上界。
1.6 可复现性
可复现有四个层次,从易到难:
- 随机性可控:所有随机数来自显式种子。
- 环境可控:Python 与各库版本被记录或锁定。
- 数据可控:知道结果对应哪个版本的数据。
- 数值可控:同一输入在不同机器、不同线程数下得到相同(或差异可解释的)结果。
import math
import numpy as np
# 1) 随机数:用 Generator + SeedSequence,并行任务各自拿独立子流
root = np.random.SeedSequence(20261005)
children = root.spawn(4) # 4 个并行 worker
streams = [np.random.default_rng(c) for c in children]
print('各 worker 的第一个随机数:', [round(g.standard_normal(), 4) for g in streams])
# 再从同一根种子派生,结果完全相同 -> 并行实验也可复现
again = [np.random.default_rng(c).standard_normal() for c in np.random.SeedSequence(20261005).spawn(4)]
print('重新派生是否一致:', np.allclose(again, [np.random.default_rng(c).standard_normal() for c in children]))
# 2) 浮点数:加法不满足结合律,求和顺序改变结果的最后几位
rng = np.random.default_rng(0)
x = rng.standard_t(3, 1_000_000) * 1e-2 # 厚尾的"日收益"
s1 = float(np.sum(x)); s2 = float(np.sum(x[::-1])); s3 = math.fsum(x)
s4 = 0.0
for v in x: # 朴素逐项累加
s4 += v
print(f'np.sum={s1:.17g}\n倒序 ={s2:.17g}\nfsum ={s3:.17g}\n循环 ={s4:.17g}')
print('最大相对差:', f'{max(abs(s1-s3), abs(s2-s3), abs(s4-s3)) / abs(s3):.1e}')
# 3) 这种差异会被"排序"放大:并列值的排名取决于计算顺序
a = np.array([0.1 + 0.2, 0.3, 0.3000000000000001])
print('看似相等的三个值:', a, ' argsort ->', np.argsort(a, kind='stable'),
' round 后 rank ->', np.argsort(np.round(a, 12), kind='stable'))
输出:
各 worker 的第一个随机数: [-0.0261, 1.9229, -1.0507, 0.2503]
重新派生是否一致: True
np.sum=-4.74696354459339
倒序 =-4.7469635445933847
fsum =-4.7469635445933864
循环 =-4.7469635445933456
最大相对差: 8.6e-15
看似相等的三个值: [0.3 0.3 0.3] argsort -> [1 0 2] round 后 rank -> [0 1 2]
要点:
- 用
np.random.default_rng(seed),不要用全局的np.random.seed。 全局状态会被任何调用随机数的库改变,同一段代码前面多调用一次随机函数,后面的结果就全变了。并行任务用SeedSequence.spawn派生子种子,保证各子流统计独立且可重建。本册所有代码都在开头创建一个Generator。 - 浮点加法不满足结合律。 一百万个数求和,不同顺序的结果在第 15 位有效数字上不同,相对差约 \(10^{-14}\)。这对收益统计没有影响,但对排序有影响:分位数分组、
rank、argsort遇到"几乎相等"的值时,名次可能随计算顺序变化。多线程 BLAS、不同版本的 pandas 聚合实现都会改变求和顺序。对需要逐位复现的排序,先round到合理精度(例如 12 位)并使用稳定排序。
白话解释:为什么
0.1 + 0.2和0.3不相等?计算机用二进制存小数,0.1、0.2 都存不精确,就像十进制写不尽 \(1/3\)。所以0.1+0.2实际是 0.30000000000000004,比"0.3"大一点点。平时没影响,但排序只看大小:三个"看起来都是 0.3"的数,排名会被这最后一位决定。在因子分组里,这意味着处在分组边界上的股票可能这次进第 5 组、下次进第 4 组,持仓就对不上了。先round到 12 位再排序,就是先把"会计上无意义的尾差"抹平,类似报表合计时的尾差调整。
- 环境与数据版本。 至少把库版本写进试验日志,最好用锁文件固定环境。数据供应商会补充退市股票、修正复权因子、重述财报;用内容哈希给数据快照编号并写进结果,是成本最低的数据版本管理。
1.7 用测试守住时间方向:未来扰动测试
对量化代码,最有价值的单元测试不是"输出等于某个数",而是**"改动未来的数据,不应改变过去的信号"**。思路很简单:选一个截断日 \(t_0\),把 \(t_0\) 之后的价格换成随机游走,重新计算信号;如果 \(t_0\) 及之前任何一个信号值变了,信号函数就用到了未来数据。这个测试与信号的具体逻辑无关,可以对每个新写的因子函数自动运行。
import numpy as np
import pandas as pd
def lookahead_test(signal_fn, prices, cut, n_trials=3, seed=0):
"""未来扰动测试:把 cut 之后的价格换成随机游走,cut 及之前的信号必须一字不差。
返回 cut 之前(含)信号的最大绝对变化;> 0 就说明信号用到了未来数据。"""
rng = np.random.default_rng(seed)
base = signal_fn(prices).loc[:cut]
worst = 0.0
for _ in range(n_trials):
p = prices.copy()
future = p.index > cut
shock = np.exp(np.cumsum(rng.normal(0, 0.05, (future.sum(), p.shape[1])), axis=0))
p.loc[future] = p.loc[future].values * shock
diff = (signal_fn(p).loc[:cut] - base).abs().max().max()
worst = max(worst, float(np.nan_to_num(diff)))
return worst
prices = (1 + R).cumprod() * 10 # 用前面合成的收益生成价格
prices.iloc[995:1006, :5] = np.nan # 前 5 只股票在 cut 前后停牌 11 天
cut = prices.index[1000]
def mom_ok(p): # 20 日动量,只用 t 及之前
return p.pct_change(20, fill_method=None)
def mom_centered(p): # 错误 1:居中窗口(center=True)偷看了未来 10 天
return p.pct_change(fill_method=None).rolling(21, center=True).sum()
def mom_global_z(p): # 错误 2:用全样本均值和标准差做时间序列标准化
m = p.pct_change(20, fill_method=None)
return (m - m.mean()) / m.std()
def mom_expanding_z(p): # 修正:只用截至 t 的扩展窗口统计量
m = p.pct_change(20, fill_method=None)
return (m - m.expanding(252).mean()) / m.expanding(252).std()
def mom_bfill(p): # 错误 3:用 bfill 补缺失,未来值被拉回过去
return p.pct_change(20, fill_method=None).bfill()
for fn in (mom_ok, mom_centered, mom_global_z, mom_expanding_z, mom_bfill):
d = lookahead_test(fn, prices, cut)
print(f'{fn.__name__:<16s} 最大变化 {d:.3e} -> {"通过" if d == 0 else "使用了未来数据"}')
输出:
mom_ok 最大变化 0.000e+00 -> 通过
mom_centered 最大变化 5.004e-01 -> 使用了未来数据
mom_global_z 最大变化 2.292e+00 -> 使用了未来数据
mom_expanding_z 最大变化 0.000e+00 -> 通过
mom_bfill 最大变化 2.906e-01 -> 使用了未来数据
白话解释:
lookahead_test的设计思路是"控制变量实验"。先用原始价格算一遍信号,截取cut及之前的部分作基准;然后只改动cut之后的价格(乘上一条随机游走),重算信号,再看cut之前的信号有没有变。如果一个信号函数只用了过去数据,未来怎么变都不该影响过去的值,差异应当精确为 0(不是"很小",而是逐位相同)。这类似审计中的"穿行测试":不检查每个数是否正确,而检查流程本身是否会把不该进来的东西放进来。它的好处是与信号逻辑无关,任何因子函数都能套用。为什么
mom_global_z会被抓住?它用整段样本的m.mean()和m.std()做标准化。改动未来价格会改变全样本均值和标准差,从而改变所有过去的 z 值。换成expanding(252)后,第 \(t\) 天只用截至 \(t\) 的数据算均值和标准差,未来怎么变都与它无关。
被抓住的三个错误都很常见:
center=True的居中窗口常见于从信号处理或可视化代码里复制过来的平滑函数。- 全样本标准化是最隐蔽的一类:用整段样本的均值和标准差做时间序列 z-score,每个历史值都"知道"了未来的分布。它不会制造离谱的 Sharpe,只会让阈值类策略(\(|z|>2\) 开仓)的表现稍好一些。修正方法是扩展窗口或滚动窗口统计量。机器学习管线中的
StandardScaler在全样本上fit,是同一个错误。 bfill(后向填充)把停牌复牌后的值拉回停牌期间。注意第一次运行时这个错误没有被抓住——因为截断日附近没有缺失值。测试数据必须包含停牌、上市、退市等"边角"情形,否则测试形同虚设。
未来扰动测试只能发现"用了未来价格"这一类泄露。财报公告日错位、股票池用了未来成分等泄露来自数据本身,需要在数据层用时点数据(本册第 02 章)解决。
1.8 合成数据与真实数据的差别
本册代码都使用合成数据,这样不依赖网络,并且真值已知、可以检验方法。但读者必须清楚它与真实数据的差别:
- 信号是种进去的。 合成数据中的预测关系平稳、线性、永远存在;真实因子会衰减、会因拥挤而失效,也会在危机中反转。
- 分布特征只模仿了一部分。 我们模仿了厚尾、波动聚集、因子结构和横截面异质性;没有模仿杠杆效应(下跌时波动上升更多,第 06 册第 03b 章)、跳跃、流动性枯竭、相关性在危机中骤升、涨跌停与停牌制度。
- 成本是假设的。 真实成本随市值、波动、成交量和市场状态变化,冲击成本随交易规模非线性增长(本册第 07 章的平方根律)。
- 没有数据错误。 真实数据有错价、漏记、重述,本册第 02 章会专门模拟其中几类。
因此,合成数据上的 Sharpe 只能用来比较方法的相对优劣,不能当作真实收益的估计。一种方法如果在"真值已知"的合成数据上都找不回真值,在真实数据上更不可信;反过来,在合成数据上表现好,只是必要条件。
1.9 常见陷阱与检查清单
时间与对齐
- [ ] 文档写明每张表"\(t\) 行数据何时可得"(收盘后、次日开盘前、公告日后)。
- [ ] 回测中权重与收益之间恰好有一次
shift(1),且只在回测模块中出现。 - [ ] 长表的所有时间运算(
shift、pct_change、rolling、ffill)都先按资产分组。 - [ ] 不使用
center=True、bfill、全样本mean/std或全样本fit的预处理器。 - [ ] 每个信号函数都通过未来扰动测试,测试数据包含停牌和缺失。
成本与容量
- [ ] 报告毛收益与净收益、换手率,以及成本假设的来源。
- [ ] 改变成本假设(例如翻倍)后结论是否仍然成立。
统计可信度
- [ ] 试验日志记录了所有尝试过的配置,包括失败的。
- [ ] 最终结论对试验次数做了校正(本册第 03 章、第 06 章)。
- [ ] IC 与 Sharpe 都报告了标准误或 t 值,而不是只给点估计。
可复现
- [ ] 随机数全部来自显式种子的
Generator。 - [ ] 结果记录了代码版本、库版本和数据指纹。
- [ ] 报告中的每个数字都能由一条脚本命令从原始数据重新生成。
本章小结
量化研究是"想法 → 数据 → 信号 → 风险 → 组合 → 回测 → 执行 → 监控"的循环,每一环都有对应的理论(前 10 册)和对应的工程错误。端到端的最小示例显示:一个毛 Sharpe 2.58 的信号可能因为换手过高而净亏损,平滑后又能恢复到 1.76;少写一个 shift(1) 会制造离谱的结果,而更隐蔽的前视错误(全样本标准化、后向填充、财报按报告期对齐)只会让结果"略好一些",因此需要自动化测试。工程上,应把数据分层、把信号写成纯函数、用配置驱动实验、用只追加的日志记录所有试验,用种子派生、版本记录和数据指纹保证可复现。合成数据的价值在于真值已知,可以检验方法,但它的结果不代表真实收益。
| 概念 | 要点 |
|---|---|
| 研究循环 | 想法、数据、信号、风险、组合、回测、执行、监控;监控结果反馈到上游 |
| 时间约定 | \(t\) 行数据在 \(t\) 日收盘后可得;\(t\) 日权重赚 \(t+1\) 日收益 |
| 换手成本 | 年化成本 ≈ 日均单边换手 × 单位成本 × 252 |
| 试验日志 | 配置 + 数据指纹 + 代码版本 → 结果;只追加;行数是多重检验的 \(N\) |
| 可复现 | default_rng + SeedSequence.spawn;记录版本;浮点求和顺序影响排序 |
| 未来扰动测试 | 改动 \(t_0\) 之后的数据,\(t_0\) 及之前的信号必须不变 |
练习
- 在 1.3 节的代码中,把成本从 10bp 改为 5bp 和 20bp,分别找出使净 Sharpe 最大的平滑半衰期(在 1、3、5、10、20、40 中选)。成本与最优半衰期是什么关系? 提示:成本越高,最优半衰期越长;这是本册第 05 章交易成本惩罚的一个特例。
- 1.3 节的组合是美元中性的,但不是 β 中性的。计算组合每日的 β 暴露(权重与
beta的内积),并把权重投影到"与beta正交"的方向上,比较投影前后的年化波动。 提示:\(\boldsymbol w_\perp=\boldsymbol w-\frac{\boldsymbol w'\boldsymbol\beta}{\boldsymbol\beta'\boldsymbol\beta}\boldsymbol\beta\),再重新归一化总杠杆。 - 写一个函数,检查长表中每只股票的日期是否严格递增、是否有重复的(日期,资产)键,并用 1.4.1 节的
df测试。 - 给 1.7 节的
lookahead_test增加对"股票池"的扰动:把 \(t_0\) 之后新上市的股票删掉,检查 \(t_0\) 之前的截面标准化信号是否改变。哪一类信号会因此失败? 提示:若截面标准化在宽表全部列上计算,而未上市股票填了 0 而不是 NaN,就会失败。 - 用
SeedSequence派生 8 个子种子,并行(或串行模拟并行)运行 1.5.2 节的 18 个配置各 8 次(每次重新生成合成数据),统计最优配置被选中的频率。它说明了什么?
延伸阅读
- 第 03 册第 10b 章(多重检验与策略过拟合),特别是 10b.6.5 节"研究流程上的防线"。
- 第 03 册第 22b 章 22b.7 节(时间序列中的交叉验证)。
- 第 05 册第 09 章(基于多元回归的研究评估:内部有效性与外部有效性)。
- 第 07 册第 08 章(人们为何交易)与第 21a 章(交易成本度量)。
- López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley. 关于研究组织方式与回测过拟合的各章。
- 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).
- Grinold, R. C., & Kahn, R. N. (2000). Active Portfolio Management (2nd ed.). McGraw-Hill. 主动管理流程的整体框架。