多个时间区间

你可能希望以不同于底层数据源的时间框架来做出交易决策。例如,你可以选择在通过周线或月线确认趋势后,在日线上执行交易。PyBroker v2 支持将回测数据压缩为更长的时间区间,并让这些压缩后的 K 线可供你的策略使用。

区间类型

你可以使用以下三种格式中的任意一种来定义时间区间:

  • 每 n 根 K 线 (大于 1int):将每 n 根基础 K 线压缩为一根 K 线。在日线数据上使用 5 会每五个交易日生成一根 K 线。

  • 时长str):以数字加单个单位字母(smhd)表示的固定时间跨度。传入 "5m" 会将 1 分钟 K 线压缩为 5 分钟 K 线。

  • 日历str):使用以下选项之一,将压缩后的 K 线对齐到日历边界:

日历字符串

边界对齐

"daily"

标准的每日边界。

"weekly"

从周一开始。

"monthly"

从每月 1 日开始。

"quarterly"

从 1 月、4 月、7 月和 10 月开始。

"yearly"

从 1 月 1 日开始。

你所选择的时间区间必须始终长于被压缩的 K 线。例如,如果你从 YFinance 获取日线数据,那么 "weekly""monthly" 都是有效的时间区间。尝试使用 "daily""1h" 将引发 ValueError

在策略中使用时间区间之前,让我们先通过直接压缩 K 线来建立一些直观认识。我们将从下载日线数据开始:

[1]:
import pybroker
from pybroker import Strategy, YFinance

pybroker.enable_data_source_cache("multiple_time_intervals")

yfinance = YFinance()
df = yfinance.query(
    ["AMD", "NVDA", "INTC"], start_date="1/1/2021", end_date="1/1/2026"
)
df.head()
Loading bar data...
[*********************100%***********************]  3 of 3 completed
Loaded bar data: 0:00:00

[1]:
date symbol open high low close volume adj_close
0 2021-01-04 AMD 92.110001 96.059998 90.919998 92.300003 51802600 92.300003
1 2021-01-04 INTC 49.889999 51.389999 49.400002 49.669998 46102500 44.902931
2 2021-01-04 NVDA 13.104250 13.652500 12.962500 13.113500 560640000 13.060796
3 2021-01-05 AMD 92.099998 93.209999 91.410004 92.769997 34208000 92.769997
4 2021-01-05 INTC 49.450001 50.830002 49.330002 50.610001 24866600 45.752716

压缩 K 线

compress_bars 函数可将 OHLCV 数据(可以是 Pandas DataFrameBarData)转换为更长的时间区间,并以新的 BarData 对象形式返回结果。每根压缩后的 K 线都以其所包含的最后一根基础 K 线的日期作为时间戳。

在将基础 K 线分组为压缩 K 线时,数据将按以下方式聚合:

  • 开盘价: 取自第一根基础 K 线。

  • 最高价 / 最低价: 取最高价中的最高值和最低价中的最低值。

  • 收盘价: 取自最后一根基础 K 线。

  • 成交量: 各成交量之和。

  • VWAP: 成交量加权平均价。

  • 自定义列: 取该周期内的最后一个值(例如 YFinanceadj_close)。

你还必须提供 base_timeframe 参数,以声明输入 K 线的间隔(例如,日线数据对应 "1d")。

让我们将 AMD 压缩为日历周,并使用 bars_to_df 将结果显示为 Pandas DataFrame

[2]:
from pybroker import compress_bars
from pybroker.common import bars_to_df


amd_df = df[df["symbol"] == "AMD"]
bars_to_df(compress_bars(amd_df, "weekly", base_timeframe="1d")).head()
[2]:
date open high low close volume adj_close
0 2021-01-08 92.110001 96.400002 89.459999 94.580002 220635900.0 94.580002
1 2021-01-15 94.029999 99.230003 87.860001 88.209999 279733900.0 88.209999
2 2021-01-22 89.559998 95.949997 87.239998 92.790001 205817500.0 92.790001
3 2021-01-29 94.139999 95.739998 85.019997 85.639999 291661400.0 85.639999
4 2021-02-05 86.830002 89.480003 84.660004 87.900002 169582500.0 87.900002

每 n 根 K 线压缩的方式相同。在此示例中,每 5 根日线合并为一根 K 线:

