Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions qlib/backtest/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
"""
Expand Down
60 changes: 60 additions & 0 deletions tests/backtest/test_calendar_boundary.py
Original file line number Diff line number Diff line change
@@ -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()