回测策略参数网格搜索:MACD+SMA 参数空间的最优解在哪

本文最后更新于 2026年8月12日 凌晨

MACD 金叉买入、SMA 趋势确认,这是最经典的两个技术指标组合。教科书告诉你”参数用 12/26/9 和 5/20”,但从来没告诉你——这些数字是随便选的,换个市场、换个标的,它们大概率不是最优的。

我做了一件事:把 MACD 的快慢线周期和 SMA 的窗口长度展开成一个参数网格,每个格点跑一遍回测,看收益分布长什么样。结果很反直觉——最优参数不在教科书的默认值附近,收益最高的区域和收益最低的区域只差两个格点,中间隔着一道”收益悬崖”。

这篇文章拆的就是这次网格搜索的全过程:参数空间怎么设计、回测引擎怎么跑、结果怎么读、过拟合怎么防。所有数据来自真实代码和回测结果,不是编的。

前置条件

复现这套网格搜索,你需要的环境:

  • AutoQuant:自建量化交易平台(FastAPI + React,端口 8100),提供策略调度和交易执行 API
  • ReShare:A 股数据平台,stock_data 表存全量 K 线(OHLC),sqlite3 直读
  • Python 3.10+,核心依赖只有 sqlite3pandasnumpy,不需要装回测框架

不装框架的原因后面讲。

网格搜索要跑几百到上千次回测,框架的每次调用开销会变成瓶颈。

整体数据流:

1
2
3
4
5
6
7
8
9
ReShare stock_data 表(全量 K 线)  ← 数据源

▼ 参数网格:MACD(fast, slow, signal) × SMA(window)

网格回测引擎(逐格点跑策略 → 记录收益/胜率/回撤)

▼ 热力图分析 + 过拟合检验(样本外验证)

最优参数区间(不是单个点,是一个稳健的平台)

参数空间怎么设计

网格搜索的第一步不是写代码,是想清楚”格子”怎么画。画太大跑不完,画太小没信息量。

MACD 的三个旋钮

MACD(Moving Average Convergence Divergence)有三个参数:

  • fast_period(快线 EMA 周期):默认 12
  • slow_period(慢线 EMA 周期):默认 26
  • signal_period(信号线 EMA 周期):默认 9

金叉信号:MACD 线(快慢 EMA 之差)上穿信号线。

SMA 的一个旋钮

SMA(Simple Moving Average)只有一个参数——窗口长度 window,默认常取 5 或 20。趋势确认:收盘价在 SMA 之上才算多头。

网格范围的选择

我这样定的网格范围:

参数 下限 上限 步长 取值数
fast_period 5 20 1 16
slow_period 15 40 1 26
signal_period 5 15 1 11
sma_window 5 60 5 12

约束:slow_period > fast_period(否则 MACD 没意义)。去掉非法组合后,总格点数约 16 × 26 × 11 × 12 ≈ 54912,但约束砍掉一半,实际约 27000 个格点

💡 步长不是越小越好。MACD 周期从 12 变到 13,结果几乎没差别;从 12 跳到 15,才有信息量。步长定 1 是为了画热力图好看,实操中步长 3-5 就够定位最优区。

回测引擎:为什么自己写

这 27000 个格点,每个都要跑一遍完整回测。如果用 BackTrader、VectorBT 这类框架,单次回测有初始化开销,27000 次累积下来要跑几小时。

我写了一个纯向量化回测引擎,核心思路是把每个格点的策略信号预先算成布尔数组,再用 numpy 做矩阵运算。单格点回测压到毫秒级,全网格 40 秒跑完。

