From a3a752d77f53fdc48106befcdecfce6c05eed17b Mon Sep 17 00:00:00 2001 From: claude_dev Date: Fri, 11 Sep 2026 13:39:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(factor):=20=E5=90=88=E6=88=90=E5=B1=82v2c?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E9=87=8D=E4=BC=B0=E2=80=94=E2=80=9412?= =?UTF-8?q?=E4=B8=AA=E5=B9=B4=E5=BA=A6=E5=88=B6=E5=BA=A6=E6=A1=A3=E6=A1=88?= =?UTF-8?q?(quant12/all18=C3=972021-2026,3=E5=B9=B4=E7=AA=97=E6=8B=9F?= =?UTF-8?q?=E5=90=88+=E6=96=B0=E6=97=A7=E5=90=84=E5=8D=8A=E5=B9=B3?= =?UTF-8?q?=E6=BB=91)+composite=5Frolling=E6=89=AB=E6=8F=8F=E6=B3=A8?= =?UTF-8?q?=E5=86=8C+0=E6=9D=83=E6=BA=90=E8=B7=B3=E8=BF=87=E7=98=A6?= =?UTF-8?q?=E8=BA=AB(all18=5F2021=E5=8D=81=E5=85=AB=E9=A1=B9=E9=99=8D?= =?UTF-8?q?=E5=8D=81=E4=BA=8C=E9=A1=B9),9=E6=96=B0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=85=A8=E9=87=8F277=E7=BB=BF=20[nas]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_factor/__init__.py | 1 + sanguo_factor/composite_rolling.py | 62 +++++++ sanguo_factor/composite_weighting.py | 14 ++ .../weight_profiles/all18_roll_2021.json | 86 ++++++++++ .../weight_profiles/all18_roll_2022.json | 86 ++++++++++ .../weight_profiles/all18_roll_2023.json | 86 ++++++++++ .../weight_profiles/all18_roll_2024.json | 86 ++++++++++ .../weight_profiles/all18_roll_2025.json | 86 ++++++++++ .../weight_profiles/all18_roll_2026.json | 86 ++++++++++ .../weight_profiles/quant12_roll_2021.json | 62 +++++++ .../weight_profiles/quant12_roll_2022.json | 62 +++++++ .../weight_profiles/quant12_roll_2023.json | 62 +++++++ .../weight_profiles/quant12_roll_2024.json | 62 +++++++ .../weight_profiles/quant12_roll_2025.json | 62 +++++++ .../weight_profiles/quant12_roll_2026.json | 62 +++++++ tests/factor/test_composite_rolling.py | 154 ++++++++++++++++++ 16 files changed, 1119 insertions(+) create mode 100644 sanguo_factor/composite_rolling.py create mode 100644 sanguo_factor/weight_profiles/all18_roll_2021.json create mode 100644 sanguo_factor/weight_profiles/all18_roll_2022.json create mode 100644 sanguo_factor/weight_profiles/all18_roll_2023.json create mode 100644 sanguo_factor/weight_profiles/all18_roll_2024.json create mode 100644 sanguo_factor/weight_profiles/all18_roll_2025.json create mode 100644 sanguo_factor/weight_profiles/all18_roll_2026.json create mode 100644 sanguo_factor/weight_profiles/quant12_roll_2021.json create mode 100644 sanguo_factor/weight_profiles/quant12_roll_2022.json create mode 100644 sanguo_factor/weight_profiles/quant12_roll_2023.json create mode 100644 sanguo_factor/weight_profiles/quant12_roll_2024.json create mode 100644 sanguo_factor/weight_profiles/quant12_roll_2025.json create mode 100644 sanguo_factor/weight_profiles/quant12_roll_2026.json create mode 100644 tests/factor/test_composite_rolling.py diff --git a/sanguo_factor/__init__.py b/sanguo_factor/__init__.py index 2c86d0d..f5e9124 100644 --- a/sanguo_factor/__init__.py +++ b/sanguo_factor/__init__.py @@ -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(滚动制度,导入即注册) diff --git a/sanguo_factor/composite_rolling.py b/sanguo_factor/composite_rolling.py new file mode 100644 index 0000000..1418ff7 --- /dev/null +++ b/sanguo_factor/composite_rolling.py @@ -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[a-z0-9]+)_roll_(?P20\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 diff --git a/sanguo_factor/composite_weighting.py b/sanguo_factor/composite_weighting.py index c883d62..9a6d0ca 100644 --- a/sanguo_factor/composite_weighting.py +++ b/sanguo_factor/composite_weighting.py @@ -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") diff --git a/sanguo_factor/weight_profiles/all18_roll_2021.json b/sanguo_factor/weight_profiles/all18_roll_2021.json new file mode 100644 index 0000000..c38e733 --- /dev/null +++ b/sanguo_factor/weight_profiles/all18_roll_2021.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/all18_roll_2022.json b/sanguo_factor/weight_profiles/all18_roll_2022.json new file mode 100644 index 0000000..2cc6153 --- /dev/null +++ b/sanguo_factor/weight_profiles/all18_roll_2022.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/all18_roll_2023.json b/sanguo_factor/weight_profiles/all18_roll_2023.json new file mode 100644 index 0000000..f862474 --- /dev/null +++ b/sanguo_factor/weight_profiles/all18_roll_2023.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/all18_roll_2024.json b/sanguo_factor/weight_profiles/all18_roll_2024.json new file mode 100644 index 0000000..4220531 --- /dev/null +++ b/sanguo_factor/weight_profiles/all18_roll_2024.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/all18_roll_2025.json b/sanguo_factor/weight_profiles/all18_roll_2025.json new file mode 100644 index 0000000..fd04f0a --- /dev/null +++ b/sanguo_factor/weight_profiles/all18_roll_2025.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/all18_roll_2026.json b/sanguo_factor/weight_profiles/all18_roll_2026.json new file mode 100644 index 0000000..d9d8ec0 --- /dev/null +++ b/sanguo_factor/weight_profiles/all18_roll_2026.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/quant12_roll_2021.json b/sanguo_factor/weight_profiles/quant12_roll_2021.json new file mode 100644 index 0000000..befc306 --- /dev/null +++ b/sanguo_factor/weight_profiles/quant12_roll_2021.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/quant12_roll_2022.json b/sanguo_factor/weight_profiles/quant12_roll_2022.json new file mode 100644 index 0000000..277b89f --- /dev/null +++ b/sanguo_factor/weight_profiles/quant12_roll_2022.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/quant12_roll_2023.json b/sanguo_factor/weight_profiles/quant12_roll_2023.json new file mode 100644 index 0000000..0a7b313 --- /dev/null +++ b/sanguo_factor/weight_profiles/quant12_roll_2023.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/quant12_roll_2024.json b/sanguo_factor/weight_profiles/quant12_roll_2024.json new file mode 100644 index 0000000..0bd437a --- /dev/null +++ b/sanguo_factor/weight_profiles/quant12_roll_2024.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/quant12_roll_2025.json b/sanguo_factor/weight_profiles/quant12_roll_2025.json new file mode 100644 index 0000000..e30f1bf --- /dev/null +++ b/sanguo_factor/weight_profiles/quant12_roll_2025.json @@ -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" +} \ No newline at end of file diff --git a/sanguo_factor/weight_profiles/quant12_roll_2026.json b/sanguo_factor/weight_profiles/quant12_roll_2026.json new file mode 100644 index 0000000..40279a9 --- /dev/null +++ b/sanguo_factor/weight_profiles/quant12_roll_2026.json @@ -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" +} \ No newline at end of file diff --git a/tests/factor/test_composite_rolling.py b/tests/factor/test_composite_rolling.py new file mode 100644 index 0000000..338d542 --- /dev/null +++ b/tests/factor/test_composite_rolling.py @@ -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} 项"