Skip to content

贝叶斯优化快 60 倍,却让我错过了 14% 的收益

作 者:老余捞鱼

原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。

本文约 3800 字 | 预计阅读 8-12 分钟,建议先收藏。

· · ·

几周前读到一篇文章,一群人用数学模型在预测市场(Polymarket)里,一年赚了 4000 万美元。整数规划、Bregman 投影、Frank-Wolfe 优化算法,一整套框架。这类文章看完通常我就翻过去了,但这次没有。

预测市场套利,一套完整的数学框架

让我停下来的不是预测市场本身,我不玩那些。是文章里一个思路:他们面对的市场可能有 2 的 63 次方个结果,不可能全算一遍。所以赢家的做法是逐个构建”已验证有效点”的小集合,用已有认知来决定下一个该看哪里。不遍历,而是让搜索变聪明。

这和我手上一个问题一模一样。

问题在哪

我维护一个策略实验室。回测、滚动窗口调参,样本外稳了就上线。参数优化我一直用的是网格搜索,穷举范围内所有组合,谁夏普最高就选谁。

能用,但一直有点慢。更关键的是,我从没认真想过它找到的到底是不是全局最优,还是仅仅在我检查过的组合里排第一。

挑了手头最简单的策略做实验:AAPL 双均线金叉死叉,快慢线两个参数。打算用网格搜索和预测市场那篇论文的 Frank-Wolfe 思路对打一轮。这个思路落到我的场景就是贝叶斯优化,,先搭一个”好区域大概在哪”的代理模型,只在值得看的地方花评估预算,不撒胡椒面。

基准线:丐版的网格搜索

在搞花活之前,我需要一个真实的对比基准。网格搜索快线周期从 5 天到 59 天,慢线周期从 20 天到 199 天,9,080 组参数全部跑完:

import pandas as pd
import numpy as np
import yfinance as yf
import vectorbt as vbt
import itertools
import time

symbol = "AAPL"

df = yf.download(symbol, start="2015-01-01", end="2024-01-01", multi_level_index=False)

# 参数搜索范围
fast_range = range(5, 60)   # 55 个值
slow_range = range(20, 200)  # 180 个值

results = []
start_time = time.time()

for fast, slow in itertools.product(fast_range, slow_range):
    if fast >= slow:
        continue  # 快线必须低于慢线

    sma_fast = df['Close'].rolling(fast).mean()
    sma_slow = df['Close'].rolling(slow).mean()

    # 金叉入场,死叉出场
    entries = (sma_fast > sma_slow) & (sma_fast.shift(1) <= sma_slow.shift(1))
    exits = (sma_fast < sma_slow) & (sma_fast.shift(1) >= sma_slow.shift(1))

    pf = vbt.Portfolio.from_signals(
        close=df['Close'], entries=entries, exits=exits,
        init_cash=100_000, fees=0.001, slippage=0.002, freq='1d'
    )

    results.append({'fast': fast, 'slow': slow, 'sharpe': pf.sharpe_ratio()})

elapsed = time.time() - start_time
print(f"共评估 {len(results)} 组参数,耗时 {elapsed:.1f} 秒")


best = max(results, key=lambda r: r['sharpe'] if not pd.isna(r['sharpe']) else -999)
print(f"网格搜索最优: {best}")

跑出来的:

  • 共评估 9,080 组参数组合
  • 耗时 197.3 秒(约 3.3 分钟)
  • 最优参数:快线=18 天,慢线=24 天,夏普比率 1.2913

均线策略参数空间存在两个截然不同的峰值,短周期均值回归 vs 长周期趋势跟踪

两个参数的全网格 3.3 分钟跑完,还行。但滚动窗口优化要拆 10 个窗口,这就 33 分钟了。再加一个止损参数,哪怕只取 20 个候选值,立刻暴涨到约 11 小时。这才是我在真实策略里撞到的墙,也是那篇预测市场论文瞄准的靶心:维度的诅咒。他们管的是二元市场结果,我管的是连续参数空间,本质上是一回事。

用那个思路实测:贝叶斯优化

那篇文章里的核心思路是:不用列举指数级大的空间,而是构建一个”已评估点”的主动集合,用已经检查过的点的信息来决定下一个加哪个点。重复直到收敛,从不碰空间里的大部分区域。

对于夏普比率这类黑盒,最直接的翻译就是贝叶斯优化:用一个代理模型学习已知点上的目标函数形状,在”这里看起来不错”和”这块还没探过”之间做权衡,决定下一个采样点。我跑的代码:

import optuna
import pandas as pd
import yfinance as yf
import vectorbt as vbt

symbol = "AAPL"
df = yf.download(symbol, start="2015-01-01", end="2024-01-01", multi_level_index=False)

def objective(trial):
    # Optuna 自动在范围内采样参数
    fast = trial.suggest_int('fast', 5, 60)
    slow = trial.suggest_int('slow', 20, 200)
    if fast >= slow:
        raise optuna.TrialPruned()  # 无效参数直接跳过

    sma_fast = df['Close'].rolling(fast).mean()
    sma_slow = df['Close'].rolling(slow).mean()

    entries = (sma_fast > sma_slow) & (sma_fast.shift(1) <= sma_slow.shift(1))
    exits = (sma_fast < sma_slow) & (sma_fast.shift(1) >= sma_slow.shift(1))

    pf = vbt.Portfolio.from_signals(
        close=df['Close'], entries=entries, exits=exits,
        init_cash=100_000, fees=0.001, slippage=0.002, freq='1d'
    )
    sharpe = pf.sharpe_ratio()
    return sharpe if not pd.isna(sharpe) else -999

study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=150)  # 只跑 150 个点,而非 9,080 个

