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
29 changes: 29 additions & 0 deletions qlib/backtest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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=(
{}
Expand All @@ -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],
Expand All @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions tests/misc/test_backtest_account_freq_threading.py
Original file line number Diff line number Diff line change
@@ -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()