Files
sanguo_vnpy_v2/tests/portfolio/test_routes_pipeline.py

668 lines
34 KiB
Python

# tests/portfolio/test_routes_pipeline.py
"""接真端点 TDD——spec §4.5 决议 L 六件之 4(前端零改动=删 mock 即接真).
环境注入全走 monkeypatch env(SANGUO_PIPELINE_DB/SANGUO_STRATEGY_REGISTRY/
SANGUO_PROMOTION_PASSPHRASE/SANGUO_FACTOR_MONTHLY_DIR),双机同代码各读各的.
"""
import json
import os
import pytest
import yaml
from fastapi.testclient import TestClient
from sanguo_api.app import create_app
from sanguo_api.auth import hash_password
from sanguo_portfolio import pipeline_store
@pytest.fixture()
def env(tmp_path, monkeypatch):
pipeline_db = str(tmp_path / "pipeline.db")
registry = str(tmp_path / "strategy_registry.yaml")
monthly = str(tmp_path / "factor_monthly")
issue_dir = str(tmp_path / "registry_events")
attribution_dir = str(tmp_path / "attribution")
monkeypatch.setenv("SANGUO_PIPELINE_DB", pipeline_db)
monkeypatch.setenv("SANGUO_STRATEGY_REGISTRY", registry)
monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", monthly)
monkeypatch.setenv("SANGUO_PROMOTION_PASSPHRASE", "毕业口令")
monkeypatch.setenv("SANGUO_REGISTRY_ISSUE_DIR", issue_dir)
monkeypatch.setenv("SANGUO_STRATEGY_EVENTS",
str(tmp_path / "strategy_events.jsonl"))
monkeypatch.setenv("SANGUO_ATTRIBUTION_DIR", attribution_dir)
pipeline_store.init_db(pipeline_db)
os.makedirs(monthly, exist_ok=True)
with open(registry, "w", encoding="utf-8") as f:
yaml.safe_dump({"strategies": {
"s_shadow": {"name": "s_shadow", "hypothesis": "H-1",
"composite_version": "quant12_v2a", "stage": "shadow",
"backfilled": False,
"stage_history": [{"kind": "transition", "from": "paper",
"to": "shadow", "at": "2026-09-01"}],
"gate_snapshot": None, "review_issue": None,
"capital": None, "retired_cause": None,
"backtest_run": None, "paper_account_id": 1},
"s_live": {"name": "s_live", "hypothesis": "H-2",
"composite_version": "quant12_v2a", "stage": "live",
"backfilled": True, "stage_history": [],
"gate_snapshot": None, "review_issue": None,
"capital": {"mode": "占账户比例", "value": 30,
"at": "2026-09-18"},
"retired_cause": None, "backtest_run": None,
"paper_account_id": None},
}}, f, allow_unicode=True)
return {"pipeline_db": pipeline_db, "registry": registry, "monthly": monthly,
"issue_dir": issue_dir, "attribution": attribution_dir}
@pytest.fixture(scope="module")
def client(tmp_path_factory):
app = create_app(
db_path=str(tmp_path_factory.mktemp("routes") / "app.db"),
auth_config={"username": "admin",
"password_hash": hash_password("admin"),
"jwt_secret": "test-secret", "expire_minutes": 60},
max_workers=1,
)
return TestClient(app)
@pytest.fixture(scope="module")
def token(client) -> str:
r = client.post("/api/v1/auth/login",
json={"username": "admin", "password": "admin"})
assert r.status_code == 200
return r.json()["token"]
def _h(token): return {"Authorization": f"Bearer {token}"}
def test_requires_auth(client):
assert client.get("/api/v1/pipeline/ladder").status_code == 401
def test_ladder_shape(client, token, env):
r = client.get("/api/v1/pipeline/ladder", headers=_h(token))
assert r.status_code == 200
items = r.json()["items"]
by = {i["name"]: i for i in items}
assert by["s_live"]["stage"] == "live"
assert by["s_live"]["capitalShare"] == "30%"
assert "补录" in by["s_live"]["note"]
assert by["s_shadow"]["stage"] == "shadow"
assert by["s_shadow"]["shadowWeeks"] >= 0
assert "retired" not in [i["stage"] for i in items]
def test_gate_config_get_put_audit(client, token, env):
r = client.get("/api/v1/pipeline/gate-config", headers=_h(token))
body = r.json()
assert body["defaults"]["backtest_sharpe_min"] == 1.0
assert body["effective"]["shadow_weeks_min"] == 4
assert body["audit"] == []
r2 = client.put("/api/v1/pipeline/gate-config", headers=_h(token),
json={"key": "shadow_te_annual_max", "value": 0.06,
"changed_by": "admin"})
assert r2.status_code == 200
r3 = client.get("/api/v1/pipeline/gate-config", headers=_h(token))
assert r3.json()["effective"]["shadow_te_annual_max"] == 0.06
assert r3.json()["audit"][0]["old_value"] == 0.05
bad = client.put("/api/v1/pipeline/gate-config", headers=_h(token),
json={"key": "nope", "value": 1, "changed_by": "admin"})
assert bad.status_code == 422
def test_graduate_passphrase_and_stage_gates(client, token, env):
# 口令错 → 403
r = client.post("/api/v1/pipeline/promotion/graduate", headers=_h(token),
json={"name": "s_shadow", "passphrase": "错的",
"allocMode": "固定金额", "allocValue": "50000"})
assert r.status_code == 403
# 非 shadow 态 → 422
r2 = client.post("/api/v1/pipeline/promotion/graduate", headers=_h(token),
json={"name": "s_live", "passphrase": "毕业口令",
"allocMode": "固定金额", "allocValue": "50000"})
assert r2.status_code == 422
# shadow 周数不足(2026-09-01 起算,monkeypatch 今天不可行——改用周报数据缺失路径:
# 周报三指标无数据 → 422 周报数据不足)
r3 = client.post("/api/v1/pipeline/promotion/graduate", headers=_h(token),
json={"name": "s_shadow", "passphrase": "毕业口令",
"allocMode": "固定金额", "allocValue": "50000"})
assert r3.status_code == 422
assert "周报" in r3.json()["detail"]
def test_graduate_success_flow(client, token, env):
# 补齐周报(s_shadow 满 4 周且四项全达标)→ 毕业成功
from datetime import date, timedelta
ws = date(2026, 9, 7)
for i in range(4):
pipeline_store.upsert_weekly(env["pipeline_db"], {
"strategy_id": "s_shadow",
"week_start": (ws + timedelta(weeks=i)).isoformat(),
"te_annual": 0.04, "fill_rate": 0.97, "vs_backtest": 0.008,
"weeks_counted": 5,
"computed_at": "2026-10-10T20:45:00"})
# registry 影子起算日挪到 5 周前使 shadow_weeks>=4(不写死日期)
with open(env["registry"], encoding="utf-8") as f:
reg = yaml.safe_load(f)
reg["strategies"]["s_shadow"]["stage_history"][0]["at"] = (
(date.today() - timedelta(weeks=5)).isoformat())
with open(env["registry"], "w", encoding="utf-8") as f:
yaml.safe_dump(reg, f, allow_unicode=True)
r = client.post("/api/v1/pipeline/promotion/graduate", headers=_h(token),
json={"name": "s_shadow", "passphrase": "毕业口令",
"allocMode": "固定金额", "allocValue": "50000",
"reviewIssue": 88})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
with open(env["registry"], encoding="utf-8") as f:
reg2 = yaml.safe_load(f)
e = reg2["strategies"]["s_shadow"]
assert e["stage"] == "live" and e["capital"]["value"] == 50000
assert e["gate_snapshot"]["passed"] is True
assert e["review_issue"] == 88
tmpl = os.path.join(env["issue_dir"], "s_shadow_graduate_issue.md")
assert os.path.exists(tmpl) # J1:模板必落盘
# 再毕业一次 → 422(已是 live)
r2 = client.post("/api/v1/pipeline/promotion/graduate", headers=_h(token),
json={"name": "s_shadow", "passphrase": "毕业口令",
"allocMode": "固定金额", "allocValue": "50000"})
assert r2.status_code == 422
def test_todos_aggregates_local_artifacts(client, token, env):
with open(os.path.join(env["monthly"], "nas_2026-09-30.json"), "w",
encoding="utf-8") as f:
json.dump({"verdicts": {"fa_gross_margin": {"state": "alert"}},
"collective": {"is_collective_decay": False}}, f)
with open(os.path.join(env["monthly"], "data_gaps.json"), "w",
encoding="utf-8") as f:
json.dump({"s02_sentiment_delta": ["corpus_sentiment"]}, f)
r = client.get("/api/v1/pipeline/todos", headers=_h(token))
items = r.json()["items"]
texts = " ".join(i["title"] for i in items)
assert "fa_gross_margin" in texts # 衰减告警(factor 月报 JSON)
assert "s02_sentiment_delta" in texts # 数据缺口(data_gaps.json)
for i in items:
assert i["path"].startswith("/pipeline/") # 每条可跳转
assert i["severity"] in ("info", "warn", "critical")
def test_factor_verdict_routes(client, token, env, tmp_path, monkeypatch):
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY",
str(tmp_path / "factor_registry.yaml"))
with open(tmp_path / "factor_registry.yaml", "w", encoding="utf-8") as f:
yaml.safe_dump({"factors": {"fa_gross_margin": {
"name": "fa_gross_margin", "hypothesis": "H-1",
"status": "assessable", "versions": []}}}, f, allow_unicode=True)
monkeypatch.setenv("SANGUO_FACTOR_EVENTS",
str(tmp_path / "registry_events.jsonl"))
r = client.post("/api/v1/pipeline/factors/fa_gross_margin/verdict",
headers=_h(token),
json={"verdict": "promote",
"answers": [True, True, True, True, True],
"note": "IC 两窗 t=3.1/2.8,共线 0.42"})
assert r.status_code == 200
with open(tmp_path / "factor_registry.yaml", encoding="utf-8") as f:
assert yaml.safe_load(f)["factors"]["fa_gross_margin"]["status"] == "promoted"
# revise=留观(不改状态,只留评审事件)
r2 = client.post("/api/v1/pipeline/factors/fa_gross_margin/verdict",
headers=_h(token),
json={"verdict": "revise", "answers": [True, True, False,
True, True],
"note": "三问不过,退回改造观察"})
assert r2.status_code == 200
with open(tmp_path / "factor_registry.yaml", encoding="utf-8") as f:
assert yaml.safe_load(f)["factors"]["fa_gross_margin"]["status"] == "promoted"
def test_factor_verdict_bootstraps_runtime_registry(client, token, tmp_path,
monkeypatch):
# M1:未设 env 时 verdict 端点默认写 data/ 运行副本,首触从 config/ 种子自举
monkeypatch.chdir(tmp_path) # 默认路径全 cwd 相对,隔离到 tmp
os.makedirs("config", exist_ok=True)
with open(os.path.join("config", "factor_registry.yaml"), "w",
encoding="utf-8") as f:
yaml.safe_dump({"factors": {"fa_x": {
"name": "fa_x", "hypothesis": "H-1", "status": "assessable",
"versions": []}}}, f, allow_unicode=True)
r = client.post("/api/v1/pipeline/factors/fa_x/verdict", headers=_h(token),
json={"verdict": "promote", "answers": [True] * 5,
"note": "首触自举后直写运行副本"})
assert r.status_code == 200
with open(os.path.join("data", "factor_registry.yaml"),
encoding="utf-8") as f:
assert yaml.safe_load(f)["factors"]["fa_x"]["status"] == "promoted"
assert os.path.exists(os.path.join("data", "registry_events.jsonl"))
def test_factors_registry_to_board(client, token, env, tmp_path, monkeypatch):
# 看板/候选下拉接真:注册表(运行副本)→PipelineFactor;graveyard 不上板
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY",
str(tmp_path / "factor_registry.yaml"))
with open(tmp_path / "factor_registry.yaml", "w", encoding="utf-8") as f:
yaml.safe_dump({"factors": {
"fa_fund_x": {"name": "fa_fund_x", "hypothesis": "H-1",
"status": "assessable",
"versions": [{"v": 1, "commit": "abc",
"params": {"source": "fundamentals_pit"},
"effective_from": "2026-01-01",
"effective_to": None}]},
"fa_dead": {"name": "fa_dead", "hypothesis": "H-2",
"status": "graveyard", "cause": "两窗反号",
"versions": []},
"fa_pledge_y": {"name": "fa_pledge_y", "hypothesis": "H-3",
"status": "promoted", "promotion_t": 4.01,
"versions": [{"v": 2, "commit": "def",
"params": {"source": "pledge_detail"},
"effective_from": "2026-02-01",
"effective_to": None}]},
}}, f, allow_unicode=True)
# eval_db 指向不存在文件(月度批评首班未跑)→ IC 值如实 null
monkeypatch.setenv("SANGUO_FACTOR_EVAL_DB",
str(tmp_path / "no_such_factor_eval.db"))
r = client.get("/api/v1/pipeline/factors", headers=_h(token))
assert r.status_code == 200
by = {i["id"]: i for i in r.json()["items"]}
assert "fa_dead" not in by # graveyard 不上板
fa = by["fa_fund_x"]
assert fa["icRecentT"] is None and fa["icFullT"] is None
assert fa["category"] == "fundamental" # source=fundamentals_pit
assert fa["version"] == "1"
pg = by["fa_pledge_y"]
assert pg["category"] == "pledge" # source 含 pledge
assert pg["promotedAtT"] == 4.01 # promotion_t 原样回传
assert pg["status"] == "promoted"
def test_factors_reads_injected_eval_db(client, token, env, tmp_path, monkeypatch):
# eval 库对齐月度链:app 注入路径优先,default_eval_db_path(data_backup 旧落点)只兜底
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY",
str(tmp_path / "factor_registry.yaml"))
with open(tmp_path / "factor_registry.yaml", "w", encoding="utf-8") as f:
yaml.safe_dump({"factors": {"fa_inj": {
"name": "fa_inj", "hypothesis": "H-9", "status": "assessable",
"versions": [{"v": 1, "commit": "abc", "params": {},
"effective_from": "2026-01-01",
"effective_to": None}]}},
}, f, allow_unicode=True)
# env 兜底路径故意不存在:只有命中注入路径才可能拿到 t
monkeypatch.setenv("SANGUO_FACTOR_EVAL_DB",
str(tmp_path / "no_such_eval.db"))
from datetime import date
from sanguo_api.routes_pipeline import set_pipeline_eval_db_path
from sanguo_factor import eval_store
inj_db = str(tmp_path / "factor_eval.db")
eval_store.init_db(inj_db)
end = date.today().isoformat()
run_id = eval_store.create_run(inj_db, "monthly_2026-09", "sz", 10, 1,
"2025-10-01", end, {})
eval_store.save_results(inj_db, run_id, [{
"factor": "fa_inj", "category": "fundamental", "expression": "x",
"metrics": {"1": {"t_stat": 2.7, "ic_mean": 0.03, "count": 240}}}])
eval_store.finish_run(inj_db, run_id, "done", 1)
set_pipeline_eval_db_path(inj_db)
try:
r = client.get("/api/v1/pipeline/factors", headers=_h(token))
assert r.status_code == 200
by = {i["id"]: i for i in r.json()["items"]}
assert by["fa_inj"]["icRecentT"] == 2.7 # t 来自注入库
assert by["fa_inj"]["lastEvalDate"] == end
finally:
set_pipeline_eval_db_path("") # 还原兜底,不影响他测
# ---- Task1(P2 defer): graduate 分支直测 + vs=0.0 回归 + 顺序双发 ----
def _set_shadow_since(registry_path: str, at: str) -> None:
"""把 s_shadow 的 shadow 起点改早(≥4 周),使影子周数 gate 可过."""
with open(registry_path, encoding="utf-8") as f:
reg = yaml.safe_load(f)
reg["strategies"]["s_shadow"]["stage_history"] = [
{"kind": "transition", "from": "paper", "to": "shadow", "at": at}]
with open(registry_path, "w", encoding="utf-8") as f:
yaml.safe_dump(reg, f, allow_unicode=True)
def _put_weekly(pipeline_db: str, name: str, te: float, fill: float, vs) -> None:
pipeline_store.upsert_weekly(pipeline_db, {
"strategy_id": name, "week_start": "2026-09-21",
"te_annual": te, "fill_rate": fill, "vs_backtest": vs,
"weeks_counted": 5, "computed_at": "2026-09-24T20:45:00"})
def test_graduate_503_no_passphrase(client, token, env, monkeypatch):
monkeypatch.delenv("SANGUO_PROMOTION_PASSPHRASE", raising=False)
r = client.post("/api/v1/pipeline/promotion/graduate",
json={"name": "s_shadow", "passphrase": "x"}, headers=_h(token))
assert r.status_code == 503
def test_graduate_403_wrong_passphrase(client, token, env):
r = client.post("/api/v1/pipeline/promotion/graduate",
json={"name": "s_shadow", "passphrase": "错的"},
headers=_h(token))
assert r.status_code == 403
def test_graduate_404_unknown(client, token, env):
r = client.post("/api/v1/pipeline/promotion/graduate",
json={"name": "s_unknown", "passphrase": "毕业口令"},
headers=_h(token))
assert r.status_code == 404
def test_graduate_422_wrong_stage(client, token, env):
r = client.post("/api/v1/pipeline/promotion/graduate",
json={"name": "s_live", "passphrase": "毕业口令"},
headers=_h(token))
assert r.status_code == 422
def test_graduate_422_no_weekly(client, token, env):
_set_shadow_since(env["registry"], "2026-08-20")
r = client.post("/api/v1/pipeline/promotion/graduate",
json={"name": "s_shadow", "passphrase": "毕业口令"},
headers=_h(token))
assert r.status_code == 422
assert "周报数据不足" in str(r.json())
def test_graduate_422_gate_fail_te_over(client, token, env):
_set_shadow_since(env["registry"], "2026-08-20")
_put_weekly(env["pipeline_db"], "s_shadow", te=0.06, fill=0.99, vs=0.0)
r = client.post("/api/v1/pipeline/promotion/graduate",
json={"name": "s_shadow", "passphrase": "毕业口令"},
headers=_h(token))
assert r.status_code == 422
body = r.json()
assert body["detail"]["error"].startswith("影子毕业 gate 未过")
# GateCheck 字段名=key,TE 项键=shadow_te_annual_max(brief 原文 name/te_annual 与仓库现状对齐)
assert any(c["key"] == "shadow_te_annual_max" and not c["passed"]
for c in body["detail"]["checks"])
def test_graduate_200_vs_zero_passes(client, token, env):
"""vs_backtest=0.0(完美贴合)必须过 gate——旧代码 `0.0 or 1.0`=1.0 误判超阈(本修 RED 主证)."""
_set_shadow_since(env["registry"], "2026-08-20")
_put_weekly(env["pipeline_db"], "s_shadow", te=0.01, fill=0.99, vs=0.0)
r = client.post("/api/v1/pipeline/promotion/graduate",
json={"name": "s_shadow", "passphrase": "毕业口令"},
headers=_h(token))
assert r.status_code == 200
assert r.json()["ok"] is True # 返回体={ok,issue}(无 name 键,brief 原文对齐仓库现状)
def test_graduate_twice_second_422(client, token, env):
"""顺序双发:第一次毕业成功后 registry 已 live,第二次必须 422(并发锁的状态语义)."""
_set_shadow_since(env["registry"], "2026-08-20")
_put_weekly(env["pipeline_db"], "s_shadow", te=0.01, fill=0.99, vs=0.001)
body = {"name": "s_shadow", "passphrase": "毕业口令"}
r1 = client.post("/api/v1/pipeline/promotion/graduate", json=body,
headers=_h(token))
assert r1.status_code == 200
r2 = client.post("/api/v1/pipeline/promotion/graduate", json=body,
headers=_h(token))
assert r2.status_code == 422
def test_todos_graduate_candidate(client, token, env):
_set_shadow_since(env["registry"], "2026-08-20")
_put_weekly(env["pipeline_db"], "s_shadow", te=0.01, fill=0.99, vs=0.001)
r = client.get("/api/v1/pipeline/todos", headers=_h(token))
assert r.status_code == 200
ids = [item["id"] for item in r.json()["items"]]
assert "grad-s_shadow" in ids
# —— 追加:研判卡端点(P3 第一刀) ——
def _write_monthly(monthly_dir, as_of, is_decay, verdicts=None, points=None):
doc = {"as_of": as_of,
"collective": {"median": 1.2 if is_decay else 2.8,
"floor": 1.5, "is_collective_decay": is_decay, "n": 4},
"verdicts": verdicts or {},
"monthly_points": points or {}}
with open(os.path.join(monthly_dir, f"host_{as_of}.json"), "w",
encoding="utf-8") as f:
json.dump(doc, f, ensure_ascii=False)
def test_research_card_null_paths(client, token, env):
assert client.get("/api/v1/pipeline/research-card",
headers=_h(token)).json() == {"card": None}
_write_monthly(env["monthly"], "2026-09-30", False)
assert client.get("/api/v1/pipeline/research-card",
headers=_h(token)).json() == {"card": None}
def test_research_card_shape(client, token, env, tmp_path, monkeypatch):
# 裁决记录隔离到 tmp:卡上 ruling 断言不依赖 cwd 下 data/ 落盘
monkeypatch.setenv("SANGUO_VERDICT_RECORDS",
str(tmp_path / "verdict_records.jsonl"))
_write_monthly(env["monthly"], "2026-09-30", True,
verdicts={"fa": {"state": "alert", "ratio": 0.4},
"fb": {"state": "yellow", "ratio": 0.8},
"fc": {"state": "ok", "ratio": 0.6}},
points={"fa": [{"month": "2026-09", "t": 0.5}]})
card = client.get("/api/v1/pipeline/research-card",
headers=_h(token)).json()["card"]
assert card["id"] == "collective-2026-09"
assert card["triggeredAt"] == "2026-09-30"
assert card["medianT"] == 1.2 and card["threshold"] == 1.5
assert card["topDecayFactors"] == ["fa", "fc", "fb"] # alert 优先,ratio 升序
assert [o["level"] for o in card["options"]] == list(range(7))
assert card["ruling"] is None
assert "功课累积中" in card["replay"]["after3m"][0]["outcome"]
assert len(card["replay"]["after3m"]) == 7
def test_research_card_with_replay_doc(client, token, env):
_write_monthly(env["monthly"], "2026-09-30", True,
verdicts={"fa": {"state": "alert", "ratio": 0.4}})
with open(os.path.join(env["monthly"], "verdict_replay.json"), "w",
encoding="utf-8") as f:
json.dump({"since": "2025-01", "episodes": [
{"month": "2026-06", "median": 0.6, "n": 4, "replayable": True,
"tiers": {"0": {"ret3m": 0.023, "max_dd": -0.051},
"1": {"ret3m": 0.016, "max_dd": -0.036},
"2": {"ret3m": 0.031, "max_dd": -0.044},
"3": None, "4": None, "5": {"ret3m": 0.023, "max_dd": -0.051},
"6": {"ret3m": 0.0, "max_dd": 0.0}}}]}, f)
card = client.get("/api/v1/pipeline/research-card",
headers=_h(token)).json()["card"]
assert "2026-06" in card["replay"]["period"]
rows = {r["option"]: r["outcome"] for r in card["replay"]["after3m"]}
assert rows["档0 不动,继续观察"] == "+2.3%/回撤 -5.1%(直加·估)"
assert rows["档3 降调仓频率/只出不进"] == "需精确验证(现有回测工具)"
assert rows["档6 防御模式(最重)"] == "+0.0%/回撤 0.0%(直加·估)"
def test_verdict_post_lifecycle(client, token, env, monkeypatch, tmp_path):
recs = str(tmp_path / "verdict_records.jsonl")
monkeypatch.setenv("SANGUO_VERDICT_RECORDS", recs)
_write_monthly(env["monthly"], "2026-09-30", True)
r = client.post("/api/v1/pipeline/research-card/verdict",
headers=_h(token),
json={"month": "2026-09", "level": 2, "reason": "先挪钱"})
assert r.status_code == 200 and r.json()["record"]["level"] == 2
card = client.get("/api/v1/pipeline/research-card",
headers=_h(token)).json()["card"]
assert card["ruling"]["month"] == "2026-09"
assert card["ruling"]["reason"] == "先挪钱"
assert client.post("/api/v1/pipeline/research-card/verdict",
headers=_h(token),
json={"month": "2026-09", "level": 0, "reason": ""}).status_code == 409
assert client.post("/api/v1/pipeline/research-card/verdict",
headers=_h(token),
json={"month": "2026-09", "level": 9, "reason": ""}).status_code == 422
assert client.post("/api/v1/pipeline/research-card/verdict",
headers=_h(token),
json={"month": "2026-9", "level": 1, "reason": ""}).status_code == 422
# —— 追加:归因日报骨架(P3 第一刀) ——
def test_attribution_empty_before_weekly(client, token, env):
doc = client.get("/api/v1/pipeline/attribution",
headers=_h(token)).json()
assert doc["period"] == "周报待算"
assert doc["factorContribution"] == []
# registry 两策略(s_live/s_shadow)皆无周报行 → 零策略行,仅 IS 五行
# (无 is_daily 行 → 五行 live/diff 全 "—",细节见 test_attribution_is_rows_empty_when_no_data)
assert len(doc["liveVsBacktest"]) == 5
def test_attribution_weekly_rows(client, token, env):
# brief 原文 upsert all_weather/momentum 不在 env registry——端点按 registry
# 遍历查 latest_weekly,周报表孤行不上报;改以 registry 两策略承接 brief
# 同款数值,断言值/格式/阈语义逐字不变
for name, te, fr, vs in [("s_live", 0.031, 0.97, 0.011),
("s_shadow", 0.06, 0.90, -0.04)]:
pipeline_store.upsert_weekly(env["pipeline_db"], {
"strategy_id": name, "week_start": "2026-09-21",
"te_annual": te, "fill_rate": fr, "vs_backtest": vs,
"weeks_counted": 1, "computed_at": "2026-09-25T20:45:00"})
doc = client.get("/api/v1/pipeline/attribution",
headers=_h(token)).json()
assert doc["period"] == "2026-09-21"
rows = {r["metric"]: r for r in doc["liveVsBacktest"]}
te_row = rows["s_live·跟踪误差(年化)"]
assert te_row["live"] == "3.10%" and te_row["backtest"] == "阈 5.00%"
assert te_row["diff"] == "-1.90%"
fr_row = rows["s_shadow·成交率"]
assert fr_row["live"] == "90.0%" and fr_row["diff"] == "-5.0%"
vs_row = rows["s_shadow·周收益差(vs回测)"]
assert vs_row["diff"] == "超"
# 无 is_daily 行 → IS 五行如实 "—"(「待接真」占位文案已随 P3 接线移除)
is_rows = [r for r in doc["liveVsBacktest"]
if "落地差距" in r["metric"] or "市场漂移" in r["metric"]]
assert len(is_rows) == 5
assert all(r["live"] == "—" and r["diff"] == "—" for r in is_rows)
assert doc["factorContribution"] == []
# —— 追加:归因端点接真(P3 接线棒,spec §4.7) ——
# IS 五行 diff 口径:is_daily 无 traded_value 列,契约①的 bp 归一(金额/当日成交额)
# 无分母可除 → 按金额直显(f"{v:+,.2f}元",正=逆风),腿数注在 live 行内补量级语境;
# 金额 None → live/diff "—"(宁缺毋假)。
def test_attribution_is_rows_from_latest_is(client, token, env):
pipeline_store.upsert_is_daily(env["pipeline_db"], {
"day": "2026-09-25", "scope": "all", "legs_count": 3,
"legs_no_anchor": 1, "delay_cost": -1234.56, "impact_cost": 789.0,
"opportunity_cost": None, "fee_total": -45.67,
"price_movement": 210.5, "is_total": -280.73,
"identity_resid": 0.0, "computed_at": "2026-09-25T20:45:00"})
doc = client.get("/api/v1/pipeline/attribution", headers=_h(token)).json()
rows = {r["metric"]: r for r in doc["liveVsBacktest"]}
assert rows["延迟成本(落地差距)"] == {
"metric": "延迟成本(落地差距)", "live": "-1,234.56元(3腿)",
"backtest": "—", "diff": "-1,234.56元"}
assert rows["冲击成本(落地差距)"]["live"] == "789.00元(3腿)"
assert rows["冲击成本(落地差距)"]["diff"] == "+789.00元"
assert rows["机会成本(落地差距)"]["live"] == "—" # None 宁缺毋假
assert rows["机会成本(落地差距)"]["diff"] == "—"
assert rows["显性费用(落地差距)"]["live"] == "-45.67元(3腿)"
assert rows["显性费用(落地差距)"]["diff"] == "-45.67元"
assert rows["价格移动(市场漂移·参考)"]["live"] == "210.50元(3腿)"
assert rows["价格移动(市场漂移·参考)"]["diff"] == "+210.50元"
# IS 五行收尾且次序钉死(延迟/冲击/机会/显性/价格移动参考)
assert [r["metric"] for r in doc["liveVsBacktest"]] == [
"延迟成本(落地差距)", "冲击成本(落地差距)", "机会成本(落地差距)",
"显性费用(落地差距)", "价格移动(市场漂移·参考)"]
def test_attribution_is_rows_empty_when_no_data(client, token, env):
doc = client.get("/api/v1/pipeline/attribution", headers=_h(token)).json()
is_rows = [r for r in doc["liveVsBacktest"]
if "落地差距" in r["metric"] or "市场漂移" in r["metric"]]
assert len(is_rows) == 5 # 行仍在,不 404
assert all(r["live"] == "—" and r["diff"] == "—"
and r["backtest"] == "—" for r in is_rows)
def test_attribution_factor_contribution_from_json(client, token, env):
contrib = [
{"factor": "fa_pledge_net_chg", "month": "2026-09",
"contribution_ret": 0.0042, "cum_ret": 0.031, "exposure_avg": 0.35},
{"factor": "fa_res_target_upside", "month": "2026-09",
"contribution_ret": -0.0011, "cum_ret": -0.008, "exposure_avg": 0.12}]
adir = env["attribution"]
os.makedirs(adir, exist_ok=True)
with open(os.path.join(adir, "attribution_2026-09-24.json"), "w",
encoding="utf-8") as f:
json.dump({"factorContribution": [{"factor": "stale"}]}, f)
with open(os.path.join(adir, "attribution_2026-09-25.json"), "w",
encoding="utf-8") as f:
json.dump({"factorContribution": contrib}, f)
doc = client.get("/api/v1/pipeline/attribution", headers=_h(token)).json()
# 09-27 契位修复: 月度长表聚合为前端短表 {factorId, contributionPct}
# (近 12 月 Σ×100;文件名降序取最新);直通旧契曾致前端 toFixed 整页崩
assert doc["factorContribution"] == [
{"factorId": "fa_pledge_net_chg", "contributionPct": 0.42},
{"factorId": "fa_res_target_upside", "contributionPct": -0.11}]
def test_attribution_factor_contribution_missing_file(client, token, env):
# env 注入目录存在但空 → [];目录整个不存在同路径 fail-soft 返 [] 不 500
doc = client.get("/api/v1/pipeline/attribution", headers=_h(token)).json()
assert doc["factorContribution"] == []
def test_todos_factor_review_candidates(client, token, env, tmp_path, monkeypatch):
# 三矛盾②:触点②补源——assessable 且近窗 t≥2 → 待办「待晋级评审」;
# 无 IC 数据/t 不显著/非 assessable 一律不上板(与评审页下拉同门)
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY",
str(tmp_path / "factor_registry.yaml"))
with open(tmp_path / "factor_registry.yaml", "w", encoding="utf-8") as f:
yaml.safe_dump({"factors": {
"fa_hot": {"name": "fa_hot", "hypothesis": "H-1",
"status": "assessable", "versions": []},
"fa_nodata": {"name": "fa_nodata", "hypothesis": "H-2",
"status": "assessable", "versions": []},
"fa_weak": {"name": "fa_weak", "hypothesis": "H-3",
"status": "assessable", "versions": []},
"fa_prom": {"name": "fa_prom", "hypothesis": "H-4",
"status": "promoted", "promotion_t": 3.0,
"versions": []},
}}, f, allow_unicode=True)
monkeypatch.setenv("SANGUO_FACTOR_EVAL_DB",
str(tmp_path / "no_such_eval.db"))
from datetime import date
from sanguo_api.routes_pipeline import set_pipeline_eval_db_path
from sanguo_factor import eval_store
inj_db = str(tmp_path / "factor_eval.db")
eval_store.init_db(inj_db)
end = date.today().isoformat()
run_id = eval_store.create_run(inj_db, "monthly_2026-09", "sz", 10, 1,
"2025-10-01", end, {})
eval_store.save_results(inj_db, run_id, [
{"factor": "fa_hot", "category": "fundamental", "expression": "x",
"metrics": {"1": {"t_stat": 3.4, "ic_mean": 0.03, "count": 240}}},
{"factor": "fa_weak", "category": "fundamental", "expression": "y",
"metrics": {"1": {"t_stat": -0.2, "ic_mean": 0.001, "count": 240}}},
])
eval_store.finish_run(inj_db, run_id, "done", 1)
set_pipeline_eval_db_path(inj_db)
try:
r = client.get("/api/v1/pipeline/todos", headers=_h(token))
assert r.status_code == 200
items = r.json()["items"]
rev = [i for i in items if i["id"] == "review-fa_hot"]
assert len(rev) == 1
assert rev[0]["touchpoint"] == "promotion"
assert rev[0]["path"] == "/pipeline/review"
assert rev[0]["severity"] == "warn"
assert "3.40" in rev[0]["detail"] or "3.4" in rev[0]["detail"]
ids = [i["id"] for i in items]
assert "review-fa_nodata" not in ids # 无 IC 数据不上板
assert "review-fa_weak" not in ids # t<2 不上板
assert "review-fa_prom" not in ids # 非 assessable 不上板
finally:
set_pipeline_eval_db_path("")