diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..628e253 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: test + +on: + push: + branches: ["**"] + pull_request: + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: python -m pip install -e . + - run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..579aef4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +.venv/ +*.egg-info/ +.pytest_cache/ +artifacts/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7d279a0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 bozarnr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7e0114b..a0524b1 100644 --- a/README.md +++ b/README.md @@ -1 +1,55 @@ -# eee \ No newline at end of file +# AI Alpha Research Lab + +An auditable sandbox for formula-based alpha research. The project is designed +to make a negative result useful: a candidate is promoted only when it survives +time-safe evaluation, out-of-sample checks, and explicit trading frictions. + +中文简介:这是一个可审计的公式因子研究沙盒。它不把漂亮的样本内指标 +当成成果;候选公式必须通过时点安全、样本外、换手与成本门槛,才会被晋级。 + +## What is included + +- A small allow-listed expression language (`rank`, `delta`, `mean`, arithmetic). +- Point-in-time evaluation on a stock-date panel. +- A strict promotion gate for OOS IC, turnover, and costs. +- A deterministic synthetic-data demo that intentionally promotes no factor. +- Regression tests for parsing, future-field rejection, and the rejection gate. + +## Quick start + +```bash +python -m pip install -e . +python -m ai_alpha_lab.demo +python -m unittest discover -s tests -v +``` + +The demo is an engineering smoke test, not a backtest or investment result. + +## Evidence boundary + +The initial research record is deliberately conservative. A prior multi-source +AutoAlpha-style study implemented the search and validation pipeline, but under +the frozen executable protocol its final candidate count was zero. See +[`evidence/validation-summary.md`](evidence/validation-summary.md) and +[`research_state.json`](research_state.json). + +## Repository layout + +```text +src/ai_alpha_lab/ public, dependency-light research core +tests/ regression and safety checks +evidence/ compact, human-readable evidence records +research_state.json current claim ceiling and next reopening condition +``` + +## Non-goals + +- No private data, employer code, credentials, or proprietary research assets. +- No claim that a high IC, this demo, or a successful run is tradable. +- No automatic capital deployment. + +## Next research track + +The formula space used in the first record is closed. Reopen only with a +pre-registered economic mechanism, genuinely independent data, a frozen +baseline, and the same or stricter promotion gates. diff --git a/evidence/validation-summary.md b/evidence/validation-summary.md new file mode 100644 index 0000000..e5bd285 --- /dev/null +++ b/evidence/validation-summary.md @@ -0,0 +1,30 @@ +# Validation Summary: Initial Formula Search Record + +This public record summarizes a completed AutoAlpha-style research loop without +publishing private data, employer code, credentials, or raw experiment outputs. + +## What was implemented + +- Allow-listed AST expression evaluation rather than `eval`. +- Formula canonicalization, hashes, depth tracking, and search lineage. +- Stratified search, diversity filtering, warm starts, and ablations. +- Next-open execution timing, transaction costs, risk controls, turnover, and + frozen independent-test promotion gates. + +## What the evidence supports + +The pipeline can generate, evaluate, audit, and reject formula candidates. It +does **not** support a claim that the tested price/volume/return formula space +contains an online-ready alpha pool. + +## Strict result + +Across frozen transfers and a multi-source retest, the final candidate count +was **0**. Discovery-stage signals concentrated in short-horizon amount and +return transformations; they did not retain a positive cost-aware portfolio +result after transfer. + +## Reopening rule + +Do not search the same closed formula space harder. Restart only with a +pre-registered new mechanism or field, independent data, and a frozen protocol. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..85fbad0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "ai-alpha-research-lab" +version = "0.1.0" +description = "Auditable formula-alpha research with strict promotion gates" +requires-python = ">=3.10" +dependencies = ["numpy>=1.24", "pandas>=2.0"] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/research_state.json b/research_state.json new file mode 100644 index 0000000..3dad909 --- /dev/null +++ b/research_state.json @@ -0,0 +1,22 @@ +{ + "project": "ai-alpha-research-lab", + "updated_at": "2026-07-30", + "claim_ceiling": "implemented", + "status": "closed_without_online_candidate_under_current_protocol", + "public_scope": "Clean-room educational implementation and compact research evidence only.", + "frozen_protocol": { + "signal_timing": "after close t", + "execution_timing": "next trading-day open", + "transaction_cost_bps": 30, + "independent_test_use": "promotion audit only" + }, + "result": { + "final_candidate_count": 0, + "interpretation": "The validation system rejected unstable candidates; this is not a claim of tradable alpha." + }, + "reopen_conditions": [ + "Pre-register a new economic mechanism or tradable field.", + "Use data independent of all prior selection decisions.", + "Freeze the baseline, costs, universe, and promotion gate before search." + ] +} diff --git a/src/ai_alpha_lab/__init__.py b/src/ai_alpha_lab/__init__.py new file mode 100644 index 0000000..0049b14 --- /dev/null +++ b/src/ai_alpha_lab/__init__.py @@ -0,0 +1,6 @@ +"""Public, auditable building blocks for formula-alpha research.""" + +from .expressions import FormulaError, evaluate_formula +from .research import PromotionGate, evaluate_candidate + +__all__ = ["FormulaError", "PromotionGate", "evaluate_candidate", "evaluate_formula"] diff --git a/src/ai_alpha_lab/demo.py b/src/ai_alpha_lab/demo.py new file mode 100644 index 0000000..6842542 --- /dev/null +++ b/src/ai_alpha_lab/demo.py @@ -0,0 +1,41 @@ +"""Deterministic, synthetic smoke run. It must not be interpreted as a backtest.""" + +from __future__ import annotations + +import json + +import numpy as np +import pandas as pd + +from .research import PromotionGate, evaluate_candidate + + +def synthetic_panel(seed: int = 7) -> pd.DataFrame: + rng = np.random.default_rng(seed) + dates = pd.bdate_range("2024-01-02", periods=90) + assets = [f"asset_{index:02d}" for index in range(24)] + index = pd.MultiIndex.from_product([dates, assets], names=["date", "asset"]) + panel = index.to_frame(index=False) + noise = rng.normal(0, 0.015, len(panel)) + panel["returns"] = noise + panel["close"] = 100 * (1 + panel["returns"]).groupby(panel["asset"]).cumprod() + panel["amount"] = rng.lognormal(mean=14, sigma=0.7, size=len(panel)) + panel["forward_return"] = panel.groupby("asset")["returns"].shift(-1) + return panel.sort_values(["asset", "date"]).reset_index(drop=True) + + +def main() -> None: + panel = synthetic_panel() + result = evaluate_candidate( + "rank(delta(amount, 3)) - rank(mean(returns, 5))", + panel, + split_date="2024-03-15", + # A deliberately conservative gate prevents a noise-only smoke run + # from being presented as an investable discovery. + gate=PromotionGate(min_oos_rank_ic=0.05, max_turnover=0.60), + ) + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/src/ai_alpha_lab/expressions.py b/src/ai_alpha_lab/expressions.py new file mode 100644 index 0000000..1bb0c40 --- /dev/null +++ b/src/ai_alpha_lab/expressions.py @@ -0,0 +1,81 @@ +"""A tiny point-in-time formula language with no dynamic code execution.""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass + +import pandas as pd + + +class FormulaError(ValueError): + """Raised when a candidate violates the public formula contract.""" + + +@dataclass(frozen=True) +class FormulaContract: + allowed_fields: frozenset[str] + max_window: int = 60 + + +def _require_panel(frame: pd.DataFrame) -> None: + if not {"date", "asset"}.issubset(frame.columns): + raise FormulaError("panel requires date and asset columns") + if not frame.sort_values(["asset", "date"])[["asset", "date"]].equals(frame[["asset", "date"]]): + raise FormulaError("panel must be sorted by asset and date") + + +def _cross_section_rank(series: pd.Series, frame: pd.DataFrame) -> pd.Series: + return series.groupby(frame["date"], sort=False).rank(pct=True) + + +def _by_asset_rolling(series: pd.Series, frame: pd.DataFrame, window: int) -> pd.Series: + return series.groupby(frame["asset"], sort=False).transform( + lambda values: values.rolling(window=window, min_periods=window).mean() + ) + + +def _by_asset_delta(series: pd.Series, frame: pd.DataFrame, period: int) -> pd.Series: + return series.groupby(frame["asset"], sort=False).transform(lambda values: values.diff(period)) + + +def evaluate_formula(expression: str, frame: pd.DataFrame, contract: FormulaContract) -> pd.Series: + """Evaluate an allow-listed formula using only contemporaneous or past values.""" + _require_panel(frame) + tree = ast.parse(expression, mode="eval") + + def visit(node: ast.AST) -> pd.Series | int | float: + if isinstance(node, ast.Name): + if node.id not in contract.allowed_fields or node.id not in frame.columns: + raise FormulaError(f"field is not allowed: {node.id}") + if "future" in node.id.lower() or "label" in node.id.lower(): + raise FormulaError(f"future-looking field is forbidden: {node.id}") + return frame[node.id].astype(float) + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return node.value + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)): + left, right = visit(node.left), visit(node.right) + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Sub): + return left - right + if isinstance(node.op, ast.Mult): + return left * right + return left / right.replace(0, float("nan")) if isinstance(right, pd.Series) else left / right + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + return -visit(node.operand) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + args = [visit(argument) for argument in node.args] + if node.func.id == "rank" and len(args) == 1 and isinstance(args[0], pd.Series): + return _cross_section_rank(args[0], frame) + if node.func.id in {"delta", "mean"} and len(args) == 2 and isinstance(args[0], pd.Series): + period = args[1] + if not isinstance(period, int) or not 1 <= period <= contract.max_window: + raise FormulaError("window must be an integer within the contract") + return _by_asset_delta(args[0], frame, period) if node.func.id == "delta" else _by_asset_rolling(args[0], frame, period) + raise FormulaError(f"unsupported formula syntax: {ast.dump(node, include_attributes=False)}") + + result = visit(tree.body) + if not isinstance(result, pd.Series): + raise FormulaError("formula must evaluate to a series") + return result.replace([float("inf"), float("-inf")], float("nan")) diff --git a/src/ai_alpha_lab/research.py b/src/ai_alpha_lab/research.py new file mode 100644 index 0000000..1ab2298 --- /dev/null +++ b/src/ai_alpha_lab/research.py @@ -0,0 +1,69 @@ +"""Strict candidate evaluation that separates diagnostics from promotion.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass + +import pandas as pd + +from .expressions import FormulaContract, evaluate_formula + + +@dataclass(frozen=True) +class PromotionGate: + min_oos_rank_ic: float = 0.02 + min_oos_net_return: float = 0.0 + max_turnover: float = 0.60 + transaction_cost_bps: float = 30.0 + + +def _daily_rank_ic(panel: pd.DataFrame, signal: pd.Series) -> pd.Series: + data = panel.assign(signal=signal).dropna(subset=["signal", "forward_return"]) + return data.groupby("date", sort=False).apply( + lambda group: group["signal"].rank().corr(group["forward_return"].rank()), include_groups=False + ) + + +def _portfolio_diagnostics(panel: pd.DataFrame, signal: pd.Series, cost_bps: float) -> pd.DataFrame: + """Calculate a simple top-quintile diagnostic with explicit turnover costs.""" + ranked = panel.assign(signal=signal).groupby("date", sort=False)["signal"].rank(pct=True) + positions = (ranked >= 0.8).astype(float) + shifts = positions.groupby(panel["asset"], sort=False).shift(1).fillna(0.0) + daily_turnover = (positions - shifts).abs().groupby(panel["date"], sort=False).mean() + returns = panel.assign(position=positions).groupby("date", sort=False).apply( + lambda group: group.loc[group["position"] > 0, "forward_return"].mean(), + include_groups=False, + ) + diagnostics = pd.DataFrame({"turnover": daily_turnover, "gross_return": returns}) + diagnostics["net_return"] = diagnostics["gross_return"] - diagnostics["turnover"] * cost_bps / 10_000 + return diagnostics + + +def evaluate_candidate(expression: str, panel: pd.DataFrame, split_date: str, gate: PromotionGate) -> dict: + """Return diagnostics and promotion status; never optimize against test results.""" + contract = FormulaContract(frozenset({"close", "amount", "returns"})) + signal = evaluate_formula(expression, panel, contract) + rank_ic = _daily_rank_ic(panel, signal) + oos = rank_ic.loc[rank_ic.index >= pd.Timestamp(split_date)] + portfolio = _portfolio_diagnostics(panel, signal, gate.transaction_cost_bps) + oos_portfolio = portfolio.loc[portfolio.index >= pd.Timestamp(split_date)] + mean_oos_ic = float(oos.mean()) if not oos.empty else float("nan") + turnover = float(oos_portfolio["turnover"].mean()) + gross_return = float(oos_portfolio["gross_return"].mean()) + net_return = float(oos_portfolio["net_return"].mean()) + promoted = bool( + mean_oos_ic >= gate.min_oos_rank_ic + and turnover <= gate.max_turnover + and net_return >= gate.min_oos_net_return + ) + return { + "formula": expression, + "oos_rank_ic": round(mean_oos_ic, 6), + "oos_gross_return": round(gross_return, 6), + "oos_net_return": round(net_return, 6), + "turnover": round(turnover, 6), + "transaction_cost_bps": gate.transaction_cost_bps, + "promoted": promoted, + "reason": "passed frozen promotion gate" if promoted else "rejected by frozen OOS IC, turnover, or net-return gate", + "gate": asdict(gate), + } diff --git a/tests/test_research.py b/tests/test_research.py new file mode 100644 index 0000000..a34327e --- /dev/null +++ b/tests/test_research.py @@ -0,0 +1,46 @@ +import unittest + +from ai_alpha_lab.demo import synthetic_panel +from ai_alpha_lab.expressions import FormulaContract, FormulaError, evaluate_formula +from ai_alpha_lab.research import PromotionGate, evaluate_candidate + + +class FormulaSafetyTests(unittest.TestCase): + def setUp(self): + self.panel = synthetic_panel() + self.contract = FormulaContract(frozenset({"close", "amount", "returns"})) + + def test_formula_uses_past_data_only(self): + output = evaluate_formula("rank(delta(amount, 3))", self.panel, self.contract) + self.assertEqual(len(output), len(self.panel)) + self.assertTrue(output.isna().any()) + + def test_future_field_is_rejected(self): + with self.assertRaises(FormulaError): + evaluate_formula("forward_return", self.panel, self.contract) + + def test_unsorted_panel_is_rejected(self): + with self.assertRaises(FormulaError): + evaluate_formula("rank(amount)", self.panel.sample(frac=1, random_state=1), self.contract) + + def test_demo_candidate_is_not_promoted(self): + result = evaluate_candidate( + "rank(delta(amount, 3)) - rank(mean(returns, 5))", + self.panel, + split_date="2024-03-15", + gate=PromotionGate(min_oos_rank_ic=0.05, max_turnover=0.60), + ) + self.assertFalse(result["promoted"]) + + def test_costs_reduce_reported_net_return(self): + result = evaluate_candidate( + "rank(delta(amount, 3))", + self.panel, + split_date="2024-03-15", + gate=PromotionGate(transaction_cost_bps=30), + ) + self.assertLessEqual(result["oos_net_return"], result["oos_gross_return"]) + + +if __name__ == "__main__": + unittest.main()