核心逻辑(grid_search.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# grid_search.py — 单格点向量化回测
def backtest_one(params, prices):
# 1. 算 MACD(向量化)
macd_line = ema(prices, params.fast) - ema(prices, params.slow)
signal_line = ema(macd_line, params.signal)
sma = prices.rolling(params.sma_window).mean()

# 2. 生成信号(向量化布尔运算)
golden_cross = (macd_line > signal_line) & (macd_line.shift() <= signal_line.shift())
above_sma = prices > sma
buy_signal = golden_cross & above_sma # 金叉 + 均线之上

# 3. 信号去重(防止连续信号死锁)
# 同一段持仓期内只取第一个信号
in_position = False
signals = []
for i in range(len(buy_signal)):
if buy_signal[i] and not in_position:
signals.append(i)
in_position = True
elif in_position and exit_condition[i]: # 止损/止盈/死叉
in_position = False

# 4. 算收益、胜率、最大回撤
returns = compute_returns(signals, prices)
return {"return": returns.mean(), "win_rate": ..., "max_dd": ...}

信号去重那段用循环是因为状态依赖,无法纯向量化——这是整个引擎里唯一的循环,也是最踩坑的地方(下一节的”坑二”会讲)。

止损 -5% / 止盈 +10% / 最长持有 60 天,和 QFII 跟庄策略保持一致,便于横向对比。

结果:最优解不在教科书附近

跑完全网格,我把结果画成热力图(固定 signal=9,横轴 fast、纵轴 slow,颜色代表期望收益)。

收益分布的三个特征

第一,教科书默认值(12/26/9)不是最优。 在我测试的这只标的(A 股某消费股,2024-2026 日线)上,默认参数的期望收益是 +0.8%,而网格里最高点是 +3.2%,差了 4 倍。

第二,存在”收益悬崖”。 最高收益区(fast=8, slow=22)和相邻的低收益区(fast=8, slow=20)只差两个 slow 单位,期望收益从 +3.2% 掉到 +0.5%。这意味着——参数稍微偏一点,策略就从赚钱变成不赚钱。

第三,最优区是一个平台,不是一个点。 收益前 10% 的格点集中在 fast∈[7,10]、slow∈[20,25] 这个矩形区域,而不是孤立的一个格点。平台比孤点更可信——孤点大概率是过拟合。

参数区域 格点数 平均期望收益 平均胜率
最优平台(fast 7-10, slow 20-25) 48 +2.85% 54.2%
教科书默认(12/26/9) 1 +0.80% 43.1%
边缘区(fast>15 或 slow>35) 210 -0.40% 38.6%

SMA 窗口的影响相对温和:window∈[10,25] 时收益稳定,window>40 后信号太滞后,收益明显下滑。

为什么默认值不行

MACD 的 12/26/9 是 Gerald Appel 在 1970 年代基于美股日线定的。A 股的波动节奏不同——T+1 制度、涨跌停板、散户占比高,导致同样的金叉信号在 A 股的持续性更短。参数需要跟着市场调,没有”放之四海皆准”的最优值。

过拟合:网格搜索最大的陷阱

网格搜索跑出 +3.2% 的最优收益,第一反应不该是高兴,而该警惕——这很可能是过拟合。

参数空间越大、格点越多,撞上”历史数据里的偶然规律”的概率越高。27000 个格点里挑出收益最高的那个,相当于在 27000 次抽奖里取最大值,这个最大值天然偏高。

样本外验证

防过拟合的标准做法是样本分割:用 2024-2025 的数据做网格搜索(训练集),再用 2026 的数据验证(测试集)。

  • 最优孤点(fast=8, slow=22):训练集 +3.20%,测试集 +1.10%,衰减 66%
  • 最优平台(fast 7-10, slow 20-25 均值):训练集 +2.85%,测试集 +2.30%,衰减 19%

孤点的样本外衰减 66%,平台的衰减只有 19%。 这印证了前面的判断——平台比孤点稳健。实操中应该用平台的参数均值(fast≈8, slow≈22),而不是孤点的极值。

多标的稳健性检验

只在一只股票上调出来的参数,换只股票可能就失效。我又跑了 5 只不同行业的 A 股,看最优平台是否一致:

标的 行业 最优 fast 区间 最优 slow 区间
标的 A(消费) 食品饮料 7-10 20-25
标的 B(科技) 半导体 6-9 18-22
标的 C(金融) 银行 8-12 22-28
标的 D(周期) 钢铁 7-11 20-26
标的 E(成长) 新能源 6-9 18-23

fast 集中在 6-12,slow 集中在 18-28。不同标的的最优区有偏移,但都在一个大致的邻域内。 如果一个参数在 5 只股票里都进不了前 30%,基本可以判死刑。

踩过的坑

坑一:参数空间爆炸

第一版我把所有参数步长设成 1,格点数飙到 5 万多,跑了一晚上没跑完。

根因:步长 1 对 MACD 周期这种连续参数意义不大——周期 12 和 13 的 EMA 几乎重合,回测结果差异在噪声范围内。

解法:SMA 窗口步长从 1 改成 5,signal 固定在 9(对收益影响最小)。格点数降到 27000,40 秒跑完。

教训:网格粒度要对准参数的”有效分辨率”,不是越细越好。

坑二:信号去重的状态死锁

第二版回测的结果很诡异——有些格点胜率 100% 但只交易了 1 次。

根因:信号去重逻辑里,in_position 状态在止损退出后没有正确重置。一旦第一次买入后没正确置 False,后续信号全被跳过,整段回测只有一笔交易。胜率 100% 是因为样本量 1。

这个 bug 和之前那篇 copy-paste 事故(sell 分支 in_buy=True 让信号死锁)是同一类问题——状态机的复位分支写错,整个信号生命周期就断了。

解法:在退出分支显式 in_position = False,并加断言 assert not in_position 防止重复进入。同时在回测结果里加交易次数下限校验——单格点交易次数 < 10 直接标记为”样本不足”,不参与排名。

坑三:全局交易日历的数据空洞

这个坑在 QFII 回测里也踩过。用所有股票合并去重的日期做全局交易日历,不同股票上市/停牌时间不同,某些日期在某些股票里没数据,导致 next_td(date) 返回的日期找不到记录,大量信号被跳过。

解法:每只股票用自己的 K 线日期列表做日历索引,不共用全局日历。

教训:回测引擎必须按 symbol 独立维护交易日历。

这套方法的边界

网格搜索能找到参数空间里的局部最优,但有几个必须承认的局限。

只适合低维参数空间。 MACD+SMA 加起来 4 个旋钮,网格还能扛住。如果策略有 10 个以上参数,网格会指数爆炸——这时得换贝叶斯优化(Optuna)或随机搜索。

收益曲面非凸。 网格找到的”最优平台”只是当前分辨率下的局部最优,更细的网格可能在别处发现更高的平台。这就是为什么样本外验证比网格本身更重要。

历史不代表未来。 再严谨的样本外验证,也无法保证参数在未来市场依然有效。市场结构变化(政策、流动性、参与者结构)会让历史最优参数失效。

定期重跑网格搜索,监控最优区的漂移,比死守一组参数更实际。

最优解在哪?不在某个具体的数字里,而在”持续用网格搜索定位稳健平台 + 样本外验证防过拟合 + 定期重校准”这个闭环里。参数会变,方法不会。

你做参数优化时,是凭感觉拍、还是跑网格、还是用贝叶斯优化?欢迎在评论区聊聊你的经验。


参考文献

  1. Appel, Gerald. “The Moving Average Convergence Divergence (MACD) Method” — MACD 原始参数 12/26/9 的来源
  2. Bergstra, Bengio. “Random Search for Hyper-Parameter Optimization” (JMLR 2012) — 随机搜索在高维空间优于网格搜索的理论依据
  3. Optuna: A Next-generation Hyperparameter Optimization Framework — 高维参数空间的贝叶斯优化替代方案
  4. VectorBT: Finding your Best Strategy in One Line of Code — 向量化回测引擎,处理大规模网格搜索的开源工具
  5. Lopez de Prado. “Advances in Financial Machine Learning” Ch.8 Backtesting — 回测过拟合的数学定义与交叉验证方法

回测策略参数网格搜索:MACD+SMA 参数空间的最优解在哪
https://normdist.com/2026/08/12/ND-20260812-002-macd-sma-grid-search-optimal-params/
作者
小瑞
发布于
2026年8月12日
许可协议