feat(factor): 合成层v2b财务源市值中性化——cs_neutralize截面OLS残差算子(纯polars向量化进EXPRESSION_FUNCTIONS)+6个fund_*_neu因子(剥壳派生不手抄)+weighting三源表匹配与v2b档案graceful注册,18新测试全量268绿 [nas]
CI/CD / test (push) Successful in 24s
CI/CD / nas-deploy (push) Successful in 1s
CI/CD / nas-verify (push) Successful in 9s

This commit is contained in:
2026-09-11 07:39:05 +08:00
parent daaf49263c
commit 7fc547c1ab
5 changed files with 426 additions and 16 deletions
+33 -13
View File
@@ -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)
+134
View File
@@ -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_yb·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",
]
+253
View File
@@ -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) # 还原全局注册表
+3 -1
View File
@@ -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)
+3 -2
View File
@@ -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)