← 返回文章列表
July 3, 2026
5 分钟阅读

双轴参数空间:为什么你的大部分参数扫描几乎是免费的

双轴参数空间:为什么你的大部分参数扫描几乎是免费的
#量化交易
#回测
#参数搜索
#优化
#缓存
#维度灾难
Part 4 of 10 · Collection
High-Performance Backtest Engines

选自"没有幻觉的回测"系列。

维度灾难通常被当作一句警告来讲述:你每增加一个参数,都会成倍放大搜索空间,所以一个 18 维的策略是无望扫描的。这种叙述方式悄悄地假定了每一个维度的评估成本都相同。事实并非如此。在我们的双/三时间框架引擎中,三分之二的参数扫描起来几乎是免费的,而三分之一的参数几乎承担了全部的计算开销。一旦你看清了这种分裂,"搜索空间太大"就不再是恰当的抱怨。恰当的问题是:你到底在为哪些维度付费?

那个本不需要重新计算的维度

我们的参数搜索基准运行的是一个多时间框架动量策略:在两个或三个时间框架上使用 Hull 移动平均线及其三重平滑变体(HMA3),并配以一个方向分离门控,由它决定一次交叉何时"干净"到足以据此行动。三时间框架变体的完整搜索空间是十八维——每个时间框架的周期和 HMA 长度,再加上每个时间框架上用于入场和出场的一组分离阈值。

扫描这个空间的朴素做法是单层循环:选一个参数向量,从头构建每个指标,运行模拟,打分,重复。我们就是从这里起步的。它很慢,而且它慢的原因一旦被我们说出口就显得很尴尬:在绝大多数相邻试验之间,我们都在重新计算那些根本没有变化的指标。

把入场分离阈值从 0.03 微调到 0.035 再重新运行。一整年 1 分钟 K 线上的 Hull MA 与上一次试验逐位相同——阈值根本不出现在它的定义中的任何地方。然而朴素循环还是照样把它重建一遍,跨越五十多万根 K 线,深入三个时间框架,每一次都如此。我们几乎把全部的计算预算都花在了重新推导那些对我们实际正在改变的参数保持不变的量上。

这个观察就是整篇文章的核心。一旦你注意到有些参数会改变指标,而有些参数只改变应用在固定指标上的决策规则,参数空间就不再是一团扁平的维度云,而变成了两层嵌套、价签迥异的结构。

是两条轴,而非一条

同一参数空间的两条轴对应不同的价签:一张稀疏而昂贵的指标网格,其每个格子都解锁出一整片密集、几乎免费的阈值配置薄片

按照重新评估某个参数会迫使你重新计算什么来划分参数:

  • 昂贵轴——指标参数。 时间框架周期和 HMA 长度。改变其中任何一个,你都必须在整个价格序列上重建指标:对更高的时间框架重采样,运行 Hull 加权,重新计算交叉序列以及每次交叉处的分离度。这是一次针对每一根历史 K 线的 O(n) 扫描,而且对每一个不同的组合你都要完整地付出这份代价。在三时间框架策略中,这条轴是六个参数:高、中、低三个时间框架各自的 (period, hma_length)

  • 廉价轴——决策阈值。 方向分离门控,它基于已经计算好的交叉序列和分离序列,来决定入场还是出场。改变一个阈值,任何指标都不会动。你只需对预计算信号重新运行一次 O(n) 遍历,检查门控并记账成交。在三时间框架策略中,这条轴是十二个参数(每个时间框架上有四个分离阈值——入场买、入场卖、出场买、出场卖,共三个时间框架)。双时间框架变体则有八个(两个时间框架各四个阈值)。

所以这个空间真正的形状是:三时间框架为 6 个昂贵 + 12 个廉价 = 18,双时间框架为 4 个昂贵 + 8 个廉价 = 12。三分之二的维度都住在廉价轴上。而且这两条轴不只是数量不同——它们的单位成本相差超过三个数量级,而这个数字正是本文余下部分要讲的东西。

