250 lines
12 KiB
Python
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 不算进来
|