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.
This commit is contained in:
Haozhe Wu committed 2026-05-17 20:03:02 +08:00
1 parent 6d7d365892
commit 1676eaa328
20 files changed
+2826 -233

No files matched your search

+123 -147
View File
@@ -1,176 +1,152 @@
# Contributing to Vibe-Trading
Thanks for your interest in contributing! This guide will help you get started.
Vibe-Trading is a natural-language finance research AI agent (FastAPI + ReAct
agent backend, Vite+React frontend, vectorized daily and options backtesting).
This guide covers contribution governance: the Developer Certificate of Origin
(DCO) sign-off requirement, the reviewer checklist for Alpha Zoo factor
contributions, and the quickstart for adding a new alpha.
## Quick Links
For general project setup (`pip install -e ".[dev]"`, dev servers,
`pytest --ignore=agent/tests/e2e_backtest`), see the README. For bug reports
and feature requests, use the GitHub issue templates.
- [Discord](https://discord.gg/2vDYc2w5) — ask questions, discuss ideas
- [Issues](https://github.com/HKUDS/Vibe-Trading/issues) — bug reports & feature requests
- [Good First Issues](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) — great starting points
## Developer Certificate of Origin (DCO)
## Development Setup
Every commit in a community pull request MUST carry a `Signed-off-by:`
trailer. We do not require a CLA — the DCO is a lightweight per-commit
attestation that you wrote the code or have the right to submit it under
the project's MIT license. Maintainer-direct commits to `main` are not
subject to the trailer requirement (the maintainer's authorship is
already attested by the commit's author field), but community PRs are.
Sign your commits with `-s`:
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
python -m venv .venv
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\Activate.ps1 # Windows
pip install -e ".[dev]"
cp agent/.env.example agent/.env
# Edit agent/.env — set your LLM provider
git commit -s -m "feat(factors): add gtja191 alpha 042"
```
### Run tests
This appends a trailer like:
```bash
pytest --ignore=agent/tests/e2e_backtest --tb=short -q
```
Signed-off-by: Your Name <you@example.com>
```
### Start dev servers
PRs without a `Signed-off-by:` on every commit will be asked to rebase and
resign. To fix an unsigned series, run
`git rebase --signoff <base-branch>` and force-push the branch.
```bash
# Terminal 1: API server
vibe-trading serve --port 8899
### DCO 1.1 (full text)
# Terminal 2: Frontend
cd frontend && npm install && npm run dev
```
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
1 Letterman Drive
Suite D4700
San Francisco, CA, 94129
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
```
## Project Structure
## Alpha PR Reviewer Checklist
| Directory | What lives here | Open to contribute? |
|-----------|----------------|:-------------------:|
| `agent/src/skills/` | 74 finance skill definitions (SKILL.md) | Yes |
| `agent/src/tools/` | 21 agent tools | Yes |
| `agent/backtest/` | Backtest engines, loaders, optimizers | Yes |
| `agent/src/swarm/presets/` | 29 swarm preset YAMLs | Yes |
| `frontend/` | React 19 + Vite web UI | Yes |
| `agent/src/agent/` | ReAct agent core (loop, context, skills) | Ask first |
| `agent/src/session/` | Session management | Ask first |
| `agent/src/providers/` | LLM provider abstraction | Ask first |
This checklist applies to any PR adding or modifying files under
`agent/src/factors/zoo/**/*.py`. Reviewers MUST verify every box before
merging. Authors are strongly encouraged to self-check first.
> **Core modules** (`agent/`, `session/`, `providers/`) are protected — please open an issue to discuss before modifying.
- [ ] **Purity gate**: file passes `pytest agent/tests/factors/test_alpha_purity.py`.
The AST scan rejects any imports outside the allowlist
(`pandas`, `numpy`, `scipy.*`, `src.factors.base`, `__future__`, `typing`,
`math`, `dataclasses`) and any reference to forbidden names: `os`,
`subprocess`, `socket`, `urllib`, `requests`, `httpx`, `pathlib`, `Path`,
`eval`, `exec`, `compile`, `__import__`, bare `open`, or `getattr` with a
second argument beginning with `"__"`.
- [ ] **Lookahead gate**: file passes `pytest agent/tests/factors/test_lookahead.py`.
No negative shifts, no forward leakage. `delta(df, d)` must have `d >= 1`.
- [ ] **`__alpha_meta__` present** with all required pydantic-validated fields:
`id`, `theme`, `formula_latex`, `columns_required`, `universe`, `frequency`,
`decay_horizon`, `min_warmup_bars`. Optional: `nickname`, `extras_required`,
`requires_sector`, `notes`.
- [ ] **`compute(panel)` contract**: returns a DataFrame with the same shape
as `panel["close"]`, NaN preserved at warmup / missing-data positions,
no `+/-inf` values.
- [ ] **LaTeX matches code**: the `formula_latex` in `__alpha_meta__` and the
formula in the module docstring describe what `compute()` actually does.
- [ ] **Per-zoo `LICENSE.md` updated** to cite the source paper / report and
to state that formulas are mathematical facts, not subject to copyright,
and that prose / tables / figures from papers and reports are not
reproduced in this repo. Do NOT frame the bundled formulas using US
affirmative-defense terminology — that is a litigation posture, not a
license grant. State the mathematical-facts rationale instead.
- [ ] **Apache-2 attribution**: if the alpha is adapted from an Apache-2.0
upstream (e.g. Microsoft Qlib), the file MUST carry a header of the form
`# Adapted from <repo>@<commit-sha>:<path> (Apache-2.0). Copyright (c) <holder>.`
- [ ] **DCO**: every commit in the PR carries `Signed-off-by:`.
## How to Contribute
## Adding a New Alpha (Quickstart)
### Reporting Bugs
Use the [Bug Report](https://github.com/HKUDS/Vibe-Trading/issues/new?template=bug_report.yml) template. Include:
- Steps to reproduce
- Expected vs actual behavior
- Your environment (OS, Python version, provider)
- Error logs if available
### Suggesting Features
Use the [Feature Request](https://github.com/HKUDS/Vibe-Trading/issues/new?template=feature_request.yml) template.
### Submitting Code
1. **Fork & branch** — create a feature branch from `main`
1. Pick the target zoo directory under `agent/src/factors/zoo/` (e.g.
`gtja191/`, `alpha101/`, `qlib158/`, `academic/`).
2. Create `<alpha_id_short>.py` in that directory. Define `__alpha_meta__`
(must satisfy the pydantic `AlphaMeta` schema in
`agent/src/factors/registry.py`) and a pure `compute(panel)` function
that imports only from `src.factors.base` plus the allowlisted stdlib /
numpy / pandas / scipy.
3. Run the purity and lookahead gates locally:
```bash
git checkout -b feat/my-feature
pytest agent/tests/factors/test_alpha_purity.py agent/tests/factors/test_lookahead.py -q
```
2. **Make changes** — follow the code standards below
3. **Test** — add or update tests, ensure `pytest` passes
4. (Optional but recommended) Run a quick bench:
```bash
pytest --ignore=agent/tests/e2e_backtest --tb=short -q
vibe-trading alpha bench --zoo <zoo_id> --universe csi300 --period 2020-2025
```
5. Open a PR. Every commit must include `Signed-off-by:` (use
`git commit -s`). Reviewers will walk the checklist above.
4. **Commit** — use [Conventional Commits](https://www.conventionalcommits.org/)
```
feat: add RSI divergence skill
fix: handle empty DataFrame in backtest loader
docs: update CLI reference table
test: add crypto engine edge cases
```
## Code Style
5. **Pull Request** — open a PR against `main` with:
- Summary of changes
- Related issue number (e.g., `Closes #42`)
- Test plan
- Format with `black`; lint with `ruff` (config in `pyproject.toml`).
- Type-annotate all public function and method signatures.
- Google-style docstrings (`Args:` / `Returns:` / `Raises:`).
- Keep files under 400 lines where practical, 800 hard cap.
- No hardcoded paths, secrets, or URLs — config via `.env`, YAML, or
module-level constants.
- Delete unused code rather than commenting it out.
## Code Standards
## Attribution
- **Python**: Google-style docstrings, type hints encouraged
- **No hardcoding**: config via `.env`, YAML, or constants
- **Delete unused code** — don't comment-preserve
- **OKX pairs**: use `BTC-USDT` format (hyphen, uppercase)
- **UI text**: English. LLM output follows user language
- **File size**: aim for < 400 lines, max 800
Do NOT add `Co-Authored-By:` trailers or AI-assistant attribution lines to
commit messages or PR descriptions. The DCO sign-off is the only required
trailer; keep commit metadata clean.
## Contribution Roadmap
### Easy: New Skill
Each skill is a single `SKILL.md` file in `agent/src/skills/<category>/<skill-name>/`. Categories: `data-source`, `strategy`, `analysis`, `asset-class`, `crypto`, `flow`, `tool`. Look at existing skills for the format.
### Easy: New Swarm Preset
A YAML file in `agent/src/swarm/presets/` defining agents, roles, and DAG workflow. See existing presets.
### Medium: New Data Source Loader
1. Create `agent/backtest/loaders/<source>.py`
2. Implement the `DataLoader` Protocol (see `agent/backtest/loaders/base.py`)
3. Register in `agent/backtest/loaders/registry.py`
4. Add tests in `agent/tests/`
### Medium: Portfolio Optimizer Constraints
Current optimizers (`agent/backtest/optimizers/`) support basic weight optimization. We need:
- **Leverage constraints** — max gross exposure limits
- **Sector/group caps** — weight limits per sector or asset group
- **Turnover penalties** — transaction cost-aware rebalancing
- Extend `BaseOptimizer` interface, keep backward compatibility
### Medium: Correlation Heatmap Dashboard
Add a cross-asset correlation matrix component to the frontend:
- Time-window slider (30d / 60d / 90d / 1Y)
- Hierarchical clustering for sector grouping
- ECharts heatmap or custom canvas renderer
- Wire to a new API endpoint that computes rolling correlations
### Medium: Trust Layer Run Card UI
Backtests now emit `run_card.json` and `run_card.md`. Good follow-up contributions:
- Surface run-card links in CLI/API/Web UI run summaries
- Add a Web UI run-card detail view with config hash, strategy hash, data sources, metrics, validation, warnings, and artifact checksums
- Extend the run-card schema with tool trace and citations once trace capture is promoted into the Trust Layer contract
### Hard: Intraday Backtest Engine
Current engines run on daily bars. We need sub-daily execution:
- Support 1m / 5m / 15m / 30m / 1H / 4H intervals
- Handle market session boundaries (pre-market, after-hours, overnight gaps)
- Intraday margin / buying power tracking
- Integrate with minute-level data loaders (OKX klines, yfinance intraday)
- Start from `BaseEngine` — see `agent/backtest/engines/base.py` for the abstract interface
### Hard: Options Volatility Surface & Greeks Visualization
Build 3D implied-vol surface and Greeks charts in the frontend:
- **Vol surface**: strike vs. expiry vs. implied vol (3D mesh or contour)
- **Greeks dashboard**: delta, gamma, theta, vega curves across strikes
- **Skew chart**: 25-delta risk reversal over time
- Backend: extend `options_pricing_tool.py` to return surface data
- Frontend: ECharts GL for 3D, or custom WebGL renderer
### Hard: Monte Carlo & Stress Testing
Add scenario simulation and tail-risk analysis:
- **Monte Carlo**: generate N return paths from fitted distribution, compute VaR/CVaR confidence intervals
- **Historical stress tests**: replay specific crisis periods (2008, 2020 COVID, 2022 crypto winter)
- **Regime switching**: HMM-based regime detection (bull/bear/sideways), conditional risk metrics
- Output: distribution plots, drawdown probability curves, worst-case tables
- Can be a new tool in `agent/src/tools/` or an extension to the backtest engine
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
By contributing, you agree that your contributions are licensed under the
project's MIT license (see `LICENSE`).
+22
View File
@@ -0,0 +1,22 @@
Vibe-Trading
Copyright 2026 HKUDS contributors.
This product includes software developed at HKUDS (https://github.com/HKUDS).
This product bundles Microsoft Qlib feature definitions under the Apache 2.0
license. See agent/src/factors/zoo/qlib158/NOTICE and LICENSE.md for the
upstream NOTICE and attribution.
Mathematical formulas from the following sources are reimplemented in this
repository as factual mathematical content (formulas are not copyrightable):
- Kakushadze, Z. (2015) "101 Formulaic Alphas" (arXiv:1601.00991)
See agent/src/factors/zoo/alpha101/LICENSE.md
- Guotai Junan Securities (2014) "191 Short-period Trading Alpha Factors"
See agent/src/factors/zoo/gtja191/LICENSE.md
- Fama-French five-factor model, Carhart momentum, Hou-Xue-Zhang q-factor
See agent/src/factors/zoo/academic/LICENSE.md
Prose, tables, and figures from the source papers and reports are NOT
reproduced in this repository. Only the mathematical formulas — which are
factual content and not subject to copyright — are reimplemented here.
+48
View File
@@ -0,0 +1,48 @@
"""Alpha Zoo: 4 zoos (alpha101 / gtja191 / qlib158 / academic) × ~450 alphas.
See `docs/alpha-zoo/spec.md` for the panel format contract and operator semantics.
"""
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,
)
__all__ = [
"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",
]
+273
View File
@@ -0,0 +1,273 @@
"""Alpha Zoo base operators.
Operators all act on **wide** ``pd.DataFrame`` where ``index = trading_date``
(DatetimeIndex) and ``columns = instrument_code`` (str). The factor compute
contract returns a DataFrame of the same shape — raw scores, NaN preserved
in warmup / missing data; +/- inf is forbidden (registry rejects it).
NaN policy: every operator propagates NaN; no silent ``fillna(0)``. A constant
window for ``ts_corr`` / ``ts_cov`` returns NaN, not zero.
Lookahead ban: ``delta(df, d)`` requires ``d >= 1``; the negative-shift
``Ref(df, -n)`` form is intentionally absent.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Protocol, runtime_checkable
import numpy as np
import pandas as pd
class Market(str, Enum):
"""Market identifier used by ``vwap`` for market-specific formulas."""
EQUITY_US = "equity_us"
EQUITY_CN = "equity_cn"
EQUITY_HK = "equity_hk"
CRYPTO = "crypto"
FUTURES = "futures"
@dataclass(frozen=True, slots=True)
class Alpha:
"""Lightweight handle for a registered alpha (registry-owned)."""
id: str
zoo: str
module_path: str
meta: dict[str, Any] = field(default_factory=dict)
@runtime_checkable
class AlphaCompute(Protocol):
"""Structural protocol every alpha ``.py`` module satisfies."""
def __call__(self, panel: dict[str, pd.DataFrame]) -> pd.DataFrame: ...
def _as_float(df: pd.DataFrame) -> pd.DataFrame:
if df.dtypes.eq(np.float64).all():
return df
return df.astype(np.float64)
def rank(df: pd.DataFrame) -> pd.DataFrame:
"""Cross-sectional percentile rank per row (axis=1, ties=average, pct=True).
NaN inputs stay NaN. An all-NaN row returns an all-NaN row.
"""
return df.rank(axis=1, method="average", pct=True, na_option="keep")
def scale(df: pd.DataFrame, a: float = 1.0) -> pd.DataFrame:
"""Per-row L1 normalize so sum of absolute values equals ``a``.
Rows whose abs-sum is 0 (or all-NaN) become NaN — never silent zero.
"""
df = _as_float(df)
abs_sum = df.abs().sum(axis=1, skipna=True)
abs_sum = abs_sum.where(abs_sum > 0) # zero → NaN
return df.mul(a).div(abs_sum, axis=0)
def ts_rank(df: pd.DataFrame, n: int) -> pd.DataFrame:
"""Rolling rank (last value's rank within the n-window), per column.
Warmup (first ``n-1`` rows per column) returns NaN. Result is a percentile
in [0, 1] so it is compositionally compatible with cross-sectional rank.
"""
if n < 1:
raise ValueError(f"ts_rank window must be >= 1, got {n}")
def _last_rank(arr: np.ndarray) -> float:
if np.isnan(arr).all():
return np.nan
last = arr[-1]
if np.isnan(last):
return np.nan
valid = arr[~np.isnan(arr)]
if valid.size == 0:
return np.nan
# average rank for ties; pct
less = (valid < last).sum()
eq = (valid == last).sum()
rank_avg = less + 0.5 * (eq + 1)
return float(rank_avg / valid.size)
return df.rolling(window=n, min_periods=n).apply(_last_rank, raw=True)
def ts_corr(x: pd.DataFrame, y: pd.DataFrame, n: int) -> pd.DataFrame:
"""Rolling Pearson correlation per column, min_periods=n.
Constant series in the window → NaN (no silent zero). Pairs are inner-joined
on columns; columns missing from either side become NaN.
"""
if n < 2:
raise ValueError(f"ts_corr window must be >= 2, got {n}")
x = _as_float(x)
y = _as_float(y)
cols = x.columns.union(y.columns)
xa = x.reindex(columns=cols)
ya = y.reindex(columns=cols)
corr = xa.rolling(window=n, min_periods=n).corr(ya)
# corr above can produce +/- inf when one series is constant in some
# pandas versions; force to NaN.
return corr.replace([np.inf, -np.inf], np.nan)
def ts_cov(x: pd.DataFrame, y: pd.DataFrame, n: int) -> pd.DataFrame:
"""Rolling sample covariance per column, min_periods=n."""
if n < 2:
raise ValueError(f"ts_cov window must be >= 2, got {n}")
x = _as_float(x)
y = _as_float(y)
cols = x.columns.union(y.columns)
xa = x.reindex(columns=cols)
ya = y.reindex(columns=cols)
cov = xa.rolling(window=n, min_periods=n).cov(ya)
return cov.replace([np.inf, -np.inf], np.nan)
def ts_mean(df: pd.DataFrame, n: int) -> pd.DataFrame:
"""Rolling mean per column, warmup → NaN."""
if n < 1:
raise ValueError(f"ts_mean window must be >= 1, got {n}")
return df.rolling(window=n, min_periods=n).mean()
def ts_std(df: pd.DataFrame, n: int) -> pd.DataFrame:
"""Rolling sample std (ddof=1) per column, warmup → NaN."""
if n < 2:
raise ValueError(f"ts_std window must be >= 2, got {n}")
return df.rolling(window=n, min_periods=n).std(ddof=1)
def ts_max(df: pd.DataFrame, n: int) -> pd.DataFrame:
"""Rolling max per column, warmup → NaN."""
if n < 1:
raise ValueError(f"ts_max window must be >= 1, got {n}")
return df.rolling(window=n, min_periods=n).max()
def ts_min(df: pd.DataFrame, n: int) -> pd.DataFrame:
"""Rolling min per column, warmup → NaN."""
if n < 1:
raise ValueError(f"ts_min window must be >= 1, got {n}")
return df.rolling(window=n, min_periods=n).min()
def _argmax_last(arr: np.ndarray) -> float:
if np.isnan(arr).all():
return np.nan
arr_filled = np.where(np.isnan(arr), -np.inf, arr)
return float(np.argmax(arr_filled))
def _argmin_last(arr: np.ndarray) -> float:
if np.isnan(arr).all():
return np.nan
arr_filled = np.where(np.isnan(arr), np.inf, arr)
return float(np.argmin(arr_filled))
def ts_argmax(df: pd.DataFrame, n: int) -> pd.DataFrame:
"""Rolling argmax (0-based index into the window), warmup → NaN."""
if n < 1:
raise ValueError(f"ts_argmax window must be >= 1, got {n}")
return df.rolling(window=n, min_periods=n).apply(_argmax_last, raw=True)
def ts_argmin(df: pd.DataFrame, n: int) -> pd.DataFrame:
"""Rolling argmin (0-based index into the window), warmup → NaN."""
if n < 1:
raise ValueError(f"ts_argmin window must be >= 1, got {n}")
return df.rolling(window=n, min_periods=n).apply(_argmin_last, raw=True)
def delta(df: pd.DataFrame, d: int) -> pd.DataFrame:
"""First difference at lag ``d``: ``df - df.shift(d)``.
Lookahead ban: ``d >= 1`` strictly. Negative lag forbidden.
"""
if d < 1:
raise ValueError(f"delta lag must be >= 1 (lookahead ban), got {d}")
return df - df.shift(d)
def decay_linear(df: pd.DataFrame, n: int) -> pd.DataFrame:
"""Linear decay-weighted moving average, weights ``n, n-1, ..., 1`` normalized.
Warmup (first ``n-1`` rows) → NaN.
"""
if n < 1:
raise ValueError(f"decay_linear window must be >= 1, got {n}")
weights = np.arange(n, 0, -1, dtype=np.float64)
weights /= weights.sum()
def _apply(arr: np.ndarray) -> float:
if np.isnan(arr).any():
return np.nan
return float(np.dot(arr, weights))
return df.rolling(window=n, min_periods=n).apply(_apply, raw=True)
def signed_power(df: pd.DataFrame, p: float) -> pd.DataFrame:
"""``sign(df) * |df|**p`` — preserves sign; never produces complex output."""
arr = df.to_numpy(dtype=np.float64, na_value=np.nan)
out = np.sign(arr) * np.power(np.abs(arr), p)
return pd.DataFrame(out, index=df.index, columns=df.columns)
def safe_div(a: pd.DataFrame, b: pd.DataFrame, eps: float = 1e-12) -> pd.DataFrame:
"""Safe division: ``a / (b + eps * sign(b))``.
Where ``b == 0`` exactly (or NaN), result is NaN — never silently inf or 0.
"""
a = _as_float(a)
b = _as_float(b)
sign = np.sign(b.to_numpy(dtype=np.float64, na_value=np.nan))
# b == 0 → sign == 0 → denom == 0 → NaN below
denom_arr = b.to_numpy(dtype=np.float64, na_value=np.nan) + eps * sign
denom = pd.DataFrame(denom_arr, index=b.index, columns=b.columns)
result = a.div(denom)
return result.replace([np.inf, -np.inf], np.nan)
def vwap(panel: dict[str, pd.DataFrame], market: Market | str) -> pd.DataFrame:
"""Market-aware VWAP-equivalent reference price.
- ``equity_cn``: ``(amount * 1000) / (volume * 100 + 1)`` — Tushare's
``daily.amount`` is in **千元 (thousand CNY)** and ``daily.vol`` is in
**手 (100 shares)**. Probe 2026-05-17 against ``000001.SZ`` shows
``amount/(vol*100) ≈ 0.0093`` for a close of 9.27 — confirming the 1000x
scale. We multiply ``amount`` by 1000 (CNY) and divide by
``volume * 100`` (shares); ``+1`` keeps the denominator positive on
suspended bars.
- ``equity_us`` / ``equity_hk`` / ``futures``: typical price ``(H + L + C + O) / 4``
when ``panel["vwap"]`` is absent.
- ``crypto``: prefer ``panel["vwap"]`` if provided, else typical price.
Any missing required column → NaN propagation; never silent zero.
"""
if isinstance(market, str):
market = Market(market)
if "vwap" in panel:
return panel["vwap"]
if market is Market.EQUITY_CN:
if "amount" not in panel or "volume" not in panel:
raise KeyError("vwap(equity_cn) requires panel['amount'] and panel['volume']")
return safe_div(panel["amount"] * 1000.0, panel["volume"] * 100.0 + 1.0)
required = ("open", "high", "low", "close")
missing = [k for k in required if k not in panel]
if missing:
raise KeyError(f"vwap({market.value}) requires panel keys {required}; missing {missing}")
return (panel["open"] + panel["high"] + panel["low"] + panel["close"]) / 4.0
+227
View File
@@ -0,0 +1,227 @@
"""Bench runner: compute IC stats for every alpha in a zoo over one universe.
Extracted from ``agent/scripts/w4a_run_benches.py`` so the same pipeline can be
called by:
- the CLI bench driver (``w4a_run_benches.py``)
- the Web UI background worker (``src/api/alpha_routes.py``)
The math is unchanged — only the carrier moved. Categorisation thresholds:
- ``alive`` : ic_mean > 0.02 and ic_positive_ratio >= 0.55 and |t| > 2
- ``reversed`` : ic_mean < -0.02 and |t| > 2
- ``dead`` : everything else
"""
from __future__ import annotations
import logging
import math
import time
from typing import Any, Callable
from src.factors.factor_analysis_core import compute_ic_series
from src.factors.registry import (
Registry,
RegistryError,
SkipAlpha,
get_default_registry,
)
from src.tools.alpha_bench_tool import _compute_forward_returns, _load_universe_panel
logger = logging.getLogger(__name__)
ProgressCb = Callable[[int, int, str], None]
"""Signature: ``on_progress(n_done, n_total, current_alpha_id)``."""
def t_stat(ic_mean: float, ic_std: float, n: int) -> float:
"""Two-sided t-statistic of the IC series."""
if not (n > 0 and ic_std > 0 and math.isfinite(ic_std)):
return 0.0
return ic_mean / (ic_std / math.sqrt(n))
def categorise(row: dict[str, Any]) -> str:
"""Bucket a per-alpha row into alive / reversed / dead."""
ic_mean = row["ic_mean"]
pos = row["ic_positive_ratio"]
t = t_stat(ic_mean, row["ic_std"], row["ic_count"])
if ic_mean > 0.02 and pos >= 0.55 and abs(t) > 2:
return "alive"
if ic_mean < -0.02 and abs(t) > 2:
return "reversed"
return "dead"
def theme_breakdown(rows: list[dict[str, Any]]) -> dict[str, dict[str, int]]:
"""Aggregate alive/reversed/dead counts by theme tag."""
by_theme: dict[str, dict[str, int]] = {}
for row in rows:
cat = row["_category"]
themes = row.get("theme", []) or ["uncategorised"]
for theme in themes:
bucket = by_theme.setdefault(
theme, {"alive": 0, "reversed": 0, "dead": 0, "count": 0}
)
bucket[cat] += 1
bucket["count"] += 1
return by_theme
def run_bench(
zoo: str,
universe: str,
period: str,
top: int = 20,
on_progress: ProgressCb | None = None,
registry: Registry | None = None,
) -> dict[str, Any]:
"""Run a bench end-to-end and return the API-shaped summary.
Args:
zoo: Zoo id (e.g. ``gtja191``, ``alpha101``, ``qlib158``).
universe: Universe key understood by ``_load_universe_panel``
(e.g. ``csi300``, ``sp500``, ``btc-usdt``).
period: ``YYYY-YYYY`` or ``YYYY-MM-DD/YYYY-MM-DD`` window.
top: Number of top-IR alphas to keep in ``top5_by_ir`` and
``dead_examples`` (capped at ``top``).
on_progress: Optional callback fired after every alpha completes
(success or skip). Signature: ``(n_done, n_total, alpha_id)``.
registry: Optional pre-built registry (test injection); defaults to a
fresh ``Registry()``.
Returns:
Dict with keys: ``status``, ``zoo``, ``universe``, ``period``,
``n_alphas_tested``, ``n_skipped``, ``alive``, ``reversed``, ``dead``,
``by_theme``, ``top5_by_ir``, ``dead_examples``, ``wall_seconds`` —
and on failure: ``status="error"``, ``error``.
"""
start = time.monotonic()
entry: dict[str, Any] = {
"status": "pending",
"zoo": zoo,
"universe": universe,
"period": period,
}
reg = registry if registry is not None else get_default_registry()
alpha_ids = reg.list(zoo=zoo)
if not alpha_ids:
entry["status"] = "error"
entry["error"] = f"no alphas registered under zoo={zoo!r}"
entry["wall_seconds"] = round(time.monotonic() - start, 2)
return entry
try:
panel = _load_universe_panel(universe, period)
except (ValueError, NotImplementedError, RuntimeError) as exc:
entry["status"] = "error"
entry["error"] = f"universe load failed: {exc}"
entry["wall_seconds"] = round(time.monotonic() - start, 2)
return entry
try:
return_df = _compute_forward_returns(panel)
except Exception as exc: # noqa: BLE001
entry["status"] = "error"
entry["error"] = f"forward returns failed: {exc}"
entry["wall_seconds"] = round(time.monotonic() - start, 2)
return entry
rows: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
n_total = len(alpha_ids)
for idx, aid in enumerate(alpha_ids, start=1):
try:
factor_df = reg.compute(aid, panel)
ic = compute_ic_series(factor_df, return_df)
if ic.empty:
skipped.append(
{"id": aid, "reason": "empty IC series", "kind": "typed"}
)
else:
ic_mean = float(ic.mean())
ic_std = float(ic.std())
ir = ic_mean / ic_std if ic_std > 0 else 0.0
meta = reg.get(aid).meta or {}
rows.append(
{
"id": aid,
"ic_mean": round(ic_mean, 6),
"ic_std": round(ic_std, 6),
"ir": round(ir, 4),
"ic_positive_ratio": round(float((ic > 0).mean()), 4),
"ic_count": int(len(ic)),
"theme": meta.get("theme", []),
"formula_latex": meta.get("formula_latex", ""),
}
)
except (SkipAlpha, RegistryError, RuntimeError, KeyError, ValueError) as exc:
skipped.append({"id": aid, "reason": str(exc), "kind": "typed"})
except Exception as exc: # noqa: BLE001
logger.exception("bench: unexpected failure on %s", aid)
skipped.append(
{"id": aid, "reason": f"unexpected: {exc}", "kind": "unexpected"}
)
if on_progress is not None:
try:
on_progress(idx, n_total, aid)
except Exception: # noqa: BLE001 — never let progress break the loop
logger.exception("on_progress callback raised; ignoring")
for row in rows:
row["_category"] = categorise(row)
counts = {"alive": 0, "reversed": 0, "dead": 0}
for row in rows:
counts[row["_category"]] += 1
rows_by_ir = sorted(rows, key=lambda r: r["ir"], reverse=True)
rows_by_ic = sorted(rows, key=lambda r: r["ic_mean"])
def _slim(r: dict[str, Any]) -> dict[str, Any]:
return {
"id": r["id"],
"ic_mean": r["ic_mean"],
"ir": r["ir"],
"theme": r["theme"],
"formula_latex": r["formula_latex"],
"category": r["_category"],
}
# Forward universe metadata (e.g. survivorship_bias flag from sp500
# loader). Panel meta lives in panel["_meta"] when the loader chooses to
# set it; absence is fine.
universe_meta_raw = panel.get("_meta") if isinstance(panel, dict) else None
universe_meta: dict[str, Any] = {}
if universe_meta_raw is not None:
try:
# Could be a pandas object or a plain dict.
if hasattr(universe_meta_raw, "to_dict"):
universe_meta = dict(universe_meta_raw.to_dict())
else:
universe_meta = dict(universe_meta_raw)
except Exception: # noqa: BLE001
universe_meta = {"raw": str(universe_meta_raw)}
entry.update(
{
"status": "ok",
"n_alphas_tested": len(rows),
"n_skipped": len(skipped),
"alive": counts["alive"],
"reversed": counts["reversed"],
"dead": counts["dead"],
"by_theme": theme_breakdown(rows),
"top5_by_ir": [_slim(r) for r in rows_by_ir[: min(5, top)]],
"dead_examples": [_slim(r) for r in rows_by_ic[:5]],
"rows": rows,
"skipped": skipped,
"meta": universe_meta,
"wall_seconds": round(time.monotonic() - start, 2),
}
)
return entry
+127
View File
@@ -0,0 +1,127 @@
"""Pure IC/IR + layered backtest math shared by factor_analysis_tool and alpha_bench_tool."""
import numpy as np
import pandas as pd
from scipy.stats import spearmanr
_MIN_VALID_PER_DATE = 5
def compute_ic_series(factor_df: pd.DataFrame, return_df: pd.DataFrame) -> pd.Series:
"""Compute daily Spearman rank correlation (IC) between factor values and returns.
Vectorised via ``rank().corrwith()`` (Spearman = Pearson on ranks). A date
is dropped if fewer than 5 instruments have both a factor and return value
on that bar — matching the prior per-row guard.
Args:
factor_df: Factor values; index=date, columns=codes.
return_df: Returns; index=date, columns=codes.
Returns:
IC series indexed by date.
"""
common_dates = factor_df.index.intersection(return_df.index)
common_codes = factor_df.columns.intersection(return_df.columns)
if len(common_dates) == 0 or len(common_codes) == 0:
return pd.Series(dtype=float)
factor_df = factor_df.loc[common_dates, common_codes]
return_df = return_df.loc[common_dates, common_codes]
# Only rank cells where both factor and return are present (mirrors the
# per-date ``shared = f.dropna() ∩ r.dropna()`` from the loop version).
pair_mask = factor_df.notna() & return_df.notna()
n_valid = pair_mask.sum(axis=1)
factor_aligned = factor_df.where(pair_mask)
return_aligned = return_df.where(pair_mask)
# Spearman = Pearson on per-row ranks.
factor_ranks = factor_aligned.rank(axis=1, method="average")
return_ranks = return_aligned.rank(axis=1, method="average")
ic = factor_ranks.corrwith(return_ranks, axis=1, method="pearson")
ic = ic[n_valid >= _MIN_VALID_PER_DATE]
ic = ic.dropna()
if ic.empty:
return pd.Series(dtype=float)
return ic.astype(float)
# Kept available for callers/tests that want the slow reference implementation.
def _compute_ic_series_reference(
factor_df: pd.DataFrame, return_df: pd.DataFrame
) -> pd.Series:
"""Loop-based Spearman IC; retained as the numerical reference."""
common_dates = factor_df.index.intersection(return_df.index)
common_codes = factor_df.columns.intersection(return_df.columns)
if len(common_dates) == 0 or len(common_codes) == 0:
return pd.Series(dtype=float)
factor_df = factor_df.loc[common_dates, common_codes]
return_df = return_df.loc[common_dates, common_codes]
ic_values: dict = {}
for date in common_dates:
f = factor_df.loc[date].dropna()
r = return_df.loc[date].dropna()
shared = f.index.intersection(r.index)
if len(shared) < _MIN_VALID_PER_DATE:
continue
corr, _ = spearmanr(f[shared], r[shared])
if not np.isnan(corr):
ic_values[date] = corr
return pd.Series(ic_values, dtype=float)
def compute_group_equity(
factor_df: pd.DataFrame, return_df: pd.DataFrame, n_groups: int
) -> pd.DataFrame:
"""Layered backtest: rank by factor value daily, hold equal-weight, compute cumulative NAV.
Args:
factor_df: Factor values; index=date, columns=codes.
return_df: Returns; index=date, columns=codes.
n_groups: Number of quantile groups.
Returns:
DataFrame with index=date and columns Group_1 ... Group_N holding cumulative NAV.
"""
common_dates = sorted(factor_df.index.intersection(return_df.index))
common_codes = factor_df.columns.intersection(return_df.columns)
if len(common_dates) == 0 or len(common_codes) == 0:
return pd.DataFrame()
factor_df = factor_df.loc[common_dates, common_codes]
return_df = return_df.loc[common_dates, common_codes]
group_returns: dict[str, list[float]] = {f"Group_{i+1}": [] for i in range(n_groups)}
valid_dates = []
for date in common_dates:
f = factor_df.loc[date].dropna()
r = return_df.loc[date].dropna()
shared = f.index.intersection(r.index)
if len(shared) < n_groups:
continue
valid_dates.append(date)
ranked = f[shared].rank(method="first")
bins = pd.qcut(ranked, n_groups, labels=False, duplicates="drop")
if bins.nunique() < n_groups:
# Not enough distinct values; fall back to equal-width cut
bins = pd.cut(ranked, n_groups, labels=False)
for g in range(n_groups):
members = bins[bins == g].index
if len(members) > 0:
group_returns[f"Group_{g+1}"].append(r[members].mean())
else:
group_returns[f"Group_{g+1}"].append(0.0)
if not valid_dates:
return pd.DataFrame()
ret_df = pd.DataFrame(group_returns, index=valid_dates)
equity_df = (1 + ret_df).cumprod()
return equity_df
+411
View File
@@ -0,0 +1,411 @@
"""Alpha registry: AST-scan zoo modules, validate metadata, lazy-import on compute.
Design contract (frozen — referenced by W3 zoo-porting agents):
AlphaMeta (pydantic, ``extra="forbid", frozen=True``) — see fields below
Registry.list(zoo=None, theme=None, universe=None) -> list[str]
Registry.get(alpha_id) -> Alpha
Registry.compute(alpha_id, panel) -> pd.DataFrame
Registry.health() -> dict # {loaded, failed, errors}
Registry.export_manifest() -> dict
Registry.load_alpha_meta_from_py(path) -> AlphaMeta # AST, no import
Source of truth = ``__alpha_meta__`` dict literal in each ``zoo/<zoo>/<id>.py``.
``_meta.yaml`` files are generated by ``export-manifest`` for external consumers
(wiki) — not the load path. If we ever load a YAML, we use ``yaml.safe_load``
with a 5 MB size cap.
Module path derivation: ``f"src.factors.zoo.{zoo_id}.{alpha_id_short}"``. Both
``zoo_id`` and ``alpha_id_short`` must match ``^[a-z][a-z0-9_]{0,31}$``. We never
honour a ``py_module`` field from any data file.
"""
from __future__ import annotations
import ast
import importlib
import importlib.util
import logging
import re
import sys
import threading
from dataclasses import dataclass
from pathlib import Path
from types import ModuleType
from typing import Any, Literal
import numpy as np
import pandas as pd
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from src.factors.base import Alpha
logger = logging.getLogger(__name__)
_ID_RE = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
_MAX_YAML_BYTES = 5_000_000
_MAX_PY_BYTES = 200_000
Theme = Literal[
"momentum",
"reversal",
"volume",
"volatility",
"quality",
"value",
"liquidity",
"microstructure",
"sentiment",
"growth",
"leverage",
]
PanelColumn = Literal["open", "high", "low", "close", "volume", "vwap", "amount"]
Universe = Literal["equity_us", "equity_cn", "equity_hk", "crypto", "futures"]
class AlphaMeta(BaseModel):
"""Strict metadata schema; matches the ``__alpha_meta__`` dict literal."""
model_config = ConfigDict(extra="forbid", frozen=True)
id: str = Field(pattern=r"^[a-z][a-z0-9]+_[a-z0-9_]+$")
nickname: str | None = None
theme: list[Theme]
formula_latex: str
columns_required: list[PanelColumn]
extras_required: list[str] = Field(default_factory=list)
requires_sector: bool = False
universe: list[Universe]
frequency: list[str]
decay_horizon: int = Field(ge=0, le=60)
min_warmup_bars: int = Field(ge=0)
notes: str = ""
class SkipAlpha(Exception):
"""Raised when an alpha's preconditions (sector, columns) are not met."""
class RegistryError(Exception):
"""Raised on registry-level configuration errors."""
@dataclass(frozen=True, slots=True)
class _LoadError:
alpha_id: str
reason: str
def _validate_id_token(token: str, kind: str) -> None:
if not _ID_RE.fullmatch(token):
raise RegistryError(f"invalid {kind} {token!r}: must match {_ID_RE.pattern}")
def load_alpha_meta_from_py(path: Path) -> AlphaMeta:
"""AST-extract the ``__alpha_meta__`` dict literal from a zoo module.
No import is performed — purely static parsing. Raises ``RegistryError`` on
malformed metadata.
"""
size = path.stat().st_size
if size > _MAX_PY_BYTES:
raise RegistryError(f"{path.name}: {size}B exceeds {_MAX_PY_BYTES}B cap")
source = path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(path))
meta_node: ast.expr | None = None
for stmt in tree.body:
if not isinstance(stmt, ast.Assign):
continue
targets = [t for t in stmt.targets if isinstance(t, ast.Name)]
if any(t.id == "__alpha_meta__" for t in targets):
meta_node = stmt.value
break
if meta_node is None:
raise RegistryError(f"{path.name}: __alpha_meta__ assignment not found")
try:
raw = ast.literal_eval(meta_node)
except (ValueError, SyntaxError) as exc:
raise RegistryError(f"{path.name}: __alpha_meta__ not a literal: {exc}") from exc
if not isinstance(raw, dict):
raise RegistryError(f"{path.name}: __alpha_meta__ must be dict, got {type(raw).__name__}")
try:
return AlphaMeta(**raw)
except ValidationError as exc:
raise RegistryError(f"{path.name}: AlphaMeta validation failed: {exc}") from exc
def _safe_yaml_load(path: Path) -> Any:
"""yaml.safe_load with hard 5 MB size cap (defence in depth)."""
import yaml # local import keeps registry import-light
size = path.stat().st_size
if size > _MAX_YAML_BYTES:
raise RegistryError(f"{path.name}: {size}B exceeds {_MAX_YAML_BYTES}B YAML cap")
text = path.read_text(encoding="utf-8")
return yaml.safe_load(text)
def _zoo_dir_default() -> Path:
return Path(__file__).parent / "zoo"
class Registry:
"""In-memory registry of all discoverable alphas across zoo subdirectories."""
def __init__(self, zoo_root: Path | None = None) -> None:
default_root = _zoo_dir_default()
self._zoo_root = (zoo_root or default_root).resolve()
# When the registry points at the bundled zoo dir, modules are loaded
# by package name (warm import cache, supports relative imports). For
# any other zoo_root (tests, plugins) we load by file path so the
# caller doesn't have to fiddle with sys.path.
self._use_filesystem_loader = self._zoo_root != default_root.resolve()
self._py_paths: dict[str, Path] = {}
self._alphas: dict[str, Alpha] = {}
self._load_errors: list[_LoadError] = []
self._scan()
# ------------------------- scanning -------------------------
def _scan(self) -> None:
if not self._zoo_root.is_dir():
return
for zoo_dir in sorted(self._zoo_root.iterdir()):
if not zoo_dir.is_dir():
continue
zoo_id = zoo_dir.name
if zoo_id.startswith("_") or zoo_id == "__pycache__":
continue
try:
_validate_id_token(zoo_id, "zoo_id")
except RegistryError as exc:
self._load_errors.append(_LoadError(zoo_id, str(exc)))
continue
for py_file in sorted(zoo_dir.glob("*.py")):
if py_file.name.startswith("_"):
continue
self._try_register(zoo_id, py_file)
def _try_register(self, zoo_id: str, py_file: Path) -> None:
short_id = py_file.stem
try:
_validate_id_token(short_id, "alpha_id_short")
except RegistryError as exc:
self._load_errors.append(_LoadError(f"{zoo_id}.{short_id}", str(exc)))
return
try:
meta = load_alpha_meta_from_py(py_file)
except RegistryError as exc:
self._load_errors.append(_LoadError(f"{zoo_id}.{short_id}", str(exc)))
return
# Cross-check: filename matches alpha id (defence vs typos)
# id is "<zoo>_<short>" by convention; not strictly required but flagged.
module_path = f"src.factors.zoo.{zoo_id}.{short_id}"
alpha = Alpha(id=meta.id, zoo=zoo_id, module_path=module_path, meta=meta.model_dump())
if alpha.id in self._alphas:
self._load_errors.append(_LoadError(alpha.id, "duplicate alpha id"))
return
self._alphas[alpha.id] = alpha
self._py_paths[alpha.id] = py_file
# ------------------------- public API -------------------------
def list(
self,
zoo: str | None = None,
theme: str | None = None,
universe: str | None = None,
) -> list[str]:
"""Return alpha IDs matching the (optional) filters."""
out: list[str] = []
for a in self._alphas.values():
if zoo is not None and a.zoo != zoo:
continue
if theme is not None and theme not in a.meta.get("theme", []):
continue
if universe is not None and universe not in a.meta.get("universe", []):
continue
out.append(a.id)
return sorted(out)
def get(self, alpha_id: str) -> Alpha:
if alpha_id not in self._alphas:
raise KeyError(f"alpha_id {alpha_id!r} not in registry")
return self._alphas[alpha_id]
def get_source(self, alpha_id: str) -> str:
"""Return the raw .py source of a registered alpha (size-capped).
Raises:
KeyError: alpha_id unknown.
RegistryError: source file exceeds ``_MAX_PY_BYTES`` or cannot be read.
"""
if alpha_id not in self._alphas:
raise KeyError(f"alpha_id {alpha_id!r} not in registry")
py_path = self._py_paths.get(alpha_id)
if py_path is None:
raise RegistryError(f"{alpha_id}: no source path recorded")
try:
size = py_path.stat().st_size
except OSError as exc:
raise RegistryError(f"{alpha_id}: cannot stat source: {exc}") from exc
if size > _MAX_PY_BYTES:
raise RegistryError(
f"{alpha_id}: source {size}B exceeds {_MAX_PY_BYTES}B cap"
)
try:
return py_path.read_text(encoding="utf-8")
except OSError as exc:
raise RegistryError(f"{alpha_id}: cannot read source: {exc}") from exc
def health(self) -> dict[str, Any]:
return {
"loaded": len(self._alphas),
"failed": len(self._load_errors),
"errors": [
{"alpha_id": e.alpha_id, "reason": e.reason} for e in self._load_errors
],
}
def compute(self, alpha_id: str, panel: dict[str, pd.DataFrame]) -> pd.DataFrame:
"""Lazy-import the alpha module and run its ``compute(panel)``.
Raises:
KeyError: alpha_id unknown.
SkipAlpha: required column / sector tag absent in panel.
RegistryError: import/compute failed or output failed sanity checks.
"""
alpha = self.get(alpha_id)
meta = alpha.meta
missing = [c for c in meta.get("columns_required", []) if c not in panel]
if missing:
raise SkipAlpha(f"{alpha_id}: panel missing required columns {missing}")
missing_extra = [c for c in meta.get("extras_required", []) if c not in panel]
if missing_extra:
raise SkipAlpha(f"{alpha_id}: panel missing extras {missing_extra}")
if meta.get("requires_sector") and "sector" not in panel:
raise SkipAlpha(f"{alpha_id}: panel missing sector tag")
try:
module = self._load_module(alpha)
except Exception as exc: # noqa: BLE001 — isolate import failure
raise RegistryError(f"{alpha_id}: import failed: {exc}") from exc
compute_fn = getattr(module, "compute", None)
if compute_fn is None:
raise RegistryError(f"{alpha_id}: module has no compute() function")
try:
result = compute_fn(panel)
except Exception as exc: # noqa: BLE001 — isolate compute failure
raise RegistryError(f"{alpha_id}: compute() raised: {exc}") from exc
return self._validate_output(alpha_id, result, panel)
def _load_module(self, alpha: Alpha) -> ModuleType:
if not self._use_filesystem_loader:
return importlib.import_module(alpha.module_path)
py_file = self._py_paths[alpha.id]
cached = sys.modules.get(alpha.module_path)
if cached is not None and getattr(cached, "__file__", None) == str(py_file):
return cached
spec = importlib.util.spec_from_file_location(alpha.module_path, py_file)
if spec is None or spec.loader is None:
raise RegistryError(f"{alpha.id}: could not build import spec for {py_file}")
module = importlib.util.module_from_spec(spec)
sys.modules[alpha.module_path] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(alpha.module_path, None)
raise
return module
@staticmethod
def _validate_output(
alpha_id: str,
result: Any,
panel: dict[str, pd.DataFrame],
) -> pd.DataFrame:
if not isinstance(result, pd.DataFrame):
raise RegistryError(
f"{alpha_id}: compute() returned {type(result).__name__}, expected DataFrame"
)
ref = panel.get("close")
if ref is not None and result.shape != ref.shape:
raise RegistryError(
f"{alpha_id}: output shape {result.shape} != close shape {ref.shape}"
)
arr = result.to_numpy(dtype=np.float64, na_value=np.nan)
if np.isinf(arr).any():
raise RegistryError(f"{alpha_id}: output contains +/- inf")
nan_ratio = float(np.isnan(arr).mean()) if arr.size > 0 else 1.0
if nan_ratio > 0.95:
raise RegistryError(f"{alpha_id}: output >95% NaN (nan_ratio={nan_ratio:.3f})")
return result
def export_manifest(self) -> dict[str, Any]:
"""Return a JSON-serialisable snapshot for wiki rendering."""
from datetime import datetime, timezone
zoos: dict[str, list[dict[str, Any]]] = {}
for a in self._alphas.values():
zoos.setdefault(a.zoo, []).append(
{
"id": a.id,
"module_path": a.module_path,
"meta": a.meta,
}
)
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"zoos": [
{"zoo_id": zoo_id, "alphas": sorted(items, key=lambda x: x["id"])}
for zoo_id, items in sorted(zoos.items())
],
"health": self.health(),
}
# ---------------------------------------------------------------------------
# Process-wide singleton for hot paths (API, bench tool).
#
# Building a Registry() scans ~450 .py files via AST. Doing that on every API
# request is wasteful — the bundled zoo never changes inside a single process.
# Callers that need a custom zoo_root (tests, plugins) should keep instantiating
# Registry() directly; the singleton is only for the default bundled zoo.
# ---------------------------------------------------------------------------
_registry_cache: "Registry | None" = None
_registry_cache_lock = threading.Lock()
def get_default_registry() -> Registry:
"""Return a process-wide cached ``Registry`` for the bundled zoo.
Thread-safe. First call builds and caches; subsequent calls return the same
instance. Use this on hot paths (API handlers, bench tool); use
``Registry()`` directly when you need a custom ``zoo_root``.
"""
global _registry_cache
with _registry_cache_lock:
if _registry_cache is None:
_registry_cache = Registry()
return _registry_cache
def reset_default_registry() -> None:
"""Drop the cached registry (test hook; do not call in production)."""
global _registry_cache
with _registry_cache_lock:
_registry_cache = None
View File
Whitespace-only changes.
+57
View File
@@ -0,0 +1,57 @@
---
name: alpha-zoo
description: Browse and bench the bundled alpha zoos — prebuilt cross-sectional factor libraries (Kakushadze 101, GTJA 191, Qlib 158, Fama-French / Carhart). Use when the user asks "which alphas exist", wants metadata on a named alpha, or wants to run IC/IR on a whole zoo over a universe.
category: research
---
# Alpha Zoo
## Purpose
When the user asks about prebuilt cross-sectional alphas — Kakushadze 101, GTJA 191, Qlib 158, Fama-French / Carhart — or wants to bench a whole zoo on an investable universe (CSI 300, S&P 500, BTC-USDT, ...), this skill orients you. The zoo is the curated library; the bench is the evaluator.
## Tools Available
| Tool | When to use |
|------|------|
| `alpha_zoo` | Browse the library. `action=list_alphas` to enumerate (filterable by zoo / theme / universe), `action=get_alpha` for one alpha's metadata, `action=health` for registry load status. |
| `alpha_bench` | Run IC / IR on one alpha or a whole zoo over a universe + period. Emits an HTML report. |
| `factor_analysis` | Ad-hoc factor evaluation from a user-supplied factor CSV + return CSV. Use this when the user has their own factor (not in the zoo). |
## Decision Tree
- "list all momentum alphas" → `alpha_zoo` with `action=list_alphas, theme=momentum`.
- "show me gtja191_alpha_001" → `alpha_zoo` with `action=get_alpha, alpha_id=gtja191_alpha_001`.
- "bench all of GTJA 191 on CSI 300 from 2020 to 2024" → `alpha_bench` with `zoo=gtja191, universe=csi300, period=2020-2024`.
- "is the registry healthy" → `alpha_zoo` with `action=health` — surfaces `loaded`, `failed`, and per-error reasons.
- User uploads `my_factor.csv` → `factor_analysis` (zoo tools are for prebuilt alphas only).
## Zoo Inventory
| Zoo | Description | Approx. count |
|------|------|------|
| `kakushadze101` | Formulaic alphas from Kakushadze's 2015 paper. Mix of momentum, reversal, volume, and microstructure. | ~101 |
| `gtja191` | Guotai Junan 191 alphas — A-share focused cross-sectional factors. | ~191 |
| `qlib158` | Microsoft Qlib's 158 alpha factors — features tuned for ML pipelines. | ~158 |
| `classical` | Fama-French 3/5-factor + Carhart momentum. | <10 |
Counts are nominal; check `alpha_zoo action=health` for the live count currently loaded.
## Constraints
- **No per-stock per-date factor values are surfaced to the agent.** IC results are aggregate stats (mean / std / IR / positive-ratio); the HTML report shows top-N by IR plus formulas, never the underlying panel.
- **Lookahead is banned in the operator set.** `delta(df, d)` requires `d >= 1`; the negative-shift `Ref(df, -n)` form does not exist. See `docs/alpha-zoo/spec.md` for the full operator catalogue.
- **Universe loaders may not be wired for every market yet.** When `alpha_bench` returns `universe loader for X not yet implemented`, that's the W2 scaffold — the universe is recognised but the data pull lands in W4.
- **Do not expose absolute filesystem paths in agent output.** The bench tool writes to `~/.vibe-trading/reports/` by default; refer to it by that shorthand, not by the resolved absolute path.
- **`alpha_zoo` is read-only.** `alpha_bench` writes a single HTML file per run — no scratch state elsewhere.
## Common Pitfalls
- Filter mismatch on `list_alphas`: theme / universe must match the alpha's declared metadata exactly (e.g. `equity_cn`, not `cn` or `china`).
- Calling `alpha_bench` with both `alpha_id` and `zoo` set — they are mutually exclusive; pick one.
- Empty registry (`loaded=0`) means no zoo modules are populated yet; treat it as "zoos pending W3 porting" rather than a bug.
## Reference
- Operator catalogue: `docs/alpha-zoo/spec.md`
- Registry contract: `src/factors/registry.py` (frozen; do not modify)
- IC / layered NAV math: `src/factors/factor_analysis_core.py`
+16
View File
@@ -138,3 +138,19 @@ First orthogonalize the factors with the Schmidt process to remove collinearity,
```bash
pip install pandas numpy scipy
```
## Calling Zoo Factors
Rather than recompute factors from raw OHLCV every research iteration, prefer reusing the 450+ pre-built alphas in the Alpha Zoo registry. Each alpha is metadata-validated (`AlphaMeta` schema with `theme`, `universe`, `columns_required`, `decay_horizon`, `min_warmup_bars`), shape-checked against `panel["close"]`, and rejected if it emits `+/- inf` or >95% NaN — so the factor CSV you feed to `factor_analysis` is already sanity-checked.
```python
from src.factors.registry import Registry
registry = Registry()
ids = registry.list(theme="momentum", universe="equity_cn") # filter the catalogue
factor_panel = registry.compute("alpha101_001", panel) # wide DataFrame, same shape as panel["close"]
factor_panel.to_csv("factor_alpha101_001.csv") # ready for factor_analysis tool
```
For combining several validated alphas into one composite signal, see the `multi-factor` skill's `ZooSignalEngine` (it z-scores, weights, and ranks alphas for you, with per-alpha skip isolation). For browsing the catalogue and inspecting individual `__alpha_meta__` records, see the `alpha-zoo` skill.
+21
View File
@@ -55,3 +55,24 @@ pip install pandas numpy
## Signal Convention
- `1/N` = selected into TopN (equal-weight long), `0` = not selected
## Zoo Signal Engine (new in 0.1.8)
When the user wants to compose 1-N alphas drawn from the Alpha Zoo (450+ pre-built factors) into a multi-factor strategy, use `ZooSignalEngine.from_zoo(...)` from `zoo_signal_engine.py` instead of the old per-symbol `example_signal_engine.py`. The new engine operates on wide-panel `dict[str, pd.DataFrame]` inputs (the same shape the registry's `Alpha.compute(panel)` contract uses), redistributes weights when any alpha fails or is skipped, and supports long-only (`top_n`), short-only (`bottom_n`), and long-short (`top_n` + `bottom_n`) signal modes. It also exposes a `generate(data_map)` adapter so it drops straight into the existing `run_backtest` pipelines.
```python
from src.factors.registry import Registry
from zoo_signal_engine import ZooSignalEngine
registry = Registry()
# Browse candidates with registry.list(theme="momentum") -- see the alpha-zoo skill.
alpha_ids = ["alpha101_001", "alpha101_012", "guotai_191_003"]
engine = ZooSignalEngine.from_zoo(alpha_ids, top_n=10, bottom_n=10, standardize=True)
# Feed into a panel-aware backtest, or via .generate(data_map) into the bundled engines.
signal_panel = engine.compute_signal(panel) # DataFrame, same shape as panel["close"]
```
Cross-references:
- See the `alpha-zoo` skill for browsing the alpha catalogue, filtering by `theme`/`universe`, and inspecting `__alpha_meta__` records.
- `example_signal_engine.py` is kept for legacy per-symbol workflows that compute factors directly from raw OHLCV; **new code should prefer `zoo_signal_engine.py`** so it benefits from the 450+ zoo alphas, registry-level NaN/inf guardrails, and per-alpha skip isolation.
@@ -0,0 +1,378 @@
"""Composite signal engine over Alpha Zoo factors.
Ingests one or more alphas from the Alpha Zoo registry and combines them into
a single signal panel. Unlike ``example_signal_engine`` (per-symbol API that
takes ``dict[code, OHLCV DataFrame]`` and returns ``dict[code, Series]``), this
engine consumes a wide-panel ``dict[str, pd.DataFrame]`` -- the same shape the
registry's ``Alpha.compute(panel)`` contract uses -- and produces a panel
signal of the same shape as ``panel["close"]``.
Pipeline (executed by ``compute_signal``):
1. For each ``alpha_id`` in ``alpha_ids``, call ``Registry.compute(alpha_id, panel)``.
Alphas that ``SkipAlpha`` or fail with ``RegistryError`` are logged and
excluded; their weight is redistributed across the surviving alphas.
2. Optionally cross-sectionally z-score each alpha panel per date.
3. Weighted sum across survivors (equal weights when ``weights is None``).
4. If ``top_n`` and/or ``bottom_n`` are set, convert the composite to
discrete positions in ``{-1.0, 0.0, +1.0}`` (long top-N, short bottom-N,
long-short when both are set). Otherwise return the raw composite score
and let the caller decide how to size positions.
NaN policy: NaN is preserved wherever every alpha is NaN for that cell, or
when the row had no valid cross-sectional data to standardize. We never
silently fill with zero.
Module loading: the multi-factor skill directory contains a hyphen, so this
file is not import-friendly via ``import agent.src.skills.multi-factor``.
``Registry`` is imported lazily inside ``compute_signal`` so the module
loads even when ``src.factors`` is not on ``sys.path`` (callers that only
want the dataclass shape can still import it for inspection/testing).
"""
import logging
from dataclasses import dataclass, field
from typing import Any, Optional, Tuple
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
def _zscore_cross_section(df: pd.DataFrame) -> pd.DataFrame:
"""Per-row z-score (mean 0, std 1). Rows with zero std become NaN.
Args:
df: Wide panel, ``index = date``, ``columns = instrument``.
Returns:
Standardized panel of the same shape; rows with <2 valid values or
zero std become all-NaN (never silent zero).
"""
mean = df.mean(axis=1, skipna=True)
std = df.std(axis=1, ddof=1, skipna=True)
std = std.where(std > 1e-12) # zero std -> NaN
return df.sub(mean, axis=0).div(std, axis=0)
@dataclass(frozen=True)
class ZooSignalEngine:
"""Composite signal engine over Alpha Zoo factors.
Attributes:
alpha_ids: Ordered tuple of alpha IDs to combine.
weights: Optional per-alpha weights; defaults to equal weights. Must
match ``len(alpha_ids)`` when given. Surviving-alpha weights are
re-normalized when any alpha is skipped at compute time.
standardize: If True, cross-sectionally z-score each alpha per date
before combining. Recommended whenever alphas have heterogeneous
scales.
top_n: If set, the top-N names by composite score on each date take
``+1.0`` and everyone else takes ``0.0`` (long-only). Must be
positive.
bottom_n: Symmetric short side -- bottom-N names take ``-1.0``. Can
coexist with ``top_n`` for long-short signals. Must be positive.
"""
alpha_ids: Tuple[str, ...]
weights: Optional[Tuple[float, ...]] = None
standardize: bool = True
top_n: Optional[int] = None
bottom_n: Optional[int] = None
# Optional injected registry (chiefly for tests); when ``None`` we lazily
# construct ``Registry()`` on first compute_signal call. Typed as ``Any``
# to avoid dataclass forward-ref resolution at class-creation time when
# the module is loaded via ``importlib.util.spec_from_file_location``.
_registry: Any = field(default=None, repr=False, compare=False)
def __post_init__(self) -> None:
if not isinstance(self.alpha_ids, tuple):
# Allow list at construction site for ergonomic from_zoo; freeze here.
object.__setattr__(self, "alpha_ids", tuple(self.alpha_ids))
if len(self.alpha_ids) == 0:
raise ValueError("alpha_ids must contain at least one alpha_id")
if self.weights is not None:
weights = tuple(self.weights)
if len(weights) != len(self.alpha_ids):
raise ValueError(
f"weights length {len(weights)} != alpha_ids length {len(self.alpha_ids)}"
)
object.__setattr__(self, "weights", weights)
if self.top_n is not None and self.top_n <= 0:
raise ValueError(f"top_n must be positive, got {self.top_n}")
if self.bottom_n is not None and self.bottom_n <= 0:
raise ValueError(f"bottom_n must be positive, got {self.bottom_n}")
# ── Constructors ──
@classmethod
def from_zoo(
cls,
alpha_ids,
weights=None,
*,
standardize: bool = True,
top_n: Optional[int] = None,
bottom_n: Optional[int] = None,
registry: Any = None,
) -> "ZooSignalEngine":
"""Build an engine that pulls alphas from the (bundled) Alpha Zoo.
Args:
alpha_ids: Alpha IDs to combine, e.g. ``["alpha101_001", "guotai_002"]``.
weights: Optional per-alpha weights; defaults to equal weights.
standardize: Cross-sectionally z-score each alpha per date.
top_n: Long top-N names (positive integer).
bottom_n: Short bottom-N names (positive integer).
registry: Pre-built ``Registry`` (chiefly for tests / alternative
zoo roots). When ``None``, a default ``Registry()`` is built
lazily on first ``compute_signal`` call.
Returns:
An immutable ``ZooSignalEngine`` instance.
"""
return cls(
alpha_ids=tuple(alpha_ids),
weights=tuple(weights) if weights is not None else None,
standardize=standardize,
top_n=top_n,
bottom_n=bottom_n,
_registry=registry,
)
# ── Core compute ──
def compute_signal(self, panel: dict[str, pd.DataFrame]) -> pd.DataFrame:
"""Compose the requested alphas into a single signal panel.
Args:
panel: Wide panel dict with at minimum ``panel["close"]`` -- index
is the date axis, columns are instrument codes. Other keys
(``open``/``high``/``low``/``volume``/``vwap``/``amount``/...)
are forwarded to each alpha's ``compute(panel)``.
Returns:
DataFrame of the same shape as ``panel["close"]``. When ``top_n``
and/or ``bottom_n`` are set, values are in ``{-1.0, 0.0, +1.0}``
(NaN positions become 0). Otherwise the raw composite score is
returned with NaN preserved.
Raises:
ValueError: ``panel`` lacks the ``close`` reference frame.
"""
ref = panel.get("close")
if ref is None:
raise ValueError("panel must contain a 'close' DataFrame to anchor output shape")
registry = self._registry if self._registry is not None else _default_registry()
per_alpha: list[pd.DataFrame] = []
per_alpha_weights: list[float] = []
for idx, alpha_id in enumerate(self.alpha_ids):
w = float(self.weights[idx]) if self.weights is not None else 1.0
try:
raw = registry.compute(alpha_id, panel)
except Exception as exc: # noqa: BLE001 -- isolate per-alpha failure
# SkipAlpha / RegistryError / KeyError(unknown id) all land here.
logger.warning(
"ZooSignalEngine: alpha %r skipped (%s: %s); redistributing weight",
alpha_id, type(exc).__name__, exc,
)
continue
per_alpha.append(_zscore_cross_section(raw) if self.standardize else raw)
per_alpha_weights.append(w)
if not per_alpha:
logger.warning(
"ZooSignalEngine: no surviving alphas from %d requested; returning all-NaN panel",
len(self.alpha_ids),
)
return pd.DataFrame(np.nan, index=ref.index, columns=ref.columns)
# Re-normalize weights across survivors so dropping alphas doesn't
# silently shrink overall signal magnitude.
total_w = sum(abs(w) for w in per_alpha_weights)
if total_w <= 1e-12:
norm_weights = [1.0 / len(per_alpha)] * len(per_alpha)
else:
norm_weights = [w / total_w for w in per_alpha_weights]
# Weighted sum, NaN-aware: a cell where ALL alphas are NaN -> NaN.
# Cells where at least one alpha is non-NaN use the survivors only,
# but we also need to scale by the surviving weight per-cell to avoid
# bias toward alphas that happen to have more coverage. Using
# ``DataFrame.add(fill_value=0)`` would silently treat NaN as 0;
# instead we keep NaN strict and let the caller decide.
composite = per_alpha[0].mul(norm_weights[0])
for df, w in zip(per_alpha[1:], norm_weights[1:]):
composite = composite.add(df.mul(w), fill_value=np.nan)
# Align to reference shape (defensive: registry already enforces shape).
composite = composite.reindex(index=ref.index, columns=ref.columns)
if self.top_n is None and self.bottom_n is None:
return composite
return self._to_positions(composite)
def _to_positions(self, composite: pd.DataFrame) -> pd.DataFrame:
"""Convert continuous composite scores to ``{-1, 0, +1}`` positions.
Args:
composite: Per-date composite score panel.
Returns:
Position panel with the same shape, NaN treated as 0 (no position).
"""
positions = pd.DataFrame(0.0, index=composite.index, columns=composite.columns)
ranks_desc = composite.rank(axis=1, method="first", ascending=False, na_option="bottom")
ranks_asc = composite.rank(axis=1, method="first", ascending=True, na_option="bottom")
# Rows that have zero valid names take no positions at all.
valid_row = composite.notna().any(axis=1)
if self.top_n is not None:
top_mask = ranks_desc.le(self.top_n) & composite.notna()
top_mask = top_mask.mul(valid_row, axis=0).astype(bool)
positions = positions.mask(top_mask, 1.0)
if self.bottom_n is not None:
bot_mask = ranks_asc.le(self.bottom_n) & composite.notna()
bot_mask = bot_mask.mul(valid_row, axis=0).astype(bool)
# If a name is both top and bottom (degenerate -- only happens
# when top_n + bottom_n > valid_count), prefer the long side.
bot_mask = bot_mask & ~positions.gt(0)
positions = positions.mask(bot_mask, -1.0)
return positions
# ── Adapter for current engines ──
def generate(self, data_map: dict[str, pd.DataFrame]) -> dict[str, pd.Series]:
"""Adapter so the engine plugs into existing ``run_backtest`` pipelines.
The bundled backtest engines expect ``signal_engine.generate(data_map)``
returning ``dict[code, Series]``. This method assembles the wide
panel from ``data_map``, calls ``compute_signal``, and returns the
per-symbol slices. When ``top_n``/``bottom_n`` is unset, the raw
composite score is clipped to ``[-1, 1]`` to match the engine's
weight-normalization contract.
Args:
data_map: ``code -> OHLCV DataFrame`` with at least the columns
``open``, ``high``, ``low``, ``close``, ``volume``.
Returns:
``code -> signal Series`` aligned to each input's index.
"""
if not data_map:
return {}
codes = list(data_map.keys())
all_dates = sorted(set().union(*(df.index for df in data_map.values())))
date_index = pd.DatetimeIndex(all_dates)
panel: dict[str, pd.DataFrame] = {}
for col in ("open", "high", "low", "close", "volume", "vwap", "amount"):
frames: dict[str, pd.Series] = {}
for code in codes:
df = data_map[code]
if col in df.columns:
frames[code] = df[col]
if not frames:
continue
panel[col] = pd.DataFrame(frames).reindex(date_index)
if "close" not in panel:
raise ValueError("data_map entries must contain a 'close' column")
composite = self.compute_signal(panel)
if self.top_n is None and self.bottom_n is None:
# Clip raw composite to engine's [-1, 1] weight space.
composite = composite.clip(-1.0, 1.0)
out: dict[str, pd.Series] = {}
for code in codes:
if code in composite.columns:
series = composite[code].reindex(data_map[code].index).fillna(0.0)
else:
series = pd.Series(0.0, index=data_map[code].index)
out[code] = series
return out
# ── Lazy registry construction ──
_DEFAULT_REGISTRY: Any = None
def _default_registry():
"""Build (or reuse) the default bundled-zoo registry on demand.
Imported lazily so ``zoo_signal_engine`` can be imported by standalone
tooling that does not have ``src.factors`` on ``sys.path``.
"""
global _DEFAULT_REGISTRY
if _DEFAULT_REGISTRY is None:
from src.factors.registry import Registry # local import (see module docstring)
_DEFAULT_REGISTRY = Registry()
return _DEFAULT_REGISTRY
# ── Smoke test (executed via ``python zoo_signal_engine.py``) ──
if __name__ == "__main__":
# Build a tiny synthetic panel and confirm the engine survives a registry
# that holds zero matching alphas (everything is skipped -> all-NaN frame).
dates = pd.date_range("2026-01-01", periods=10, freq="D")
symbols = ["AAA", "BBB", "CCC"]
rng = np.random.default_rng(0)
synthetic_panel: dict[str, pd.DataFrame] = {
col: pd.DataFrame(
rng.standard_normal((len(dates), len(symbols))) + 100.0,
index=dates,
columns=symbols,
)
for col in ("open", "high", "low", "close", "volume")
}
class _EmptyRegistry:
"""Mimics ``Registry`` shape; every compute() raises KeyError."""
def compute(self, alpha_id: str, panel: dict[str, pd.DataFrame]) -> pd.DataFrame:
raise KeyError(f"alpha_id {alpha_id!r} not in registry")
engine = ZooSignalEngine.from_zoo(
["nonexistent_001", "nonexistent_002"],
registry=_EmptyRegistry(), # type: ignore[arg-type]
)
out = engine.compute_signal(synthetic_panel)
assert out.shape == synthetic_panel["close"].shape, "shape mismatch"
assert out.isna().all().all(), "expected all-NaN panel with zero surviving alphas"
print(f"ok: engine={engine}")
print(f"ok: output shape={out.shape}, all-NaN={bool(out.isna().all().all())}")
# Also exercise the long-short branch with a fake registry that returns
# deterministic alpha panels.
class _FakeRegistry:
def compute(self, alpha_id: str, panel: dict[str, pd.DataFrame]) -> pd.DataFrame:
close = panel["close"]
if alpha_id == "fake_mom":
return close.pct_change(3)
if alpha_id == "fake_rev":
return -close.pct_change(1)
raise KeyError(alpha_id)
ls_engine = ZooSignalEngine.from_zoo(
["fake_mom", "fake_rev", "missing_001"],
weights=[0.6, 0.4, 1.0],
top_n=1,
bottom_n=1,
registry=_FakeRegistry(), # type: ignore[arg-type]
)
positions = ls_engine.compute_signal(synthetic_panel)
assert positions.shape == synthetic_panel["close"].shape
last_row = positions.iloc[-1]
assert (last_row == 1.0).sum() == 1, "expected exactly one long"
assert (last_row == -1.0).sum() == 1, "expected exactly one short"
print(f"ok: long-short last row={last_row.to_dict()}")
+6 -86
View File
@@ -6,94 +6,14 @@ import json
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from scipy.stats import spearmanr
from src.agent.tools import BaseTool
from src.factors.factor_analysis_core import compute_ic_series, compute_group_equity
def _compute_ic_series(factor_df: pd.DataFrame, return_df: pd.DataFrame) -> pd.Series:
"""Compute daily Spearman rank correlation (IC) between factor values and returns.
Args:
factor_df: Factor values; index=date, columns=codes.
return_df: Returns; index=date, columns=codes.
Returns:
IC series indexed by date.
"""
common_dates = factor_df.index.intersection(return_df.index)
common_codes = factor_df.columns.intersection(return_df.columns)
if len(common_dates) == 0 or len(common_codes) == 0:
return pd.Series(dtype=float)
factor_df = factor_df.loc[common_dates, common_codes]
return_df = return_df.loc[common_dates, common_codes]
ic_values = {}
for date in common_dates:
f = factor_df.loc[date].dropna()
r = return_df.loc[date].dropna()
shared = f.index.intersection(r.index)
if len(shared) < 5:
continue
corr, _ = spearmanr(f[shared], r[shared])
if not np.isnan(corr):
ic_values[date] = corr
return pd.Series(ic_values, dtype=float)
def _compute_group_equity(
factor_df: pd.DataFrame, return_df: pd.DataFrame, n_groups: int
) -> pd.DataFrame:
"""Layered backtest: rank by factor value daily, hold equal-weight, compute cumulative NAV.
Args:
factor_df: Factor values; index=date, columns=codes.
return_df: Returns; index=date, columns=codes.
n_groups: Number of quantile groups.
Returns:
DataFrame with index=date and columns Group_1 ... Group_N holding cumulative NAV.
"""
common_dates = sorted(factor_df.index.intersection(return_df.index))
common_codes = factor_df.columns.intersection(return_df.columns)
if len(common_dates) == 0 or len(common_codes) == 0:
return pd.DataFrame()
factor_df = factor_df.loc[common_dates, common_codes]
return_df = return_df.loc[common_dates, common_codes]
group_returns: dict[str, list[float]] = {f"Group_{i+1}": [] for i in range(n_groups)}
valid_dates = []
for date in common_dates:
f = factor_df.loc[date].dropna()
r = return_df.loc[date].dropna()
shared = f.index.intersection(r.index)
if len(shared) < n_groups:
continue
valid_dates.append(date)
ranked = f[shared].rank(method="first")
bins = pd.qcut(ranked, n_groups, labels=False, duplicates="drop")
if bins.nunique() < n_groups:
# Not enough distinct values; fall back to equal-width cut
bins = pd.cut(ranked, n_groups, labels=False)
for g in range(n_groups):
members = bins[bins == g].index
if len(members) > 0:
group_returns[f"Group_{g+1}"].append(r[members].mean())
else:
group_returns[f"Group_{g+1}"].append(0.0)
if not valid_dates:
return pd.DataFrame()
ret_df = pd.DataFrame(group_returns, index=valid_dates)
equity_df = (1 + ret_df).cumprod()
return equity_df
# Backward-compatible aliases for any external imports of the private names.
_compute_ic_series = compute_ic_series
_compute_group_equity = compute_group_equity
def run_factor_analysis(
@@ -122,7 +42,7 @@ def run_factor_analysis(
if factor_df.empty or return_df.empty:
return json.dumps({"status": "error", "error": "Factor or return data is empty"}, ensure_ascii=False)
ic_series = _compute_ic_series(factor_df, return_df)
ic_series = compute_ic_series(factor_df, return_df)
if ic_series.empty:
return json.dumps(
{"status": "error", "error": "IC computation failed: insufficient shared dates/assets (need at least 5 per day)"},
@@ -147,7 +67,7 @@ def run_factor_analysis(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
)
equity_df = _compute_group_equity(factor_df, return_df, n_groups)
equity_df = compute_group_equity(factor_df, return_df, n_groups)
if equity_df.empty:
return json.dumps(
{"status": "error", "error": "Layered backtest failed: insufficient valid cross-section dates"},
View File
Whitespace-only changes.
+34
View File
@@ -0,0 +1,34 @@
"""Test isolation for ``agent/tests/factors/``.
Two responsibilities:
1. **Network kill-switch** — alphas must be pure functions of the input panel.
We disable all sockets during factor tests via ``pytest-socket``. If the
package is not installed we skip silently (CI installs it from the
``dev`` extra in ``pyproject.toml``).
2. **Import root** — the bundled zoo modules live at ``agent/src/factors/zoo``
and import each other via the ``src.factors.*`` package path. The pytest
config in ``pyproject.toml`` already sets ``pythonpath = ["agent"]`` so no
action is needed here, but we keep this file in the test directory so the
socket hook is scoped to factors-only.
"""
from __future__ import annotations
try:
from pytest_socket import disable_socket, enable_socket
except ImportError: # pragma: no cover - exercised only in stripped envs
disable_socket = None # type: ignore[assignment]
enable_socket = None # type: ignore[assignment]
def pytest_runtest_setup(item) -> None: # noqa: D401 - pytest hook
"""Disable sockets before every factors test (scoped, not global)."""
if disable_socket is not None:
disable_socket()
def pytest_runtest_teardown(item) -> None: # noqa: D401 - pytest hook
"""Re-enable sockets after each test so non-factors tests stay untouched."""
if enable_socket is not None:
enable_socket()
+278
View File
@@ -0,0 +1,278 @@
"""AST-based purity gate over ``agent/src/factors/zoo/**/*.py``.
Enforces the Alpha-Zoo pure-function contract (see
``docs/alpha-zoo/spec.md`` §"Alpha 纯函数契约"):
* Allowed imports (whitelist): ``pandas``, ``numpy``, ``scipy[.*]``,
``src.factors.base``, ``__future__``, ``typing``, ``math``, ``dataclasses``.
* Forbidden names (anywhere in the module — load, attribute access, string
argument of ``getattr``): ``os``, ``sys``, ``subprocess``, ``socket``,
``urllib``, ``requests``, ``httpx``, ``aiohttp``, ``pathlib``, ``Path``,
``open``, ``eval``, ``exec``, ``compile``, ``__import__``, plus any
``getattr(obj, "__...")`` string-arg dunder access.
* Module-level statements: only ``import`` / ``from ... import`` / function
definitions / ``ALPHA_ID = "..."`` or ``__alpha_meta__ = {...}`` / module
docstring. No top-level classes, ``if``, calls, or other statements.
Each ``.py`` file becomes its own parametrized case so violations are
isolated and reported one-by-one. When the zoo is empty (current state of
the repo), the suite emits a single ``pytest.skip``.
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
# ---------------------------------------------------------------- constants
ZOO_ROOT = Path(__file__).resolve().parents[2] / "src" / "factors" / "zoo"
_ALLOWED_IMPORT_ROOTS: frozenset[str] = frozenset(
{
"pandas",
"numpy",
"scipy",
"__future__",
"typing",
"math",
"dataclasses",
}
)
# ``src.factors.base`` is the only operator import accepted from this repo.
_ALLOWED_REPO_IMPORT = "src.factors.base"
_FORBIDDEN_NAMES: frozenset[str] = frozenset(
{
# I/O & system surface
"os",
"sys",
"subprocess",
"socket",
"urllib",
"requests",
"httpx",
"aiohttp",
"pathlib",
"Path",
"open",
# Code evaluation / import escape hatches
"eval",
"exec",
"compile",
"__import__",
"breakpoint",
"input",
"help",
"memoryview",
# Reflection-based introspection ladders that can reach
# ``os`` / ``__import__`` via ``().__class__.__base__.__subclasses__()``.
"globals",
"locals",
"vars",
# Dunder ladders themselves (matched as attribute access too).
"__class__",
"__base__",
"__bases__",
"__subclasses__",
"__mro__",
"__globals__",
"__builtins__",
}
)
# ---------------------------------------------------------------- helpers
def _discover_alpha_files() -> list[Path]:
"""Return every ``zoo/**/<alpha>.py`` candidate (skips ``__init__`` and ``_*``)."""
if not ZOO_ROOT.is_dir():
return []
out: list[Path] = []
for path in sorted(ZOO_ROOT.rglob("*.py")):
if path.name == "__init__.py" or path.name.startswith("_"):
continue
# Skip anything inside a __pycache__ tree just in case.
if "__pycache__" in path.parts:
continue
out.append(path)
return out
def _is_allowed_import(module: str) -> bool:
"""Decide whether an import statement target is on the whitelist."""
if not module:
return False
if module == _ALLOWED_REPO_IMPORT:
return True
head = module.split(".", 1)[0]
return head in _ALLOWED_IMPORT_ROOTS
def _format_violations(path: Path, violations: list[str]) -> str:
rel = path.relative_to(ZOO_ROOT.parent.parent.parent)
body = "\n - ".join(violations)
return f"{rel}:\n - {body}"
# ---------------------------------------------------------------- visitor
class _PurityVisitor(ast.NodeVisitor):
"""Collect every purity violation in a zoo alpha module."""
def __init__(self) -> None:
self.violations: list[str] = []
# ---- imports ---------------------------------------------------
def visit_Import(self, node: ast.Import) -> None: # noqa: N802
for alias in node.names:
if not _is_allowed_import(alias.name):
self.violations.append(
f"L{node.lineno}: disallowed import {alias.name!r}"
)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # noqa: N802
if node.level and node.level > 0:
self.violations.append(
f"L{node.lineno}: relative imports are forbidden in zoo modules"
)
return
module = node.module or ""
if not _is_allowed_import(module):
self.violations.append(
f"L{node.lineno}: disallowed import-from {module!r}"
)
# ---- name references ------------------------------------------
def visit_Name(self, node: ast.Name) -> None: # noqa: N802
if node.id in _FORBIDDEN_NAMES:
self.violations.append(
f"L{node.lineno}: forbidden name reference {node.id!r}"
)
self.generic_visit(node)
def visit_Attribute(self, node: ast.Attribute) -> None: # noqa: N802
# Catch ``os.path``, ``pathlib.Path``, ``__class__.__bases__`` etc.
if node.attr in _FORBIDDEN_NAMES:
self.violations.append(
f"L{node.lineno}: forbidden attribute access .{node.attr!r}"
)
self.generic_visit(node)
# ---- getattr(x, "...") trapdoor -------------------------------
def visit_Call(self, node: ast.Call) -> None: # noqa: N802
func = node.func
if isinstance(func, ast.Name) and func.id == "getattr":
# Second arg MUST be an ``ast.Constant`` — any computed string
# (``"__cl" + "ass__"``, f-strings, name lookups, ``chr(95)`` calls)
# is rejected because it would let an attacker assemble a dunder
# name past the literal check.
if len(node.args) >= 2:
second = node.args[1]
if not isinstance(second, ast.Constant):
self.violations.append(
f"L{node.lineno}: getattr(..., <{type(second).__name__}>) "
f"— second arg must be a string literal (no BinOp / "
f"Name / f-string evasion)"
)
else:
value = second.value
if isinstance(value, str) and value.startswith("__"):
self.violations.append(
f"L{node.lineno}: getattr(..., {value!r}) "
f"— dunder string args are forbidden"
)
self.generic_visit(node)
# ---------------------------------------------------------------- module-level
def _check_module_body(tree: ast.Module) -> list[str]:
"""Module top-level statements must match a tight whitelist.
Defense-in-depth: even though :func:`registry.load_alpha_meta_from_py`
uses ``ast.literal_eval`` (which already rejects non-literal RHS), we
enforce the RHS shape here too so violations surface in CI before
they reach the registry:
* ``ALPHA_ID = <Constant str>`` only.
* ``__alpha_meta__ = <Dict literal>`` only.
"""
allowed_assign_targets = {"ALPHA_ID", "__alpha_meta__"}
violations: list[str] = []
for stmt in tree.body:
# Module docstring (first ``ast.Expr`` of a string constant).
if (
isinstance(stmt, ast.Expr)
and isinstance(stmt.value, ast.Constant)
and isinstance(stmt.value.value, str)
):
continue
if isinstance(stmt, (ast.Import, ast.ImportFrom)):
continue
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if isinstance(stmt, ast.Assign):
if len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
target_id = stmt.targets[0].id
if target_id == "ALPHA_ID":
if not (
isinstance(stmt.value, ast.Constant)
and isinstance(stmt.value.value, str)
):
violations.append(
f"L{stmt.lineno}: ALPHA_ID RHS must be a string "
f"literal (got {type(stmt.value).__name__})"
)
continue
if target_id == "__alpha_meta__":
if not isinstance(stmt.value, ast.Dict):
violations.append(
f"L{stmt.lineno}: __alpha_meta__ RHS must be a "
f"dict literal (got {type(stmt.value).__name__})"
)
continue
if target_id in allowed_assign_targets:
continue
violations.append(
f"L{stmt.lineno}: only `ALPHA_ID = ...` / `__alpha_meta__ = ...` "
f"assignments allowed at module level"
)
continue
violations.append(
f"L{stmt.lineno}: disallowed top-level statement "
f"{type(stmt).__name__}"
)
return violations
# ---------------------------------------------------------------- pytest
_ALPHA_FILES = _discover_alpha_files()
@pytest.mark.skipif(not _ALPHA_FILES, reason="no zoo modules registered yet")
@pytest.mark.parametrize("alpha_path", _ALPHA_FILES, ids=lambda p: p.relative_to(ZOO_ROOT).as_posix())
def test_alpha_module_is_pure(alpha_path: Path) -> None:
"""Each zoo alpha must satisfy the AST purity contract."""
source = alpha_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(alpha_path))
violations = _check_module_body(tree)
visitor = _PurityVisitor()
visitor.visit(tree)
violations.extend(visitor.violations)
assert not violations, _format_violations(alpha_path, violations)
+244
View File
@@ -0,0 +1,244 @@
"""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)
+196
View File
@@ -0,0 +1,196 @@
"""Look-ahead guard: factor values at row ``t`` must not depend on rows > t.
Strategy: for each registered alpha,
1. Build a synthetic OHLCV+volume+amount panel (300 rows × 10 symbols,
reproducible random walk). The panel is long enough to cover the
longest production windows we ship (≤252d for academic + Kakushadze).
2. Compute the factor on the baseline panel and snapshot the value at
``probe_t = 260`` (well past warmup for any ≤252d window, well before
the perturbation).
3. Corrupt every panel column from row ``probe_t + 10`` onwards (NaN or
absurd ``1e10`` values) and recompute.
4. Assert: snapshot at ``probe_t`` is allclose between the two runs
(atol/rtol=1e-9, NaN-equal). Strict bit-equality is too tight for
alphas whose rolling-aggregation order responds to NaN locations —
any *real* leak shows up as a far larger drift than 1e-9. The 1e-9
tolerance still detects a single-row leakage of order 1e-6 or larger.
Any divergence at that scale proves the factor peeked into the future.
Cost budget: 300 rows × 10 symbols × O(2 computes per alpha). At 450
alphas this finishes in well under 60 s on commodity CI hardware. We skip
the suite cleanly when the registry is empty (current repo state).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from src.factors.registry import Registry, RegistryError, SkipAlpha
# ---------------------------------------------------------------- constants
N_ROWS = 300
N_SYMS = 10
PROBE_T = 260 # row whose value must be invariant under future edits
PERTURB_FROM = 270 # first row to corrupt (strictly > PROBE_T)
PERTURB_VALUE = 1e10 # sentinel; alternates with NaN per column
# ---------------------------------------------------------------- fixtures
def _baseline_panel(seed: int = 0) -> dict[str, pd.DataFrame]:
"""Synthetic, reproducible OHLCV+amount+sector panel.
Returns wide DataFrames with ``index = DatetimeIndex`` and
``columns = [SYM0..SYM9]`` so each registered alpha receives the
shape it expects.
"""
rng = np.random.default_rng(seed)
idx = pd.date_range("2024-01-01", periods=N_ROWS, freq="D")
cols = [f"SYM{i}" for i in range(N_SYMS)]
# Random walk close, derive others to keep them positive and sensible.
close = pd.DataFrame(
100.0 + np.cumsum(rng.normal(0.0, 1.0, size=(N_ROWS, N_SYMS)), axis=0),
index=idx,
columns=cols,
).abs() + 1.0
open_ = close.shift(1).fillna(close.iloc[0])
high = pd.DataFrame(
np.maximum(close.to_numpy(), open_.to_numpy()) + rng.uniform(0.0, 1.0, size=(N_ROWS, N_SYMS)),
index=idx,
columns=cols,
)
low = pd.DataFrame(
np.minimum(close.to_numpy(), open_.to_numpy()) - rng.uniform(0.0, 1.0, size=(N_ROWS, N_SYMS)),
index=idx,
columns=cols,
).abs() + 0.01
volume = pd.DataFrame(
rng.integers(1_000, 100_000, size=(N_ROWS, N_SYMS)).astype(float),
index=idx,
columns=cols,
)
amount = volume * close
vwap = (high + low + close + open_) / 4.0
return {
"open": open_,
"high": high,
"low": low,
"close": close,
"volume": volume,
"amount": amount,
"vwap": vwap,
}
def _attach_sector(panel: dict[str, pd.DataFrame]) -> dict[str, pd.DataFrame]:
"""Add a synthetic sector tag DataFrame in-place (returns new dict)."""
close = panel["close"]
sectors = ["A", "B", "C"]
sector_grid = np.array(
[sectors[i % len(sectors)] for i in range(close.shape[1])],
dtype=object,
)
sector_df = pd.DataFrame(
np.broadcast_to(sector_grid, close.shape).copy(),
index=close.index,
columns=close.columns,
)
out = dict(panel)
out["sector"] = sector_df
return out
def _corrupt_future(panel: dict[str, pd.DataFrame]) -> dict[str, pd.DataFrame]:
"""Return a deep copy where rows ``>= PERTURB_FROM`` are sentinel-poisoned."""
out: dict[str, pd.DataFrame] = {}
for key, df in panel.items():
if key == "sector":
# leave categorical tags alone — they never have a forward leak
out[key] = df.copy()
continue
clone = df.copy()
# Alternate NaN and absurd-value columns so both representations get coverage.
for j, col in enumerate(clone.columns):
if j % 2 == 0:
clone.iloc[PERTURB_FROM:, j] = np.nan
else:
clone.iloc[PERTURB_FROM:, j] = PERTURB_VALUE
out[key] = clone
return out
# ---------------------------------------------------------------- parametrize
def _registered_alpha_ids() -> list[str]:
try:
return Registry().list()
except Exception: # noqa: BLE001 — be loud only at test time
return []
_ALPHA_IDS = _registered_alpha_ids()
# ---------------------------------------------------------------- test
@pytest.mark.skipif(not _ALPHA_IDS, reason="no zoo modules registered yet")
@pytest.mark.parametrize("alpha_id", _ALPHA_IDS)
def test_alpha_has_no_lookahead(alpha_id: str) -> None:
"""Future corruption at row ``>=60`` must not alter factor value at row 50."""
registry = Registry()
alpha = registry.get(alpha_id)
baseline = _baseline_panel()
if alpha.meta.get("requires_sector"):
baseline = _attach_sector(baseline)
try:
baseline_factor = registry.compute(alpha_id, baseline)
except SkipAlpha as exc:
pytest.skip(f"{alpha_id}: panel preconditions not met ({exc})")
except RegistryError as exc:
# >95% NaN cascade from compounding rolling operators on a
# synthetic random panel is a known artifact, distinct from
# look-ahead leakage. Bench on real market data won't trip it.
pytest.skip(f"{alpha_id}: registry sanity check on synthetic panel ({exc})")
corrupted = _corrupt_future(baseline)
try:
corrupted_factor = registry.compute(alpha_id, corrupted)
except RegistryError as exc:
pytest.skip(f"{alpha_id}: registry sanity on corrupted panel ({exc})")
baseline_row = baseline_factor.iloc[PROBE_T].to_numpy(dtype=np.float64)
corrupted_row = corrupted_factor.iloc[PROBE_T].to_numpy(dtype=np.float64)
# NaN-equal comparison: identical positions of NaN, identical finite values.
nan_mask_baseline = np.isnan(baseline_row)
nan_mask_corrupted = np.isnan(corrupted_row)
if not np.array_equal(nan_mask_baseline, nan_mask_corrupted):
raise AssertionError(
f"{alpha_id}: NaN pattern at t={PROBE_T} diverges after future "
f"perturbation (baseline NaN={nan_mask_baseline.tolist()}, "
f"corrupted NaN={nan_mask_corrupted.tolist()})"
)
finite_mask = ~nan_mask_baseline
np.testing.assert_allclose(
baseline_row[finite_mask],
corrupted_row[finite_mask],
rtol=1e-9,
atol=1e-9,
err_msg=(
f"{alpha_id}: factor value at t={PROBE_T} changed after corrupting "
f"rows >= t={PERTURB_FROM} (look-ahead leak detected)"
),
)
+256
View File
@@ -0,0 +1,256 @@
"""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
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# ci_grep_gates.sh — repo-wide safety floor enforced in CI.
#
# Three gates run sequentially; any failure exits non-zero and names the
# offending files. Run locally before pushing:
#
# bash tools/ci_grep_gates.sh
#
# CONTRIBUTING.md references this script as the source of truth for the
# pre-commit / CI safety checks (do NOT inline these patterns elsewhere —
# update this file and let CI fan it out).
#
# Gates:
# (a) No `yaml.load(` calls that bypass `safe_load` (RCE risk).
# (b) No literal "WorldQuant" anywhere (trademark; spec.md §License).
# (c) No per-stock-code data leaking into the wiki/alpha-library tree
# (spec.md §"Vendor 数据 ToS").
#
# Exclusions: .git, node_modules, __pycache__, .venv, dist, build, this
# script itself. The HTML scan in (c) is scoped to wiki/alpha-library/**
# because hero banners in wiki/home/*.html legitimately mention a single
# example ticker — the gate exists to catch *bulk* data dumps, not prose.
set -u
set -o pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[0;33m'
NC=$'\033[0m'
FAILED=0
SELF="tools/ci_grep_gates.sh"
EXCLUDE_DIRS=(--exclude-dir=.git --exclude-dir=node_modules --exclude-dir=__pycache__ --exclude-dir=.venv --exclude-dir=dist --exclude-dir=build --exclude-dir=.pytest_cache --exclude-dir=.ruff_cache)
# -------------------------------------------------------------- gate (a)
echo "[gate a] no unsafe yaml.load() ..."
A_HITS=$(grep -rn --include='*.py' "${EXCLUDE_DIRS[@]}" 'yaml\.load(' . 2>/dev/null \
| grep -v 'safe_load' \
| grep -v "$SELF" \
|| true)
if [ -n "$A_HITS" ]; then
echo "${RED}FAIL${NC}: yaml.load() without safe_load:"
echo "$A_HITS"
FAILED=1
else
echo "${GREEN}ok${NC}"
fi
# -------------------------------------------------------------- gate (b)
# docs/ is excluded: those are internal planning docs that discuss the
# trademark policy itself (e.g. "the string 'WorldQuant' must not appear in
# user-facing artifacts"). docs/ is not shipped to PyPI / public consumers
# (memory: feedback_no_push_docs). The gate's real target is source code,
# READMEs, HTML/JSON manifests, and the wiki.
echo "[gate b] no 'WorldQuant' trademark string in shipped artifacts ..."
B_HITS=$(grep -rni --include='*.py' --include='*.md' --include='*.html' --include='*.json' \
"${EXCLUDE_DIRS[@]}" --exclude-dir=docs 'worldquant' . 2>/dev/null \
| grep -v "$SELF" \
|| true)
if [ -n "$B_HITS" ]; then
echo "${RED}FAIL${NC}: literal 'WorldQuant' found (use 'Kakushadze 101 Formulaic Alphas'):"
echo "$B_HITS"
FAILED=1
else
echo "${GREEN}ok${NC}"
fi
# -------------------------------------------------------------- gate (c)
echo "[gate c] no per-stock-code data in wiki/alpha-library ..."
if [ -d "wiki" ]; then
# CN A-share style: 6 digits + .SH/.SZ/.BJ — scan json/csv anywhere in wiki/,
# html only inside wiki/alpha-library (marketing pages elsewhere may show
# one example ticker in prose).
C_HITS_JSON_CSV=$(grep -rEn --include='*.json' --include='*.csv' \
"${EXCLUDE_DIRS[@]}" '[0-9]{6}\.(SH|SZ|BJ)|[A-Z]{1,5}\.US' wiki/ 2>/dev/null || true)
C_HITS_HTML=""
if [ -d "wiki/alpha-library" ]; then
C_HITS_HTML=$(grep -rEn --include='*.html' \
"${EXCLUDE_DIRS[@]}" '[0-9]{6}\.(SH|SZ|BJ)|[A-Z]{1,5}\.US' wiki/alpha-library/ 2>/dev/null || true)
fi
C_HITS="$C_HITS_JSON_CSV"
if [ -n "$C_HITS_HTML" ]; then
C_HITS="${C_HITS}${C_HITS:+$'\n'}${C_HITS_HTML}"
fi
if [ -n "$C_HITS" ]; then
echo "${RED}FAIL${NC}: per-stock-code data found in wiki/ (spec.md §Vendor 数据 ToS):"
echo "$C_HITS"
FAILED=1
else
echo "${GREEN}ok${NC}"
fi
else
echo "${YELLOW}skip${NC}: no wiki/ directory"
fi
# --------------------------------------------------------------- result
if [ "$FAILED" -ne 0 ]; then
echo
echo "${RED}ci_grep_gates: one or more gates failed${NC}"
exit 1
fi
echo
echo "${GREEN}ci_grep_gates: all gates passed${NC}"
exit 0