Parameter Optimization

PyBroker v2 supports parameterized strategies. This allows you to backtest strategies using different combinations of parameters and automatically select the best performers. This process is known as parameter optimization and is handled via the Optuna framework.

These strategy parameters are created as hyperparameters, as shown in the next section.

Declaring Hyperparameters

A hyperparameter is a named, tunable value created with hyperparam. Each one has a default that regular backtests use, and a search range given by low, high, and step. The candidate values start at low (inclusive), and then increase by step until high (inclusive).

A hyperparameter can be used to parameterize:

To demonstrate, we will build a moving average crossover strategy with two hyperparameters: the moving average’s period and a stop_pct stop loss.

[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%

Optimizing with Tree-structured Parzen Estimator (TPE)

Grid search grows multiplicatively with each added hyperparameter. An alternative approach is using sampler="tpe". Optuna’s TPESampler (Tree-structured Parzen Estimator) uses Bayesian Optimization by fitting a probability model to completed trials to suggest promising values for the next run. Note that n_trials is required for every sampler except "grid", and providing a seed makes the parameter search reproducible.

Because TPE adapts based on earlier results, its trials always run sequentially:

[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 recovered the same best values as the exhaustive grid search while evaluating only 15 of the 25 combinations.

Other Samplers

sampler="random" chooses combinations uniformly at random with Optuna’s RandomSampler. Like grid, random trials can be evaluated in parallel:

[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}

Any optuna.samplers.BaseSampler instance can also be passed directly to optimize to customize the parameter search:

[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%

Every optimization also returns the underlying optuna.Study for inspecting the trials:

[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

Walkforward Optimization

Finally, optimize supports walkforward optimization. If you pass > 1 to the windows parameter, PyBroker will independently tune the hyperparameters for each window’s in-sample split and then combine the out-of-sample results:

[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}

Each window is tuned separately, so the best parameters can differ between windows. The combined result contains the best_params of the last (most recent) window:

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