Files
tick-stock-panel/backend/tests/test_factor_store.py
T
shy3130 e0cd625ef4 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 通过
2026-09-05 15:41:15 +08:00

149 lines
5.6 KiB
Python

"""自定义/复合因子存储与 scoring 桥测试 (P3)。"""
from __future__ import annotations
from datetime import date
import polars as pl
import pytest
from app.factors import store
from app.factors.registry import (
FactorSpec,
all_factors,
factor_columns_view,
get_factor,
unregister_factor,
)
from app.strategy import scoring
@pytest.fixture()
def cleanup_registry():
"""测试注册的自定义因子在用例后清理, 不污染全局注册表。"""
before = set()
yield before
for fid in before:
unregister_factor(fid)
def _panel(n_days: int = 30) -> pl.DataFrame:
rows = []
volumes = {"A": 1000.0, "B": 3000.0, "C": 2000.0}
for index in range(n_days):
for symbol, close in (("A", 10.0 + index), ("B", 50.0 - index), ("C", 20.0 + index * 2)):
rows.append({
"symbol": symbol, "date": date(2026, 1, index + 1),
"close": close, "volume": volumes[symbol] + index, "amount": (1000.0 + index) * close,
})
return pl.DataFrame(rows).sort(["symbol", "date"])
def test_custom_factor_definition_roundtrip(tmp_path, cleanup_registry) -> None:
definition = {
"id": "uf_test_rev", "kind": "custom", "version": 1, "label": "测试反转",
"group": "自定义", "formula": "rank(-ts_sum(close / ts_delay(close, 1) - 1, 5))",
"description": "", "direction": "low", "status": "draft",
}
spec = store.register_definition(definition)
cleanup_registry.add("uf_test_rev")
assert spec.kind == "custom"
assert "close" in spec.dependencies
assert spec.warmup_bars >= 6
store.save_one(tmp_path, definition)
loaded = store.load_all(tmp_path)
assert len(loaded) == 1 and loaded[0]["id"] == "uf_test_rev"
# 目录视图与 all_factors 追加动态因子
ids = [item["id"] for item in factor_columns_view()]
assert ids[:77] == [item["id"] for item in factor_columns_view()[:77]]
assert "uf_test_rev" in ids and ids.index("uf_test_rev") >= 77
assert any(s.id == "uf_test_rev" for s in all_factors())
# 快照约束不受影响: 未注册动态因子时目录 = 77 内置
unregister_factor("uf_test_rev")
assert len(factor_columns_view()) == 77
def test_custom_factor_invalid_rejected(cleanup_registry) -> None:
with pytest.raises(ValueError, match="E005"):
store.register_definition({
"id": "uf_bad", "kind": "custom", "label": "坏因子",
"formula": "ts_delay(close, -3)", "status": "draft",
})
with pytest.raises(ValueError, match="uf_"):
store.register_definition({
"id": "wrong_prefix", "kind": "custom", "label": "坏前缀",
"formula": "close", "status": "draft",
})
def test_composite_definition_and_cycle_guard(cleanup_registry) -> None:
definition = {
"id": "cf_test_combo", "kind": "composite", "version": 1, "label": "测试组合",
"members": {"momentum_20d": 0.6, "turnover_rate": 0.4}, "status": "draft",
}
spec = store.register_definition(definition)
cleanup_registry.add("cf_test_combo")
assert spec.kind == "composite"
assert spec.components == (("momentum_20d", 0.6), ("turnover_rate", 0.4))
assert spec.dependencies == frozenset({"momentum_20d", "turnover_rate"})
# 自引用拒绝
with pytest.raises(ValueError, match="自身"):
store.to_spec({**definition, "id": "cf_self", "members": {"cf_self": 1.0, "close": 1.0}})
def test_scoring_bridge_composite(cleanup_registry) -> None:
"""复合因子经 scoring 物化: 截面加权 z 分可计算且依赖展开正确。"""
store.register_definition({
"id": "cf_ztest", "kind": "composite", "version": 1, "label": "桥接测试",
"members": {"close": 0.5, "volume": 0.5}, "status": "active",
})
cleanup_registry.add("cf_ztest")
deps = scoring.scoring_dependencies({"cf_ztest": 1.0})
assert deps == {"close", "volume"}
assert scoring.scoring_warmup_bars({"cf_ztest": 1.0}) >= 1
frame = scoring.materialize_scoring_columns(_panel(), {"cf_ztest"})
assert "cf_ztest" in frame.columns
values = frame.filter(pl.col("date") == date(2026, 1, 10))["cf_ztest"]
assert values.is_not_null().all()
# 截面 z 之和的均值近似为 0 (等权两成员)
assert abs(values.mean()) < 1e-9
def test_scoring_bridge_custom_materializes(cleanup_registry) -> None:
"""自定义 DSL 因子经 materialize_scoring_columns 物化 (与检验共用路径)。"""
store.register_definition({
"id": "uf_rank_close", "kind": "custom", "version": 1, "label": "价格排名",
"formula": "rank(close)", "status": "draft",
})
cleanup_registry.add("uf_rank_close")
frame = scoring.materialize_scoring_columns(_panel(), {"uf_rank_close"})
assert "uf_rank_close" in frame.columns
day = frame.filter(pl.col("date") == date(2026, 1, 1))
assert day["uf_rank_close"].is_not_null().all()
def test_load_into_registry_isolated_failure(tmp_path, cleanup_registry) -> None:
good = {
"id": "uf_good", "kind": "custom", "version": 1, "label": "好因子",
"formula": "close + 1", "status": "draft",
}
store.save_one(tmp_path, good)
(tmp_path / "user_data" / "custom_factors" / "uf_broken.json").write_text(
"{ not json", encoding="utf-8"
)
loaded = store.load_into_registry(tmp_path)
assert loaded == ["uf_good"]
cleanup_registry.add("uf_good")
def test_unregister_builtin_rejected() -> None:
with pytest.raises(ValueError, match="内置"):
unregister_factor("rsi_14")
spec = get_factor("rsi_14")
assert isinstance(spec, FactorSpec)