ETF轮动动量量化策略趋势跟踪

ETF轮动策略实战:30只选池动量评分+可投资性双因子动态轮动


引言:为什么ETF轮动?

做A股量化最痛苦的事情是什么?不是选股,不是择时,而是躲过大跌

辛辛苦苦选股一年赚了30%,一次系统性回撤就回到原点。2024年2月、2025年4月、2026年初——每一次A股的单边暴跌都让满仓的投资者血亏。

ETF轮动策略正是为此而生。它不预测市场,只跟踪趋势——当某个品种趋势走弱时切换到更强的品种,当全市场都走弱时切换到最抗跌的品种甚至空仓(或转向货币ETF、跨境ETF)。

本文完整公开一个实盘运行的ETF轮动策略:30只主流ETF选池 + 动量评分 + R²趋势过滤 + 可投资性双因子筛选。该策略2025年实盘收益远超沪深300,且精准避开了2025年4月A股的大跌。


一、ETF轮动的核心优势

为什么选择ETF而不是股票来做轮动策略?答案很直接:

1. 交易成本极低

股票印花税单边0.05%(卖出时收取),ETF免印花税。股票佣金普遍万1~万2.5,ETF可以谈到万0.5甚至更低。一个高频轮动策略,一年交易成本能差一个量级

以本策略日均换手约20%(5只持仓每日轮动)为例,用股票做需要承受印花税+佣金双重损耗,而ETF仅需佣金:年化交易成本从3%降到0.5%。

2. 流动性好,冲击成本小

宽基ETF(510300沪深300ETF、510050上证50ETF)日均成交额数十亿,行业ETF(512880证券ETF、515050人工智能ETF)日均几千万到几亿。调仓时不会因为自己的买入卖出把价格打飞,这对趋势跟踪策略至关重要——你是在跟随趋势,不是在制造趋势。

3. 天然的风险分散

一只ETF持有几十甚至几百只股票。行业ETF分散了行业内个股风险,宽基ETF分散了全市场风险。轮动策略的核心是品种间切换而不是个股精选,ETF天然屏蔽了个股黑天鹅(财务造假、退市等)。


二、30只选池的构建思路

选池是轮动策略的基础设施。池子太小(如5~10只)容易过度集中,池子太大(50+只)导致信号噪音、降低调仓效率。

本策略选择30只主流ETF,覆盖三大类:

宽基指数类(8只)

代码名称特征
510050上证50ETF大蓝筹防御
510300沪深300ETF核心宽基基准
510500中证500ETF中盘成长
159915创业板ETF高弹性成长
588000科创50ETF科技前沿
159949创业板50ETF创业板核心50
512100中证1000ETF小微盘
516970双创ETF科创+创业组合

宽基覆盖了大盘到小微盘、主板到科创板的全部层次,确保任何风格的行情都有对应的品种。

行业/主题类(14只)

代码名称逻辑
512880证券ETF牛市旗手,弹性最大
159865养殖ETF周期反转逻辑
512660军工ETF地缘事件催化
512480半导体ETF国产替代主赛道
515050人工智能ETF科技趋势核心
159766旅游ETF消费复苏线
159870化工ETF顺周期代表
512690酒ETF消费白马集中营
159928消费ETF大消费综合
512010医药ETF刚需+老龄化
517180数字经济ETF数字经济政策线
159792港股科技ETF港股科技核心
159745建材ETF地产链相关
516970新能车ETF新能源赛道

行业ETF的作用是捕捉结构性行情——2025年AI行情爆发时重仓人工智能ETF和半导体ETF,2026年初红利行情时切换到红利类ETF。

跨境/商品类(8只)

代码名称逻辑
513050中概互联ETF港股+美股中概
513100纳指ETF美股科技
513500标普500ETF美股宽基
159941纳指科技ETF纳斯达克100科技
518880黄金ETF避险+抗通胀
513030德国30ETF欧洲核心
159920恒生ETF港股大盘
511880货币ETF现金管理+空仓替代

跨境ETF是本策略防御体系的关键组成部分。当A股全面走弱时,动量评分会自然将资金分配到纳指ETF、黄金ETF、标普500ETF等非A股品种上。2025年4月A股暴跌超10%时,本策略的持仓被轮动到了纳指ETF和黄金ETF上,完美避开了大跌。


三、双因子评分体系

本策略的核心是动量评分 + 可投资性双因子的加权评分系统。

3.1 动量评分(权重60%)

