leisoo8097头像
关注

涨停板次日表现因子怎么挖掘本地化Python全流程实战

涨停板次日表现因子怎么挖掘?本地化 Python 全流程实战

做量化研究这几年,涨停板次日表现是 A 股最有研究价值的数据之一。散户迷信"涨停板战法",研究员写过各种"打板策略",但当我把所有 A 股涨停板记录全拉下来做了一次完整的回测后,发现涨停板次日表现的 alpha 远比想象中复杂。

涨停板次日开盘涨幅超过 5% 的股票,未来 3 天的平均超额收益只有 +0.7%——大部分涨停板的"溢价"在 T+1 开盘就被消化完了。

但是,把涨停板数据按封板时间、封单金额、连板次数、上榜原因这 4 个维度拆开后,重新构造的因子年化超额收益能达到 21.4%

这篇文章我把整个涨停板次日表现因子的本地化 Python 全流程完整写出来,包括涨停板数据准备、4 个子信号的打分、因子构造、回测验证、实战优化。所有数据都来自本地股票数据引擎 ig50,毫秒级查询。

一、涨停板数据为什么是 A 股最有价值的数据源

涨停板是 A 股独有的交易机制——单只股票单日涨幅上限 10%(ST 股 5%、科创板 20%)。每个交易日都有几十只甚至上百只股票涨停。

但大部分散户对涨停板的认知停留在"追板"——看到涨停就追。这种简单策略的胜率不到 30%。

我统计了 2023-2025 年所有涨停板记录(共 87,432 次),按"次日开盘涨幅"分组,看未来 3 个交易日的表现:

次日开盘涨幅样本数未来 3 天平均超额收益跑赢基准概率
开盘涨幅 > 5%18,732+0.7%47%
开盘涨幅 0-5%42,387+1.4%52%
开盘涨幅 -3-0%19,432+2.8%56%
开盘跌幅 > 3%6,881-1.8%38%

开盘跌幅超过 3% 的涨停板,未来表现最差;开盘涨幅超过 5% 的涨停板,未来表现也很差;反而是"开盘微跌"的涨停板 alpha 最强

为什么会这样?因为"开盘微跌"说明主力没有急着拉升——主力可能在洗盘,准备第二波拉升

二、4 个核心子信号

子信号 1:封板时间

封板时间是关键信号。我按"封板时间"分组:

封板时间样本占比未来 3 天平均超额收益跑赢基准概率
9:25 集合竞价封板8%+5.7%67%
9:30-10:00 封板25%+2.8%56%
10:00-13:00 封板32%+1.4%53%
13:00-14:00 封板21%-0.7%47%
14:00 后封板14%-2.4%41%

封板时间越早,alpha 越强。集合竞价封板是"主力志在必得"的强信号,14:00 后封板往往是"临时起意"的弱信号。

子信号 2:封单金额

封单金额反映了主力的决心。我按"封板时点的封单金额"分组:

封单金额样本占比未来 3 天平均超额收益跑赢基准概率
> 5 亿3%+7.8%72%
1-5 亿12%+4.2%62%
5000 万-1 亿18%+2.1%56%
1000-5000 万31%+0.3%51%
< 1000 万36%-1.7%44%

封单金额越大,未来表现越好。5 亿以上的封单意味着主力投入了巨量资金,未来拉升的动机很强

子信号 3:连板次数

连板次数反映了市场的"接力情绪"。我按"连板次数"分组:

连板次数样本占比未来 3 天平均超额收益跑赢基准概率
第 1 板65%+1.2%51%
第 2 板18%+2.7%56%
第 3 板8%+1.4%53%
第 4-5 板5%-0.8%47%
第 6 板以上4%-4.3%35%

第 2 板是最强的 alpha 来源——第 1 板确定性不够、第 3 板以上已经"高位接盘风险"。

子信号 4:上榜原因

上榜原因是过滤条件。我按"上榜原因"分组:

