3. ETF池静态筛选与流动性门槛设定
筛选口径与判定规则
本节的任务是在进入轮动回测之前,把候选ETF池从"能拿到数据"收敛到"值得参与轮动"。静态筛选只看三个与回测可执行性直接相关的指标,不涉及任何收益预测:
- 日均成交额:取最近 60 个交易日的日成交额均值,衡量可交易容量。门槛过低会导致回测中假设的成交价无法实现。
- 上市天数:从首个有效交易日到面板末日的自然交易日数量,用于剔除新上市、历史样本不足的标的。
- 价格缺失率:在统一交易日历下,该标的缺失收盘价的比例。缺失率过高说明停牌频繁或数据源不完整,会污染动量窗口计算。
三项指标采用"与"关系:任一不达标即剔除。这样做的原因是三者分别对应容量风险、样本长度风险与数据质量风险,缺一不可。示例参数设定为:日均成交额 ≥ 5000 万元、上市天数 ≥ 250 个交易日、价格缺失率 ≤ 5%。
筛选流程与数据流
下图给出从候选池到初始标的集合的处理链,以及每个环节的判定条件与输出。注意缺失率是在统一交易日历对齐之后计算的,因此它天然包含了上市日差异带来的前段空缺——这也是为什么上市天数门槛与缺失率门槛需要同时存在:前者管"够不够长",后者管"齐不齐"。
实现:一次筛选,两份输出
下面用一段自洽的 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 只。判读时重点看三件事:
- 剔除原因构成:若"成交额不足"占绝大多数,说明候选池本身偏小盘,需要重新审视池子的构建来源;若"缺失率过高"集中出现,应回查数据源而非直接放宽门槛。
- 分布重心是否右移:筛选后最低区间应被清空,中位数上移。若筛选后仍存在大量低成交额标的,说明门槛参数与单位不一致(例如把"万元"当成"元")。
- 数量是否过少:若筛选后不足 10 只,轮动策略的排序区分度会显著下降,此时应优先放宽成交额门槛而非缺失率门槛,因为后者直接关系信号可信度。
| 指标 | 门槛 | 筛选前 | 筛选后 | 说明 |
|---|---|---|---|---|
| 标的数量 | — | 42 | 27 | 剔除 15 只 |
| 日均成交额中位数 | ≥ 0.5 亿 | 约 0.45 亿 | 约 0.85 亿 | 分布重心右移 |
| 最低成交额区间只数 | — | 9 | 0 | 0–0.2 亿区间清空 |
| 上市天数不足只数 | ≥ 250 | — | 剔除 4 | 多为近一年新发 |
| 缺失率超限只数 | ≤ 5% | — | 剔除 3 | 长期停牌或数据缺口 |
表中数值为示例数据下的预期结果,用于说明判读方法,不代表任何真实市场统计。实际使用时须以本地行情数据重跑,并核对成交额单位(元/万元/亿元)在读取环节是否已统一。
筛选完成后,把 keep 列表作为后续动量信号计算的列子集即可。建议将 detail 表连同参数一起存档,这样当回测结果异常时,可以快速判断问题出在标的池构成还是信号逻辑本身。