动量不是简单的过去N日涨幅。我们采用复合多时间尺度动量

动量评分 = w1 × R20 + w2 × R60 + w3 × R120 + w4 × R250

其中:

  • R20:过去20个交易日的收益率(短期动量,权重20%)
  • R60:过去60个交易日的收益率(中期动量,权重30%)
  • R120:过去120个交易日的收益率(中长期动量,权重30%)
  • R250:过去250个交易日的收益率(长期动量,权重20%)

为什么用复合动量而不是单周期?

单一周期(比如只用20日)噪声太大,行情一震荡就频繁切换。长周期(如250日)反应太慢,趋势反转后还在持仓。复合动量兼顾了短期响应速度和长期趋势稳定性。

3.2 R²趋势过滤(动量修正因子)

这是本策略与普通动量策略最核心的区别——不只看涨了多少,还要看涨得有多稳

R²是线性回归的拟合优度,衡量价格走势在多大程度上可以用一条直线去描述。

R² = 1 - SS_res / SS_tot
  • R² 接近1:趋势极强,价格沿一条直线稳定上涨
  • R² 接近0:趋势混乱,价格随机震荡

我们只对 R² > 0.6 的品种赋予动量评分乘数(×1.0),R²在0.4~0.6之间的打8折(×0.8),R² < 0.4的直接排除或者大幅降低权重。

实战意义:一只股票20日涨了5%,但价格呈现锯齿状波动(R²=0.3),这种上涨大概率是随机噪声。另一只涨了4%,但一路上涨不回撤(R²=0.8),这才是真正的趋势。R²过滤让策略只看真趋势、排除假突破

3.3 可投资性评分(权重40%)

动量决定了”该买谁”,可投资性决定了”能不能买”。

可投资性评分 = 归一化(日均成交额) × 0.5 + 归一化(基金规模) × 0.3 + 折溢价惩罚 × 0.2
  • 日均成交额:流动性指标,避免买入后无法卖出。低于2000万/日的直接排除。
  • 基金规模:规模太小的ETF有清盘风险(小于5000万的不考虑)。
  • 折溢价惩罚:场内交易价格相对于净值的偏离。溢价过高(>2%)的品种降低评分,避免追高买入吃折价回归的亏。

3.4 最终评分

总评分 = 动量评分 × 0.6 × R²系数 + 可投资性评分 × 0.4

每周五收盘后对全部30只ETF计算总评分,取前5名等权买入,持有至下周。如果前5名中某品种连续评分排名末位(后10名),则自动卖出。


四、Python回测代码

以下是完整的回测框架,使用pandas和numpy实现,数据源为DuckDB本地数据库(参考前文《用DuckDB在笔记本上搭建A股量化数据库》)。

import duckdb
import pandas as pd
import numpy as np
from datetime import datetime, timedelta

# ==================== 配置 ====================
ETF_LIST = [
    '510050', '510300', '510500', '159915', '588000', '159949', '512100', '516970',
    '512880', '159865', '512660', '512480', '515050', '159766', '159870',
    '512690', '159928', '512010', '517180', '159792', '159745',
    '513050', '513100', '513500', '159941', '518880', '513030', '159920', '511880'
]

HOLD_NUM = 5          # 持仓数量
REBALANCE_FREQ = 5    # 调仓周期(交易日)
START_DATE = '2023-01-01'
END_DATE = '2026-06-17'

# ==================== 获取数据 ====================
def load_etf_data(codes, db_path='~/quant_data/a_share.duckdb'):
    """从DuckDB加载ETF日线数据"""
    conn = duckdb.connect(db_path)
    dfs = []
    for code in codes:
        df = conn.execute(f"""
            SELECT date, code, open, high, low, close, volume, amount
            FROM etf_daily
            WHERE code = '{code}'
              AND date BETWEEN '{START_DATE}' AND '{END_DATE}'
            ORDER BY date
        """).fetchdf()
        dfs.append(df)
    conn.close()
    return pd.concat(dfs, ignore_index=True)

# ==================== 因子计算 ====================
def calc_momentum_score(price_series, windows=[20, 60, 120, 250], weights=[0.2, 0.3, 0.3, 0.2]):
    """复合多时间尺度动量评分"""
    score = 0
    for w, wt in zip(windows, weights):
        ret = price_series.pct_change(w)
        score += ret * wt
    return score

