🐛 Bug Description
TradeCalendarManager.get_step_time returns each trading step's interval as
(calendar[i], epsilon_change(calendar[i + 1])) — it reads the next bar to form the right
endpoint:
# qlib/backtest/utils.py:130-131 (TradeCalendarManager.get_step_time)
calendar_index = self.start_index + trade_step - shift
return self._calendar[calendar_index], epsilon_change(self._calendar[calendar_index + 1])
For the last trading step, calendar_index == self.end_index. If end_time is the dataset's
final calendar bar (end_index == len(self._calendar) - 1), then calendar_index + 1 is out
of bounds and the backtest dies with an opaque:
IndexError: index <N> is out of bounds for axis 0 with size <N>
File ".../qlib/backtest/utils.py", line 131, in get_step_time
return self._calendar[calendar_index], epsilon_change(self._calendar[calendar_index + 1])
File ".../qlib/backtest/backtest.py", in collect_data_loop
_trade_decision = trade_strategy.generate_trade_decision(_execute_result)
File ".../qlib/contrib/strategy/signal_strategy.py", in generate_trade_decision
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
The index N out of bounds for size N form is the tell: the step needs calendar[end_index + 1]
but the calendar has no bar after end_time.
Root cause
The right-endpoint peek assumes there is always at least one bar after end_time. The
calendar is built with future=True:
# qlib/backtest/utils.py:69 (TradeCalendarManager.reset)
_calendar = Cal.calendar(freq=freq, future=True)
future=True is meant to supply a calendar that extends beyond the data so the +1 peek is safe.
But when a dataset has no future calendar configured (the common case for a self-built provider),
future=True silently falls back to the current calendar with only a warning:
WARNING qlib.data [data.py] - load calendar error: freq=day, future=True; return current calendar!
WARNING qlib.data [data.py] - You can get future calendar by referring to ...
So there is no bar after the last data day, and any backtest whose end_time is that last day
crashes at the final step. The failure is opaque (an IndexError deep in get_step_time, far from
the user's backtest(end_time=...) call) and easy to hit (it's simply "backtest through the end of
my data").
To Reproduce
- Use a provider whose calendar has no future extension (a self-built
qlib dataset; Cal.calendar(future=True) then warns and returns the current calendar).
- Call
qlib.backtest.backtest(start_time=..., end_time=<the last calendar day>, strategy=..., executor={"class": "SimulatorExecutor", ...}).
- The run dies at the final step with
IndexError: index N is out of bounds for axis 0 with size N in TradeCalendarManager.get_step_time (utils.py:131).
(Ending one or more bars before the last calendar day does not crash, because calendar[index+1] then exists.)
Expected Behavior
A backtest ending on the last available calendar bar should either succeed or fail with a clear,
actionable message — not an opaque IndexError. Options:
- In
get_step_time, clamp the right endpoint at the boundary, e.g.
right = epsilon_change(self._calendar[i + 1]) if i + 1 < len(self._calendar) else <end-of-last-bar>
(mirroring the day-end logic already used elsewhere, e.g. day_start + pd.Timedelta(days=1) at
utils.py:154), so the final step's interval is well-defined without a future bar.
- And/or validate up front in
backtest() that end_time leaves at least one trailing bar when no
future calendar is available, raising a clear error (or auto-clamping) instead of crashing mid-loop.
- And/or promote the
future=True fallback warning to a clearer, louder signal that the right
boundary is unsafe for backtesting through the data end.
Environment
- Qlib version: 0.9.7 (
qlib/backtest/utils.py:131, TradeCalendarManager.get_step_time)
- Python version: 3.12
- freq:
day; provider: a self-built daily dataset with no future calendar.
Additional context
Discovered while building an out-of-sample walk-forward harness whose last window's end_time was
the dataset's final calendar day. Worked around it by capping end_time a couple of bars before
the calendar end (so calendar[index+1] always exists). A boundary-safe get_step_time (or an
upfront check/clamp in backtest) would remove the need for that workaround and turn a cryptic
IndexError into correct behavior or a clear error.
🐛 Bug Description
TradeCalendarManager.get_step_timereturns each trading step's interval as(calendar[i], epsilon_change(calendar[i + 1]))— it reads the next bar to form the rightendpoint:
For the last trading step,
calendar_index == self.end_index. Ifend_timeis the dataset'sfinal calendar bar (
end_index == len(self._calendar) - 1), thencalendar_index + 1is outof bounds and the backtest dies with an opaque:
The
index N out of bounds for size Nform is the tell: the step needscalendar[end_index + 1]but the calendar has no bar after
end_time.Root cause
The right-endpoint peek assumes there is always at least one bar after
end_time. Thecalendar is built with
future=True:future=Trueis meant to supply a calendar that extends beyond the data so the+1peek is safe.But when a dataset has no future calendar configured (the common case for a self-built provider),
future=Truesilently falls back to the current calendar with only a warning:So there is no bar after the last data day, and any backtest whose
end_timeis that last daycrashes at the final step. The failure is opaque (an
IndexErrordeep inget_step_time, far fromthe user's
backtest(end_time=...)call) and easy to hit (it's simply "backtest through the end ofmy data").
To Reproduce
qlibdataset;Cal.calendar(future=True)then warns and returns the current calendar).qlib.backtest.backtest(start_time=..., end_time=<the last calendar day>, strategy=..., executor={"class": "SimulatorExecutor", ...}).IndexError: index N is out of bounds for axis 0 with size NinTradeCalendarManager.get_step_time(utils.py:131).(Ending one or more bars before the last calendar day does not crash, because
calendar[index+1]then exists.)Expected Behavior
A backtest ending on the last available calendar bar should either succeed or fail with a clear,
actionable message — not an opaque
IndexError. Options:get_step_time, clamp the right endpoint at the boundary, e.g.right = epsilon_change(self._calendar[i + 1]) if i + 1 < len(self._calendar) else <end-of-last-bar>(mirroring the day-end logic already used elsewhere, e.g.
day_start + pd.Timedelta(days=1)atutils.py:154), so the final step's interval is well-defined without a future bar.backtest()thatend_timeleaves at least one trailing bar when nofuture calendar is available, raising a clear error (or auto-clamping) instead of crashing mid-loop.
future=Truefallback warning to a clearer, louder signal that the rightboundary is unsafe for backtesting through the data end.
Environment
qlib/backtest/utils.py:131,TradeCalendarManager.get_step_time)day; provider: a self-built daily dataset with no future calendar.Additional context
Discovered while building an out-of-sample walk-forward harness whose last window's
end_timewasthe dataset's final calendar day. Worked around it by capping
end_timea couple of bars beforethe calendar end (so
calendar[index+1]always exists). A boundary-safeget_step_time(or anupfront check/clamp in
backtest) would remove the need for that workaround and turn a crypticIndexErrorinto correct behavior or a clear error.