合成数据生成:Ornstein-Uhlenbeck 回测
历史只走出了一条路径,你的回测数字是那条路径的产物。用 OU 过程造一千条统计性质相同的路径,你拿到的是一个分布而不是一个数——以及它会在哪些地方骗你。
你的回测只见过一条路径
历史回测有一个藏得很深的偏倚:你测的是策略在市场碰巧走出的那一条路径上的表现。策略在这条路径上赚钱,不等于它在另一条路径上也赚钱。市场完全可以走出另一条统计性质完全相同的路径,而你的策略在那条路径上亏掉本金。
这不是哲学抬杠。你的回测输出一个夏普,比如 1.6。这个数字的不确定性有多大?单条路径的回测不回答这个问题——它连问都没法问,因为它只有一个样本点。
修正办法很直接:造一批统计性质相同的合成路径,在每一条上跑同一个策略。你得到的不是一个夏普,是一个夏普的分布。分布的均值是你的期望表现估计,分布的宽度是你对这个估计的信心。这个东西比单一数字诚实得多。
这套做法的参考是 AFML 第 13 章(“Backtesting on Synthetic Data”),基础模块是 Ornstein-Uhlenbeck 过程。
(López de Prado, M., Advances in Financial Machine Learning, 第 13 章。ISBN 978-1119482086。)
OU 过程:一块可以随便复制的积木
OU 是均值回归的随机过程:
其中 是均值回归速度, 是长期均值, 是波动率, 是标准布朗运动。漂移项 就是全部的机制:偏离越远,往回拉的力越大。
用 Euler-Maruyama 离散化,写成能直接进循环的形式:
其中 。
OU 是平稳的:常数均值 、常数方差 ,滞后 处的自相关是 ,指数衰减。这三条性质决定了它能用在哪儿——价差、基差、波动率、期限结构,这些量本身就有一个”该在的位置”。价格没有。对趋势量硬套 OU,你会生成一堆围着历史均值打转的假路径,然后得出一个”策略在任何路径上都能均值回归赚钱”的结论,而这个结论完全是你自己的模型设定造出来的。趋势量用几何布朗运动或者别的带漂移的过程。
(Uhlenbeck, G. E., & Ornstein, L. S., “On the Theory of the Brownian Motion,” Physical Review, 36(5), 1930, pp. 823–841。)
把参数校准出来(以及一个容易写错的地方)
给定一段历史序列,要把 估出来。长期均值就是样本均值:
回归速度来自滞后 1 的样本自相关 。离散观测下 OU 就是一个 AR(1),其自回归系数等于 ,反解得到:
这里有个前提: 必须为正。如果你的序列滞后 1 自相关是负的或接近零, 要么没定义要么爆出一个巨大的 ,意思是这段数据根本不像 OU,该换模型而不是硬算。
波动率这一步是最容易写错的。 要从残差里估,而不是从增量里估:
其中 是 AR(1) 残差 的样本方差。分母 正是把残差方差换算回 的因子,它和残差配对才成立。如果你在分子上放的是一阶差分 的方差,那你多算了一份均值回归拉回来的位移, 会系统性偏大——而且 越大偏得越多。这个错误在实现里很常见,因为”增量的方差”听起来天经地义。
Python 里的一个最简校准:
import numpy as np
def calibrate_ou(series, dt=1.0):
"""Calibrate OU parameters from a time series."""
mu = series.mean()
rho = np.corrcoef(series[:-1], series[1:])[0, 1]
kappa = -np.log(rho) / dt
resid = (series[1:] - mu) - rho * (series[:-1] - mu)
s_eps_sq = np.var(resid, ddof=1)
sigma_sq = s_eps_sq * 2 * kappa / (1 - np.exp(-2 * kappa * dt))
sigma = np.sqrt(sigma_sq)
return {'mu': mu, 'kappa': kappa, 'sigma': sigma}
给个量级感:一个日频序列,滞后 1 自相关 0.95,算出来 /天,按 252 个交易日折算大约 13/年。半衰期是 天——价差偏离之后大约两周半回到一半。这个数你应该对着原序列看一眼是否说得通;对不上,多半是校准窗口跨了不同的市场状态。
一千条路径长什么样
参数有了,生成路径就是把离散化公式塞进循环:
def generate_ou_path(mu, kappa, sigma, x0, n_steps, dt=1.0, seed=None):
"""Generate one OU path."""
rng = np.random.default_rng(seed)
path = np.zeros(n_steps)
path[0] = x0
for t in range(1, n_steps):
path[t] = path[t-1] + kappa * (mu - path[t-1]) * dt + sigma * np.sqrt(dt) * rng.standard_normal()
return path
换 seed 反复调用,就得到一族路径:均值、方差、自相关都一样,具体实现各不相同。回测时通常生成 1000 条以上,在每条上跑一遍策略,收集夏普。用 10 条也能出图,但那张图的形状主要是抽样噪声,不是策略的性质。
AFML 第 13 章的框架比这块积木走得远。它想让合成数据在三个层面上贴住历史:边际分布要有相同的均值、方差、偏度、峰度;序列依赖要有相同的自相关结构;多资产时横截面依赖要有相同的相关结构。做法是把边际分布和依赖结构拆开各自校准,再用 copula 拼回去——边际通常用核密度估计拟合经验分布,依赖结构用经验相关矩阵。
不想上 copula 的话,有一个够用的简化版:
- 把历史数据经经验 CDF 做边际变换之后,拟合成多元正态。
- 用相同的协方差矩阵生成合成多元正态样本。
- 通过经验逆 CDF 变换回原始边际分布。
出来的合成数据边际分布和相关结构都和历史数据对得上。代价是它把依赖结构简化成了线性相关,尾部相关那部分丢了——这一点后面还会咬回来。
把历史那个数放回分布里读
有了分布,历史回测那个孤零零的夏普就不再是结论,而是分布上的一个点。最严谨的用法是两个都跑,然后比位置:跑历史回测拿到夏普估计,生成 1000 条合成路径拿到夏普分布,看历史值落在哪儿。
落在分布主体里,说明策略表现和它的统计性质所允许的差不多,没有明显的路径过拟合。落在上尾,两种解释都成立——可能是过拟合到了那条特定路径,也可能真抓到了合成模型没建模的优势,这时候需要下一节的工具来分。落在中位数以下,可能是运气差,也可能是策略压根没利用上这些统计性质,反而被自己的规则拖累。
这就是合成回测真正解决的问题。它把”一条路径”的偏倚换成了”很多路径”的不确定性——后者你至少能量化。历史回测给你一个点估计而不告诉你误差棒,合成回测给你误差棒。
还有个便宜的诊断:把历史回测的净值曲线和十几条合成回测的曲线画在同一张图上。如果历史那条混在里面看不出来,策略稳健;如果它一眼就是最漂亮的那条,怀疑过拟合。这比看统计量直观,而且能看出形状上的异常——比如历史曲线光滑得不像话。
(AFML, 第 13 章。)
搜索过的夏普要先去膨胀
上面这套有一个可以整个失效的方式:你在合成数据上回测了 100 个策略,挑出夏普 2.5 的那个,宣布策略很好。这个结论忽略了多重检验——100 个毫无优势的策略里,本来就该有几个凭运气拿到高夏普。合成数据不能豁免这条,它只是把搜索的战场换了个地方。
去膨胀夏普比率(DSR)就是干这个的。它把多重检验、收益的非正态性和记录长度一起算进去,是评估”搜出来的”策略的正确指标:
其中 是观测夏普(与 同频率,不年化), 是收益观测数, 是偏度, 是峰度, 是标准正态 CDF。分母里的偏度和峰度项是关键:负偏、厚尾会放大夏普估计本身的方差,同一个 在厚尾收益上的证据强度更低。
阈值 不是 0,而是 次试验下纯噪声能达到的期望最大夏普:
其中 是这 次试验的夏普估计的横截面标准差, 是 Euler-Mascheroni 常数。 进对数量级,所以试 100 个和试 10000 个的门槛差不了几倍——但和试 1 个差很多。DSR 读作”在校正多重检验之后,真实夏普高于该阈值的概率”,0.95 以上才算这个策略大概站得住。
关键是 得老实填。你自己心里清楚试过多少个变体;填 1 的话这个公式什么也纠正不了。
(Bailey, D. H., & López de Prado, M., “The Deflated Sharpe Ratio,” Journal of Portfolio Management, 40(5), 2014。)
一个完整的例子
设定:两个协整资产的价差上的均值回归策略。价差大致服从 OU,、、。历史数据 5 年,1250 个日观测。
校准到历史价差,拿到 (略为正)、(约 12/年)、。然后生成 1000 条路径,每条 1250 天长,参数相同、随机种子不同。
策略规则很简单:价差偏离均值 2 个标准差时入场,回归时出场。在每条合成路径上跑一遍,记录夏普。
出来的分布大致正态,均值 1.3、标准差 0.4;按正态分位算,5 百分位约 0.64,95 百分位约 1.96。这一步就已经有信息了:即便策略、参数、统计性质全部固定,纯粹因为路径不同,夏普也能在 0.64 到 1.96 之间晃。任何把回测夏普报到小数点后两位的做法,在这个宽度面前都是自欺。
历史回测给出的夏普是 1.6,在分布的第 77 百分位左右——偏上,但远不到异常。DSR 算出来 0.92,低于 0.95 的线,意思是策略大概率是真的,但还留着一点不确定性没被排除。
合起来读:这个策略的期望夏普在 1.3 附近,实际拿到多少很大程度上取决于市场走哪条路径;历史那个 1.6 和这个范围一致,不需要用过拟合来解释。这个结论比”回测夏普 1.6”信息量大得多,也保守得多。
(AFML, 第 13 章。)
同一根杠杆反过来用:压力测试
生成路径的参数是你填的,这意味着你可以填历史没出现过的参数。这是合成数据除了不确定性量化之外的第二个用途,而且往往更有价值——历史数据回答不了”如果波动率翻倍会怎样”,因为样本里可能压根没有那种时期。
沿用上面的例子,三个方向各推一把:把 设成 2(校准值是 1.05),模拟高波动状态,看策略还能不能用;把 设成 0.01(校准值是 0.048),模拟均值回归明显变弱的状态,看优势还在不在;把 设成 1(校准值是 0.02),模拟价差出现持续偏移的状态——这一档往往最致命,因为”回归到均值”的策略在均值本身漂移时会一直加仓错误方向。
每个压力场景生成 100 条以上路径再评估。一个常见的结果是:在校准状态下表现很好的策略,在压力状态下直接失效。这基本就是对历史状态过拟合的信号。稳健的策略不需要在压力状态下同样赚钱,但应该在大多数压力状态下夏普仍然为正,或者至少亏得有限、可预期。
模型会在哪里骗你
合成数据的诚实是有边界的,而且边界的位置由你选的模型决定。
最根本的一条:合成路径不是未来。它们由校准出来的统计性质生成,如果未来的状态和历史状态不一样,这些路径就对不上。合成回测告诉你的是”策略在可信的替代历史上会怎样”,不是”策略在实际未来会怎样”。这两件事的差距,就是你对市场结构稳定性的假设有多强。
尾部是第二个漏洞,而且是 OU 特有的。OU 的增量是高斯的,真实金融收益是厚尾的——极端事件出现的频率比高斯预测的高。所以 OU 生成的路径系统性地低估尾部事件,任何靠尾部行为吃饭或者被尾部行为杀死的策略(卖期权是最典型的)在这套框架下会被严重错估,而且是往好的方向错估。上一节那个用经验 CDF 做边际变换的简化版能补回一部分边际的厚尾,但多资产的尾部相关——危机时什么都一起跌——线性相关矩阵是抓不住的。
第三,路径相关的策略难评估。基于回撤的仓位管理、持有到目标价才平仓这类规则,依赖的是事件的具体顺序。统计性质完全相同的两条路径,回撤序列可以差得很远,策略结果自然也不同。这不算是缺陷——合成分布的宽度本来就该把这种波动包进去——但它意味着这类策略的分布会比看起来更宽,你需要更多路径才能把分位数估稳。
最后,横截面相关会变。历史相关矩阵在未来不一定成立,而合成数据忠实地保留了它。你保留的可能正是那个即将失效的东西。
模型选错是把上面所有问题一次性放大的做法。OU 适合均值回归量;趋势量用随机游走或几何布朗运动;波动率用 GARCH;多资产用带合适依赖结构的多元模型。用 OU 生成股价路径然后发现”均值回归策略很赚钱”,你测的是你的模型,不是你的策略。
(AFML, 第 13 章。)
不是均值回归量怎么办:马尔可夫链
对离散信号——订单簿状态、状态标签、离散化后的信号档位——OU 完全不适用,连续过程在离散状态上没有意义。这时候用马尔可夫链。
马尔可夫链由转移矩阵 定义, 是一个时间步内从状态 转到状态 的概率。校准就三步:
- 把历史信号编码为状态序列。
- 统计转移次数: = 状态 之后出现状态 的次数。
- 估计 。
生成时从状态的实证分布开始,每一步按 转移即可。这样保留下来的是离散信号该有的东西:平稳分布、转移概率、自相关结构。对牛市/熊市/震荡这类多状态标签,马尔可夫链还会以正确的概率生成状态切换——这是 OU 给不了的,OU 只有一个状态。
局限在阶数上。1 阶链的当前状态只依赖前一个状态,长记忆依赖捕捉不到;如果你的信号有”连续三天同向之后更可能继续”这种结构,1 阶链会把它抹平。要更长的记忆就上高阶链(2 阶、3 阶……),或者用隐马尔可夫模型——代价是参数个数随阶数指数增长,样本很快就不够了。
(用于状态检测的隐马尔可夫模型在 Hamilton, J. D., “Analysis of Time Series Subject to Changes in Regime,” Journal of Econometrics, 53(1-3), 1990, pp. 7–33 中讨论。)
块自助法:便宜的那条路
OU 和马尔可夫都要求你先承诺一个模型。块自助法不用:
- 从历史序列中提取长度为 的重叠块。
- 有放回地重采样这些块。
- 把重采样的块拼接起来形成合成序列。
块内结构(自相关、分布性质)被保留,块间结构被打散。合成序列本质上是历史片段的随机重排。取 (一个交易月),月度尺度的模式留着,年度尺度的没了——生成出来的东西像是从历史里随机抽月份拼成的一年。
它的好处是实现只要几行、不需要任何分布假设,而且偏度和峰度这些高阶矩是从数据里直接带过来的,不像 OU 那样被高斯假设抹掉。厚尾问题在这条路上基本不存在。
代价也清楚。合成序列被历史块封死了,出不了新模式——历史上没发生过的组合,自助法造不出来。块的接缝处会出现不真实的跳变, 越小接缝越多。长记忆结构跨不过块边界,所以跟 1 阶马尔可夫链一样丢长依赖。
所以它适合做快速的健全性检查,严谨度不如基于模型的路线。当补充用,不当替代用。
(Künsch, H. R., “The Jackknife and the Bootstrap for General Stationary Observations,” Annals of Statistics, 17(3), 1989, pp. 1217–1241。)
三种方法不一致的时候,信息量最大
三条路都摆出来了,稳妥的做法是三条都走:OU 生成 1000 条、块自助法生成 1000 条、马尔可夫链生成 1000 条,然后比三个夏普分布。
分布形状接近,说明策略表现对合成方法的选择不敏感,结论可以放心用。分布明显不同,说明你的结论其实取决于建模选择,这时候真正该做的是搞清楚哪个假设在起作用——通常是尾部、长记忆或者状态切换三者之一。
有一个几乎每次都会出现的现象值得先知道:OU 的夏普分布比另外两个紧,因为 OU 路径本身比自助法或马尔可夫路径平滑;块自助法的分布最宽,因为它能把历史上不相邻的异常时期拼在一起,造出比真实历史更难看的序列。
这个差异不是谁对谁错,是用途不同。做风险管理,取宽的那个(块自助法)更保守,你想知道的是坏情况能有多坏。做期望表现估计,紧的那个(OU)可能更准,因为它没有把人为拼接产生的不连续算进去。两个都看,用途决定你信哪个。
值多少钱
拿到手的是一个更诚实的表现估计。跨合成路径的平均夏普比历史夏普更接近期望表现,路径间的标准差直接给了你不确定性。
方向上你该预期估计值往下走。历史夏普是在一条被你反复看过的路径上算出来的,合成均值会明显低于它——打掉两三成到一半是常见的量级,但这个幅度随策略、样本长度和你搜索过多少变体变化很大,不该当成固定折扣用。上面那个例子里,历史 2.0 的策略对应合成均值 1.3、标准差 0.5 的分布是完全正常的:2.0 太乐观,1.3 更现实,而 0.5 的标准差告诉你连 1.3 都别当成确定的数。
代价是工程量。生成合成数据、在每条路径上跑一遍回测、再分析分布,需要写代码也需要算力,1000 条路径 × 1250 天的回测不是随手跑跑。简单策略、参数少、逻辑透明的,历史回测加一点常识可能就够了。参数多、经过大量搜索、你自己都说不清为什么这套参数最好的复杂策略,合成回测的投入回报率最高——因为那正是最可能骗到你的情形。
放到整个评估链条里看,合成回测只是一环。历史回测是基线,告诉你在实际发生过的那条路径上会怎样;合成回测给出一批可信的替代历史,把结论从点变成分布;压力测试用极端参数逼出策略的失效边界;walk-forward 优化在滚动窗口上重拟合重评估,检验策略跟不跟得上条件变化;模拟盘是唯一能暴露滑点、成交和延迟这些现实摩擦的一环,前面四种全都假设你能按理论价成交。
五种方法各有各的盲区,也各自补上前一种的盲区,所以严谨的评估是全跑一遍。全通过的策略才是实盘部署的候选;任何一环不过,回去查为什么,而不是绕过去。
(López de Prado, M., Advances in Financial Machine Learning, 第 13 章。ISBN 978-1119482086。)