From f20fb0ca8587d98de51099c1ee6228376043eba0 Mon Sep 17 00:00:00 2001 From: Vedant Date: Sun, 19 Jul 2026 17:15:12 +0530 Subject: [PATCH 1/2] fix: guard calendar[index+1] peek in get_step_time for last bar (#2278) When end_time falls on the final calendar entry and no future calendar is available, calendar_index + 1 is out of bounds, crashing the backtest with an opaque IndexError. Guard the peek: if calendar_index + 1 < len(self._calendar) use the next bar as before; otherwise compute the right endpoint as left + one freq-period, mirroring the day_start + Timedelta(days=1) idiom already used in get_data_cal_range. epsilon_change is applied to whichever right is chosen, so the closed-interval convention is preserved on both paths. Added tests/backtest/test_calendar_boundary.py covering: - last-bar step does not raise - day and minute fallback values - pre-boundary steps are unchanged - full loop terminates with the correct step count --- qlib/backtest/utils.py | 11 +- tests/backtest/test_calendar_boundary.py | 168 +++++++++++++++++++++++ 2 files changed, 177 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..bab569259ed 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,14 @@ 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 we're at the very last bar, there's no next entry to peek at, so + # derive the right endpoint from the current bar's own frequency instead + if calendar_index + 1 < len(self._calendar): + right = self._calendar[calendar_index + 1] + else: + 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..e1f597df5f9 --- /dev/null +++ b/tests/backtest/test_calendar_boundary.py @@ -0,0 +1,168 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Tests for the calendar right-boundary fix in TradeCalendarManager.get_step_time. + +Before the fix, calling get_step_time on the very last trading step raised: + IndexError: index N is out of bounds for axis 0 with size N +because it always tried to read calendar[index + 1] even when index was the +last position. + +These tests are intentionally self-contained: they load only qlib/utils/time.py +and qlib/backtest/utils.py via importlib so the test can run without a full +qlib installation or qlib/__init__.py side-effects. +""" + +import importlib.util +import sys +import types +import unittest +from pathlib import Path + +import numpy as np +import pandas as pd + +# --------------------------------------------------------------------------- +# Import the two modules we care about without triggering qlib/__init__.py +# --------------------------------------------------------------------------- + +QLIB_ROOT = Path(__file__).resolve().parents[2] / "qlib" + + +def _load_module(rel_path: str, name: str): + """Load a single .py file as a module, bypassing package __init__.""" + spec = importlib.util.spec_from_file_location(name, QLIB_ROOT / rel_path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +# qlib.utils.time has no heavy deps — load it directly +time_mod = _load_module("utils/time.py", "qlib.utils.time") +epsilon_change = time_mod.epsilon_change +Freq = time_mod.Freq + +# backtest/utils.py imports from qlib.utils.time and qlib.data.data (Cal). +# We stub out qlib.data.data so only the parts we test are exercised. +_fake_qlib_data = types.ModuleType("qlib.data.data") +_fake_qlib_data.Cal = None # Cal is not used by get_step_time +sys.modules.setdefault("qlib.data", types.ModuleType("qlib.data")) +sys.modules["qlib.data.data"] = _fake_qlib_data + +# backtest/decision is only imported under TYPE_CHECKING — safe to stub +_fake_decision = types.ModuleType("qlib.backtest.decision") +_fake_decision.BaseTradeDecision = object +sys.modules.setdefault("qlib.backtest", types.ModuleType("qlib.backtest")) +sys.modules["qlib.backtest.decision"] = _fake_decision + +utils_mod = _load_module("backtest/utils.py", "qlib.backtest.utils") +TradeCalendarManager = utils_mod.TradeCalendarManager + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_manager(dates: list, start: str, end: str, freq: str = "day") -> TradeCalendarManager: + """Build a TradeCalendarManager backed by a hand-crafted numpy calendar. + + qlib stores calendars as object-dtype numpy arrays of pd.Timestamp values, + so we match that exactly here. + """ + # object-dtype array of Timestamps — same as what Cal.calendar() returns + cal = np.array([pd.Timestamp(d) for d in dates], dtype=object) + start_ts = pd.Timestamp(start) + end_ts = pd.Timestamp(end) + start_idx = int(np.searchsorted(cal, start_ts)) + end_idx = int(np.searchsorted(cal, end_ts)) + + mgr = object.__new__(TradeCalendarManager) + mgr.freq = freq + mgr.start_time = start_ts + mgr.end_time = end_ts + mgr._calendar = cal + mgr.start_index = start_idx + mgr.end_index = end_idx + mgr.trade_len = end_idx - start_idx + 1 + mgr.trade_step = 0 + mgr.level_infra = None + return mgr + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestCalendarBoundary(unittest.TestCase): + # three trading days, no future extension + DATES = [ + pd.Timestamp("2024-01-02"), + pd.Timestamp("2024-01-03"), + pd.Timestamp("2024-01-04"), + ] + + def _mgr(self, start="2024-01-02", end="2024-01-04", freq="day"): + return _make_manager(self.DATES, start, end, freq=freq) + + def test_non_boundary_step_unchanged(self): + """Steps that aren't at the calendar end keep the original epsilon_change(next_bar) logic.""" + mgr = self._mgr() + left, right = mgr.get_step_time(trade_step=0) + self.assertEqual(left, self.DATES[0]) + self.assertEqual(right, epsilon_change(self.DATES[1])) + + def test_boundary_step_does_not_raise(self): + """get_step_time on the very last bar must not raise IndexError.""" + mgr = self._mgr() + last_step = mgr.trade_len - 1 # calendar_index == len(calendar)-1 + try: + mgr.get_step_time(trade_step=last_step) + except IndexError as exc: + self.fail(f"get_step_time raised IndexError on the last bar: {exc}") + + def test_boundary_fallback_day(self): + """Fallback right = left + 1 day, epsilon_change applied.""" + mgr = self._mgr() + last_step = mgr.trade_len - 1 + left, right = mgr.get_step_time(trade_step=last_step) + expected_right = epsilon_change(self.DATES[2] + Freq.get_timedelta(*Freq.parse("day"))) + self.assertEqual(left, self.DATES[2]) + self.assertEqual(right, expected_right) + + def test_boundary_fallback_minute(self): + """Same fallback logic works for minute-frequency calendars.""" + base = pd.Timestamp("2024-01-02 09:30") + dates = [base + pd.Timedelta(minutes=i) for i in range(3)] + mgr = _make_manager(dates, str(dates[0]), str(dates[2]), freq="1min") + last_step = mgr.trade_len - 1 + left, right = mgr.get_step_time(trade_step=last_step) + expected_right = epsilon_change(dates[2] + Freq.get_timedelta(*Freq.parse("1min"))) + self.assertEqual(left, dates[2]) + self.assertEqual(right, expected_right) + + def test_pre_boundary_steps_identical_to_old_peek(self): + """Every step except the last returns the same result as the original calendar[i+1] peek.""" + mgr = self._mgr() + cal = mgr._calendar + for step in range(mgr.trade_len - 1): + idx = mgr.start_index + step + left, right = mgr.get_step_time(trade_step=step) + self.assertEqual(left, cal[idx]) + self.assertEqual(right, epsilon_change(cal[idx + 1])) + + def test_full_loop_step_count(self): + """Loop through all steps — exactly trade_len iterations, finished() True at end.""" + mgr = self._mgr() + count = 0 + while not mgr.finished(): + mgr.get_step_time() # must not raise + mgr.step() + count += 1 + self.assertEqual(count, mgr.trade_len) + self.assertTrue(mgr.finished()) + + +if __name__ == "__main__": + unittest.main() From c320b081686802c682c9cc0010673d11a605b12e Mon Sep 17 00:00:00 2001 From: Vedant Date: Fri, 24 Jul 2026 01:23:30 +0530 Subject: [PATCH 2/2] fix: replace bare assert with descriptive ValueError for RL time-index alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1972 ## Problem In , two bare statements guarded the requirement that and must each be divisible by : assert data_config["source"]["default_start_time_index"] % data_granularity == 0 assert data_config["source"]["default_end_time_index"] % data_granularity == 0 When a user supplied a misaligned value (e.g. with ), they received a bare with no message, making it impossible to understand what went wrong or how to fix it. ## Changes ### qlib/rl/contrib/_utils.py (new) Extracts the validation logic into a standalone function that lives in a module with **zero** heavy dependencies (no tianshou, torch, gym). This allows the function to be unit-tested in any CI environment regardless of which optional packages are installed. ### qlib/rl/contrib/train_onpolicy.py - Imports from and calls it instead of the bare asserts. - Adds a comprehensive NumPy-style docstring to that explicitly documents the alignment constraint with examples, a block, and a section. ### docs/component/rl/quickstart.rst - Fixes two wrong config key names in the example YAML ( → , → , → ). - Adds a admonition explaining the alignment rule and providing concrete examples for different granularities. - Fixes the invocation commands (removed spurious suffix). ### tests/rl/test_train_onpolicy_alignment.py (new) 14 unit tests covering valid and invalid configurations. Tests verify that: - Valid index/granularity pairs do not raise. - Misaligned indices raise (not ). - Error messages contain the actual values, the modulo remainder, and a suggested correction. - errors are reported before errors when both are wrong. --- docs/component/rl/quickstart.rst | 27 +++-- qlib/rl/contrib/_utils.py | 78 +++++++++++++ qlib/rl/contrib/train_onpolicy.py | 65 ++++++++++- tests/rl/__init__.py | 0 tests/rl/test_train_onpolicy_alignment.py | 127 ++++++++++++++++++++++ 5 files changed, 288 insertions(+), 9 deletions(-) create mode 100644 qlib/rl/contrib/_utils.py create mode 100644 tests/rl/__init__.py create mode 100644 tests/rl/test_train_onpolicy_alignment.py diff --git a/docs/component/rl/quickstart.rst b/docs/component/rl/quickstart.rst index 5e98e3baff6..7813b2624b9 100644 --- a/docs/component/rl/quickstart.rst +++ b/docs/component/rl/quickstart.rst @@ -50,13 +50,13 @@ QlibRL provides an example of an implementation of a single asset order executio data: source: order_dir: ./data/training_order_split - data_dir: ./data/pickle_dataframe/backtest + feature_root_dir: ./data/pickle_dataframe/backtest # number of time indexes total_time: 240 - # start time index - default_start_time: 0 - # end time index - default_end_time: 240 + # start time index (must be divisible by simulator.data_granularity) + default_start_time_index: 0 + # end time index (must be divisible by simulator.data_granularity) + default_end_time_index: 240 proc_data_dim: 6 num_workers: 0 queue_size: 20 @@ -84,6 +84,19 @@ QlibRL provides an example of an implementation of a single asset order executio checkpoint_path: ./checkpoints checkpoint_every_n_iters: 1 +.. warning:: + + ``data_config["source"]["default_start_time_index"]`` and + ``data_config["source"]["default_end_time_index"]`` **must both be + divisible by** ``simulator.data_granularity``. This is because the RL + environment slices each trading day into uniform windows of + ``data_granularity`` ticks, so misaligned indices would corrupt the step + boundaries and raise a ``ValueError`` at startup. + + For example, if ``simulator.data_granularity: 5``, valid values are + ``0, 5, 10, 15, …``. The safest starting point is always + ``default_start_time_index: 0``. + And the config file for backtesting: @@ -160,13 +173,13 @@ With the above config files, you can start training the agent by the following c .. code-block:: console - $ python -m qlib.rl.contrib.train_onpolicy.py --config_path train_config.yml + $ python -m qlib.rl.contrib.train_onpolicy --config_path train_config.yml After the training, you can backtest with the following command: .. code-block:: console - $ python -m qlib.rl.contrib.backtest.py --config_path backtest_config.yml + $ python -m qlib.rl.contrib.backtest --config_path backtest_config.yml In that case, :class:`~qlib.rl.order_execution.simulator_qlib.SingleAssetOrderExecution` and :class:`~qlib.rl.order_execution.simulator_simple.SingleAssetOrderExecutionSimple` as examples for simulator, :class:`qlib.rl.order_execution.interpreter.FullHistoryStateInterpreter` and :class:`qlib.rl.order_execution.interpreter.CategoricalActionInterpreter` as examples for interpreter, :class:`qlib.rl.order_execution.policy.PPO` as an example for policy, and :class:`qlib.rl.order_execution.reward.PAPenaltyReward` as an example for reward. For the single asset order execution task, if developers have already defined their simulator/interpreters/reward function/policy, they could launch the training and backtest pipeline by simply modifying the corresponding settings in the config files. diff --git a/qlib/rl/contrib/_utils.py b/qlib/rl/contrib/_utils.py new file mode 100644 index 00000000000..1900aa15706 --- /dev/null +++ b/qlib/rl/contrib/_utils.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Lightweight validation utilities for RL training configuration. + +This module intentionally has **zero** heavy dependencies (no torch, tianshou, +gym, etc.) so that the helpers here can be imported and unit-tested in any +environment, including lightweight CI images. +""" + +from __future__ import annotations + + +def validate_time_alignment( + default_start_time_index: int, + default_end_time_index: int, + data_granularity: int, +) -> None: + """Validate that start/end time indices are aligned to ``data_granularity``. + + Both ``default_start_time_index`` and ``default_end_time_index`` must be + exact multiples of ``data_granularity``. This is required because the RL + environment slices each trading day into uniform windows of + ``data_granularity`` ticks; a misaligned start/end index would silently + corrupt step boundaries. + + Parameters + ---------- + default_start_time_index : int + The starting tick index, taken from + ``data_config["source"]["default_start_time_index"]``. + default_end_time_index : int + The ending tick index (exclusive), taken from + ``data_config["source"]["default_end_time_index"]``. + data_granularity : int + Number of raw ticks grouped into a single data point (from + ``simulator_config["data_granularity"]``). + + Raises + ------ + ValueError + If either index is not divisible by ``data_granularity``. + + Examples + -------- + Valid – 0 and 240 are both multiples of 5: + + >>> validate_time_alignment(0, 240, 5) + + Invalid – 3 is not a multiple of 5: + + >>> validate_time_alignment(3, 240, 5) # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + ValueError: data_config['source']['default_start_time_index'] (=3) ... + """ + if default_start_time_index % data_granularity != 0: + _suggested = (default_start_time_index // data_granularity) * data_granularity + raise ValueError( + f"data_config['source']['default_start_time_index'] " + f"(={default_start_time_index}) must be divisible by " + f"data_granularity (={data_granularity}), but " + f"{default_start_time_index} % {data_granularity} = " + f"{default_start_time_index % data_granularity}. " + f"Hint: set default_start_time_index to a multiple of " + f"{data_granularity} (e.g. {_suggested})." + ) + + if default_end_time_index % data_granularity != 0: + _suggested_end = (default_end_time_index // data_granularity) * data_granularity + raise ValueError( + f"data_config['source']['default_end_time_index'] " + f"(={default_end_time_index}) must be divisible by " + f"data_granularity (={data_granularity}), but " + f"{default_end_time_index} % {data_granularity} = " + f"{default_end_time_index % data_granularity}. " + f"Hint: set default_end_time_index to a multiple of " + f"{data_granularity} (e.g. {_suggested_end})." + ) diff --git a/qlib/rl/contrib/train_onpolicy.py b/qlib/rl/contrib/train_onpolicy.py index 83dd924103f..419106899e2 100644 --- a/qlib/rl/contrib/train_onpolicy.py +++ b/qlib/rl/contrib/train_onpolicy.py @@ -96,6 +96,8 @@ def __getitem__(self, index: int) -> Order: return order +from qlib.rl.contrib._utils import validate_time_alignment as _validate_time_alignment + def train_and_test( env_config: dict, @@ -109,6 +111,58 @@ def train_and_test( run_training: bool, run_backtest: bool, ) -> None: + """Run the training and/or backtest pipeline for a single-asset order execution RL task. + + Parameters + ---------- + env_config : dict + Environment configuration (``concurrency``, ``parallel_mode``, etc.). + simulator_config : dict + Simulator configuration including ``time_per_step``, ``vol_limit``, and + optionally ``data_granularity`` (default ``1``). ``data_granularity`` + controls how many raw ticks are grouped into a single data point fed to + the agent. + trainer_config : dict + Trainer configuration (``max_epoch``, ``batch_size``, etc.). + data_config : dict + Data source configuration. The sub-key ``data_config["source"]`` must + contain at minimum: + + - ``order_dir`` – path to the directory holding order pickle files. + - ``feature_root_dir`` – path to the feature data directory. + - ``default_start_time_index`` – the starting tick index of the trading + window. **Must be divisible by** ``data_granularity``. + - ``default_end_time_index`` – the ending tick index of the trading + window (exclusive). **Must be divisible by** ``data_granularity``. + + .. note:: + Both ``default_start_time_index`` and ``default_end_time_index`` + must be exact multiples of ``data_granularity``. This is required + because the RL environment slices each trading day into uniform + windows of ``data_granularity`` ticks, so any misaligned start/end + index would silently corrupt the step boundaries. A safe default + is ``default_start_time_index: 0``. For example, if + ``data_granularity`` is ``5``, valid values are ``0, 5, 10, …``. + + state_interpreter : StateInterpreter + Interpreter that converts raw simulator state to an RL observation. + action_interpreter : ActionInterpreter + Interpreter that converts RL action to a simulator action. + policy : BasePolicy + The tianshou policy to train or evaluate. + reward : Reward + Reward function. + run_training : bool + Whether to run the training loop. + run_backtest : bool + Whether to run the backtest loop. + + Raises + ------ + ValueError + If ``default_start_time_index`` or ``default_end_time_index`` is not + divisible by ``data_granularity``. + """ order_root_path = Path(data_config["source"]["order_dir"]) data_granularity = simulator_config.get("data_granularity", 1) @@ -124,8 +178,15 @@ def _simulator_factory_simple(order: Order) -> SingleAssetOrderExecutionSimple: vol_threshold=simulator_config["vol_limit"], ) - assert data_config["source"]["default_start_time_index"] % data_granularity == 0 - assert data_config["source"]["default_end_time_index"] % data_granularity == 0 + _start = data_config["source"]["default_start_time_index"] + _end = data_config["source"]["default_end_time_index"] + + _validate_time_alignment( + default_start_time_index=_start, + default_end_time_index=_end, + data_granularity=data_granularity, + ) + if run_training: train_dataset, valid_dataset = [ diff --git a/tests/rl/__init__.py b/tests/rl/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/rl/test_train_onpolicy_alignment.py b/tests/rl/test_train_onpolicy_alignment.py new file mode 100644 index 00000000000..6f21a03ace9 --- /dev/null +++ b/tests/rl/test_train_onpolicy_alignment.py @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Tests for data-alignment validation in qlib.rl.contrib.train_onpolicy. + +Regression test for GitHub issue #1972: + https://github.com/microsoft/qlib/issues/1972 + +Previously, misaligned ``default_start_time_index`` / ``default_end_time_index`` +values triggered a bare ``AssertionError`` with no message. The fix extracts +the validation into ``_validate_time_alignment`` and raises a descriptive +``ValueError`` so that users immediately understand what went wrong and how to +correct their config. + +These tests import only ``_validate_time_alignment``, which is a plain-Python +function with zero heavy dependencies, so they run without tianshou/torch/gym +being installed. +""" + +import pytest + +# --------------------------------------------------------------------------- +# Import the standalone validation helper directly from the lightweight +# _utils module, which has **zero** heavy dependencies (no torch/tianshou/gym). +# --------------------------------------------------------------------------- +from qlib.rl.contrib._utils import validate_time_alignment + + +# --------------------------------------------------------------------------- +# Tests: valid configurations should NOT raise +# --------------------------------------------------------------------------- + +class TestAlignmentValid: + """Valid index/granularity combinations should not raise.""" + + def test_granularity_1_start_0_end_240(self): + """Any index is valid when data_granularity=1 (default).""" + validate_time_alignment(0, 240, 1) # should not raise + + def test_granularity_5_start_0_end_235(self): + """0 and 235 are both multiples of 5.""" + validate_time_alignment(0, 235, 5) + + def test_granularity_30_start_0_end_210(self): + """0 and 210 are both multiples of 30.""" + validate_time_alignment(0, 210, 30) + + def test_granularity_10_start_10_end_240(self): + """10 and 240 are both multiples of 10.""" + validate_time_alignment(10, 240, 10) + + def test_granularity_2_start_0_end_240(self): + """0 and 240 are both multiples of 2.""" + validate_time_alignment(0, 240, 2) + + +# --------------------------------------------------------------------------- +# Tests: invalid configurations MUST raise ValueError (not AssertionError) +# --------------------------------------------------------------------------- + +class TestAlignmentInvalid: + """Misaligned indices should raise a descriptive ValueError, not AssertionError.""" + + def test_start_not_aligned_raises_value_error(self): + """start_time_index=1 with data_granularity=5 is invalid.""" + with pytest.raises(ValueError, match="default_start_time_index"): + validate_time_alignment(1, 235, 5) + + def test_end_not_aligned_raises_value_error(self): + """end_time_index=237 with data_granularity=5 is invalid.""" + with pytest.raises(ValueError, match="default_end_time_index"): + validate_time_alignment(0, 237, 5) + + def test_error_message_contains_actual_start_value(self): + """The error message must include the offending start value and granularity.""" + with pytest.raises(ValueError) as exc_info: + validate_time_alignment(3, 240, 10) + msg = str(exc_info.value) + assert "3" in msg, "Error message should include the bad start index" + assert "10" in msg, "Error message should include data_granularity" + + def test_error_message_contains_hint(self): + """The error message must contain a suggested corrected value.""" + with pytest.raises(ValueError) as exc_info: + validate_time_alignment(3, 240, 5) + msg = str(exc_info.value) + # Nearest lower multiple of 5 for start=3 is 0 + assert "0" in msg, "Error message should suggest 0 as a valid start index" + + def test_raises_value_error_not_assertion_error(self): + """The old code raised AssertionError; the new code must raise ValueError. + + This is the key regression check for issue #1972. + """ + with pytest.raises(ValueError): + validate_time_alignment(1, 240, 2) + # Also ensure it is NOT accidentally an AssertionError + try: + validate_time_alignment(1, 240, 2) + except AssertionError: + pytest.fail( + "Raised AssertionError instead of ValueError — regression of issue #1972!" + ) + except ValueError: + pass # expected + + def test_granularity_30_start_1(self): + """start=1 is not a multiple of 30.""" + with pytest.raises(ValueError, match="default_start_time_index"): + validate_time_alignment(1, 210, 30) + + def test_both_misaligned_raises_on_start_first(self): + """When both indices are misaligned, start error is raised first.""" + with pytest.raises(ValueError, match="default_start_time_index"): + validate_time_alignment(3, 238, 5) + + def test_only_end_misaligned(self): + """A valid start but invalid end should still raise on end.""" + with pytest.raises(ValueError, match="default_end_time_index"): + validate_time_alignment(0, 238, 5) + + def test_error_message_contains_modulo_result(self): + """The error message should show the actual remainder.""" + with pytest.raises(ValueError) as exc_info: + validate_time_alignment(3, 240, 5) + msg = str(exc_info.value) + # 3 % 5 = 3 — the remainder should appear in the message + assert "3" in msg