From 1676eaa328102f4f1419e959eddb6bcb1fd78028 Mon Sep 17 00:00:00 2001 From: Haozhe Wu Date: Sun, 17 May 2026 20:03:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(factors):=20Alpha=20Zoo=20framework=20?= =?UTF-8?q?=E2=80=94=20registry,=20operators,=20safety=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CONTRIBUTING.md | 270 ++++++------ NOTICE | 22 + agent/src/factors/__init__.py | 48 ++ agent/src/factors/base.py | 273 ++++++++++++ agent/src/factors/bench_runner.py | 227 ++++++++++ agent/src/factors/factor_analysis_core.py | 127 ++++++ agent/src/factors/registry.py | 411 ++++++++++++++++++ agent/src/factors/zoo/__init__.py | 0 agent/src/skills/alpha-zoo/SKILL.md | 57 +++ agent/src/skills/factor-research/SKILL.md | 16 + agent/src/skills/multi-factor/SKILL.md | 21 + .../skills/multi-factor/zoo_signal_engine.py | 378 ++++++++++++++++ agent/src/tools/factor_analysis_tool.py | 92 +--- agent/tests/factors/__init__.py | 0 agent/tests/factors/conftest.py | 34 ++ agent/tests/factors/test_alpha_purity.py | 278 ++++++++++++ agent/tests/factors/test_base.py | 244 +++++++++++ agent/tests/factors/test_lookahead.py | 196 +++++++++ agent/tests/factors/test_registry.py | 256 +++++++++++ tools/ci_grep_gates.sh | 109 +++++ 20 files changed, 2826 insertions(+), 233 deletions(-) create mode 100644 NOTICE create mode 100644 agent/src/factors/__init__.py create mode 100644 agent/src/factors/base.py create mode 100644 agent/src/factors/bench_runner.py create mode 100644 agent/src/factors/factor_analysis_core.py create mode 100644 agent/src/factors/registry.py create mode 100644 agent/src/factors/zoo/__init__.py create mode 100644 agent/src/skills/alpha-zoo/SKILL.md create mode 100644 agent/src/skills/multi-factor/zoo_signal_engine.py create mode 100644 agent/tests/factors/__init__.py create mode 100644 agent/tests/factors/conftest.py create mode 100644 agent/tests/factors/test_alpha_purity.py create mode 100644 agent/tests/factors/test_base.py create mode 100644 agent/tests/factors/test_lookahead.py create mode 100644 agent/tests/factors/test_registry.py create mode 100644 tools/ci_grep_gates.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8d13c50..7292f040 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ``` -### 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 ` 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 @: (Apache-2.0). Copyright (c) .` +- [ ] **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 `.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 --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///`. 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/.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`). diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..183d9899 --- /dev/null +++ b/NOTICE @@ -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. diff --git a/agent/src/factors/__init__.py b/agent/src/factors/__init__.py new file mode 100644 index 00000000..d20f6f3c --- /dev/null +++ b/agent/src/factors/__init__.py @@ -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", +] diff --git a/agent/src/factors/base.py b/agent/src/factors/base.py new file mode 100644 index 00000000..72ac4044 --- /dev/null +++ b/agent/src/factors/base.py @@ -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 diff --git a/agent/src/factors/bench_runner.py b/agent/src/factors/bench_runner.py new file mode 100644 index 00000000..394a3695 --- /dev/null +++ b/agent/src/factors/bench_runner.py @@ -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 diff --git a/agent/src/factors/factor_analysis_core.py b/agent/src/factors/factor_analysis_core.py new file mode 100644 index 00000000..463b77cc --- /dev/null +++ b/agent/src/factors/factor_analysis_core.py @@ -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 diff --git a/agent/src/factors/registry.py b/agent/src/factors/registry.py new file mode 100644 index 00000000..77dfbf39 --- /dev/null +++ b/agent/src/factors/registry.py @@ -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//.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 "_" 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 diff --git a/agent/src/factors/zoo/__init__.py b/agent/src/factors/zoo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agent/src/skills/alpha-zoo/SKILL.md b/agent/src/skills/alpha-zoo/SKILL.md new file mode 100644 index 00000000..13fc3781 --- /dev/null +++ b/agent/src/skills/alpha-zoo/SKILL.md @@ -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` diff --git a/agent/src/skills/factor-research/SKILL.md b/agent/src/skills/factor-research/SKILL.md index 253e6dcf..7365b2f8 100644 --- a/agent/src/skills/factor-research/SKILL.md +++ b/agent/src/skills/factor-research/SKILL.md @@ -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. + diff --git a/agent/src/skills/multi-factor/SKILL.md b/agent/src/skills/multi-factor/SKILL.md index 4bc2d946..13acdf58 100644 --- a/agent/src/skills/multi-factor/SKILL.md +++ b/agent/src/skills/multi-factor/SKILL.md @@ -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. + diff --git a/agent/src/skills/multi-factor/zoo_signal_engine.py b/agent/src/skills/multi-factor/zoo_signal_engine.py new file mode 100644 index 00000000..aaad6f17 --- /dev/null +++ b/agent/src/skills/multi-factor/zoo_signal_engine.py @@ -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()}") diff --git a/agent/src/tools/factor_analysis_tool.py b/agent/src/tools/factor_analysis_tool.py index fbabf2bd..d72bc59a 100644 --- a/agent/src/tools/factor_analysis_tool.py +++ b/agent/src/tools/factor_analysis_tool.py @@ -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"}, diff --git a/agent/tests/factors/__init__.py b/agent/tests/factors/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agent/tests/factors/conftest.py b/agent/tests/factors/conftest.py new file mode 100644 index 00000000..39a6283d --- /dev/null +++ b/agent/tests/factors/conftest.py @@ -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() diff --git a/agent/tests/factors/test_alpha_purity.py b/agent/tests/factors/test_alpha_purity.py new file mode 100644 index 00000000..2db187d4 --- /dev/null +++ b/agent/tests/factors/test_alpha_purity.py @@ -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/**/.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 = `` only. + * ``__alpha_meta__ = `` 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) diff --git a/agent/tests/factors/test_base.py b/agent/tests/factors/test_base.py new file mode 100644 index 00000000..8767ea43 --- /dev/null +++ b/agent/tests/factors/test_base.py @@ -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) diff --git a/agent/tests/factors/test_lookahead.py b/agent/tests/factors/test_lookahead.py new file mode 100644 index 00000000..c7d9dec3 --- /dev/null +++ b/agent/tests/factors/test_lookahead.py @@ -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)" + ), + ) diff --git a/agent/tests/factors/test_registry.py b/agent/tests/factors/test_registry.py new file mode 100644 index 00000000..2f9bacab --- /dev/null +++ b/agent/tests/factors/test_registry.py @@ -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.` 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 diff --git a/tools/ci_grep_gates.sh b/tools/ci_grep_gates.sh new file mode 100644 index 00000000..01017c22 --- /dev/null +++ b/tools/ci_grep_gates.sh @@ -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