feat(platform): 因子平台与因子↔策略双向联动 v0.2.3

- 因子平台: /factors 一级页(检验/因子库/编辑器/组合/挖掘), DSL 公式因子(25 算子点选、双语字段、我的因子模板、脏公式守卫), 版本与生命周期, 自动挖掘 L1 统计筛选
- 因子↔策略四条桥: 触发器 Zap 快建因子条件信号、因子一键生成排名策略、自定义信号 AI 提示词接入因子分组、策略回测因子归因(胜/败单入场信号日因子均值, 独立 tab, 双语因子名)
- 回测: 统计卡新增盈亏比(≥1 红/<1 绿), 蒙卡回撤合并为中位/95% 双值卡(自适应字号), 高级设置基础过滤与策略编辑器参数对齐(5 组区间)
- 信号库独立页 /signals(原设置 tab 迁出), 持仓提醒入导航; 挖掘并入因子页第 5 tab, /mining 旧链接重定向
- 研究线配套: 因子目录 61→77(评分/矩阵双内核), stats_v2(Newey-West/BH-FDR/DSR), enriched 管道与异动/报价服务配套调整
- 文档: README 导航与特性表、features.md 因子平台章节、操作说明书 9.2、factor-platform-plan 执行状态与 §5、二开文档桥接说明; 交流与支持节改版
- 版本 0.2.2 → 0.2.3; 后端全量 1625 passed(1 例环境性跳过), 前端 build 通过
This commit is contained in:
shy3130
2026-09-05 15:41:15 +08:00
parent bab609b2d4
commit e0cd625ef4
71 changed files with 9127 additions and 548 deletions
+309
View File
@@ -0,0 +1,309 @@
"""因子 API (validate / trial) 契约测试 (P2)。"""
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.factors import router
@pytest.fixture()
def cleanup_registry():
"""测试注册的自定义因子在用例后注销, 不污染全局注册表 (快照测试依赖 77 基线)。"""
created: set[str] = set()
yield created
from app.factors.registry import unregister_factor
for fid in created:
unregister_factor(fid)
class _FakeEngine:
"""合成面板: 3 只股票日收益固定 1%/2%/3%, 任何按价格排序的因子 IC 恒为 1。"""
def __init__(self, n_days: int = 40) -> None:
rows = []
end = date.today()
for index in range(n_days):
day = end - timedelta(days=n_days - 1 - index)
for symbol_id, daily_return in (("A", 0.01), ("B", 0.02), ("C", 0.03)):
# 正基数且增速同序: C 永远最高价且回报最高 → 按价格排序的因子 IC 恒为 1
rows.append({
"symbol": symbol_id,
"date": day,
"close": (1.0 + daily_return) ** index * 10.0 * (ord(symbol_id) - ord("A") + 1),
})
self.panel = pl.DataFrame(rows).sort(["symbol", "date"])
def load_panel(self, symbols, start, end, *, columns=None, asset_type="stock", **_kwargs):
frame = self.panel
if columns is not None:
for column in columns:
if column not in frame.columns:
frame = frame.with_columns(pl.lit(None).cast(pl.Float64).alias(column))
frame = frame.select(columns)
return frame.filter((pl.col("date") >= start) & (pl.col("date") <= end))
def _client(with_engine: bool = False) -> TestClient:
app = FastAPI()
app.include_router(router)
if with_engine:
app.state.backtest_engine = _FakeEngine()
return TestClient(app)
def test_validate_ok_formula() -> None:
response = _client().post("/api/factors/validate", json={"formula": "rank(-ts_sum(change_pct, 5))"})
assert response.status_code == 200
payload = response.json()
assert payload["ok"] is True
assert payload["errors"] == []
assert payload["dependencies"] == ["change_pct"]
assert payload["warmup_bars"] == 6
assert payload["cross_sectional"] is True
def test_validate_future_function_rejected() -> None:
response = _client().post("/api/factors/validate", json={"formula": "rank(ts_delta(close, -5))"})
assert response.status_code == 200
payload = response.json()
assert payload["ok"] is False
assert payload["errors"][0]["code"] == "E005"
assert "position" in payload["errors"][0]
def test_trial_golden_ic() -> None:
response = _client(with_engine=True).post(
"/api/factors/trial", json={"formula": "close", "days": 30},
)
assert response.status_code == 200
payload = response.json()
assert payload["ok"] is True
assert payload["n_dates"] == 30 # 40 日面板, 首日无前收 → 39 个可算截面, 取最近 30
assert payload["ic_mean"] == pytest.approx(1.0, abs=1e-9)
assert payload["ic_win_rate"] == pytest.approx(1.0, abs=1e-9)
# 恒定 IC 序列 std=0, IR 无定义 → None (除零保护)
assert payload["ic_std"] in (None, 0.0)
assert payload["ir"] is None
assert len(payload["ic_series"]) == 30
def test_trial_compile_failure_400() -> None:
response = _client(with_engine=True).post(
"/api/factors/trial", json={"formula": "nope_col + 1", "days": 30},
)
assert response.status_code == 400
body = response.json()["detail"]
assert body["errors"][0]["code"] == "E001"
def test_trial_computes_virtual_factor_via_shared_path() -> None:
# 引用虚拟因子 ma20_bias: 试算端点复用 _compute_missing_factors 补算路径
# (compute_indicators 算 ma20 + materialize 物化 bias), 3 列粗面板即可出结果
response = _client(with_engine=True).post(
"/api/factors/trial", json={"formula": "ma20_bias", "days": 20},
)
assert response.status_code == 200
payload = response.json()
assert payload["ok"] is True
assert payload["n_dates"] == 20
assert payload["ic_mean"] is not None
def test_group_and_status_update_after_registry_load(tmp_path, cleanup_registry) -> None:
"""改分组/状态在因子已注册 (启动加载后) 的真实路径下可用。
回归保护: 同版本直接 register 会被注册表拒绝 ("版本未提升"),
端点必须先注销再按新元数据注册。
"""
from pathlib import Path
from types import SimpleNamespace
from app.factors.registry import get_factor
app = FastAPI()
app.include_router(router)
app.state.backtest_engine = _FakeEngine()
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=Path(tmp_path)))
client = TestClient(app)
created = client.post("/api/factors/custom", json={
"id": "uf_group_test", "label": "分组测试", "formula": "rank(-ts_sum(change_pct, 5))",
})
assert created.status_code == 200
factor_id = created.json()["id"]
cleanup_registry.add(factor_id)
from app.factors import store
store.load_into_registry(Path(tmp_path)) # 模拟重启后的注册状态
registered = get_factor(factor_id)
assert registered is not None and registered.group == "自定义"
renamed = client.post(f"/api/factors/custom/{factor_id}/group", json={"group": "我的动量组"})
assert renamed.status_code == 200
assert renamed.json()["group"] == "我的动量组"
refreshed = get_factor(factor_id)
assert refreshed is not None and refreshed.group == "我的动量组" # 注册表同步
on_disk = next(d for d in store.load_all(Path(tmp_path)) if d["id"] == factor_id)
assert on_disk["group"] == "我的动量组" # 磁盘持久化
activated = client.post(f"/api/factors/custom/{factor_id}/status", json={"status": "active"})
assert activated.status_code == 200 # 修复前: 400 "版本未提升"
assert get_factor(factor_id).stability == "stable"
bad = client.post(f"/api/factors/custom/{factor_id}/group", json={"group": " "})
assert bad.status_code == 400 # 空白分组名 fail-closed
def test_update_custom_factor_bumps_version(tmp_path, cleanup_registry) -> None:
"""编辑已有自定义因子: 版本提升注册 + 公式变化回 draft + 试算门禁。"""
from pathlib import Path
from types import SimpleNamespace
from app.factors import store
from app.factors.registry import get_factor
cleanup_registry.add("uf_edit_test")
app = FastAPI()
app.include_router(router)
app.state.backtest_engine = _FakeEngine()
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=Path(tmp_path)))
client = TestClient(app)
data_dir = Path(tmp_path)
created = client.post("/api/factors/custom", json={
"id": "uf_edit_test", "label": "编辑测试", "formula": "rank(-ts_sum(change_pct, 5))",
})
assert created.status_code == 200
assert created.json()["version"] == 1
# 激活后再编辑: 公式变化 → 新版本 + 回 draft (生命周期语义)
activated = client.post("/api/factors/custom/uf_edit_test/status", json={"status": "active"})
assert activated.status_code == 200
updated = client.post("/api/factors/custom/uf_edit_test/update", json={
"label": "编辑测试v2", "group": "新分组", "formula": "rank(-ts_sum(change_pct, 10))",
"description": "窗口从 5 改 10", "direction": "low",
})
assert updated.status_code == 200
body = updated.json()
assert body["version"] == 2 and body["status"] == "draft"
spec = get_factor("uf_edit_test")
assert spec is not None
assert spec.version == 2 and spec.group == "新分组" and spec.label == "编辑测试v2"
assert "change_pct" in spec.dependencies
on_disk = next(d for d in store.load_all(data_dir) if d["id"] == "uf_edit_test")
assert on_disk["version"] == 2 and on_disk["status"] == "draft"
# 仅改元数据 (公式不变): 版本仍提升, 状态保留 (不回 draft)
client.post("/api/factors/custom/uf_edit_test/status", json={"status": "active"})
meta = client.post("/api/factors/custom/uf_edit_test/update", json={
"label": "仅改名字", "group": "新分组", "formula": "rank(-ts_sum(change_pct, 10))",
})
assert meta.status_code == 200
assert meta.json() == {"ok": True, "id": "uf_edit_test", "version": 3, "status": "active"}
missing = client.post("/api/factors/custom/uf_nope/update", json={
"label": "x", "formula": "close",
})
assert missing.status_code == 404
def test_trial_response_includes_newey_west_t() -> None:
response = _client(with_engine=True).post(
"/api/factors/trial", json={"formula": "close", "days": 30},
)
assert response.status_code == 200
payload = response.json()
assert payload["n_dates"] == 30
assert "t_newey_west" in payload # 恒定 IC 序列下可为 None, 但字段必须存在
def test_delete_custom_and_composite_factor(tmp_path, cleanup_registry) -> None:
"""删除契约: 复合因子可直接删; 成员被复合引用时 409 列引用方, force 才放行。"""
from pathlib import Path
from types import SimpleNamespace
from app.factors import store
from app.factors.registry import get_factor
app = FastAPI()
app.include_router(router)
app.state.backtest_engine = _FakeEngine() # 创建门禁需试算
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=Path(tmp_path)))
client = TestClient(app)
data_dir = Path(tmp_path)
client.post("/api/factors/custom", json={
"id": "uf_del_member", "label": "被引用成员", "formula": "rank(-ts_sum(change_pct, 5))",
})
cleanup_registry.add("uf_del_member")
created = client.post("/api/factors/composite", json={
"id": "cf_del_test", "label": "删除测试组合", "members": {"uf_del_member": 1.0, "momentum_10d": -0.5},
})
assert created.status_code == 200
cleanup_registry.add("cf_del_test")
# 404: 不存在的因子
assert client.delete("/api/factors/custom/cf_nope").status_code == 404
# 409: 成员被复合因子引用 → fail-closed, 返回引用方列表
blocked = client.delete("/api/factors/custom/uf_del_member")
assert blocked.status_code == 409
detail = blocked.json()["detail"]
assert "cf_del_test" in str(detail["references"])
assert get_factor("uf_del_member") is not None # 引用未解除, 因子仍在
# 复合因子本身可直接删除 (无人引用它)
removed = client.delete("/api/factors/custom/cf_del_test")
assert removed.status_code == 200
assert removed.json() == {"ok": True, "id": "cf_del_test", "removed_references": []}
assert get_factor("cf_del_test") is None
assert all(d["id"] != "cf_del_test" for d in store.load_all(data_dir)) # 磁盘已删
# 引用解除后成员可正常删除
freed = client.delete("/api/factors/custom/uf_del_member")
assert freed.status_code == 200
assert get_factor("uf_del_member") is None
def test_delete_with_strategy_reference_requires_force(tmp_path, cleanup_registry) -> None:
"""策略文件引用同样拦截: 409 列出 strategies/*.json, force=true 放行。"""
import json
from pathlib import Path
from types import SimpleNamespace
from app.factors.registry import get_factor
app = FastAPI()
app.include_router(router)
app.state.backtest_engine = _FakeEngine() # 创建门禁需试算
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=Path(tmp_path)))
client = TestClient(app)
data_dir = Path(tmp_path)
client.post("/api/factors/custom", json={
"id": "uf_strat_ref", "label": "策略引用", "formula": "rank(-ts_sum(change_pct, 5))",
})
cleanup_registry.add("uf_strat_ref")
strategies_dir = data_dir / "strategies"
strategies_dir.mkdir()
(strategies_dir / "my_strategy.json").write_text(
json.dumps({"name": "my_strategy", "factors": {"uf_strat_ref": 1.0}}), encoding="utf-8",
)
blocked = client.delete("/api/factors/custom/uf_strat_ref")
assert blocked.status_code == 409
assert blocked.json()["detail"]["references"] == ["strategies/my_strategy.json"]
forced = client.delete("/api/factors/custom/uf_strat_ref?force=true")
assert forced.status_code == 200
assert forced.json()["removed_references"] == ["strategies/my_strategy.json"]
assert get_factor("uf_strat_ref") is None