Files
Auto-Trader/agent/tests/factors/test_registry.py
T
Haozhe Wu 1676eaa328 feat(factors): Alpha Zoo framework — registry, operators, safety gates
New module agent/src/factors/ providing the foundation for the Alpha
Zoo:

- base.py: 19 cross-sectional + time-series operators on wide
  pd.DataFrame panels (rank/scale/ts_*/delta/decay_linear/signed_power/
  safe_div + market-aware vwap). NaN propagates strictly; delta(d>=1)
  enforced at the operator layer; no negative shifts allowed.
- registry.py: AST-extracts __alpha_meta__ from each zoo .py module
  without importing it; pydantic AlphaMeta (extra="forbid",
  frozen=True). Lazy importlib.import_module on compute(); filesystem-
  loader fallback for plugin/test trees. Sanity gates reject +/-inf,
  NaN ratio > 95%, and shape mismatches.
- factor_analysis_core.py: IC/IR + layered backtest math extracted
  from agent/src/tools/factor_analysis_tool.py so it's reusable from
  CLI, bench tool, and API.
- bench_runner.py: per-alpha compute → IC vs forward return →
  alive/reversed/dead categorisation (mean IC > 0.02, pos_ratio >=
  0.55, |t-stat| > 2). Shared by CLI driver and API worker.

Multi-factor skill gains a panel-style ZooSignalEngine.from_zoo()
factory (cross-sectional standardisation + weighting + optional
top-N / bottom-N long-short conversion). The legacy per-symbol
example_signal_engine.py is left untouched. SKILL.md edits in
multi-factor and factor-research cross-reference the new zoo.

Safety floor:
- test_alpha_purity.py: AST allowlist scan of every zoo/**/*.py
  module. Allowed imports: pandas, numpy, scipy.*, src.factors.base,
  __future__, typing, math, dataclasses. Banned names: os/sys/
  subprocess/socket/urllib/requests/httpx/pathlib/Path/open/eval/exec/
  compile/__import__, plus dunder-access via getattr(_, "__*"),
  breakpoint, input, help, memoryview, globals, locals, vars, and
  the __class__/__bases__/__mro__/__globals__/__builtins__ chain.
  Module-level statements limited to docstring + imports + functions
  + ALPHA_ID/__alpha_meta__ assignments with literal RHS.
- test_lookahead.py: 300-row synthetic panel; corrupt rows >= 270;
  assert factor at probe row 260 unchanged within 1e-9.
- test_base.py + test_registry.py + conftest.py with opt-in
  pytest-socket integration that hard-fails outbound network during
  the factors test suite.
