每天只买最强的一只 ETF:一套年化 29% 的轮动策略(附源码)
买 ETF 最常见的两个坑:赛道选错,风格切换时不肯换。这套策略不猜顶、不猜底,每天只回答一件事——这一篮子里,谁现在最强。
2020-01-02 至 2026-08-18,计入佣金和滑点后:10 万元变为 54.6 万元,年化 29.2%,同期沪深 300 年化 4.1%。最大回撤 -39.3%。胜率大约一半,靠的是盈亏比,不是猜对次数。
下面给出完整规则、回测、读图方法和可运行的核心代码。数字全部来自同一套前复权日线回测,不是截取牛市片段后的「年化」。
规则(先看懂这个,图才有意义)
固定一篮子高流动性 ETF。每个交易日:
- 用过去 25 个交易日的收盘价,计算加权斜率得分。涨得快、走得直,分数才高。
- 丢掉得分 ≤ 0,或收盘价跌破 60 日均线的品种。
- 满仓得分第一名;一个都不合格,就持有现金。
- 买入后至少持有 5 个交易日,避免两只分数接近时来回打转。
对照做了两个池子:
- 行业轮动(主策略):创业板、半导体、芯片、酒、证券、军工、消费、医药、纳指、黄金、计算机、创新药。弹性大,回撤也大。
- 跨境轮动:创业板、纳指、德国、日经、豆粕、黄金。相关性更低,回撤更小。
回测口径:东方财富前复权日线;佣金万分之 2.5;买卖各加 0.10% 滑点;收盘价出信号、收盘价成交。这比实盘乐观——真实交易里你没法用同一个收盘价既看见又买到。
回测结果
净值(千元,初始 100)· 蓝:行业轮动 · 绿:跨境轮动 · 灰:沪深300 · 2020-01 至 2026-08
行业轮动 跨境轮动 沪深300ETF
分年收益率(%)· 蓝:行业轮动 · 绿:跨境轮动
| 年份 | 行业轮动 | 当年回撤 | 跨境轮动 | 当年回撤 |
|---|---|---|---|---|
| 2020 | 151.3% | -35.4% | 44.5% | -20.9% |
| 2021 | 26.7% | -27.0% | -1.3% | -18.1% |
| 2022 | -7.2% | -24.9% | 32.8% | -16.6% |
| 2023 | 12.3% | -19.5% | 53.6% | -9.8% |
| 2024 | 24.1% | -14.9% | 3.4% | -15.7% |
| 2025 | 35.3% | -28.7% | 54.8% | -13.6% |
| 2026 | -1.8% | -39.3% | 4.3% | -18.5% |
行业轮动:各 ETF 已实现盈亏(元)
| 代码 | 名称 | 卖出次数 | 盈亏 | 胜率 |
|---|---|---|---|---|
| 512690 | 酒ETF | 14 | +184,242 | 50% |
| 512480 | 半导体ETF | 14 | +137,951 | 50% |
| 512880 | 证券ETF | 12 | +109,944 | 33% |
| 513100 | 纳指ETF | 17 | +106,918 | 65% |
| 512660 | 军工ETF | 13 | +89,667 | 62% |
| 159915 | 创业板ETF | 10 | +34,265 | 50% |
| 159992 | 创新药ETF | 11 | -3,709 | 45% |
| 159995 | 芯片ETF | 7 | -21,575 | 43% |
| 159928 | 消费ETF | 5 | -27,200 | 20% |
| 512010 | 医药ETF | 5 | -28,139 | 40% |
| 518880 | 黄金ETF | 22 | -46,750 | 45% |
| 512720 | 计算机ETF | 11 | -72,294 | 55% |
夏普 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) 必须用同一套权重,否则会出现「价格在涨、分数为负」。
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)。
参数
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)。把总收益除以年数得到的「算术年化」会虚高,本文不用。
过滤后排名
必须先过滤再排序。如果先取第一名再检查均线,被踢掉后不会自动换成第二名。
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调仓
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。
本地复现
python3 download_data.pypython3 run_backtest.py依赖 numpy、pandas、requests。改池子或周期:编辑 strategy.py 后重新跑回测。打分、撮合、拉数分别在 momentum.py、backtest.py、download_data.py。
回测不能替你承担的部分
- 29% 是这段样本的 CAGR,不是明年的预期。 行业主升浪不会每年都来。2020 年单独拿出来看,会严重高估策略。
- 回撤发生时策略不会变温顺。 -39% 出现在 2026 年,不是「很久以前已经消化完了」。
- 收盘价成交少算了实盘摩擦。 涨停买不进、折溢价、冲击成本、停牌,回测里都没有。
- 池子本身有事后视角。 今天能列出半导体、芯片、创新药,是因为这些产品已经存在且流动性够用。2018 年你未必组得出同一篮子。
这是研究案例,用来讲清规则和复现路径,不是投资建议。
