From 1f2923078be86bb9896f826c7ec96897d559f7c4 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 2 Nov 2026 21:04:44 +0800 Subject: [PATCH 1/2] fix: defer optional performance monitor numeric imports --- docs/ARCHITECTURE.md | 8 + .../strategy_lifecycle/performance_monitor.py | 37 ++-- tests/test_interval_monitor_adoption.py | 2 +- tests/test_performance_monitor_wheel.py | 160 ++++++++++++++++++ 4 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 tests/test_performance_monitor_wheel.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 240281c9..b99dde7b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -497,6 +497,14 @@ Notifications are dispatched through a shared `NotificationPublisher` that wraps | `health_dashboard` | Aggregate health metrics across platforms | | `audit_log` | Immutable audit log for strategy changes | +`performance_monitor.PerformanceMonitor.record` and `record_execution`, along +with its domain, stream and provenance helpers, work with the core installation. +Numeric monitoring through `run_monitor` requires the `research` extra +(`pip install 'quant-platform-kit[research]'`). Its pandas, metrics and return +collector imports are deferred until invocation; recording payloads and metric +calculations retain their existing semantics. Consumers pinned to older QPK +revisions need a separate pin update to receive this import fix. + ## 5. Naming Conventions ### 5.1 Repository Names diff --git a/src/quant_platform_kit/strategy_lifecycle/performance_monitor.py b/src/quant_platform_kit/strategy_lifecycle/performance_monitor.py index 1521cb2a..0cea1463 100644 --- a/src/quant_platform_kit/strategy_lifecycle/performance_monitor.py +++ b/src/quant_platform_kit/strategy_lifecycle/performance_monitor.py @@ -2,6 +2,8 @@ Collects daily strategy returns, computes rolling metrics for all configured windows, and persists StrategyPerformanceSnapshot records to the performance store. +Recording requires only the core installation; numeric monitoring requires the +``research`` extra (pandas and NumPy), loaded when monitoring is invoked. """ from __future__ import annotations @@ -12,12 +14,11 @@ from dataclasses import replace from datetime import date from pathlib import Path -from typing import Any, Mapping, Sequence - -import pandas as pd +from typing import TYPE_CHECKING, Any, Mapping, Sequence logger = logging.getLogger(__name__) +from quant_platform_kit.backtest.config import DEFAULT_WINDOWS from quant_platform_kit.common.jsonio import utc_now_iso from quant_platform_kit.strategy_lifecycle.contracts import ( StrategyPerformanceSnapshot, @@ -25,18 +26,10 @@ normalize_interval_return_coverage, resolve_return_observation_contract, ) -from quant_platform_kit.strategy_lifecycle.performance_metrics import ( - DEFAULT_WINDOWS, - annualization_basis_is_comparable, - compare_with_backtest, - compute_window_metrics, - normalize_return_series, -) from quant_platform_kit.strategy_lifecycle.performance_store import PerformanceStore -from quant_platform_kit.strategy_lifecycle.return_collector import ( - ReturnCollector, - resolve_strategy_benchmark, -) + +if TYPE_CHECKING: + from quant_platform_kit.strategy_lifecycle.return_collector import ReturnCollector _SOURCE_REVISION_SHA = re.compile(r"^[0-9a-f]{40}$") _SOURCE_REVISION_SENTINELS = frozenset( @@ -121,6 +114,8 @@ def resolve_lifecycle_stream_id( def _is_valid_series(series: object, *, min_observations: int = 10) -> bool: + import pandas as pd + if not isinstance(series, pd.Series): return False return len(series.dropna()) >= min_observations @@ -178,6 +173,20 @@ def run_monitor( Returns: List of StrategyPerformanceSnapshot objects generated. """ + # Recording stays dependency-free; numerical monitoring requires [research]. + import pandas as pd + + from quant_platform_kit.strategy_lifecycle.performance_metrics import ( + annualization_basis_is_comparable, + compare_with_backtest, + compute_window_metrics, + normalize_return_series, + ) + from quant_platform_kit.strategy_lifecycle.return_collector import ( + ReturnCollector, + resolve_strategy_benchmark, + ) + requested = required_start_at is not None or required_end_at is not None if requested: from quant_platform_kit.strategy_lifecycle.live_equity import _parse_interval_timestamp diff --git a/tests/test_interval_monitor_adoption.py b/tests/test_interval_monitor_adoption.py index d9ed72e0..d1102498 100644 --- a/tests/test_interval_monitor_adoption.py +++ b/tests/test_interval_monitor_adoption.py @@ -300,7 +300,7 @@ def test_monitor_missing_or_mismatched_baseline_coverage_cannot_create_drift(sel root = Path(tmp); store = PerformanceStore(local_root=root / "store") store.save_backtest_result(replace(baseline(), sharpe_ratio=.1, cagr=.01, max_drawdown=-.01)) kwargs = {} if ref is None else {"baseline_coverage_by_profile": {"coverage_case": ref}} - with patch("quant_platform_kit.strategy_lifecycle.performance_monitor.compare_with_backtest", + with patch("quant_platform_kit.strategy_lifecycle.performance_metrics.compare_with_backtest", return_value={"sharpe_deviation": .6}) as compare: result = run_monitor("crypto", collector=collector(root, receipts(gap=True)), store=store, windows=(126,), min_observations=2, source_revision="a" * 40, diff --git a/tests/test_performance_monitor_wheel.py b/tests/test_performance_monitor_wheel.py new file mode 100644 index 00000000..07d0da2b --- /dev/null +++ b/tests/test_performance_monitor_wheel.py @@ -0,0 +1,160 @@ +"""Test the installed wheel's core recorder and optional numeric boundary.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import venv + +import pytest + + +_PROBE = ''' +import importlib.util +import json +from pathlib import Path +import sys + + +def offline(event, args): + if event in {"socket.connect", "socket.bind", "socket.getaddrinfo", + "subprocess.Popen", "os.system", "os.posix_spawn"}: + raise AssertionError("unexpected external operation: " + event) + + +sys.addaudithook(offline) +import quant_platform_kit.strategy_lifecycle.performance_monitor as monitor +from quant_platform_kit.strategy_lifecycle.performance_store import PerformanceStore + +origin = Path(monitor.__file__).resolve() +assert origin.is_relative_to(Path(sys.prefix).resolve()), origin +store = PerformanceStore(local_root=Path.cwd() / "records") +''' + +_CORE_PROBE = _PROBE + ''' +for name in ("numpy", "pandas"): + assert importlib.util.find_spec(name) is None, name + assert name not in sys.modules, name + +recorder = monitor.PerformanceMonitor(store=store) +assert recorder.record("synthetic_profile", None, {"status": "dry_run"}, + domain="crypto", stream_id="synthetic-stream")["ok"] +assert recorder.record_execution("synthetic_profile", {"status": "dry_run"}, + domain="crypto", stream_id="synthetic-stream")["ok"] +records = list(store.list_live_run_records("crypto", strategy_profile="synthetic_profile", + stream_id="synthetic-stream")) +assert len(records) == 2, records +assert {record.get("record_kind", "decision") for record in records} == {"decision", "execution"} +assert all(record["execution_result"] == {"status": "dry_run"} for record in records) +assert monitor.infer_strategy_domain("crypto_synthetic") == "crypto" +assert monitor.resolve_monitor_source_revision("synthetic-revision") == "synthetic-revision" +monitor.try_record_platform_execution("synthetic_profile", {}) +assert recorder.record("", None) == {"ok": False, "skipped": "empty_profile"} +for name in ("numpy", "pandas"): + assert name not in sys.modules, name +try: + monitor.run_monitor("crypto", store=store) +except ModuleNotFoundError as exc: + assert exc.name == "pandas", exc +else: + raise AssertionError("numeric monitoring must require the research dependencies") +assert not list(Path("records").glob("daily/**/*.json")) +print(json.dumps({"mode": "core", "origin": str(origin), "records": len(records), + "numeric_dependencies": "absent", "external_operations": 0})) +''' + +_RESEARCH_PROBE = _PROBE + ''' +import pandas as pd +from quant_platform_kit.strategy_lifecycle.performance_metrics import DEFAULT_WINDOWS +assert monitor.DEFAULT_WINDOWS == DEFAULT_WINDOWS == (21, 63, 126, 252, 756) +assert monitor.run_monitor.__kwdefaults__["windows"] == DEFAULT_WINDOWS + + +class SyntheticCollector: + def collect(self, domain): + assert domain == "crypto" + return {"synthetic_profile": pd.Series( + [0.01, -0.02], index=pd.to_datetime(["2026-01-01", "2026-01-02"]))} + + def collect_benchmark(self, domain, symbol): + return pd.Series([0.005, -0.01], index=pd.to_datetime(["2026-01-01", "2026-01-02"])) + + +snapshots = monitor.run_monitor( + "crypto", collector=SyntheticCollector(), store=store, windows=(2,), + min_observations=2, source_revision="synthetic-revision") +assert len(snapshots) == 1 +snapshot = snapshots[0] +metrics = snapshot.windows[2] +assert abs(metrics.total_return - (1.01 * 0.98 - 1)) < 1e-12 +assert metrics.observation_count == 2 +assert metrics.periods_per_year == 365.25 +benchmark_cagr = (1.005 * 0.99) ** (365.25 / 2) - 1 +assert abs(metrics.excess_cagr - (metrics.cagr - benchmark_cagr)) < 1e-12 +assert snapshot.latest_return == -0.02 +assert snapshot.source_revision == "synthetic-revision" +loaded = store.load_latest_snapshot("crypto", "synthetic_profile") +assert loaded is not None +assert loaded.windows[2].to_dict() == metrics.to_dict() +assert loaded.latest_return == snapshot.latest_return +assert loaded.source_revision == snapshot.source_revision +persisted = next(Path("records/daily").rglob("*.json")) +payload = json.loads(persisted.read_text()) +assert payload.pop("schema_version") == "strategy_lifecycle.v1" +assert payload == snapshot.to_dict() +for name in ("numpy", "pandas"): + module = sys.modules[name] + assert Path(module.__file__).resolve().is_relative_to(Path(sys.prefix).resolve()) +print(json.dumps({"mode": "research", "origin": str(origin), + "total_return": metrics.total_return, "periods_per_year": 365.25, + "external_operations": 0})) +''' + + +def _setup_env(root: Path) -> dict[str, str]: + env = {"PATH": os.defpath, "HOME": str(root)} + for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy", + "SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "PIP_CERT"): + if key in os.environ: + env[key] = os.environ[key] + return env + + +def _run(args: list[str], cwd: Path, env: dict[str, str]) -> str: + result = subprocess.run(args, cwd=cwd, env=env, text=True, capture_output=True, timeout=300) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout + + +@pytest.fixture(scope="module") +def wheel(tmp_path_factory: pytest.TempPathFactory) -> Path: + root = tmp_path_factory.mktemp("monitor-wheel") + source = root / "source" + source.mkdir() + repo = Path(__file__).resolve().parents[1] + shutil.copytree(repo / "src", source / "src", ignore=shutil.ignore_patterns( + "__pycache__", "*.egg-info")) + for name in ("pyproject.toml", "setup.py", "README.md", "LICENSE"): + shutil.copy2(repo / name, source / name) + _run([sys.executable, "-I", "-m", "pip", "--isolated", "wheel", "--no-deps", + "--index-url", "https://pypi.org/simple", "--wheel-dir", str(root / "wheels"), + str(source)], root, _setup_env(root)) + return next((root / "wheels").glob("*.whl")) + + +@pytest.mark.parametrize("extra,probe", [("", _CORE_PROBE), ("research", _RESEARCH_PROBE)], + ids=["core-no-extras", "research-numeric-unit"]) +def test_installed_monitor_wheel(wheel: Path, tmp_path: Path, extra: str, probe: str) -> None: + environment = tmp_path / "venv" + venv.EnvBuilder(with_pip=True, system_site_packages=False).create(environment) + python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + env = _setup_env(tmp_path) + target = str(wheel) + (f"[{extra}]" if extra else "") + _run([str(python), "-I", "-m", "pip", "--isolated", "install", "--no-cache-dir", + "--index-url", "https://pypi.org/simple", target], tmp_path, env) + _run([str(python), "-I", "-m", "pip", "check"], tmp_path, env) + print(_run([str(python), "-I", "-c", probe], tmp_path, env).strip()) From 4423441ef598f8ad8b549e90eb4712aed37bc92d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 2 Nov 2026 22:21:16 +0800 Subject: [PATCH 2/2] fix: preserve lazy performance monitor public api --- docs/ARCHITECTURE.md | 10 +- .../strategy_lifecycle/performance_monitor.py | 50 +++++++- tests/test_performance_monitor_public_api.py | 109 ++++++++++++++++++ 3 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 tests/test_performance_monitor_public_api.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b99dde7b..b3135572 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -501,9 +501,13 @@ Notifications are dispatched through a shared `NotificationPublisher` that wraps with its domain, stream and provenance helpers, work with the core installation. Numeric monitoring through `run_monitor` requires the `research` extra (`pip install 'quant-platform-kit[research]'`). Its pandas, metrics and return -collector imports are deferred until invocation; recording payloads and metric -calculations retain their existing semantics. Consumers pinned to older QPK -revisions need a separate pin update to receive this import fix. +collector imports are deferred until invocation or access to a historical +re-export. Direct imports of `ReturnCollector`, the numeric helpers and `pd` +retain their original identities; `typing.get_type_hints(run_monitor)` resolves +the original collector class. These accesses require the `research` extra. +Recording payloads and metric calculations retain their existing semantics. +Consumers pinned to older QPK revisions need a separate pin update to receive +this import fix. ## 5. Naming Conventions diff --git a/src/quant_platform_kit/strategy_lifecycle/performance_monitor.py b/src/quant_platform_kit/strategy_lifecycle/performance_monitor.py index 0cea1463..f0653ef1 100644 --- a/src/quant_platform_kit/strategy_lifecycle/performance_monitor.py +++ b/src/quant_platform_kit/strategy_lifecycle/performance_monitor.py @@ -14,7 +14,9 @@ from dataclasses import replace from datetime import date from pathlib import Path -from typing import TYPE_CHECKING, Any, Mapping, Sequence +from typing import Any, Mapping, Sequence +import sys as _sys +from importlib import import_module as _import_module logger = logging.getLogger(__name__) @@ -28,8 +30,45 @@ ) from quant_platform_kit.strategy_lifecycle.performance_store import PerformanceStore -if TYPE_CHECKING: - from quant_platform_kit.strategy_lifecycle.return_collector import ReturnCollector +# Keep historical re-exports lazy, including the pandas module alias. +_LAZY_EXPORTS = { + "pd": ("pandas", None), + "ReturnCollector": ("quant_platform_kit.strategy_lifecycle.return_collector", "ReturnCollector"), + "resolve_strategy_benchmark": ( + "quant_platform_kit.strategy_lifecycle.return_collector", "resolve_strategy_benchmark"), + **{name: ("quant_platform_kit.strategy_lifecycle.performance_metrics", name) for name in ( + "annualization_basis_is_comparable", "compare_with_backtest", + "compute_window_metrics", "normalize_return_series", + )}, +} +# get_type_hints evaluates annotations in globals(), bypassing module __getattr__. +# A module-qualified reference invokes lazy lookup and returns the original class. +_monitor_module = _sys.modules[__name__] + + +def __getattr__(name: str) -> Any: + if name not in _LAZY_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, attribute = _LAZY_EXPORTS[name] + try: + # Check directly: pandas may wrap a missing NumPy in a generic ImportError. + _import_module("numpy") + _import_module("pandas") + module = _import_module(module_name) + except ModuleNotFoundError as exc: + if exc.name not in {"pandas", "numpy"}: + raise + raise ImportError( + f"{name} requires pandas and NumPy; " + "install quant-platform-kit[research]" + ) from exc + value = module if attribute is None else getattr(module, attribute) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_LAZY_EXPORTS)) _SOURCE_REVISION_SHA = re.compile(r"^[0-9a-f]{40}$") _SOURCE_REVISION_SENTINELS = frozenset( @@ -130,7 +169,7 @@ def run_monitor( min_observations: int = 10, fail_on_empty: bool = True, store: PerformanceStore | None = None, - collector: ReturnCollector | None = None, + collector: _monitor_module.ReturnCollector | None = None, strategy_benchmarks: Mapping[str, str] | None = None, require_explicit_benchmark: bool = False, live_stream_id: str | None = None, @@ -562,3 +601,6 @@ def try_record_platform_execution( ) except Exception as exc: # pragma: no cover logger.warning("PerformanceMonitor.record_execution failed: %s", exc) + + +__all__ = sorted({name for name in globals() if not name.startswith("_")} | set(_LAZY_EXPORTS)) diff --git a/tests/test_performance_monitor_public_api.py b/tests/test_performance_monitor_public_api.py new file mode 100644 index 00000000..29d7a4d3 --- /dev/null +++ b/tests/test_performance_monitor_public_api.py @@ -0,0 +1,109 @@ +"""Preserve the monitor's main-branch public surface without eager numerics.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys + +import pytest + + +# Implicit star-import surface of main at 31009f36b9d144497410cfe3a5b78e2517052961. +# Includes historical imported aliases, not only functions defined here. +MAIN_PUBLIC_NAMES = { + "Any", "DEFAULT_WINDOWS", "Mapping", "Path", "PerformanceMonitor", + "PerformanceStore", "ReturnCollector", "Sequence", "StrategyPerformanceSnapshot", + "annotations", "annualization_basis_is_comparable", "compare_with_backtest", + "compute_window_metrics", "date", "infer_strategy_domain", + "interval_return_coverage_comparison_reason", "logger", "logging", + "normalize_interval_return_coverage", "normalize_return_series", "os", "pd", "re", + "replace", "resolve_lifecycle_stream_id", "resolve_monitor_source_revision", + "resolve_return_observation_contract", "resolve_strategy_benchmark", "run_monitor", + "try_record_platform_execution", "utc_now_iso", +} +LAZY_EXPORTS = { + "pd": ("pandas", None), + "ReturnCollector": ("quant_platform_kit.strategy_lifecycle.return_collector", "ReturnCollector"), + "resolve_strategy_benchmark": ( + "quant_platform_kit.strategy_lifecycle.return_collector", "resolve_strategy_benchmark"), + **{name: ("quant_platform_kit.strategy_lifecycle.performance_metrics", name) for name in ( + "annualization_basis_is_comparable", "compare_with_backtest", + "compute_window_metrics", "normalize_return_series", + )}, +} + + +def _probe(code: str) -> None: + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[1] / "src")) + result = subprocess.run([sys.executable, "-c", code], env=env, text=True, + capture_output=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_public_surface_and_type_hints_without_prior_attribute_access(): + _probe(f''' +import sys +import typing +import quant_platform_kit.strategy_lifecycle.performance_monitor as monitor +assert "pandas" not in sys.modules +assert "numpy" not in sys.modules +expected = {MAIN_PUBLIC_NAMES!r} +assert set(monitor.__all__) == expected +assert {{name for name in dir(monitor) if not name.startswith("_")}} == expected +assert not (expected & {{"pd", "ReturnCollector"}}) & vars(monitor).keys() +hints = typing.get_type_hints(monitor.run_monitor) +from quant_platform_kit.strategy_lifecycle.return_collector import ReturnCollector +assert hints["collector"] == ReturnCollector | None +assert monitor.ReturnCollector is ReturnCollector +namespace = {{}} +exec("from quant_platform_kit.strategy_lifecycle.performance_monitor import *", namespace) +assert set(namespace) - {{"__builtins__"}} == expected +assert namespace["ReturnCollector"] is ReturnCollector +try: + monitor.not_a_public_symbol +except AttributeError: + pass +else: + raise AssertionError("unknown attributes must raise AttributeError") +''') + + +@pytest.mark.parametrize("name,target", LAZY_EXPORTS.items()) +def test_legacy_direct_import_identity(name, target): + _probe(f''' +import importlib +import inspect +from quant_platform_kit.strategy_lifecycle.performance_monitor import {name} as actual +module = importlib.import_module({target[0]!r}) +expected = getattr(module, {target[1]!r}) if {target[1]!r} else module +assert actual is expected +if callable(expected): + assert inspect.signature(actual) == inspect.signature(expected) +''') + + +@pytest.mark.parametrize("blocked", ["pandas", "numpy"]) +def test_core_import_and_fail_closed_optional_exports(blocked): + _probe(f''' +import importlib.abc +import sys +class BlockNumerics(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".")[0] == {blocked!r}: + raise ModuleNotFoundError("blocked optional dependency", name={blocked!r}) +sys.meta_path.insert(0, BlockNumerics()) +import quant_platform_kit.strategy_lifecycle.performance_monitor as monitor +assert "pandas" not in sys.modules +assert "numpy" not in sys.modules +assert set(monitor.__all__) <= set(dir(monitor)) +for name in {list(LAZY_EXPORTS)!r}: + try: + getattr(monitor, name) + except ImportError as exc: + assert "quant-platform-kit[research]" in str(exc), str(exc) + assert isinstance(exc.__cause__, ModuleNotFoundError) + else: + raise AssertionError(name + " must require the research extra") +''')