2. 板块上涨家数的基础计算

在板块宽度分析中,最基础也最容易被低估的一步,是“把上涨家数算准”。很多分析结论之所以失真,并不是因为指标选错了,而是因为成分股口径没有统一:停牌股被当成平盘、ST股被混入正常样本、新股上市首日被计入涨跌统计,都会让上涨家数出现系统性偏差。本节围绕三个基础计数——上涨家数、下跌家数、平盘家数——说明定义、计算函数的设计,以及特殊成分股的处理方式。

三个基础计数的定义

设某一交易日板块内参与统计的成分股集合为 S,对每只股票 i,取其当日涨跌幅 pct_i(单位:%)。三个计数的定义如下:

  • 上涨家数:满足 pct_i > 0 的股票数量,记为 up。
  • 下跌家数:满足 pct_i < 0 的股票数量,记为 down。
  • 平盘家数:满足 pct_i = 0 的股票数量,记为 flat。

这里有一个必须提前说明的参照约定:涨跌幅以前收盘价为基准,pct_i = (今收 - 前收) / 前收 × 100%。正号表示上涨,负号表示下跌,零表示收盘价与前收完全相等。全文与图示均采用这一约定,不做切换。需要强调的是,平盘不等于停牌:停牌股当日没有成交,其涨跌幅通常记为 0 或空值,如果直接按 pct_i = 0 归入平盘,就会把“无法交易”误当成“价格未动”,这是最常见的口径错误。

另一个容易忽略的细节是有效样本数。设 valid = up + down + flat,它应当等于“当日可正常交易且纳入统计的成分股数量”,而不是板块成分股总数。只有当 valid 与成分股总数一致时,上涨家数才具有跨日可比性;否则应同时记录有效样本数,供后续宽度指标做归一化。

计算函数的设计

把上述定义落成一个可复用的函数,关键在于输入数据的结构。假设我们已有一张按“交易日 × 股票”组织的行情表,字段包括 trade_date、code、pct_chg、is_suspend(是否停牌)、is_st(是否 ST)、list_days(上市天数)。函数的目标是:对指定板块、指定交易日,返回三个计数及有效样本数。

def calc_breadth(quotes, members, trade_date,
                 exclude_st=True, min_list_days=1):
    """
    quotes:   行情表,含 code / trade_date / pct_chg / is_suspend / is_st / list_days
    members:  板块成分股代码集合
    trade_date: 统计日期
    返回: dict(up, down, flat, valid, suspended, excluded)
    """
    day = quotes[(quotes.trade_date == trade_date) &
                 (quotes.code.isin(members))]

    suspended = day[day.is_suspend].code.nunique()

    # 剔除停牌
    tradable = day[~day.is_suspend]

    # 剔除上市不足 min_list_days 的新股
    tradable = tradable[tradable.list_days >= min_list_days]

    # 可选剔除 ST
    if exclude_st:
        tradable = tradable[~tradable.is_st]

    pct = tradable.pct_chg.dropna()
    up   = int((pct > 0).sum())
    down = int((pct < 0).sum())
    flat = int((pct == 0).sum())

    return {
        "up": up, "down": down, "flat": flat,
        "valid": up + down + flat,
        "suspended": int(suspended),
        "excluded": len(members) - (up + down + flat) - int(suspended)
    }

这段实现体现了三个处理顺序:先按板块成分股过滤,再剔除停牌,最后处理新股与 ST。顺序不能随意调换——如果先剔除 ST 再剔除停牌,suspended 的计数就会漏掉停牌中的 ST 股,导致“有效样本 + 停牌 + 剔除”无法与成分股总数对齐。函数返回的 excluded 是一个校验项:up + down + flat + suspended + excluded 应等于板块成分股总数,若不等,说明成分股名单与行情表存在未匹配的代码,需要先排查数据对齐问题。

特殊成分股的处理原则

停牌股:不参与涨跌统计,单独计数。停牌期间价格未更新,若计入平盘会人为压低波动、虚增平盘比例;若计入上涨或下跌则更无依据。处理方式是整体剔除,并在结果中保留 suspended 字段,便于观察板块内停牌比例是否异常升高。

