# tests/factor/test_fundamental_neutralize.py """合成层 v2b: cs_neutralize 截面中性化算子数学等值/边界 + fund_*_neu 6 因子注册 + weighting 三源表.""" import datetime as dt import json import os import sys 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 polars as pl import pytest from vnpy.alpha.dataset.utility import DataProxy, EXPRESSION_FUNCTIONS, calculate_by_expression from sanguo_factor import composite_library, composite_weighting, fundamental_neutralize from sanguo_factor.fundamental_neutralize import ( FUND_NEU_SOURCES, SIZE_EXPRESSION, cs_neutralize, build_neutralized_expression, ) from sanguo_factor.composite_weighting import WeightProfile, register_composite_from_profile from sanguo_factor.fundamental_library import ( FUNDAMENTAL_FACTORS, FUNDAMENTAL_P1_FACTORS, FUNDAMENTAL_P1B_FACTORS, ) from sanguo_factor.registry import get_factor, _REGISTRY @pytest.fixture(autouse=True) def _ensure_v2b_registered(): """其它测试模块清空 _REGISTRY 后幂等重挂(与 test_composite_weighting 同模式).""" fundamental_neutralize._register_all() composite_weighting._register_all() def _proxy(rows: list[tuple[str, str, float | None]]) -> DataProxy: """(date, symbol, value) 行列表 → DataProxy(datetime/vt_symbol/data).""" df = pl.DataFrame({ "datetime": [dt.date.fromisoformat(d) for d, _s, _v in rows], "vt_symbol": [s for _d, s, _v in rows], "v": [v for _d, _s, v in rows], }) return DataProxy(df) # ==================== 算子数学等值(手算 OLS) ==================== def test_cs_neutralize_matches_hand_computed_ols_positive_slope(): """正斜率日手算: x=[1,2,3,4], y=[1,3,2,5] → b=1.1, a=0, resid=[-0.1,0.8,-1.3,0.6].""" x = _proxy([("2024-01-02", f"s{i}", v) for i, v in enumerate([1.0, 2.0, 3.0, 4.0])]) y = _proxy([("2024-01-02", f"s{i}", v) for i, v in enumerate([1.0, 3.0, 2.0, 5.0])]) got = cs_neutralize(y, x).df["data"].to_list() assert got == pytest.approx([-0.1, 0.8, -1.3, 0.6], abs=1e-12) def test_cs_neutralize_matches_hand_computed_ols_negative_slope(): """负斜率日手算: x=[1,2,3,4], y=[5,4,2,1] → b=-1.4, a=6.5, resid=[-0.1,0.3,-0.3,0.1].""" x = _proxy([("2024-01-03", f"s{i}", v) for i, v in enumerate([1.0, 2.0, 3.0, 4.0])]) y = _proxy([("2024-01-03", f"s{i}", v) for i, v in enumerate([5.0, 4.0, 2.0, 1.0])]) got = cs_neutralize(y, x).df["data"].to_list() assert got == pytest.approx([-0.1, 0.3, -0.3, 0.1], abs=1e-12) def test_cs_neutralize_days_are_independent(): """逐日独立回归: 同一调用内两日用各自斜率(拼 day1+day2,两组手算值各归各).""" x = _proxy([(d, f"s{i}", v) for d, vals in (("2024-01-02", [1.0, 2.0, 3.0, 4.0]), ("2024-01-03", [1.0, 2.0, 3.0, 4.0])) for i, v in enumerate(vals)]) y = _proxy([(d, f"s{i}", v) for d, vals in (("2024-01-02", [1.0, 3.0, 2.0, 5.0]), ("2024-01-03", [5.0, 4.0, 2.0, 1.0])) for i, v in enumerate(vals)]) got = cs_neutralize(y, x).df["data"].to_list() assert got == pytest.approx([-0.1, 0.8, -1.3, 0.6, -0.1, 0.3, -0.3, 0.1], abs=1e-12) # ==================== 算子边界 ==================== def test_cs_neutralize_null_rows_propagate(): """null 传播: x/y 任一 null 的行残差 null;其余行按有效配对样本回归.""" # 5 股: 3 个有效配对(n=3 达阈值) + 1 个 y-null + 1 个 x-null x = _proxy([("2024-01-02", "s0", 1.0), ("2024-01-02", "s1", 2.0), ("2024-01-02", "s2", 3.0), ("2024-01-02", "s3", 4.0), ("2024-01-02", "s4", None)]) y = _proxy([("2024-01-02", "s0", 1.0), ("2024-01-02", "s1", 3.0), ("2024-01-02", "s2", 2.0), ("2024-01-02", "s3", None), ("2024-01-02", "s4", 9.0)]) got = cs_neutralize(y, x).df["data"].to_list() assert got[3] is None and got[4] is None # null 行残差 null # 前 3 行 = x=[1,2,3], y=[1,3,2] 的手算 OLS: b=0.5, a=1.0 → resid=[-0.5,1.0,-0.5] assert got[:3] == pytest.approx([-0.5, 1.0, -0.5], abs=1e-12) def test_cs_neutralize_small_group_returns_null(): """当日有效样本 < 3 → 该日全部残差 null(含有效行).""" x = _proxy([("2024-01-02", "s0", 1.0), ("2024-01-02", "s1", 2.0), ("2024-01-02", "s2", None), ("2024-01-02", "s3", None)]) y = _proxy([("2024-01-02", "s0", 1.0), ("2024-01-02", "s1", 3.0), ("2024-01-02", "s2", 7.0), ("2024-01-02", "s3", 9.0)]) got = cs_neutralize(y, x).df["data"].to_list() assert got == [None, None, None, None] def test_cs_neutralize_zero_variance_returns_null(): """当日 var(x)=0(x 全同) → 该日全部残差 null.""" x = _proxy([("2024-01-02", f"s{i}", 5.0) for i in range(4)]) y = _proxy([("2024-01-02", f"s{i}", v) for i, v in enumerate([1.0, 2.0, 3.0, 4.0])]) got = cs_neutralize(y, x).df["data"].to_list() assert got == [None, None, None, None] def test_cs_neutralize_residual_mean_zero_per_day(): """截面性质: 残差逐日均值 ≈ 0(OLS 回归性质).""" rows_x, rows_y = [], [] for day, xvals, yvals in ( ("2024-01-02", [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2.0, 1.5, 4.0, 3.5, 6.5, 5.0]), ("2024-01-03", [10.0, 20.0, 30.0, 40.0, 50.0, 60.0], [8.0, 9.0, 2.0, 3.0, 1.0, 7.0]), ("2024-01-04", [-3.0, -1.0, 0.0, 2.0, 4.0, 9.0], [5.0, -2.0, 3.0, -4.0, 1.0, 0.5]), ): for i, (xv, yv) in enumerate(zip(xvals, yvals)): rows_x.append((day, f"s{i}", xv)) rows_y.append((day, f"s{i}", yv)) out = cs_neutralize(_proxy(rows_y), _proxy(rows_x)).df per_day = out.group_by("datetime").agg(pl.col("data").mean().alias("mean_resid")) for row in per_day.iter_rows(named=True): assert row["mean_resid"] == pytest.approx(0.0, abs=1e-10) # ==================== 引擎注册与表达式求值 ==================== def test_cs_neutralize_registered_in_expression_functions(): """沿 fast_ops 官方扩展点: cs_neutralize 已注册进 vnpy EXPRESSION_FUNCTIONS.""" assert EXPRESSION_FUNCTIONS.get("cs_neutralize") is cs_neutralize def test_cs_neutralize_evaluates_via_engine_expression(): """引擎端到端: calculate_by_expression('cs_neutralize(y_col, x_col)') 多参数映射列, 输出与直接函数调用逐点一致.""" df = pl.DataFrame({ "datetime": [dt.date(2024, 1, 2)] * 4 + [dt.date(2024, 1, 3)] * 4, "vt_symbol": [f"s{i}" for i in range(4)] * 2, "y_col": [1.0, 3.0, 2.0, 5.0, 5.0, 4.0, 2.0, 1.0], "x_col": [1.0, 2.0, 3.0, 4.0] * 2, }) via_engine = calculate_by_expression(df, "cs_neutralize(y_col, x_col)") direct = cs_neutralize(DataProxy(df[["datetime", "vt_symbol", "y_col"]]), DataProxy(df[["datetime", "vt_symbol", "x_col"]])) assert via_engine["data"].to_list() == pytest.approx(direct.df["data"].to_list(), abs=1e-12) # ==================== FUND_NEU_SOURCES 表与 6 因子注册 ==================== def test_fund_neu_sources_table_shape(): """表形态: 6 项 = FUND_SOURCES 同名源加 _neu 后缀,方向全 '+'(高=好语义).""" assert FUND_NEU_SOURCES == [(f"{name}_neu", "+") for name, _d in composite_library.FUND_SOURCES] assert len(FUND_NEU_SOURCES) == 6 def test_six_neu_factors_registered_with_cs_neutralize_and_size(): """6 个 fund_*_neu 已注册(category=fundamental),表达式含 cs_neutralize 与总市值.""" for neu_name, _direction in FUND_NEU_SOURCES: fac = get_factor(neu_name) assert fac is not None, f"{neu_name} 未注册" assert fac["category"] == "fundamental" assert fac["expression"].startswith("cs_rank(") assert "cs_neutralize(" in fac["expression"] assert SIZE_EXPRESSION in fac["expression"] def test_neu_sign_direction_matches_source(): """负号方向与原源一致: 原内层带负号(nsi/gdhs_chg)→ '(-1) * cs_neutralize(正指标)'; 原内层无负号(bp)→ 直接 cs_neutralize.不手抄指标式,符号从注册表表达式派生.""" nsi = get_factor("fund_nsi_neu")["expression"] assert nsi == "cs_rank((-1) * cs_neutralize(nsi, close * share_capital))" gdhs = get_factor("fund_gdhs_chg_neu")["expression"] assert gdhs == "cs_rank((-1) * cs_neutralize(gdhs_chg, close * share_capital))" bp = get_factor("fund_bp_neu")["expression"] assert bp == "cs_rank(cs_neutralize(equity / (close * share_capital), close * share_capital))" # 全 6 源一致性: 符号形态从原源表达式剥壳推导,逐源核对 for src_name, _d in composite_library.FUND_SOURCES: assert get_factor(f"{src_name}_neu")["expression"] == \ build_neutralized_expression(get_factor(src_name)["expression"]) def test_original_fund_sources_unchanged(): """原 6 源零回归: fund_bp 等表达式与 fundamental_library 注册表逐字一致.""" registered = {name: expr for name, expr, _doc, _ic in [*FUNDAMENTAL_FACTORS, *FUNDAMENTAL_P1_FACTORS, *FUNDAMENTAL_P1B_FACTORS]} for src_name, _d in composite_library.FUND_SOURCES: assert get_factor(src_name)["expression"] == registered[src_name] # ==================== weighting 三源表匹配 + v2b graceful ==================== def test_weighting_accepts_fund_neu_profile(): """纯 FUND_NEU_SOURCES 档案: 方向表匹配通过并注册,项=内嵌(已定向,不再包 cs_rank).""" name = "composite_fund6_neu_test" _REGISTRY.pop(name, None) sources = {n: (d, 1.0) for n, d in FUND_NEU_SOURCES} register_composite_from_profile(WeightProfile("t-neu", "t", "manual", sources, (), {}), name) fac = get_factor(name) assert fac is not None and fac["category"] == "composite" assert get_factor("fund_bp_neu")["expression"] in fac["expression"] assert "vma_60" not in fac["expression"] _REGISTRY.pop(name, None) def test_weighting_accepts_combined_18_profile(): """QUANT+FUND_NEU 合并 18 源档案: 匹配通过,量价=rank 项+中性化=内嵌项并存.""" name = "composite_all18_v2b_test" _REGISTRY.pop(name, None) sources = {n: (d, 1.0) for n, d in composite_library.QUANT_SOURCES} sources.update({n: (d, 1.0) for n, d in FUND_NEU_SOURCES}) register_composite_from_profile(WeightProfile("t-18", "t", "manual", sources, (), {}), name) expr = get_factor(name)["expression"] assert get_factor("vma_60")["expression"] in expr # 量价源进合成 assert get_factor("fund_bp_neu")["expression"] in expr # 中性化源进合成 _REGISTRY.pop(name, None) def test_weighting_rejects_mixed_direction_drift(): """方向漂移仍拒: 18 源档案翻转一源方向 → ValueError 且不注册(不被三选一吞掉).""" name = "composite_all18_v2b_bad" sources = {n: (d, 1.0) for n, d in composite_library.QUANT_SOURCES} sources.update({n: (d, 1.0) for n, d in FUND_NEU_SOURCES}) first = next(iter(sources)) d, w = sources[first] sources[first] = ("-" if d == "+" else "+", w) with pytest.raises(ValueError, match="QUANT_SOURCES"): register_composite_from_profile( WeightProfile("t-bad", "t", "manual", sources, (), {}), name) assert get_factor(name) is None def test_v2b_profile_absent_skips_registration(tmp_path, monkeypatch): """v2b 档案不存在(真档案已上线,monkeypatch 指向不存在路径模拟缺席): _register_all 不炸、不注册 composite_all18_v2b.""" _REGISTRY.pop(composite_weighting._V2B_COMPOSITE_NAME, None) monkeypatch.setattr(composite_weighting, "_V2B_PROFILE_PATH", tmp_path / "absent_v2b.json") composite_weighting._register_all() # 不应 raise assert get_factor(composite_weighting._V2B_COMPOSITE_NAME) is None def test_v2b_profile_present_registers(tmp_path, monkeypatch): """v2b 档案存在(tmp 档案+monkeypatch 路径): _register_all 自动注册 composite_all18_v2b.""" _REGISTRY.pop(composite_weighting._V2B_COMPOSITE_NAME, None) sources = {n: (d, 1.0) for n, d in composite_library.QUANT_SOURCES} sources.update({n: (d, 1.0) for n, d in FUND_NEU_SOURCES}) profile = tmp_path / "all18_v2b_v1.json" profile.write_text(json.dumps({ "profile_id": "all18-v2b-v1", "name": "t", "method": "manual", "sources": {n: {"direction": d, "weight": w} for n, (d, w) in sources.items()}, }, ensure_ascii=False), encoding="utf-8") monkeypatch.setattr(composite_weighting, "_V2B_PROFILE_PATH", profile) composite_weighting._register_all() fac = get_factor(composite_weighting._V2B_COMPOSITE_NAME) assert fac is not None and fac["category"] == "composite" assert get_factor("fund_bp_neu")["expression"] in fac["expression"] _REGISTRY.pop(composite_weighting._V2B_COMPOSITE_NAME, None) # 还原全局注册表