Skip to content

Backtest IndexError at the right calendar boundary (get_step_time peeks calendar[index+1]) #2278

Description

@zhaow-de

🐛 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

  1. 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).
  2. Call qlib.backtest.backtest(start_time=..., end_time=<the last calendar day>, strategy=..., executor={"class": "SimulatorExecutor", ...}).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions