Files
sanguo_vnpy_v2/tests/api/test_routes_pipeline_factors.py
T

250 lines
12 KiB
Python

# 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 不算进来