[3]:
bars_to_df(compress_bars(amd_df, 5, base_timeframe="1d")).head()
[3]:
date open high low close volume adj_close
0 2021-01-08 92.110001 96.400002 89.459999 94.580002 220635900.0 94.580002
1 2021-01-15 94.029999 99.230003 87.860001 88.209999 279733900.0 88.209999
2 2021-01-25 89.559998 95.949997 87.239998 94.129997 260904400.0 94.129997
3 2021-02-01 94.910004 95.720001 84.660004 87.660004 278933800.0 87.660004
4 2021-02-08 88.489998 91.989998 86.879997 91.470001 174863100.0 91.470001

多时间框架策略

在构建回测时,你可以通过向 add_execution 传递 intervals 参数来使用更高的时间框架。随后,你的执行函数便可通过 ctx.interval 访问这些压缩后的 K 线,该方法会返回一个只读的 IntervalContext

使用 intervals 参数仅提供压缩后的 K 线。除非像本文档后续内容那样显式绑定,否则指标和模型永远不会在这些区间上计算。

为防止前视偏差,ctx.interval 只会公开 已完成 的 K 线。例如,当前正在形成的这一周或这一月永远不可见,从而确保未来数据不会泄漏到你的每日交易决策中。

在下面的策略中,我们将在日线上执行交易,同时使用更长的时间区间来生成不同的交易信号:

  • 月线(市场状态): 仅当最后一根已完成的月线收盘价高于三个月前的收盘价时才建仓。

  • 周线(趋势): 仅当最后一根已完成的周线收盘价高于十周前的收盘价时才建仓,跌破时平仓。

  • 日线(择时): 在首根日线收盘价高于最后一根已完成的周线收盘价时建仓。

[4]:
def buy_with_trend(ctx):
    weekly = ctx.interval("weekly")
    monthly = ctx.interval("monthly")
    # Wait until enough completed weekly and monthly bars exist.
    if len(weekly.close) < 10 or len(monthly.close) < 4:
        return
    regime_up = monthly.close[-1] > monthly.close[-4]
    trend_up = weekly.close[-1] > weekly.close[-10]
    pos = ctx.long_pos()
    if not pos and regime_up and trend_up and ctx.close[-1] > weekly.close[-1]:
        ctx.buy_shares = 100
    elif pos and not trend_up:
        ctx.sell_all_shares()


strategy = Strategy(yfinance, start_date="1/1/2021", end_date="1/1/2026")
strategy.add_execution(
    buy_with_trend,
    ["AMD", "NVDA", "INTC"],
    intervals=["weekly", "monthly"],
)
result = strategy.backtest(timeframe="1d")
result.metrics_df.head(20)
Backtesting: 2021-01-01 00:00:00 to 2026-01-01 00:00:00

Loaded cached bar data.

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:00
[4]:
name value
0 trade_count 25
1 initial_market_value 100000.0
2 end_market_value 118433.0
3 total_pnl 18433.0
4 unrealized_pnl 0.0
5 total_return_pct 18.433
6 total_profit 28083.0
7 total_loss -9650.0
8 total_fees 0.0
9 max_drawdown -9555.0
10 max_drawdown_pct -7.797903
11 max_drawdown_date 2025-05-19 00:00:00
12 win_rate 60.0
13 loss_rate 40.0
14 winning_trades 15
15 losing_trades 10
16 avg_pnl 737.32
17 avg_return_pct 18.0996
18 avg_trade_bars 57.08
19 avg_profit 1872.2

将指标绑定到区间

要在压缩 K 线上计算指标,请使用 Indicator.intervals(…) 将其绑定到一个或多个区间。

下面的示例更新了周线趋势规则,将周线收盘价与基于周线计算的 10 根 K 线 SMA 进行比较:

[5]:
from pybroker.vect import sumv

sma_10 = pybroker.indicator("sma_10", lambda data: sumv(data.close, 10) / 10)


def buy_with_indicator(ctx):
    weekly = ctx.interval("weekly")
    monthly = ctx.interval("monthly")
    # Wait until enough completed weekly and monthly bars exist.
    if len(weekly.close) < 10 or len(monthly.close) < 4:
        return
    wk_sma = weekly.indicator("sma_10")
    regime_up = monthly.close[-1] > monthly.close[-4]
    trend_up = weekly.close[-1] > wk_sma[-1]
    pos = ctx.long_pos()
    if not pos and regime_up and trend_up and ctx.close[-1] > wk_sma[-1]:
        ctx.buy_shares = 100
    elif pos and not trend_up:
        ctx.sell_all_shares()


