跳转至

第 11 章:参数优化与稳健性检验

量化策略通常包含若干参数(如均线周期、止损阈值)。参数的选择对策略表现有着决定性影响。本章将探讨如何通过科学的参数优化 (Parameter Optimization) 寻找最优解,并深入分析过拟合 (Overfitting) 的成因与防范措施。

本章实践入口

快速运行与验收

python examples/textbook/ch11_optimization.py

验收要点:

  1. 脚本可完成参数搜索并输出最优参数组合。
  2. 输出中可比较样本内与样本外表现差异。
  3. 改变搜索范围后,最优参数与结果变化具有一致性。

11.1 参数优化理论

11.1.1 目标函数 (Objective Function)

参数优化的本质是一个数学规划问题:

\[ \max_{\theta \in \Theta} f(\theta | D_{train}) \]

其中:

  • \(\theta\):策略参数向量。
  • \(\Theta\):参数搜索空间。
  • \(D_{train}\):训练集数据(样本内数据)。
  • \(f\):目标函数,通常为夏普比率、卡玛比率或净利润。

11.1.2 搜索算法

  1. 网格搜索 (Grid Search):穷举所有可能的参数组合。
    • 优点:能找到全局最优解(在离散网格上)。
    • 缺点:计算量随参数数量指数级增长(维数灾难)。
  2. 随机搜索 (Random Search):在参数空间内随机采样。
    • 优点:在高维空间中效率通常高于网格搜索。
  3. 遗传算法 (Genetic Algorithm):模拟生物进化过程,通过变异和交叉寻找最优解。适合非凸优化问题。

11.2 过拟合:量化交易的隐形杀手

过拟合 (Overfitting) 指策略在样本内 (In-Sample) 表现极佳,但在样本外 (Out-of-Sample) 迅速失效的现象。

11.2.1 统计学原理

过拟合的本质是多重假设检验 (Multiple Hypothesis Testing) 的谬误。 假设我们在随机生成的噪声数据上测试 100 组参数,即使没有任何真实规律,我们也大概率能找到一组在 95% 置信水平下“显著有效”的参数。

\[ P(\text{至少一次伪显著}) = 1 - (1 - 0.05)^{100} \approx 99.4\% \]

这意味着,尝试的参数越多,找到“伪规律”的概率就越大。这被称为数据窥探偏差 (Data Snooping Bias)

11.2.2 偏差-方差权衡 (Bias-Variance Tradeoff)

  • 欠拟合 (High Bias):模型太简单,无法捕捉市场规律(如:买入持有)。
  • 过拟合 (High Variance):模型太复杂,记住了历史数据的噪音(如:用 10 个参数拟合 100 天的数据)。

11.3 稳健性检验 (Robustness Testing)

为了检验策略是否过拟合,我们需要进行严格的稳健性测试。

11.3.1 样本外测试 (Out-of-Sample Testing)

将历史数据分为训练集 (Train Set)测试集 (Test Set)

  • 原则:训练集用于优化参数,测试集仅用于验证。测试集数据在优化过程中必须严格不可见。
  • 标准:如果测试集夏普比率显著低于训练集(如衰减超过 50%),则存在过拟合。

11.3.2 参数敏感性分析 (Parameter Sensitivity)

优秀的策略应该在参数平原 (Parameter Plateau) 上,而不是参数尖峰 (Parameter Peak) 上。

  • 参数平原:参数微小变化(如均线从 20 变 21),绩效指标保持稳定。
  • 参数尖峰:参数微小变化,绩效断崖式下跌。这通常意味着过拟合。

可以通过绘制参数热力图 (Heatmap) 来可视化参数敏感性。

11.3.3 滚动回测 (Walk-Forward Analysis)

模拟真实交易中“定期重新优化”的过程。

  1. \(T_0 \sim T_1\) 优化参数,在 \(T_1 \sim T_2\) 使用该参数交易。
  2. \(T_1 \sim T_2\) 重新优化参数,在 \(T_2 \sim T_3\) 使用新参数交易。
  3. 拼接所有测试段的资金曲线。

