时间序列模型
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_train 和 lag_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 |