diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 9daba911533..dfdd572cca6 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -116,6 +116,7 @@ def create_account_instance( benchmark: Optional[str], account: Union[float, int, dict], pos_type: str = "Position", + freq: str = "day", ) -> Account: """ # TODO: is very strange pass benchmark_config in the account (maybe for report) @@ -148,6 +149,11 @@ def create_account_instance( ... pos_type: str Postion type. + freq: str + Trading frequency for the account (e.g. "day", "60min"). The Account + defaulted to "day" before #1846 even when the executor ran at a higher + frequency, which broke benchmark fetches for users with only intraday + data. """ if isinstance(account, (int, float)): init_cash = account @@ -161,6 +167,7 @@ def create_account_instance( return Account( init_cash=init_cash, position_dict=position_dict, + freq=freq, pos_type=pos_type, benchmark_config=( {} @@ -174,6 +181,22 @@ def create_account_instance( ) +def _executor_time_per_step(executor: Union[str, dict, object, Path]) -> Optional[str]: + """Extract ``time_per_step`` from an executor config or instance. + + Returns ``None`` when it cannot be determined (e.g. executor is a path + or an already-instantiated executor without an exposed attribute). + """ + if isinstance(executor, dict): + kwargs = executor.get("kwargs") or {} + tps = kwargs.get("time_per_step") + if isinstance(tps, str): + return tps + return None + tps = getattr(executor, "time_per_step", None) + return tps if isinstance(tps, str) else None + + def get_strategy_executor( start_time: Union[pd.Timestamp, str], end_time: Union[pd.Timestamp, str], @@ -190,12 +213,18 @@ def get_strategy_executor( from ..strategy.base import BaseStrategy # pylint: disable=C0415 from .executor import BaseExecutor # pylint: disable=C0415 + # Derive the account's freq from the executor's time_per_step so the + # benchmark / portfolio metrics match the executor's bar frequency + # (see #1846). Falls back to the historic "day" default when the + # executor's frequency cannot be determined. + account_freq = _executor_time_per_step(executor) or "day" trade_account = create_account_instance( start_time=start_time, end_time=end_time, benchmark=benchmark, account=account, pos_type=pos_type, + freq=account_freq, ) exchange_kwargs = copy.copy(exchange_kwargs) diff --git a/tests/misc/test_backtest_account_freq_threading.py b/tests/misc/test_backtest_account_freq_threading.py new file mode 100644 index 00000000000..9c7e65daf6e --- /dev/null +++ b/tests/misc/test_backtest_account_freq_threading.py @@ -0,0 +1,56 @@ +"""Regression test for https://github.com/microsoft/qlib/issues/1846. + +The reporter only had 60min data and ran backtest with a 60min executor, +but the Account was always constructed with the hardcoded default +``freq="day"``. The benchmark fetch at ``Account.__init__`` therefore +tried to read day-level data and raised before the executor got a chance +to reset the account to its own ``time_per_step``. + +The fix threads ``time_per_step`` from the executor config (or executor +instance) into ``create_account_instance`` so the account is born with +the correct freq. + +This test exercises ``_executor_time_per_step`` directly — it is the +extraction helper introduced by the fix and is what guards the +"executor → account.freq" handoff. +""" + +import unittest +from types import SimpleNamespace + +from qlib.backtest import _executor_time_per_step + + +class TestExecutorFreqExtraction(unittest.TestCase): + def test_dict_with_time_per_step(self) -> None: + cfg = { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": {"time_per_step": "60min"}, + } + self.assertEqual(_executor_time_per_step(cfg), "60min") + + def test_dict_without_kwargs_returns_none(self) -> None: + # Falls back to the historic "day" default in the caller. + self.assertIsNone(_executor_time_per_step({"class": "Whatever"})) + self.assertIsNone(_executor_time_per_step({"class": "Whatever", "kwargs": {}})) + + def test_dict_with_non_string_time_per_step_is_ignored(self) -> None: + # Defensive: only honor string time_per_step. The constraint + # matches BaseExecutor's type annotation. + self.assertIsNone(_executor_time_per_step({"kwargs": {"time_per_step": 60}})) + + def test_instance_with_time_per_step_attribute(self) -> None: + instance = SimpleNamespace(time_per_step="day") + self.assertEqual(_executor_time_per_step(instance), "day") + + def test_instance_without_attribute_returns_none(self) -> None: + self.assertIsNone(_executor_time_per_step(SimpleNamespace())) + + def test_string_path_executor_returns_none(self) -> None: + # Path/str executors carry no freq info — caller defaults to "day". + self.assertIsNone(_executor_time_per_step("/path/to/executor.yaml")) + + +if __name__ == "__main__": + unittest.main()