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:
1 parent
6d7d365892
commit
1676eaa328
20 files changed
+2826
-233
No files matched your search
+123
-147
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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"},
|
||||
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)"
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user