print(f"最优参数: {study.best_params}")
print(f"最优夏普比率: {study.best_value}")

结果:

  • 只评估了 150 个点(网格搜索的 1.7%)
  • 耗时 11 秒(网格搜索的 5.6%,快了约 18 倍)
  • 最优参数:快线=28 天,慢线=189 天,夏普比率 1.1305

第一反应:立竿见影。评估量降到 1/60,速度提了 18 倍。看 Optuna 的试跑记录更让人放心,,第 0 轮就在慢线 198 天附近跑出夏普 1.0+,到第 75 轮前后采样器明显锁在快线 25-28、慢线 185-190 的区域,一直在里面精调。收敛曲线漂亮,速度飞快。

然后我打开之前存的网格搜索结果看了一眼。

我没预料到的部分

网格搜索最优:快线 18 天、慢线 24 天,夏普 1.2913。贝叶斯最优:快线 28 天、慢线 189 天,夏普 1.1305。

不只是差了一点。它落在参数空间里一个完全不同的区域,错过了真正的最优区约 14%。而且那个更好的角落,贝叶斯优化基本没碰过。

贝叶斯优化锁死在错误的山峰上,网格搜索才能找到真正的全局最优

这迫使我重新审视最初的假设。我默认夏普比率作为这两个参数的函数,是一个平滑的单峰曲面,,找到有潜力的邻域,精调,完事。但数据说的是另一回事:这个策略至少有两个截然不同的最优区。一个在短周期(快线约 18、慢线约 24,间距仅 6 天,接近均值回归逻辑),另一个在长周期趋势跟踪区(快线约 25-28、慢线约 185-190)。两个不同的交易逻辑,独立成立,互不连通。

贝叶斯优化很早就摸到了第二个峰,然后它的机制决定了它会一直在这个峰里深挖。大多数时候这是对的,除了目标函数不只有一个峰、而你恰好锁在了错误的那一个的时候。网格搜索笨,但它从来不停下探索别处的脚步。这是它不会犯这个错的根本原因。

增加搜索预算能解决这个问题吗?

在我得出”贝叶斯优化不靠谱,用回网格”的结论前,再拿同一套配置跑 5 个随机种子试下:

  • 默认 TPE 采样器:5 个种子里,最佳夏普比率最高 1.2816,平均 1.1614。只有 1 个种子找到了短周期区域。
  • 强制更多前期探索(把前期强制探索次数从 10 次提高到 60 次):最佳夏普比率最高 1.1653,平均 1.1414。仍然只有 1/5 的种子找到了短周期区域,而且这个配置实际上跑出了比默认设置更低的最优值。
  • 纯随机采样:最佳夏普比率最高 1.1916,平均 1.1439。找到短周期区域 2/5次。

以上这些全没按预期走。

同一套算法、同样的试跑预算,换一颗随机种子的瞬间,最佳夏普就从 1.10 摆到 1.28,幅度接近 15%。你只跑一次的话,根本没法判断自己拿到的是这个分布的上沿还是下沿。

加大探索预算也没解决问题。把强制探索次数从 10 提到 60,仍只有 1/5 能找到短周期区,而且这批实验样本的最优值反而更低。我以为”多探索 = 更安全”,数据不买账。

最反直觉的一个:纯随机搜索找到短周期区域的概率(2/5)反而高于两种 TPE 配置(各 1/5)。但机制上说得通,,TPE 的天职就是找到一个好区然后快速深挖。当还存在另一个不相关的好区时,这个”快速深挖”恰恰挡了路。

同一个算法、同一个预算,换个随机种子,最佳夏普比率就从 1.10 摇摆到 1.28

有一个种子,2024,不管什么配置都能摸到短周期区,结果落在 1.17 到 1.28 之间。网格搜索确认的最优是 1.29,说明这个区域真实可及。但能不能在单次运行里找到它,看起来更多取决于随机数生成器,而不是你有意做了什么选择。

观点总结

这套“构建主动集合而非遍历全部”的思路本身并无问题。速度数据也确实亮眼:评估量仅为网格搜索的六十分之一,墙上时间缩短至原来的十八分之一。问题在于,我一开始带着一个错误前提进场,误以为收敛快就等同于答案正确。而这次实验,正是用最直接的数据告诉我:并非如此。贝叶斯优化收敛得干脆利落,全程没有任何警告,也没有任何异常标记,最终却给出了一个错误的结论。若没有网格搜索在旁边作为对照,我很可能就这样将其部署上线了。

与其用网格搜索全面取代贝叶斯优化,或指望加大探索预算来“压平”多峰问题,这两项操作在现有数据中都站不住脚。

我的做法是:把单次贝叶斯运行仅当作一个起始假设,而非最终结论。我会跑多个随机种子,在采信任何结果之前,先检视它们之间是否一致。如果5个种子落在了5个截然不同的区域,那么这种不一致本身便是最值得关注的信号,它应该被解读为关键信息,而不是需要被平均掉的噪声。

贝叶斯优化的正确打开方式:多 seed 验证,必要时用网格搜索兜底

那帮从预测市场赚走 4000 万的交易员,不是只跑一次 Frank-Wolfe 就拿结果下单。文章里写了执行验证层、阈值检查,还有他们在投钱之前追踪的收敛差距。我直接省略了”验证”这一步,就拿了结果。他们没跳过的那个步骤,才是真关键。


回测工具:vectorbt + Optuna,标的 AAPL,2015-01 至 2024-01,手续费 0.1%,滑点 0.2%

#参数优化 #贝叶斯优化 #网格搜索 #Optuna #量化策略 #机器学习 #策略开发 #滚动窗口优化 #预测市场 #AAPL

Published inAI&Invest专栏

Be First to Comment

    发表回复