参数优化

PyBroker v2 支持参数化策略。这使你可以使用不同的参数组合来回测策略,并自动选择表现最佳的组合。这一过程称为**参数优化**,由 Optuna 框架 负责处理。

这些策略参数以超参数的形式创建,具体如下一节所示。

声明超参数

超参数是使用 hyperparam 创建的一个具名的、可调节的值。每个超参数都有一个供常规回测使用的 default,以及一个由 lowhighstep 给定的搜索范围。候选值从 low (含)开始,按 step 递增,直至 high (含)为止。

超参数可用于参数化以下内容:

为了演示这一点,我们将构建一个带有两个超参数的移动平均线交叉策略:移动平均线的 period,以及一个 stop_pct 止损。

[1]:
import numpy as np
import pybroker
from pybroker import Strategy, YFinance, sumv

pybroker.enable_data_source_cache("parameter_optimization")

period = pybroker.hyperparam("period", default=30, low=10, high=50, step=10)
stop_pct = pybroker.hyperparam(
    "stop_pct", default=6.0, low=2.0, high=10.0, step=2.0
)


def sma(bar_data, period):
    return sumv(bar_data.close, period) / period


# The hyperparam is passed in place of a concrete period.
sma_ind = pybroker.indicator("sma", sma, period=period)


def sma_cross_stop(ctx):
    sma = ctx.indicator("sma")
    if np.isnan(sma[-1]):
        return
    pos = ctx.long_pos()
    if not pos and ctx.close[-1] > sma[-1]:
        ctx.buy_shares = 100
        ctx.stop_loss_pct = ctx.hyperparam("stop_pct")
    elif pos and ctx.close[-1] < sma[-1]:
        ctx.sell_all_shares()


strategy = Strategy(YFinance(), start_date="1/1/2021", end_date="1/1/2026")
strategy.add_execution(
    sma_cross_stop,
    ["MRK", "TGT", "ORCL"],
    indicators=sma_ind,
    hyperparams=[stop_pct],
)

result = strategy.backtest()
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
Backtesting: 2021-01-01 00:00:00 to 2026-01-01 00:00:00

Loading bar data...
[*********************100%***********************]  3 of 3 completed
Loaded bar data: 0:00:01

Computing indicators...
100% (3 of 3) |##########################| Elapsed Time: 0:00:00 Time:  0:00:00

Test split: 2021-01-04 00:00:00 to 2025-12-31 00:00:00
100% (1255 of 1255) |####################| Elapsed Time: 0:00:00 Time:  0:00:00

Finished backtest: 0:00:01
Total return: 2.88%

使用网格搜索进行优化

optimize 方法会按照 train_size (默认 0.5,即 50/50)指定的比例,将数据拆分为训练窗口和测试窗口。当 Optuna 搜索参数空间时,每个被选中的组合都会在训练窗口上进行回测,并使用 score_fn 打分。默认情况下,该分数会被最大化(传入 direction="minimize" 则改为最小化)。为了防止过拟合,回测仅在训练窗口上运行。搜索结束后,在样本内训练窗口上表现最佳的参数会在样本外测试窗口上进行评估。

在 Optuna 中,采样器是在优化过程中决定测试哪些参数组合的算法。默认的采样器是 grid,它会评估每一种可能的参数组合。对于我们的示例,这会产生 5 × 5 = 25 次试验。这些试验会被并行评估:

[2]:
def score_fn(result):
    return result.metrics.total_return_pct


opt_result = strategy.optimize(score_fn)
print("Best train params:", opt_result.best_params)
print("Best train score:", opt_result.best_score)
print(f"Total return: {opt_result.result.metrics.total_return_pct:.2f}%")
Loaded cached bar data.

Optimizing: 25 trials (grid)
Computing indicators...
100% (3 of 3) |##########################| Elapsed Time: 0:00:00 Time:  0:00:00

Test split: 2023-07-05 00:00:00 to 2025-12-31 00:00:00
100% (627 of 627) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Best train params: {'period': 10, 'stop_pct': 2.0}
Best train score: 6.0325799999999985
Total return: 5.70%

best_params 保存了最佳的样本内数值,而 best_score 则是它们在训练窗口上获得的分数。

使用优化后的参数,我们可以看到总回报相较之前有所提升。result 属性包含了在测试窗口上使用最佳参数的 TestResult

[3]:
opt_result.result.metrics_df.head()
[3]:
name value
0 trade_count 181
1 initial_market_value 100000.0
2 end_market_value 106232.42
3 total_pnl 5696.42
4 unrealized_pnl 536.0

使用树形 Parzen 估计器(TPE)进行优化

网格搜索的规模会随着每新增一个超参数而成倍增长。另一种替代方案是使用 sampler="tpe"。Optuna 的 TPESampler (树形 Parzen 估计器)通过对已完成的试验拟合概率模型,采用 贝叶斯优化 的方式,为下一次运行提出有希望的取值。请注意,除 "grid" 外,其余每种采样器都需要设置 n_trials,并且提供随机种子可以使参数搜索结果可复现。

由于 TPE 会根据先前的结果进行自适应调整,它的试验总是按顺序运行:

[4]:
opt_result = strategy.optimize(score_fn, sampler="tpe", n_trials=15, seed=2)
print("Best params:", opt_result.best_params)
print("Best train score:", opt_result.best_score)
print(f"Total return: {opt_result.result.metrics.total_return_pct:.2f}%")
Loaded cached bar data.

Optimizing: 15 trials (tpe)
Loaded cached indicator data.

Test split: 2023-07-05 00:00:00 to 2025-12-31 00:00:00
100% (627 of 627) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Best params: {'period': 20, 'stop_pct': 8.0}
Best train score: 5.119359999999995
Total return: 5.48%

TPE 仅评估了 25 个组合中的 15 个,就得到了与穷举网格搜索相同的最佳数值。

其他采样器

sampler="random" 使用 Optuna 的 RandomSampler 均匀随机地选择组合。与网格搜索一样,随机试验也可以并行评估:

[5]:
opt_result = strategy.optimize(score_fn, sampler="random", n_trials=10, seed=1)
print("Best params:", opt_result.best_params)
Loaded cached bar data.

Optimizing: 10 trials (random)
Computing indicators...
100% (3 of 3) |##########################| Elapsed Time: 0:00:00 Time:  0:00:00

Test split: 2023-07-05 00:00:00 to 2025-12-31 00:00:00
100% (627 of 627) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Best params: {'period': 10, 'stop_pct': 2.0}

任何 optuna.samplers.BaseSampler 实例也都可以直接传给 optimize 以自定义参数搜索:

[6]:
from optuna.samplers import TPESampler

# Use fewer random startup trials before TPE's model takes over.
opt_result = strategy.optimize(
    score_fn, sampler=TPESampler(n_startup_trials=5), n_trials=15, seed=2
)
print("Best params:", opt_result.best_params)
print(f"Total return: {opt_result.result.metrics.total_return_pct:.2f}%")
Loaded cached bar data.

Optimizing: 15 trials (tpe)
Loaded cached indicator data.

Test split: 2023-07-05 00:00:00 to 2025-12-31 00:00:00
100% (627 of 627) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Best params: {'period': 30, 'stop_pct': 8.0}
Total return: -5.98%

每次优化还会返回底层的 optuna.Study,供你检查各次试验:

[7]:
opt_result.study.trials_dataframe().head()
[7]:
number value datetime_start datetime_complete duration params_period params_stop_pct state
0 0 5.04756 2026-08-11 13:30:51.214844 2026-08-11 13:30:51.270106 0 days 00:00:00.055262 20 4.0 COMPLETE
1 1 1.90168 2026-08-11 13:30:51.270139 2026-08-11 13:30:51.316517 0 days 00:00:00.046378 50 2.0 COMPLETE
2 2 1.90168 2026-08-11 13:30:51.316552 2026-08-11 13:30:51.362868 0 days 00:00:00.046316 50 2.0 COMPLETE
3 3 3.40112 2026-08-11 13:30:51.362915 2026-08-11 13:30:51.408936 0 days 00:00:00.046021 40 6.0 COMPLETE
4 4 0.42380 2026-08-11 13:30:51.408971 2026-08-11 13:30:51.456350 0 days 00:00:00.047379 50 10.0 COMPLETE

前向优化

最后,optimize 还支持前向优化。如果向 windows 参数传入 > 1,PyBroker 会针对每个窗口的样本内拆分独立调优超参数,然后合并各窗口的样本外结果:

[8]:
opt_result = strategy.optimize(score_fn, windows=3)

for i, window in enumerate(opt_result.windows):
    print(
        f"Window {i + 1} train: {window.train_start_date:%Y-%m-%d} to "
        f"{window.train_end_date:%Y-%m-%d}, "
        f"test: {window.test_start_date:%Y-%m-%d} to "
        f"{window.test_end_date:%Y-%m-%d}"
    )
    print(f"Window {i + 1} best params:", window.params)
Loaded cached bar data.

Optimizing: 3 windows, 25 trials per window (75 total, grid)
Computing indicators...
100% (3 of 3) |##########################| Elapsed Time: 0:00:00 Time:  0:00:00

Test split: 2022-04-05 00:00:00 to 2023-07-05 00:00:00
100% (313 of 313) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Computing indicators...
100% (3 of 3) |##########################| Elapsed Time: 0:00:00 Time:  0:00:00

Test split: 2023-07-06 00:00:00 to 2024-10-01 00:00:00
100% (313 of 313) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Loaded cached indicator data.

Test split: 2024-10-02 00:00:00 to 2025-12-31 00:00:00
100% (313 of 313) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Window 1 train: 2021-01-07 to 2022-04-04, test: 2022-04-05 to 2023-07-05
Window 1 best params: {'period': 30, 'stop_pct': 4.0}
Window 2 train: 2022-04-05 to 2023-07-05, test: 2023-07-06 to 2024-10-01
Window 2 best params: {'period': 10, 'stop_pct': 2.0}
Window 3 train: 2023-07-06 to 2024-10-01, test: 2024-10-02 to 2025-12-31
Window 3 best params: {'period': 10, 'stop_pct': 10.0}

每个窗口都是单独调优的,因此不同窗口的最佳参数可能有所不同。合并后的结果包含**最后** (最近的)一个窗口的 best_params

[9]:
print("Best params:", opt_result.best_params)
Best params: {'period': 10, 'stop_pct': 10.0}