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 (
intgreater than1): Compresses everynbase bars into one bar. Using5on 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, ord). 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 |
|---|---|
|
Standard daily boundary. |
|
Starts on Monday. |
|
Starts on the 1st of the month. |
|
Begins in January, April, July, and October. |
|
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 |