# tests/api/test_routes_pipeline_factors.py """pipeline 因子评审端点 TDD——#91② 控制台判死回写卡片(D5 对齐 CLI).""" import os import pytest import yaml from fastapi.testclient import TestClient from sanguo_api.app import create_app from sanguo_api.auth import create_token, set_jwt_config from sanguo_portfolio import pipeline_store @pytest.fixture() def client(tmp_path, monkeypatch): # fixture 逐字对齐 test_routes_pipeline_hypotheses.py 既有范式,另隔离注册表/事件 monkeypatch.setenv("SANGUO_PIPELINE_DB", str(tmp_path / "pipeline.db")) monkeypatch.setenv("SANGUO_LLM_API_KEY", "sk-test") monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "reg.yaml")) monkeypatch.setenv("SANGUO_FACTOR_EVENTS", str(tmp_path / "events.jsonl")) set_jwt_config(secret="test", expire_minutes=60) app = create_app(db_path=str(tmp_path / "t.db"), file_dir=None) c = TestClient(app) c.headers.update({"Authorization": f"Bearer {create_token('admin')}"}) return c def _seed_factor_card(client, name="fa_dead"): r = client.post("/api/v1/pipeline/hypotheses", json={ "title": "t", "logic": "If a,则 b", "expectedSign": "positive", "falsifiable": "若 a<=0 则证伪", "dataNeeds": ["bars_daily"], "sentence": "s"}) hyp = r.json()["item"]["id"] # hyp- 前缀,回写才生效 reg = {"factors": {name: {"name": name, "hypothesis": hyp, "status": "assessable", "versions": []}}} with open(os.environ["SANGUO_FACTOR_REGISTRY"], "w", encoding="utf-8") as f: yaml.safe_dump(reg, f, allow_unicode=True) return name, hyp def test_graveyard_verdict_writes_back_card(client, tmp_path): name, hyp = _seed_factor_card(client) r = client.post(f"/api/v1/pipeline/factors/{name}/verdict", json={"verdict": "graveyard", "note": "ic 崩了"}) assert r.status_code == 200 and r.json()["action"] == "graveyard" card = next(c for c in pipeline_store.list_hypotheses( str(tmp_path / "pipeline.db")) if c["id"] == hyp) assert card["state"] == "graveyard" assert card["factorId"] == name assert name in card["deathReason"] and "ic 崩了" in card["deathReason"] def test_revise_verdict_does_not_write_back(client, tmp_path): name, hyp = _seed_factor_card(client) r = client.post(f"/api/v1/pipeline/factors/{name}/verdict", json={"verdict": "revise", "note": "再看看"}) assert r.status_code == 200 card = next(c for c in pipeline_store.list_hypotheses( str(tmp_path / "pipeline.db")) if c["id"] == hyp) assert card["state"] != "graveyard" and not card.get("deathReason") def test_factors_and_detail_carry_description(client, tmp_path): """2026-10-09 补丁:description 条目带出,factors 列表与出生档案都可见.""" import yaml name, hyp = _seed_factor_card(client) # 建卡+写 registry(assessable) reg_path = os.environ["SANGUO_FACTOR_REGISTRY"] with open(reg_path, encoding="utf-8") as f: reg = yaml.safe_load(f) reg["factors"][name]["description"] = "毛利变动因子自述" # codex review LOW-1:并排一个无 description 的存量形态因子,真测缺省=空串 reg["factors"]["fa_legacy"] = {"name": "fa_legacy", "hypothesis": hyp, "status": "assessable", "versions": []} with open(reg_path, "w", encoding="utf-8") as f: yaml.safe_dump(reg, f, allow_unicode=True) items = client.get("/api/v1/pipeline/factors").json()["items"] assert next(i for i in items if i["id"] == name)["description"] == "毛利变动因子自述" assert next(i for i in items if i["id"] == "fa_legacy")["description"] == "" d = client.get(f"/api/v1/pipeline/factors/{name}/detail").json() assert d["factor"]["description"] == "毛利变动因子自述" def test_graveyard_verdict_rejects_blank_note(client, tmp_path): """codex review HIGH-1:纯空白死因不可过(必填语义,直连 API 绕过前端 trim).""" name, hyp = _seed_factor_card(client) r = client.post(f"/api/v1/pipeline/factors/{name}/verdict", json={"verdict": "graveyard", "note": " "}) assert r.status_code == 422 assert "note" in r.json()["detail"] def test_graveyard_repeat_returns_422_not_500(client, tmp_path): """codex review MEDIUM-1:终态重复判死=422 中文原因,不裸 500.""" name, hyp = _seed_factor_card(client) r1 = client.post(f"/api/v1/pipeline/factors/{name}/verdict", json={"verdict": "graveyard", "note": "重复提交防护"}) assert r1.status_code == 200 r2 = client.post(f"/api/v1/pipeline/factors/{name}/verdict", json={"verdict": "graveyard", "note": "再来一次"}) assert r2.status_code == 422 assert "墓园" in r2.json()["detail"] def test_graveyard_factor_list_endpoint(client, tmp_path): """因子墓园可见性(10-09 用户验收缺口):registry graveyard 条目出列表带死因, 供假设池页墓园 tab 判死因子区——工厂板/详情页都不收,这是唯一视图.""" name, hyp = _seed_factor_card(client) client.post(f"/api/v1/pipeline/factors/{name}/verdict", json={"verdict": "graveyard", "note": "ic 崩了"}) reg_path = os.environ["SANGUO_FACTOR_REGISTRY"] with open(reg_path, encoding="utf-8") as f: reg = yaml.safe_load(f) # 老编号因子(H- 前缀,无卡回写)也进因子墓园——正是本端点要兜住的形态 reg["factors"]["fa_old"] = {"name": "fa_old", "hypothesis": "H-2026-09-20-03", "status": "graveyard", "cause": "IC 两窗反号(09-22 终判)", "versions": []} with open(reg_path, "w", encoding="utf-8") as f: yaml.safe_dump(reg, f, allow_unicode=True) items = client.get("/api/v1/pipeline/factors/graveyard").json()["items"] got = {i["name"]: i for i in items} # res_fwd_ep=种子 registry 自带的 graveyard 因子(ensure_runtime_registry 播种),一并可见 assert set(got) == {"fa_dead", "fa_old", "res_fwd_ep"} assert got["fa_dead"]["cause"] == "ic 崩了" assert got["fa_dead"]["hypothesis"] == hyp assert got["fa_old"]["cause"] == "IC 两窗反号(09-22 终判)" # 活跃因子不出现在墓园列表 assert "fa_gross_margin" not in got def test_factor_detail_ic_stats(client, tmp_path, monkeypatch): """detail 端点带出 factor_stats(全期弹药),无报告时如实 None.""" import json as _json name, _ = _seed_factor_card(client, name="fa_x") # 注册活因子(assessable) rep_dir = tmp_path / "reports" / "factor_monthly" rep_dir.mkdir(parents=True) doc = {"as_of": "2026-09-30", "monthly_points": {"fa_x": [ {"month": "2025-09", "ic_mean": 0.04, "t": 2.1, "count": 1}, {"month": "2025-10", "ic_mean": 0.06, "t": 2.4, "count": 1}, {"month": "2026-09", "ic_mean": 0.02, "t": 1.1, "count": 1}]}, "factor_stats": {"fa_x": {"icAll": 0.04, "tAll": 1.7321, "positiveRatio": 1.0, "byYear": {"2025": 0.05, "2026": 0.02}}}} (rep_dir / "nas_2026-09-30.json").write_text(_json.dumps(doc), "utf-8") monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) r = client.get(f"/api/v1/pipeline/factors/{name}/detail") assert r.status_code == 200 got = r.json()["factor"]["icStats"] assert got["icAll"] == 0.04 and got["tAll"] == 1.7321 assert got["byYear"]["2025"] == 0.05 # 无报告时的反向用例:指到空目录,icStats 如实 None(不造数) empty = tmp_path / "reports_empty" empty.mkdir() monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(empty)) r2 = client.get(f"/api/v1/pipeline/factors/{name}/detail") assert r2.status_code == 200 and r2.json()["factor"]["icStats"] is None def test_factors_list_ic_full_t_from_stats(client, tmp_path, monkeypatch): """codex review:列表端点 icFullT 从最新报告 factor_stats.tAll 接真 (spec 承诺 factors 端点全史 t 升级);无报告仍如实 None.""" import json as _json name, _ = _seed_factor_card(client, name="fa_x") rep_dir = tmp_path / "reports" / "factor_monthly" rep_dir.mkdir(parents=True) doc = {"as_of": "2026-09-30", "factor_stats": {"fa_x": {"icAll": 0.04, "tAll": 1.7321, "positiveRatio": 1.0, "byYear": {"2025": 0.05}}}} (rep_dir / "nas_2026-09-30.json").write_text(_json.dumps(doc), "utf-8") monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) items = client.get("/api/v1/pipeline/factors").json()["items"] row = next(i for i in items if i["id"] == name) assert row["icFullT"] == 1.7321 # tAll=None(样本不足)如实 null,不造数 doc["factor_stats"]["fa_x"]["tAll"] = None (rep_dir / "nas_2026-09-30.json").write_text(_json.dumps(doc), "utf-8") items = client.get("/api/v1/pipeline/factors").json()["items"] row = next(i for i in items if i["id"] == name) assert row["icFullT"] is None # 无报告:零读不崩,如实 null empty = tmp_path / "reports_empty" empty.mkdir() monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(empty)) items = client.get("/api/v1/pipeline/factors").json()["items"] row = next(i for i in items if i["id"] == name) assert row["icFullT"] is None def test_factor_detail_ic_stats_prefers_newer_daily(client, tmp_path, monkeypatch): """2026-10-11 双层分工补丁:icStats 读根层∪daily/ 取 as_of 新者—— 根层 10-04 旧 schema 无 factor_stats,daily 件有真值→速览出真值.""" import json as _json name, _ = _seed_factor_card(client, name="fa_x") rep_dir = tmp_path / "reports" / "factor_monthly" rep_dir.mkdir(parents=True) (rep_dir / "vps_2026-10-04.json").write_text( _json.dumps({"as_of": "2026-10-04", "monthly_points": {}}), "utf-8") # 旧 schema daily_dir = rep_dir / "daily" daily_dir.mkdir() (daily_dir / "vps_2026-10-09.json").write_text(_json.dumps({ "as_of": "2026-10-09", "factor_stats": {"fa_x": {"icAll": 0.03, "tAll": 2.5, "positiveRatio": 0.8, "byYear": {"2026": 0.03}}}}), "utf-8") monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) got = client.get(f"/api/v1/pipeline/factors/{name}/detail").json()["factor"]["icStats"] assert got and got["tAll"] == 2.5 # daily 件新者胜,不再被旧根层件挡住 def test_factor_detail_ic_stats_root_newer_wins(client, tmp_path, monkeypatch): """根层件更新时仍胜 daily(取 as_of 新者,不是 daily 优先).""" import json as _json name, _ = _seed_factor_card(client, name="fa_x") rep_dir = tmp_path / "reports" / "factor_monthly" rep_dir.mkdir(parents=True) (rep_dir / "vps_2026-11-04.json").write_text(_json.dumps({ "as_of": "2026-11-04", "factor_stats": {"fa_x": {"icAll": 0.05, "tAll": 3.3, "positiveRatio": 0.9, "byYear": {}}}}), "utf-8") daily_dir = rep_dir / "daily" daily_dir.mkdir() (daily_dir / "vps_2026-10-20.json").write_text(_json.dumps({ "as_of": "2026-10-20", "factor_stats": {"fa_x": {"icAll": 0.01, "tAll": 1.1, "positiveRatio": 0.5, "byYear": {}}}}), "utf-8") monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) got = client.get(f"/api/v1/pipeline/factors/{name}/detail").json()["factor"]["icStats"] assert got and got["tAll"] == 3.3 def test_judgement_layer_ignores_daily_dir(client, tmp_path, monkeypatch): """判定层不动:monthly-review 仍只读根层,daily 件不得泄漏进速览卡/对拍.""" import json as _json rep_dir = tmp_path / "reports" / "factor_monthly" rep_dir.mkdir(parents=True) (rep_dir / "vps_2026-10-04.json").write_text(_json.dumps({ "as_of": "2026-10-04", "factors": {"fa_old": {"t": 1.0}}, "collective": {"median": 2.1, "floor": 2.0}}), "utf-8") daily_dir = rep_dir / "daily" daily_dir.mkdir() (daily_dir / "vps_2026-10-09.json").write_text(_json.dumps({ "as_of": "2026-10-09", "factors": {"fa_new": {"t": 9.9}}, "collective": {"median": 9.9, "floor": 2.0}}), "utf-8") monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) got = client.get("/api/v1/pipeline/monthly-review").json() assert got["runDate"] == "2026-10-04" # 仍是根层最新,不被 daily 顶掉 assert got["factorsEvaluated"] == 1 # daily 的 fa_new 不算进来