487 lines
22 KiB
Python
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"]
|