这个结构是严格嵌套的。昂贵轴上的每一个点——每个时间框架一组具体的 (period, hma_length)——定义了一组固定的信号数组。在那个固定的地基之上,坐落着廉价轴的一整张薄片:成千上万个阈值向量,每一个都是对同一批数组的一次快速遍历。你只为地基付一次费,然后把它摊销到整张薄片上。

为什么指标对阈值不变

这套缓存之所以奏效,靠的是一个数学事实,而非侥幸的实现细节,而且它值得被精确地陈述,因为它是那个承重的假设。指标数组仅仅(period, hma_length) 的函数。分离阈值根本不出现在它们的定义中。

具体来说,对每个时间框架,引擎会在基准索引上预计算四个对齐的数组:Hull MA hma、其三重平滑值 hma3、交叉序列 cross(+1 买 / −1 卖 / 0),以及每次交叉处的 separation 百分比。这些数组中的每一个都是由价格和两个指标参数推导而来。阈值——那个你拿来与 separation 比较的数字——是稍后在决策时才应用的。它从不触碰这些数组。

这并不是一次天然安全的重排。它之所以安全,只是因为我们对信息何时变得可用这件事非常小心,而这正是本系列别处那篇前视偏差研究所关注的问题。基准 K 线 i 上的更高时间框架指标,是由已收盘的更高 TF K 线,加上一根正在形成的 K 线计算而来的,这根 K 线的实时收盘价等于当前基准收盘价 close[i]——一个在第 i 根 K 线时已知的值。没有任何来自未来的东西泄露进来。所以第 i 根 K 线处的预计算信号,恰恰就是一个实盘机器人在第 i 根 K 线时会看到的东西,而且无论你稍后用哪个阈值去检验它,它都保持有效。缓存一个泄露的信号只会把泄露一并缓存下来;缓存一个因果的信号,缓存的则是你真正可以拿去交易的东西。

这种不变性给了我们一个因式分解。把对一个完整参数向量 θ = (indicator_params, threshold_params) 的评估写成:

signals   = build_indicators(indicator_params)      # EXPENSIVE, depends only on indicator_params
score     = simulate(signals, threshold_params)     # CHEAP, reuses signals across all thresholds

build_indicators 不读取 threshold_params。就是这一个事实,授权了缓存:固定住 indicator_params,自由地变动 threshold_params,而 signals 是一个你只需计算一次的常量。

架构:计算一次,扫描多次

一个以指标参数为键的信号缓存,喂给一次快速批量扫描:一次指标构建扇出成一整排阈值评估,它们共享同一批缓存数组

这个引擎(scripts/engine_multitf.py,我们回测器中的提交 bfc8aaa)用两个部件实现了这一因式分解,而这两个部件的名字恰好说明了它们的作用。

SignalCache——被记忆化的昂贵轴。 它是一个以 (period_bars, hma_length) 为键的字典。向它索取某个时间框架的信号,如果那个指标组合此前已经构建过,它就返回缓存的 TFSignals,否则就构建一次并存起来。因为键只是指标参数,每一个共享同一指标组合的阈值配置——而在一次密集的阈值扫描里,那是成千上万个——都会命中同一个缓存条目。尤其是更高的时间框架,对此回报丰厚:比如说,四个候选周期乘以少数几个 HMA 长度组成的粗网格,只是一个很小数量的不同的昂贵构建,而每一个都被堆叠在它之上的整张阈值薄片所复用。

sweep_separations——被批处理的廉价轴。 它接收缓存的信号数组和一个阈值向量矩阵(sps,形状为 [m, 12]),并把它们全部送进一个已编译的内核里运行。每一行都是一次 O(n) 遍历:走遍所有 K 线,应用门控,在 open[i+1] 处记账无泄露的成交,累计盈亏和持仓时间。这个循环内部不重建任何指标——它直接从缓存中读取 crossseparation。内层模拟经过 JIT 编译(Numba),所以一旦预热完成,每个配置的成本就由对 K 线的那一次线性扫描主导,而不是由 Python 开销主导。

