Files
Auto-Trader/agent/tests/factors/test_base.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

245 lines
6.7 KiB
Python

"""Operator unit tests — NaN propagation, divide-by-zero, warmup, lookahead ban."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from src.factors.base import (
Alpha,
AlphaCompute,
Market,
decay_linear,
delta,
rank,
safe_div,
scale,
signed_power,
ts_argmax,
ts_argmin,
ts_corr,
ts_cov,
ts_max,
ts_mean,
ts_min,
ts_rank,
ts_std,
vwap,
)
def _frame(rows: list[list[float]], dates: int | None = None) -> pd.DataFrame:
n = dates if dates is not None else len(rows)
idx = pd.date_range("2024-01-01", periods=n, freq="D")
return pd.DataFrame(rows, index=idx, columns=list("ABCD")[: len(rows[0])])
# ---------------- rank / scale ----------------
def test_rank_pct_per_row() -> None:
df = _frame([[1.0, 2.0, 3.0, 4.0], [4.0, 3.0, 2.0, 1.0]])
r = rank(df)
assert pytest.approx(r.iloc[0].tolist()) == [0.25, 0.5, 0.75, 1.0]
assert pytest.approx(r.iloc[1].tolist()) == [1.0, 0.75, 0.5, 0.25]
def test_rank_preserves_nan_row() -> None:
df = _frame([[np.nan, np.nan, np.nan, np.nan]])
assert rank(df).isna().all().all()
def test_rank_partial_nan_row() -> None:
df = _frame([[1.0, np.nan, 3.0, np.nan]])
r = rank(df)
assert np.isnan(r.iloc[0, 1])
assert np.isnan(r.iloc[0, 3])
assert r.iloc[0, 0] < r.iloc[0, 2]
def test_scale_l1_normalises() -> None:
df = _frame([[1.0, -2.0, 3.0, -4.0]])
s = scale(df, a=1.0)
assert pytest.approx(s.abs().sum(axis=1).iloc[0]) == 1.0
def test_scale_zero_row_returns_nan() -> None:
df = _frame([[0.0, 0.0, 0.0, 0.0]])
assert scale(df).isna().all().all()
# ---------------- ts_* warmup + edges ----------------
def test_ts_mean_warmup_is_nan() -> None:
df = _frame([[i, i] for i in range(5)])
out = ts_mean(df, 3)
assert out.iloc[:2].isna().all().all()
assert pytest.approx(out.iloc[2, 0]) == 1.0
assert pytest.approx(out.iloc[4, 0]) == 3.0
def test_ts_corr_constant_window_is_nan() -> None:
x = _frame([[1.0, 1.0]] * 5)
y = _frame([[float(i), float(i)] for i in range(5)])
out = ts_corr(x, y, 3)
assert out.iloc[2:].isna().all().all()
def test_ts_std_warmup_window() -> None:
df = _frame([[1.0], [2.0], [3.0], [4.0], [5.0]])
out = ts_std(df, 3)
assert out.iloc[:2].isna().all().all()
assert pytest.approx(out.iloc[2, 0]) == 1.0 # std of [1,2,3]
def test_ts_rank_pct_within_window() -> None:
df = _frame([[1.0], [3.0], [2.0], [4.0]])
out = ts_rank(df, 3)
# window [3,2,4]: last=4 is largest → pct rank ≈ 1.0
assert pytest.approx(out.iloc[3, 0]) == 1.0
def test_ts_max_min_argmax_argmin() -> None:
df = _frame([[1.0], [5.0], [3.0], [2.0]])
assert pytest.approx(ts_max(df, 3).iloc[3, 0]) == 5.0
assert pytest.approx(ts_min(df, 3).iloc[3, 0]) == 2.0
# window [5,3,2]: argmax=0, argmin=2
assert pytest.approx(ts_argmax(df, 3).iloc[3, 0]) == 0.0
assert pytest.approx(ts_argmin(df, 3).iloc[3, 0]) == 2.0
def test_ts_cov_warmup() -> None:
x = _frame([[float(i)] for i in range(5)])
y = _frame([[float(i) * 2] for i in range(5)])
out = ts_cov(x, y, 3)
assert out.iloc[:2].isna().all().all()
assert out.iloc[2, 0] > 0
# ---------------- delta lookahead ban ----------------
def test_delta_d_zero_raises() -> None:
df = _frame([[1.0]])
with pytest.raises(ValueError, match="delta lag"):
delta(df, 0)
def test_delta_negative_raises() -> None:
df = _frame([[1.0]])
with pytest.raises(ValueError, match="delta lag"):
delta(df, -1)
def test_delta_positive_shift() -> None:
df = _frame([[1.0], [3.0], [6.0], [10.0]])
out = delta(df, 1)
assert np.isnan(out.iloc[0, 0])
assert pytest.approx(out.iloc[1, 0]) == 2.0
assert pytest.approx(out.iloc[3, 0]) == 4.0
# ---------------- decay_linear ----------------
def test_decay_linear_weights() -> None:
df = _frame([[1.0], [2.0], [3.0]])
out = decay_linear(df, 3)
# weights 3,2,1 / 6 applied to [1,2,3] → (1*3 + 2*2 + 3*1)/6 = 10/6
assert pytest.approx(out.iloc[2, 0]) == 10.0 / 6.0
def test_decay_linear_warmup() -> None:
df = _frame([[1.0], [2.0]])
out = decay_linear(df, 3)
assert out.isna().all().all()
# ---------------- signed_power ----------------
def test_signed_power_preserves_sign_no_complex() -> None:
df = _frame([[-4.0, 4.0, 0.0, -1.0]])
out = signed_power(df, 0.5)
assert pytest.approx(out.iloc[0].tolist()) == [-2.0, 2.0, 0.0, -1.0]
assert not np.iscomplexobj(out.to_numpy())
def test_signed_power_p_two() -> None:
df = _frame([[-3.0, 2.0]])
out = signed_power(df, 2)
assert pytest.approx(out.iloc[0].tolist()) == [-9.0, 4.0]
# ---------------- safe_div ----------------
def test_safe_div_zero_denominator_is_nan() -> None:
a = _frame([[1.0, 2.0]])
b = _frame([[0.0, 0.0]])
out = safe_div(a, b)
assert out.isna().all().all()
def test_safe_div_propagates_nan() -> None:
a = _frame([[1.0, np.nan]])
b = _frame([[2.0, 3.0]])
out = safe_div(a, b)
assert pytest.approx(out.iloc[0, 0]) == 0.5
assert np.isnan(out.iloc[0, 1])
# ---------------- vwap ----------------
def test_vwap_us_typical_price() -> None:
panel = {
"open": _frame([[10.0]]),
"high": _frame([[12.0]]),
"low": _frame([[8.0]]),
"close": _frame([[11.0]]),
}
out = vwap(panel, Market.EQUITY_US)
assert pytest.approx(out.iloc[0, 0]) == (10 + 12 + 8 + 11) / 4.0
def test_vwap_cn_uses_amount_volume() -> None:
# Tushare ``amount`` is in 千元 (thousand CNY) and ``volume`` is in 手
# (100 shares). True VWAP = (amount * 1000 CNY) / (volume * 100 shares).
panel = {
"amount": _frame([[10000.0]]), # 10000 千元 = 10,000,000 CNY
"volume": _frame([[10.0]]), # 10 手 = 1000 股
}
out = vwap(panel, Market.EQUITY_CN)
expected = (10000.0 * 1000.0) / (10.0 * 100.0 + 1.0)
assert pytest.approx(out.iloc[0, 0]) == expected
def test_vwap_prefers_panel_vwap_column() -> None:
panel = {"vwap": _frame([[42.0]])}
out = vwap(panel, Market.CRYPTO)
assert pytest.approx(out.iloc[0, 0]) == 42.0
def test_vwap_us_missing_column_raises() -> None:
with pytest.raises(KeyError):
vwap({"open": _frame([[1.0]])}, Market.EQUITY_US)
# ---------------- Alpha dataclass + Protocol ----------------
def test_alpha_dataclass_is_frozen() -> None:
a = Alpha(id="zoo_001", zoo="zoo", module_path="x.y.z")
with pytest.raises(Exception):
a.id = "other" # type: ignore[misc]
def test_alpha_compute_protocol_runtime_check() -> None:
def my_alpha(panel: dict[str, pd.DataFrame]) -> pd.DataFrame:
return panel["close"]
assert isinstance(my_alpha, AlphaCompute)