strategy = Strategy(yfinance, start_date="1/1/2021", end_date="1/1/2026")
strategy.add_execution(
    buy_with_indicator,
    ["AMD", "NVDA", "INTC"],
    indicators=sma_10.intervals("weekly"),
    intervals="monthly",
)

PyBroker 会自动将已绑定的区间与执行函数的 intervals 参数取并集,因此示例中的 "weekly" 区间可通过 ctx.interval(“weekly”) 访问。此时 intervals 参数只需指定 "monthly",用于提供原始的月线数据。

请注意,绑定会覆盖在数据源基础时间框架上计算该指标的行为。若要同时在数据源的基础时间框架上计算该指标,需向 Indicator.intervals() 传入 "base"

[6]:
result = strategy.backtest(timeframe="1d")
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% (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:00
[6]:
name value
0 trade_count 36
1 initial_market_value 100000.0
2 end_market_value 120278.0
3 total_pnl 20361.0
4 unrealized_pnl -83.0
5 total_return_pct 20.361
6 total_profit 31960.0
7 total_loss -11599.0
8 total_fees 0.0
9 max_drawdown -9555.0
10 max_drawdown_pct -7.398317
11 max_drawdown_date 2025-11-21 00:00:00
12 win_rate 47.222222
13 loss_rate 52.777778
14 winning_trades 17
15 losing_trades 19
16 avg_pnl 565.583333
17 avg_return_pct 10.874444
18 avg_trade_bars 38.972222
19 avg_profit 1880.0

在区间上训练模型

你可以用相同的方式,通过 ModelSource.intervals(…) 绑定模型。PyBroker 接着会使用该区间的压缩 K 线以及任何已注册的指标,为每个区间训练模型。随后,你可以在该区间的上下文上调用 preds 方法来获取该区间的预测结果。

本示例训练一个 LinearRegression 模型,根据周线收盘价预测下一周的收益率:

[7]:
from sklearn.linear_model import LinearRegression


def train_weekly(symbol, train_data, test_data):
    # Predict the next weekly return from the weekly close.
    returns = train_data["close"].pct_change().shift(-1)
    train_rows = train_data.assign(pred=returns).dropna()
    model = LinearRegression()
    model.fit(train_rows[["close"]], train_rows[["pred"]])
    return model, ["close"]


model_weekly = pybroker.model("weekly_slr", train_weekly)


def hold_with_model(ctx):
    preds = ctx.interval("weekly").preds("weekly_slr")
    if len(preds) == 0:
        return
    if not ctx.long_pos():
        if preds[-1] > 0:
            ctx.buy_shares = 100
    elif preds[-1] < 0:
        ctx.sell_all_shares()


strategy = Strategy(yfinance, start_date="1/1/2021", end_date="1/1/2026")
strategy.add_execution(
    hold_with_model,
    ["AMD", "NVDA", "INTC"],
    models=model_weekly.intervals("weekly"),
)

Walkforward Analysis 过程中,区间模型训练数据与测试数据之间的 lookahead 将以压缩 K 线为单位强制执行,以防止未来数据泄漏。

[8]:
result = strategy.walkforward(windows=3, train_size=0.5, timeframe="1d")
result.metrics_df.head(20)
Backtesting: 2021-01-01 00:00:00 to 2026-01-01 00:00:00

Loaded cached bar data.

Train split: 2021-01-07 00:00:00 to 2022-04-04 00:00:00
Finished training models: 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

Train split: 2022-04-05 00:00:00 to 2023-07-05 00:00:00
Finished training models: 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

Train split: 2023-07-06 00:00:00 to 2024-10-01 00:00:00
Finished training models: 0:00:00

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

Finished backtest: 0:00:00
[8]:
name value
0 trade_count 10
1 initial_market_value 100000.0
2 end_market_value 123627.0
3 total_pnl 23627.0
4 unrealized_pnl 0.0
5 total_return_pct 23.627
6 total_profit 25186.0
7 total_loss -1559.0
8 total_fees 0.0
9 max_drawdown -13776.0
10 max_drawdown_pct -12.158334
11 max_drawdown_date 2025-04-08 00:00:00
12 win_rate 90.0
13 loss_rate 10.0
14 winning_trades 9
15 losing_trades 1
16 avg_pnl 2362.7
17 avg_return_pct 44.464
18 avg_trade_bars 192.6
19 avg_profit 2798.444444