这两个部件组合成一个自然的嵌套搜索:外层循环走遍昂贵轴(每次迭代构建并缓存一个指标组合),而内层循环在那些缓存数组上扇出一个跨越廉价轴的宽批次。在代码中,其形状恰好如此——一个很小的昂贵循环包裹着一个很宽的廉价批次:

cache = SignalCache(base_close, base_ts)           # keyed by (period, hma_length)

for htf_p, htf_h, mtf_p, mtf_h, ltf_p, ltf_h in indicator_grid:   # EXPENSIVE axis (coarse)
    htf = cache.get(htf_p, htf_h)                  # built once, then a cache hit forever
    mtf = cache.get(mtf_p, mtf_h)
    ltf = cache.get(ltf_p, ltf_h)

    sps = sample_thresholds(m=4000)                # CHEAP axis: [m, 12] threshold vectors
    pnl, n_trades, bars_in_pos = sweep_separations(  # one compiled batch, no indicator work
        base_close, base_open, htf, ltf, sps, mtf=mtf)

你只在指标网格所允许的次数内触碰那个昂贵的构建器,而让廉价扫描去承担体量——成千上万行 sps,对着永不移动的数组。这就是全部的优化——没有近似,没有损失保真度,只是拒绝重新计算那些没有变化的东西。

"免费"实际上要花多少钱:数字

每个配置成本的柱状对比:一根高柱代表"每个配置都重新计算",旁边是代表"缓存阈值遍历"的一条细缝,并标注了约 1,600 倍的差距

我们在演示工作负载上测量了这两条轴:一整年的 1 分钟 ETHUSDT K 线(约 527k 根),三时间框架,指标已预热且 JIT 编译已排除在计时之外。

在廉价轴上,sweep_separations 稳定维持在每秒约 5,600 个阈值配置。这是对每个配置的一次完整模拟——门控、成交、盈亏、敞口——跨越五十多万根 K 线,大约每个配置 180 微秒。它之所以能这么快,是因为它做零指标工作:每个配置都读取同一批缓存的 crossseparation 数组。

现在给替代方案定个价。构建一次三时间框架的指标集大约需要几百毫秒的量级(约 0.3 秒)——对三个时间框架重采样、Hull 加权、在整整一年上提取交叉和分离度。如果你在配置循环内部重新计算指标——也就是朴素的单层循环设计——那么每秒那 5,600 个配置中的每一个,都会转而付出完整的指标构建代价。每个配置的成本从约 180 微秒膨胀到约 0.3 秒:

每个配置的成本 配置/秒
廉价轴(缓存信号) 约 180 µs 约 5,600
每个配置重新计算指标 约 0.3 s 约 3.4

这个比值是约 1,600 倍。在缓存信号上扫描廉价轴,比那种为每个阈值向量都重建指标的朴素设计,大约便宜三个数量级。说得具体些:一批几千个阈值配置,在缓存路径上不到一秒就能跑完,而如果每个配置都要重建它的指标,则要花掉将近一个小时。相同的结果,相同的保真度,数学上没有任何捷径——唯一的区别在于,一个重新计算了一个不变量,而另一个没有。

这不是你在最后随手撒上去的一点微优化。它改变了哪些搜索是可行的。在 5,600 cfg/s 的速度下,阈值轴足够密集,可以被恰当地探索——你负担得起为每个指标组合做一个精细网格或一次长时间的随机/QMC 采样——而昂贵轴则刻意保持为一小撮构建。计算预算流向了参数真正有成本的地方。

维度灾难,重新定价

一张扁平均匀的参数网格被重新标注成一张两级价格图:少数几个昂贵的格子用粗线框出,其余的则被涂成几乎免费的底色