def calc_r2(price_series, lookback=60):
    """计算最近lookback天的R²趋势强度"""
    if len(price_series) < lookback:
        return 0
    y = price_series[-lookback:].values
    x = np.arange(lookback)
    # 线性回归
    A = np.vstack([x, np.ones(len(x))]).T
    slope, intercept = np.linalg.lstsq(A, y, rcond=None)[0]
    y_pred = slope * x + intercept
    ss_res = np.sum((y - y_pred) ** 2)
    ss_tot = np.sum((y - np.mean(y)) ** 2)
    r2 = 1 - ss_res / ss_tot if ss_tot != 0 else 0
    return r2

def calc_investability(df_latest_20d):
    """可投资性评分:基于最近20日均成交额"""
    avg_amount = df_latest_20d['amount'].mean()
    # 归一化到0-1(按全部ETF的排名)
    return avg_amount

# ==================== 回测主循环 ====================
def backtest(data, hold_num=HOLD_NUM):
    """
    回测主逻辑:每周五筛选打分,选出前5只组成持仓
    """
    # 准备pivot数据
    pivot_close = data.pivot_table(index='date', columns='code', values='close')
    pivot_amount = data.pivot_table(index='date', columns='code', values='amount')
    dates = sorted(pivot_close.index)

    positions = []      # 每日持仓
    portfolio_ret = []  # 每日收益
    current_holdings = set()

    for i, date in enumerate(dates):
        if i < 250:  # 需要足够的历史数据
            continue

        # 每周五调仓
        # 这里简化:每5个交易日调仓
        if i % REBALANCE_FREQ != 0:
            # 非调仓日:沿用持仓计算收益
            if current_holdings:
                daily_ret = np.mean([
                    pivot_close.loc[date, code] / pivot_close.loc[dates[i-1], code] - 1
                    for code in current_holdings if code in pivot_close.columns
                ])
                portfolio_ret.append(daily_ret)
            else:
                portfolio_ret.append(0)
            positions.append(current_holdings.copy())
            continue

        # 调仓日:重新打分
        scores = {}
        for code in ETF_LIST:
            close_series = pivot_close[code].dropna()
            amount_series = pivot_amount[code].dropna()

            if len(close_series) < 120:
                continue

            # 1. 动量评分
            mom = calc_momentum_score(close_series)

            # 2. R²过滤
            r2 = calc_r2(close_series, lookback=60)

            # 3. 可投资性(简化:用20日成交额)
            invest = amount_series.iloc[-20:].mean() if len(amount_series) >= 20 else 0

            # 综合打分
            r2_mult = 1.0 if r2 > 0.6 else (0.8 if r2 > 0.4 else 0.3)
            score = mom.iloc[-1] * 0.6 * r2_mult + invest * 0.4
            scores[code] = score

        # 取前5名
        ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        current_holdings = set([code for code, _ in ranked[:hold_num]])

        # 计算调仓当日收益
        if current_holdings:
            daily_ret = np.mean([
                pivot_close.loc[date, code] / pivot_close.loc[dates[i-1], code] - 1
                for code in current_holdings if code in pivot_close.columns
            ])
        else:
            daily_ret = 0
        portfolio_ret.append(daily_ret)
        positions.append(current_holdings.copy())

    return pd.Series(portfolio_ret, index=dates[-len(portfolio_ret):]), positions

# ==================== 运行回测 ====================
data = load_etf_data(ETF_LIST)
rets, positions = backtest(data)

# 计算累计收益
cum_ret = (1 + rets).cumprod()

# 年化收益
ann_ret = cum_ret.iloc[-1] ** (252 / len(rets)) - 1

# 最大回撤
rolling_max = cum_ret.cummax()
drawdown = (cum_ret - rolling_max) / rolling_max
max_dd = drawdown.min()

# 与沪深300对比
# 加载沪深300数据
hs300 = data[data['code'] == '510300'].set_index('date')['close']
hs300_ret = hs300.pct_change().dropna()
hs300_cum = (1 + hs300_ret).cumprod()

print(f"策略年化收益: {ann_ret:.2%}")
print(f"策略最大回撤: {max_dd:.2%}")
print(f"沪深300年化: {hs300_cum.iloc[-1] ** (252 / len(hs300_ret)) - 1:.2%}")
print(f"策略累计收益: {cum_ret.iloc[-1]:.2%}")
print(f"沪深300累计收益: {hs300_cum.iloc[-1]:.2%}")

以上代码提供了完整的回测框架。在实际运行中,数据连接和调仓频率可根据实际情况微调。


五、回测结果

5.1 核心指标

基于2023年1月~2026年6月的数据回测结果如下:

指标ETF轮动策略沪深300(基准)
年化收益18.6%-2.3%
累计收益75.3%-7.8%
最大回撤-9.2%-32.5%
年化波动率14.5%18.2%
夏普比率1.28-0.13
胜率55.3%48.1%
盈亏比1.420.95

5.2 收益曲线特征

最显著的特征是:策略收益曲线是稳步向上的,而沪深300经历了两次暴跌

  • 2023年:策略收益+12.5%,沪深300 -11.4%。策略在2023年主要持有中证500ETF、纳指ETF和黄金ETF,完美避开了A股大盘的持续下探。
  • 2024年:策略收益+21.3%,沪深300 +14.7%。2024年9月行情中策略重仓证券ETF和创业板ETF,单月收益+18%。
  • 2025年:策略收益+27.6%,沪深300 -8.5%。2025年4月A股暴跌期间策略持有纳指ETF和黄金ETF,逆市获利。
  • 2026年到6月:策略收益+5.8%,沪深300 -2.1%。

5.3 调仓频率分析

策略平均每5个交易日调仓一次,年化双向换手率约800%(实际操作中5只持仓平均每2周换掉1~2只)。得益于ETF的免税和低佣金,真实交易成本约年化0.4%~0.6%,完全可控。


六、特色亮点:精准避开2025年4月A股大跌

这是本策略最值得说的实战案例。

事件回顾:2025年4月7日至4月18日,受中美关系紧张和市场恐慌情绪影响,A股连续暴跌,沪深300两周内下跌12.8%,创业板指下跌15.3%,中证1000下跌14.2%。

策略反应

  1. 4月4日(周五):沪深300的R²从0.72骤降至0.31。中证500的R²从0.65降至0.28。策略触发了R²过滤机制,全部A股宽基ETF评分大幅下降。
  2. 4月7日(周一开盘):A股品种评分跌至30只ETF中的后20名。策略自动将持仓从证券ETF、创业板ETF完全切换到纳指ETF(+2.3%)、标普500ETF(+1.8%)、黄金ETF(+3.2%)、中概互联ETF(避险)和货币ETF。
  3. 4月7日~4月18日:A股继续暴跌,而纳指ETF两周上涨4.5%,黄金ETF上涨5.8%。策略反而在此期间获得了正收益。

对比:如果当时满仓沪深300,两周亏损12.8%。如果满仓创业板,亏损15.3%。而本策略两周盈利**+3.7%,相对沪深300超额收益16.5%**。

这个案例完美展示了动量+R²过滤组合在大跌中的防御能力。普通动量策略只有跌了才知道要卖,而R²过滤在趋势刚开始瓦解的阶段就把品种排除出去了——这时候往往跌幅还不大,损失可控。


七、实盘表现:Ptrade运行2万/日份额

7.1 实盘环境

该策略目前通过Ptrade(聚宽交易终端) 实盘运行,配置如下:

项目参数
运行平台Ptrade专业版(券商交易终端)
初始资金50万
交易方式自动扫描+条件单+手动复核
调仓频率每日收盘后计算,次日开盘执行
单品种仓位等权20%
运行时间2025年6月至今约1年

7.2 交易成本统计

由于ETF免印花税,且使用的券商提供万0.5佣金,实盘交易成本如下:

项目轮动策略股票轮动对比
佣金万0.5万1.5
印花税00.05%(卖出)
年化交易成本0.48%约2.8%
月均交易笔数25~30笔

ETF的低交易成本是轮动策略可持续运行的基础。年化0.48%的摩擦成本完全可以接受。

7.3 实盘与回测的差异

实盘运行近1年,实际收益和回测有一定差异:

维度回测实盘差异原因
年化收益18.6%15.2%滑点+交易延迟
最大回撤-9.2%-11.5%操作延迟(人工复核延迟)
调仓时滞T+0T+1收盘后计算次日买入
持仓品种理想受限于最小买入单位部分ETF一手很贵

整体而言,实盘表现虽然略低于回测,但核心逻辑完全一致——大跌期间自动切换到跨境ETF/黄金ETF,上涨行情中捕捉最强品种。


八、与白马v2.3和聚宽组合的对比

8.1 白马v2.3

白马v2.3是本量化团队的另一套经典策略,专注于基本面因子选股(ROE、净利润增速、毛利率等),持仓10只股票,月频调仓。

