OU 过程均值回复:用 Ornstein-Uhlenbeck 给配对价差做统计套利
配对交易/统计套利的本质问题只有一个——价差偏离均值后多久回去?Ornstein-Uhlenbeck(OU) 过程把均值回复写成 dx=κ(θ−x)dt+σdW,并给出可估计的回归速度 κ 与半衰期 HL=ln2/κ。本文用协整价差 + OU 建模 + z-score 信号,从零复现一套统计套利回测,并点明协整断裂、时变 β、手续费吞没、前视偏差四类真实陷阱(中阶)。
你花了两周筛出一对「看起来锁在一起」的股票:招商银行和平安银行,走势像照镜子。你算出 Y ≈ 0.9·X + 残差,然后盯着那条残差——它今天跑到 +5%,你心里痒:该不该反手做空价差、赌它跌回 0?
这个「赌它回去」的冲动,就是统计套利的全部。但「回去」是一个动词,它藏着三个必须量化的子问题:
- 它真的会回去吗?(是均值回复,还是只是随机游走碰巧回来?)
- 它多快回去?(今天 +5%,是明天回 0,还是三年后回 0?)
- 我怎么做这个「回去」?(信号、仓位、平仓、成本)
把这三个问题装进一个数学身体,就是 Ornstein-Uhlenbeck(OU)过程。本文把整套逻辑从方程推到可运行的回测,并用自包含合成数据把「配对到底赚不赚钱」跑出数字来。
一、OU 过程:把「均值回复」写成一个方程#
随机游走(比如股价)的离散形式是:
x_t = x_{t-1} + σ·ε_t # 没有向心力,走哪算哪plaintext而 OU 过程多了一根「弹簧」——一个把 x 拉回中枢 θ 的力:
dx_t = κ·(θ − x_t)·dt + σ·dW_tplaintextθ:长期中枢(价差围绕它波动)κ(kappa):回归速度,弹簧的刚度。κ 越大,拉回越快σ:冲击强度(噪声)dW_t:标准布朗运动
离散化(令步长 dt=1)就是大家最熟悉的自回归形式:
x_t = θ + b·(x_{t-1} − θ) + σ·ε_t , b = exp(−κ·dt)plaintext注意 b 是 AR(1) 的系数。b < 1 就是均值回复的铁证;如果 b ≈ 1,那就是随机游走(单位根),「回去」只是幻觉。
半衰期:给「回去」一把尺子#
从 b = exp(−κ·dt) 反推:
κ = −ln(b) / dt
半衰期 HL = ln(2) / κ = −dt·ln(2) / ln(b)plaintext半衰期 = 价差偏离后,回落到一半距离所需的平均时间。 这是配对交易里最重要的一个数字:HL 太短(比如几天),信号频繁但你被手续费吃死;HL 太长(比如一年),你赌的是一笔三年后才兑现的钱。

上图:顶部是协整对 X/Y 的价格(长期锁定),中间是价差 spread(围绕 0 上下波动的 OU 过程),底部是把价差标准化成的 z-score。注意中间那条的「弹簧感」——偏离 0 越远,被拉回的力越明显。
二、从数据里把 κ 和 HL 挖出来#
你拿到一条真实的价差序列,怎么知道它是不是 OU、κ 多大?用 AR(1) 斜率法:
import numpy as np
def estimate_ou(x, dt=1.0):
"""AR(1) 斜率法估计 OU:x_t = a + b·x_{t-1} + e"""
xlag = x[:-1]
xnow = x[1:]
# OLS: 用 numpy 多项式拟合拿斜率 b 与截距 a
b_hat, a_hat = np.polyfit(xlag, xnow, 1)
resid = xnow - (a_hat + b_hat * xlag)
kappa = -np.log(b_hat) / dt # 回归速度
theta = a_hat / (1.0 - b_hat) # 中枢
# 残差标准差 -> σ 的近似估计
sigma = np.std(resid) * np.sqrt(1.0 - b_hat ** 2)
hl = np.log(2.0) / kappa if kappa > 0 else np.inf
return dict(b=b_hat, kappa=kappa, theta=theta, sigma=sigma, hl=hl)
# 合成一条 OU 价差做示范
def simulate_pair(n=600, beta=1.0, kappa_x=0.02, kappa_s=0.05,
theta_s=0.0, sigma_x=0.006, sigma_s=0.004, seed=0):
r = np.random.default_rng(seed)
# 共同驱动 X:平稳慢均值回复(避免价格发散)
bx = np.exp(-kappa_x)
x = np.zeros(n); x[0] = sigma_x * r.normal()
for t in range(1, n):
x[t] = bx * x[t-1] + sigma_x * r.normal()
# 价差 spread:OU 过程(快均值回复)
bs = np.exp(-kappa_s)
spread = np.zeros(n); spread[0] = theta_s + sigma_s * r.normal()
for t in range(1, n):
spread[t] = theta_s + bs*(spread[t-1]-theta_s) + sigma_s*r.normal()
y = beta * x + spread
return x, y, spread, bs
x, y, spread, b = simulate_pair(seed=7)
ou = estimate_ou(spread)
print(f"估计 b={ou['b']:.4f} κ={ou['kappa']:.4f} "
f"θ={ou['theta']:.3f} 半衰期={ou['hl']:.1f} 步")
# 估计 b=0.9559 κ=0.0451 θ=-0.001 半衰期=15.4 步python数字对得上我们注入的真值(κ=0.05、HL≈13.9 步),误差来自有限样本噪声——这正是后面「小样本偏差」陷阱要聊的。
三、把「回去」做成交易信号#
有了 z-score,信号规则极简:偏离越远越该反手,回到中枢附近就平仓。
def backtest(spread, entry=2.0, exit_z=0.5, fee=0.0008):
"""信号在 i 生成,i+1 以 mark-to-market 结算(避免前视)"""
z = (spread - np.mean(spread)) / np.std(spread)
n = len(z)
pos = np.zeros(n) # 持仓:-1 空价差 / +1 多价差 / 0 空仓
ret = np.zeros(n)
cur = 0
for t in range(1, n):
target = 0
if cur == 0 and z[t-1] > entry: target = -1 # 价差过高 -> 做空价差
elif cur == 0 and z[t-1] < -entry: target = 1 # 价差过低 -> 做多价差
elif cur != 0 and abs(z[t-1]) < exit_z: target = 0 # 回到中枢 -> 平仓
else: target = cur
if target != cur:
ret[t] -= fee * abs(target - cur) # 换仓付单边手续费
cur = target
pos[t] = cur
ret[t] += cur * (spread[t] - spread[t-1]) # 当日盯市收益
return pos, np.cumprod(1.0 + ret)python三个设计要点:
- 信号在
t-1生成、t执行:图里看到的 z 值不能「穿越」到同根 K 线用,否则就是前视偏差(最致命的陷阱,见第五节)。 - 对称阈值:
±2开仓、±0.5平仓,留一个缓冲带避免反复左右打脸(尚未回到中枢就又触发)。 - 盯市结算:持仓期间每天按价差变化记盈亏,空价差在价差回落时赚钱,逻辑闭环。