回到本文开篇的那个叙述框架。维度灾难说的是,搜索空间随参数数量呈指数增长,所以维度越多就绝对越糟。这一点在网格的规模上是对的。但它在覆盖网格的成本上具有误导性,因为它把每个维度都定了相同的价。

一旦你把这两条轴分开,这个数字读起来就不一样了。三时间框架策略是 18 维的,但其中只有 6 个维度是昂贵的。另外 12 个是廉价轴维度,它们扩张了网格,却没有以任何有意义的方式扩张计算开销——你可以用几个小钱就往上面砸成千上万个配置。双时间框架策略是 12 维的,只有 4 个昂贵维度和 8 个廉价维度。在这两种情况下,"灾难"的大部分都集中在那条几乎不花成本就能扫描的轴上。

所以,诚实地推理搜索成本,靠的不是"有多少个参数",而是"有多少个昂贵参数,以及它们的网格能有多粗"。昂贵轴才是指数真正伤人的地方,也是你想要一个小而精心挑选的网格的地方——几个候选周期,一段适度的 HMA 长度范围——可能还会从粗到细地细化,秉承我们在别处使用的自适应分辨率逐层下钻的精神。廉价轴则是你可以挥霍的地方,因为每增加一个阈值配置只需 180 微秒。

这次重新定价的适用范围远远超出我们的 HMA 策略。这个模式——有些参数改变特征,大多数参数改变应用在固定特征上的规则——在系统化交易中处处复现。指标长度、重采样频率和回看窗口是昂贵的;入场/出场阈值、止损距离、仓位规模乘数以及确认门控是廉价的。任何时候,只要一个参数只是在已计算好的信号之上重塑决策边界,它就属于廉价轴,而且就应该在那里被扫描。来自同一族的相关缓存收益也出现在多时间框架 parquet 缓存以及更宏观的回测引擎速度阶梯中。

免费午餐的账单在哪里

廉价轴在计算上是廉价的。它在统计上并不廉价,而把这两者混为一谈,正是你如何把一次性能胜利变成一台过拟合机器的方式。

你评估的每一个阈值配置都是一次试验,而当你在一个数据集上运行成千上万次试验时,其中最好的那个之所以看起来不错,有一部分是靠运气。把这些试验变得便宜 1,600 倍并不会让运气消失——它只会让运气更容易被累积起来。密集的阈值扫描恰恰是多重检验膨胀咬得最狠的情形:候选者众多,历史却只有一段,而选择规则报告的是最大值。世界上最快的引擎也会乐呵呵地递给你一个阈值向量,它把你测试窗口里的噪声拟合得漂漂亮亮,却在样本外败下阵来。

所以纪律必须随速度一起扩张。一旦一次试验的计算成本降向零,那么统计核算就成了那个约束性的约束,而你必须明确地为它付账:

  • 为试验次数做贬值(deflate)。 用你尝试过多少个配置来给赢家打分,而不是拿零来打分。缩减夏普比率(Deflated Sharpe Ratio)回测过拟合概率(PBO)的存在正是为了这个——它们把"我们尝试了 4,000 个阈值向量"变成对所报告优势的一次折扣。
  • 逐折(per fold)做样本外验证。 一次廉价的扫描仍然必须在一次诚实的前向优化(walk-forward)划分内部运行;一个只在样本内取胜的阈值毫无价值,无论你找到它的速度有多快。我们的引擎让成交保持无泄露(open[i+1]),正是为了让廉价轴无法靠偷看来买到性能。
  • 偏好高原而非尖峰。 因为阈值轴既密集又快速,你可以绘制它的响应曲面,而不只是它的 argmax。一大片全都奏效的阈值区域是一个真实的优势;一个孤零零的尖锐最大值则是一个拟合出来的假象——高原与尖峰之分,正是被我们所描述的这份速度变得负担得起。