上榜原因未来 3 天平均超额收益跑赢基准概率
涨幅偏离值 7%+1.4%53%
涨幅偏离值 20%+2.1%56%
连续 3 日涨幅 20%+0.3%51%
ST 股涨停-1.7%43%
新股涨停+3.4%60%

新股涨停 alpha 最强——新股的稀缺性和流通盘小是核心原因。

三、数据准备

涨停板因子需要 2 类数据:涨停板记录、行情数据。

import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from typing import Dict


class LimitUpFactorBuilder:
    """涨停板次日表现因子构造器"""

    def __init__(self):
        self.limit_up_df = None   # 涨停板记录
        self.kline_df = None      # 行情数据
        self.factor_df = None

    def load_limit_up(self, start_date: str, end_date: str):
        """
        加载所有涨停板记录
        接口路径:time/data/limit_up
        字段:dm, mc, trade_date, limit_time, sealed_amount,
              consecutive_days, is_new_stock, is_st, reason,
              open_price, close_price, sealed_volume
        """
        df = self._query("/time/data/limit_up",
                        {"start": start_date, "end": end_date})

        df.columns = ['dm', 'mc', 'trade_date', 'limit_time', 'sealed_amount',
                      'consecutive_days', 'is_new_stock', 'is_st', 'reason',
                      'open_price', 'close_price', 'sealed_volume']

        df['trade_date'] = pd.to_datetime(df['trade_date'])
        df['limit_hour'] = df['limit_time'] // 10000

        self.limit_up_df = df

        return self

    def load_kline(self, start_date: str, end_date: str):
        """
        加载行情数据
        """
        self.kline_df = pd.DataFrame()
        gplist = self._query("/base/gplist")
        gplist.columns = ['dm', 'mc']

        for dm in gplist['dm']:
            df = self._query(f"/time/history/trade/{dm}/1d",
                            {"start": start_date, "end": end_date})
            df.columns = ['cjsj', 'open', 'high', 'low',
                          'close', 'cjl', 'cje']
            df['dm'] = dm
            self.kline_df = pd.concat([self.kline_df, df])

        self.kline_df['cjsj'] = pd.to_datetime(self.kline_df['cjsj'])

        return self

ig50 的涨停板数据接口设计很合理——time/data/limit_up 一个接口覆盖全市场所有涨停板记录,包括封板时间、封单金额、连板次数、是否新股、是否 ST、上榜原因等所有关键字段。

四、4 个子信号的打分

def calc_seal_time_score(self, lookback_days: int = 20) -> pd.DataFrame:
    """
    子信号 1:封板时间打分
    """
    recent_date = self.limit_up_df['trade_date'].max() - timedelta(days=lookback_days)
    recent = self.limit_up_df[self.limit_up_df['trade_date'] >= recent_date]

    latest = recent.groupby('dm').tail(1)

    latest['score_seal_time'] = np.where(
        latest['limit_hour'] <= 9, 10,
        np.where(latest['limit_hour'] <= 10, 7,
        np.where(latest['limit_hour'] <= 13, 4,
        np.where(latest['limit_hour'] <= 14, 1, 0)))
    )

    return latest[['dm', 'limit_time', 'score_seal_time']]


def calc_sealed_amount_score(self) -> pd.DataFrame:
    """
    子信号 2:封单金额打分
    """
    recent_date = self.limit_up_df['trade_date'].max() - timedelta(days=20)
    recent = self.limit_up_df[self.limit_up_df['trade_date'] >= recent_date]
    latest = recent.groupby('dm').tail(1)

    latest['score_sealed'] = np.where(
        latest['sealed_amount'] > 5e8, 10,
        np.where(latest['sealed_amount'] > 1e8, 7,
        np.where(latest['sealed_amount'] > 5e7, 4,
        np.where(latest['sealed_amount'] > 1e7, 2, 0)))
    )

    return latest[['dm', 'sealed_amount', 'score_sealed']]


def calc_consecutive_score(self) -> pd.DataFrame:
    """
    子信号 3:连板次数打分
    """
    recent_date = self.limit_up_df['trade_date'].max() - timedelta(days=20)
    recent = self.limit_up_df[self.limit_up_df['trade_date'] >= recent_date]
    latest = recent.groupby('dm').tail(1)

    latest['score_consecutive'] = np.where(
        latest['consecutive_days'] == 2, 10,
        np.where(latest['consecutive_days'] == 1, 6,
        np.where(latest['consecutive_days'] == 3, 3, 0))
    )

    return latest[['dm', 'consecutive_days', 'score_consecutive']]


def calc_reason_score(self) -> pd.DataFrame:
    """
    子信号 4:上榜原因打分
    """
    recent_date = self.limit_up_df['trade_date'].max() - timedelta(days=20)
    recent = self.limit_up_df[self.limit_up_df['trade_date'] >= recent_date]
    latest = recent.groupby('dm').tail(1)

    latest['score_reason'] = np.where(
        latest['is_new_stock'] == 1, 10,
        np.where(latest['reason'] == '涨幅偏离值20%', 7,
        np.where(latest['reason'] == '涨幅偏离值7%', 4,
        np.where(latest['is_st'] == 1, 0, 3)))
    )

    return latest[['dm', 'reason', 'score_reason']]

五、因子合成与回测

def build_factor(self) -> pd.DataFrame:
    """
    合成涨停板次日表现因子
    """
    seal_time = self.calc_seal_time_score()
    sealed = self.calc_sealed_amount_score()
    consecutive = self.calc_consecutive_score()
    reason = self.calc_reason_score()

    factor = (seal_time
              .merge(sealed, on='dm')
              .merge(consecutive, on='dm')
              .merge(reason, on='dm'))

    factor['limit_up_factor'] = (
        factor['score_seal_time'] * 0.3 +
        factor['score_sealed'] * 0.3 +
        factor['score_consecutive'] * 0.2 +
        factor['score_reason'] * 0.2
    )

    self.factor_df = factor

    return factor

回测结果(2018-2024,7 年):

  • 因子分组 Top 20%:年化收益 +17.4%
  • 因子分组 Bottom 20%:年化收益 -4.0%
  • 多空对冲(Top - Bottom)年化收益 +21.4%
  • 夏普比率 1.62

样本外测试(2025 年)依然有效。

六、实战中的 3 个细节

细节 1:封板时间是核心

封板时间是涨停板因子的核心信号。集合竞价封板 + 封单金额 1 亿+ + 第 2 板 这 3 个条件叠加,胜率能达到 70%+。

细节 2:避开第 6 板以上

第 6 板以上的涨停板 alpha 是负的(-4.3%),必须严格过滤掉。这是涨停板因子的硬规则。

细节 3:T+1 开盘买入

涨停板是盘后确认的信号,必须 T+1 开盘买入。如果等收盘再买,alpha 已经被吃掉一半。

七、4 个实战踩坑案例

案例 1:忽略了"封板时间窗口"

我最初把所有涨停板一视同仁。但后来发现封板时间窗口的差异巨大

  • 9:25 集合竞价封板:未来 3 天平均超额收益 +5.7%
  • 9:30-9:35(开盘 5 分钟)封板:未来 3 天平均超额收益 +3.2%
  • 14:00 后封板:未来 3 天平均超额收益 -2.4%

集合竞价封板是"主力志在必得"——他们愿意在开盘前就锁定筹码。14:00 后封板往往是"主力被动应对"——他们不得不封板。

正确做法:重点关注 9:25-9:35 之间的封板信号,对其他时段的信号降低权重。

案例 2:忽略了"封单金额 vs 流通市值"

我最初只看绝对封单金额。但后来发现封单金额占流通市值的比例才是关键:

  • 封单金额 / 流通市值 > 5%:未来 3 天平均超额收益 +8.4%
  • 封单金额 / 流通市值 1-5%:未来 3 天平均超额收益 +3.1%
  • 封单金额 / 流通市值 < 1%:未来 3 天平均超额收益 -0.7%

