diff --git a/sanguo_factor/composite_weighting.py b/sanguo_factor/composite_weighting.py index 3c331f8..f7d9bb0 100644 --- a/sanguo_factor/composite_weighting.py +++ b/sanguo_factor/composite_weighting.py @@ -15,21 +15,27 @@ composite_quant12_v2a;方向/包裹口径完全复用 composite_library v1.1 权重 6 位定点落表达式(可复现、可 diff). 与 v1.1 关系: composite_quant12/fund6/all18 等权式零改动(v1.1 测试锁死); -v2a 为独立注册名.档案 sources 方向表须与 composite_library.QUANT_SOURCES -逐对一致(硬校验——档案只给权重,方向唯一权威在代码源表,防两轨漂移). -幂等: 注册名已存在直接跳过(与 composite_library._register_all 同语义). +v2a 为独立注册名.档案 sources 方向表须与三源表之一(QUANT_SOURCES / +FUND_NEU_SOURCES / 两者合并18源)逐对一致(硬校验——档案只给权重,方向唯一 +权威在代码源表,防两轨漂移).幂等: 注册名已存在直接跳过(同 _register_all 语义); +v2b 档案(all18_v2b_v1.json)由数据拟合产出,存在才注册,缺省静默跳过. """ import json from dataclasses import dataclass from pathlib import Path from . import composite_library +from .fundamental_neutralize import FUND_NEU_SOURCES from .registry import register_factor, get_factor, _REGISTRY # 默认 L1 档案: 相对本文件定位(与代码同仓同版本,不依赖 cwd) _DEFAULT_PROFILE_PATH = Path(__file__).parent / "weight_profiles" / "quant12_icirfit_v1.json" _DEFAULT_COMPOSITE_NAME = "composite_quant12_v2a" +# v2b(财务源市值中性化)档案: 由后续数据拟合产出,存在才注册(graceful,不 ImportError) +_V2B_PROFILE_PATH = Path(__file__).parent / "weight_profiles" / "all18_v2b_v1.json" +_V2B_COMPOSITE_NAME = "composite_all18_v2b" + @dataclass(frozen=True) class WeightProfile: @@ -105,25 +111,32 @@ def build_weighted_expression(terms: list[tuple[str, float]]) -> str: def register_composite_from_profile(profile: WeightProfile, name: str) -> None: """按档案权重注册合成因子(幂等,名字已注册跳过). - 档案 sources 方向表须与 composite_library.QUANT_SOURCES 完全一致 - (名字集合 + direction 逐对相等,不一致 raise ValueError——先于幂等跳过, - 漂移不被静默吞掉);项形态按源 category 走 rank_term/_embed_term - (复用 v1.1 两函数,口径不漂移);源因子必须已注册(get_factor,缺则 raise). + 档案 sources 方向表须与三源表之一逐对一致(v2b 起): QUANT_SOURCES / + FUND_NEU_SOURCES / 两者拼接 18 源(名字集合 + direction 逐对相等,不一致 + raise ValueError——先于幂等跳过,漂移不被静默吞掉);项形态按源 category 走 + rank_term/_embed_term(复用 v1.1 两函数,口径不漂移);源因子必须已注册 + (get_factor,缺则 raise). """ - expected = dict(composite_library.QUANT_SOURCES) declared = {src: d for src, (d, _w) in profile.sources.items()} - if declared != expected: + matched: list[tuple[str, str]] | None = None + for table in (composite_library.QUANT_SOURCES, + FUND_NEU_SOURCES, + composite_library.QUANT_SOURCES + FUND_NEU_SOURCES): + if declared == dict(table): + matched = table + break + if matched is None: raise ValueError( - "档案源方向表与 composite_library.QUANT_SOURCES 不一致" + "档案源方向表与三源表(QUANT_SOURCES/FUND_NEU_SOURCES/两者合并18源)均不一致" "(名字集合+方向须逐对相等): " - f"档案={sorted(declared.items())} 期望={sorted(expected.items())}" + f"档案={sorted(declared.items())}" ) if name in _REGISTRY: return weights = resolve_weights(profile) terms = [] - for src_name, direction in composite_library.QUANT_SOURCES: # 按源表顺序拼装 + for src_name, direction in matched: # 按匹配源表顺序拼装 src = get_factor(src_name) if src is None: raise ValueError(f"合成源未注册: {src_name}") @@ -136,10 +149,17 @@ def register_composite_from_profile(profile: WeightProfile, name: str) -> None: def _register_all() -> None: - """幂等注册默认 v2a 合成因子(先幂等重挂 v1.1 源,registry 被清后可独立重建).""" + """幂等注册默认 v2a 合成因子(先幂等重挂 v1.1 源,registry 被清后可独立重建). + + v2b(all18_v2b)档案存在才注册;不存在静默跳过(档案由后续数据拟合产出, + 文件到位即自动注册,不 ImportError). + """ composite_library._register_all() register_composite_from_profile(load_profile(str(_DEFAULT_PROFILE_PATH)), _DEFAULT_COMPOSITE_NAME) + if _V2B_PROFILE_PATH.exists(): + register_composite_from_profile(load_profile(str(_V2B_PROFILE_PATH)), + _V2B_COMPOSITE_NAME) # 模块导入时自动注册(与 composite_library 同模式);失败给可读错误(fail-fast) diff --git a/sanguo_factor/fundamental_neutralize.py b/sanguo_factor/fundamental_neutralize.py new file mode 100644 index 0000000..9158a74 --- /dev/null +++ b/sanguo_factor/fundamental_neutralize.py @@ -0,0 +1,134 @@ +"""合成层 v2b: 财务源市值中性化 —— cs_neutralize 截面 OLS 残差算子 + fund_*_neu 六源. + +治「财务源 2021 后沦为微盘风格代理稀释合成」: 财务源因子对总市值(size)截面 +中性化取残差,剥离 size 因子暴露后仅留财务信息本身.中性化变量只用 size +(close × share_capital 总市值,表达式层两列现成可得);BP 不进中性化变量—— +fund_bp 自身即 BP,对自身回归残差恒零. + +cs_neutralize(y, x): 逐日截面 OLS 残差 resid = y − a − b·x +(b=cov(x,y)/var(x), a=mean(y)−b·mean(x)),纯 polars over("datetime") 聚合代数 +(cs_rank 同款窗口广播,无 python 逐组循环).边界: 当日有效配对样本 <3 或 +var(x)=0 → 该日残差 null;x/y 任一 null 的行残差 null(配对掩码,缺一侧的行 +不进回归统计).注册进 vnpy EXPRESSION_FUNCTIONS(fast_ops 官方扩展点,导入即注册, +表达式字符串求值可达). + +FUND_NEU_SOURCES: 6 项 = composite_library.FUND_SOURCES 同名源加 _neu 后缀, +方向全 "+"(中性化因子保持「高=好」语义,负号在表达式内定向,同原源模式). +neu 表达式 = cs_rank((±)cs_neutralize(<原指标式>, size)),其中原指标式与负号 +严格从注册表原源表达式剥掉外层 cs_rank 后代码取得(不手抄,防两处漂移); +原内层负号(如 '-nsi')移到 cs_neutralize 外——OLS 线性 resid(−y)=−resid(y), +统一「中性化正指标+外置符号」形态. +""" +import os +import sys + +import polars as pl + +# Inject vnpy source path (follow fast_ops / tests conftest pattern) +_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "vnpy_v4.4.0")) +if _VNPY_SRC not in sys.path: + sys.path.insert(0, _VNPY_SRC) + +from vnpy.alpha.dataset.utility import DataProxy, EXPRESSION_FUNCTIONS + +from . import composite_library +from .registry import register_factor, get_factor, _REGISTRY + +# size 中性化变量 = 总市值(close × share_capital,与 fundamental_library 估值族同式) +SIZE_EXPRESSION = "close * share_capital" +_NEU_SUFFIX = "_neu" +_MIN_SAMPLES = 3 # 当日有效配对样本 <3 无截面回归意义 → 残差 null + + +def cs_neutralize(feature_y: DataProxy, feature_x: DataProxy) -> DataProxy: + """逐日截面 OLS 残差: resid = y − a − b·x(b=cov/var, a=mean_y−b·mean_x). + + 纯 polars over("datetime") 聚合代数(组统计量广播回行,无逐组循环); + 配对掩码: x 或 y null 的行不进回归统计且自身残差 null. + """ + # 两操作数源自同一父 df 且全算子保长保序(fast_ts_corr 同前提)—— + # 按位拼接等价于按键 join,免大表 hash join + h_y, h_x = feature_y.df.height, feature_x.df.height + if h_y != h_x: + raise ValueError(f"cs_neutralize 操作数长度不一致: {h_y} vs {h_x}(行序对齐前提被破坏)") + df = feature_y.df.with_columns(feature_x.df["data"].alias("data_x")) + + y, x = pl.col("data"), pl.col("data_x") + pair = y.is_not_null() & x.is_not_null() + ym = pl.when(pair).then(y) # 配对掩码后仅有效对进统计(null 自动被 mean 跳过) + xm = pl.when(pair).then(x) + n = xm.count().over("datetime") + mean_y = ym.mean().over("datetime") + mean_x = xm.mean().over("datetime") + var_x = (xm * xm).mean().over("datetime") - mean_x * mean_x + cov_xy = (xm * ym).mean().over("datetime") - mean_x * mean_y + slope = cov_xy / var_x + resid = y - (mean_y + slope * (x - mean_x)) # = y − a − b·x + result = pl.when((n >= _MIN_SAMPLES) & (var_x > 0)).then(resid).otherwise(None) + + out = df.select(pl.col("datetime"), pl.col("vt_symbol"), result.alias("data")) + return DataProxy(out) + + +def register_neutralize_ops() -> list[str]: + """注册 cs_neutralize 进 vnpy EXPRESSION_FUNCTIONS(幂等),返回算子名清单.""" + EXPRESSION_FUNCTIONS["cs_neutralize"] = cs_neutralize + return ["cs_neutralize"] + + +# 财务 6 源中性化版源表(方向全 "+": cs_neutralize 线性保持定向,cs_rank 后仍高=好) +FUND_NEU_SOURCES: list[tuple[str, str]] = [ + (f"{name}{_NEU_SUFFIX}", "+") for name, _direction in composite_library.FUND_SOURCES +] + + +def _strip_cs_rank(expression: str) -> str: + """剥掉外层 cs_rank(...): 'cs_rank(-nsi)' → '-nsi';非 cs_rank 包裹 raise.""" + prefix = "cs_rank(" + if not (expression.startswith(prefix) and expression.endswith(")")): + raise ValueError(f"财务源表达式非 cs_rank 包裹,无法剥壳: {expression!r}") + return expression[len(prefix):-1] + + +def build_neutralized_expression(source_expression: str) -> str: + """原源注册表达式 → 市值中性化版: cs_rank((±)cs_neutralize(<原指标式>, size)). + + 原内层负号(如 '-nsi')移到 cs_neutralize 外成 '(-1) *'(resid(−y)=−resid(y) + 数学等价,形态统一);正内层直接中性化.不手抄指标式,符号从入参表达式派生. + """ + inner = _strip_cs_rank(source_expression) + if inner.startswith("-"): + signed = f"(-1) * cs_neutralize({inner[1:]}, {SIZE_EXPRESSION})" + else: + signed = f"cs_neutralize({inner}, {SIZE_EXPRESSION})" + return f"cs_rank({signed})" + + +def _register_all() -> None: + """注册 6 个 fund_*_neu 因子(幂等,category=fundamental→合成层内嵌项口径; + 先幂等重挂 v1.1 源,registry 被清后可独立重建).""" + composite_library._register_all() + register_neutralize_ops() + for src_name, _direction in composite_library.FUND_SOURCES: + neu_name = f"{src_name}{_NEU_SUFFIX}" + if neu_name in _REGISTRY: + continue + src = get_factor(src_name) + if src is None: + raise ValueError(f"中性化源因子未注册: {src_name}") + register_factor(neu_name, build_neutralized_expression(src["expression"]), + category="fundamental") + + +# 模块导入时自动注册(与 fundamental_library / composite_library 同模式); +# cs_neutralize 同步进 EXPRESSION_FUNCTIONS,表达式字符串求值可达 +_register_all() + + +__all__ = [ + "cs_neutralize", + "register_neutralize_ops", + "FUND_NEU_SOURCES", + "SIZE_EXPRESSION", + "build_neutralized_expression", +] diff --git a/tests/factor/test_fundamental_neutralize.py b/tests/factor/test_fundamental_neutralize.py new file mode 100644 index 0000000..c37ebc9 --- /dev/null +++ b/tests/factor/test_fundamental_neutralize.py @@ -0,0 +1,253 @@ +# 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(): + """v2b 档案不存在(仓库当前态): _register_all 不炸、不注册 composite_all18_v2b.""" + _REGISTRY.pop(composite_weighting._V2B_COMPOSITE_NAME, None) + assert not composite_weighting._V2B_PROFILE_PATH.exists() + 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) # 还原全局注册表 diff --git a/tests/factor/test_fundamental_p1_library.py b/tests/factor/test_fundamental_p1_library.py index 0147a91..cfdc43c 100644 --- a/tests/factor/test_fundamental_p1_library.py +++ b/tests/factor/test_fundamental_p1_library.py @@ -16,6 +16,7 @@ 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_P1_FACTORS +from sanguo_factor.fundamental_neutralize import FUND_NEU_SOURCES # noqa: F401 v2b 计数用 from sanguo_factor.registry import list_factors, get_factor @@ -100,4 +101,5 @@ def test_p1_registration_idempotent(): """重复注册幂等(防重入).""" fundamental_library._register_all() fundamental_library._register_all() - assert len(list_factors("fundamental")) == 75 + # 75 = P0 32 + P1 37 + P1-B 6;v2b 另挂 6 个 fund_*_neu(fundamental 类) + assert len(list_factors("fundamental")) == 75 + len(FUND_NEU_SOURCES) diff --git a/tests/factor/test_fundamental_p1b_library.py b/tests/factor/test_fundamental_p1b_library.py index 3058a4b..ad34fda 100644 --- a/tests/factor/test_fundamental_p1b_library.py +++ b/tests/factor/test_fundamental_p1b_library.py @@ -16,6 +16,7 @@ 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.fundamental_neutralize import FUND_NEU_SOURCES # noqa: F401 v2b 计数用 from sanguo_factor.registry import list_factors, get_factor @@ -68,5 +69,5 @@ def test_p1b_vb_factors_consume_vb_columns(): 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 + # P0 32 + P1 37 + P1-B 6 = 75;v2b 另挂 6 个 fund_*_neu(fundamental 类) + assert len(list_factors("fundamental")) == 75 + len(FUND_NEU_SOURCES)