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:
Indicators: pass it as a keyword argument to indicator.
Executions: attach it with
hyperparams=on add_execution and read it with ctx.hyperparam in the execution function.
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 Grid Search
The optimize method splits the data into train and test windows as specified by train_size (0.5 for 50/50 by default). As Optuna searches the parameter space, each selected combination is backtested on the train window and scored with score_fn. By default, this score is maximized (pass direction="minimize" to minimize instead). To guard against overfitting, the backtests
are run exclusively on the training window. Once finished, the best performing parameters on the in-sample train window are evaluated on the out-of-sample test window.
In Optuna, a sampler is the algorithm that decides which parameter combinations to test during the optimization process. The default sampler is grid, which will evaluate every possible parameter combination. For our example, this results in 5 × 5 = 25 total trials. These trials are evaluated in parallel:
[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 holds the best in-sample values, and best_score is the score they earned on the train window.
Using the optimized parameters, we’re able to see an improvment to the total return from before. The result attribute contains the TestResult of using the best parameters on the test window:
[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 |
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}