Multiple Time Intervals

You may want to make trading decisions on a different timeframe than your underlying data source. For instance, you might choose to execute trades on daily bars after confirming the trend on weekly or monthly bars. PyBroker v2 supports compressing backtest data into longer intervals and making those compressed bars available to your strategy.

Interval Types

You can define an interval using any of these three formats:

  • Every-n-bars (int greater than 1): Compresses every n base bars into one bar. Using 5 on daily data produces one bar per five trading days.

  • Duration (str): A fixed time span written as digits followed by a single unit letter (s, m, h, or d). Passing "5m" compresses 1-minute bars into 5-minute bars.

  • Calendar (str): Aligns compressed bars to calendar boundaries using one of the following options:

Calendar String

Boundary Alignment

"daily"

Standard daily boundary.

"weekly"

Starts on Monday.

"monthly"

Starts on the 1st of the month.

"quarterly"

Begins in January, April, July, and October.

"yearly"

Starts on January 1.

Your chosen interval must always be longer than the bars being compressed. For example, if you fetch daily bars from YFinance, then "weekly" and "monthly" are valid intervals. Attempting to use "daily" or "1h" will raise a ValueError.

Before using intervals in a strategy, let’s build some intuition by compressing bars directly. We will start by downloading daily data:

[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

Compressing Bars

The compress_bars function converts OHLCV data (either a Pandas DataFrame or BarData) to a longer interval, returning the result as a new BarData object. Every compressed bar is timestamped with the date of the last base bar it contains.

When grouping base bars into a compressed bar, the data is aggregated as follows:

  • Open: Taken from the first base bar.

  • High / Low: The highest high and lowest low.

  • Close: Taken from the last base bar.

  • Volume: The sum of the volumes.

  • VWAP: The volume-weighted average.

  • Custom columns: The last value in the period (e.g., YFinance’s adj_close).

You must also supply the base_timeframe parameter to declare the spacing of your input bars (for example, "1d" for daily data).

Let’s compress AMD into calendar weeks and view the result as a Pandas DataFrame with bars_to_df:

[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

Every-n-bars compression works the same way. In this example, every 5 daily bars become one bar:

[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

A Multi-Timeframe Strategy

To use higher timeframes in your backtest, pass the intervals parameter to add_execution. Your execution function can then access the compressed bars through ctx.interval, which returns a read-only IntervalContext.

Using the intervals parameter provides compressed bars only. Indicators and models are never computed on these intervals unless you bind them explicitly, as shown later in this notebook.

To prevent look-ahead bias, ctx.interval only ever exposes completed bars. For example, the week or month that is currently forming is never visible, ensuring that future data cannot leak into your daily trading decisions.

In the following strategy, we will execute trades on daily bars while using longer intervals to generate different trading signals:

  • Monthly (Regime): Only enter when the last completed monthly close is higher than the close from three months ago.

  • Weekly (Trend): Only enter when the last completed weekly close is higher than the close from ten weeks ago, and exit when it falls below.

  • Daily (Timing): Enter on the first daily close above the last completed weekly close.

[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

Binding an Indicator to an Interval

To compute an indicator on compressed bars, bind it to one or more intervals with Indicator.intervals(…).

The example below updates the weekly trend rule to compare the weekly close against a 10-bar SMA calculated from the weekly bars:

[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 automatically combines bound intervals with those in the execution’s intervals parameter. Here, the "weekly" interval is made accessible via ctx.interval(“weekly”) and the intervals parameter only needs to specify "monthly" for the raw monthly bars.

Note that the binding will override also computing the indicator on the base timeframe of the data source. To also compute the indicator on the base timeframe of the data source, pass "base" to Indicator.intervals().

[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

Training a Model on an Interval

You can bind models in the same way with ModelSource.intervals(…). PyBroker will then train models for each interval using the interval’s compressed bars and any registered indicators. You can then access the per-interval predictions by calling the preds method on that interval’s context.

This example trains a LinearRegression model to predict the next weekly return from the weekly close:

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

During Walkforward Analysis, the lookahead between an interval model’s train and test data is enforced using the compressed bar units in order to prevent future leakage.

[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