每天只买最强的一只 ETF:一套年化 29% 的轮动策略(附源码)

每天只买最强的一只 ETF:一套年化 29% 的轮动策略(附源码)

asmcosasmcos
2 次阅读
ETF轮动动量策略量化回测
文章目录

每天只买最强的一只 ETF:一套年化 29% 的轮动策略(附源码)

买 ETF 最常见的两个坑:赛道选错,风格切换时不肯换。这套策略不猜顶、不猜底,每天只回答一件事——这一篮子里,谁现在最强

2020-01-02 至 2026-08-18,计入佣金和滑点后:10 万元变为 54.6 万元,年化 29.2%,同期沪深 300 年化 4.1%。最大回撤 -39.3%。胜率大约一半,靠的是盈亏比,不是猜对次数。

下面给出完整规则、回测、读图方法和可运行的核心代码。数字全部来自同一套前复权日线回测,不是截取牛市片段后的「年化」。


规则(先看懂这个,图才有意义)

固定一篮子高流动性 ETF。每个交易日:

  1. 用过去 25 个交易日的收盘价,计算加权斜率得分。涨得快、走得直,分数才高。
  2. 丢掉得分 ≤ 0,或收盘价跌破 60 日均线的品种。
  3. 满仓得分第一名;一个都不合格,就持有现金。
  4. 买入后至少持有 5 个交易日,避免两只分数接近时来回打转。

对照做了两个池子:

  • 行业轮动(主策略):创业板、半导体、芯片、酒、证券、军工、消费、医药、纳指、黄金、计算机、创新药。弹性大,回撤也大。
  • 跨境轮动:创业板、纳指、德国、日经、豆粕、黄金。相关性更低,回撤更小。

回测口径:东方财富前复权日线;佣金万分之 2.5;买卖各加 0.10% 滑点;收盘价出信号、收盘价成交。这比实盘乐观——真实交易里你没法用同一个收盘价既看见又买到。


回测结果

29.2%
年化 CAGR
446%
累计收益 · 10万→54.6万
-39.3%
最大回撤 · 2026-01-12 ~ 2026-04-29
4.1%
同期沪深300年化

净值(千元,初始 100)· 蓝:行业轮动 · 绿:跨境轮动 · 灰:沪深300 · 2020-01 至 2026-08

7722938053268320202021202220232024202520262026

行业轮动 跨境轮动 沪深300ETF

分年收益率(%)· 蓝:行业轮动 · 绿:跨境轮动

151%-7%2020202120222023202420252026
年份行业轮动当年回撤跨境轮动当年回撤
2020151.3%-35.4%44.5%-20.9%
202126.7%-27.0%-1.3%-18.1%
2022-7.2%-24.9%32.8%-16.6%
202312.3%-19.5%53.6%-9.8%
202424.1%-14.9%3.4%-15.7%
202535.3%-28.7%54.8%-13.6%
2026-1.8%-39.3%4.3%-18.5%

行业轮动:各 ETF 已实现盈亏(元)

代码名称卖出次数盈亏胜率
512690酒ETF14+184,24250%
512480半导体ETF14+137,95150%
512880证券ETF12+109,94433%
513100纳指ETF17+106,91865%
512660军工ETF13+89,66762%
159915创业板ETF10+34,26550%
159992创新药ETF11-3,70945%
159995芯片ETF7-21,57543%
159928消费ETF5-27,20020%
512010医药ETF5-28,13940%
518880黄金ETF22-46,75045%
512720计算机ETF11-72,29455%

夏普 0.73(rf=2%) · 卡玛 0.74 · 胜率 49% · 盈亏比 1.55 · 佣金万2.5 + 滑点 0.10%

这张图在说什么

29% 不是每年稳拿。 行业轮动 2020 年 +151%,吃的是白酒、半导体、券商的主升浪;2022 年 -7%,2026 年截至 8 月仍是小亏。把样本从 2020 年中切到 2021 年中,任何轮动都能写成「年化飞起」。完整样本才有对照意义。

两个池子解决的问题不同。 2022 年 A 股行业普跌,行业轮动亏钱,跨境池因为纳指、日经、黄金、豆粕反而 +33%。2020 年结构牛里,行业池远强于跨境池。要弹性用行业,要回撤可控用跨境(年化 27.0%,最大回撤 -20.9%)。

利润集中在少数主升浪。 酒、半导体、证券、纳指四只贡献了大部分盈利;计算机、黄金是净亏损。胜率 49% 说明:这不是「经常猜对」,而是对的时候赚得足够多(盈亏比 1.55),错的时候用均线和空仓把亏截住。

最大回撤发生在 2026 年 1–4 月,不是陈年旧账。 -39% 意味着 10 万可以变成约 6 万。回测好看的前提是你能拿住这段。


为什么这样设计

不用「25 日涨幅」。 涨幅只看两个点:今天比 25 天前贵了多少。中间暴涨暴跌、最后一天拉回来,分数一样高。加权斜率看整段路径:对 (\ln(价格)) 做回归,斜率是速度,再乘 (R^2)(有多直)。走得歪,分数会被打折;阴跌,斜率为负,进不了池子。近期数据权重大(1→2),对拐点比等权回归更敏感。

加 60 日均线。 近 25 日得分为正,经常只是熊市反弹。价格在均线下方就过滤掉,等于多一道「大趋势还在」的确认。2022 年行业轮动只亏 7%,和这一条有关。