这是检验策略真实生命力的“金标准”。

11.4 AKQuant 优化实战

akquant 提供了 run_grid_search 函数,支持并行计算。

11.4.0 Windows 并行运行前置条件

max_workers > 1 时,Windows 使用多进程 spawn 模式,需满足以下条件:

  • 策略类必须位于可导入模块中,不能直接定义在 __main__
  • 脚本入口必须加 if __name__ == "__main__":
  • 这类报错属于多进程序列化限制,不是成交策略语义错误。

示例:

from akquant import run_grid_search
from my_strategy_module import TailTradingStrategy


def main() -> None:
    results = run_grid_search(
        strategy=TailTradingStrategy,
        param_grid=param_grid,
        data=all_data,
        max_workers=4,
    )
    print(results.head())


if __name__ == "__main__":
    main()

11.4.1 参数模型驱动(适合页面化)

在面向页面配置、策略市场、研究平台等场景中,推荐采用以下分层:

  • 参数模型层:在策略类中声明 PARAM_MODEL(基于 akquant.params.ParamModel)。
    • 用于参数类型约束、默认值管理、前端 JSON Schema 导出。
  • 优化搜索层:继续使用 run_grid_search(param_grid=...)
    • param_grid 只负责离散候选值,不承担复杂对象校验。

推荐这样做的核心原因是:运行参数校验参数组合搜索在职责上是不同问题,拆开后更清晰、更稳健。

策略示意(节选):

from akquant import IntParam, ParamModel, Strategy


class SmaParams(ParamModel):
    fast_period: int = IntParam(10, ge=2, le=200)
    slow_period: int = IntParam(30, ge=3, le=500)


class SmaStrategy(Strategy):
    PARAM_MODEL = SmaParams

11.4.2 代码示例

"""
第 11 章:参数优化与过拟合 (Optimization & Overfitting).

本示例展示了如何使用 AKQuant 的网格搜索 (Grid Search) 功能来寻找最优的策略参数。
同时,我们也会探讨过度优化带来的风险。

策略逻辑:
- 依然使用双均线策略 (MA_Short vs MA_Long)
- 优化目标:寻找夏普比率 (Sharpe Ratio) 最高的参数组合
    - short_window: [3, 5, 10]
    - long_window: [15, 20, 30, 60]

AKQuant 特性:
- `run_grid_search`: 自动多进程并行回测,极大提高优化效率。
"""

from typing import Any, List

import akquant as aq
import numpy as np
import pandas as pd
from akquant import Bar, Strategy


# 模拟数据生成
def generate_mock_data(length: int = 1000) -> pd.DataFrame:
    """生成模拟数据."""
    np.random.seed(42)
    dates = pd.date_range(start="2020-01-01", periods=length, freq="D")
    prices = 100 + np.cumsum(np.random.randn(length))
    df = pd.DataFrame(
        {
            "date": dates,
            "open": prices,
            "high": prices + 1,
            "low": prices - 1,
            "close": prices,
            "volume": 100000,
            "symbol": "MOCK",
        }
    )
    return df


class OptStrategy(Strategy):
    """参数优化演示策略."""

    def __init__(self, short_window: int = 5, long_window: int = 20) -> None:
        """初始化策略."""
        super().__init__()
        self.short_window = short_window
        self.long_window = long_window
        # 动态设置 warmup_period,确保足够计算最长的均线
        self.warmup_period = long_window + 1

    def on_bar(self, bar: Bar) -> None:
        """收到 Bar 事件的回调."""
        symbol = bar.symbol
        closes = self.get_history(
            count=self.long_window + 1, symbol=symbol, field="close"
        )
        if len(closes) < self.long_window + 1:
            return

        history_closes = closes[:-1]
        ma_short = history_closes[-self.short_window :].mean()
        ma_long = history_closes[-self.long_window :].mean()

        pos = self.get_position(symbol)

        if ma_short > ma_long and pos == 0:
            self.order_target_percent(0.95, symbol)
        elif ma_short < ma_long and pos > 0:
            self.close_position(symbol)


