4. 异常扩大的直观判定与基线建立
在上一节中,我们已经能够从盘口快照中读出买卖价差,并识别出价差在短时间内的跳变。但“价差变大”本身并不等于“异常”——开盘集合竞价后的头几分钟、午间休市前后、以及重大消息发布瞬间,价差天然会扩大。真正需要警惕的是:当前价差相对它自身的正常波动水平,偏离得足够远、持续得足够久。因此本节的核心任务,是把“异常扩大”从主观感觉转化为可计算、可复现的判定,并完成第一轮基于阈值的异常标记。
一、为什么必须建立“基线”而不是用固定阈值
初学者最容易犯的错误,是给所有标的设一个统一阈值,例如“价差超过 5 个最小变动价位就算异常”。这种做法在流动性差异巨大的市场里几乎必然失效:一只日均成交额数十亿的大盘股,价差长期在 1~2 个 tick;而一只小盘股,价差常态就在 8~15 个 tick。用同一个绝对阈值,前者会被频繁误报,后者则几乎永远触发不了。
更合理的思路是让每个标的“和自己比”。基线要回答的问题是:在正常状态下,这个标的的价差通常落在什么范围? 一旦当前值显著超出这个范围,才进入异常候选。基线建立有三条常用路径,它们各有侧重,实践中往往组合使用:
- 滚动均值 + 标准差:假设价差在局部窗口内近似平稳,用均值刻画中心、用标准差刻画离散度,偏离超过 k 倍标准差即视为异常。计算简单、响应快,但对厚尾和突发跳变敏感。
- 滚动分位数:不假设分布形态,直接取窗口内的高分位(如 90%、95%)作为“正常上界”。对偏态分布更稳健,适合价差这种右偏明显的指标。
- 历史分布:用更长周期(如过去 20 个交易日同时段)的价差分布,取分位点作为参照。它能吸收日内季节性,但需要足够长的历史样本,且对 regime 变化反应较慢。
二、滚动窗口的关键参数与计算过程
无论采用哪种方法,都要先确定三个参数:窗口长度、滚动步长、判定倍数或分位点。以 1 分钟频的盘口快照为例,一个常见的教学设定是:窗口取 60 个观测(约 1 小时),步长 1(逐点滚动),均值法取 k=3,分位法取 P95。这些数值属于示例假设,实际应结合标的流动性和采样频率调整。
滚动均值的计算并不复杂,但要注意“只用历史、不用未来”,否则会引入前视偏差。对时刻 t 的价差 st,基线均值取 t 之前(不含 t)窗口内的样本:
# 示例:滚动均值与滚动标准差(窗口 W=60,逐点滚动,不含当前点)
import numpy as np
def rolling_baseline(spreads, W=60, k=3.0):
mu = np.full(len(spreads), np.nan)
sd = np.full(len(spreads), np.nan)
for t in range(W, len(spreads)):
win = spreads[t-W:t] # 严格使用历史窗口
mu[t] = win.mean()
sd[t] = win.std(ddof=1)
upper = mu + k * sd # 正常上界
flag = spreads > upper # 第一轮异常标记
return mu, sd, upper, flag
滚动分位数的写法类似,只是把 win.mean() 换成 np.quantile(win, 0.95)。这里有一个容易被忽略的细节:分位数上界本身会随窗口内异常值被“抬高”。如果一段行情里价差持续偏高,P95 也会跟着上移,导致后续真正的异常被漏掉。缓解办法是使用更长的历史窗口,或在窗口内先做温和的截尾处理。
三、把三种基线放在一起看
下图展示了同一段价差序列上,滚动均值带、滚动 P95 上界与历史同时段分布上界的关系,以及第一轮阈值标记的落点。可以看到:均值带对缓慢漂移敏感,P95 上界更贴合右偏的尖峰,而历史分布上界在日内季节性明显的时段提供了额外参照。
四、第一轮阈值标记的落地规则
有了基线,就可以把“异常扩大”写成一条可执行的判定规则。建议第一轮采用“双条件 + 持续性”的组合,而不是单点越界即报警:
- 幅度条件:当前价差同时超过滚动均值 + 3σ 与滚动 P95 上界,取两者中较高者作为触发线。双条件能显著降低单方法误报。
- 持续性条件:连续 N 个观测(示例取 N=3)都满足幅度条件,才正式标记。单点尖峰可能只是瞬时挂单撤单,持续越界更接近真实的流动性恶化。
- 冷却期:同一标的被标记后,设置一段冷却时间(示例 15 分钟),避免同一事件被反复计数。
把这三条写成伪代码,逻辑就非常清晰:
# 第一轮阈值标记(示例参数)
W, k, N, COOLDOWN = 60, 3.0, 3, 15
last_flag_time = None
for t in range(W, len(spreads)):
win = spreads[t-W:t]
upper = max(win.mean() + k*win.std(ddof=1), np.quantile(win, 0.95))
over = spreads[t] > upper
streak = streak + 1 if over else 0
if streak >= N and cooldown_ok(t, last_flag_time, COOLDOWN):
mark_anomaly(t, spreads[t], upper) # 记录时间、价差、触发线
last_flag_time = t
streak = 0
五、结果如何观察与判断
第一轮标记完成后,不要急着把它当成最终结论,而应先做标记质量检查。建议至少观察三个量:标记总数、标记时段分布、以及标记点前后的价差回落情况。如果标记大量集中在开盘后 5 分钟和收盘前 10 分钟,说明基线没有吸收日内季节性,应改用历史同时段分布或加入时段哑变量;如果标记点出现后价差迅速回落,说明多为瞬时扰动,可适当提高 N 或 k;如果标记稀少但事后回看确有持续扩大,则说明阈值过松,应下调分位点或缩短窗口。
至此,我们已经完成了从“价差读数”到“基线建立”再到“第一轮阈值标记”的闭环。这一轮的输出是一份带时间戳、触发线和标记原因的候选异常清单,它还不是最终判定,但为后续结合成交量、盘口深度和消息面做二次确认提供了可靠的起点。