不合格就空仓。 这是绝对动量:全池都没有正分,持有现金。相对动量(永远拿第一名)在普跌年会跟着一起跌。空仓不是额外写的「if 熊市」,是过滤后名单为空的自然结果。

最少持有 5 天,且不加 18% 止盈。 信号每天都在变。不设最短持有,会在两只分数接近的 ETF 之间反复换仓,手续费吃掉趋势。止盈设太近,会把 2023–2025 纳指、黄金这类长趋势提前切开。


核心源码

实现拆成四块:打分、过滤、调仓、绩效。改池子和周期只动参数,不必改公式。

打分:加权斜率

period=25 时取 26 根收盘价(含当天)。回归与 (R^2) 必须用同一套权重,否则会出现「价格在涨、分数为负」。

python
def weighted_slope_momentum_v2(closes: np.ndarray, period: int) -> float:    n = period + 1    if len(closes) < n:        return np.nan    y = np.log(np.asarray(closes[-n:], dtype=float))    x = np.arange(n, dtype=float)    weights = np.linspace(1.0, 2.0, n)          # 最旧 1,最新 2    slope, intercept = np.polyfit(x, y, 1, w=weights)    y_pred = slope * x + intercept    w = weights ** 2                            # 与回归口径一致    mean_y = float(np.sum(w * y) / np.sum(w))    ss_res = float(np.sum(w * (y - y_pred) ** 2))    ss_tot = float(np.sum(w * (y - mean_y) ** 2))    r2 = 1.0 if ss_tot == 0 else 1.0 - ss_res / ss_tot    annualized = float(np.exp(float(slope) * 250.0) - 1.0)    return annualized * r2                      # 得分 = 年化斜率收益 × R²

numpy.polyfit(..., w=weights) 里 (w) 会进入残差平方,等效加权最小二乘的权重是 (w^2),所以计算 (R^2) 时也要用 (w^2)。

参数

python
MAIN = Config(    pool=list(POOL_INDUSTRY.keys()),  # 12 只行业 / 主题 ETF    start="2020-01-01",    end="2026-08-18",    initial_cash=100_000.0,    commission=0.00025,       # 万分之 2.5    slippage=0.001,           # 单边 0.10%    momentum_period=25,    momentum_floor=0.0,       # 得分必须 > 0    top_n=1,                  # 只拿第一名    min_hold_days=5,    ma_filter=60,             # 收盘价 > MA60    enable_take_profit=False,    enable_trailing_stop=False,)

买入价 = 收盘价 × (1 + 滑点),卖出价 = 收盘价 × (1 − 滑点),佣金按成交金额另扣。年化用日历日复合:((期末/期初)^{365.25/天数}-1)。把总收益除以年数得到的「算术年化」会虚高,本文不用。

过滤后排名

必须先过滤再排序。如果先取第一名再检查均线,被踢掉后不会自动换成第二名。

python
def rank_codes(day: pd.Timestamp) -> list[tuple[str, float]]:    scored = []    loc = close.index.get_loc(day)    for code in cfg.pool:        if pd.isna(close_raw.at[day, code]):            continue                              # 当日无行情,不买        hist = close[code].iloc[: loc + 1].dropna().to_numpy()        if len(hist) < cfg.ma_filter:            continue        if hist[-1] <= hist[-cfg.ma_filter :].mean():            continue                              # 跌破均线,不买        score = weighted_slope_momentum_v2(hist, cfg.momentum_period)        if np.isnan(score) or score <= cfg.momentum_floor:            continue        scored.append((code, float(score)))    scored.sort(key=lambda x: x[1], reverse=True)    return scored

调仓

python
ranked = rank_codes(day)target = [c for c, _ in ranked[: cfg.top_n]]      # 最多 1 只for pos in list(positions.values()):    pos.hold_days += 1    if pos.hold_days < cfg.min_hold_days:        continue                                  # 未满 5 日,不换    if pos.code not in target:        sell(pos.code, day, "rotate")             # 不再是第一名,卖掉if target and cash > 1 and not positions:    buy(target[0], day, cash)                     # 空仓则全仓买入第一名

target 为空时把旧仓卖掉、现金留在账上,就是空仓,不需要单独判断熊市。

行情用东方财富日线,fqt=1 前复权;深市代码 0.15xxxx,沪市 1.51xxxx


本地复现

bash
python3 download_data.pypython3 run_backtest.py

依赖 numpypandasrequests。改池子或周期:编辑 strategy.py 后重新跑回测。打分、撮合、拉数分别在 momentum.pybacktest.pydownload_data.py


回测不能替你承担的部分

  • 29% 是这段样本的 CAGR,不是明年的预期。 行业主升浪不会每年都来。2020 年单独拿出来看,会严重高估策略。
  • 回撤发生时策略不会变温顺。 -39% 出现在 2026 年,不是「很久以前已经消化完了」。
  • 收盘价成交少算了实盘摩擦。 涨停买不进、折溢价、冲击成本、停牌,回测里都没有。
  • 池子本身有事后视角。 今天能列出半导体、芯片、创新药,是因为这些产品已经存在且流动性够用。2018 年你未必组得出同一篮子。

这是研究案例,用来讲清规则和复现路径,不是投资建议。

评论区0

还没有评论,快来抢沙发吧~

登录 后可发表评论