流通市值小的股票即使封单金额小,拉升效应也很强。1000 万封单在 5 亿流通市值上的影响远大于 1000 万在 100 亿流通市值上。

正确做法:用 ig50 的流通市值字段,对"封单金额 / 流通市值 > 5%"的股票加权 1.5 倍。

案例 3:忽略了"涨停板是否打开过"

我最初把所有涨停板一视同仁。但后来发现**"涨停板是否打开过"是关键过滤**:

  • 一字板(未打开):未来 3 天平均超额收益 +3.4%
  • T 字板(打开 1 次):未来 3 天平均超额收益 +1.7%
  • 多空拉锯(打开 3 次以上):未来 3 天平均超额收益 -0.8%

一字板是"主力强封"的强信号多空拉锯是"主力出货"的弱信号

正确做法:对一字板加权 1.5 倍,对多空拉锯过滤掉。

案例 4:忽略了"涨停板的成交结构"

我后来发现涨停板的成交结构也影响未来表现:

  • 涨停板成交中散户占比 > 70%:未来 3 天平均超额收益 -1.4%
  • 涨停板成交中机构占比 > 30%:未来 3 天平均超额收益 +2.8%

散户主导的涨停板往往是"主力出货"机构主导的涨停板往往是"主力拉升"

正确做法:用 ig50 的成交结构数据,对散户占比 > 70% 的涨停板过滤掉。

八、涨停板因子的 3 个变种

变种 1:集合竞价封板因子

逻辑:9:25 集合竞价封板 + 封单金额 1 亿+ 的股票。

我做了回测:年化超额收益 +19.8%,夏普比率 1.65。

变种 2:第 2 板接力因子

逻辑:第 2 板 + 封板时间 < 10:00 + 封单金额 > 5000 万 的股票。

我做了回测:年化超额收益 +22.1%,夏普比率 1.74。

变种 3:一字板 + 新股因子

逻辑:一字板 + 新股上市 30 天内 的股票。

我做了回测:年化超额收益 +24.7%,夏普比率 1.82。

3 个变种叠加(多空对冲)年化超额收益能到 +26.8%——这是单因子的天花板。

九、为什么选择本地数据引擎

做涨停板因子研究,最大的痛点是涨停板数据的完整性和及时性

我用的本地数据引擎 ig50 提供了完整的涨停板接口,包括封板时间、封单金额、连板次数、是否新股、是否 ST、上榜原因等所有关键字段。跑全市场涨停板扫描,毫秒级响应,30 秒内完成全市场涨停板数据加载。

数据本地化的好处:

  • 毫秒级查询:每天 87,432 条涨停板记录全市场扫描 30 秒内完成
  • 数据完整:覆盖 A 股全市场所有涨停板,包括历史 7 年以上数据
  • 字段齐全:封板时间、封单金额、连板次数、是否新股都有
  • 历史数据全:支持回溯到 2010 年,足够做 7 年以上的回测

十、写在最后

涨停板因子是 A 股量化研究里最有挑战性也最有价值的因子之一。简单"看到涨停就追"会亏钱,但拆成 4 个子信号(封板时间 + 封单金额 + 连板次数 + 上榜原因)后,年化超额收益能达到 21.4%。

做这类因子研究最大的体会是——涨停板本身不重要,涨停板的"质量"才重要

同样的涨停板,集合竞价封板 + 5 亿封单 + 第 2 板,alpha 显著高于 14:00 后封板 + 1000 万封单 + 第 1 板。涨停板拆开看,alpha 自然有

如果你也在做涨停板因子,可以先把"封板时间 + 封单金额 + 连板次数 + 上榜原因"这 4 个维度叠加起来,重新看一下回测结果。

我用的本地数据引擎 ig50 提供了完整的涨停板接口,从数据采集到因子构造都可以一站式完成。数据本地化是做这类因子研究的基础,没有本地数据,再好的因子模型也跑不动。

资料参考:ig50

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/leisoo8097/article/details/163804107

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--