ST 股:是否剔除取决于分析目的。ST 股涨跌幅限制通常为 ±5%,与主板 ±10%、创业板/科创板 ±20% 不同,混在一起会让“上涨家数”的含义变得模糊。若分析目标是衡量板块整体的做多情绪,建议剔除 ST 并单独统计;若目标是观察全样本的涨跌分布,则应保留但在结果中标注 ST 家数。函数中用 exclude_st 开关控制,避免把口径写死。

新股:上市初期(尤其首日及前若干日)没有涨跌幅限制或波动极大,且缺乏可比的前收基准,容易对上涨家数造成一次性冲击。常用做法是设置 min_list_days,例如剔除上市不足 1 个交易日或不足 5 个交易日的股票。参数取值应在整个分析周期内保持一致,否则跨日比较会失真。

建议在每日结果中固定输出五个字段:up、down、flat、valid、suspended。前三个是基础计数,后两个用于校验与解释,缺一不可。

计算流程与口径校验

下图把从成分股名单到三个计数的处理链路画出来,重点标出停牌、新股、ST 三个分流点,以及最终的对齐校验关系。

板块成分股名单 members(代码集合) 当日行情表 pct_chg / is_suspend 等 按日期与成分股 取交集 逐股分流判断 停牌 / 新股 / ST 停牌股 剔除,计入 suspended 上市不足 N 日新股 剔除,计入 excluded ST 股(可选) 按开关剔除或保留 有效样本 pct_chg 剔除后剩余股票 保留 上涨家数 up pct_chg > 0 下跌家数 down pct_chg < 0 平盘家数 flat pct_chg = 0 对齐校验 up+down+flat +suspended+excluded = 成分股总数

图中“对齐校验”是整条链路的收口。实际运行时,如果某日校验不通过,最常见的原因是成分股名单更新滞后于行情表,或某只股票代码发生变更导致匹配失败。此时不应直接使用当日的上涨家数,而应先补齐名单或修正代码映射,再重新计算。

一个可核对的计算示例

假设某板块共有 50 只成分股,某一交易日的情况为:停牌 2 只;上市不足 5 个交易日的新股 1 只;ST 股 3 只(按剔除口径处理);其余 44 只正常交易。在这 44 只中,上涨 26 只、下跌 15 只、平盘 3 只。则:

项目数量说明
板块成分股总数50名单口径
停牌 suspended2剔除,不参与涨跌统计
新股 excluded1上市不足 5 日,剔除
ST 剔除3按 exclude_st=True 处理
有效样本 valid4426 + 15 + 3
上涨 up26pct_chg > 0
下跌 down15pct_chg < 0
平盘 flat3pct_chg = 0

校验:26 + 15 + 3 + 2 + 1 + 3 = 50,与成分股总数一致,说明口径闭合。若不做剔除,直接把 50 只全部按涨跌幅符号计数,停牌股与新股会被错误地归入平盘或涨跌,上涨家数可能被高估或低估数只,在宽度指标的日度序列上形成噪声。

实现时的几个注意点

第一,pct_chg 的精度问题。行情数据常保留两位小数,理论上应为 0 的平盘可能因四舍五入显示为 0.00,也可能出现 -0.00。建议在比较前统一做数值规整,例如将绝对值小于 1e-6 的值置为 0,避免平盘家数被漏计。

第二,停牌判断应以行情表的停牌标记为准,而不是以“成交量为 0”推断。部分股票当日可能因极端行情出现零成交,但并未停牌,二者含义不同。

第三,ST 状态与新股天数都是时变属性,必须使用“当日”的状态,而不是当前最新状态。若用最新 ST 名单回溯历史,会把历史上尚未 ST 的股票错误剔除,造成前视偏差。

第四,函数应保持纯计算、不修改输入数据,便于在多个板块、多个日期上批量调用。批量计算时,把结果按 trade_date 排序后落表,字段固定为 up / down / flat / valid / suspended / excluded,后续的上涨家数占比、涨跌家数比等宽度指标都从这张表派生,口径自然统一。

不要用“上涨家数 ÷ 成分股总数”直接作为宽度比例。分母应使用有效样本数 valid,否则停牌与剔除较多的交易日会被系统性低估,跨日比较失去意义。