时间序列模型

PyBroker v2 引入了对时间序列模型回测的支持。这类模型不是逐个样本单独训练,而是基于序列自身的历史值进行预测。

为了展示其工作原理,我们将回测两种不同的策略。第一种策略依赖于使用 arch 库构建的 GARCH(1,1) 模型给出的波动率预测。第二种策略使用一个在每根 K 线上都重新拟合的滚动回归模型。由于 PyBroker 默认不包含 arch,你必须先运行 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>

使用 GARCH 预测波动率

GARCH 用于对收益率序列的波动率进行建模,因此我们首先为对数收益率定义一个 indicator

[2]:
from pybroker.indicator import returns

log_return_ind = returns("log_return", "close", use_log=True)

训练函数会将对数收益率缩放为百分比,以获得更可靠的估计:

[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")

该模型使用训练窗口和测试窗口合并后的收益率进行构建。传入 last_obs 可确保参数估计仅依赖训练窗口,并将测试收益率隔离开来,以防止数据泄漏。

对于每根测试数据 K 线,预测函数都会使用已训练的模型来预测下一根 K 线的方差:

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

由于模型已经保存了完整的收益率序列,因此模型的输入为当前 K 线的位置。由此,预测过程只会使用到该位置为止的收益率数据。

默认情况下,PyBroker 会在一次调用中将全部测试窗口数据传给模型以生成预测。这种向量化的方式虽然高效,但并不适用于自回归模型,因为这类模型需要依赖上一步的输出来生成下一次预测。此时,向 pybroker.model(…) 传入 per_bar=True 会使 predict_fn 针对每根输入 K 线调用一次:

[5]:
garch_model = pybroker.model(
    "garch",
    train_garch,
    predict_fn=predict_garch,
    indicators=[log_return_ind],
    per_bar=True,
)

该策略接着将 ctx.preds 给出的波动率预测用作市场状态过滤器:当预测波动率低于阈值时进场做多,并在波动率升至阈值以上时离场:

[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

基于滞后收益率的随机森林

第二种策略训练一个 RandomForestRegressor,根据滞后收益率来预测下一根 K 线的收益率:

[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

训练函数会接收 lag_trainlag_test 参数,二者是通过为模型配置所需数量的 lags 而构建的。每个参数都是一个特征矩阵,每个样本对应一行;这些行以该 K 线的 log_return 值开头,后面跟着其滞后收益率值。

与逐 K 线计算的 GARCH 模型不同,predict_fn 使用 PyBroker 的默认行为,在一次调用中传入整个测试窗口以生成模型的预测结果:

[8]:
def predict_forest(model, data):
    return model.predict(data)

在注册模型时将 lags 设为 3,会为 lag_cols 中声明的每一列都包含过去三个滞后值:

[9]:
forest_model = pybroker.model(
    "forest",
    train_forest,
    predict_fn=predict_forest,
    lags=3,
    lag_cols=[log_return_ind],
)

当下一根 K 线收益率的 ctx.preds 为正时,该策略买入;为负时平仓。接着,我们运行一次 walkforward 回测:

[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