feat(factor): 合成层v2c滚动重估——12个年度制度档案(quant12/all18×2021-2026,3年窗拟合+新旧各半平滑)+composite_rolling扫描注册+0权源跳过瘦身(all18_2021十八项降十二项),9新测试全量277绿 [nas]
CI/CD / test (push) Successful in 24s
CI/CD / nas-deploy (push) Successful in 28s
CI/CD / nas-verify (push) Successful in 9s

This commit is contained in:
2026-09-11 13:39:37 +08:00
parent 0bd88b6e37
commit a3a752d77f
16 changed files with 1119 additions and 0 deletions
+1
View File
@@ -4,3 +4,4 @@ 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(权重档案,导入即注册)
from . import composite_rolling # noqa: F401 合成层 v2c(滚动制度,导入即注册)
+62
View File
@@ -0,0 +1,62 @@
"""合成层 v2c(Rolling): 滚动制度因子注册——每年一份权重制度,档案扫描批量挂载.
v2c 语义: 权重不再是全期单档案,而是按年滚动——每年一份"制度"档案
weight_profiles/{scope}_roll_{year}.json(数据拟合产出,scope=quant12/all18),
注册为 composite_v2c_{scope}_{year};跑批按年段评估后离线拼接(本模块只管
注册机制,不做拼接).
文件名 → 因子名约定: year 取档案名尾部 4 位数字(20 开头),scope 取 _roll_
前段;不合约定的文件(v2a/v2b 档案等)不纳入扫描.依赖完全复用 composite_
weighting(v2a)管线: load_profile 校验 + register_composite_from_profile
三源表匹配/0 权项跳过/幂等注册._register_all 先幂等重挂 v2a 依赖链
(v1.1 源+neu+v2a/v2b 默认档案),再逐滚动档案注册;单档案失败 raise 且带
文件名——制度期缺失会让年段拼接静默少一段,宁可失败不静默吞.
"""
import re
from pathlib import Path
from . import composite_weighting
from .composite_weighting import load_profile, register_composite_from_profile
# 滚动档案目录: 相对本文件定位(与代码同仓同版本,不依赖 cwd)
_PROFILE_DIR = Path(__file__).parent / "weight_profiles"
# 约定名: {scope}_roll_{year}.json(year=尾部 4 位数字,20 开头)
_ROLLING_NAME_RE = re.compile(r"^(?P<scope>[a-z0-9]+)_roll_(?P<year>20\d{2})\.json$")
def _iter_rolling_profiles() -> list[tuple[str, Path]]:
"""扫描 weight_profiles/ 下约定名滚动档案,返回 (因子名, 路径) 列表.
排序稳定 (scope 字典序, year 升序);非约定名文件(v2a/v2b 档案/非 json)
不纳入——因子名排序与 (scope,year) 元组序在 scope 互为前缀时会分叉,
故显式按元组排,不依赖名字串序.
"""
entries: list[tuple[str, str, Path]] = []
for path in _PROFILE_DIR.iterdir():
m = _ROLLING_NAME_RE.match(path.name)
if m is not None:
entries.append((m["scope"], m["year"], path))
entries.sort(key=lambda e: (e[0], e[1]))
return [(f"composite_v2c_{scope}_{year}", path) for scope, year, path in entries]
def _register_all() -> None:
"""幂等注册全部滚动制度因子(先幂等重挂 v2a 依赖链,再逐滚动档案注册).
单档案加载/注册失败 raise 且带文件名(防静默丢制度期);registry 被清后
可独立重建(v2a 的 _register_all 幂等重挂 v1.1 源+neu+默认档案).
"""
composite_weighting._register_all()
for name, path in _iter_rolling_profiles():
try:
register_composite_from_profile(load_profile(str(path)), name)
except Exception as e:
raise ValueError(f"滚动制度档案加载/注册失败({path.name}): {e}") from e
# 模块导入时自动注册(与 composite_weighting 同模式);失败给可读错误(fail-fast)
try:
_register_all()
except Exception as e:
raise ImportError(f"合成层 v2c 滚动制度因子注册失败({_PROFILE_DIR}): {e}") from e
+14
View File
@@ -116,6 +116,9 @@ def register_composite_from_profile(profile: WeightProfile, name: str) -> None:
raise ValueError——先于幂等跳过,漂移不被静默吞掉);项形态按源 category 走
rank_term/_embed_term(复用 v1.1 两函数,口径不漂移);源因子必须已注册
(get_factor,缺则 raise).
resolved 后权重为 0 的源项跳过不入式(v2c 滚动档案存在 neu 源全 0 的
制度期,0*w 项纯耗求值、cs_neutralize OLS 白算;全零档案 raise ValueError).
"""
declared = {src: d for src, (d, _w) in profile.sources.items()}
matched: list[tuple[str, str]] | None = None
@@ -134,9 +137,18 @@ def register_composite_from_profile(profile: WeightProfile, name: str) -> None:
if name in _REGISTRY:
return
# 全零档案非法: resolve 归一会除零,先给可读 ValueError(L2 override 也可能压成全 0)
effective = {src: w for src, (_d, w) in profile.sources.items()}
for ov in profile.overrides:
effective[ov["source"]] = float(ov["weight"])
if sum(effective.values()) <= 0:
raise ValueError(f"档案全部源有效权重为 0(全零档案非法): {name}")
weights = resolve_weights(profile)
terms = []
for src_name, direction in matched: # 按匹配源表顺序拼装
if weights[src_name] <= 0: # 0 权项跳过不入式(见 docstring)
continue
src = get_factor(src_name)
if src is None:
raise ValueError(f"合成源未注册: {src_name}")
@@ -145,6 +157,8 @@ def register_composite_from_profile(profile: WeightProfile, name: str) -> None:
else:
term = composite_library.rank_term(src["expression"], direction)
terms.append((term, weights[src_name]))
if not terms:
raise ValueError(f"档案过滤 0 权后无有效项: {name}")
register_factor(name, build_weighted_expression(terms), category="composite")
@@ -0,0 +1,86 @@
{
"profile_id": "all18-roll-2021",
"name": "v2c滚动 all18 2021制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2018-01",
"end": "2020-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.130152
},
"alpha42": {
"direction": "+",
"weight": 0.033644
},
"klow": {
"direction": "-",
"weight": 0.077528
},
"alpha2": {
"direction": "+",
"weight": 0.086917
},
"alpha12": {
"direction": "+",
"weight": 0.132173
},
"vol_ma5": {
"direction": "-",
"weight": 0.060808
},
"wvma_20": {
"direction": "-",
"weight": 0.063921
},
"fund_nsi_neu": {
"direction": "+",
"weight": 0.0
},
"vma_60": {
"direction": "+",
"weight": 0.098737
},
"fund_gp_over_m_neu": {
"direction": "+",
"weight": 0.0
},
"cord_5": {
"direction": "-",
"weight": 0.104439
},
"alpha81": {
"direction": "-",
"weight": 0.06114
},
"fund_bp_neu": {
"direction": "+",
"weight": 0.0
},
"fund_sue_np_neu": {
"direction": "+",
"weight": 0.0
},
"fund_gdhs_chg_neu": {
"direction": "+",
"weight": 0.0
},
"fund_growth_scissors_neu": {
"direction": "+",
"weight": 0.0
},
"alpha83": {
"direction": "+",
"weight": 0.117903
},
"kup": {
"direction": "-",
"weight": 0.032637
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,86 @@
{
"profile_id": "all18-roll-2022",
"name": "v2c滚动 all18 2022制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2019-01",
"end": "2021-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.13266
},
"alpha42": {
"direction": "+",
"weight": 0.029096
},
"klow": {
"direction": "-",
"weight": 0.093628
},
"alpha2": {
"direction": "+",
"weight": 0.069661
},
"alpha12": {
"direction": "+",
"weight": 0.107469
},
"vol_ma5": {
"direction": "-",
"weight": 0.068048
},
"wvma_20": {
"direction": "-",
"weight": 0.062336
},
"fund_nsi_neu": {
"direction": "+",
"weight": 0.017251
},
"vma_60": {
"direction": "+",
"weight": 0.08382
},
"fund_gp_over_m_neu": {
"direction": "+",
"weight": 0.011238
},
"cord_5": {
"direction": "-",
"weight": 0.079221
},
"alpha81": {
"direction": "-",
"weight": 0.061465
},
"fund_bp_neu": {
"direction": "+",
"weight": 0.013903
},
"fund_sue_np_neu": {
"direction": "+",
"weight": 0.0
},
"fund_gdhs_chg_neu": {
"direction": "+",
"weight": 0.022258
},
"fund_growth_scissors_neu": {
"direction": "+",
"weight": 0.002767
},
"alpha83": {
"direction": "+",
"weight": 0.095155
},
"kup": {
"direction": "-",
"weight": 0.050024
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,86 @@
{
"profile_id": "all18-roll-2023",
"name": "v2c滚动 all18 2023制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2020-01",
"end": "2022-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.120745
},
"alpha42": {
"direction": "+",
"weight": 0.032799
},
"klow": {
"direction": "-",
"weight": 0.098778
},
"alpha2": {
"direction": "+",
"weight": 0.050731
},
"alpha12": {
"direction": "+",
"weight": 0.094002
},
"vol_ma5": {
"direction": "-",
"weight": 0.074204
},
"wvma_20": {
"direction": "-",
"weight": 0.044468
},
"fund_nsi_neu": {
"direction": "+",
"weight": 0.036712
},
"vma_60": {
"direction": "+",
"weight": 0.076656
},
"fund_gp_over_m_neu": {
"direction": "+",
"weight": 0.023663
},
"cord_5": {
"direction": "-",
"weight": 0.068403
},
"alpha81": {
"direction": "-",
"weight": 0.056433
},
"fund_bp_neu": {
"direction": "+",
"weight": 0.030378
},
"fund_sue_np_neu": {
"direction": "+",
"weight": 0.0
},
"fund_gdhs_chg_neu": {
"direction": "+",
"weight": 0.04279
},
"fund_growth_scissors_neu": {
"direction": "+",
"weight": 0.002767
},
"alpha83": {
"direction": "+",
"weight": 0.077659
},
"kup": {
"direction": "-",
"weight": 0.068812
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,86 @@
{
"profile_id": "all18-roll-2024",
"name": "v2c滚动 all18 2024制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2021-01",
"end": "2023-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.114329
},
"alpha42": {
"direction": "+",
"weight": 0.043816
},
"klow": {
"direction": "-",
"weight": 0.099951
},
"alpha2": {
"direction": "+",
"weight": 0.041571
},
"alpha12": {
"direction": "+",
"weight": 0.096394
},
"vol_ma5": {
"direction": "-",
"weight": 0.082495
},
"wvma_20": {
"direction": "-",
"weight": 0.034207
},
"fund_nsi_neu": {
"direction": "+",
"weight": 0.041572
},
"vma_60": {
"direction": "+",
"weight": 0.083153
},
"fund_gp_over_m_neu": {
"direction": "+",
"weight": 0.02445
},
"cord_5": {
"direction": "-",
"weight": 0.069871
},
"alpha81": {
"direction": "-",
"weight": 0.052601
},
"fund_bp_neu": {
"direction": "+",
"weight": 0.033987
},
"fund_sue_np_neu": {
"direction": "+",
"weight": 0.0
},
"fund_gdhs_chg_neu": {
"direction": "+",
"weight": 0.03597
},
"fund_growth_scissors_neu": {
"direction": "+",
"weight": 0.0
},
"alpha83": {
"direction": "+",
"weight": 0.066094
},
"kup": {
"direction": "-",
"weight": 0.079539
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,86 @@
{
"profile_id": "all18-roll-2025",
"name": "v2c滚动 all18 2025制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2022-01",
"end": "2024-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.119655
},
"alpha42": {
"direction": "+",
"weight": 0.050787
},
"klow": {
"direction": "-",
"weight": 0.088173
},
"alpha2": {
"direction": "+",
"weight": 0.04371
},
"alpha12": {
"direction": "+",
"weight": 0.100069
},
"vol_ma5": {
"direction": "-",
"weight": 0.072462
},
"wvma_20": {
"direction": "-",
"weight": 0.03619
},
"fund_nsi_neu": {
"direction": "+",
"weight": 0.040747
},
"vma_60": {
"direction": "+",
"weight": 0.088396
},
"fund_gp_over_m_neu": {
"direction": "+",
"weight": 0.025284
},
"cord_5": {
"direction": "-",
"weight": 0.074449
},
"alpha81": {
"direction": "-",
"weight": 0.056022
},
"fund_bp_neu": {
"direction": "+",
"weight": 0.038387
},
"fund_sue_np_neu": {
"direction": "+",
"weight": 0.000165
},
"fund_gdhs_chg_neu": {
"direction": "+",
"weight": 0.032269
},
"fund_growth_scissors_neu": {
"direction": "+",
"weight": 0.00216
},
"alpha83": {
"direction": "+",
"weight": 0.048876
},
"kup": {
"direction": "-",
"weight": 0.082198
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,86 @@
{
"profile_id": "all18-roll-2026",
"name": "v2c滚动 all18 2026制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2023-01",
"end": "2025-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.138156
},
"alpha42": {
"direction": "+",
"weight": 0.047246
},
"klow": {
"direction": "-",
"weight": 0.071891
},
"alpha2": {
"direction": "+",
"weight": 0.051341
},
"alpha12": {
"direction": "+",
"weight": 0.094452
},
"vol_ma5": {
"direction": "-",
"weight": 0.061948
},
"wvma_20": {
"direction": "-",
"weight": 0.047066
},
"fund_nsi_neu": {
"direction": "+",
"weight": 0.036158
},
"vma_60": {
"direction": "+",
"weight": 0.086164
},
"fund_gp_over_m_neu": {
"direction": "+",
"weight": 0.023599
},
"cord_5": {
"direction": "-",
"weight": 0.078083
},
"alpha81": {
"direction": "-",
"weight": 0.063933
},
"fund_bp_neu": {
"direction": "+",
"weight": 0.039418
},
"fund_sue_np_neu": {
"direction": "+",
"weight": 0.003018
},
"fund_gdhs_chg_neu": {
"direction": "+",
"weight": 0.033661
},
"fund_growth_scissors_neu": {
"direction": "+",
"weight": 0.006269
},
"alpha83": {
"direction": "+",
"weight": 0.041295
},
"kup": {
"direction": "-",
"weight": 0.076302
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,62 @@
{
"profile_id": "quant12-roll-2021",
"name": "v2c滚动 quant12 2021制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2018-01",
"end": "2020-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.130152
},
"alpha42": {
"direction": "+",
"weight": 0.033644
},
"klow": {
"direction": "-",
"weight": 0.077528
},
"alpha2": {
"direction": "+",
"weight": 0.086917
},
"alpha12": {
"direction": "+",
"weight": 0.132173
},
"vol_ma5": {
"direction": "-",
"weight": 0.060808
},
"wvma_20": {
"direction": "-",
"weight": 0.063921
},
"vma_60": {
"direction": "+",
"weight": 0.098737
},
"cord_5": {
"direction": "-",
"weight": 0.104439
},
"alpha81": {
"direction": "-",
"weight": 0.06114
},
"alpha83": {
"direction": "+",
"weight": 0.117903
},
"kup": {
"direction": "-",
"weight": 0.032637
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,62 @@
{
"profile_id": "quant12-roll-2022",
"name": "v2c滚动 quant12 2022制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2019-01",
"end": "2021-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.141856
},
"alpha42": {
"direction": "+",
"weight": 0.031276
},
"klow": {
"direction": "-",
"weight": 0.101203
},
"alpha2": {
"direction": "+",
"weight": 0.074433
},
"alpha12": {
"direction": "+",
"weight": 0.114894
},
"vol_ma5": {
"direction": "-",
"weight": 0.073232
},
"wvma_20": {
"direction": "-",
"weight": 0.066625
},
"vma_60": {
"direction": "+",
"weight": 0.089695
},
"cord_5": {
"direction": "-",
"weight": 0.084816
},
"alpha81": {
"direction": "-",
"weight": 0.066004
},
"alpha83": {
"direction": "+",
"weight": 0.101269
},
"kup": {
"direction": "-",
"weight": 0.054696
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,62 @@
{
"profile_id": "quant12-roll-2023",
"name": "v2c滚动 quant12 2023制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2020-01",
"end": "2022-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.139807
},
"alpha42": {
"direction": "+",
"weight": 0.037986
},
"klow": {
"direction": "-",
"weight": 0.11437
},
"alpha2": {
"direction": "+",
"weight": 0.058717
},
"alpha12": {
"direction": "+",
"weight": 0.108835
},
"vol_ma5": {
"direction": "-",
"weight": 0.085931
},
"wvma_20": {
"direction": "-",
"weight": 0.051465
},
"vma_60": {
"direction": "+",
"weight": 0.088757
},
"cord_5": {
"direction": "-",
"weight": 0.079192
},
"alpha81": {
"direction": "-",
"weight": 0.065336
},
"alpha83": {
"direction": "+",
"weight": 0.089913
},
"kup": {
"direction": "-",
"weight": 0.07969
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,62 @@
{
"profile_id": "quant12-roll-2024",
"name": "v2c滚动 quant12 2024制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2021-01",
"end": "2023-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.132345
},
"alpha42": {
"direction": "+",
"weight": 0.050697
},
"klow": {
"direction": "-",
"weight": 0.115682
},
"alpha2": {
"direction": "+",
"weight": 0.04811
},
"alpha12": {
"direction": "+",
"weight": 0.111555
},
"vol_ma5": {
"direction": "-",
"weight": 0.095477
},
"wvma_20": {
"direction": "-",
"weight": 0.03959
},
"vma_60": {
"direction": "+",
"weight": 0.096227
},
"cord_5": {
"direction": "-",
"weight": 0.080856
},
"alpha81": {
"direction": "-",
"weight": 0.060884
},
"alpha83": {
"direction": "+",
"weight": 0.076523
},
"kup": {
"direction": "-",
"weight": 0.092053
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,62 @@
{
"profile_id": "quant12-roll-2025",
"name": "v2c滚动 quant12 2025制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2022-01",
"end": "2024-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.139073
},
"alpha42": {
"direction": "+",
"weight": 0.058994
},
"klow": {
"direction": "-",
"weight": 0.102338
},
"alpha2": {
"direction": "+",
"weight": 0.050774
},
"alpha12": {
"direction": "+",
"weight": 0.116229
},
"vol_ma5": {
"direction": "-",
"weight": 0.084095
},
"wvma_20": {
"direction": "-",
"weight": 0.042046
},
"vma_60": {
"direction": "+",
"weight": 0.102671
},
"cord_5": {
"direction": "-",
"weight": 0.08647
},
"alpha81": {
"direction": "-",
"weight": 0.065105
},
"alpha83": {
"direction": "+",
"weight": 0.056727
},
"kup": {
"direction": "-",
"weight": 0.095478
}
},
"overrides": [],
"created": "2026-09-11"
}
@@ -0,0 +1,62 @@
{
"profile_id": "quant12-roll-2026",
"name": "v2c滚动 quant12 2026制度",
"method": "icir_fit_rolling3y_smoothed",
"fit_window": {
"start": "2023-01",
"end": "2025-12",
"smooth": "新旧各半"
},
"sources": {
"alpha16": {
"direction": "+",
"weight": 0.161035
},
"alpha42": {
"direction": "+",
"weight": 0.055084
},
"klow": {
"direction": "-",
"weight": 0.083813
},
"alpha2": {
"direction": "+",
"weight": 0.059831
},
"alpha12": {
"direction": "+",
"weight": 0.110114
},
"vol_ma5": {
"direction": "-",
"weight": 0.072211
},
"wvma_20": {
"direction": "-",
"weight": 0.054842
},
"vma_60": {
"direction": "+",
"weight": 0.100444
},
"cord_5": {
"direction": "-",
"weight": 0.09101
},
"alpha81": {
"direction": "-",
"weight": 0.074519
},
"alpha83": {
"direction": "+",
"weight": 0.048139
},
"kup": {
"direction": "-",
"weight": 0.088959
}
},
"overrides": [],
"created": "2026-09-11"
}
+154
View File
@@ -0,0 +1,154 @@
# tests/factor/test_composite_rolling.py
"""合成层 v2c(Rolling): 滚动制度因子——档案扫描/命名/0 权项过滤/失败留痕/幂等重建/回归."""
import importlib
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, composite_rolling
from sanguo_factor.composite_weighting import (
WeightProfile, load_profile, resolve_weights, register_composite_from_profile,
)
from sanguo_factor.registry import get_factor, _REGISTRY
# 12 滚动档案 × scope(quant12/all18) × 年(2021-2026);排序稳定 = (scope 字典序, year 升序)
EXPECTED_NAMES = ([f"composite_v2c_all18_{y}" for y in range(2021, 2027)]
+ [f"composite_v2c_quant12_{y}" for y in range(2021, 2027)])
@pytest.fixture(autouse=True)
def _ensure_v2c_registered():
"""其它测试模块清空 _REGISTRY 后幂等重挂(与 test_composite_weighting 同模式)."""
composite_rolling._register_all()
def _split_top_terms(expr: str) -> list[str]:
"""按括号深度 0 切分合成表达式顶层加法项(term 内部可含 + 与括号)."""
body = expr[expr.index("(") + 1: expr.rindex(") / ")]
parts, depth, start, i = [], 0, 0, 0
while i < len(body):
if body[i] == "(":
depth += 1
elif body[i] == ")":
depth -= 1
elif depth == 0 and body.startswith(" + ", i):
parts.append(body[start:i])
start = i + 3
i += 3
continue
i += 1
parts.append(body[start:])
return parts
# ==================== 档案扫描与命名 ====================
def test_iter_rolling_profiles_discovers_12_stable_order():
"""扫描 weight_profiles/ 认 12 个 {scope}_roll_{year}.json,排序 (scope, year) 稳定;
v2a/v2b 等非约定名文件不纳入."""
got = composite_rolling._iter_rolling_profiles()
assert [name for name, _p in got] == EXPECTED_NAMES
assert all(p.name.endswith(".json") and "_roll_" in p.name for _n, p in got)
def test_all_12_rolling_factors_registered_composite():
"""导入即注册: 12 个 composite_v2c_* 因子全在 registry,category=composite."""
for name in EXPECTED_NAMES:
fac = get_factor(name)
assert fac is not None, f"{name} 未注册"
assert fac["category"] == "composite"
# ==================== 0 权项过滤(表达式项数) ====================
def test_quant12_rolling_expression_exact_12_terms():
"""quant12 滚动档案权重全正: 表达式恰 12 项,无 0.000000 项."""
expr = get_factor("composite_v2c_quant12_2021")["expression"]
assert len(_split_top_terms(expr)) == 12
assert "0.000000 * " not in expr
def test_all18_2021_drops_zero_weight_neu_terms():
"""all18 2021 制度 6 个 neu 源 0 权被跳过: 表达式恰 12 项,neu 源因子串不出现."""
expr = get_factor("composite_v2c_all18_2021")["expression"]
assert len(_split_top_terms(expr)) == 12
assert "0.000000 * " not in expr
profile = load_profile(str(next(
p for n, p in composite_rolling._iter_rolling_profiles()
if n == "composite_v2c_all18_2021")))
zero_srcs = [s for s, (_d, w) in profile.sources.items() if w <= 0]
assert len(zero_srcs) == 6 # 设计行为: 2021 neu 全 0
for src in zero_srcs:
assert get_factor(src)["expression"] not in expr, f"0 权源 {src} 不应入式"
def test_all_rolling_terms_count_matches_positive_weights():
"""派生断言(不钉死档案数值): 12 份滚动档案顶层项数 == 正权源数,
每个正权源以 6 位定点权重入式(all18_2022..2024 含零星 0 权 neu 源同样被跳过)."""
for name, path in composite_rolling._iter_rolling_profiles():
weights = resolve_weights(load_profile(str(path)))
positive = {s: w for s, w in weights.items() if w > 0}
expr = get_factor(name)["expression"]
terms = _split_top_terms(expr)
assert len(terms) == len(positive), f"{name} 项数 {len(terms)} != 正权源数 {len(positive)}"
for src, w in positive.items():
assert any(t.startswith(f"{w:.6f} * (") for t in terms), \
f"{name}{src} 权重项"
# ==================== 全零防线与失败留痕 ====================
def test_register_all_zero_profile_raises_value_error():
"""全零档案非法: 直接构造 WeightProfile(L1 全 0,绕过 load 校验)注册 → ValueError,
且不注册(可读错误优于 ZeroDivisionError)."""
name = "composite_v2c_zerotest"
_REGISTRY.pop(name, None)
sources = {n: (d, 0.0) for n, d in composite_library.QUANT_SOURCES}
with pytest.raises(ValueError, match="全零"):
register_composite_from_profile(
WeightProfile("t-zero", "t", "manual", sources, (), {}), name)
assert get_factor(name) is None
def test_register_failure_message_carries_filename(tmp_path, monkeypatch):
"""单档案加载失败 raise 且带文件名(防静默丢制度期)."""
bad = tmp_path / "quant12_roll_2020.json"
bad.write_text(json.dumps({"profile_id": "broken", "name": "t", "method": "m"}),
encoding="utf-8") # 缺 sources
monkeypatch.setattr(composite_rolling, "_iter_rolling_profiles",
lambda: [("composite_v2c_quant12_2020", bad)])
with pytest.raises(ValueError, match="quant12_roll_2020.json"):
composite_rolling._register_all()
# ==================== 幂等重建与回归 ====================
def test_registry_cleared_reload_rebuilds_independently():
"""registry 清空后 reload composite_rolling 可独立重建: v2c 12 个 +
依赖链(v1.1 源/neu/v2a/v2b)全部重挂(幂等)."""
_REGISTRY.clear()
assert get_factor("composite_v2c_quant12_2024") is None
importlib.reload(composite_rolling)
for name in EXPECTED_NAMES:
assert get_factor(name) is not None, f"{name} 未重建"
for dep in ("alpha16", "fund_bp_neu",
composite_weighting._DEFAULT_COMPOSITE_NAME,
composite_weighting._V2B_COMPOSITE_NAME):
assert get_factor(dep) is not None, f"依赖 {dep} 未重挂"
def test_v2a_v2b_default_factors_unaffected():
"""回归: v2c 上线后既有 v2a/v2b 默认因子不变(全正权档案,项数 12/18,权重入式)."""
v2a = get_factor(composite_weighting._DEFAULT_COMPOSITE_NAME)
assert v2a is not None
assert len(_split_top_terms(v2a["expression"])) == 12
v2b = get_factor(composite_weighting._V2B_COMPOSITE_NAME)
assert v2b is not None
assert len(_split_top_terms(v2b["expression"])) == 18
weights = resolve_weights(
load_profile(str(composite_weighting._DEFAULT_PROFILE_PATH)))
for src, w in weights.items():
assert f"{w:.6f} * (" in v2a["expression"], f"v2a 缺 {src}"