From b08e339359bddd4b1d2ba69b76a2b07b76f22002 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:36:43 +0800 Subject: [PATCH 01/15] docs: establish public AI alpha research boundary --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) 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. From 7f2e37d929addb56b737c2a9fd6c97719a4ffab6 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:36:48 +0800 Subject: [PATCH 02/15] feat: add pyproject.toml --- pyproject.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 pyproject.toml 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"] From 495698bad574f97091bc4617239e108a4349d93a Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:36:53 +0800 Subject: [PATCH 03/15] feat: add .gitignore --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .gitignore 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/ From 63368f576496ece42d56f6c1a4a42d55d7d137e9 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:36:58 +0800 Subject: [PATCH 04/15] feat: add LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE 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. From 1f5cf14a15e3c029c4042f889fac5fa6153c0305 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:03 +0800 Subject: [PATCH 05/15] feat: add research_state.json --- research_state.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 research_state.json 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." + ] +} From 53a2c05b48d8f57493b68da2bd04ac0da92e6213 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:08 +0800 Subject: [PATCH 06/15] feat: add evidence/validation-summary.md --- evidence/validation-summary.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 evidence/validation-summary.md 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. From 9d5e9fb2aaa18d9f90f5db9fbe8fc2a7e06a2657 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:13 +0800 Subject: [PATCH 07/15] feat: add src/ai_alpha_lab/__init__.py --- src/ai_alpha_lab/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/ai_alpha_lab/__init__.py 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"] From 23fb98ca819daa71e4d9d95e8ed40b0337669e7e Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:18 +0800 Subject: [PATCH 08/15] feat: add src/ai_alpha_lab/expressions.py --- src/ai_alpha_lab/expressions.py | 81 +++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/ai_alpha_lab/expressions.py 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")) From e175cab7d0208d6cbe9a1a064e7e922c1de90a4f Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:23 +0800 Subject: [PATCH 09/15] feat: add src/ai_alpha_lab/research.py --- src/ai_alpha_lab/research.py | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/ai_alpha_lab/research.py diff --git a/src/ai_alpha_lab/research.py b/src/ai_alpha_lab/research.py new file mode 100644 index 0000000..1067d83 --- /dev/null +++ b/src/ai_alpha_lab/research.py @@ -0,0 +1,50 @@ +"""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 + 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 _turnover(panel: pd.DataFrame, signal: pd.Series) -> float: + 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) + return float((positions - shifts).abs().groupby(panel["date"], sort=False).mean().mean()) + + +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)] + mean_oos_ic = float(oos.mean()) if not oos.empty else float("nan") + turnover = _turnover(panel, signal) + promoted = bool(mean_oos_ic >= gate.min_oos_rank_ic and turnover <= gate.max_turnover) + return { + "formula": expression, + "oos_rank_ic": round(mean_oos_ic, 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 or turnover gate", + "gate": asdict(gate), + } From 9437b2550b9fd67a98de1e071bd412d1e8560cb8 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:28 +0800 Subject: [PATCH 10/15] feat: add src/ai_alpha_lab/demo.py --- src/ai_alpha_lab/demo.py | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/ai_alpha_lab/demo.py 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() From 85410b6d105d99c9c82535c0efcce944b2b670bc Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:33 +0800 Subject: [PATCH 11/15] feat: add tests/test_research.py --- tests/test_research.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/test_research.py diff --git a/tests/test_research.py b/tests/test_research.py new file mode 100644 index 0000000..4f7fc97 --- /dev/null +++ b/tests/test_research.py @@ -0,0 +1,33 @@ +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_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"]) + + +if __name__ == "__main__": + unittest.main() From c477cf056d4f89223be226cb30a71e834a1892f8 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:38:30 +0800 Subject: [PATCH 12/15] test: reject unsorted point-in-time panels --- tests/test_research.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_research.py b/tests/test_research.py index 4f7fc97..32f506b 100644 --- a/tests/test_research.py +++ b/tests/test_research.py @@ -19,6 +19,10 @@ 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))", From 7533765afd7ea8b886d2ec26d48ae057e86f7cb0 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:38:34 +0800 Subject: [PATCH 13/15] ci: run public research safety checks --- .github/workflows/test.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/test.yml 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 From c7deee1dcef50bffe9d5c89dc5899d2aa0423584 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:57:33 +0800 Subject: [PATCH 14/15] fix: make transaction costs part of promotion --- src/ai_alpha_lab/research.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/ai_alpha_lab/research.py b/src/ai_alpha_lab/research.py index 1067d83..1ab2298 100644 --- a/src/ai_alpha_lab/research.py +++ b/src/ai_alpha_lab/research.py @@ -12,6 +12,7 @@ @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 @@ -23,11 +24,19 @@ def _daily_rank_ic(panel: pd.DataFrame, signal: pd.Series) -> pd.Series: ) -def _turnover(panel: pd.DataFrame, signal: pd.Series) -> float: +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) - return float((positions - shifts).abs().groupby(panel["date"], sort=False).mean().mean()) + 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: @@ -36,15 +45,25 @@ def evaluate_candidate(expression: str, panel: pd.DataFrame, split_date: str, ga 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 = _turnover(panel, signal) - promoted = bool(mean_oos_ic >= gate.min_oos_rank_ic and turnover <= gate.max_turnover) + 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 or turnover gate", + "reason": "passed frozen promotion gate" if promoted else "rejected by frozen OOS IC, turnover, or net-return gate", "gate": asdict(gate), } From ae81657eb3e0facae41417e9a7df0023752e5396 Mon Sep 17 00:00:00 2001 From: gao huaifeng <97791974+bozarnr@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:57:51 +0800 Subject: [PATCH 15/15] test: assert cost-aware net-return accounting --- tests/test_research.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_research.py b/tests/test_research.py index 32f506b..a34327e 100644 --- a/tests/test_research.py +++ b/tests/test_research.py @@ -32,6 +32,15 @@ def test_demo_candidate_is_not_promoted(self): ) 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()