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 上界更贴合右偏的尖峰,而历史分布上界在日内季节性明显的时段提供了额外参照。

价差滚动基线与第一轮阈值标记(示例数据) 价差 时间 → 历史同时段 P95 滚动 P95 上界 滚动均值 ± 3σ 带 实际价差序列 第一轮异常标记 实际价差 均值带 P95

四、第一轮阈值标记的落地规则

有了基线,就可以把“异常扩大”写成一条可执行的判定规则。建议第一轮采用“双条件 + 持续性”的组合,而不是单点越界即报警:

  1. 幅度条件:当前价差同时超过滚动均值 + 3σ 与滚动 P95 上界,取两者中较高者作为触发线。双条件能显著降低单方法误报。
  2. 持续性条件:连续 N 个观测(示例取 N=3)都满足幅度条件,才正式标记。单点尖峰可能只是瞬时挂单撤单,持续越界更接近真实的流动性恶化。
  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;如果标记稀少但事后回看确有持续扩大,则说明阈值过松,应下调分位点或缩短窗口。

基线不是一次设定就永久有效的。当标的的流动性结构发生变化(如被纳入指数、发生停复牌、出现连续涨跌停)时,旧窗口的统计特性会失真。建议定期用最近 20 个交易日的数据重估参数,并在标记日志中记录当时使用的窗口与阈值,便于事后复盘。

至此,我们已经完成了从“价差读数”到“基线建立”再到“第一轮阈值标记”的闭环。这一轮的输出是一份带时间戳、触发线和标记原因的候选异常清单,它还不是最终判定,但为后续结合成交量、盘口深度和消息面做二次确认提供了可靠的起点。