3. ETF池静态筛选与流动性门槛设定

筛选口径与判定规则

本节的任务是在进入轮动回测之前,把候选ETF池从"能拿到数据"收敛到"值得参与轮动"。静态筛选只看三个与回测可执行性直接相关的指标,不涉及任何收益预测:

  • 日均成交额:取最近 60 个交易日的日成交额均值,衡量可交易容量。门槛过低会导致回测中假设的成交价无法实现。
  • 上市天数:从首个有效交易日到面板末日的自然交易日数量,用于剔除新上市、历史样本不足的标的。
  • 价格缺失率:在统一交易日历下,该标的缺失收盘价的比例。缺失率过高说明停牌频繁或数据源不完整,会污染动量窗口计算。

三项指标采用"与"关系:任一不达标即剔除。这样做的原因是三者分别对应容量风险、样本长度风险与数据质量风险,缺一不可。示例参数设定为:日均成交额 ≥ 5000 万元、上市天数 ≥ 250 个交易日、价格缺失率 ≤ 5%。

筛选流程与数据流

下图给出从候选池到初始标的集合的处理链,以及每个环节的判定条件与输出。注意缺失率是在统一交易日历对齐之后计算的,因此它天然包含了上市日差异带来的前段空缺——这也是为什么上市天数门槛与缺失率门槛需要同时存在:前者管"够不够长",后者管"齐不齐"。

候选ETF池 → 三项静态门槛 → 初始标的集合 候选ETF池 N₀ = 42 只 宽基/行业/主题混合 指标计算(统一日历) 近60日日均成交额 上市交易日数 / 缺失率 门槛① 日均成交额 ≥ 5000 万元 门槛② 上市天数 ≥ 250 个交易日 门槛③ 价格缺失率 ≤ 5% AND 全部满足 初始标的集合 N₁ = 27 只 剔除标的(15 只) 记录剔除原因,便于复核 筛选前后成交额分布对比(示例数据,单位:亿元) 筛选前 N₀=42 0–0.2: 9 0.2–0.5: 13 0.5–1: 10 >1: 10 筛选后 N₁=27 0–0.2: 0 0.2–0.5: 4 0.5–1: 10 >1: 13 低成交额区间被整体清空,分布重心右移;剩余标的的成交额中位数由约 0.45 亿元升至约 0.85 亿元。

实现:一次筛选,两份输出

下面用一段自洽的 Python 代码完成筛选。输入是一个已按统一交易日历对齐的宽表 close(行=交易日,列=ETF代码)与对应的 amount 成交额宽表。代码同时输出筛选后的代码列表、剔除明细表,以及筛选前后的成交额分布统计,便于直接核对。

import pandas as pd
import numpy as np

# close, amount: 行=统一交易日, 列=ETF代码(已对齐,缺失为 NaN)
# 示例参数
MIN_AVG_AMOUNT = 5e7      # 5000 万元
MIN_LISTED_DAYS = 250
MAX_MISS_RATE = 0.05
AMOUNT_WINDOW = 60

def static_screen(close: pd.DataFrame, amount: pd.DataFrame,
                  min_avg_amount=MIN_AVG_AMOUNT,
                  min_listed_days=MIN_LISTED_DAYS,
                  max_miss_rate=MAX_MISS_RATE,
                  window=AMOUNT_WINDOW) -> tuple[list, pd.DataFrame]:
    n_days = len(close)
    rows = []
    for code in close.columns:
        s = close[code]
        valid = s.notna()
        listed_days = int(valid.sum())                 # 有效交易日数
        miss_rate = 1 - listed_days / n_days           # 统一日历下的缺失率
        avg_amount = amount[code].tail(window).mean()  # 近60日均成交额
        reasons = []
        if not (avg_amount >= min_avg_amount):
            reasons.append("成交额不足")
        if listed_days < min_listed_days:
            reasons.append("上市天数不足")
        if miss_rate > max_miss_rate:
            reasons.append("缺失率过高")
        rows.append({
            "code": code,
            "avg_amount": avg_amount,
            "listed_days": listed_days,
            "miss_rate": miss_rate,
            "passed": len(reasons) == 0,
            "reason": ";".join(reasons) if reasons else "通过",
        })
    detail = pd.DataFrame(rows).set_index("code")
    keep = detail.index[detail["passed"]].tolist()
    return keep, detail

def amount_dist(amount: pd.DataFrame, codes: list) -> pd.Series:
    # 以每只ETF近60日均成交额为一个观测,按区间统计只数
    avg = amount[codes].tail(AMOUNT_WINDOW).mean()
    bins = [0, 0.2e8, 0.5e8, 1e8, np.inf]
    labels = ["0–0.2亿", "0.2–0.5亿", "0.5–1亿", ">1亿"]
    return pd.cut(avg, bins=bins, labels=labels, right=False).value_counts().reindex(labels, fill_value=0)

keep, detail = static_screen(close, amount)
print(f"筛选前 {close.shape[1]} 只 → 筛选后 {len(keep)} 只")
print(detail.loc[~detail["passed"], ["reason"]].value_counts("reason"))
print("筛选前分布:\n", amount_dist(amount, list(close.columns)))
print("筛选后分布:\n", amount_dist(amount, keep))
缺失率的分母必须是统一交易日历的总行数,而不是该标的自身的有效样本数。若用后者,一只刚上市 30 天的 ETF 缺失率会算成 0,从而绕过质量门槛——这正是本节把"上市天数"与"缺失率"并列的原因。

结果判读与复核要点

以示例数据运行后,预期得到筛选前 42 只、筛选后 27 只,剔除 15 只。判读时重点看三件事:

  1. 剔除原因构成:若"成交额不足"占绝大多数,说明候选池本身偏小盘,需要重新审视池子的构建来源;若"缺失率过高"集中出现,应回查数据源而非直接放宽门槛。
  2. 分布重心是否右移:筛选后最低区间应被清空,中位数上移。若筛选后仍存在大量低成交额标的,说明门槛参数与单位不一致(例如把"万元"当成"元")。
  3. 数量是否过少:若筛选后不足 10 只,轮动策略的排序区分度会显著下降,此时应优先放宽成交额门槛而非缺失率门槛,因为后者直接关系信号可信度。
指标门槛筛选前筛选后说明
标的数量—4227剔除 15 只
日均成交额中位数≥ 0.5 亿约 0.45 亿约 0.85 亿分布重心右移
最低成交额区间只数—900–0.2 亿区间清空
上市天数不足只数≥ 250—剔除 4多为近一年新发
缺失率超限只数≤ 5%—剔除 3长期停牌或数据缺口
表中数值为示例数据下的预期结果,用于说明判读方法,不代表任何真实市场统计。实际使用时须以本地行情数据重跑,并核对成交额单位(元/万元/亿元)在读取环节是否已统一。

筛选完成后,把 keep 列表作为后续动量信号计算的列子集即可。建议将 detail 表连同参数一起存档,这样当回测结果异常时,可以快速判断问题出在标的池构成还是信号逻辑本身。