4. 排名选ETF策略规则设计

上一节我们已经能算出每个交易日、每只候选ETF的动量得分并完成横截面排名。但排名本身不是策略,它只是“信号源”。要把排名变成可回测、可复现的交易规则,必须把三件事说清楚:多久调一次仓、每次持有几只、什么条件下买和卖。这三者共同决定了策略的换手率、集中度和信号滞后程度,也直接决定了回测结果是否可信。本节就围绕这三项规则展开,并给出一个可直接运行的信号生成函数。

一、调仓频率:在信号新鲜度与交易成本之间取舍

调仓频率决定了策略“看排名”的节奏。常见选择有每日、每周、每两周、每月。频率越高,排名信号越新鲜,但换手率和交易成本也越高;频率越低,成本越低,但可能错过趋势反转。对ETF轮动策略而言,周频和月频是最常用的两档,因为ETF本身的波动比个股温和,日频调仓往往只是把噪声当成信号。

实现上不建议用“每隔N个自然日”这种写法,而应使用交易日历上的重平衡日。一个稳妥的做法是:预先在交易日索引上标记调仓日,例如每周最后一个交易日,或每月最后一个交易日。这样既避免了节假日错位,也让回测与实盘的执行日一致。下面用“每周最后一个交易日调仓”作为教学示例设定。

二、持仓数量:集中度与稳定性的平衡

持仓数量N决定了组合的集中度。N=1时策略最激进,完全押注排名第一的ETF,收益弹性大但回撤也大;N=3~5是常见折中,既能分散单一品种的意外风险,又保留了排名靠前品种的超额收益;N过大(如10以上)则逐渐退化为等权持有全部候选,排名信号被稀释。

需要特别注意的是候选池规模与N的关系。如果候选池只有8只ETF而N取5,那么排名的作用非常有限;一般建议候选池规模至少是N的3倍以上,排名才有区分度。教学示例中我们设定候选池为12只行业与宽基ETF,持仓数量N=3。

三、买入与卖出条件:把排名转成可执行指令

最直接的规则是“买入排名前N、卖出跌出前N”。但这条规则有一个隐患:当某只ETF在N名附近反复徘徊时,会产生频繁的买卖抖动。工程上通常加入缓冲区(buffer):买入要求排名进入前N,卖出要求排名跌出前N+B(B为缓冲位数,如2)。这样排名在第N与第N+B之间波动时持仓不变,显著降低换手。

此外还要处理两个边界情况。其一是数据不足:新上市ETF动量窗口不满时,应将其排名置为无效并排除在候选之外,而不是用短窗口硬算。其二是空仓保护:可设定“若排名第一的ETF动量得分低于阈值(如0),则全部空仓”,用于规避系统性下跌。教学示例中缓冲位B=2,动量阈值设为0。

排名选ETF:调仓日信号生成逻辑 输入 动量得分矩阵 (日期 × ETF) 调仓日判断 每周最后一个 交易日? 否 维持原持仓 不产生信号 是 横截面排名 + 有效性过滤 剔除数据不足的ETF 按得分降序排名 Top1 得分 > 0 ? 否则全部空仓 是 买卖条件(N=3,缓冲B=2) 买入:排名 ≤ 3 且当前未持有 卖出:排名 > 5(即跌出前3+缓冲2) 排名 4~5 之间:持仓保持不变 输出目标持仓权重

四、信号生成函数:把规则写成代码

规则明确后,就可以写信号生成函数。它的输入是动量得分矩阵和调仓日标记,输出是每个调仓日的目标持仓列表。下面给出一个结构清晰的教学示例实现(Python,pandas),参数与上文设定一致:

import pandas as pd

def generate_signals(score_df, rebalance_days, N=3, buffer=2, threshold=0.0):
    """
    score_df: 索引为交易日,列为ETF代码,值为动量得分(NaN表示数据不足)
    rebalance_days: 调仓日集合(交易日索引的子集)
    返回: dict{调仓日: [目标持仓ETF列表]}
    """
    signals = {}
    holding = set()

    for day in score_df.index:
        if day not in rebalance_days:
            continue

        row = score_df.loc[day].dropna()          # 剔除数据不足的ETF
        if row.empty:
            signals[day] = []
            holding = set()
            continue

        rank = row.rank(ascending=False, method="min")  # 1为最强

        # 空仓保护:Top1得分不达标则清仓
        if row.max() <= threshold:
            signals[day] = []
            holding = set()
            continue

        # 卖出:已持有但排名跌出 N+buffer
        holding = {c for c in holding if rank.get(c, 999) <= N + buffer}

        # 买入:排名进入前N且当前未持有
        buys = [c for c in rank[rank <= N].index if c not in holding]
        holding |= set(buys)

        # 若买入后超过N只,按排名截断
        target = sorted(holding, key=lambda c: rank[c])[:N]
        holding = set(target)
        signals[day] = target

    return signals

这段代码有几个值得注意的实现细节。第一,rank(ascending=False, method="min")保证得分最高者排名为1,且并列时取最小名次,避免并列导致持仓数超标。第二,卖出判断用rank.get(c, 999),当某只持仓ETF当日得分缺失(NaN被dropna剔除)时,视为排名极差而卖出,这是一种保守处理。第三,买入后若持仓超过N只,按排名截断,保证持仓数量严格受控。

五、规则参数速查与验证要点

把本节规则整理成一张可对照的参数表,便于在回测中逐项核对:

规则项教学示例取值作用与调整方向
调仓频率每周最后一个交易日降低换手;改为月频成本更低但信号更滞后
持仓数量 N3控制集中度;N越小弹性越大、回撤越深
买入条件排名 ≤ N只买最强梯队
卖出条件排名 > N + B(B=2)缓冲区抑制排名抖动带来的频繁交易
空仓阈值Top1 得分 ≤ 0 则空仓规避系统性下跌,阈值需结合得分定义调整
数据有效性动量窗口不足则剔除防止新ETF用短窗口产生虚假高分

验证信号函数是否正确,不必等完整回测。可以先做三项快速检查:其一,任取一个调仓日,手工按得分排序,核对输出的目标持仓是否恰为前N名(在无历史持仓的初始日);其二,构造一个“某持仓ETF排名从第2跌到第6”的场景,确认它被卖出;其三,构造“排名在第4、第5徘徊”的场景,确认持仓不被反复买卖。这三项检查覆盖了买入、卖出和缓冲区三条主路径,能有效发现排名方向写反、缓冲位用错等常见错误。

调仓频率、持仓数量、缓冲位这三个参数存在耦合:调仓越频繁,越需要缓冲位来压换手;持仓数量越少,单只ETF的进出对组合影响越大,越应检查卖出条件是否过于敏感。参数不应孤立调优,而应放在同一组回测中联合观察换手率与最大回撤。

至此,排名信号已经被完整地翻译成“何时调、买几只、买什么、卖什么”的可执行规则,并封装为可复用的信号生成函数。下一步只需把目标持仓转换为权重、叠加交易成本,即可进入回测环节。