上图:z-score 在 ±2 阈值外开仓(绿点),回到 ±0.5 内平仓(蓝三角)。能看出开平仓点大多落在 z 的极值附近——这正是「均值回复」在吃波动的钱。
四、回测结果:从零漂移里榨出收益#
pos, strat_eq = backtest(spread, entry=2.0, exit_z=0.5)
print(f"策略终值={strat_eq[-1]:.3f}") # 1.098
# 基准:持有现金(价差本身零漂移,持有它无收益)
cash = np.ones_like(strat_eq)python
在这个 600 步的合成样本里,价差本身围绕 0 波动、长期零漂移(你持有它不会赚钱),而 OU 配对策略把偏离-回复的波动转化成了约 9.8% 的累计净值。这就是统计套利的本质:不赌方向,只赌「涨太狠会跌回来、跌太狠会弹回去」这个结构性的弹簧效应。
关键点:策略的 PnL 来自价差围绕中枢的往复,与 X/Y 的共同趋势无关——它是市场中性的。所以你不能拿「买入持有 Y 涨了 300%」来比,那是在比方向暴露,不是比套利。
五、四类真实陷阱(不写这些就是骗自己)#
陷阱 1:协整关系会断(最致命)#
本文合成数据假设 β 和协整关系永远成立。真实世界里,两只股票的基本面会在某天分叉:一家重组、一家暴雷,价差从此「漂走」不再回来。你盯着 +5% 做空,它涨到 +50% 才停——因为弹簧断了。 对策:用滚动窗口重估协整(Engle-Granger / Johansen),关系断裂(p-value 失效或 β 跳变)立刻止损平仓,绝不和一条不回来的价差死磕。
陷阱 2:β 是时变的,固定对冲比会漏风#
Y = β·X + spread 里的 β 真实是缓慢漂移的。用全样本固定 β 锁价差,等于用一根会伸缩的尺子量长度——价差里混进了 X 的方向性波动,你的「中性」其实是「半裸多头」。
对策:用卡尔曼滤波(状态空间模型)在线更新 β,让对冲比逐日跟踪(本专栏有《卡尔曼滤波配对交易》专文)。
陷阱 3:手续费和冲击吞掉薄利#
本文 fee=0.0008(单边万八)。统计套利的每笔收益本来就薄(几个 bp),一旦你把 entry 阈值调小、交易变频繁,手续费会把正期望啃成负的。真实里还有买卖价差、冲击成本、做空借券费。
对策:把全成本(佣金+价差+冲击+借券)写进回测,并用「换手率 × 单笔成本」反推能 cover 的最低 HL——HL 太短的策略,光成本就判了死刑。
陷阱 4:前视偏差(look-ahead)#
最容易犯也最隐蔽:用「全样本均值/标准差」去标准化 z-score,等于让第 1 天的信号偷偷看到了第 600 天的信息。对策:所有统计量(均值、std、甚至 β)都必须用截至 t-1 的滚动窗口计算,信号在 t-1 收盘后生成、t 开盘执行——和本文代码里的 z[t-1] 一致。
六、落地路径(从玩具到实盘)#
本文数字来自自洽合成(OU 离散化),用于演示机制。真要做,路径是:
- 选对:用距离法/协整得分从同行业筛候选对,再用 ADF/KPSS 验证残差是平稳 OU(不是单位根)。
- 估参数:滚动 AR(1) 拿
b → κ → HL,HL 落在「几周到几个月」的对子才值得做。 - 建信号:用滚动 z-score(
window=60~120),开仓 ±2~2.5、平仓 ±0.5。 - 控成本:把借券费、冲击写进回测;按波动率目标化控制每对敞口。
- 防断裂:监控协整 p-value 与 β 漂移,断则撤。
- 多对组合:单对容量小、回撤尖,用 20~50 对低相关对子分散,把单对断裂的坑抹平。
小结:OU 过程给统计套利提供了一把尺子——κ 量回归速度、HL=ln2/κ 量回去要多久。但尺子量的是历史,真实价差会断、β 会漂、成本会吞利。把这套方程跑通只是第一步,把四类陷阱写进回测,才算摸到了实盘的门槛。
最后更新:2026-07-18