Files
sanguo_vnpy_v2/tests/factor/test_monthly_batch.py
T

487 lines
22 KiB
Python

# tests/factor/test_monthly_batch.py
"""先批后评·第一段 TDD——决议 J:跑批触发从「人点」变「日历」."""
import os
import pytest
from sanguo_factor import monthly_batch
from sanguo_factor.monthly_batch import main, month_end, plan_batch, window_start
from sanguo_factor.version_registry import save_registry, transition, upsert_factor
@pytest.fixture(autouse=True)
def _ensure_libraries_registered():
"""全量套件中 alpha 系测试会 _REGISTRY.clear() 后只重挂自身(既有模式)——
本模块「注册表名可解析」断言依赖全库注册,幂等重挂保证顺序无关."""
from sanguo_factor import composite_rolling, fundamental_library, sentiment_library
fundamental_library._register_all()
sentiment_library._register_all()
composite_rolling._register_all()
@pytest.fixture()
def registry():
reg = {"factors": {}}
# vol_ma5=builtin 真名(批参数须可在因子库解析, 审计 F-3 防线)
upsert_factor(reg, "vol_ma5", hypothesis="H-1") # incubating → 跑
upsert_factor(reg, "b_dead", hypothesis="H-2") # graveyard → 不跑
transition(reg, "b_dead", "assessable")
transition(reg, "b_dead", "graveyard", cause="test", evidence_run="r1")
upsert_factor(reg, "c_retired", hypothesis="H-3") # retired → 不跑
transition(reg, "c_retired", "assessable")
transition(reg, "c_retired", "promoted", promoted_at="2026-01-01",
promotion_t=3.0)
transition(reg, "c_retired", "retired")
return reg
def test_month_end_normalizes_to_last_day():
assert month_end("2026-09-18") == "2026-09-30"
assert month_end("2024-02-01") == "2024-02-29" # 闰年
def test_window_start_twelve_months():
assert window_start("2026-09-30", 12) == "2025-10-01"
def test_plan_batch_excludes_dead_and_labels(registry):
plan = plan_batch(registry, "2026-09-30")
assert plan["factor_names"] == ["vol_ma5"]
assert plan["start"] == "2025-10-01" and plan["end"] == "2026-09-30"
assert plan["label"] == "monthly_2026-09"
def test_main_invokes_run_batch_eval_with_plan(monkeypatch, registry, tmp_path):
calls = {}
def fake_run(factor_names, start, end, eval_db, label, **kw):
calls.update(names=factor_names, start=start, end=end, label=label)
return {"run_id": "x"}
monkeypatch.setattr(monthly_batch, "run_batch_eval", fake_run)
p = str(tmp_path / "reg.yaml")
save_registry(p, registry)
rc = main(["--as-of", "2026-09-30", "--registry", p, "--eval-db", "fake.db"])
assert rc == 0
assert calls["names"] == ["vol_ma5"]
assert calls["label"] == "monthly_2026-09"
assert calls["start"] == "2025-10-01" and calls["end"] == "2026-09-30"
def test_plan_batch_skips_unregistered_names(registry):
"""血统名漂移防线(审计 F-3): 库中不存在的名字剔除并列入 skipped 显式可见."""
upsert_factor(registry, "fa_typo_name", hypothesis="H-4") # 库中无此名
plan = plan_batch(registry, "2026-09-30")
assert plan["factor_names"] == ["vol_ma5"]
assert plan["skipped_unregistered"] == ["fa_typo_name"]
def test_checked_in_registry_yaml_names_all_resolve():
"""入库 config/factor_registry.yaml 全员可在因子库解析(F-3 守门,防再漂移)."""
import os
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
from sanguo_factor.registry import get_factor
from sanguo_factor.version_registry import load_registry
reg = load_registry(os.path.join(repo_root, "config", "factor_registry.yaml"))
bad = sorted(n for n in reg["factors"] if get_factor(n) is None)
assert not bad, f"注册表名与因子库漂移(须改名对齐或先注册因子): {bad}"
def test_resolve_with_yaml_registers_dynamic_expression():
import sanguo_factor.monthly_batch as mb
from sanguo_factor.registry import get_factor
reg = {"factors": {"llm_x": {
"name": "llm_x", "hypothesis": "hyp-1", "status": "incubating",
"versions": [{"v": 1, "commit": "decompose", "params": {
"expression": "cs_rank(close)", "source": "bars_daily",
"origin": "decomposer"}}]}}}
resolve = mb.resolve_with_yaml(reg)
assert get_factor("llm_x") is None # 未注册前不可解析
found = resolve("llm_x")
assert found is not None and found["category"] == "technical"
assert found["expression"] == "cs_rank(close)"
assert resolve("不存在的因子") is None
assert mb.resolve_with_yaml({"factors": {}})("nope") is None
def test_resolve_with_yaml_blocks_unprechecked_expression(capsys):
"""P1-4 纵深防御: yaml 运行期可变,注册前护栏——违规表达式不直通
eval 汇点(eval 即执行),记 skipped_unregistered+stderr."""
import sanguo_factor.monthly_batch as mb
from sanguo_factor.registry import get_factor
reg = {"factors": {"llm_evil": {
"name": "llm_evil", "hypothesis": "h", "status": "incubating",
"versions": [{"v": 1, "commit": "hand", "params": {
"expression": "__import__('os').system('boom')",
"source": "bars_daily"}}]}}}
assert mb.resolve_with_yaml(reg)("llm_evil") is None
assert get_factor("llm_evil") is None # 未注册进库
err = capsys.readouterr().err
assert "llm_evil" in err and "跳过" in err
def test_resolve_with_yaml_blocks_source_mismatch(capsys):
"""P1-4 顺带: source 声明与 derive_source 推导不符=编数嫌疑,拒注册."""
import sanguo_factor.monthly_batch as mb
reg = {"factors": {"llm_src": {
"name": "llm_src", "hypothesis": "h", "status": "incubating",
"versions": [{"v": 1, "commit": "hand", "params": {
"expression": "cs_rank(close)",
"source": "corpus_sentiment"}}]}}}
assert mb.resolve_with_yaml(reg)("llm_src") is None
assert "source" in capsys.readouterr().err
def test_plan_batch_picks_up_yaml_only_factor():
import sanguo_factor.monthly_batch as mb
reg = {"factors": {
"llm_x": {"name": "llm_x", "hypothesis": "h", "status": "incubating",
"versions": [{"v": 1, "params": {"expression": "cs_rank(close)"}}]},
"无表达式者": {"name": "无表达式者", "hypothesis": "h", "status": "incubating",
"versions": []}}}
plan = mb.plan_batch(reg, "2026-10-31", resolver=mb.resolve_with_yaml(reg))
assert "llm_x" in plan["factor_names"]
assert plan["skipped_unregistered"] == ["无表达式者"]
def test_main_exports_values_and_writes_ic_gate(tmp_path, monkeypatch):
import json
import numpy as np
import pandas as pd
import yaml
import sanguo_factor # noqa: F401 内存库注册(供 fund 因子名解析)
import sanguo_factor.monthly_batch as mb
reg_path = tmp_path / "reg.yaml"
reg_path.write_text(yaml.safe_dump({"factors": {
"llm_x": {"name": "llm_x", "hypothesis": "hyp-1", "status": "incubating",
"versions": [{"v": 1, "commit": "decompose", "params": {
"expression": "cs_rank(close)", "source": "bars_daily",
"origin": "decomposer"}}]},
# 手写库真名同批在库(闸对照腿;import sanguo_factor 即已注册可解析)
"fund_gross_margin": {
"name": "fund_gross_margin", "hypothesis": "hyp-0",
"status": "incubating",
"versions": [{"v": 1, "commit": "hand", "params": {}}]}}}),
encoding="utf-8")
calls = {}
def fake_run(factor_names, start, end, eval_db, label, **kw):
calls.update(kw, names=factor_names)
vdir = kw["factor_values_out"]
os.makedirs(vdir, exist_ok=True)
rng = np.random.default_rng(3)
w = pd.DataFrame(rng.normal(size=(25, 12)),
index=pd.date_range("2026-09-01", periods=25),
columns=[f"{i}.SSE" for i in range(12)])
for n in ("llm_x", "fund_gross_margin"):
w.to_parquet(os.path.join(vdir, f"{n}.parquet"))
return {"done": 2}
monkeypatch.setattr(mb, "run_batch_eval", fake_run)
rc = mb.main(["--as-of", "2026-10-31", "--registry", str(reg_path),
"--eval-db", str(tmp_path / "e.db"),
"--report-dir", str(tmp_path / "rep")])
assert rc == 0
assert calls["names"] == ["fund_gross_margin", "llm_x"] # D2:yaml 因子进批
rep = json.loads((tmp_path / "rep" / "ic_gate_monthly_2026-10.json"
).read_text())
assert rep["checked"] == 1 and rep["flagged"][0]["factor"] == "llm_x"
assert rep["flagged"][0]["vs"] == "fund_gross_margin"
# —— #70① 合成层入册:真注册表加载后两名进月度批(内存命中,非 yaml-miss) ——
def test_real_config_composites_enter_plan():
import sanguo_factor # noqa: F401 导入即注册(含合成层全链)
from sanguo_factor.monthly_batch import plan_batch, resolve_with_yaml
from sanguo_factor.version_registry import load_registry
reg_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(sanguo_factor.__file__))),
"config", "factor_registry.yaml")
reg = load_registry(reg_path)
plan = plan_batch(reg, "2026-10-04", resolver=resolve_with_yaml(reg))
assert "composite_quant12" in plan["factor_names"] # 等权基线
assert "composite_quant12_v2a" in plan["factor_names"] # 生产冠军
assert "composite_quant12" not in plan["skipped_unregistered"]
# —— 决议 M 半环: 月度批缺口告警+可选开单(fail-soft 不阻批) ——
def test_main_reports_gaps_and_opens_issue(tmp_path, capsys, monkeypatch):
import sanguo_factor.monthly_batch as mb
import sanguo_factor.data_gap_check as dgc
reg = tmp_path / "reg.yaml"
reg.write_text(
"factors:\n"
" fa_probe:\n"
" name: fa_probe\n hypothesis: x\n status: assessable\n"
" versions:\n - v: 1\n commit: c0ffee\n"
" params: {source: s_not_wired}\n", encoding="utf-8")
mani = tmp_path / "mani.yaml"
mani.write_text("domains: {bars_daily: {desc: x}}\n", encoding="utf-8")
def fake_resolve(name):
return True
monkeypatch.setattr(mb, "resolve_with_yaml", lambda r: fake_resolve)
monkeypatch.setattr(mb, "run_batch_eval",
lambda *a, **k: {"run_id": "r", "factors_total": 1,
"factors_done": 1})
opened = []
monkeypatch.setattr(dgc, "maybe_open_gap_issue",
lambda gaps, mp, **kw: opened.append(gaps) or 3)
rc = mb.main(["--registry", str(reg), "--as-of", "2026-10-04",
"--eval-db", str(tmp_path / "e.db"),
"--manifest", str(mani), "--gap-issue",
"--report-dir", str(tmp_path)])
assert rc == 0 # 缺口不阻批
err = capsys.readouterr().err
assert "数据缺口" in err and "fa_probe" in err
assert opened and "fa_probe" in opened[0]
def test_main_missing_manifest_failsoft(tmp_path, capsys, monkeypatch):
# m6: --manifest 指向不存在路径 → stderr 告警+跳过缺口比对,批照跑 rc=0
import sanguo_factor.monthly_batch as mb
reg = tmp_path / "reg.yaml"
reg.write_text(
"factors:\n"
" fa_probe:\n"
" name: fa_probe\n hypothesis: x\n status: assessable\n"
" versions:\n - v: 1\n commit: c0ffee\n"
" params: {source: s_not_wired}\n", encoding="utf-8")
def fake_resolve(name):
return True
monkeypatch.setattr(mb, "resolve_with_yaml", lambda r: fake_resolve)
monkeypatch.setattr(mb, "run_batch_eval",
lambda *a, **k: {"run_id": "r", "factors_total": 1,
"factors_done": 1})
rc = mb.main(["--registry", str(reg), "--as-of", "2026-10-04",
"--eval-db", str(tmp_path / "e.db"),
"--manifest", str(tmp_path / "nope.yaml"),
"--report-dir", str(tmp_path)])
assert rc == 0
assert "manifest" in capsys.readouterr().err
# —— P2-11/P2-12: manifest 四态参数化钉契约(缺路径/坏语法/坏形状/正常) ——
@pytest.mark.parametrize("state,mani_text,expect_marker,err_frag", [
# 缺路径: 最需告警场景不再零输出——标记件+告警,批照跑(P2-11)
("缺路径", None, True, "manifest 不存在"),
# 坏语法: yaml.YAMLError 原先无捕获批前崩溃(P2-12)
("坏语法", "a: [1, 2", True, "不可读"),
# 坏形状: 标量/列表 manifest→check_gaps .get AttributeError(P2-12)
("坏形状", "- just_a_list\n", True, "不可读"),
# 正常且供给齐: 不落标记件、无缺口告警(gaps 空)
("正常供给齐", "domains: {s_not_wired: {desc: x}}\n", False, None),
# 正常且有缺口: 真缺口照常告警(gaps 实),标记件仅属失败态
("正常有缺口", "domains: {other: {desc: x}}\n", False, None),
])
def test_manifest_four_states_contract(tmp_path, capsys, monkeypatch, state,
mani_text, expect_marker, err_frag):
"""四态各自契约一次钉死(防只修一态再犯): 失败三态=标记件
{"manifest_missing": true}+stderr 告警+批照跑 rc0+不开单;
正常态=不落标记件,缺口告警按比对实况。"""
import json
import sanguo_factor.monthly_batch as mb
reg = tmp_path / "reg.yaml"
reg.write_text(
"factors:\n"
" fa_probe:\n"
" name: fa_probe\n hypothesis: x\n status: assessable\n"
" versions:\n - v: 1\n commit: c0ffee\n"
" params: {source: s_not_wired}\n", encoding="utf-8")
mani = tmp_path / "mani.yaml"
if mani_text is not None:
mani.write_text(mani_text, encoding="utf-8")
monkeypatch.setattr(mb, "resolve_with_yaml", lambda r: (lambda n: True))
monkeypatch.setattr(mb, "run_batch_eval",
lambda *a, **k: {"run_id": "r", "factors_total": 1,
"factors_done": 1})
rc = mb.main(["--registry", str(reg), "--as-of", "2026-10-04",
"--eval-db", str(tmp_path / "e.db"),
"--manifest", str(mani),
"--report-dir", str(tmp_path)])
assert rc == 0 # 四态皆不阻批
err = capsys.readouterr().err
gaps_path = tmp_path / "data_gaps.json"
if expect_marker:
assert gaps_path.exists(), f"{state}: 标记件未落"
assert json.loads(gaps_path.read_text()) == {"manifest_missing": True}
assert err_frag in err, f"{state}: 告警缺失"
assert "批照跑" in err or "照跑批" in err
elif state == "正常有缺口":
assert not gaps_path.exists(), "正常态不应落标记件"
assert "数据缺口" in err and "fa_probe" in err
else: # 正常供给齐
assert not gaps_path.exists(), "正常态不应落标记件"
assert "数据缺口" not in err
def test_main_passes_vnpy_db_override(tmp_path, monkeypatch):
# 首班 VPS 修复: --vnpy-db 透传 run_batch_eval vnpy_db_override(cfg 路径双机各异)
import sanguo_factor.monthly_batch as mb
reg = tmp_path / "reg.yaml"
reg.write_text(
"factors:\n"
" fa_probe:\n"
" name: fa_probe\n hypothesis: x\n status: assessable\n"
" versions:\n - v: 1\n commit: c0ffee\n"
" params: {source: bars_daily}\n", encoding="utf-8")
def fake_resolve(name):
return True
monkeypatch.setattr(mb, "resolve_with_yaml", lambda r: fake_resolve)
seen = {}
monkeypatch.setattr(mb, "run_batch_eval",
lambda names, start, end, db, label, **kw:
seen.update(kw) or
{"run_id": "r", "factors_total": 1, "factors_done": 1})
mani = tmp_path / "mani.yaml"
mani.write_text("domains: {bars_daily: {desc: x}}\n", encoding="utf-8")
rc = mb.main(["--registry", str(reg), "--as-of", "2026-10-04",
"--eval-db", str(tmp_path / "e.db"),
"--manifest", str(mani),
"--vnpy-db", "/tmp/alt_vnpy.db",
"--report-dir", str(tmp_path)])
assert rc == 0
assert seen.get("vnpy_db_override") == "/tmp/alt_vnpy.db"
def test_main_passes_fund_data_dir(tmp_path, monkeypatch):
# 首班 VPS 修复#2: --fund-data-dir 透传 run_batch_eval fund_data_dir
import sanguo_factor.monthly_batch as mb
reg = tmp_path / "reg.yaml"
reg.write_text(
"factors:\n"
" fa_probe:\n"
" name: fa_probe\n hypothesis: x\n status: assessable\n"
" versions:\n - v: 1\n commit: c0ffee\n"
" params: {source: fundamentals_pit}\n", encoding="utf-8")
def fake_resolve(name):
return True
monkeypatch.setattr(mb, "resolve_with_yaml", lambda r: fake_resolve)
seen = {}
monkeypatch.setattr(mb, "run_batch_eval",
lambda names, start, end, db, label, **kw:
seen.update(kw) or
{"run_id": "r", "factors_total": 1, "factors_done": 1})
mani = tmp_path / "mani.yaml"
mani.write_text("domains: {fundamentals_pit: {desc: x}}\n", encoding="utf-8")
rc = mb.main(["--registry", str(reg), "--as-of", "2026-10-04",
"--eval-db", str(tmp_path / "e.db"),
"--manifest", str(mani),
"--fund-data-dir", "/tmp/static_root",
"--report-dir", str(tmp_path)])
assert rc == 0
assert seen.get("fund_data_dir") == "/tmp/static_root"
# —— P2-9: IC 闸系统性失败不再呈现绿灯(缺件计数+stderr 告警) ——
def test_main_warns_when_new_factor_values_missing(tmp_path, capsys, monkeypatch):
"""新因子 parquet 缺件(eval 单因子失败形态)→checked<len(new_names)
触发 stderr 显式告警(告警属信号语义,rc 仍 0);report 计数如实."""
import json
import numpy as np
import pandas as pd
import yaml
import sanguo_factor # noqa: F401 内存库注册(供 fund 因子名解析)
import sanguo_factor.monthly_batch as mb
reg_path = tmp_path / "reg.yaml"
reg_path.write_text(yaml.safe_dump({"factors": {
"llm_x": {"name": "llm_x", "hypothesis": "hyp-1", "status": "incubating",
"versions": [{"v": 1, "commit": "decompose", "params": {
"expression": "cs_rank(close)", "source": "bars_daily",
"origin": "decomposer"}}]},
"llm_y": {"name": "llm_y", "hypothesis": "hyp-2", "status": "incubating",
"versions": [{"v": 1, "commit": "decompose", "params": {
"expression": "cs_rank(volume)", "source": "bars_daily",
"origin": "decomposer"}}]},
# 手写库真名同批在库(闸对照腿)
"fund_gross_margin": {
"name": "fund_gross_margin", "hypothesis": "hyp-0",
"status": "incubating",
"versions": [{"v": 1, "commit": "hand", "params": {}}]}}}),
encoding="utf-8")
def fake_run(factor_names, start, end, eval_db, label, **kw):
vdir = kw["factor_values_out"]
os.makedirs(vdir, exist_ok=True)
rng = np.random.default_rng(3)
w = pd.DataFrame(rng.normal(size=(25, 12)),
index=pd.date_range("2026-09-01", periods=25),
columns=[f"{i}.SSE" for i in range(12)])
# llm_y 缺件:eval 单因子失败不落盘的形态
for n in ("llm_x", "fund_gross_margin"):
w.to_parquet(os.path.join(vdir, f"{n}.parquet"))
return {"done": 2}
monkeypatch.setattr(mb, "run_batch_eval", fake_run)
rc = mb.main(["--as-of", "2026-10-31", "--registry", str(reg_path),
"--eval-db", str(tmp_path / "e.db"),
"--report-dir", str(tmp_path / "rep")])
assert rc == 0 # 告警不改退出码
err = capsys.readouterr().err
assert "IC 正交闸" in err and "缺件" in err
rep = json.loads((tmp_path / "rep" / "ic_gate_monthly_2026-10.json"
).read_text())
assert rep["checked"] == 1 and rep["skipped_missing_values"] == 1
assert rep["compared_pairs"] == 1
# —— P3-11: 新因子判定改条目级 origin(双向漂移收敛) ——
def test_decomposer_origin_stable_across_manual_bump():
"""手工升版(新 params 不带 origin)不再无声退出闸视野;同注册表状态下
连续两月判定一致(不再随最新版 params 翻转)."""
import sanguo_factor.monthly_batch as mb
reg = {"factors": {
"llm_x": {"name": "llm_x", "hypothesis": "h", "status": "incubating",
"versions": [{"v": 1, "commit": "decompose", "params": {
"expression": "cs_rank(close)", "source": "bars_daily",
"origin": "decomposer"}}]}}}
month1 = mb.decomposer_new_names(reg, ["llm_x"])
# 月 2: 手工升版——v2 params 不携带 origin(旧判定在此无声退出闸视野)
reg["factors"]["llm_x"]["versions"].append(
{"v": 2, "commit": "hand", "params": {"expression": "cs_rank(volume)",
"source": "bars_daily"}})
month2 = mb.decomposer_new_names(reg, ["llm_x"])
assert month1 == ["llm_x"]
assert month2 == ["llm_x"]
def test_decomposer_origin_entry_level_wins_hand_factor_excluded():
"""条目级 origin 存在则以条目级为准;手写因子(origin 全无)不入闸视野."""
import sanguo_factor.monthly_batch as mb
reg = {"factors": {
# 条目级 origin=decomposer,版本 params 不带(未来写入形态)
"llm_entry": {"name": "llm_entry", "hypothesis": "h",
"status": "incubating", "origin": "decomposer",
"versions": [{"v": 1, "commit": "hand", "params": {}}]},
# 条目级显式改判 manual:压过旧版本 params 的 decomposer 戳
"llm_override": {"name": "llm_override", "hypothesis": "h",
"status": "incubating", "origin": "manual",
"versions": [{"v": 1, "commit": "decompose",
"params": {"origin": "decomposer"}}]},
# 手写因子:origin 全无,不入闸视野
"fa_hand": {"name": "fa_hand", "hypothesis": "h",
"status": "incubating",
"versions": [{"v": 1, "commit": "hand", "params": {}}]}}}
picked = mb.decomposer_new_names(reg, ["llm_entry", "llm_override", "fa_hand"])
assert picked == ["llm_entry"]