Backtest IndexError at the right calendar boundary (get_step_time peeks calendar[index+1])
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
qlibdataset;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 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:
- 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)atutils.py:154), so the final step's interval is well-defined without a future bar. - And/or validate up front in
backtest()thatend_timeleaves 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=Truefallback 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.
Source: microsoft/qlib