远思为径,码动价值。欢迎来到远思码技术实验室的最新技术实践记录。
有了回测引擎,第一件想做的事一定是"跑一个看起来能赚钱的策略"。实验室也不例外,但我们刻意选了最简单、最没有故事性的两个策略来做基准,因为它们最不容易过拟合,也最容易暴露研究流程里的问题。
重要声明:本文所有回测结果均为实验室内部示例,使用的历史区间有限、未考虑全部交易限制,不构成任何投资建议。
一、策略 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.79 | 0.28 |
| 最大回撤 | -19.6% | -42.1% |
| Calmar | 0.60 | 0.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% 的曲线",而是三件事:
- 择时工具的价值主要在压回撤,不要在它身上指望超额收益;
- 参数稳健性比单点最优重要一个数量级,热力图上的高原才是真信号;
- 换手是隐形税,任何策略的第一版都应该先算"成本翻倍后还剩多少"。
下一阶段,我们从"规则策略"转向"机器学习选股"——用 LightGBM 做截面收益预测,那里会碰到比参数网格更危险的东西:信息泄露。
远思为径,码动价值。回测里每一条平滑的曲线,背后都藏着一堆没写进报告的前提。