Skip to content
Merged
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
12 changes: 12 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,18 @@ 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 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

### 5.1 Repository Names
Expand Down
79 changes: 65 additions & 14 deletions src/quant_platform_kit/strategy_lifecycle/performance_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,30 +15,60 @@
from datetime import date
from pathlib import Path
from typing import Any, Mapping, Sequence

import pandas as pd
import sys as _sys
from importlib import import_module as _import_module

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,
interval_return_coverage_comparison_reason,
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,
)

# 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(
Expand Down Expand Up @@ -121,6 +153,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
Expand All @@ -135,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,
Expand Down Expand Up @@ -178,6 +212,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
Expand Down Expand Up @@ -553,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))
2 changes: 1 addition & 1 deletion tests/test_interval_monitor_adoption.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions tests/test_performance_monitor_public_api.py
Original file line number Diff line number Diff line change
@@ -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")
''')
Loading
Loading