4. 基于分钟线数据的开板次数统计算法

开板次数统计的核心难点不在“数”,而在“认”。分钟线数据只记录每分钟的开高低收与成交量,本身并不携带“涨停”“开板”“回封”这类语义标签。算法要做的,是把连续的分钟K线还原成一条状态序列:某一分钟处于涨停封板状态,下一分钟价格跌回涨停价之下,就构成一次开板;随后再次触及涨停价并维持,就构成一次回封。开板次数等于“封板→开板”的状态切换次数,而不是简单统计最低价低于涨停价的分钟数量。

先明确判定口径。设某只非ST股票前收盘价为 pre_close,涨停价按交易所规则四舍五入到分:limit_up = round(pre_close × 1.10, 2)。分钟线中,若某分钟的最高价 high 达到 limit_up,说明该分钟内曾触及涨停;若最低价 low 也等于 limit_up,说明整分钟都封在涨停价,属于“封死”状态;若 high == limit_up 但 low < limit_up,说明该分钟内曾打开,属于“触板但未封住”。这个区分是后续所有统计的基础,必须先用价格字段把每分钟归入明确类别。

分钟线状态识别:封板 → 开板 → 回封 判定基准:涨停价 limit_up = round(pre_close × 1.10, 2),价格单位:元 09:30 10:00 10:30 11:00 11:30 涨停价 11.00 封板段 开板段(价格回落) 回封段 二次开板 尾盘封死至收盘 开板① 回封① 开板② 回封② 统计结果:开板次数 = 2,回封次数 = 2,首次开板时间 = 09:52,最终状态 = 封板至收盘

上图展示了一个典型的“封板—开板—回封—再开板—再回封”序列。算法遍历分钟线时,维护一个布尔状态 sealed 表示“上一分钟是否处于封死状态”。初始时 sealed = False,open_count = 0,reseal_count = 0。对每一分钟:若 low == limit_up,则当前分钟封死;若此前 sealed 为真而当前分钟 low < limit_up,说明封板被打开,open_count += 1;若此前 sealed 为假而当前分钟封死,且此前已经发生过开板,则 reseal_count += 1。遍历结束后,open_count 即为开板次数。这个状态机只依赖相邻两分钟的封板属性,逻辑简单且不易重复计数。

实际数据中,有三类特殊情形必须单独处理,否则统计结果会明显失真。

第一类是集合竞价。9:15 至 9:25 的竞价数据常被并入 9:30 的第一根分钟线,也可能单独成行。若某股以涨停价开盘,9:30 第一分钟往往直接呈现 low == limit_up,此时不应把“开盘即封板”误判为“回封”。处理办法是:初始化时先判断首根有效分钟线是否封死,若是,则令 sealed = True 且不计回封;同时把 9:25 的竞价成交价作为辅助校验,若竞价价等于涨停价,说明开盘即封,开板计数从 0 起算。若竞价价低于涨停价而 9:30 首分钟封死,则属于盘中拉升封板,同样不计回封,只作为初始封板状态。

第二类是盘中临时停牌。分钟线在停牌期间通常缺失,或出现成交量为 0 的占位行。若某股封板后停牌,复牌后继续封板,缺失区间不应被当作“开板”。算法应在遍历前先剔除 volume == 0 且价格无变化的占位记录,或按交易时段重建连续分钟索引,对缺失分钟做“状态保持”处理:停牌前 sealed 为真,则复牌首分钟若仍封死,状态延续,不触发开板事件。若复牌后价格低于涨停价,才计一次开板。停牌本身不是开板,这一点必须在口径中写清楚。

第三类是尾盘与收盘状态。14:57 之后进入收盘集合竞价,部分数据源的最后一分钟可能只有收盘价。若收盘价等于涨停价,应视为封板至收盘;若收盘价低于涨停价,而此前处于封板状态,则尾盘开板应计入开板次数。算法对最后一分钟同样执行状态切换判断,不能因为“快收盘了”而跳过。

下面给出一个可直接参考的 Python 实现骨架,输入为包含 time、open、high、low、close、volume 的分钟线 DataFrame:

def count_limit_open(df, pre_close, ratio=0.10):
    limit_up = round(pre_close * (1 + ratio), 2)
    df = df[df["volume"] > 0].sort_values("time").reset_index(drop=True)
    sealed = False
    open_count = 0
    reseal_count = 0
    first_open_time = None
    for _, row in df.iterrows():
        is_sealed = (row["low"] == limit_up)
        if sealed and not is_sealed:
            open_count += 1
            if first_open_time is None:
                first_open_time = row["time"]
        elif (not sealed) and is_sealed and open_count > 0:
            reseal_count += 1
        sealed = is_sealed
    return {
        "limit_up": limit_up,
        "open_count": open_count,
        "reseal_count": reseal_count,
        "first_open_time": first_open_time,
        "final_sealed": sealed,
    }

这段代码的关键在于 sealed 的更新时机:先判断事件,再更新状态,保证“封板→开板”只计一次。若把更新放在判断之前,连续多分钟开板会被重复计数;若用 high == limit_up 代替 low == limit_up 判断封死,则触板未封的分钟会被误认为封板,开板次数被系统性低估。建议在实现后用一只已知走势的样本做校验:例如某股当日 10:05 首次封板、10:20 开板、10:35 回封并封至收盘,正确结果应为开板 1 次、回封 1 次、首次开板时间 10:20。若输出为 0 或大于 1,就应回查价格精度与状态更新顺序。

价格比较务必使用“分”为单位的整数或 round(x, 2) 后的浮点值,避免 11.000000001 与 11.00 不相等导致封板判断失败。对科创板、创业板 20% 涨跌幅及 ST 股 5% 涨跌幅,只需把 ratio 参数化,不要写死 1.10。

完成单日统计后,还应把结果落成结构化字段:股票代码、交易日、涨停价、开板次数、回封次数、首次开板时间、最后封板时间、是否封至收盘。这些字段既可用于单日复盘排序,也可跨日聚合出“某股近 20 日平均开板次数”等指标。统计口径一旦固定,后续所有复盘结论才有可比性;口径含糊,开板次数就只是一个看似精确、实则无法复现的数字。