if __name__ == "__main__":
    df = generate_mock_data()

    print("开始运行第 11 章参数优化示例...")
    print("正在进行网格搜索 (Grid Search)...")

    # 定义参数网格
    # 键名必须与策略 __init__ 中的参数名一致
    param_grid = {"short_window": [3, 5, 10], "long_window": [15, 20, 30, 60]}

    # 运行网格搜索
    # max_workers: 并行进程数,默认根据 CPU 核心数自动设置
    # metric: 优化目标指标,默认为 sharpe_ratio
    results: Any = aq.run_grid_search(
        strategy=OptStrategy,
        data=df,
        param_grid=param_grid,
        initial_cash=100_000,
        commission_rate=0.0003,
        max_workers=4,  # 限制为 4 个进程
    )

    print("\n" + "=" * 40)
    print("优化结果 (按夏普比率排序)")
    print("=" * 40)

    # OptimizationResult 对象包含所有参数组合的回测结果
    # 我们可以将其转换为 DataFrame 方便查看
    df_results = pd.DataFrame(results)

    # 按照 sharpe_ratio 降序排列
    # 注意:AKQuant 的结果中,metrics 是一个字典
    # 我们需要展开它

    # 提取关键指标
    summary: List[Any] = []
    for res in results:
        params = res.params
        metrics = res.metrics

        # metrics 可能是 BacktestResult 对象或者字典,视版本而定
        # run_grid_search 通常返回一个包含 params 和 metrics 的轻量级对象
        # 这里假设 metrics 是一个字典,包含 sharpe_ratio 等

        # 实际上 aq.run_grid_search 返回的是 List[OptimizationResult]
        # OptimizationResult.metrics 是一个 PerformanceMetrics 对象或字典

        # 让我们直接打印最优结果
        pass

    # 简单起见,我们直接打印前 3 名
    # run_grid_search 返回的结果通常已经按默认指标排序了 (如果内部实现了的话)
    # 但为了保险,我们手动排序

    # 假设 results 是 List[OptimizationResult]
    # OptimizationResult(params={'short_window': 3, 'long_window': 15}, metrics=...)

    sorted_results = sorted(
        results,
        key=lambda x: (
            x.metrics.sharpe_ratio
            if hasattr(x.metrics, "sharpe_ratio")
            else x.metrics.get("sharpe_ratio", -999)
        ),
        reverse=True,
    )

    print(f"{'Short':<6} {'Long':<6} {'Sharpe':<10} {'Return':<10} {'MaxDD':<10}")
    print("-" * 50)

    for res in sorted_results[:5]:
        p = res.params
        m = res.metrics

        # 兼容不同版本的属性访问
        sharpe = getattr(m, "sharpe_ratio", m.get("sharpe_ratio", 0))
        ret = getattr(m, "total_return_pct", m.get("total_return_pct", 0))
        dd = getattr(m, "max_drawdown_pct", m.get("max_drawdown_pct", 0))

        print(
            f"{p['short_window']:<6} {p['long_window']:<6} "
            f"{sharpe:<10.2f} {ret:<10.2f}% {dd:<10.2f}%"
        )

    print("\n" + "=" * 40)
    print("最佳参数组合:")
    best = sorted_results[0]
    print(best.params)

11.4.2A 新参数(并行日志与严格参数校验)

为提升优化可观测性与结果可靠性,推荐关注以下参数:

  • forward_worker_logsrun_grid_search):
    • False(默认):性能优先,子进程日志可能在主进程不可见;
    • True:将子进程 self.log() 聚合回主进程,适合排障与教学演示。
  • strict_strategy_paramsrun_backtest,默认 True):
    • 严格校验策略构造参数;
    • param_grid 中存在策略不接受的参数时,立即抛错,避免静默回退导致“看似跑完但结果无效”。
  • run_walk_forward 也支持通过 **kwargs 透传这两个参数:
    • forward_worker_logs 作用于样本内优化阶段(内部 run_grid_search);
    • strict_strategy_params 在样本内优化与样本外验证阶段都生效。

