Time Series Models
PyBroker v2 introduces support for backtesting time series models. Instead of training on examples individually, these models make predictions based on a series’ own past values.
To show how this works, we will backtest two different strategies. The first relies on a volatility forecast from a GARCH(1,1) model built with the arch library. The second strategy uses a rolling regression that is refit on every bar. Since PyBroker does not include arch by default, you must install it first by running pip install arch.
[1]:
import arch
import numpy as np
import pandas as pd
import pybroker
from pybroker import Strategy, YFinance
pybroker.enable_data_source_cache("time_series")
[1]:
<pybroker.cache._L1Cache at 0x7fa8207145f0>
Forecasting Volatility with GARCH
GARCH will model the volatility of a return series, so we start by defining an indicator for log returns:
[2]:
from pybroker.indicator import returns
log_return_ind = returns("log_return", "close", use_log=True)
The training function scales the log returns to percentages for more reliable estimation:
[3]:
def train_garch(symbol, train_data, test_data):
returns = (
pd.concat((train_data["log_return"], test_data["log_return"]))
.dropna()
.to_numpy()
* 100
)
n_train = int(train_data["log_return"].count())
# Estimate on the train window only; the test returns are held out
# for forecasting.
am = arch.arch_model(returns, vol="GARCH", p=1, q=1)
return am.fit(last_obs=n_train, disp="off")
The model is built using the combined returns from both the train and test windows. Passing last_obs ensures that parameter estimation relies solely on the train window and isolates the test returns to prevent data leakage.
For every test data bar, the prediction function uses the trained model to forecast the variance of the next bar:
[4]:
def predict_garch(model, data):
# Position of the current bar in the model's return series.
pos = model.fit_stop + len(data) - 1
# Forecast the next bar's variance from the trained model.
forecast = model.forecast(horizon=1, start=pos)
variance = forecast.variance.to_numpy()[0, 0]
# Annualize the one-day volatility forecast.
return np.sqrt(variance) / 100 * np.sqrt(252)
Because the model already stores the full return series, the model input is the location of the current bar. By doing this, the forecast then only uses returns up to that point.
By default, PyBroker passes all test window data to the model in a single call to make a make a prediction. While this vectorized approach is efficient, it fails for autoregressive models since they rely on the previous step’s output to generate their next forecast. Passing per_bar=True to pybroker.model(…) will cause the
predict_fn to be called once per bar of input:
[5]:
garch_model = pybroker.model(
"garch",
train_garch,
predict_fn=predict_garch,
indicators=[log_return_ind],
per_bar=True,
)
The strategy then uses the volatility forecast from ctx.preds as a regime filter. It enters a long position when forecast volatility is below the threshold, and exits when it rises above:
[6]:
VOL_THRESHOLD = 0.30
def vol_filter(ctx):
pred_vol = ctx.preds("garch")[-1]
if not ctx.long_pos():
# Enter while forecast volatility is below the threshold.
if pred_vol < VOL_THRESHOLD:
ctx.buy_shares = ctx.calc_target_shares(0.5)
elif pred_vol > VOL_THRESHOLD:
# Exit when forecast volatility rises above the threshold.
ctx.sell_all_shares()
strategy = Strategy(YFinance(), start_date="1/1/2021", end_date="1/1/2026")
strategy.add_execution(vol_filter, ["SBUX", "IBM"], models=garch_model)
result = strategy.walkforward(windows=2, train_size=0.5)
result.metrics_df.head(20)
Backtesting: 2021-01-01 00:00:00 to 2026-01-01 00:00:00
Loading bar data...
[*********************100%***********************] 2 of 2 completed
Loaded bar data: 0:00:00
Computing indicators...
100% (2 of 2) |##########################| Elapsed Time: 0:00:00 Time: 0:00:00
Train split: 2021-01-05 00:00:00 to 2022-08-31 00:00:00
Finished training models: 0:00:00
Test split: 2022-09-01 00:00:00 to 2024-05-01 00:00:00
100% (418 of 418) |######################| Elapsed Time: 0:00:00 Time: 0:00:000:00
Train split: 2022-09-01 00:00:00 to 2024-05-01 00:00:00
Finished training models: 0:00:00
Test split: 2024-05-02 00:00:00 to 2025-12-31 00:00:00
100% (418 of 418) |######################| Elapsed Time: 0:00:00 Time: 0:00:000:00
Finished backtest: 0:00:01
[6]:
| name | value | |
|---|---|---|
| 0 | trade_count | 6 |
| 1 | initial_market_value | 100000.0 |
| 2 | end_market_value | 159861.28 |
| 3 | total_pnl | -9971.31 |
| 4 | unrealized_pnl | 69832.59 |
| 5 | total_return_pct | -9.97131 |
| 6 | total_profit | 7728.96 |
| 7 | total_loss | -17700.27 |
| 8 | total_fees | 0.0 |
| 9 | max_drawdown | -35133.94 |
| 10 | max_drawdown_pct | -21.476558 |
| 11 | max_drawdown_date | 2025-04-08 00:00:00 |
| 12 | win_rate | 66.666667 |
| 13 | loss_rate | 33.333333 |
| 14 | winning_trades | 4 |
| 15 | losing_trades | 2 |
| 16 | avg_pnl | -1661.885 |
| 17 | avg_return_pct | -2.738333 |
| 18 | avg_trade_bars | 54.0 |
| 19 | avg_profit | 1932.24 |
Random Forest on Lagged Returns
The second strategy trains a RandomForestRegressor to predict the next bar’s return from lagged returns:
[7]:
from sklearn.ensemble import RandomForestRegressor
def train_forest(symbol, train_data, test_data, lag_train, lag_test):
rets = train_data["log_return"].to_numpy()
# Regress each next-bar return on the bar's return and its lags.
forest = RandomForestRegressor(random_state=42)
forest.fit(lag_train[:-1], rets[1:])
return forest
The training function receives lag_train and lag_test parameters built from configuring the model with the desired number of lags. Each parameter is a feature matrix containing one row per example. These rows begin with the bar’s log_return value, followed by its lagged return values.
Unlike the per-bar GARCH model, the predict_fn uses PyBroker’s default behavior and passes the entire test window in a single call to generate the model’s predictions:
[8]:
def predict_forest(model, data):
return model.predict(data)
Registering the model with lags set to 3 will include the past three lagged values for each column declared in lag_cols:
[9]:
forest_model = pybroker.model(
"forest",
train_forest,
predict_fn=predict_forest,
lags=3,
lag_cols=[log_return_ind],
)
The strategy buys when ctx.preds for the next-bar return is positive and exits when it is negative. We then run a walkforward backtest:
[10]:
def trade_forest(ctx):
pred = ctx.preds("forest")[-1]
if not ctx.long_pos():
if pred > 0:
ctx.buy_shares = ctx.calc_target_shares(0.5)
elif pred < 0:
ctx.sell_all_shares()
strategy.clear_executions()
strategy.add_execution(trade_forest, ["SBUX", "IBM"], models=forest_model)
result = strategy.walkforward(windows=2, train_size=0.5)
result.metrics_df.head(20)
Backtesting: 2021-01-01 00:00:00 to 2026-01-01 00:00:00
Loaded cached bar data.
Computing indicators...
100% (2 of 2) |##########################| Elapsed Time: 0:00:00 Time: 0:00:00
Train split: 2021-01-05 00:00:00 to 2022-08-31 00:00:00
Finished training models: 0:00:00
Test split: 2022-09-01 00:00:00 to 2024-05-01 00:00:00
100% (418 of 418) |######################| Elapsed Time: 0:00:00 Time: 0:00:00
Train split: 2022-09-01 00:00:00 to 2024-05-01 00:00:00
Finished training models: 0:00:00
Test split: 2024-05-02 00:00:00 to 2025-12-31 00:00:00
100% (418 of 418) |######################| Elapsed Time: 0:00:00 Time: 0:00:00
Finished backtest: 0:00:00
[10]:
| name | value | |
|---|---|---|
| 0 | trade_count | 416 |
| 1 | initial_market_value | 100000.0 |
| 2 | end_market_value | 128723.45 |
| 3 | total_pnl | 29056.09 |
| 4 | unrealized_pnl | -332.64 |
| 5 | total_return_pct | 29.05609 |
| 6 | total_profit | 198427.3 |
| 7 | total_loss | -169371.21 |
| 8 | total_fees | 0.0 |
| 9 | max_drawdown | -19026.54 |
| 10 | max_drawdown_pct | -15.650611 |
| 11 | max_drawdown_date | 2023-06-23 00:00:00 |
| 12 | win_rate | 51.923077 |
| 13 | loss_rate | 48.076923 |
| 14 | winning_trades | 216 |
| 15 | losing_trades | 200 |
| 16 | avg_pnl | 69.84637 |
| 17 | avg_return_pct | 0.137668 |
| 18 | avg_trade_bars | 2.100962 |
| 19 | avg_profit | 918.644907 |