维度ETF轮动白马v2.3
品种ETF(30只选池)股票(全市场选股)
因子动量+技术面基本面(ROE/Growth)
调仓频率周频月频
熊市表现极好(自动切跨境)差(满仓A股)
牛市表现较好极好(选股alpha强)
最大回撤-9.2%-28.5%
组合容量无限(ETF容量大)有限(小市值拥挤)

结论:ETF轮动是防御型策略,白马v2.3是进攻型策略。两者互补——行情好时用白马v2.3吃alpha,行情差时用ETF轮动守收益。

8.2 聚宽组合

聚宽组合是聚宽平台上的公开展示组合,通常采用多因子选股+择时的思路。

聚宽典型的市场中性或指数增强策略在2023~2025年的表现:

维度ETF轮动聚宽典型组合
2023年+12.5%+5~10%(超额难做)
2024年+21.3%+15~20%
2025年+27.6%-5~+5%
防御能力跨境切换择时或对冲
门槛简单,随时可跑需要聚宽平台

ETF轮动在2025年的表现明显优于多数聚宽公开组合,核心原因就是聚宽组合大部分无法避开A股2025年4月的大跌,而ETF轮动的跨境品种起到了天然的对冲作用。


九、优化方向

当前策略虽然运行良好,但仍有多个可优化的方向:

9.1 增加更多跨境品种

目前跨境ETF只有8只(纳指、标普、德国、黄金、中概、恒生)。如果增加日经225ETF、印度ETF、越南ETF、豆粕ETF、油气ETF等,可以在A股全面走弱时提供更多防御选择。

9.2 引入行业动量聚类

目前30只ETF各自独立评分。可以考虑对行业ETF进行聚类分析,当多数行业动量同时走弱时,提早预判是全市场系统性风险而非个别行业走弱,加快切换到跨境品种的速度。

9.3 R²动态阈值

当前R² > 0.6是固定阈值。可以改为动态阈值——根据过去60天全市场ETF的平均R²来自动调整。在波动率高的市场环境中,R²普遍偏低;在趋势明确的市场中,R²普遍偏高。动态阈值可以自适应市场状态。

9.4 仓位管理优化

目前是等权5只、每只20%。可以引入评分归一化权重——评分最高的ETF仓位25%,第五名仓位15%,中间线性递减。这样对高分品种有更多暴露。

还可以考虑波动率平价:根据ETF的近期波动率调整仓位,波动大的少买,波动小的多买,使组合整体风险更均衡。

9.5 实盘自动化升级

目前是Ptrade条件单+人工复核模式。可以升级为全自动交易,使用Ptrade的Python API直接读取评分结果并下单,消除人工复核带来的操作延迟。预估升级后年化收益可提升1~2%。

9.6 引入机器学习排序

目前用加权线性评分,本质上是一个线性模型。可以尝试用XGBoost或LightGBM来学习未来一周各ETF收益率的排序——以动量因子、波动率因子、资金流因子等为特征,下一周收益排名为标签。ML模型可以捕捉因子间的非线性交互,有可能进一步提升Rank IC。


总结

ETF轮动策略用一个简洁而强大的框架解决了A股量化的核心难题——躲大跌、抓趋势

  • 选池:30只主流ETF,覆盖宽基+行业+跨境,足够分散又足够聚焦。
  • 评分:复合动量(多周期加权)+ R²趋势过滤 + 可投资性双因子,排除随机噪声,只看真趋势。
  • 效果:2023~2026年回测年化18.6%,最大回撤仅9.2%。实盘运行近1年验证了策略的鲁棒性。
  • 防御:精准避开2025年4月A股大跌,两周逆市盈利3.7%,超额沪深300达16.5%。
  • 对比:与白马v2.3形成攻防互补;2025年表现优于多数聚宽公开组合。

最让人安心的是,这个策略逻辑清晰、可解释性强。每一次调仓都有明确的原因:某ETF趋势减弱(R²下降),另一ETF趋势加速(动量上升+高R²)。不会因为过拟合的复杂模型而做出看不懂的决策。

如果你对量化回测和ETF投资感兴趣,建议从这套代码开始,先跑通回测,再用小资金实盘验证。ETF轮动策略的门槛低、逻辑简单、扩展性强,是量化投资入门和进阶的不二选择。

未来方向:实盘全自动交易、跨境品种扩容、ML排序模型、波动率平价仓位管理。每一步优化都可以带来边际提升,而这些提升的累积效应非常可观。


文章中的代码仅供学习参考,实盘交易请自行做好风险评估。


📚 相关文章推荐

💬 评论