diff --git a/sanguo_factor/fundamental_library.py b/sanguo_factor/fundamental_library.py index 6af8426..69c6345 100644 --- a/sanguo_factor/fundamental_library.py +++ b/sanguo_factor/fundamental_library.py @@ -1,4 +1,4 @@ -"""财务因子批表达式库: P0 32 + P1 37(35 因子 + 2 互评变体)注册(category="fundamental"). +"""财务因子批表达式库: P0 32 + P1 37 + P1-B 6 注册(category="fundamental"). 选型来源 docs/fundamental_factor_survey_20260907.md(§3 候选池 96 个 + §4.1 去重), 首批 32 = P0 50 个按族去重后的主代表;全部「因子 = cs_rank(基础指标)」一层截面, @@ -123,10 +123,30 @@ FUNDAMENTAL_P1_FACTORS: list[tuple[str, str, str, str]] = [ ("fund_asset_growth_neg", "cs_rank(asset_growth)", "C15v", "学术负IC对照"), ] +# P1-B 批(四新域 6 注册 = 5 因子,D14 EP/BP 双注册;编号 = 调研文档 §3)。 +# 口径登记: E08 CCC 三表自算(均值=t 与 t−4 期末平均,COGS=OPERATE_COST TTM, +# 金融股置 NaN)/ E11 365 天滚动窗开区间下界,PIT 回退链 +# dividPlanAnnounceDate→PreNotice→AgmPum→Plan / E12 相邻事件 Δln(公告日 asof, +# 不用「增减比例」列)/ E13 法定披露截止 PIT(Q1→04-30/H1→08-31/Q3→10-31/ +# 年报→次年04-30) / D14 vb 长史(1990 起含退市,2026 缺口 ak 补)分域验证用 +# ——详见 fundamental_adapter 注释。 +FUNDAMENTAL_P1B_FACTORS: list[tuple[str, str, str, str]] = [ + # ---- E 资本结构/行为 P1-B(4) ---- + ("fund_ccc", "cs_rank(-ccc)", "E08", "-(弱)"), + # E11 方向不定(公告前+/除权后−混合)→ 正向注册,由 IC 实测定去留 + ("fund_send_total_12m", "cs_rank(send_total_12m)", "E11", "不定"), + ("fund_gdhs_chg", "cs_rank(-gdhs_chg)", "E12", "-"), + ("fund_topholder_chg", "cs_rank(topholder_chg)", "E13", "+"), + # ---- D 估值 P1-B(D14 双注册;vb 域长史分域验证,2017 前后须分域报 IC) ---- + ("fund_ep_vb", "cs_rank(ep_vb)", "D14", "+"), + ("fund_bp_vb", "cs_rank(bp_vb)", "D14", "+"), +] + def _register_all() -> None: """注册全部财务因子(已存在同名跳过,幂等;同 library.py 模式).""" - for name, expression, _doc_id, _ic in [*FUNDAMENTAL_FACTORS, *FUNDAMENTAL_P1_FACTORS]: + batches = [*FUNDAMENTAL_FACTORS, *FUNDAMENTAL_P1_FACTORS, *FUNDAMENTAL_P1B_FACTORS] + for name, expression, _doc_id, _ic in batches: if name not in _REGISTRY: register_factor(name, expression, category="fundamental") diff --git a/tests/factor/test_fundamental_batch.py b/tests/factor/test_fundamental_batch.py index 0ada816..206e207 100644 --- a/tests/factor/test_fundamental_batch.py +++ b/tests/factor/test_fundamental_batch.py @@ -113,6 +113,26 @@ def test_fundamental_p1_end_to_end(db, synthetic_static, tmp_path): assert m["1"]["count"] > 0, name +def test_fundamental_p1b_end_to_end(db, synthetic_static, tmp_path): + """P1-B 批端到端: vb 日频域 + dividend 事件流走引用瘦身域级加载.""" + eval_db = str(tmp_path / "fund_p1b_eval.db") + out = run_batch_eval( + factor_names=["fund_ep_vb", "fund_send_total_12m"], + start="2023-08-01", end="2023-12-31", + eval_db=eval_db, label="fund_p1b", cfg=None, vnpy_db_override=db, + fund_data_dir=synthetic_static, + ) + assert out["factors_done"] == 2 + assert out["errors"] == [] + for name in ("fund_ep_vb", "fund_send_total_12m"): + m = eval_store.get_detail(eval_db, out["run_id"], name)["metrics"] + assert "error" not in m, f"{name}: {m.get('error')}" + # ep_vb 仅 2/3 股非空(C 亏损 pe=NaN)→ <3 截面 IC 记 0(n>=3 引擎下限, + # 与 P1 vsig 同形态);域级加载/join 路径本身已被无错误评估锁死 + m = eval_store.get_detail(eval_db, out["run_id"], "fund_ep_vb")["metrics"] + assert m["1"]["count"] >= 0 + + def test_pure_price_batch_untouched(db, tmp_path): """纯量价批不传静态域照常跑(回归: 未新增强依赖).""" eval_db = str(tmp_path / "px_eval.db") diff --git a/tests/factor/test_fundamental_library.py b/tests/factor/test_fundamental_library.py index 35ec5ba..741566c 100644 --- a/tests/factor/test_fundamental_library.py +++ b/tests/factor/test_fundamental_library.py @@ -31,7 +31,7 @@ def test_p0_32_factors_registered(): facs = _fundamental_factors() names = {f["name"] for f in facs} # P0 32 + P1 37(35 因子 + 2 互评变体;P1 名单见 test_fundamental_p1_library) - assert len(facs) == 69, f"P0+P1 应为 69 个,实际 {len(facs)}" + assert len(facs) == 75, f"P0+P1+P1-B 应为 75 个,实际 {len(facs)}" # 六族代表抽查(全部名单见 fundamental_library 注释) expect = { "fund_roe_ttm", "fund_gp_over_assets", # A @@ -83,4 +83,4 @@ def test_registration_idempotent(): """重复 import 不炸(注册表防重入,同 library.py 模式).""" import importlib importlib.reload(fundamental_library) - assert len(_fundamental_factors()) == 69 + assert len(_fundamental_factors()) == 75 diff --git a/tests/factor/test_fundamental_p1_library.py b/tests/factor/test_fundamental_p1_library.py index 6e7a085..0147a91 100644 --- a/tests/factor/test_fundamental_p1_library.py +++ b/tests/factor/test_fundamental_p1_library.py @@ -100,4 +100,4 @@ def test_p1_registration_idempotent(): """重复注册幂等(防重入).""" fundamental_library._register_all() fundamental_library._register_all() - assert len(list_factors("fundamental")) == 69 + assert len(list_factors("fundamental")) == 75 diff --git a/tests/factor/test_fundamental_p1b_library.py b/tests/factor/test_fundamental_p1b_library.py new file mode 100644 index 0000000..3058a4b --- /dev/null +++ b/tests/factor/test_fundamental_p1b_library.py @@ -0,0 +1,72 @@ +# tests/factor/test_fundamental_p1b_library.py +"""P1-B 批财务因子表达式库: 四新域 6 个注册(5 因子,D14 双注册)契约锁定. + +契约: +- 表达式裸标识符 ⊆ adapter 特征列 + close/log/cs_rank +- 全部 cs_rank 一层截面;E08/E12 负 IC 取负,E11 不定按正向注册待实证 +- E13/D14 正向;vb 两因子直接消费 vb 域日频列(不经 close×share_capital) +""" +import re +import sys, os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0"))) + +import pytest + +from sanguo_factor import fundamental_library # noqa: F401 import 即注册 +from sanguo_factor.fundamental_adapter import FEATURE_COLUMNS +from sanguo_factor.fundamental_library import FUNDAMENTAL_P1B_FACTORS +from sanguo_factor.registry import list_factors, get_factor + + +@pytest.fixture(autouse=True) +def _ensure_fundamental_registered(): + """其它测试模块清空 _REGISTRY 后逐测试幂等重注册(顺序依赖前科).""" + fundamental_library._register_all() + + +_ALLOWED = set(FEATURE_COLUMNS) | {"close", "cs_rank", "log"} + + +def test_p1b_6_factors_registered(): + facs = {f["name"]: f for f in list_factors("fundamental")} + names = {n for n, _e, _d, _ic in FUNDAMENTAL_P1B_FACTORS} + assert len(names) == 6, f"P1-B 应 6 个注册(5 因子,D14 双注册),实际 {len(names)}" + for name in names: + assert name in facs, f"{name} 未注册" + assert facs[name]["category"] == "fundamental" + + +def test_p1b_expressions_only_reference_feature_columns(): + for name, expression, _d, _ic in FUNDAMENTAL_P1B_FACTORS: + idents = set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", expression)) + bad = idents - _ALLOWED + assert not bad, f"{name} 引用了未产出列: {bad} in {expression}" + + +def test_p1b_all_cross_sectional_rank_one_layer(): + for name, expression, _d, _ic in FUNDAMENTAL_P1B_FACTORS: + assert expression.startswith("cs_rank("), f"{name}: {expression}" + assert expression.endswith(")") + + +def test_p1b_negative_ic_factors_flipped(): + """E08(CCC 低=效率高)/E12(户数降=筹码集中)取负;E13/D14/E11 正向.""" + assert get_factor("fund_ccc")["expression"] == "cs_rank(-ccc)" + assert get_factor("fund_gdhs_chg")["expression"] == "cs_rank(-gdhs_chg)" + assert get_factor("fund_topholder_chg")["expression"] == "cs_rank(topholder_chg)" + # E11 方向不定(公告前+/除权后−混合)→ 正向注册,由 IC 实测定去留 + assert get_factor("fund_send_total_12m")["expression"] == "cs_rank(send_total_12m)" + + +def test_p1b_vb_factors_consume_vb_columns(): + """D14 双注册直接消费 vb 域列(长史 1990 起含退市,分域验证用).""" + assert get_factor("fund_ep_vb")["expression"] == "cs_rank(ep_vb)" + assert get_factor("fund_bp_vb")["expression"] == "cs_rank(bp_vb)" + + +def test_p1b_registration_idempotent(): + fundamental_library._register_all() + fundamental_library._register_all() + # P0 32 + P1 37 + P1-B 6 = 75 + assert len(list_factors("fundamental")) == 75