Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
.venv/
*.egg-info/
.pytest_cache/
artifacts/
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,55 @@
# eee
# 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.
30 changes: 30 additions & 0 deletions evidence/validation-summary.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
22 changes: 22 additions & 0 deletions research_state.json
Original file line number Diff line number Diff line change
@@ -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."
]
}
6 changes: 6 additions & 0 deletions src/ai_alpha_lab/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
41 changes: 41 additions & 0 deletions src/ai_alpha_lab/demo.py
Original file line number Diff line number Diff line change
@@ -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()
81 changes: 81 additions & 0 deletions src/ai_alpha_lab/expressions.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +49 to +53
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"))
69 changes: 69 additions & 0 deletions src/ai_alpha_lab/research.py
Original file line number Diff line number Diff line change
@@ -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
)
Comment on lines +22 to +24


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),
}
Loading
Loading