Skip to content

fix: backtest IndexError at the right calendar boundary (get_step_time peeks calendar[index+1])#2279

Open
zhaow-de wants to merge 1 commit into
microsoft:mainfrom
zhaow-de:fix/backtest-calendar-right-boundary
Open

fix: backtest IndexError at the right calendar boundary (get_step_time peeks calendar[index+1])#2279
zhaow-de wants to merge 1 commit into
microsoft:mainfrom
zhaow-de:fix/backtest-calendar-right-boundary

Conversation

@zhaow-de

@zhaow-de zhaow-de commented Jun 21, 2026

Copy link
Copy Markdown

Description

TradeCalendarManager.get_step_time formed each step's right endpoint by peeking the next calendar bar (qlib/backtest/utils.py:131):

return self._calendar[calendar_index], epsilon_change(self._calendar[calendar_index + 1])

On the final step calendar_index == end_index. When end_time is the last bar of Cal.calendar(future=True) — which is exactly what happens when a dataset has no future calendar configured, since future=True then silently falls back to the current calendar — calendar_index + 1 indexes out of bounds and the backtest dies with an opaque IndexError: index N is out of bounds for axis 0 with size N, deep inside get_step_time and far from the user's backtest(end_time=...) call.

This PR clamps the right endpoint at the boundary: when no bar exists after the current one, it uses the end of the current bar's period (left + one freq unit), mirroring the day-end logic already used in get_data_cal_range (utils.py:154):

left = self._calendar[calendar_index]
if calendar_index + 1 < len(self._calendar):
    right = self._calendar[calendar_index + 1]
else:
    # No bar exists after this one (e.g. end_time is the last calendar bar and no
    # future calendar is configured). Fall back to the end of the current bar's period.
    right = left + Freq.get_timedelta(*Freq.parse(self.freq))
return left, epsilon_change(right)

The final step's interval stays a single bar (end - start = 1·freq - 1s < freq, so is_single_value remains true), and every non-final step is unchanged (the original peek-the-next-bar path is preserved exactly).

Motivation and Context

Related issue: closes #2278.

get_step_time is called on every backtest step, and the right-endpoint peek assumes there is always at least one bar after end_time. That assumption holds only when a future calendar extends past the data. For a self-built provider without a future calendar, Cal.calendar(future=True) warns and returns the current calendar, so any backtest whose end_time is the last calendar day crashes at the final step. The failure is easy to hit ("backtest through the end of my data") and hard to diagnose (an IndexError in get_step_time, not at the call site). This makes a backtest ending on the last available bar succeed with a well-defined final interval instead of crashing.

How Has This Been Tested?

  • Pass the test by running: pytest qlib/tests/test_all_pipeline.py under upper directory of qlib.
  • If you are adding a new feature, test on your own test scripts.

Added a regression test at tests/backtest/test_calendar_boundary.py:

  • test_get_step_time_at_last_calendar_barget_step_time at the last calendar bar now returns a well-defined single-bar interval. Fails before this change (raises IndexError), passes after.
  • test_non_boundary_step_unchanged — pins the non-boundary right endpoint to its original value (next_bar - 1s), proving the common path is unchanged.

Screenshots of Test Results (if appropriate):

  1. Pipeline test (tests/test_all_pipeline.py, full backtest exercising get_step_time on every step):
test_all_pipeline.py::TestAllFlow::test_0_train PASSED                   [ 33%]
test_all_pipeline.py::TestAllFlow::test_1_backtest PASSED                [ 66%]
test_all_pipeline.py::TestAllFlow::test_2_expmanager PASSED              [100%]
================== 3 passed, 3 warnings in 294.11s (0:04:54) ===================
  1. Your own tests (tests/backtest/test_calendar_boundary.py):
backtest/test_calendar_boundary.py::TradeCalendarBoundaryTest::test_get_step_time_at_last_calendar_bar PASSED [ 50%]
backtest/test_calendar_boundary.py::TradeCalendarBoundaryTest::test_non_boundary_step_unchanged PASSED [100%]
============================== 2 passed in 3.74s ===============================

Types of changes

  • Fix bugs

…t calendar bar

get_step_time formed each step's right endpoint by peeking the next calendar bar
(`self._calendar[calendar_index + 1]`). On the final step `calendar_index == end_index`;
when `end_time` is the last bar of the (future) calendar — e.g. a self-built dataset with
no future calendar, where `Cal.calendar(future=True)` falls back to the current calendar —
that peek indexed out of bounds and the backtest died with an opaque
`IndexError: index N is out of bounds for axis 0 with size N` deep inside get_step_time.

Clamp the right endpoint at the boundary: when no next bar exists, use the end of the
current bar's period (`left + one freq unit`), mirroring the day-end logic already used in
get_data_cal_range. The non-boundary path is unchanged.

Add a regression test asserting get_step_time at the last calendar bar returns a
well-defined single-bar interval instead of raising.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant