From 3af0bc7abf0d7289740b874af9cef1bbc4190e25 Mon Sep 17 00:00:00 2001 From: Zhao Wang Date: Sun, 21 Jun 2026 12:22:35 +0200 Subject: [PATCH] fix: make TradeCalendarManager.get_step_time boundary-safe at the last calendar bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- qlib/backtest/utils.py | 13 ++++- tests/backtest/test_calendar_boundary.py | 60 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 tests/backtest/test_calendar_boundary.py diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 4210c9548a8..18fde070aad 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -8,7 +8,7 @@ import numpy as np -from qlib.utils.time import epsilon_change +from qlib.utils.time import epsilon_change, Freq if TYPE_CHECKING: from qlib.backtest.decision import BaseTradeDecision @@ -128,7 +128,16 @@ def get_step_time(self, trade_step: int | None = None, shift: int = 0) -> Tuple[ if trade_step is None: trade_step = self.get_trade_step() calendar_index = self.start_index + trade_step - shift - return self._calendar[calendar_index], epsilon_change(self._calendar[calendar_index + 1]) + 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 so the final step's interval stays well-defined instead of indexing + # out of bounds. + right = left + Freq.get_timedelta(*Freq.parse(self.freq)) + return left, epsilon_change(right) def get_data_cal_range(self, rtype: str = "full") -> Tuple[int, int]: """ diff --git a/tests/backtest/test_calendar_boundary.py b/tests/backtest/test_calendar_boundary.py new file mode 100644 index 00000000000..ca2109ab408 --- /dev/null +++ b/tests/backtest/test_calendar_boundary.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Regression test for TradeCalendarManager.get_step_time at the right calendar boundary. + +``get_step_time`` forms each step's right endpoint by peeking the *next* calendar bar +(``self._calendar[calendar_index + 1]``). On the final step, ``calendar_index`` equals +``end_index``; if ``end_time`` is the last bar of the (future) calendar, that peek indexes +out of bounds and the backtest dies with an opaque ``IndexError`` deep inside +``get_step_time`` (see ``qlib/backtest/utils.py``). A backtest that ends on the last +available calendar bar must instead produce a well-defined final interval. +""" + +import unittest + +import pandas as pd + +from qlib.data import D +from qlib.backtest.utils import TradeCalendarManager +from qlib.tests import TestAutoData + + +class TradeCalendarBoundaryTest(TestAutoData): + def test_get_step_time_at_last_calendar_bar(self): + """The final step must not overflow when end_time is the last calendar bar.""" + cal = D.calendar(future=True, freq="day") + last = pd.Timestamp(cal[-1]) + prev = pd.Timestamp(cal[-2]) + + tcm = TradeCalendarManager(freq="day", start_time=prev, end_time=last) + last_step = tcm.get_trade_len() - 1 + + # Before the fix this raises: IndexError: index N is out of bounds for axis 0 with size N + start, end = tcm.get_step_time(last_step) + + # Left endpoint is the last bar itself. + self.assertEqual(start, last) + # Right endpoint is well-defined and stays within the last bar's period (a single bar): + # start < end < start + 1 day. + self.assertGreater(end, start) + self.assertLess(end, last + pd.Timedelta(days=1)) + + def test_non_boundary_step_unchanged(self): + """A step that is not at the boundary keeps the original peek-the-next-bar behaviour.""" + cal = D.calendar(future=True, freq="day") + # End two bars before the calendar end so calendar[index + 1] still exists. + end = pd.Timestamp(cal[-3]) + start = pd.Timestamp(cal[-5]) + + tcm = TradeCalendarManager(freq="day", start_time=start, end_time=end) + last_step = tcm.get_trade_len() - 1 + _, right = tcm.get_step_time(last_step) + + # Original behaviour: right endpoint is epsilon before the next real calendar bar. + next_bar = pd.Timestamp(cal[-2]) + self.assertEqual(right, next_bar - pd.Timedelta(seconds=1)) + + +if __name__ == "__main__": + unittest.main()