双均线与动量轮动策略实战:参数稳健性比收益率更重要

用自研向量化引擎跑两个入门级但不平庸的策略:双均线择时与 ETF 动量轮动。重点不在收益率,而在参数稳健性、成本敏感性与分年度表现——如何判断一条漂亮的曲线是真的还是拟合出来的。

远思为径,码动价值。欢迎来到远思码技术实验室的最新技术实践记录。

有了回测引擎,第一件想做的事一定是"跑一个看起来能赚钱的策略"。实验室也不例外,但我们刻意选了最简单、最没有故事性的两个策略来做基准,因为它们最不容易过拟合,也最容易暴露研究流程里的问题。

重要声明:本文所有回测结果均为实验室内部示例,使用的历史区间有限、未考虑全部交易限制,不构成任何投资建议。

一、策略 A:双均线择时

逻辑烂大街:短均线上穿长均线满仓,下穿空仓。它的价值不在于赚钱,而在于给我们一个"基准尺子"——任何复杂策略如果连它都打不过,就没必要继续。

# strategies/ma_cross.py
import pandas as pd


def ma_cross_signal(close: pd.Series, fast: int = 20, slow: int = 60) -> pd.Series:
    """返回目标仓位序列:1 满仓 / 0 空仓。只在收盘后生成,次日生效。"""
    ma_fast = close.rolling(fast).mean()
    ma_slow = close.rolling(slow).mean()
    sig = (ma_fast > ma_slow).astype(float)
    # 均线未成型期间不持仓
    sig[ma_slow.isna()] = 0.0
    return sig

把它接到引擎上只需要构造 target_w 矩阵:

# strategies/run_ma.py
import pandas as pd

from backtest.engine import BacktestConfig, run_backtest
from strategies.ma_cross import ma_cross_signal


def run_ma_backtest(symbol: str, start: str, end: str,
                    fast: int = 20, slow: int = 60) -> dict:
    from src.data.loader import load_bars

    df = load_bars([symbol], start, end, adjust="qfq")
    close = df.pivot(index="trade_date", columns="symbol", values="close")
    volume = df.pivot(index="trade_date", columns="symbol", values="volume")
    pct = load_bars([symbol], start, end, adjust="none")
    pct = pct.pivot(index="trade_date", columns="symbol", values="pct_chg")

    sig = ma_cross_signal(close[symbol], fast, slow)
    target_w = pd.DataFrame({symbol: sig}, index=close.index)
    # 引擎内部会做 shift(1):sig 在 T 日收盘生成,T+1 日承担收益
    res = run_backtest(close, volume, target_w, pct_chg=pct)
    return res.metrics

实验室以沪深 300 ETF(510300)为标的,2019-01 ~ 2025-06 的结果(示例):

参数 (fast, slow)年化收益夏普最大回撤年化换手
(20, 60)7.2%0.62-18.4%2.1 倍
(10, 30)5.8%0.49-21.7%4.6 倍
(30, 120)6.9%0.66-16.2%0.9 倍
买入持有3.4%0.28-42.1%0

有价值的结论只有一个:择时把最大回撤从 -42% 压到 -18%,代价是年化收益只从 3.4% 提到 7.2%。这才是双均线的真实贡献——它是个风险管理工具,不是收益引擎。

二、策略 B:ETF 动量轮动

第二个策略稍微进阶:在一个 ETF 池里按动量排名,只持有最强的几只。

# strategies/momentum_rotation.py
from __future__ import annotations

import numpy as np
import pandas as pd


def momentum_score(close: pd.DataFrame, lookback: int = 20,
                   vol_window: int = 20) -> pd.DataFrame:
    """风险调整动量:过去 N 日收益 / 过去 N 日波动。截面排名用。"""
    ret = close / close.shift(lookback) - 1
    vol = close.pct_change().rolling(vol_window).std()
    return (ret / vol).replace([np.inf, -np.inf], np.nan)


def build_target_weights(close: pd.DataFrame, score: pd.DataFrame,
                         top_k: int = 2, rebalance: str = "M",
                         min_score: float = 0.0) -> pd.DataFrame:
    """月末选动量最强的 top_k 只等权持有,其余现金。

    rebalance='M' 表示只在每月最后一个交易日调仓,中间日权重保持不变。
    """
    idx = close.index
    if rebalance == "M":
        # 每月最后一个交易日。这里统一转成 Timestamp,避免 datetime64 与 Timestamp
        # 在 set 里哈希不一致导致"一个调仓日都没命中"的静默 bug。
        month_end = pd.Series(idx, index=idx).groupby(idx.to_period("M")).max()
        rebal_set = {pd.Timestamp(d).normalize() for d in month_end}
    else:
        rebal_set = {pd.Timestamp(d).normalize() for d in idx}

    target = pd.DataFrame(0.0, index=idx, columns=close.columns)
    last_w = pd.Series(0.0, index=close.columns)
    for date, row in score.iterrows():
        if pd.Timestamp(date).normalize() in rebal_set:
            valid = row.dropna()
            # 只买动量为正的标的,否则空仓
            valid = valid[valid > min_score].sort_values(ascending=False)
            picks = valid.head(top_k).index
            last_w = pd.Series(0.0, index=close.columns)
            if len(picks):
                last_w[picks] = 1.0 / top_k
        target.loc[date] = last_w
    return target

ETF 池选了六只,覆盖股、债、商品、海外:

POOL = {
    "510300": "沪深300ETF",
    "510500": "中证500ETF",
    "159915": "创业板ETF",
    "513100": "纳指ETF",
    "518880": "黄金ETF",
    "511010": "国债ETF",
}

2019-01 ~ 2025-06,lookback=20, top_k=2, 月度调仓 的示例结果:

指标动量轮动沪深300 买入持有
年化收益11.8%3.4%
年化波动14.2%21.5%
夏普0.790.28
最大回撤-19.6%-42.1%
Calmar0.600.08
年化换手8.4 倍0
日胜率53.1%48.6%

分年度看,它并不每年都赢:

年份轮动沪深300差异
2019+21.4%+36.1%-14.7%
2020+28.6%+27.2%+1.4%
2021+16.3%-5.2%+21.5%
2022-8.9%-21.6%+12.7%
2023+5.2%-11.4%+16.6%
2024+14.7%+14.7%0.0%
2025H1+6.1%+2.8%+3.3%

2019 年跑输 15 个点,原因是牛市里持有单一最强动量 ETF,反而在板块轮动中来回挨打。这个"缺陷"贯穿了后面所有讨论:动量策略在趋势延续时赚钱,在急切换时亏钱。

三、参数稳健性:热力图比单点更好看也更诚实

实验室的规矩是:任何策略都不允许只看一组参数的结果。于是把 lookback × 调仓周期 打成一张热力图。

# scripts/grid_robustness.py
import itertools

import pandas as pd

from strategies.momentum_rotation import build_target_weights, momentum_score
from strategies.run_ma import run_ma_backtest  # noqa: F401  (示意:复用取数/引擎)


def grid_search(close: pd.DataFrame, volume: pd.DataFrame, pct: pd.DataFrame) -> pd.DataFrame:
    rows = []
    for lookback, top_k, rebal in itertools.product([10, 20, 40, 60], [1, 2, 3], ["M", "W"]):
        score = momentum_score(close, lookback=lookback)
        w = build_target_weights(close, score, top_k=top_k, rebalance=rebal)
        res = run_backtest(close, volume, w, pct_chg=pct)
        rows.append({"lookback": lookback, "top_k": top_k, "rebal": rebal,
                     **{k: res.metrics[k] for k in ("年化收益", "夏普", "最大回撤", "年化换手")}})
    return pd.DataFrame(rows)


def robustness_table(df: pd.DataFrame) -> pd.DataFrame:
    """汇总:多少比例的参数组合夏普 > 0.5,多少 > 0.3。"""
    return pd.DataFrame({
        "夏普>0.8": [(df["夏普"] > 0.8).mean()],
        "夏普>0.5": [(df["夏普"] > 0.5).mean()],
        "夏普>0.3": [(df["夏普"] > 0.3).mean()],
        "夏普中位数": [df["夏普"].median()],
        "最差夏普": [df["夏普"].min()],
    })

24 组参数的实际分布(示例):

  • 夏普中位数 0.61,最好 0.88,最差 0.12;
  • 18/24 组夏普 > 0.4,说明策略不是靠某一组魔法参数撑起来的;
  • 但 top_k=1 的 8 组里有 5 组夏普 < 0.4,集中度过高是这个策略的脆弱点。

判断标准很朴素:如果参数热力图上有大片"深坑",说明策略在拟合噪声;如果是一片"高原",参数不敏感,策略逻辑本身站得住。上面这张表勉强算高原。

四、成本敏感性:把假设打回原形

回测里最贵的不是滑点,是乐观。把成本参数逐级调高,看策略还剩多少:

成本假设年化收益夏普
佣金万三 + 无滑点13.2%0.88
佣金万三 + 单边 0.1% 滑点11.8%0.79
佣金万三 + 单边 0.2% 滑点10.4%0.70
再加 0.1% 冲击成本8.1%0.55
极端:单边 0.5%3.2%0.22

换手 8.4 倍的策略,成本每提高 0.1%,年化就掉 1.3~1.4 个点。这是 ETF 轮动真实的脆弱面——它需要每月真金白银地换仓。

也正因为这个原因,实验室的实盘版本改成了"只在新信号与当前持仓差异超过一定阈值时才调仓",把年化换手从 8.4 倍压到 4 倍左右,年化收益只掉了 0.8 个点。这类工程细节,永远比再找一个因子更值钱。

五、几个只在实盘阶段才暴露的问题

  • ETF 溢价与流动性。纳指 ETF(513100)长期有溢价,回测里按净值成交的假设在实盘会被吃掉 1~3%。更麻烦的是它偶尔限购,大资金根本买不进去。
  • 上市时间不同。创业板 ETF 2011 年就有,纳指 ETF 2013 年上市。如果起始日期按最长的算,早期回测里那些"缺失标的"会被当成持续下跌处理,动量排名就失真了。正确做法是只在当期有数据的标的里排名(dropna() 已经处理,但要检查是不是真的这么做的)。
  • 幸存者偏差。ETF 池是按今天的名单选的。2019 年时并没有现在这么多行业 ETF,如果回测里混进了"因为后来表现好才被记住的品种",结论就不可信。
  • 分红与拆分。ETF 分红会导致价格跳空,未做复权的动量会误判趋势反转。

六、结论:策略的意义在于暴露流程问题

两个策略跑完,实验室得到的收获不是"找到了一条年化 12% 的曲线",而是三件事:

  1. 择时工具的价值主要在压回撤,不要在它身上指望超额收益;
  2. 参数稳健性比单点最优重要一个数量级,热力图上的高原才是真信号;
  3. 换手是隐形税,任何策略的第一版都应该先算"成本翻倍后还剩多少"。

下一阶段,我们从"规则策略"转向"机器学习选股"——用 LightGBM 做截面收益预测,那里会碰到比参数网格更危险的东西:信息泄露。

远思为径,码动价值。回测里每一条平滑的曲线,背后都藏着一堆没写进报告的前提。