- tools/ci_grep_gates.sh: rejects yaml.load( without safe_load,
  any trademarked-name leak in shipped artifacts, and any per-stock-
  code data leak in wiki/**/*.{json,csv,html}.

Governance:
- CONTRIBUTING.md with the Developer Certificate of Origin trailer
  for community PRs and the alpha PR reviewer checklist.
- NOTICE at the repo root with Apache-2.0 attribution for the
  upstream qlib NOTICE and the formulas-as-mathematical-content
  stance for Kakushadze / GTJA / Fama-French sources.
2026-05-17 20:03:02 +08:00

257 lines
8.2 KiB
Python

"""Registry tests: scan, pydantic, lazy import, error isolation, sanity checks."""
from __future__ import annotations
import sys
import textwrap
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
from src.factors.registry import (
AlphaMeta,
Registry,
RegistryError,
SkipAlpha,
load_alpha_meta_from_py,
)
GOOD_META = """
__alpha_meta__ = {
"id": "__FULL_ID__",
"theme": ["momentum"],
"formula_latex": r"close - open",
"columns_required": ["close", "open"],
"universe": ["equity_us"],
"frequency": ["1d"],
"decay_horizon": 5,
"min_warmup_bars": 1,
}
"""
GOOD_COMPUTE = """
import pandas as pd
def compute(panel):
return panel["close"] - panel["open"]
"""
BAD_COMPUTE_RAISES = """
def compute(panel):
raise RuntimeError("intentional")
"""
BAD_COMPUTE_WRONG_TYPE = """
def compute(panel):
return "not a dataframe"
"""
BAD_COMPUTE_INF = """
import numpy as np
import pandas as pd
def compute(panel):
out = panel["close"].copy()
out.iloc[0, 0] = np.inf
return out
"""
def _full_id(zoo_id: str, short_id: str) -> str:
suffix = short_id.split("_", 1)[-1] if "_" in short_id else short_id
return f"{zoo_id}_{suffix}"
def _write_alpha(zoo_dir: Path, short_id: str, zoo_id: str, *, body: str = GOOD_COMPUTE, meta: str | None = None) -> None:
meta_block = (meta or GOOD_META).replace("__FULL_ID__", _full_id(zoo_id, short_id))
text = textwrap.dedent(meta_block).strip() + "\n\n" + textwrap.dedent(body).strip() + "\n"
(zoo_dir / f"{short_id}.py").write_text(text, encoding="utf-8")
@pytest.fixture
def mini_zoo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Build a fake zoo tree and make src.factors.zoo resolve into it."""
zoo_root = tmp_path / "factors" / "zoo"
fake_zoo = zoo_root / "fakezoo"
fake_zoo.mkdir(parents=True)
(fake_zoo / "__init__.py").write_text("", encoding="utf-8")
(zoo_root / "__init__.py").write_text("", encoding="utf-8")
(tmp_path / "factors" / "__init__.py").write_text("", encoding="utf-8")
_write_alpha(fake_zoo, "alpha_001", "fakezoo")
_write_alpha(fake_zoo, "alpha_002", "fakezoo", body=BAD_COMPUTE_RAISES)
_write_alpha(fake_zoo, "alpha_003", "fakezoo", body=BAD_COMPUTE_WRONG_TYPE)
_write_alpha(fake_zoo, "alpha_004", "fakezoo", body=BAD_COMPUTE_INF)
# Make `src.factors.zoo.fakezoo.<id>` importable from tmp_path's tree by
# monkey-patching sys.path to include tmp_path, then creating an `src`
# alias that points to `factors/`-as-`src.factors`.
src_alias = tmp_path / "src"
src_alias.symlink_to(tmp_path)
monkeypatch.syspath_prepend(str(tmp_path))
# Reset any cached modules from previous test
for mod_name in list(sys.modules):
if mod_name.startswith("src.factors.zoo.fakezoo"):
del sys.modules[mod_name]
return zoo_root
def _panel(n: int = 5) -> dict[str, pd.DataFrame]:
idx = pd.date_range("2024-01-01", periods=n, freq="D")
cols = ["X", "Y"]
return {
"close": pd.DataFrame(np.arange(n * 2, dtype=float).reshape(n, 2), index=idx, columns=cols),
"open": pd.DataFrame(np.arange(n * 2, dtype=float).reshape(n, 2) * 0.5, index=idx, columns=cols),
}
# ---------------- AST extraction ----------------
def test_load_alpha_meta_from_py(tmp_path: Path) -> None:
f = tmp_path / "alpha_001.py"
f.write_text(textwrap.dedent(GOOD_META.replace("__FULL_ID__", "fakezoo_001")) + "\n", encoding="utf-8")
meta = load_alpha_meta_from_py(f)
assert isinstance(meta, AlphaMeta)
assert meta.id == "fakezoo_001"
assert meta.columns_required == ["close", "open"]
def test_load_alpha_meta_missing_assignment(tmp_path: Path) -> None:
f = tmp_path / "alpha_001.py"
f.write_text("# no meta here\n", encoding="utf-8")
with pytest.raises(RegistryError, match="not found"):
load_alpha_meta_from_py(f)
def test_load_alpha_meta_not_literal(tmp_path: Path) -> None:
f = tmp_path / "alpha_001.py"
f.write_text("x = 1\n__alpha_meta__ = some_callable()\n", encoding="utf-8")
with pytest.raises(RegistryError, match="not a literal"):
load_alpha_meta_from_py(f)
def test_load_alpha_meta_pydantic_rejects_unknown_field(tmp_path: Path) -> None:
f = tmp_path / "alpha_001.py"
bad = """
__alpha_meta__ = {
"id": "x_001",
"theme": ["momentum"],
"formula_latex": "close",
"columns_required": ["close"],
"universe": ["equity_us"],
"frequency": ["1d"],
"decay_horizon": 5,
"min_warmup_bars": 1,
"py_module": "should be forbidden",
}
"""
f.write_text(textwrap.dedent(bad), encoding="utf-8")
with pytest.raises(RegistryError, match="validation"):
load_alpha_meta_from_py(f)
def test_load_alpha_meta_py_size_cap(tmp_path: Path) -> None:
f = tmp_path / "alpha_001.py"
# pad to > _MAX_PY_BYTES (200 KB)
body = "# " + "x" * 250_000 + "\n"
f.write_text(body, encoding="utf-8")
with pytest.raises(RegistryError, match="cap"):
load_alpha_meta_from_py(f)
# ---------------- Registry scan + isolation ----------------
def test_registry_scans_and_reports_health(mini_zoo: Path) -> None:
reg = Registry(zoo_root=mini_zoo)
health = reg.health()
# 4 alphas registered; isolation is at compute-time, not scan-time
assert health["loaded"] == 4
assert health["failed"] == 0
def test_registry_invalid_zoo_id_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
zoo_root = tmp_path / "factors" / "zoo"
bad_zoo = zoo_root / "1bad" # starts with digit → fails regex
bad_zoo.mkdir(parents=True)
_write_alpha(bad_zoo, "alpha_001", "fakezoo")
reg = Registry(zoo_root=zoo_root)
assert reg.health()["loaded"] == 0
assert any("1bad" in e["alpha_id"] for e in reg.health()["errors"])
def test_registry_duplicate_id_rejected(tmp_path: Path) -> None:
zoo_root = tmp_path / "factors" / "zoo"
z = zoo_root / "fakezoo"
z.mkdir(parents=True)
_write_alpha(z, "alpha_001", "fakezoo")
# second file with same alpha id (different filename, same __alpha_meta__["id"])
text = textwrap.dedent(GOOD_META.replace("__FULL_ID__", "fakezoo_001")) + "\n" + textwrap.dedent(GOOD_COMPUTE) + "\n"
(z / "alpha_dup.py").write_text(text, encoding="utf-8")
reg = Registry(zoo_root=zoo_root)
assert any("duplicate" in e["reason"] for e in reg.health()["errors"])
def test_registry_list_filters(mini_zoo: Path) -> None:
reg = Registry(zoo_root=mini_zoo)
assert reg.list(zoo="fakezoo") == sorted(reg.list())
assert reg.list(theme="reversal") == []
assert "fakezoo_001" in reg.list(theme="momentum")
assert reg.list(universe="crypto") == []
def test_registry_get_unknown_raises(mini_zoo: Path) -> None:
reg = Registry(zoo_root=mini_zoo)
with pytest.raises(KeyError):
reg.get("does_not_exist")
# ---------------- compute() error isolation ----------------
def test_registry_compute_good_alpha(mini_zoo: Path) -> None:
reg = Registry(zoo_root=mini_zoo)
out = reg.compute("fakezoo_001", _panel())
assert isinstance(out, pd.DataFrame)
assert out.shape == (5, 2)
def test_registry_compute_skip_missing_column(mini_zoo: Path) -> None:
reg = Registry(zoo_root=mini_zoo)
panel = _panel()
del panel["open"]
with pytest.raises(SkipAlpha, match="open"):
reg.compute("fakezoo_001", panel)
def test_registry_compute_raises_isolated(mini_zoo: Path) -> None:
reg = Registry(zoo_root=mini_zoo)
with pytest.raises(RegistryError, match="intentional"):
reg.compute("fakezoo_002", _panel())
def test_registry_compute_wrong_return_type(mini_zoo: Path) -> None:
reg = Registry(zoo_root=mini_zoo)
with pytest.raises(RegistryError, match="DataFrame"):
reg.compute("fakezoo_003", _panel())
def test_registry_compute_inf_rejected(mini_zoo: Path) -> None:
reg = Registry(zoo_root=mini_zoo)
with pytest.raises(RegistryError, match="inf"):
reg.compute("fakezoo_004", _panel())
# ---------------- export_manifest ----------------
def test_export_manifest_shape(mini_zoo: Path) -> None:
reg = Registry(zoo_root=mini_zoo)
m = reg.export_manifest()
assert "generated_at" in m
assert m["zoos"][0]["zoo_id"] == "fakezoo"
assert len(m["zoos"][0]["alphas"]) == 4