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 2 commits 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 2 commits 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)

epsilon_change is applied once to whichever right endpoint is chosen, so the closed-interval convention is identical on both paths. The final step 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 path is preserved exactly).

Motivation and Context

Related issues: closes #2278, closes #1063 (same IndexError: index 4132 is out of bounds for axis 0 with size 4132, same root cause; the old workaround was to set end_time before the last calendar date).

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. This makes such a backtest produce 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.

New regression tests live in tests/backtest/test_calendar_boundary.py and map to the acceptance criteria discussed on #2278:

Invariant Test
No IndexError at the final bar test_get_step_time_at_last_calendar_bar (real future calendar) / test_boundary_fallback_day
Non-boundary right endpoint stays epsilon_change(calendar[i+1]) test_non_boundary_step_unchanged
Pre-boundary steps byte-for-byte identical to the old peek test_pre_boundary_steps_identical_to_old_peek
Fallback covered for day and intraday (minute) test_boundary_fallback_day, test_boundary_fallback_minute
shift moves the effective boundary (shift=1 stays on the normal path) test_shift_moves_effective_boundary
trade_step=None / current-step path test_current_step_path
finished() correct — exactly trade_len steps, no extra iteration test_full_loop_finished_and_step_count

Each boundary-touching test was confirmed to fail with the original IndexError on the pre-fix code and to pass after the fix.

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 [ 12%]
backtest/test_calendar_boundary.py::TradeCalendarBoundaryTest::test_non_boundary_step_unchanged PASSED [ 25%]
backtest/test_calendar_boundary.py::TradeCalendarBoundaryUnitTest::test_boundary_fallback_day PASSED [ 37%]
backtest/test_calendar_boundary.py::TradeCalendarBoundaryUnitTest::test_boundary_fallback_minute PASSED [ 50%]
backtest/test_calendar_boundary.py::TradeCalendarBoundaryUnitTest::test_current_step_path PASSED [ 62%]
backtest/test_calendar_boundary.py::TradeCalendarBoundaryUnitTest::test_full_loop_finished_and_step_count PASSED [ 75%]
backtest/test_calendar_boundary.py::TradeCalendarBoundaryUnitTest::test_pre_boundary_steps_identical_to_old_peek PASSED [ 87%]
backtest/test_calendar_boundary.py::TradeCalendarBoundaryUnitTest::test_shift_moves_effective_boundary PASSED [100%]
============================== 8 passed in 3.70s ===============================

Types of changes

  • Fix bugs
  • Add new feature
  • Update documentation

zhaow-de added 2 commits June 21, 2026 12:22
…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.
…loop)

Expand the get_step_time boundary regression suite to the acceptance criteria
raised on the issue:
- day and minute frequencies at the final bar (same off-by-one at both granularities)
- shift, which moves the effective boundary (shift=1 stays on the normal peek path)
- the trade_step=None / current-step path
- finished()/full-loop behaviour: exactly trade_len steps, no extra iteration
- pre-boundary steps remain byte-for-byte identical to the old peek

Verified these fail with the original IndexError on the pre-fix code and pass after.
Also confirms the fix resolves the same-shape report in microsoft#1063.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant