日内均值回归的时间衰减:开盘半小时与尾盘的不同世界
「日内反转」不是一个常数——开盘 30 分钟中间价一阶自相关 -0.070,午后衰减到 -0.023,只剩三分之一。800 天 × 60 只股票的微观结构模拟把反转拆成两个可分别测量的乘数:暂时性冲击占比(开盘 5.09%、午后 1.94%)× 单 bar 释放比例(开盘 40.5%、午后 18.0%),乘积从 0.0209 掉到 0.0034——两股力量在日内反向变化,只看其中一个必然误判。最容易吃亏的一步在测量本身:用成交价测出的开盘 AC1 是 -0.075,比中间价多出 0.004,这部分是买卖价差回弹(Roll 效应)不是真反转;对照组给出更狠的证明——一个可预测性精确为零的随机游走,只要叠上价差回弹,天真回测能刷出每次 0.68bp、Sharpe 8.3 的「利润」,换成穿越价差的正确执行价立刻变成 -3.86bp、Sharpe -47.7。真实模型下只有开盘段扣掉价差后还剩 3.40bp,上午盘中 -1.82bp、午后 -2.76bp、尾盘 -3.76bp 全线为负。加上平方根冲击成本后临界容量约 382 万元,超过就归零。附带两个结构发现:持有期必须匹配该时段半衰期(开盘段从 1 bar 到 6 bar,Sharpe 6.5 → 20.1),以及收盘钟声会截断尾盘反转,46.4% 的未释放冲击外溢到隔夜(相关系数 -0.192,而盘中对照组为 -0.003)
“日内均值回归”这句话在实盘里是有歧义的。它到底指开盘那半小时的剧烈拉扯,还是下午两点半那种慢吞吞的来回?很多人默认这是同一件事,用同一套参数从 9:30 跑到 15:00。
这篇用 800 个交易日 × 60 只股票的微观结构模拟,把这个假设拆开测。结论先放这里:日内反转强度不是常数,开盘 30 分钟的一阶自相关是 -0.070,午后衰减到 -0.023,只剩三分之一。更要命的是,你用成交价测出来的数字里,有一部分根本不是反转。

一、先把「反转」拆成两个可测量的乘数#
均值回归这个词把两件不同的事混在了一起:
- 有多少比例的价格移动最终会被收回(暂时性冲击占比)
- 收回需要多久(衰减半衰期)
一个时段可以”暂时成分很高但收得极慢”,也可以”暂时成分不高但一个 bar 就收完”。这两种情况在自相关系数上可能给出相同的数字,但对交易者意味着完全不同的东西。
Hasbrouck 的经典分解把价格冲击分成永久部分(信息)和暂时部分(库存/流动性压力)。落到日内,三个时段的成因是不一样的:
- 开盘:隔夜信息集中释放。永久成分占主导,但同时订单不平衡也最大,做市商被动接货后急于回补——所以暂时成分虽然占比不高,释放速度极快
- 午后:成交清淡,既没有大额信息也没有大额冲击。暂时成分占比最低,且流动性稀薄导致回补慢
- 尾盘:MOC 单、指数跟踪单的机械冲击回升,但做市商临近收盘不愿加大隔夜敞口,回补反而更慢
模拟里我把这三个参数外生设定成可审查的曲线:
x = np.linspace(0, 1, N_BARS) # N_BARS = 48,即 5 分钟 bar
# 暂时性冲击占比:开盘与尾盘高,午后最低
trans_share = 0.052*np.exp(-x/0.12) + 0.016 + 0.050*np.exp(-(1-x)/0.09)
trans_share = np.clip(trans_share, 0.010, 0.20)
# 衰减半衰期(bar):开盘最快,尾盘最慢
halflife = 1.2 + 3.4*x**1.1 + 0.9*np.exp(-(1-x)/0.10)
decay = 0.5 ** (1.0 / halflife)python生成价格时,暂时冲击进入一个带记忆的状态变量,每个 bar 释放一部分:
for i in range(N_BARS):
f_mkt = sigma_bar[i] * 0.55 * rng.standard_normal() # 市场因子
idio = sigma_bar[i] * rng.standard_normal(N_STOCK)
shock = BETA * f_mkt + idio
perm = np.sqrt(perm_share[i]) * shock # 永久部分
new_tr = np.sqrt(trans_share[i]) * shock # 新增暂时部分
released = transient * (1 - decay[i]) # 旧暂时部分的释放
mid_ret[d, i] = perm + new_tr - released
transient = transient * decay[i] + new_trpython这里有个必须避开的坑:市场因子 f_mkt 一定要按 bar 独立抽样。我第一版把它写成了”当日市场状态”的常数,结果给所有 bar 注入了一个共同的正向成分,一阶自相关整体被推正,反转信号被系统性低估。这类 bug 不会报错,只会让你得到一个看起来合理但方向错误的结论。
分解结果:
| 时段 | 暂时占比 | 单 bar 释放比例 | 乘积(可捕捉强度) |
|---|---|---|---|
| 开盘 30 分钟 | 5.09% | 40.5% | 0.0209 |
| 午后盘中 | 1.94% | 18.0% | 0.0034 |
| 尾盘 30 分钟 | 4.60% | 13.1% | 0.0059 |

注意尾盘这一行:暂时占比 4.60%,几乎追平开盘的 5.09%,但可捕捉强度只有开盘的 28%。原因全在释放速度——尾盘半衰期 4.97 个 bar,开盘只要 1.34 个。同样多的”该收回的钱”,收不回来就不算你的。
只看暂时占比会得出”尾盘和开盘差不多”的结论,只看半衰期会得出”午后和尾盘差不多”的结论。两个都得看,而且要看乘积。
理论预测 AC ≈ -暂时占比 × (1-decay) 与实测自相关序列的相关性是 0.799——不是 1.0,因为实测值里还混着别的东西。这个”别的东西”就是下一节的主题。
二、你测到的反转,有一部分是假的#
用成交价算收益,会得到一个系统性偏负的自相关。这不是市场规律,是 Roll (1984) 描述的买卖价差回弹:成交在买价和卖价之间随机跳动,即使中间价完全不动,成交价序列也会呈现负自相关。
对比一下两种测法:
| 时段 | 中间价 AC1 | 成交价 AC1 | 价差回弹贡献 |
|---|---|---|---|
| 开盘 30 分钟 | -0.0705 | -0.0746 | -0.0041 |
| 上午盘中 | -0.0401 | -0.0469 | -0.0067 |
| 下午盘中 | -0.0230 | -0.0306 | -0.0075 |
| 尾盘 30 分钟 | -0.0242 | -0.0289 | -0.0047 |
午后受污染最严重:真实反转只有 -0.0230,成交价测出 -0.0306,虚高了 33%。因为午后真信号最弱,同样的价差噪音占比就最大。
这个偏差有多危险?我做了一个对照实验:构造一个可预测性精确为零的随机游走,只叠加买卖价差回弹,然后跑同一套反转策略。
# 纯随机游走 + 价差回弹
rw = sigma_bar[None,:,None] * rng.standard_normal((300, N_BARS, N_STOCK))
q2 = rng.choice([-1.0, 1.0], size=(300, N_BARS, N_STOCK))
rw_trade = 中间价累积 + q2 * (half_spread_bp[None,:,None] / 1e4)python结果:
- 中间价一阶自相关 +0.0005(正确,随机游走无反转)
- 成交价一阶自相关 -0.0120(凭空出现的”反转”)
- 天真回测(成交价信号、不扣价差):每次 +0.68bp,Sharpe 8.30,胜率 54.0%
- 正确执行(买付卖价、卖收买价):每次 -3.86bp,Sharpe -47.7,胜率 30.1%
一个数学上不可能有 alpha 的市场,天真回测能刷出 Sharpe 8.3。 全部差别只在两行代码:信号用成交价还是中间价,以及平仓时有没有付价差。

这是日内策略最常见的死法。它不像 look-ahead 那样有明显的时间穿越,它看起来完全合规——你用的确实是历史成交价,确实没用到未来数据。但你在用一个测量误差交易它自己。
三、扣掉正确的执行成本之后#
把执行改成现实版本:开仓时穿越入场 bar 的价差,平仓时穿越出场 bar 的价差。
if realistic_exec:
c_in = hs[i + 1] / 1e4 # 入场 bar 半价差
c_out = hs[min(i + hold, N_BARS - 1)] / 1e4 # 出场 bar 半价差
gross -= (c_in + c_out)python每次调仓的收益(多跌得最多的 10 只、空涨得最多的 10 只,持有 1 个 bar):
| 时段 | 天真(成交价信号+无价差) | 正确执行 | 中间价信号+正确执行 |
|---|---|---|---|
| 开盘 30 分钟 | +9.97bp | +3.15bp | +3.40bp |
| 上午盘中 | +2.06bp | -1.88bp | -1.82bp |
| 下午盘中 | +1.11bp | -2.73bp | -2.76bp |
| 尾盘 30 分钟 | +2.15bp | -3.84bp | -3.76bp |

四个时段里三个在正确执行下是负的。 天真口径下四个全正,而且看起来”午后虽然弱但也能赚”。加上价差之后,午后从 +1.11bp 翻成 -2.73bp——不是利润变薄,是方向反了。
只有开盘段活下来,剩 3.40bp。原因不是开盘价差便宜(恰恰相反,开盘双边价差 7.68bp 是午后 3.65bp 的两倍多),而是开盘的可捕捉强度 0.0209 足够大,扛得住这个价差。
尾盘是最典型的陷阱:暂时占比 4.60% 看起来很有吸引力,双边价差 5.76bp 也不算离谱,但半衰期 4.97 个 bar 意味着 1 个 bar 的持有期只能收回其中很小一部分,净收益 -3.76bp。
四、持有期必须匹配半衰期#
上一节固定持有 1 个 bar,这对开盘段(半衰期 1.34)大致合理,对其他时段就是错配。扫描持有期:
| 持有 bar 数 | 开盘 30 分钟 Sharpe | 下午盘中 Sharpe |
|---|---|---|
| 1 | 6.48 | -21.76 |
| 2 | 14.47 | -10.09 |
| 3 | 17.29 | -5.47 |
| 4 | 18.98 | -2.49 |
| 6 | 20.06 | -0.24 |
| 8 | 19.90 | +0.49 |

开盘段从 1 bar 到 6 bar,Sharpe 从 6.5 涨到 20.1——三倍多,只是改了持有期,信号一个字没动。原因很直白:持有 1 个 bar 只能收到第一次释放,而价差成本是一次性付清的。拉长持有期,成本摊薄,收益累积。
午后即使拉到 8 个 bar 也只是勉强回正(+0.49),因为它的问题不是持有期,是可捕捉强度本身太小(0.0034,开盘的六分之一)。持有期能修错配,修不了没有信号。
五、容量墙:Sharpe 20 是个幻觉#
上面开盘段的 Sharpe 20.06 看着很吓人,但那是零冲击成本口径。加上平方根冲击模型:
# impact_bp = K * sigma_bar * sqrt(下单金额 / 该 bar 成交额)
part = notional / bar_turnover[i + 1]
imp_bp = 1e4 * IMPACT_K * sigma_bar[i + 1] * np.sqrt(part)python假设日均成交额 3 亿元/只,按日内成交量 U 型分配到各 bar:
| 策略规模 | 冲击成本 | 净收益 | 净 Sharpe |
|---|---|---|---|
| 100 万 | 1.74bp | +1.66bp | 3.17 |
| 500 万 | 3.88bp | -0.48bp | -0.92 |
| 1000 万 | 5.49bp | -2.09bp | -3.98 |
| 5000 万 | 12.27bp | -8.87bp | -16.91 |
| 2 亿 | 24.54bp | -21.14bp | -40.30 |
临界容量约 382 万元。 超过这个规模,反转利润被自己的冲击吃光。
这个数字才是日内反转策略的真相:它是真的,但它小到几乎不构成一门生意。Sharpe 20 和容量 382 万同时成立,两者不矛盾——高 Sharpe 说的是信噪比,容量说的是这个信号能装多少钱。做市商靠它吃饭是因为他们在价差的另一侧(提供流动性、收价差),而不是像我们这样穿越价差去拿。
六、收盘钟声会截断反转#
最后一个结构性发现。尾盘产生的暂时性冲击,如果在收盘前没释放完,它不会消失——它会外溢到隔夜和次日。
模拟里我让未释放的暂时冲击有 55% 在隔夜释放:
on_release = transient * 0.55
overnight[d] = -on_release + 0.004 * rng.standard_normal(N_STOCK)
carry = transient * 0.45 # 剩下的带到次日python实测:
- 尾盘 30 分钟收益与隔夜收益相关系数:-0.192
- 盘中 30 分钟收益与隔夜收益相关系数:-0.003(对照组,确认没有伪相关)
- 尾盘冲击中未在日内释放的比例:46.4%
尾盘冲得越猛,隔夜回落越多,而盘中同样大小的移动没有这个性质。这不是”隔夜反转”这个泛泛的日历效应,它有明确的时段来源——只有被钟声打断的那部分冲击才会外溢。
对实盘的含义有两层。做尾盘反转的,你的持有期被硬性截断在收盘,剩下 46.4% 的反转你吃不到,除非愿意扛隔夜敞口。而做隔夜策略的,反过来,你的部分收益其实是在给尾盘的流动性提供者当对手盘——这部分收益跟隔夜信息无关,是纯粹的库存回补。
七、三个自检:我怀疑过什么#
按惯例列出审查过并排除的点。
怀疑一:反转是不是市场因子造成的伪相关? 第一版代码把市场因子写成了全天常数,导致所有 bar 共享一个正向成分。改成按 bar 独立抽样后,纯随机游走对照组的平均自相关是 +0.0005、绝对值最大 0.0215,策略收益 -0.0098bp(t = -0.12)。引擎不会凭空造出反转。
怀疑二:横截面 demean 会不会引入偏差? 每个 bar 我都对信号和未来收益做了横截面去均值。这一步会移除共同的市场成分——好处是策略变成纯横截面中性,坏处是如果未来收益的去均值用了同期截面信息,理论上有轻微的同期信息泄漏。但因为多空两侧对称,这个偏差在多空价差上抵消。随机游走对照组给出的 -0.12 t 值确认了这一点。
怀疑三:Sharpe 20 是不是过拟合? 不是过拟合,是漏掉了约束。参数全部外生设定,没有任何一个是拟合出来的,也没有做参数搜索。Sharpe 20 之所以出现,是因为零冲击成本 + 无限容量 + 完美执行三个假设叠加。加上平方根冲击后临界容量 382 万——这不是”策略不好”,是”策略容量小”,两个是不同的结论。
结论#
日内反转是真的,但它有三个必须同时满足的约束:
- 只在开盘段有正的净收益(扣价差后 +3.40bp,其余三个时段全负)
- 持有期必须匹配该时段半衰期(开盘段 1 bar → 6 bar,Sharpe 6.5 → 20.1)
- 容量约 382 万元,超过就被自己的冲击吃掉
而在你验证这三条之前,还有一个更前置的问题:确认你测的是中间价而不是成交价。一个零 alpha 的随机游走加上价差回弹,天真回测能给出 Sharpe 8.3。这个数字足够好看到让人跳过所有后续检查——这正是它危险的地方。
局限与偏差#
冲击模型是外生假设。 平方根律的系数 K=0.35 取自公开文献的经验范围,但真实冲击依赖标的、时段、订单类型和市场状态,且日内成交量 U 型分配本身是简化。临界容量 382 万这个数字的量级可信,精确值不可信。
暂时/永久二元分解过于干净。 真实世界里冲击的衰减不是单一指数,而是多尺度混合(毫秒级做市商回补、分钟级套利者介入、小时级基本面资金)。用单一半衰期刻画会低估短尺度、高估长尺度。
没有建模日内流动性的状态依赖。 模拟里价差和深度是确定性的日内曲线,真实市场在波动放大时价差会瞬间走宽——而这恰恰是反转信号最强的时刻。这意味着开盘段的 +3.40bp 很可能是乐观估计:你最想交易的那些 bar,正是价差最贵的 bar。
A 股的额外约束未纳入。 T+1 制度让日内多头无法当日平仓,个股融券成本高且券源不稳定,这两条会让上述多空框架在 A 股难以直接实施。本文的适用场景更接近 T+0 市场或 ETF/股指期货。