feat(factor): 合成层v2a三层权重档案——composite_quant12_v2a ICIR加权注册(权重JSON档案化零硬编码,拟合窗2018-01~2021-06考场隔离)+L2人工覆盖管道(全组重归一+reason留痕),15新测试+旧两断言稳健化+.gitignore放行档案目录 [nas]
This commit is contained in:
@@ -112,3 +112,6 @@ venv*/
|
||||
data_xtdata_stage/
|
||||
*.bak
|
||||
*.bak.*
|
||||
|
||||
# 合成层权重档案(L1模型权重/L2人工覆盖唯一事实源,须入库)
|
||||
!sanguo_factor/weight_profiles/*.json
|
||||
|
||||
@@ -3,3 +3,4 @@ from . import library # noqa: F401 (triggers _register_all to register built-i
|
||||
from . import alpha_datasets # noqa: F401 挂载 Alpha101/158(导入即注册)
|
||||
from . import fundamental_library # noqa: F401 财务因子 P0 批(导入即注册)
|
||||
from . import composite_library # noqa: F401 合成层 v1(导入即注册,源依赖前三个)
|
||||
from . import composite_weighting # noqa: F401 合成层 v2a(权重档案,导入即注册)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""合成层 v2a(Weighting): 三层权重档案——L1 模型权重 + L2 人工覆盖 + L3 表达式装配.
|
||||
|
||||
权重专题拍板的 v2a 落地: 在 v1.1 等权 baseline 之上,把 12 量价源权重从
|
||||
模型拟合(L1)与人工干预(L2)两轨合成,装配成带权合成表达式因子
|
||||
composite_quant12_v2a;方向/包裹口径完全复用 composite_library v1.1
|
||||
(rank_term/_embed_term,不复制实现).
|
||||
|
||||
三层语义:
|
||||
- L1 模型权重层: weight_profiles/*.json 档案(sources.{src}.weight =
|
||||
fit_icir 拟合权重,非负、sum≈1),档案是唯一事实源,代码零硬编码权重;
|
||||
- L2 覆盖层(overrides): 人工干预双轨验盘——每项 {source, weight, reason}
|
||||
替换对应源权重后**全组重归一化** sum=1(reason 必填=干预留痕;重归一而非
|
||||
局部缩放: 人工降权某源,其余源按 L1 相对结构分享余量);
|
||||
- L3 表达式层: build_weighted_expression 拼接 (Σ w_i·项_i) / Σw_i,
|
||||
权重 6 位定点落表达式(可复现、可 diff).
|
||||
|
||||
与 v1.1 关系: composite_quant12/fund6/all18 等权式零改动(v1.1 测试锁死);
|
||||
v2a 为独立注册名.档案 sources 方向表须与 composite_library.QUANT_SOURCES
|
||||
逐对一致(硬校验——档案只给权重,方向唯一权威在代码源表,防两轨漂移).
|
||||
幂等: 注册名已存在直接跳过(与 composite_library._register_all 同语义).
|
||||
"""
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from . import composite_library
|
||||
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"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeightProfile:
|
||||
"""三层权重档案(不可变值对象)."""
|
||||
|
||||
profile_id: str
|
||||
name: str
|
||||
method: str
|
||||
sources: dict[str, tuple[str, float]] # 源名 → (方向 "+"/"-", L1 权重)
|
||||
overrides: tuple[dict, ...] # L2 覆盖项 {source, weight, reason}
|
||||
metadata: dict # 档案其余字段(fit_window/baseline 等)
|
||||
|
||||
|
||||
def load_profile(path: str) -> WeightProfile:
|
||||
"""读 JSON 档案并校验,违例 raise ValueError(带清晰信息,fail-fast).
|
||||
|
||||
校验: 权重全 ≥0 且和 >0 / override 源 ∈ sources / override 权重 ≥0 /
|
||||
override reason 非空(人工干预必须留痕).
|
||||
"""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
for key in ("profile_id", "name", "method", "sources"):
|
||||
if key not in raw:
|
||||
raise ValueError(f"权重档案缺必填字段 {key!r}: {path}")
|
||||
|
||||
sources: dict[str, tuple[str, float]] = {}
|
||||
for src, info in raw["sources"].items():
|
||||
direction, weight = info.get("direction"), info.get("weight")
|
||||
if direction not in ("+", "-"):
|
||||
raise ValueError(f"源 {src} 方向非法(须 '+'/'-'): {direction!r}")
|
||||
if not isinstance(weight, (int, float)) or weight < 0:
|
||||
raise ValueError(f"源 {src} 权重非法(须为 ≥0 数值): {weight!r}")
|
||||
sources[src] = (direction, float(weight))
|
||||
if sum(w for _d, w in sources.values()) <= 0:
|
||||
raise ValueError(f"全部源权重和须 > 0: {path}")
|
||||
|
||||
overrides = []
|
||||
for ov in raw.get("overrides", []):
|
||||
src, weight, reason = ov.get("source"), ov.get("weight"), ov.get("reason")
|
||||
if src not in sources:
|
||||
raise ValueError(f"override 引用 sources 之外的源: {src!r}")
|
||||
if not isinstance(weight, (int, float)) or weight < 0:
|
||||
raise ValueError(f"override[{src}] 权重非法(须为 ≥0 数值): {weight!r}")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
raise ValueError(f"override[{src}] 缺非空 reason(人工干预必须留痕)")
|
||||
overrides.append(dict(ov))
|
||||
|
||||
metadata = {k: v for k, v in raw.items()
|
||||
if k not in ("profile_id", "name", "method", "sources", "overrides")}
|
||||
return WeightProfile(raw["profile_id"], raw["name"], raw["method"],
|
||||
sources, tuple(overrides), metadata)
|
||||
|
||||
|
||||
def resolve_weights(profile: WeightProfile) -> dict[str, float]:
|
||||
"""L2 覆盖层语义: 从 L1 权重出发,逐 override 替换对应源权重,再全组重归一化 sum=1.
|
||||
|
||||
无 override 时 = L1 原样(L1 档案本身已归一,重除仅消浮点归一误差).
|
||||
"""
|
||||
weights = {src: w for src, (_d, w) in profile.sources.items()}
|
||||
for ov in profile.overrides:
|
||||
weights[ov["source"]] = float(ov["weight"])
|
||||
total = sum(weights.values())
|
||||
return {src: w / total for src, w in weights.items()}
|
||||
|
||||
|
||||
def build_weighted_expression(terms: list[tuple[str, float]]) -> str:
|
||||
"""加权和拼接: (w1 * (项1) + ... ) / Σw,权重与和均 6 位定点(可 diff)."""
|
||||
sum_w = sum(w for _expr, w in terms)
|
||||
return "(" + " + ".join(f"{w:.6f} * ({expr})" for expr, w in terms) \
|
||||
+ f") / {sum_w:.6f}"
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
expected = dict(composite_library.QUANT_SOURCES)
|
||||
declared = {src: d for src, (d, _w) in profile.sources.items()}
|
||||
if declared != expected:
|
||||
raise ValueError(
|
||||
"档案源方向表与 composite_library.QUANT_SOURCES 不一致"
|
||||
"(名字集合+方向须逐对相等): "
|
||||
f"档案={sorted(declared.items())} 期望={sorted(expected.items())}"
|
||||
)
|
||||
if name in _REGISTRY:
|
||||
return
|
||||
|
||||
weights = resolve_weights(profile)
|
||||
terms = []
|
||||
for src_name, direction in composite_library.QUANT_SOURCES: # 按源表顺序拼装
|
||||
src = get_factor(src_name)
|
||||
if src is None:
|
||||
raise ValueError(f"合成源未注册: {src_name}")
|
||||
if src["category"] == "fundamental":
|
||||
term = composite_library._embed_term(src["expression"], direction)
|
||||
else:
|
||||
term = composite_library.rank_term(src["expression"], direction)
|
||||
terms.append((term, weights[src_name]))
|
||||
register_factor(name, build_weighted_expression(terms), category="composite")
|
||||
|
||||
|
||||
def _register_all() -> None:
|
||||
"""幂等注册默认 v2a 合成因子(先幂等重挂 v1.1 源,registry 被清后可独立重建)."""
|
||||
composite_library._register_all()
|
||||
register_composite_from_profile(load_profile(str(_DEFAULT_PROFILE_PATH)),
|
||||
_DEFAULT_COMPOSITE_NAME)
|
||||
|
||||
|
||||
# 模块导入时自动注册(与 composite_library 同模式);失败给可读错误(fail-fast)
|
||||
try:
|
||||
_register_all()
|
||||
except Exception as e:
|
||||
raise ImportError(
|
||||
f"合成层 v2a 默认权重档案加载/注册失败({_DEFAULT_PROFILE_PATH}): {e}"
|
||||
) from e
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"profile_id": "quant12-icirfit-v1",
|
||||
"name": "quant12 v2a ICIR加权",
|
||||
"method": "icir_fit",
|
||||
"fit_window": {
|
||||
"start": "2018-01",
|
||||
"end": "2021-06",
|
||||
"note": "724a批monthly_ic切片;2018前无量价批数据;月度IC均/std;horizon=5d;方向=v1.1已定不重选"
|
||||
},
|
||||
"constraints": {
|
||||
"non_negative": true,
|
||||
"cap_2x_mean": true,
|
||||
"zeroed": [],
|
||||
"capped": []
|
||||
},
|
||||
"sources": {
|
||||
"vma_60": {
|
||||
"direction": "+",
|
||||
"fit_icir": 1.3831,
|
||||
"weight": 0.095372
|
||||
},
|
||||
"alpha16": {
|
||||
"direction": "+",
|
||||
"fit_icir": 2.0816,
|
||||
"weight": 0.143531
|
||||
},
|
||||
"alpha83": {
|
||||
"direction": "+",
|
||||
"fit_icir": 1.4861,
|
||||
"weight": 0.10247
|
||||
},
|
||||
"alpha12": {
|
||||
"direction": "+",
|
||||
"fit_icir": 1.7292,
|
||||
"weight": 0.119235
|
||||
},
|
||||
"alpha2": {
|
||||
"direction": "+",
|
||||
"fit_icir": 1.1575,
|
||||
"weight": 0.079812
|
||||
},
|
||||
"alpha42": {
|
||||
"direction": "+",
|
||||
"fit_icir": 0.4906,
|
||||
"weight": 0.03383
|
||||
},
|
||||
"vol_ma5": {
|
||||
"direction": "-",
|
||||
"fit_icir": 1.0462,
|
||||
"weight": 0.07214
|
||||
},
|
||||
"wvma_20": {
|
||||
"direction": "-",
|
||||
"fit_icir": 0.9648,
|
||||
"weight": 0.066525
|
||||
},
|
||||
"klow": {
|
||||
"direction": "-",
|
||||
"fit_icir": 1.3213,
|
||||
"weight": 0.09111
|
||||
},
|
||||
"cord_5": {
|
||||
"direction": "-",
|
||||
"fit_icir": 1.271,
|
||||
"weight": 0.087642
|
||||
},
|
||||
"kup": {
|
||||
"direction": "-",
|
||||
"fit_icir": 0.629,
|
||||
"weight": 0.043373
|
||||
},
|
||||
"alpha81": {
|
||||
"direction": "-",
|
||||
"fit_icir": 0.9421,
|
||||
"weight": 0.06496
|
||||
}
|
||||
},
|
||||
"overrides": [],
|
||||
"created": "2026-09-11",
|
||||
"baseline": {
|
||||
"profile": "equal-weight(v1.1)",
|
||||
"h1_icir": 0.885,
|
||||
"h2_icir": 0.735
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,10 @@ def _ensure_composite_registered():
|
||||
|
||||
def test_three_composites_registered():
|
||||
facs = list_factors("composite")
|
||||
assert {f["name"] for f in facs} == {"composite_quant12", "composite_fund6",
|
||||
"composite_all18"}
|
||||
# v2a 合成层(composite_quant12_v2a)导入即注册且同类;registry 被其它模块
|
||||
# 清空后由本模块 fixture 只重挂 v1 三因子 → v2a 可在场可缺席,断言取超集
|
||||
assert {"composite_quant12", "composite_fund6",
|
||||
"composite_all18"} <= {f["name"] for f in facs}
|
||||
|
||||
|
||||
def test_source_tables_cover_18_independent_sources():
|
||||
@@ -56,8 +58,9 @@ def test_quant_sources_direction_table():
|
||||
|
||||
def test_registration_idempotent():
|
||||
import importlib
|
||||
n_before = len(list_factors("composite"))
|
||||
importlib.reload(composite_library)
|
||||
assert len(list_factors("composite")) == 3
|
||||
assert len(list_factors("composite")) == n_before # reload 不重复注册
|
||||
|
||||
|
||||
# ==================== 表达式构造契约 ====================
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# tests/factor/test_composite_weighting.py
|
||||
"""合成层 v2a(Weighting): 档案加载校验 + L2 覆盖归一 + 表达式拼接 + 注册幂等 + v1.1 回归."""
|
||||
import dataclasses
|
||||
import json
|
||||
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 composite_library, composite_weighting
|
||||
from sanguo_factor.composite_library import rank_term
|
||||
from sanguo_factor.composite_weighting import (
|
||||
WeightProfile, load_profile, resolve_weights, build_weighted_expression,
|
||||
register_composite_from_profile,
|
||||
)
|
||||
from sanguo_factor.registry import get_factor
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ensure_v2a_registered():
|
||||
"""其它测试模块清空 _REGISTRY 后幂等重挂源+v2a(与 test_composite_library 同模式)."""
|
||||
composite_weighting._register_all()
|
||||
|
||||
|
||||
# ==================== L1 档案加载与校验 ====================
|
||||
|
||||
def _base_profile_dict() -> dict:
|
||||
"""2 源小例档案骨架(load/resolve 测试用,不依赖 12 源表;违例由调用方注入)."""
|
||||
return {
|
||||
"profile_id": "t-2src", "name": "t 2源", "method": "manual",
|
||||
"sources": {
|
||||
"a": {"direction": "+", "fit_icir": 1.0, "weight": 0.6},
|
||||
"b": {"direction": "-", "fit_icir": 0.5, "weight": 0.4},
|
||||
},
|
||||
"overrides": [],
|
||||
}
|
||||
|
||||
|
||||
def _write_profile(tmp_path, profile: dict) -> str:
|
||||
"""写临时档案 JSON(测试自建,不碰真档案)."""
|
||||
p = tmp_path / "profile.json"
|
||||
p.write_text(json.dumps(profile, ensure_ascii=False), encoding="utf-8")
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_load_real_archive_directions_match_v11_source_table():
|
||||
"""真档案(只读): 12 源方向表与 QUANT_SOURCES 逐对一致,权重全正且和 ≈1."""
|
||||
profile = load_profile(str(composite_weighting._DEFAULT_PROFILE_PATH))
|
||||
assert profile.profile_id == "quant12-icirfit-v1"
|
||||
assert len(profile.sources) == 12
|
||||
assert profile.overrides == ()
|
||||
declared = {src: d for src, (d, _w) in profile.sources.items()}
|
||||
assert declared == dict(composite_library.QUANT_SOURCES)
|
||||
weights = [w for _d, w in profile.sources.values()]
|
||||
assert min(weights) > 0
|
||||
assert sum(weights) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_load_real_archive_metadata_kept(tmp_path):
|
||||
"""真档案(只读): 档案其余字段(fit_window/baseline)归入 metadata 不丢失."""
|
||||
profile = load_profile(str(composite_weighting._DEFAULT_PROFILE_PATH))
|
||||
assert profile.method == "icir_fit"
|
||||
assert profile.metadata["fit_window"]["start"] == "2018-01"
|
||||
assert profile.metadata["baseline"]["h1_icir"] == 0.885
|
||||
assert "sources" not in profile.metadata # 已提字段不重复保留
|
||||
|
||||
|
||||
def test_load_rejects_negative_weight(tmp_path):
|
||||
bad = _base_profile_dict()
|
||||
bad["sources"]["a"]["weight"] = -0.2
|
||||
with pytest.raises(ValueError, match="权重"):
|
||||
load_profile(_write_profile(tmp_path, bad))
|
||||
|
||||
|
||||
def test_load_rejects_all_zero_weights(tmp_path):
|
||||
bad = _base_profile_dict()
|
||||
bad["sources"]["a"]["weight"] = 0
|
||||
bad["sources"]["b"]["weight"] = 0
|
||||
with pytest.raises(ValueError, match="> 0"):
|
||||
load_profile(_write_profile(tmp_path, bad))
|
||||
|
||||
|
||||
def test_load_rejects_unknown_override_source(tmp_path):
|
||||
bad = _base_profile_dict()
|
||||
bad["overrides"] = [{"source": "ghost", "weight": 0.1, "reason": "盘感"}]
|
||||
with pytest.raises(ValueError, match="ghost"):
|
||||
load_profile(_write_profile(tmp_path, bad))
|
||||
|
||||
|
||||
def test_load_rejects_empty_override_reason(tmp_path):
|
||||
bad = _base_profile_dict()
|
||||
bad["overrides"] = [{"source": "a", "weight": 0.1, "reason": " "}]
|
||||
with pytest.raises(ValueError, match="reason"):
|
||||
load_profile(_write_profile(tmp_path, bad))
|
||||
|
||||
|
||||
def test_load_rejects_negative_override_weight(tmp_path):
|
||||
bad = _base_profile_dict()
|
||||
bad["overrides"] = [{"source": "a", "weight": -0.1, "reason": "盘感"}]
|
||||
with pytest.raises(ValueError, match="override"):
|
||||
load_profile(_write_profile(tmp_path, bad))
|
||||
|
||||
|
||||
# ==================== L2 覆盖层 resolve ====================
|
||||
|
||||
def _profile(sources: dict, overrides: tuple = ()) -> WeightProfile:
|
||||
return WeightProfile("t", "t", "manual", dict(sources), tuple(overrides), {})
|
||||
|
||||
|
||||
def test_resolve_without_override_equals_l1():
|
||||
"""无覆盖 = L1 原样(仅浮点归一误差)."""
|
||||
got = resolve_weights(_profile({"a": ("+", 0.6), "b": ("-", 0.4)}))
|
||||
assert got == pytest.approx({"a": 0.6, "b": 0.4})
|
||||
assert sum(got.values()) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_resolve_override_replaces_and_renormalizes():
|
||||
"""有覆盖: 替换对应源权重后全组重归一 sum=1,未覆盖源保持 L1 相对结构."""
|
||||
profile = _profile(
|
||||
{"a": ("+", 0.6), "b": ("-", 0.4)},
|
||||
overrides=({"source": "a", "weight": 0.2, "reason": "盘感降权"},),
|
||||
)
|
||||
got = resolve_weights(profile)
|
||||
assert got["a"] == pytest.approx(1 / 3) # 0.2 / (0.2+0.4)
|
||||
assert got["b"] == pytest.approx(2 / 3) # 未覆盖,按 L1 结构分享余量
|
||||
assert sum(got.values()) == pytest.approx(1.0)
|
||||
|
||||
|
||||
# ==================== L3 表达式拼接 ====================
|
||||
|
||||
def test_build_weighted_expression_exact_string():
|
||||
"""拼接形态钉死: (w1*(项1) + w2*(项2)) / Σw,权重与和均 6 位定点."""
|
||||
terms = [("cs_rank((close))", 0.25), ("cs_rank((volume))", 0.75)]
|
||||
assert build_weighted_expression(terms) == \
|
||||
"(0.250000 * (cs_rank((close))) + 0.750000 * (cs_rank((volume)))) / 1.000000"
|
||||
|
||||
|
||||
def test_build_weighted_expression_denominator_is_weight_sum():
|
||||
"""分母 = 权重和(非 1 归一假设): 0.2+0.3 → / 0.500000."""
|
||||
assert build_weighted_expression([("x", 0.2), ("y", 0.3)]).endswith("/ 0.500000")
|
||||
|
||||
|
||||
# ==================== 注册与 v1.1 回归 ====================
|
||||
|
||||
def test_v2a_registered_with_all_12_sources_and_profile_weights():
|
||||
"""composite_quant12_v2a 已注册: composite 类,12 源项齐且权重值=档案 resolve 结果."""
|
||||
fac = get_factor("composite_quant12_v2a")
|
||||
assert fac is not None and fac["category"] == "composite"
|
||||
weights = resolve_weights(
|
||||
load_profile(str(composite_weighting._DEFAULT_PROFILE_PATH)))
|
||||
expr = fac["expression"]
|
||||
for src, direction in composite_library.QUANT_SOURCES:
|
||||
term = rank_term(get_factor(src)["expression"], direction)
|
||||
assert f"{weights[src]:.6f} * ({term})" in expr, \
|
||||
f"{src} 项缺失或权重不符"
|
||||
assert expr.endswith(f") / {sum(weights.values()):.6f}")
|
||||
|
||||
|
||||
def test_register_rejects_direction_mismatch():
|
||||
"""档案方向表与 QUANT_SOURCES 不一致 → ValueError,且不注册."""
|
||||
real = load_profile(str(composite_weighting._DEFAULT_PROFILE_PATH))
|
||||
flipped = dict(real.sources)
|
||||
src0, (d0, w0) = next(iter(flipped.items()))
|
||||
flipped[src0] = ("-" if d0 == "+" else "+", w0)
|
||||
bad = dataclasses.replace(real, sources=flipped)
|
||||
with pytest.raises(ValueError, match="QUANT_SOURCES"):
|
||||
register_composite_from_profile(bad, "composite_quant12_v2a_bad")
|
||||
assert get_factor("composite_quant12_v2a_bad") is None
|
||||
|
||||
|
||||
def test_register_idempotent_double_call_keeps_expression():
|
||||
"""重复注册不炸: 名字已注册直接跳过,表达式不变."""
|
||||
before = get_factor("composite_quant12_v2a")["expression"]
|
||||
profile = load_profile(str(composite_weighting._DEFAULT_PROFILE_PATH))
|
||||
register_composite_from_profile(profile, "composite_quant12_v2a")
|
||||
assert get_factor("composite_quant12_v2a")["expression"] == before
|
||||
|
||||
|
||||
def test_v11_regression_quant12_equal_weight_unchanged():
|
||||
"""v1.1 回归: 导入 v2a 后 composite_quant12 仍 == 独立重建的等权式(零影响)."""
|
||||
expected = composite_library.build_composite_expression(
|
||||
[rank_term(get_factor(n)["expression"], d)
|
||||
for n, d in composite_library.QUANT_SOURCES]
|
||||
)
|
||||
assert get_factor("composite_quant12")["expression"] == expected
|
||||
Reference in New Issue
Block a user