用正确的方式来理解这个双轴结构:它并不为你买来更多的信心,它为你买来的是在每次试验相同的信心成本下做更多次试验。这确实很有价值——对廉价轴的密集覆盖,正是让你能够找到高原并刻画曲面的东西——但前提是你要让统计账本保持诚实。速度消除了不去彻底搜索的计算借口;它并没有消除对彻底搜索所得结果打折扣的义务。

如何构建你自己的搜索

要把这套方法应用到你自己的策略上,工作主要是分类——把你的参数分拣到正确的轴上——随后是一个嵌套循环:

  1. 按每个参数迫使你重新计算什么来给它贴标签。 如果改变它会改变某个指标/特征数组,它就是昂贵的。如果它只改变应用在固定数组上的某个比较、阈值或规模规则,它就是廉价的。拿不准的时候,问一句:这个参数是否出现在指标定义内部的任何地方?如果没有,它就是廉价的。
  2. 只用昂贵轴给缓存做键。 用指标参数来记忆化特征构建(就像 SignalCache(period, hma_length) 那样)。共享同一指标组合的相邻试验,便可免费复用同一批数组。
  3. 在缓存特征上批量处理廉价轴。 把阈值配置作为一个对预计算信号的紧凑已编译循环来运行,而不是作为完整的重新评估。这就是你的吞吐量——在我们的例子里是约 5,600 cfg/s——的来源。
  4. 嵌套循环:昂贵在外,廉价在内。 让昂贵网格保持又小又刻意(粗,或从粗到细);让廉价扫描保持密集。把预算花在参数有成本的地方。
  5. 用过拟合来给试验计数做预算,而不是用时钟。 既然时钟不再是极限,就让缩减夏普 / PBO 来当极限。决定你在统计上负担得起多少次廉价轴试验,并把赢家拿到样本外去验证。

工程上的回报和统计上的护栏是同一个想法的两面:把两条轴分开,让你能够穷尽地搜索廉价维度,而这恰恰是你随后必须为你搜索得有多穷尽而打折扣的原因。

要点

  1. 并非所有维度成本相同。 一个策略的参数分裂为一条昂贵轴(指标——在整个序列上重新计算)和一条廉价轴(阈值——对预计算信号做一次 O(n) 遍历)。在我们的引擎里,三时间框架是 6 昂贵 + 12 廉价,双时间框架是 4 + 8。
  2. 指标对阈值不变,而这种不变性就是整个优化。 特征构建只依赖于指标参数,所以你构建一次,以 (period, hma_length) 为键缓存,然后在每一个共享该组合的阈值配置之间复用。
  3. 廉价轴运行起来便宜约 1,600 倍。 在缓存信号上是每秒约 5,600 个阈值配置(每个约 180 µs),而如果你每次都重建指标则是每个配置约 0.3 秒。相同的保真度——唯一的区别是拒绝重新计算一个不变量。
  4. 维度灾难其实是一场昂贵维度的灾难。 大部分参数数量都住在那条几乎免费就能扫描的轴上。让昂贵网格保持粗糙;在廉价的那条上尽情挥霍。
  5. 速度把约束性约束从计算转移到了统计。 一次密集而快速的扫描就是一台多重检验机器。为试验次数做贬值,逐折验证,并偏好高原而非尖峰——免费午餐是真的,但那份统计账单并非可选。

完整的引擎——SignalCachesweep_separations、无泄露的多 TF 模拟,以及把它钉死在实盘实时 K 线语义上的对拍测试——都在我们回测器的 scripts/engine_multitf.py(提交 bfc8aaa)中。下次有人告诉你一个 18 参数的策略太大而无法搜索时,问问他们这些参数里有多少个真正会动到指标。通常只有三分之一,而其余的几乎都是免费的。

免责声明:本文提供的信息仅用于教育和参考目的,不构成财务、投资或交易建议。加密货币交易涉及重大损失风险。

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

紧跟市场步伐

订阅我们的时事通讯,获取独家 AI 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。