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