示例:

results = run_grid_search(
    strategy=TailTradingStrategy,
    param_grid=param_grid,
    data=all_data,
    max_workers=4,
    forward_worker_logs=True,
)

WFO 传导示例:

wfo_results = run_walk_forward(
    strategy=TailTradingStrategy,
    param_grid=param_grid,
    data=all_data,
    train_period=250,
    test_period=60,
    max_workers=4,
    forward_worker_logs=True,
    strict_strategy_params=True,
)

11.4.3 结果分析

运行上述代码后,我们会得到一个按夏普比率排序的参数表。

  • 观察前 10 名:如果前 10 名参数比较集中(如均线都在 20-25 之间),说明策略比较稳健。
  • 观察分布:如果最优参数东一榔头西一棒子,说明策略可能在拟合噪音。

11.5 组合净化交叉验证 (Combinatorial Purged Cross-Validation, CPCV)

这是由 De Prado 提出的目前最先进的回测框架。

11.5.1 为什么需要 CPCV?

  1. 传统 K-Fold:由于时间序列的相关性,导致信息泄露。
  2. Walk-Forward:虽然避免了信息泄露,但只测试了一条历史路径。如果历史重演的方式略有不同,策略可能就失效了。

11.5.2 CPCV 原理

CPCV 将数据切分为 \(N\) 组,每次选取 \(k\) 组作为测试集(共有 \(C_N^k\) 种组合)。在训练集和测试集之间进行净化 (Purging)隔离 (Embargo)

通过这种方式,我们可以生成大量可能的“历史路径”。

  • 路径生成:将所有测试集的预测结果按时间拼接,可以重组出 \(C_N^k\) 条完整的资金曲线。
  • 概率分布:我们可以得到策略夏普比率的分布,而不是单一的数值。这让我们能回答:“在 95% 的概率下,该策略的夏普比率大于 1.0 吗?”

11.6 调整后的夏普比率 (Deflated Sharpe Ratio, DSR)

如果你尝试了 1000 组参数,终于找到了一组夏普比率为 2.0 的参数。这个 2.0 是真实的吗?

Deflated Sharpe Ratio (DSR) 用于修正多重测试偏差 (Multiple Testing Bias)。它在概率夏普比率 (PSR) 的基础上,进一步考虑了尝试次数 (Number of Trials) 的影响。

\[ DSR = PSR(\widehat{SR}, SR_{benchmark}) \]

其中基准夏普 \(SR_{benchmark}\) 不再是 0,而是根据尝试次数 \(K\) 和由于尝试次数增多而导致的预期最大夏普比率 \(E[\max(SR)_K]\) 计算得出的。

\[ E[\max(SR)_K] \approx E[SR] + \sigma_{SR} \sqrt{2 \ln K} \]

这意味着:尝试的次数越多,你就应该要求越高的夏普比率,才能确信这不是运气。

11.7 高级优化算法

除了简单的网格搜索,量化领域还常用以下高级算法:

11.7.1 遗传算法 (Genetic Algorithm, GA)

模拟生物进化过程,适用于参数空间巨大且非凸的优化问题。

  1. 种群初始化:随机生成 \(N\) 个策略个体(参数组合)。
  2. 适应度评估:回测每个个体,计算夏普比率作为适应度。
  3. 选择 (Selection):优胜劣汰,保留高夏普的个体。
  4. 交叉 (Crossover):交换两个父代个体的参数片段,生成子代。
  5. 变异 (Mutation):随机微调某些参数,引入多样性,防止陷入局部最优。
  6. 迭代:重复步骤 2-5,直到满足停止条件。

