上一篇我们用等权组合和定期再平衡把多个资产装进了一个篮子,本篇要解决一个更隐蔽、也更致命的问题:当你调出一个「完美」参数,它很可能只是记忆了历史,而不是发现了规律。这一篇我们讲参数优化的正确姿势——网格搜索怎么用、样本内/样本外怎么切、以及量化圈公认的防过拟合利器:Walk-Forward(滚动前推)优化。
本篇目标
- 能用网格搜索穷举双均线参数组合,并画出「参数热力图」
- 能说清样本内(In-Sample)与样本外(Out-of-Sample)的区别,以及为什么样本外成绩才算数
- 能手写一个完整的 Walk-Forward 滚动优化循环,并解读其结果
- 能识别过拟合的典型信号:样本内漂亮、样本外崩塌
- 理解「参数平原」概念,学会挑稳健参数而不是尖峰参数
核心概念
为什么参数优化本身就是危险动作
任何策略都有参数:双均线的快慢周期、布林带的倍数、止损的百分比。给定一段历史数据,总能找到一组参数让它表现惊艳。但历史不会简单重复——在历史数据上挑出的最优参数,往往是对噪声的拟合。这就像考试前背了题库,模拟卷满分不代表真实考试能考好。
划重点:参数数量越多、数据越少、尝试的次数越多,过拟合的风险就越高。一个只有 2 个参数的策略测试了 100 组参数,和一个有 10 个参数的策略测试了 1000 组,后者更危险。
网格搜索与参数热力图
网格搜索(Grid Search)就是把参数的所有候选值组合成一个网格,逐一回测。以双均线为例,快线取 3~15,慢线取 20~120,就有几百组组合。把每组参数的年化收益填进矩阵,就得到一张热力图(Heatmap):颜色越亮代表收益越高。
热力图的价值不只是找最大值,更是看形状:如果最优点孤立地亮在一片暗色中间(尖峰),说明它高度敏感、不可信;如果高收益区域连成一片(平原),说明附近参数都能赚钱,策略逻辑更稳健。
样本内与样本外
样本内(In-Sample,简称 IS):用来调参的数据段。样本外(Out-of-Sample,简称 OOS):调参完成后用于验证的数据段,调参过程完全不接触它。规则只有一条:只有样本外的成绩才算策略的真实能力估计。样本内年化 30%、样本外 3%,这个策略基本可以判定为过拟合。
Walk-Forward:让「样本外」滚动起来
静态切分只有一个样本外段,偶然性大。Walk-Forward 的做法是:
窗口1: 训练 2015-2016 → 测试 2017
窗口2: 训练 2015-2017 → 测试 2018 (或滚动式: 训练 2016-2017 → 测试 2018)
窗口3: 训练 2015-2018 → 测试 2019
...依次前推,把每段测试期的成绩拼接成最终的评估序列
每一轮都在「当时的过去」上选参数,然后在「当时的未来」上验证——这正是模拟实盘的节奏:你永远只能用已知信息决策。拼接后的测试段收益曲线,就是这套调参流程的真实成绩单。
划重点:Walk-Forward 拼接的是「测试段」,训练段只用来选参数,绝不能把训练段的成绩也拼进去,否则又变回过拟合。
完整代码
本篇依赖:uv pip install pandas numpy matplotlib。数据沿用系列约定:akshare 优先,失败时用固定种子的本地模拟数据。
import numpy as np
import pandas as pd
# ---------- 1. 准备数据:akshare 优先,本地造数据 fallback ----------
def get_data():
try: # akshare 接口可能随版本变化,请以官方文档为准
import akshare as ak
df = ak.stock_zh_a_hist(symbol="600519", period="daily",
start_date="20150101", end_date="20241231",
adjust="qfq")
df = df.rename(columns={"日期": "date", "收盘": "close"})
df["date"] = pd.to_datetime(df["date"])
return df.set_index("date")[["close"]]
except Exception:
rng = np.random.default_rng(42) # 固定种子,保证可复现
dates = pd.bdate_range("2015-01-01", "2024-12-31")
ret = rng.normal(0.0004, 0.015, len(dates)) # 示意用随机收益
price = 100 * np.exp(np.cumsum(ret))
return pd.DataFrame({"close": price}, index=dates)
df = get_data()
print(f"数据条数: {len(df)}, 区间: {df.index[0].date()} ~ {df.index[-1].date()}")
# ---------- 2. 双均线向量化回测函数(供网格调用) ----------
def backtest_ma(df, fast, slow, fee=0.001):
"""返回策略日收益序列;信号 shift(1) 避免未来函数"""
ma_fast = df["close"].rolling(fast).mean()
ma_slow = df["close"].rolling(slow).mean()
sig = (ma_fast > ma_slow).astype(int) # 当日收盘后确定的持仓
pos = sig.shift(1).fillna(0) # 次日才执行 → 无未来函数
ret = df["close"].pct_change().fillna(0)
turnover = pos.diff().abs().fillna(0) # 换手 → 双边 0.1% 成本
strat = pos * ret - turnover * fee
return strat
# ---------- 3. 网格搜索(训练期) ----------
is_data = df[:"2022-12-31"] # 样本内:2015~2022
results = []
for fast in range(3, 16, 2):
for slow in range(20, 121, 10):
if fast >= slow:
continue
s = backtest_ma(is_data, fast, slow)
n = len(s)
ann = (1 + s.sum()) ** (252 / n) - 1 # 简化年化(对数近似教学用)
results.append((fast, slow, ann))
grid = pd.DataFrame(results, columns=["fast", "slow", "ann"])
pivot = grid.pivot(index="fast", columns="slow", values="ann")
best = grid.loc[grid["ann"].idxmax()]
print("样本内最优参数:", int(best["fast"]), "/", int(best["slow"]),
f"年化(示意): {best['ann']:.1%}")
# ---------- 4. 样本外验证 ----------
oos_data = df["2023-01-01":] # 样本外:2023~2024
oos = backtest_ma(oos_data, int(best["fast"]), int(best["slow"]))
oos_n = len(oos)
oos_ann = (1 + oos.sum()) ** (252 / oos_n) - 1
print(f"样本外年化(示意): {oos_ann:.1%}")
if oos_ann < 0.02:
print(">>> 警告:样本外明显弱于样本内,疑似过拟合")
# ---------- 5. Walk-Forward 滚动优化 ----------
def walk_forward(df, train_years=4, test_years=1, fee=0.001):
years = sorted(set(df.index.year))
oos_all = []
for i in range(0, len(years) - train_years - test_years + 1):
train_y = years[i:i + train_years]
test_y = years[train_y[-1] + 1:train_y[-1] + test_years]
tr = df[df.index.year.isin(train_y)]
te = df[df.index.year.isin(test_y)]
if len(te) == 0:
continue
best_f, best_s, best_v = 5, 60, -np.inf
for f in range(3, 16, 2): # 每轮重新选参,只用训练段
for s in range(20, 121, 10):
if f >= s:
continue
v = backtest_ma(tr, f, s, fee).sum()
if v > best_v:
best_f, best_s, best_v = f, s, v
seg = backtest_ma(te, best_f, best_s, fee) # 用选出的参数跑测试段
oos_all.append(seg)
print(f"训练{train_y[0]}-{train_y[-1]} → 测试{test_y[0]}: "
f"参数{best_f}/{best_s}")
return pd.concat(oos_all)
wf = walk_forward(df)
n = len(wf)
wf_ann = (1 + wf.sum()) ** (252 / n) - 1
print(f"Walk-Forward 拼接年化(示意): {wf_ann:.1%}")
# ---------- 6. 参数热力图 ----------
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False
fig, ax = plt.subplots(figsize=(9, 5))
im = ax.imshow(pivot.values, aspect="auto", cmap="RdYlGn")
ax.set_xticks(range(len(pivot.columns)))
ax.set_xticklabels(pivot.columns)
ax.set_yticks(range(len(pivot.index)))
ax.set_yticklabels(pivot.index)
ax.set_xlabel("慢线周期")
ax.set_ylabel("快线周期")
ax.set_title("双均线参数网格年化收益热力图(样本内,示意)")
fig.colorbar(im, label="年化收益")
plt.tight_layout()
plt.savefig("wf_heatmap.png", dpi=120)
plt.show()
代码逐段解释
- 数据准备:与系列其他篇一致,akshare 拉贵州茅台前复权日线,失败时用固定种子的随机游走模拟数据,保证代码任何环境都能跑。模拟数据的年化数字只反映随机性,不代表真实行情。
- 回测函数:把第 18 篇的向量化回测封装成函数。关键行是
sig.shift(1)——信号次日成立才执行,turnover只在持仓变化时收一次双边 0.1% 费用。这个函数将被网格和 Walk-Forward 反复调用,保证口径一致。 - 网格搜索:快线 3~15 步长 2、慢线 20~120 步长 10,跳过快线不小于慢线的无效组合。
idxmax找出样本内最优。年化用简化公式(总收益的幂换算),教学够用但偏保守估计复利。 - 样本外验证:用最优参数直接跑 2023 年之后的数据。打印的对照(样本内 vs 样本外)就是判断过拟合最直观的证据;代码里用一条简单阈值做了自动警告。
- Walk-Forward:外层按年份切窗口(4 年训练 + 1 年测试),内层在训练段重新网格选参,再立刻在紧随的测试段验证,然后窗口前移。
pd.concat把所有测试段拼成完整 OOS 曲线。注意每轮选参时完全看不到测试段。 - 热力图:
imshow把快慢线参数矩阵画成热力图,暖色亮区若是连片的,就是参数平原;若只有孤点亮,就要警惕。
示例输出(示意,数值因数据与随机种子而异):
数据条数: 2437, 区间: 2015-01-05 ~ 2024-12-31
样本内最优参数: 5 / 110 年化(示意): 19.8%
样本外年化(示意): 3.1%
>>> 警告:样本外明显弱于样本内,疑似过拟合
训练2015-2018 → 测试2019: 参数9/100
训练2016-2019 → 测试2020: 参数5/110
训练2017-2020 → 测试2021: 参数13/90
训练2018-2021 → 测试2022: 参数5/110
训练2019-2022 → 测试2023: 参数7/60
Walk-Forward 拼接年化(示意): 5.4%
注意看示意输出里的两个现象:样本内最优的 19.8% 到样本外只剩 3.1%——这就是过拟合的典型画像;而 Walk-Forward 因为每轮参数不同、更贴近真实使用方式,成绩反而比「一把最优参数跑到底」更可信。
常见坑与小练习
常见坑
- 把训练段成绩当策略成绩:最常见的自我欺骗。记住:只有测试段(或样本外)成绩算数,训练段只是选参数的依据。
- Walk-Forward 窗口切分用了未来信息:比如按「整个区间表现」挑年份切窗口。切分规则必须事先定死(如固定按自然年滚动),不能为了结果好看而调整。
- 步长太大漏掉真优参数、步长太小算到天亮:先用粗网格(步长大)定位区域,再在好区域加密细搜,兼顾效率与精度。
- 只用年化收益做选参标准:年化最高的参数往往波动巨大。改用夏普比率或「收益/回撤」这类风险调整指标选参,结果通常更稳健。
- 参数平原只在一张热力图上找:换个时间段再画一张,两张图都平缓的区域才更可信。
小练习
- 把网格的选参标准从年化收益改成夏普比率(可用第 19 篇的函数),重跑 Walk-Forward,对比拼接年化的变化。
思路提示:在
backtest_ma返回的日收益序列上套mean/std*sqrt(252)即可算夏普,减去无风险利率 2% 更规范。 - 把训练期改成 3 年、测试期改成半年(用月份切分),重跑 Walk-Forward,观察选出的参数是否更频繁地变化。
思路提示:按
df.index.to_period("M")分组切窗,训练 36 个月、测试 6 个月,步进 6 个月。 - 在热力图上用红框标出年化排名前 10 的参数组,看看它们是聚在一起(平原)还是散落各处(尖峰)。
思路提示:对
pivot.stack()排序取前 10,再用ax.add_patch(Rectangle(...))画框。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。