11.7.2 贝叶斯优化 (Bayesian Optimization)

适用于计算昂贵的优化问题(例如每次回测需要 1 小时)。

它通过构建一个代理模型 (Surrogate Model)(通常是高斯过程 Gaussian Process)来拟合目标函数 \(f(\theta)\)

  • 采集函数 (Acquisition Function):根据代理模型的预测值和不确定性,决定下一步去哪里采样(尝试哪组参数)。它在开发 (Exploitation)(去已知表现好的地方)和 探索 (Exploration)(去未知的地方)之间通过算法进行平衡。

贝叶斯优化通常能比网格搜索快 10-100 倍找到全局最优解。

11.8 目标函数的选择

优化什么指标,决定了你会得到什么样的策略。

  1. 最大化夏普比率:追求风险调整后收益。最常用。
  2. 最大化净利润:追求绝对收益。容易导致高波动、大回撤的策略。
  3. 最大化卡玛比率:追求低回撤。适合风险厌恶型资金。
  4. 多目标优化 (Multi-Objective Optimization):寻找帕累托前沿 (Pareto Frontier)。例如,同时追求高收益和低回撤。这种方法不会给出一个“最优解”,而是一组“非劣解”,供基金经理根据当前的市场观点和风险偏好进行选择。

11.9 聚类分析 (Cluster Analysis)

De Prado 提出了一种名为 ONC (Optimal Number of Clusters) 的算法,用于从一堆策略中筛选出真正互补的策略。

  1. 相关性矩阵:计算 \(N\) 个策略回测收益率的相关性矩阵。
  2. 聚类:将高相关性的策略聚为一类(例如所有的趋势策略聚在一起,所有的反转策略聚在一起)。
  3. 筛选:在每一类中只保留表现最好的一个策略。

这样构建出来的投资组合,其夏普比率通常远高于简单的等权组合,因为我们真正实现了风险分散

11.10 回测长度与显著性 (Minimum Backtest Length)

你需要多少年的数据才能证明策略有效?

\[ MinTRL \approx \frac{1.25}{\widehat{SR}^2} \left( \frac{\Phi^{-1}(1-\alpha) - \Phi^{-1}(\beta) \sqrt{1-\rho}}{1-\rho} \right)^2 \]

简单来说,策略的夏普比率越低,所需的验证时间就越长。

  • 夏普 0.5 的策略,可能需要 50 年的数据才能通过统计检验。
  • 夏普 2.0 的策略,可能只需要 2-3 年的数据。

这意味着,对于低频策略(通常夏普较低),你需要极长的历史数据;而对于高频策略(通常夏普较高),短期的验证可能就足够了。

11.11 “抽屉问题” (The File Drawer Problem)

学术界和业界都存在一种严重的发表偏差 (Publication Bias)

  • 研究员尝试了 100 个策略,其中 95 个失败了,被扔进了“抽屉”。
  • 只有 5 个成功的策略被写进了论文或展示给客户。
  • 由于读者看不到那 95 个失败的案例,他们会误以为这 5 个策略非常完美。

Deflated Sharpe Ratio 正是为了解决这个问题,它要求你在评估那 5 个成功策略时,必须考虑到背后还有 95 个失败的尝试。

本章小结

  1. 参数优化的目标是提升稳健性,不是追求样本内极值。
  2. WFO、交叉验证和防数据窥探是控制过拟合的关键手段。
  3. 实盘可用策略必须在多区间、多口径下保持一致表现。

课后练习

  1. 扩展一组参数搜索区间,比较样本内外指标变化。
  2. 设计一个滚动窗口回测并记录每段最优参数漂移。
  3. 计算一次 Deflated Sharpe Ratio 并对比普通夏普结论。

常见错误与排查

  1. 样本内过高收益:优先检查是否发生参数数据泄漏。
  2. 样本外断崖下滑:确认是否存在过窄参数空间或过拟合。
  3. 结果不可复现:固定随机种子并记录数据与代码版本。